diff --git a/CHANGELOG.md b/CHANGELOG.md index 877716e..4094fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.7] - 2026-08-23 + +### Added + +- `POST /api/upgrade`: a remote client can trigger the hub's own staleness + check; the hub restarts onto the on-disk code only when it judges that code + newer than itself (a newer `package.json` version frozen-at-start + comparison, or any `dist/**/*.js` mtime later than process start — so a + rebuild without a version bump counts). The client never decides the + restart. The daemon re-spawns itself from disk before exiting; bridges + re-register on their next heartbeat, and a respawned hub starts after the + newest dist mtime so the check cannot loop. + ## [0.11.6] - 2026-08-23 ### Added diff --git a/docs/REMOTE-CLIENTS.md b/docs/REMOTE-CLIENTS.md index b9d19d7..78aa358 100644 --- a/docs/REMOTE-CLIENTS.md +++ b/docs/REMOTE-CLIENTS.md @@ -53,6 +53,7 @@ ACP editor ────── stdio ──────────┘ | `GET /api/instances/{id}/status` | required | Real-time per-session running status of one bridge. | | `POST /api/instances/{id}/sessions/{sessionId}/close` | required | Retire a session from remote discovery — see [Closing a session](#closing-a-session). | | `GET /api/quota` | required | Account-level usage stats — same payload as `account/usage_stats`, no ACP connection needed. | +| `POST /api/upgrade` | required | Trigger the hub's own staleness check — see [Hub self-upgrade](#hub-self-upgrade). | HTTP auth: `Authorization: Bearer ` or `?token=`. @@ -291,6 +292,33 @@ Cross-instance note: if the same conversation is also registered by another bridge of the project, the hub's dedupe re-attaches it under that instance — close it there too. +## Hub self-upgrade + +```text +POST {hub}/api/upgrade → 200 { "ok": true, "restarting": false, "reason": "up-to-date", + "runningVersion": "0.11.6", "diskVersion": "0.11.6" } +``` + +Lets a remote client pick up a hub that was rebuilt on the machine (e.g. code +edited and `pnpm build` run through a remote agent session). The client only +**triggers** the check — the restart decision is entirely the hub's own. The +hub restarts onto the on-disk code only when it judges that code NEWER than +itself, by either signal: + +- the on-disk `package.json` version is newer than the version frozen into + the running process at start, **or** +- any `.js` under `dist/` has an mtime later than process start (a rebuild, + even without a version bump). + +When `restarting` is `true`, the hub exits ~500ms after replying, re-spawns +itself from the on-disk dist, and bridges re-register on their next heartbeat +(≤10s). Poll `GET /api/health` until it answers again, then refresh +`/api/instances` and reconnect. A respawned hub starts after the newest dist +mtime, so the condition self-negates — no restart loops, and an OLDER on-disk +version never triggers anything. + +Errors: `401` bad token. `GET` (or any other method) falls through to `404`. + ## Session files (read-only) Browse and download files of a session's project — served by the bridge, diff --git a/package.json b/package.json index ae86216..e15958f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-acp-server", - "version": "0.11.6", + "version": "0.11.7", "description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.", "type": "module", "license": "Apache-2.0", diff --git a/src/bin/hub.ts b/src/bin/hub.ts index 2fe1c5b..3ca183e 100644 --- a/src/bin/hub.ts +++ b/src/bin/hub.ts @@ -14,11 +14,39 @@ * hub already owns the port, which is the desired machine-singleton behaviour. */ +import { spawn } from "node:child_process"; import process from "node:process"; +import { fileURLToPath } from "node:url"; import { parseHubConfig } from "../remote/config.js"; import { startHub } from "../remote/hub-server.js"; -import { warn } from "../utils.js"; +import { log, warn } from "../utils.js"; + +/** + * Re-exec the hub from the on-disk dist (the freshest build) and exit. Runs + * after close() released the port, so the child binds cleanly; bridges racing + * to re-spawn the hub lose politely via the EADDRINUSE singleton behaviour. + * stdio is fully ignored — the parent exits immediately, and a piped stderr + * would EPIPE the child on its first log line. + */ +function respawnSelf(): void { + try { + // bin/hub.js → ../bin/hub.js is itself (this file's compiled location). + const hubJs = fileURLToPath(new URL("../bin/hub.js", import.meta.url)); + const child = spawn(process.execPath, [hubJs], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); + log(`hub: respawned from ${hubJs} (pid ${child.pid})`); + } catch (e) { + warn( + `hub: respawn failed (${e instanceof Error ? e.message : String(e)})` + + " — bridges will re-spawn the hub on their next heartbeat", + ); + } + process.exit(0); +} async function main(): Promise { const config = parseHubConfig(); @@ -28,6 +56,7 @@ async function main(): Promise { host: config.hubHost, token: config.token, onIdleExit: () => process.exit(0), + onRestart: respawnSelf, }); process.on("SIGTERM", () => void hub.close().then(() => process.exit(0))); process.on("SIGINT", () => void hub.close().then(() => process.exit(0))); diff --git a/src/remote/hub-server.ts b/src/remote/hub-server.ts index 5ec0fed..59f18ae 100644 --- a/src/remote/hub-server.ts +++ b/src/remote/hub-server.ts @@ -7,6 +7,9 @@ * plain-HTTP conveniences that spare clients a full ACP round-trip (ADR-0005): * a proxied per-instance /status, and an account-level /api/quota queried * directly (quota belongs to the machine's credentials, not to any instance). + * POST /api/upgrade lets a client TRIGGER a self-decided restart: the hub + * re-checks whether the on-disk build is newer than the running process and, + * only if so, re-spawns itself onto it — the decision is never the client's. * It holds no session state and understands no ACP — a proxied connection * stays bound to one instance for its whole lifetime. * @@ -21,6 +24,8 @@ */ import { createHash, timingSafeEqual } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; import { createServer, get as httpGet, @@ -30,6 +35,8 @@ import { type ServerResponse, } from "node:http"; import net from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { WebSocket, WebSocketServer, type RawData } from "ws"; @@ -46,6 +53,19 @@ export interface HubOptions { idleExitMs?: number; /** WebSocket keepalive ping interval (default 30s; tunnels drop idle links). */ pingIntervalMs?: number; + /** + * Fires when the hub decided it should restart onto newer on-disk code + * (a newer bridge registered, or POST /api/upgrade found the dist newer). + * The standalone daemon re-spawns a replacement before exiting (see + * bin/hub.ts); falls back to onIdleExit when unset. + */ + onRestart?: () => void; + /** + * Override the on-disk locations /api/upgrade checks against (tests point + * these at fixtures). Defaults: this package's package.json and the dist + * directory this module runs from. + */ + codePaths?: { packageJson: string; distDir: string }; } export interface HubHandle { @@ -197,6 +217,81 @@ function portOpen(port: number, timeoutMs: number): Promise { }); } +/** + * Where /api/upgrade looks for on-disk code: root package.json + dist/. + */ +function defaultCodePaths(): { packageJson: string; distDir: string } { + // dist/remote/hub-server.js → ../../package.json and dist/ (src/ in dev + // has no .js files, so the mtime signal simply stays silent there). + return { + packageJson: fileURLToPath(new URL("../../package.json", import.meta.url)), + distDir: fileURLToPath(new URL("../", import.meta.url)), + }; +} + +/** Newest mtime among .js files under dir (recursive); null when none found. */ +async function newestJsMtime(dir: string): Promise { + let newest: number | null = null; + const walk = async (current: string): Promise => { + let entries: Dirent[]; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch { + return; // unreadable subtree — skip it, don't fail the check + } + for (const entry of entries) { + const p = path.join(current, entry.name); + if (entry.isDirectory()) await walk(p); + else if (entry.isFile() && entry.name.endsWith(".js")) { + try { + // Floor to whole ms: APFS mtimes carry sub-ms fractions while + // Date.now() truncates, and a same-ms write/start pair would + // otherwise look "newer" than a hub started after it. + const mtime = Math.floor((await stat(p)).mtimeMs); + if (newest === null || mtime > newest) newest = mtime; + } catch { + /* raced deletion */ + } + } + } + }; + await walk(dir); + return newest; +} + +/** + * /api/upgrade staleness check: is the code on DISK newer than this running + * process? Either signal suffices — the on-disk package.json version beats + * the version frozen into this process at start (a release upgrade), or any + * .js under dist was written after process start (a rebuild, even without a + * version bump). A respawned process starts after the newest dist mtime, so + * the condition self-negates: no restart loops. + */ +async function diskCodeIsNewer( + paths: { packageJson: string; distDir: string }, + startedAt: number, +): Promise<{ + newer: boolean; + reason: "version" | "mtime" | "up-to-date"; + diskVersion: string | null; +}> { + let diskVersion: string | null = null; + try { + const pkg = JSON.parse(await readFile(paths.packageJson, "utf8")) as { version?: unknown }; + if (typeof pkg.version === "string") diskVersion = pkg.version; + } catch { + /* unreadable package.json — the mtime signal still applies */ + } + if (diskVersion && compareVersions(diskVersion, AGENT_INFO.version) > 0) { + return { newer: true, reason: "version", diskVersion }; + } + const newest = await newestJsMtime(paths.distDir); + if (newest !== null && newest > startedAt) { + return { newer: true, reason: "mtime", diskVersion }; + } + return { newer: false, reason: "up-to-date", diskVersion }; +} + /** * Start the hub. Resolves once listening; rejects on bind failure (including * EADDRINUSE when another hub already owns the port). @@ -210,12 +305,30 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro idleExitMs = IDLE_EXIT_MS, pingIntervalMs = PING_INTERVAL_MS, onIdleExit, + onRestart, + codePaths = defaultCodePaths(), } = options; + /** Frozen at hub start — the anchor the /api/upgrade signals compare to. */ + const startedAt = Date.now(); + const instances = new Map(); const proxyPairs = new Set<{ client: WebSocket; bridge: WebSocket }>(); const timers: Array> = []; + /** + * Reply first, then gracefully stop (close() releases the port) and hand + * over. `close` is declared below and hoisted — it only runs inside the + * timer, long after this scope is fully initialised. + */ + const restartSoon = (message: string): void => { + log(message); + const restart = setTimeout(() => { + void close().finally(() => (onRestart ?? onIdleExit)?.()); + }, 500); + restart.unref(); + }; + let idleSince: number | null = null; const wss = new WebSocketServer({ noServer: true }); @@ -362,6 +475,34 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro } return; } + // POST /api/upgrade — a remote client may TRIGGER a staleness check but + // never decide the restart: the hub compares its frozen running version + // and process start time against the on-disk package.json and dist + // mtimes, and only restarts onto code it judged newer (diskCodeIsNewer). + if (url.pathname === "/api/upgrade" && req.method === "POST") { + if (!authorized(req, url, token)) { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("unauthorized"); + return; + } + const check = await diskCodeIsNewer(codePaths, startedAt); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + ok: true, + restarting: check.newer, + reason: check.reason, + runningVersion: AGENT_INFO.version, + diskVersion: check.diskVersion, + }), + ); + if (check.newer) { + restartSoon( + `hub: on-disk code is newer (${check.reason}: ${check.diskVersion ?? "rebuilt dist"}) — restarting onto it`, + ); + } + return; + } // /api/instances/{id}/fs/... and /status — byte-level proxy to the // instance's loopback file/status endpoint (ADR-0004, ADR-0005). The hub // routes by instance id only; sessionId, path semantics, and scope checks @@ -499,13 +640,9 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(stale ? { ok: true, restarting: true } : { ok: true })); if (stale) { - log( + restartSoon( `hub: bridge ${body.version} is newer than hub ${AGENT_INFO.version} — restarting to upgrade`, ); - const restart = setTimeout(() => { - void close().finally(() => onIdleExit?.()); - }, 500); - restart.unref(); } return; } diff --git a/tests/hub.test.ts b/tests/hub.test.ts index bdcbc64..12c5ce3 100644 --- a/tests/hub.test.ts +++ b/tests/hub.test.ts @@ -4,8 +4,11 @@ * idle-exit policy. */ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { createServer, type Server } from "node:http"; import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { WebSocket, WebSocketServer } from "ws"; @@ -20,6 +23,7 @@ vi.mock("../src/handlers/account.js", () => ({ })); import { resetQuotaCacheForTest, startHub, type HubHandle } from "../src/remote/hub-server.js"; +import { AGENT_INFO } from "../src/utils.js"; const TOKEN = "test-hub-token"; const BASE_PORT = 18400; // bridge ports start here; ephemeral hub uses port 0 @@ -661,3 +665,143 @@ describe("hub version self-upgrade", () => { expect(health.status).toBe(200); }); }); + +describe("hub /api/upgrade (self-decided restart)", () => { + const upgradeUrl = (hub: HubHandle) => `http://127.0.0.1:${hub.port}/api/upgrade`; + const auth = { Authorization: `Bearer ${TOKEN}` }; + + /** + * Fixture on-disk code: package.json + dist/remote/hub-server.js. Written + * BEFORE the hub starts, so its mtimes sit below the hub's startedAt — + * exactly like a build that predates the running process. + */ + async function writeCodeFixture( + version: string, + ): Promise<{ packageJson: string; distDir: string }> { + const root = await mkdtemp(path.join(tmpdir(), "hub-upgrade-")); + const packageJson = path.join(root, "package.json"); + const distDir = path.join(root, "dist"); + await mkdir(path.join(distDir, "remote"), { recursive: true }); + await writeFile(packageJson, JSON.stringify({ version })); + await writeFile(path.join(distDir, "remote", "hub-server.js"), "// code\n"); + return { packageJson, distDir }; + } + + /** Poll a flag until true or fail (the restart fires ~500ms after replying). */ + async function until(flag: () => boolean, label: string): Promise { + await withTimeout( + new Promise((resolve) => { + const check = setInterval(() => { + if (flag()) { + clearInterval(check); + resolve(); + } + }, 50); + }), + 5000, + label, + ); + } + + it("rejects /api/upgrade without or with a wrong token", async () => { + const hub = await startTestHub(); + expect((await fetch(upgradeUrl(hub), { method: "POST" })).status).toBe(401); + expect( + (await fetch(upgradeUrl(hub), { method: "POST", headers: { Authorization: "Bearer nope" } })) + .status, + ).toBe(401); + }); + + it("stays put when the on-disk code matches the running version", async () => { + const paths = await writeCodeFixture(AGENT_INFO.version); + let restarted = false; + const hub = await startTestHub({ + codePaths: paths, + onRestart: () => { + restarted = true; + }, + }); + const res = await fetch(upgradeUrl(hub), { method: "POST", headers: auth }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + ok: true, + restarting: false, + reason: "up-to-date", + runningVersion: AGENT_INFO.version, + diskVersion: AGENT_INFO.version, + }); + await new Promise((r) => setTimeout(r, 800)); + expect(restarted).toBe(false); + expect((await fetch(`http://127.0.0.1:${hub.port}/api/health`)).status).toBe(200); + }); + + it("does not restart onto an OLDER on-disk version", async () => { + const paths = await writeCodeFixture("0.0.1"); + let restarted = false; + const hub = await startTestHub({ + codePaths: paths, + onRestart: () => { + restarted = true; + }, + }); + const res = await fetch(upgradeUrl(hub), { method: "POST", headers: auth }); + expect(await res.json()).toMatchObject({ restarting: false, reason: "up-to-date" }); + await new Promise((r) => setTimeout(r, 800)); + expect(restarted).toBe(false); + }); + + it("restarts onto a newer on-disk version", async () => { + const paths = await writeCodeFixture("9999.0.0"); + let restarted = false; + const hub = await startTestHub({ + codePaths: paths, + onRestart: () => { + restarted = true; + }, + }); + const res = await fetch(upgradeUrl(hub), { method: "POST", headers: auth }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + ok: true, + restarting: true, + reason: "version", + runningVersion: AGENT_INFO.version, + diskVersion: "9999.0.0", + }); + await until(() => restarted, "hub restart onto newer version"); + }); + + it("restarts when dist was rebuilt without a version bump", async () => { + const paths = await writeCodeFixture(AGENT_INFO.version); + let restarted = false; + const hub = await startTestHub({ + codePaths: paths, + onRestart: () => { + restarted = true; + }, + }); + // Let startedAt settle strictly below the rewrite's mtime, then "rebuild". + await new Promise((r) => setTimeout(r, 20)); + await writeFile(path.join(paths.distDir, "remote", "hub-server.js"), "// rebuilt\n"); + const res = await fetch(upgradeUrl(hub), { method: "POST", headers: auth }); + expect(await res.json()).toMatchObject({ restarting: true, reason: "mtime" }); + await until(() => restarted, "hub restart onto rebuilt dist"); + }); + + it("checks the real repo layout safely when nothing is injected", async () => { + let restarted = false; + const hub = await startTestHub({ + onRestart: () => { + restarted = true; + }, + }); + // Under vitest the defaults resolve to the repo root: package.json reads + // the same version frozen into AGENT_INFO, and src/ holds no .js files — + // so the answer must be a calm no-op, never a crash. + const res = await fetch(upgradeUrl(hub), { method: "POST", headers: auth }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ restarting: false, reason: "up-to-date" }); + await new Promise((r) => setTimeout(r, 800)); + expect(restarted).toBe(false); + }); +});