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
66 changes: 66 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
81 changes: 81 additions & 0 deletions tests/hermes_cli/test_dashboard_admin_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,89 @@ def _setup(self, _isolate_hermes_home):
"/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"]

16 changes: 16 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,10 @@ export const api = {
fetchJSON<ActionResponse>("/api/gateway/restart", { method: "POST" }),
updateHermes: () =>
fetchJSON<ActionResponse>("/api/hermes/update", { method: "POST" }),
checkHermesUpdate: (force = false) =>
fetchJSON<UpdateCheckResponse>(
`/api/hermes/update/check${force ? "?force=true" : ""}`,
),
getActionStatus: (name: string, lines = 200) =>
fetchJSON<ActionStatusResponse>(
`/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`,
Expand Down Expand Up @@ -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;
Expand Down
133 changes: 131 additions & 2 deletions web/src/pages/SystemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Brain,
Cpu,
Database,
Download,
Globe,
HardDrive,
KeyRound,
Expand All @@ -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";
Expand All @@ -43,6 +45,7 @@ import type {
HooksResponse,
HookEntry,
SystemStats,
UpdateCheckResponse,
CuratorStatus,
PortalStatus,
} from "@/lib/api";
Expand Down Expand Up @@ -176,6 +179,13 @@ export default function SystemPage() {
const [hookApprove, setHookApprove] = useState(true);
const [creatingHook, setCreatingHook] = useState(false);

// ── Update check ───────────────────────────────────────────────────
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResponse | null>(
null,
);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false);

const loadAll = useCallback(() => {
Promise.allSettled([
api.getStatus(),
Expand All @@ -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);
Expand All @@ -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));
}, []);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -387,6 +452,19 @@ export default function SystemPage() {
<div className="flex flex-col gap-8">
<Toast toast={toast} />

<ConfirmDialog
open={updateConfirmOpen}
onCancel={() => 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"
/>

<DeleteConfirmDialog
open={memoryReset.isOpen}
onCancel={memoryReset.cancel}
Expand Down Expand Up @@ -552,7 +630,19 @@ export default function SystemPage() {
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Hermes</div>
<div>v{stats?.hermes_version}</div>
<div className="flex items-center gap-2">
<span>v{stats?.hermes_version}</span>
{updateInfo &&
(updateInfo.update_available ? (
<Badge tone="warning">
{updateInfo.behind && updateInfo.behind > 0
? `${updateInfo.behind} behind`
: "update available"}
</Badge>
) : updateInfo.behind === 0 ? (
<Badge tone="success">latest</Badge>
) : null)}
</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1">
Expand Down Expand Up @@ -602,6 +692,45 @@ export default function SystemPage() {
CPU / memory / disk metrics.
</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4">
<Button
size="sm"
ghost
disabled={checkingUpdate}
prefix={
checkingUpdate ? (
<Spinner className="h-3.5 w-3.5" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)
}
onClick={() => void checkForUpdate(true)}
>
Check for updates
</Button>
{updateInfo?.update_available && updateInfo.can_apply && (
<Button
size="sm"
prefix={<Download className="h-3.5 w-3.5" />}
onClick={() => setUpdateConfirmOpen(true)}
>
Update now
</Button>
)}
{updateInfo &&
!updateInfo.can_apply &&
updateInfo.update_available && (
<span className="text-xs text-muted-foreground">
Update with{" "}
<span className="font-mono">{updateInfo.update_command}</span>
</span>
)}
{updateInfo?.message && !updateInfo.update_available && (
<span className="text-xs text-muted-foreground">
{updateInfo.message}
</span>
)}
</div>
</CardContent>
</Card>
</section>
Expand Down
Loading
Loading