Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion scripts/stamp-distribution.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ if [ ! -d "$PKG_DIR" ]; then
exit 1
fi

# Full commit SHA of the tree being packaged. Baked alongside DISTRIBUTION so
# the dashboard bundle-freshness guard can compare it against the build-id
# stamped into dist/ (see src/kiro_crew/dashboard/stale_bundle_guard.py).
# Empty-safe: git may be unavailable in some packaging sandboxes (a staged copy
# is not a git repo), in which case COMMIT stays "" and the guard skips
# silently rather than false-warning.
COMMIT="$(git -C "$PKG_DIR" rev-parse HEAD 2>/dev/null || true)"

cat > "$PKG_DIR/_build_info.py" <<EOF
"""Build-time provenance. GENERATED - do not edit and do not commit.

Expand All @@ -48,6 +56,7 @@ normal case and reports "source".
from __future__ import annotations

DISTRIBUTION = "$DIST"
COMMIT = "$COMMIT"
EOF

echo "Stamped distribution=$DIST -> $PKG_DIR/_build_info.py"
echo "Stamped distribution=$DIST commit=${COMMIT:-<none>} -> $PKG_DIR/_build_info.py"
21 changes: 21 additions & 0 deletions src/kiro_crew/beacon.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,15 @@
except ImportError:
_BAKED_DISTRIBUTION = ""

# COMMIT is a newer field of the same generated module: a _build_info.py stamped
# before the bundle-freshness guard existed carries DISTRIBUTION but not COMMIT,
# so a missing name (which ``from ... import`` also raises as ImportError) must
# not unbind _BAKED_DISTRIBUTION. Kept as its own import for that reason.
try:
from ._build_info import COMMIT as _BAKED_COMMIT # type: ignore[import-not-found]
except ImportError:
_BAKED_COMMIT = ""

# Fallback when a version string carries no parseable release number.
UNKNOWN_VERSION = "unknown"

Expand Down Expand Up @@ -251,6 +260,18 @@ def baked_distribution() -> str:
return raw if raw in KNOWN_DISTRIBUTIONS else ""


def baked_commit() -> str:
"""Return the git commit SHA stamped into the package tree, or "".

Reads the module-level :data:`_BAKED_COMMIT` (the seam tests patch) so the
dashboard bundle-freshness guard can compare the running backend's build
commit against the one recorded in ``dist/build-id.json``. Empty in a source
checkout (no ``_build_info.py``) or when git was unavailable at packaging
time, in which case the guard skips silently rather than false-warning.
"""
return str(_BAKED_COMMIT or "").strip()


def distribution() -> str:
"""Return the build's distribution channel, clamped to the known set.

Expand Down
142 changes: 142 additions & 0 deletions src/kiro_crew/dashboard/stale_bundle_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Startup guard that WARNS when the served SPA bundle is stale.

The gateway serves the dashboard from a gitignored, build-copied ``dist/``
(``handlers/core.py``). Nothing verifies that the served bundle was built from
the same tree as the running backend, so a restart that did NOT rebuild/copy
the frontend keeps serving an OLD bundle — the dashboard renders (assets are
*present*), so the gap is silent until a behavioral test fails.

This guard is the FRESHNESS counterpart to ``stale_asset_watchdog.py``'s
PRESENCE check, and it deliberately does NOT share that watchdog's response:

* The vanish watchdog fires when assets are *gone* — the process can serve
nothing useful, so it shuts down and lets a supervisor restart a fresh one.
* This guard fires when assets are *present but stale* — the dashboard still
works, and a restart alone would serve the *same* stale ``dist`` (a restart
does not rebuild the frontend). Shutting down would loop forever. So it
only logs a WARNING with rebuild guidance.

Identity comes from a build-id stamped into ``dist/build-id.json`` at
``vite build`` time (see ``website/vite.config.ts`` ``buildIdPlugin``), reusing
the exact ``${version}-${sha}`` scheme ``swVersionPlugin`` already computes. The
backend's own build commit comes from the baked ``_build_info.py`` in a packaged
install (``beacon.baked_commit()``), falling back to ``git rev-parse HEAD`` in a
source checkout.

The check is best-effort and conservative: any unknown side of the comparison
(no ``build-id.json``, a dist that predates this feature, an unknown backend
commit) means it SKIPS silently rather than false-warning. The only path that
warns is a confident mismatch between two known commits.
"""

from __future__ import annotations

import json
import logging
import subprocess
from pathlib import Path

from kiro_crew import beacon, platform_compat
from kiro_crew.dashboard.handlers.core import _DIST_DIR

logger = logging.getLogger(__name__)

# Build-id stamp emitted into dist/ by website/vite.config.ts:buildIdPlugin and
# staged into the package by the same `cp -R website/dist ...` every packaging
# path runs.
_BUILD_ID_PATH = _DIST_DIR / "build-id.json"


def _read_dist_commit() -> tuple[str, str] | None:
"""Return ``(commit, build_id)`` from ``dist/build-id.json``, or None.

