Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

- Switching from a cloud LLM provider back to a local one no longer disables the
router or leaves it pinned to the cloud provider's tier profile. (Fixes #189)
- Gateway boot and `agentos doctor` now warn when the bundled React Control UI is
older than the frontend sources in a checkout (`gateway.control_ui.dist_stale`),
instead of reporting a clean bill of health while serving a stale web UI. The
warning is advisory and never gates readiness — source mtimes are a hint, not
an oracle. Wheel installs ship no frontend sources and are never flagged.
(Fixes #200)

### Added

Expand Down
10 changes: 10 additions & 0 deletions docs/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ license ledger. The source-install scripts perform it automatically. If a
checkout is started without a bundle, the Control UI returns an actionable
`503` instead of a blank page or a different interface.

Gateway boot and `agentos doctor` also warn when a checkout's bundle is older
than its frontend sources (`gateway.control_ui.dist_stale`). The warning is
advisory: it never blocks serving the existing bundle and never degrades
overall health status, because source mtimes are a hint rather than an oracle —
`git checkout` and `git pull` rewrite them, so a freshly built bundle can be
flagged. Rebuild with `python scripts/build_control_ui.py build`; `agentos
doctor` clears on the next run, and a gateway restart clears the boot-time log
line. Wheel installs ship no frontend sources and are never flagged, and the
check is skipped entirely when the Control UI is disabled.

For hot reload during frontend work, run the gateway and Vite together:

```sh
Expand Down
10 changes: 4 additions & 6 deletions src/agentos/gateway/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -1943,13 +1943,11 @@ async def start_gateway_server(
# Gateway-specific: resolve the built React Control UI (boot order 17)
if config.control_ui.enabled:
from agentos.gateway.control_ui import _DIST_DIR
from agentos.health.control_ui import build_hint, control_ui_boot_warning

if not (_DIST_DIR / "index.html").is_file():
log.warning(
"gateway.control_ui.dist_missing",
path=str(_DIST_DIR),
hint="run `python scripts/build_control_ui.py build`",
)
bundle_warning = control_ui_boot_warning(_DIST_DIR / "index.html")
if bundle_warning is not None:
log.warning(bundle_warning, path=str(_DIST_DIR), hint=build_hint())
log.info(
"gateway.control_ui.resolved",
base_path=config.control_ui.base_path,
Expand Down
20 changes: 20 additions & 0 deletions src/agentos/gateway/rpc_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from agentos.gateway.rpc_tools import _handle_providers_status, _handle_search_status
from agentos.health.evaluator import (
evaluate_channels,
evaluate_control_ui,
evaluate_image_generation,
evaluate_logs,
evaluate_memory,
Expand Down Expand Up @@ -486,6 +487,24 @@ def _memory_embedding_payload(ctx: RpcContext) -> dict[str, Any]:
}


def _control_ui_payload(ctx: RpcContext) -> dict[str, Any]:
from agentos.gateway.control_ui import _DIST_DIR
from agentos.health.control_ui import inspect_control_ui_bundle

config = getattr(ctx, "config", None)
if config is not None and not config.control_ui.enabled:
# Boot skips the check entirely when the Control UI is disabled; there
# is no point telling an operator a bundle nobody serves is out of date.
return {"stale": None, "sourceMtime": None, "bundleMtime": None, "wheelInstall": False}
report = inspect_control_ui_bundle(_DIST_DIR / "index.html")
return {
"stale": report.stale,
"sourceMtime": report.source_mtime,
"bundleMtime": report.bundle_mtime,
"wheelInstall": report.wheel_install,
}


async def _evaluate_collection(
surface: str,
collect: Collector,
Expand Down Expand Up @@ -544,6 +563,7 @@ async def _handle_doctor_status(params: dict | None, ctx: RpcContext) -> dict[st
lambda: _image_generation_payload(ctx),
evaluate_image_generation,
),
("control_ui", lambda: _control_ui_payload(ctx), evaluate_control_ui),
]

for surface, collect, evaluate in collectors:
Expand Down
133 changes: 133 additions & 0 deletions src/agentos/health/control_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Detect when the bundled React Control UI is older than its frontend sources.

Warn-only helper shared by gateway boot and ``agentos doctor``. The mtime
comparison is a hint, not an oracle: ``git checkout`` / ``git pull`` rewrite
source mtimes and can flag a legitimately fresh bundle, so callers must never
gate readiness on this result.

Lives under ``health`` rather than at the top level so both consumers stay
inside the architecture import contract: ``health/evaluator.py`` reaches it
intra-package, and the gateway reaches it over the already-approved
``("gateway", "health")`` edge. The bundle path is passed in by those gateway
callers, which already own ``_DIST_DIR`` — nothing here re-derives it.

Nothing is memoized. Both a boot log line and a ``doctor.status`` response must
describe the filesystem as it is *now*: caching the verdict would leave the
doctor reporting a stale bundle forever after the operator rebuilt it.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path

# Built or fetched, never build inputs. ``dist`` covers both the Vite output
# directory and any nested build artefact a tool drops inside the tree.
_PRUNE_DIRS = frozenset({"node_modules", "dist", ".git", "__pycache__", ".vite"})

BUILD_CMD = "python scripts/build_control_ui.py build"


def build_hint() -> str:
"""Return the command that rebuilds the Control UI bundle."""
return BUILD_CMD


@dataclass(frozen=True)
class ControlUiBundleReport:
"""One consistent view of bundle-vs-sources, computed in a single pass.

``stale`` is ``None`` whenever there is nothing to compare — a wheel install
(no ``frontend/``) or a missing bundle. Reporting all three together keeps
the doctor's evidence internally consistent; deriving them from separate
reads is how ``stale=True`` alongside ``bundle_mtime > source_mtime`` happens.
"""

stale: bool | None
source_mtime: float | None
bundle_mtime: float | None

@property
def wheel_install(self) -> bool:
"""True when no frontend sources ship at all, so nothing can be stale."""
return self.source_mtime is None


def checkout_root() -> Path | None:
"""The source checkout this module lives in, or ``None`` for a wheel install.

Walks up looking for the checkout markers instead of counting parent levels,
so moving this module between packages cannot silently retarget it at some
unrelated directory above ``site-packages``.
"""
for candidate in Path(__file__).resolve().parents:
if (candidate / "pyproject.toml").is_file() and (candidate / "frontend").is_dir():
return candidate
return None


def frontend_input_mtime(root: Path | None = None) -> float | None:
"""Newest mtime under ``frontend/``, or ``None`` when it does not ship.

Walks the whole directory minus the pruned build/vendor trees rather than
naming individual inputs: an allowlist of files silently goes stale the
first time someone adds a config the build reads (``eslint.config.js``,
``components.json``, ``scripts/check-bundle-budget.mjs`` all postdate one),
and the failure mode is a false negative nobody can see.
"""
base = root if root is not None else checkout_root()
if base is None:
return None
frontend = base / "frontend"
if not (frontend / "package.json").is_file():
return None
newest = 0.0
for current, dirs, files in os.walk(frontend):
dirs[:] = [name for name in dirs if name not in _PRUNE_DIRS]
for filename in files:
try:
newest = max(newest, (Path(current) / filename).stat().st_mtime)
except OSError:
continue
return newest


def inspect_control_ui_bundle(
bundle_index: Path,
*,
root: Path | None = None,
) -> ControlUiBundleReport:
"""Compare ``bundle_index`` against the frontend sources in one pass.

Strict ``>``: equal timestamps count as fresh, which is false-negative safe
on filesystems with coarse mtime resolution.
"""
source_mtime = frontend_input_mtime(root)
try:
bundle_mtime: float | None = bundle_index.stat().st_mtime
except OSError:
bundle_mtime = None
if source_mtime is None or bundle_mtime is None:
return ControlUiBundleReport(
stale=None, source_mtime=source_mtime, bundle_mtime=bundle_mtime
)
return ControlUiBundleReport(
stale=source_mtime > bundle_mtime,
source_mtime=source_mtime,
bundle_mtime=bundle_mtime,
)


def control_ui_boot_warning(bundle_index: Path, *, root: Path | None = None) -> str | None:
"""The boot log event for this bundle, or ``None`` when there is nothing to say.

Lives here rather than inline in ``boot.py`` so the ordering is testable
without standing up a gateway: a missing bundle outranks a stale one, since
the two hints differ in urgency and only one should be emitted.
"""
if not bundle_index.is_file():
return "gateway.control_ui.dist_missing"
if inspect_control_ui_bundle(bundle_index, root=root).stale:
return "gateway.control_ui.dist_stale"
return None
40 changes: 40 additions & 0 deletions src/agentos/health/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import shlex
from typing import Any

from agentos.health.control_ui import BUILD_CMD
from agentos.health.model import FixStep, HealthFinding

_LEGACY_PROVIDER_REPLACEMENTS = {
Expand Down Expand Up @@ -1456,3 +1457,42 @@ def evaluate_sandbox(payload: dict[str, Any]) -> list[HealthFinding]:
evidence=evidence,
)
]


def evaluate_control_ui(payload: dict[str, Any]) -> list[HealthFinding]:
"""Warn when the bundled Control UI predates the frontend sources.

``payload["stale"]`` is ``None`` for wheel installs, a disabled Control UI,
or a missing bundle — nothing to compare, so the doctor stays clean.

``optional`` rather than the ``degrades`` a warn defaults to: source mtimes
are a hint, and ``git checkout`` / ``git pull`` rewrite them, so this can
flag a legitimately fresh bundle. A heuristic that noisy must not turn the
whole report yellow — the finding stays visible with its rebuild step while
overall status stays ``ready``.
"""
if not payload.get("stale"):
return []
return [
HealthFinding(
id="control_ui.stale",
severity="warn",
surface="control_ui",
title="Bundled control UI is out of date",
detail=(
"The packaged React console predates the frontend sources "
"in this checkout. Rebuild it so the web UI matches the "
"current source tree. Switching branches also rewrites source "
"timestamps, so a freshly built bundle can be flagged."
),
readiness_impact="optional",
evidence={key: value for key, value in payload.items() if key != "stale"},
fix_steps=[
FixStep(
label="Rebuild the Control UI bundle",
command=BUILD_CMD,
detail="Restart the gateway to clear the boot-time warning too.",
)
],
)
]
9 changes: 9 additions & 0 deletions tests/test_gateway/test_rpc_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ def _optional_image_generation(ctx: RpcContext) -> dict[str, Any]:
}


def _fresh_control_ui(ctx: RpcContext) -> dict[str, Any]:
return {"stale": None, "sourceMtime": None, "bundleMtime": None, "wheelInstall": False}


def _patch_ready_support_surfaces(monkeypatch: pytest.MonkeyPatch, rpc_doctor: Any) -> None:
monkeypatch.setattr(rpc_doctor, "_handle_doctor_memory_status", _ready_memory)
monkeypatch.setattr(rpc_doctor, "_handle_channels_status", _ready_channels)
Expand All @@ -82,6 +86,10 @@ def _patch_ready_support_surfaces(monkeypatch: pytest.MonkeyPatch, rpc_doctor: A
"_image_generation_payload",
_optional_image_generation,
)
# Reads the real repo, so a plain `git checkout` (which rewrites frontend
# source mtimes without touching the gitignored dist/) would otherwise leak
# a finding into tests that assert exact counts.
monkeypatch.setattr(rpc_doctor, "_control_ui_payload", _fresh_control_ui)


@pytest.mark.asyncio
Expand Down Expand Up @@ -344,6 +352,7 @@ async def search_status(params: dict[str, Any], ctx: RpcContext) -> dict[str, An
"_image_generation_payload",
_optional_image_generation,
)
monkeypatch.setattr(rpc_doctor, "_control_ui_payload", _fresh_control_ui)

cfg = GatewayConfig()
cfg.config_path = "/tmp/custom-agentos.toml"
Expand Down
Loading
Loading