diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ea0cdd23f..a585c5ae2 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1165,6 +1165,72 @@ async def update_hermes(): } +@app.get("/api/hermes/update/check") +async def check_hermes_update(force: bool = False): + """Report whether a Hermes update is available, without applying it. + + Powers the dashboard's "check before you update" flow: the System page + shows the commit-behind count and asks the user to confirm before + ``POST /api/hermes/update`` actually runs ``hermes update``. + + Returns: + install_method: 'git' | 'pip' | 'docker' | 'nixos' | 'homebrew' | ... + current_version: installed Hermes version string + behind: commits behind upstream (>=1), 0 if up to date, + -1 if behind by an unknown count (nix/pypi), or null if the + check could not run (offline, no remote, etc.) + update_available: convenience bool (behind is non-zero and not null) + can_apply: True when the dashboard's update button can apply it + in place (git/pip); False for docker/nix/homebrew where the + user must update out-of-band + update_command: the recommended command for this install method + message: human-readable guidance for non-applyable methods + """ + install_method = detect_install_method(PROJECT_ROOT) + update_command = recommended_update_command_for_method(install_method) + + payload: Dict[str, Any] = { + "install_method": install_method, + "current_version": __version__, + "behind": None, + "update_available": False, + "can_apply": install_method in ("git", "pip"), + "update_command": update_command, + "message": None, + } + + if install_method == "docker": + payload["message"] = format_docker_update_message() + return payload + + # banner.check_for_updates() handles git / pypi / nix-revision paths and + # caches the result for 6h. ``force`` busts the cache so the "Check now" + # button reflects reality immediately. + try: + from hermes_cli.banner import check_for_updates + + if force: + try: + (get_hermes_home() / ".update_check").unlink() + except OSError: + pass + + behind = await asyncio.to_thread(check_for_updates) + except Exception: + _log.exception("Update check failed") + behind = None + + payload["behind"] = behind + if behind is None: + payload["message"] = "Couldn't reach the update source — try again later." + elif behind == 0: + payload["message"] = "You're on the latest version." + else: + payload["update_available"] = True + + return payload + + @app.post("/api/audio/transcribe") async def transcribe_audio_upload(payload: AudioTranscriptionRequest): data_url = (payload.data_url or "").strip() diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 0190117be..ec16734e7 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -412,8 +412,89 @@ class TestAdminEndpointsAuthGate: "/api/curator", "/api/portal", "/api/system/stats", + "/api/hermes/update/check", ], ) def test_gated(self, path): resp = self.client.get(path) assert resp.status_code in (401, 403) + + +class TestUpdateCheckEndpoint: + """``GET /api/hermes/update/check`` reports availability without applying. + + Powers the dashboard's check-before-you-update flow: the System page + shows the commit-behind count and asks the user to confirm before + ``POST /api/hermes/update`` runs ``hermes update``. + """ + + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_git_install_reports_behind_count(self, monkeypatch): + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git") + # Stub the shared checker so the contract is deterministic (no network). + import hermes_cli.banner as banner + + monkeypatch.setattr(banner, "check_for_updates", lambda: 5) + + r = self.client.get("/api/hermes/update/check") + assert r.status_code == 200 + body = r.json() + assert { + "install_method", + "current_version", + "behind", + "update_available", + "can_apply", + "update_command", + "message", + } <= set(body) + assert body["install_method"] == "git" + assert body["behind"] == 5 + assert body["update_available"] is True + # git/pip installs can apply the update in place from the dashboard. + assert body["can_apply"] is True + + def test_up_to_date(self, monkeypatch): + import hermes_cli.web_server as ws + import hermes_cli.banner as banner + + monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git") + monkeypatch.setattr(banner, "check_for_updates", lambda: 0) + + body = self.client.get("/api/hermes/update/check").json() + assert body["behind"] == 0 + assert body["update_available"] is False + + def test_docker_is_not_applyable(self, monkeypatch): + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "docker") + body = self.client.get("/api/hermes/update/check").json() + # Docker images are immutable — the dashboard can't apply an update. + assert body["can_apply"] is False + assert body["message"] + assert body["behind"] is None + + def test_check_failure_is_soft(self, monkeypatch): + import hermes_cli.web_server as ws + import hermes_cli.banner as banner + + monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git") + + def _boom(): + raise RuntimeError("offline") + + monkeypatch.setattr(banner, "check_for_updates", _boom) + # A failed check must not 500 — it returns behind=null with guidance. + r = self.client.get("/api/hermes/update/check") + assert r.status_code == 200 + body = r.json() + assert body["behind"] is None + assert body["update_available"] is False + assert body["message"] + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f22a39613..a93827c55 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -495,6 +495,10 @@ export const api = { fetchJSON("/api/gateway/restart", { method: "POST" }), updateHermes: () => fetchJSON("/api/hermes/update", { method: "POST" }), + checkHermesUpdate: (force = false) => + fetchJSON( + `/api/hermes/update/check${force ? "?force=true" : ""}`, + ), getActionStatus: (name: string, lines = 200) => fetchJSON( `/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`, @@ -1009,6 +1013,18 @@ export interface HookCreate { approve?: boolean; } +export interface UpdateCheckResponse { + install_method: string; + current_version: string; + // commits behind: >=1 known count, 0 up to date, -1 behind by unknown + // count (nix/pypi), or null when the check could not run. + behind: number | null; + update_available: boolean; + can_apply: boolean; + update_command: string; + message: string | null; +} + export interface SystemStats { os: string; os_release: string; diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index 4fc09c71b..e33e3171f 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -5,6 +5,7 @@ import { Brain, Cpu, Database, + Download, Globe, HardDrive, KeyRound, @@ -31,6 +32,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; +import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog"; import { useModalBehavior } from "@/hooks/useModalBehavior"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { cn, themedBody } from "@/lib/utils"; @@ -43,6 +45,7 @@ import type { HooksResponse, HookEntry, SystemStats, + UpdateCheckResponse, CuratorStatus, PortalStatus, } from "@/lib/api"; @@ -176,6 +179,13 @@ export default function SystemPage() { const [hookApprove, setHookApprove] = useState(true); const [creatingHook, setCreatingHook] = useState(false); + // ── Update check ─────────────────────────────────────────────────── + const [updateInfo, setUpdateInfo] = useState( + null, + ); + const [checkingUpdate, setCheckingUpdate] = useState(false); + const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false); + const loadAll = useCallback(() => { Promise.allSettled([ api.getStatus(), @@ -186,8 +196,11 @@ export default function SystemPage() { api.getHooks(), api.getCurator(), api.getPortal(), + // Cached (non-forced) check so the version row shows update status on + // load without a separate effect / a forced network round-trip. + api.checkHermesUpdate(false), ]) - .then(([s, st, m, p, c, h, cur, prt]) => { + .then(([s, st, m, p, c, h, cur, prt, upd]) => { if (s.status === "fulfilled") setStatus(s.value); if (st.status === "fulfilled") setStats(st.value); if (m.status === "fulfilled") setMemory(m.value); @@ -196,6 +209,7 @@ export default function SystemPage() { if (h.status === "fulfilled") setHooks(h.value); if (cur.status === "fulfilled") setCurator(cur.value); if (prt.status === "fulfilled") setPortal(prt.value); + if (upd.status === "fulfilled") setUpdateInfo(upd.value); }) .finally(() => setLoading(false)); }, []); @@ -310,6 +324,57 @@ export default function SystemPage() { } }; + // ── Update check / apply ─────────────────────────────────────────── + const checkForUpdate = useCallback( + async (force = false) => { + setCheckingUpdate(true); + try { + const info = await api.checkHermesUpdate(force); + setUpdateInfo(info); + if (force) { + if (info.update_available) { + showToast( + info.behind && info.behind > 0 + ? `Update available — ${info.behind} commit${info.behind === 1 ? "" : "s"} behind` + : "Update available", + "success", + ); + } else if (info.behind === 0) { + showToast("You're on the latest version", "success"); + } else if (info.message) { + showToast(info.message, "error"); + } + } + } catch (e) { + showToast(`Update check failed: ${e}`, "error"); + } finally { + setCheckingUpdate(false); + } + }, + [showToast], + ); + + // Auto-check (cached) runs inside loadAll on mount; this is the + // user-triggered forced re-check from the "Check for updates" button. + const applyUpdate = async () => { + setUpdateConfirmOpen(false); + try { + const resp = await api.updateHermes(); + if (!resp.ok && resp.error === "docker_update_unsupported") { + showToast( + resp.message ?? + "Updates don't apply inside Docker — re-pull the image instead.", + "error", + ); + return; + } + setActiveAction(resp.name ?? "hermes-update"); + showToast("Update started", "success"); + } catch (e) { + showToast(`Update failed: ${e}`, "error"); + } + }; + const checkpointsPrune = useConfirmDelete({ onDelete: useCallback(async () => { try { @@ -387,6 +452,19 @@ export default function SystemPage() {
+ setUpdateConfirmOpen(false)} + onConfirm={() => void applyUpdate()} + title="Update Hermes?" + description={ + updateInfo && updateInfo.behind && updateInfo.behind > 0 + ? `This will run 'hermes update' (${updateInfo.update_command}) and pull ${updateInfo.behind} new commit${updateInfo.behind === 1 ? "" : "s"}. The gateway restarts when the update finishes; the current session keeps its prompt cache until then.` + : `This will run 'hermes update' (${updateInfo?.update_command ?? "hermes update"}) and restart the gateway when it finishes.` + } + confirmLabel="Update now" + /> +
Hermes
-
v{stats?.hermes_version}
+
+ v{stats?.hermes_version} + {updateInfo && + (updateInfo.update_available ? ( + + {updateInfo.behind && updateInfo.behind > 0 + ? `${updateInfo.behind} behind` + : "update available"} + + ) : updateInfo.behind === 0 ? ( + latest + ) : null)} +
@@ -602,6 +692,45 @@ export default function SystemPage() { CPU / memory / disk metrics.

)} +
+ + {updateInfo?.update_available && updateInfo.can_apply && ( + + )} + {updateInfo && + !updateInfo.can_apply && + updateInfo.update_available && ( + + Update with{" "} + {updateInfo.update_command} + + )} + {updateInfo?.message && !updateInfo.update_available && ( + + {updateInfo.message} + + )} +
diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 4b2599887..51ed32fba 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -259,7 +259,7 @@ the API server and webhook endpoints) with its live connection status. A consolidated administration panel for installation-wide operations: -- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.) +- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.) The Hermes version shows an **update-status badge** (up to date / N commits behind) and a **Check for updates** button. When an update is available on a git or pip install, an **Update now** button opens a confirmation dialog — showing how many commits you'll pull — before running `hermes update` in the background. On Docker/Nix/Homebrew installs the dashboard can't apply the update in place, so it shows the correct out-of-band command instead. - **Nous Portal** — login status, the active inference provider, and the Tool Gateway routing table (which tools run via the Portal vs. locally), with a link to manage your subscription. Read-only mirror of `hermes portal`. - **Skill curator** — the background skill-maintenance status (active / paused, interval, last run) with pause/resume and a run-now button. Mirrors `hermes curator`. - **Gateway** — start, stop, and restart the messaging gateway, with live status (running/stopped, PID, state) @@ -430,6 +430,7 @@ same auth gate as the rest of `/api/`. | `GET /api/ops/checkpoints` · `POST .../prune` | Inspect / prune the `/rollback` store | | `POST /api/ops/hooks` · `DELETE /api/ops/hooks` | Create / remove a shell hook (consent-gated) | | `GET /api/system/stats` | Host stats — OS, CPU, memory, disk, uptime | +| `GET /api/hermes/update/check` | Report update availability (commits behind, install method) without applying. `?force=1` busts the 6h cache | | `GET /api/curator` · `PUT .../paused` · `POST .../run` | Skill-curator status + pause/resume + run | | `GET /api/portal` | Nous Portal auth + Tool Gateway routing (read-only) | | `POST /api/ops/prompt-size` · `/dump` · `/config-migrate` | Diagnostics (backgrounded) |