None on any of: the file is absent (dist predates this feature, or a dev
build that skipped the stamp), unreadable, not valid JSON, or carries no
non-empty ``commit`` (git was unavailable when the frontend was built). Each
of these is a "cannot verify" case, not a staleness signal.
"""
try:
raw = _BUILD_ID_PATH.read_text(encoding="utf-8")
except OSError:
return None
try:
data = json.loads(raw)
except (ValueError, TypeError):
logger.debug("Bundle freshness: build-id.json is not valid JSON — skipping.")
return None
if not isinstance(data, dict):
return None
commit = str(data.get("commit") or "").strip()
build_id = str(data.get("buildId") or "").strip()
if not commit:
return None
return commit, build_id


def _backend_commit() -> str:
"""Return the running backend's build commit, or "".

Prefers the baked ``_build_info.COMMIT`` (authoritative in a packaged
install; a running copy cannot change it). Falls back to ``git rev-parse
HEAD`` for a source/dev checkout where ``_build_info.py`` is absent — git
resolved through ``trusted_system_bin`` (fixed system dirs, never ``PATH``,
which can lead with agent-writable directories where a planted shim would
run with the gateway's environment). Returns "" if none of these is
available, which makes the caller skip.
"""
baked = beacon.baked_commit()
if baked:
return baked
git_bin = platform_compat.trusted_system_bin("git")
if git_bin is None:
return ""
try:
result = subprocess.run(
[git_bin, "rev-parse", "HEAD"],
cwd=Path(__file__).resolve().parent,
capture_output=True,
text=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return ""
if result.returncode != 0:
return ""
return result.stdout.strip()


def check_bundle_freshness() -> None:
"""Warn (once, at startup) if the served SPA bundle looks stale.

Best-effort and conservative — never raises, never shuts down. Warns only
on a confident commit mismatch between the dist stamp and the backend; every
"cannot verify" case (missing stamp, unknown backend commit) skips silently
so a transitional dist or a git-less packaging path never false-alarms.
"""
try:
dist = _read_dist_commit()
if dist is None:
logger.debug("Bundle freshness: no dist/build-id.json commit to compare — skipping.")
return
dist_commit, dist_build_id = dist

backend_commit = _backend_commit()
if not backend_commit:
logger.debug("Bundle freshness: backend build commit unknown — skipping.")
return

if dist_commit != backend_commit:
logger.warning(
"Dashboard SPA bundle is STALE: the served frontend is build "
"%s (commit %s) but this backend is running commit %s. A restart "
"will not fix this — it re-serves the same dist. Rebuild and "
"restage the frontend: `cd website && npm run build`, copy "
"website/dist into src/kiro_crew/static/dist, then restart.",
dist_build_id or dist_commit[:7],
dist_commit[:7],
backend_commit[:7],
)
except Exception:
# A freshness advisory must never break startup. Swallow everything and
# note it at debug level for diagnosis.
logger.debug("Bundle freshness check failed — ignoring.", exc_info=True)
13 changes: 13 additions & 0 deletions src/kiro_crew/slack/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
run_stale_asset_watchdog,
shutdown_exit_code,
)
from kiro_crew.dashboard.stale_bundle_guard import check_bundle_freshness
from kiro_crew.dashboard.state import (
SUBAGENT_BATCH_COMPLETION_PREFIX,
SUBAGENT_COMPLETION_PREFIX,
Expand Down Expand Up @@ -11617,6 +11618,18 @@ async def _start_bg_session() -> None:
self._background_tasks.add(_watchdog)
_watchdog.add_done_callback(self._background_tasks.discard)

# Bundle-freshness guard: a one-shot WARN (never a shutdown) if the
# served SPA dist was built from a different commit than this backend —
# the present-but-stale case the vanish watchdog above cannot catch. A
# restart would re-serve the same stale dist, so warning (not shutting
# down) is the correct response. Best-effort; never raises. Run on the
# subprocess executor: the dev-checkout fallback shells out to
# `git rev-parse` (up to 5s if git wedges), which must not block the
# event loop.
await asyncio.get_running_loop().run_in_executor(
subprocess_executor(), check_bundle_freshness
)

print("👻 Kiro Crew gateway starting…")
print(f"\n{DATA_WARNING}\n")

Expand Down
10 changes: 10 additions & 0 deletions test/test_spawn_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,16 @@ def _is_bundled_skill_asset(path: Path) -> bool:
"dashboard/handlers_system.py::_scan_mcp_processes",
"dashboard/handlers_system.py::_get_static_system_info",
"dashboard/port_reclaim.py::_listeners_on_port",
# Bundle-freshness startup probe: fixed `git rev-parse HEAD` list-argv
# (no shell=True) with git PINNED via trusted_system_bin (fixed system
# dirs, never PATH — a planted shim in an agent-writable PATH dir cannot
# be selected; absent trusted git → skip, no spawn). cwd is the module's
# OWN install directory (Path(__file__).parent), never a request- or
# agent-supplied path. Runs once at dashboard startup, only in a
# source/dev checkout where the baked _build_info commit is absent, and
# its output is compared (read-only) against dist/build-id.json to log
# a staleness warning. No agent input reaches command, args, or cwd.
"dashboard/stale_bundle_guard.py::_backend_commit",
"env.py::_run",
"env.py::activate_mise",
# Node bootstrap: runs the bundled ``ensure-node.sh`` (a fixed `bash
Expand Down
Loading
Loading