From d228d78b5290dd699c3322961fb6a72c100bead4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:04:01 +0000 Subject: [PATCH 1/5] chore: start R9 projects long-poll Co-Authored-By: Claude Opus 5.5 From 9feddf4b1abebedbb892ec8dea08187095ce71eb Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:31:46 +0000 Subject: [PATCH 2/5] feat(daemon): let /v1/projects wait for a changed snapshot hackd's projects response now carries state_token, a digest that ignores per-response and refresh-timestamp fields and Docker's humanized container status. `?wait=&wait_timeout_ms=` answers when the token changes after a runtime refresh, or at the timeout with a fresh build. At most 16 requests wait; malformed parameters are 400. The daemon socket's idle limit rises to 70 s because Bun's 10 s default closes long-polls and server.timeout(request) cannot raise it. The CLI strips state_token so `hack projects --json` keeps its published shape. HACK-2218 R9. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/DESIGN.md | 2 +- docs/guides/v5-release-notes.md | 6 + docs/reference/project-state.md | 32 ++++ src/commands/projects.ts | 4 +- src/daemon/projects-wait.ts | 161 +++++++++++++++++++ src/daemon/runtime-cache.ts | 30 ++++ src/daemon/server.ts | 65 ++++++-- tests/daemon-projects-wait.test.ts | 121 ++++++++++++++ tests/projects-wait.test.ts | 250 +++++++++++++++++++++++++++++ tests/runtime-cache.test.ts | 35 ++++ 10 files changed, 687 insertions(+), 19 deletions(-) create mode 100644 src/daemon/projects-wait.ts create mode 100644 tests/daemon-projects-wait.test.ts create mode 100644 tests/projects-wait.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 019be7501..2978e51aa 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -383,7 +383,7 @@ Each is small enough for one PR. 10. **R7b — exists.** `hack doctor --fix --only --yes --json` executes one allowlisted safe fix and rechecks it. Initially only `hack gitignore` is safe; other IDs and mixed modes refuse before writes. Results use `{ok,data:{schema:1,checks,summary,counts,fix}}`; `fix` records `id`, `changed`, `confirmed`. 11. **R7c** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate. 12. **R8** Daemon unit staleness: `service.unit_stale` and reason in `hack daemon status --json`, and `version` there. -13. **R9** (optional) Push updates: `GET /v1/projects?wait=` long-poll. +13. **R9 — exists** Push updates: `GET /v1/projects?wait=&wait_timeout_ms=` long-poll ([waiting for changes](../../docs/reference/project-state.md#waiting-for-changes)). `generated_at` changes on every response, so the marker is the response's `state_token`, a digest that ignores per-response and refresh-timestamp fields. Answers on change, or at the timeout (default 25 s, max 55 s). 14. **R10 — exists** Env key state: `key_state: ok | missing | unreadable | not_checked` in `hack env list --json` (no values). `not_checked` is the legacy `hack.env.json` contract. The payload keeps its published bare-object shape; no envelope was added. 15. **R11** Admin argv plan: `hack global install|trust --plan --json` listing the root-only argv lines. 16. **R12a** Always-on persistence and `hack autostart add|remove|list|pause|resume` ([section 6](#6-always-on-hack-2213-proposed-contract)). diff --git a/docs/guides/v5-release-notes.md b/docs/guides/v5-release-notes.md index 81ba5e12f..c34d27d1a 100644 --- a/docs/guides/v5-release-notes.md +++ b/docs/guides/v5-release-notes.md @@ -35,6 +35,12 @@ no longer removes legacy inputs in the same step. The full upgrade steps are in [Upgrade to v5](upgrade-to-v5.md#preview-resume-and-undo-env-migration). +### Waiting for project changes from hackd + +| Change | What to do | +| --- | --- | +| **hackd's `GET /v1/projects` carries `state_token` and accepts `?wait=`.** The request answers when the projects content changes, or at `wait_timeout_ms` (default 25 s, 1–55 s). hackd's socket now keeps idle connections for up to 70 s. `hack projects --json` output is unchanged. | Long-poll instead of polling quickly: send the last `state_token` back and repeat. See [waiting for changes](../reference/project-state.md#waiting-for-changes). | + ### Read-only update check for any install | Change | What to do | diff --git a/docs/reference/project-state.md b/docs/reference/project-state.md index df1f248ca..747b9372f 100644 --- a/docs/reference/project-state.md +++ b/docs/reference/project-state.md @@ -88,3 +88,35 @@ writes. Ordinary JSON listing and daemon listing use live native reads when native runtime selection is configured; those reads do not start a VM or graph. ![Shared state projection](./project-state.svg) + +## Waiting for changes + +hackd's `GET /v1/projects` response also carries `state_token`, an opaque +32-character digest of its content. It leaves out values that change without a +state change: `generated_at`, `profiling`, `runtime_checked_at`, +`runtime_last_ok_at` and Docker's humanized container `status` text (container +`state` and the service state and health fields above still count). The CLI's +`hack projects --json` output does not include it. + +A client can send the token back to wait for a change: + +```text +GET /v1/projects?wait=&wait_timeout_ms=25000 +``` + +- A different current token answers at once. +- Otherwise hackd recomputes after each runtime refresh (Docker events and its + 30-second periodic refresh) and answers when the token changes, or at + `wait_timeout_ms` with a fresh build whose token may be unchanged. Ask again + with the latest token. +- Use the same query parameters as the request that produced the token. +- `wait_timeout_ms` defaults to 25000 and must be 1000–55000. A malformed + `wait` or timeout returns `400` (`invalid_wait`, `invalid_wait_timeout_ms`, + `wait_timeout_requires_wait`). +- At most 16 requests wait at once; beyond that a request is answered + immediately, as without `wait`. +- Registry-only changes, such as a new registration, show up at the next refresh + or at the timeout. Waiting ends when the client disconnects. + +hackd's socket keeps idle connections for up to 70 seconds, so a wait is not +cut off at Bun's default 10 seconds. The unsupported gateway route does not wait. diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 28a58bc4a..d0e4ce462 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -775,7 +775,9 @@ async function outputDaemonProjects({ daemon.json && (!opts.summary || daemon.json.detail_level === "summary") ) { - const { profiling, ...payload } = daemon.json; + // `state_token` is the daemon's long-poll marker (`?wait=`), not part of + // the published `projects --json` shape. + const { profiling, state_token: _stateToken, ...payload } = daemon.json; writeProfiledProjects({ payload, profiler, diff --git a/src/daemon/projects-wait.ts b/src/daemon/projects-wait.ts new file mode 100644 index 000000000..7c984c2ba --- /dev/null +++ b/src/daemon/projects-wait.ts @@ -0,0 +1,161 @@ +/** + * Optional long-poll for `GET /v1/projects` (HACK-2218 R9). + * + * Every projects payload carries `state_token`, a digest of its content + * without the per-response `generated_at` and `profiling` fields. A client that + * sends `wait=` gets an answer as soon as the token differs. Otherwise + * the request waits for runtime cache refreshes (Docker events and the daemon's + * periodic refresh) and recomputes after each one, until the token changes or + * `wait_timeout_ms` passes. A timeout answers with the unchanged payload, so a + * client simply asks again. Registry-only changes are seen at the next refresh. + */ +import { createHash } from "node:crypto"; + +export const PROJECTS_WAIT_DEFAULT_MS = 25_000; +export const PROJECTS_WAIT_MIN_MS = 1000; +export const PROJECTS_WAIT_MAX_MS = 55_000; +/** Concurrent waiters; beyond this a request is answered at once. */ +export const PROJECTS_WAIT_MAX_WAITERS = 16; + +const TOKEN_PATTERN = /^[0-9a-f]{32}$/; +const DIGITS = /^\d+$/; + +/** + * Top-level fields that differ between two builds of identical state: the + * per-response stamp and profile, and refresh timestamps that advance on + * every periodic refresh even when nothing changed. + */ +const VOLATILE_FIELDS = new Set([ + "generated_at", + "profiling", + "state_token", + "runtime_checked_at", + "runtime_last_ok_at", +]); + +/** + * Docker's container `status` is humanized text ("Up 12 seconds") that changes + * on every refresh. The container's `state` and the shared service state and + * health fields still count. + */ +function stableValue(_key: string, value: unknown): unknown { + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + "id" in value && + "state" in value && + "status" in value + ) { + const { status: _status, ...rest } = value as Record; + return rest; + } + return value; +} + +export function projectsStateToken(payload: object): string { + const stable = Object.fromEntries( + Object.entries(payload).filter(([key]) => !VOLATILE_FIELDS.has(key)) + ); + return createHash("sha256") + .update(JSON.stringify(stable, stableValue)) + .digest("hex") + .slice(0, 32); +} + +export type ProjectsWaitRequest = + | { readonly kind: "none" } + | { + readonly kind: "wait"; + readonly token: string; + readonly timeoutMs: number; + } + | { readonly kind: "invalid"; readonly error: string }; + +/** Parse `wait` and `wait_timeout_ms`; a timeout without `wait` is invalid. */ +export function parseProjectsWait( + params: URLSearchParams +): ProjectsWaitRequest { + const token = params.get("wait"); + const timeout = params.get("wait_timeout_ms"); + if (token === null) { + return timeout === null + ? { kind: "none" } + : { kind: "invalid", error: "wait_timeout_requires_wait" }; + } + if (!TOKEN_PATTERN.test(token)) { + return { kind: "invalid", error: "invalid_wait" }; + } + if (timeout === null) { + return { kind: "wait", token, timeoutMs: PROJECTS_WAIT_DEFAULT_MS }; + } + const value = DIGITS.test(timeout) ? Number(timeout) : Number.NaN; + if ( + !Number.isSafeInteger(value) || + value < PROJECTS_WAIT_MIN_MS || + value > PROJECTS_WAIT_MAX_MS + ) { + return { kind: "invalid", error: "invalid_wait_timeout_ms" }; + } + return { kind: "wait", token, timeoutMs: value }; +} + +let waiters = 0; + +/** + * Build the payload, then, for a wait request whose token still matches, + * rebuild after each refresh until it changes, the deadline passes or the + * client goes away. Always resolves with the latest payload and its token. + */ +export async function resolveProjectsPayload(opts: { + readonly build: () => Promise; + readonly wait: ProjectsWaitRequest; + /** Resolves after the next cache refresh, the deadline, or an abort. */ + readonly nextRefresh: (opts: { + readonly signal: AbortSignal; + readonly deadlineMs: number; + }) => Promise; + readonly signal: AbortSignal; + readonly now?: () => number; +}): Promise { + const now = opts.now ?? Date.now; + const tokenOf = async () => { + const payload = await opts.build(); + return { payload, token: projectsStateToken(payload) }; + }; + let current = await tokenOf(); + if ( + opts.wait.kind !== "wait" || + current.token !== opts.wait.token || + waiters >= PROJECTS_WAIT_MAX_WAITERS + ) { + return { ...current.payload, state_token: current.token }; + } + const deadlineMs = now() + opts.wait.timeoutMs; + waiters++; + try { + while (!opts.signal.aborted && now() < deadlineMs) { + await opts.nextRefresh({ signal: opts.signal, deadlineMs }); + if (opts.signal.aborted || now() >= deadlineMs) { + break; + } + current = await tokenOf(); + if (current.token !== opts.wait.token) { + break; + } + } + // A timeout still answers with fresh content: registry changes do not + // trigger a refresh, so rebuild once rather than resend the first build. + if (!opts.signal.aborted && current.token === opts.wait.token) { + current = await tokenOf(); + } + } finally { + waiters--; + } + return { ...current.payload, state_token: current.token }; +} + +/** Test seam: current number of waiting requests. */ +export function projectsWaiterCount(): number { + return waiters; +} diff --git a/src/daemon/runtime-cache.ts b/src/daemon/runtime-cache.ts index 3720470e2..40023901a 100644 --- a/src/daemon/runtime-cache.ts +++ b/src/daemon/runtime-cache.ts @@ -123,6 +123,14 @@ export interface RuntimeCache { }): PsPayload; getSnapshot(): RuntimeSnapshot | null; getDiagnostics(): RuntimeCacheDiagnostics; + /** + * Resolves after the next completed refresh, at `deadlineMs`, or when + * `signal` aborts, whichever is first. Never rejects. + */ + nextRefresh(opts: { + readonly signal: AbortSignal; + readonly deadlineMs: number; + }): Promise; } export type RuntimeCacheDiagnostics = { @@ -162,6 +170,7 @@ export function createRuntimeCache(opts: { let lastRefreshPhasesMs: Readonly> = {}; let snapshot: RuntimeSnapshot | null = null; + const refreshWaiters = new Set<() => void>(); let refreshTask: Promise | null = null; let pendingRefresh: QueuedRefresh | null = null; let lastRefreshDurationMs: number | null = null; @@ -281,6 +290,9 @@ export function createRuntimeCache(opts: { health = refreshed.health; snapshot = nextSnapshot; opts.onRefresh?.(nextSnapshot); + for (const wake of [...refreshWaiters]) { + wake(); + } } finally { const durationMs = Math.max(0, Date.now() - startedAtMs); lastRefreshPhasesMs = { ...profiler.timings }; @@ -466,10 +478,28 @@ export function createRuntimeCache(opts: { } } + const nextRefresh: RuntimeCache["nextRefresh"] = ({ signal, deadlineMs }) => + new Promise((resolve) => { + const wake = () => { + clearTimeout(timer); + signal.removeEventListener("abort", wake); + refreshWaiters.delete(wake); + resolve(); + }; + const timer = setTimeout(wake, Math.max(0, deadlineMs - Date.now())); + if (signal.aborted) { + wake(); + return; + } + signal.addEventListener("abort", wake, { once: true }); + refreshWaiters.add(wake); + }); + return { refresh, getProjectsPayload, getPsPayload, + nextRefresh, getSnapshot: () => snapshot, getDiagnostics: () => { const inspect = getRuntimeInspectCacheDiagnostics({ diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 1504efdf0..a159850f2 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -32,6 +32,11 @@ import { import { createDaemonLogger } from "./logger.ts"; import type { DaemonPaths } from "./paths.ts"; import { removeFileIfExists, writeDaemonPid } from "./process.ts"; +import { + PROJECTS_WAIT_MAX_MS, + parseProjectsWait, + resolveProjectsPayload, +} from "./projects-wait.ts"; import { createRefreshScheduler } from "./refresh-scheduler.ts"; import { type RequestTargetProxy, @@ -70,6 +75,19 @@ type DaemonMetrics = { lastProjectsResponseBytes: number | null; }; +/** + * A `/v1/projects?wait=` long-poll can last PROJECTS_WAIT_MAX_MS. Bun's default + * 10 s idle limit closes it, and `server.timeout(request)` cannot raise that + * limit (checked on Bun 1.4.4). Bun honours `idleTimeout` on unix sockets, + * although its types declare it only for host/port servers, hence the helper. + * The daemon socket is owner-only. + */ +function withLongPollIdleTimeout(options: T): T { + return Object.assign(options, { + idleTimeout: Math.ceil(PROJECTS_WAIT_MAX_MS / 1000) + 15, + }); +} + export async function runDaemon({ paths, foreground, @@ -269,16 +287,18 @@ export async function runDaemon({ let server: ReturnType; let daemonProxy: RequestTargetProxy | null = null; - server = Bun.serve({ - unix: daemonInternalSocketPath, - fetch: (req) => - handleRequest({ - req, - server, - ...requestContext, - }), - websocket: websocketHandlers, - }); + server = Bun.serve( + withLongPollIdleTimeout({ + unix: daemonInternalSocketPath, + fetch: (req) => + handleRequest({ + req, + server, + ...requestContext, + }), + websocket: websocketHandlers, + }) + ); await restrictSocketMode({ path: daemonInternalSocketPath }); try { daemonProxy = await startRequestTargetProxy({ @@ -482,13 +502,24 @@ async function handleRequest({ if (summary && includeMeta) { return jsonResponse({ error: "summary_incompatible_with_meta" }, 400); } - const payload = await cache.getProjectsPayload({ - filter, - includeGlobal, - includeUnregistered, - includeMeta, - summary, - profile: parseBoolean({ value: url.searchParams.get("profile") }), + const wait = parseProjectsWait(url.searchParams); + if (wait.kind === "invalid") { + return jsonResponse({ error: wait.error }, 400); + } + const profile = parseBoolean({ value: url.searchParams.get("profile") }); + const payload = await resolveProjectsPayload({ + build: () => + cache.getProjectsPayload({ + filter, + includeGlobal, + includeUnregistered, + includeMeta, + summary, + profile, + }), + wait, + nextRefresh: cache.nextRefresh, + signal: req.signal, }); const started = performance.now(); const body = JSON.stringify(payload); diff --git a/tests/daemon-projects-wait.test.ts b/tests/daemon-projects-wait.test.ts new file mode 100644 index 000000000..7bdc0073b --- /dev/null +++ b/tests/daemon-projects-wait.test.ts @@ -0,0 +1,121 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +/** + * A real foreground hackd in a private home: Docker points at a dead socket, + * its event watcher is off, and login-service management and autostart are + * disabled. The test owns the spawned process and stops it in afterEach. + * Guards the daemon's idle timeout: Bun's 10 s default would close a + * `/v1/projects?wait=` long-poll before it answers. + */ +const REPO_ROOT = resolve(import.meta.dir, ".."); +const ENTRY = join(REPO_ROOT, "index.ts"); + +let root: string | null = null; +let daemon: ReturnType | null = null; + +afterEach(async () => { + if (daemon) { + daemon.kill("SIGTERM"); + const exited = await Promise.race([ + daemon.exited.then(() => true), + Bun.sleep(5000).then(() => false), + ]); + if (!exited) { + daemon.kill("SIGKILL"); + await daemon.exited; + } + daemon = null; + } + if (root) { + await rm(root, { recursive: true, force: true }); + root = null; + } +}); + +async function startDaemon(): Promise<{ + readonly socket: string; + readonly cwd: string; + readonly env: Record; +}> { + // Under /tmp: the socket path must fit the platform's sun_path limit. + const cwd = await mkdtemp(join(await realpath("/tmp"), "hack-dw-")); + root = cwd; + const home = join(cwd, "home"); + await mkdir(home, { recursive: true, mode: 0o755 }); + const env = { + HOME: home, + PATH: "/usr/bin:/bin:/usr/sbin:/sbin", + TMPDIR: tmpdir(), + HACK_NO_INTERACTIVE: "1", + HACK_DAEMON_AUTOSTART_DISABLE: "1", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + HACK_SYSTEMD_DISABLE: "1", + HACK_LAUNCHD_DISABLE: "1", + HACK_LOGGER: "console", + DOCKER_HOST: `unix://${join(cwd, "no-docker.sock")}`, + DOCKER_CONFIG: join(cwd, "docker-config"), + }; + daemon = Bun.spawn( + [process.execPath, ENTRY, "daemon", "start", "--foreground"], + { cwd, env, stdin: "ignore", stdout: "ignore", stderr: "ignore" } + ); + const socket = join(home, ".hack", "daemon", "hackd.sock"); + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const ok = await fetch("http://hackd/v1/status", { unix: socket }) + .then((response) => response.ok) + .catch(() => false); + if (ok) { + return { socket, cwd, env }; + } + await Bun.sleep(100); + } + throw new Error("hackd did not answer on its socket"); +} + +test("a projects long-poll outlives Bun's default 10 s idle timeout", async () => { + const { socket, cwd, env } = await startDaemon(); + const first = (await fetch("http://hackd/v1/projects", { + unix: socket, + }).then((response) => response.json())) as { state_token?: unknown }; + expect(first.state_token).toMatch(/^[0-9a-f]{32}$/); + + const started = Date.now(); + const response = await fetch( + `http://hackd/v1/projects?wait=${first.state_token}&wait_timeout_ms=12000`, + { unix: socket } + ); + const elapsed = Date.now() - started; + expect(response.status).toBe(200); + const body = (await response.json()) as { state_token?: unknown }; + // Nothing changed, so the wait ran to its timeout and kept the token. + expect(body.state_token).toBe(first.state_token); + expect(elapsed).toBeGreaterThanOrEqual(11_500); + + const invalid = await fetch("http://hackd/v1/projects?wait=bad", { + unix: socket, + }); + expect(invalid.status).toBe(400); + + // The CLI listing served by this daemon keeps its published shape. + const cli = Bun.spawn([process.execPath, ENTRY, "projects", "--json"], { + cwd, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const killer = setTimeout(() => cli.kill("SIGKILL"), 20_000); + const [stdout, code] = await Promise.all([ + new Response(cli.stdout).text(), + cli.exited, + ]); + clearTimeout(killer); + expect(code).toBe(0); + const listing = JSON.parse(stdout) as Record; + expect(listing).toHaveProperty("projects"); + expect(listing).not.toHaveProperty("state_token"); +}, 60_000); diff --git a/tests/projects-wait.test.ts b/tests/projects-wait.test.ts new file mode 100644 index 000000000..c0236533b --- /dev/null +++ b/tests/projects-wait.test.ts @@ -0,0 +1,250 @@ +import { expect, test } from "bun:test"; +import { + PROJECTS_WAIT_DEFAULT_MS, + PROJECTS_WAIT_MAX_WAITERS, + parseProjectsWait, + projectsStateToken, + projectsWaiterCount, + resolveProjectsPayload, +} from "../src/daemon/projects-wait.ts"; + +const TOKEN = "0123456789abcdef0123456789abcdef"; +const params = (query: string) => new URLSearchParams(query); + +test("wait parameters are closed: a 32-hex token and a bounded timeout", () => { + expect(parseProjectsWait(params(""))).toEqual({ kind: "none" }); + expect(parseProjectsWait(params(`wait=${TOKEN}`))).toEqual({ + kind: "wait", + token: TOKEN, + timeoutMs: PROJECTS_WAIT_DEFAULT_MS, + }); + expect( + parseProjectsWait(params(`wait=${TOKEN}&wait_timeout_ms=1000`)) + ).toEqual({ kind: "wait", token: TOKEN, timeoutMs: 1000 }); + for (const [query, error] of [ + ["wait_timeout_ms=5000", "wait_timeout_requires_wait"], + ["wait=2026-11-15T00:00:00.000Z", "invalid_wait"], + [`wait=${TOKEN.toUpperCase()}`, "invalid_wait"], + [`wait=${TOKEN}&wait_timeout_ms=999`, "invalid_wait_timeout_ms"], + [`wait=${TOKEN}&wait_timeout_ms=55001`, "invalid_wait_timeout_ms"], + [`wait=${TOKEN}&wait_timeout_ms=1e4`, "invalid_wait_timeout_ms"], + [`wait=${TOKEN}&wait_timeout_ms=-5`, "invalid_wait_timeout_ms"], + ] as const) { + expect(parseProjectsWait(params(query))).toEqual({ + kind: "invalid", + error, + }); + } +}); + +test("the token ignores per-response fields and changes with content", () => { + const a = { generated_at: "t1", profiling: { x: 1 }, projects: [1] }; + const b = { generated_at: "t2", projects: [1] }; + const c = { generated_at: "t1", projects: [2] }; + expect(projectsStateToken(a)).toBe(projectsStateToken(b)); + expect(projectsStateToken(a)).not.toBe(projectsStateToken(c)); + expect(projectsStateToken(a)).toMatch(/^[0-9a-f]{32}$/); + // Refresh stamps and Docker's humanized status text do not count; state does. + const container = (status: string, state = "running") => ({ + runtime_checked_at: status, + runtime_last_ok_at: status, + projects: [ + { + runtime: { services: [{ containers: [{ id: "c1", state, status }] }] }, + }, + ], + }); + expect(projectsStateToken(container("Up 1 second"))).toBe( + projectsStateToken(container("Up 31 seconds")) + ); + expect(projectsStateToken(container("Up 1 second"))).not.toBe( + projectsStateToken(container("Exited (0)", "exited")) + ); + // A `status` field elsewhere (not a container entry) still counts. + expect(projectsStateToken({ projects: [{ status: "running" }] })).not.toBe( + projectsStateToken({ projects: [{ status: "stopped" }] }) + ); +}); + +/** A payload source whose content advances only when told to. */ +function source() { + let version = 0; + let builds = 0; + return { + build: async () => { + builds++; + return { generated_at: String(builds), projects: [version] }; + }, + advance: () => { + version++; + }, + builds: () => builds, + }; +} + +/** Manually driven refresh events, honouring deadline and abort like the cache. */ +function refreshes() { + const pending = new Set<() => void>(); + return { + nextRefresh: (opts: { + readonly signal: AbortSignal; + readonly deadlineMs: number; + }) => + new Promise((resolve) => { + const wake = () => { + clearTimeout(timer); + pending.delete(wake); + resolve(); + }; + const timer = setTimeout( + wake, + Math.max(0, opts.deadlineMs - Date.now()) + ); + opts.signal.addEventListener("abort", wake, { once: true }); + pending.add(wake); + }), + fire: () => { + for (const wake of [...pending]) { + wake(); + } + }, + }; +} + +const neverAborted = () => new AbortController().signal; + +test("without wait, or with a stale token, the answer is immediate", async () => { + const data = source(); + const events = refreshes(); + const plain = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "none" }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + expect(plain.state_token).toBe(projectsStateToken(plain)); + const stale = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "wait", token: TOKEN, timeoutMs: 55_000 }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + expect(stale.state_token).toBe(plain.state_token); + expect(data.builds()).toBe(2); +}); + +test("a current token waits for a refresh that changes the content", async () => { + const data = source(); + const events = refreshes(); + const first = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "none" }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + let settled = false; + const waiting = resolveProjectsPayload({ + build: data.build, + wait: { kind: "wait", token: first.state_token, timeoutMs: 55_000 }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }).then((result) => { + settled = true; + return result; + }); + await Bun.sleep(5); + expect(projectsWaiterCount()).toBe(1); + // A refresh with identical content keeps waiting. + events.fire(); + await Bun.sleep(5); + expect(settled).toBe(false); + data.advance(); + events.fire(); + const changed = await waiting; + expect(changed.projects).toEqual([1]); + expect(changed.state_token).not.toBe(first.state_token); + expect(projectsWaiterCount()).toBe(0); +}); + +test("a timeout answers with a fresh build; an abort stops waiting", async () => { + const data = source(); + const events = refreshes(); + const first = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "none" }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + // Content changes without any refresh (as a registry edit does). + data.advance(); + const timedOut = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "wait", token: first.state_token, timeoutMs: 1000 }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + // The first build already differed, so it answered at once. + expect(timedOut.projects).toEqual([1]); + + const same = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "none" }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + const started = Date.now(); + const unchanged = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "wait", token: same.state_token, timeoutMs: 1000 }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + expect(Date.now() - started).toBeGreaterThanOrEqual(900); + expect(unchanged.state_token).toBe(same.state_token); + + const controller = new AbortController(); + const aborted = resolveProjectsPayload({ + build: data.build, + wait: { kind: "wait", token: same.state_token, timeoutMs: 55_000 }, + nextRefresh: events.nextRefresh, + signal: controller.signal, + }); + await Bun.sleep(5); + const buildsBefore = data.builds(); + controller.abort(); + await aborted; + expect(data.builds()).toBe(buildsBefore); + expect(projectsWaiterCount()).toBe(0); +}); + +test("past the waiter cap a matching request is answered at once", async () => { + const data = source(); + const events = refreshes(); + const first = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "none" }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + const controller = new AbortController(); + const held = Array.from({ length: PROJECTS_WAIT_MAX_WAITERS }, () => + resolveProjectsPayload({ + build: data.build, + wait: { kind: "wait", token: first.state_token, timeoutMs: 55_000 }, + nextRefresh: events.nextRefresh, + signal: controller.signal, + }) + ); + await Bun.sleep(5); + expect(projectsWaiterCount()).toBe(PROJECTS_WAIT_MAX_WAITERS); + const extra = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "wait", token: first.state_token, timeoutMs: 55_000 }, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + expect(extra.state_token).toBe(first.state_token); + controller.abort(); + await Promise.all(held); + expect(projectsWaiterCount()).toBe(0); +}); diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 77f73fae2..5e2beed2f 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -961,3 +961,38 @@ test("an event refresh retains known job completion and publishes a subsequent c false, ]); }); + +test("nextRefresh wakes on a completed refresh, its deadline or an abort", async () => { + const cache = createRuntimeCache({}); + const never = new AbortController().signal; + + let woke = false; + const waiting = cache + .nextRefresh({ signal: never, deadlineMs: Date.now() + 60_000 }) + .then(() => { + woke = true; + }); + await Bun.sleep(5); + expect(woke).toBe(false); + await cache.refresh({ reason: "test" }); + await waiting; + expect(woke).toBe(true); + + const started = Date.now(); + await cache.nextRefresh({ signal: never, deadlineMs: Date.now() + 20 }); + expect(Date.now() - started).toBeLessThan(5000); + + const controller = new AbortController(); + const aborted = cache.nextRefresh({ + signal: controller.signal, + deadlineMs: Date.now() + 60_000, + }); + controller.abort(); + await aborted; + const already = new AbortController(); + already.abort(); + await cache.nextRefresh({ + signal: already.signal, + deadlineMs: Date.now() + 60_000, + }); +}); From 7c1359fc9afb468661b90f9043ec35a8225f4da0 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:36:48 +0000 Subject: [PATCH 3/5] fix(daemon): do not miss a refresh that lands during a projects build The waiter subscribed to refreshes only after building, so a refresh completing during the build was missed and the request could wait out its timeout with newer state cached. The cache now counts completed refreshes; the resolver reads the count before each build and nextRefresh returns at once when a newer refresh has completed. Addresses the persistent review. Co-Authored-By: Claude Opus 5.5 --- src/daemon/projects-wait.ts | 14 ++++++++-- src/daemon/runtime-cache.ts | 20 +++++++++++--- src/daemon/server.ts | 1 + tests/projects-wait.test.ts | 54 ++++++++++++++++++++++++++++++++++++- tests/runtime-cache.test.ts | 25 +++++++++++++++-- 5 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/daemon/projects-wait.ts b/src/daemon/projects-wait.ts index 7c984c2ba..1a502caf2 100644 --- a/src/daemon/projects-wait.ts +++ b/src/daemon/projects-wait.ts @@ -110,8 +110,14 @@ let waiters = 0; export async function resolveProjectsPayload(opts: { readonly build: () => Promise; readonly wait: ProjectsWaitRequest; - /** Resolves after the next cache refresh, the deadline, or an abort. */ + /** Completed-refresh count, read before each build. */ + readonly refreshGeneration: () => number; + /** + * Resolves once a refresh newer than `after` has completed (at once if one + * already has), at the deadline, or on abort. + */ readonly nextRefresh: (opts: { + readonly after: number; readonly signal: AbortSignal; readonly deadlineMs: number; }) => Promise; @@ -119,7 +125,11 @@ export async function resolveProjectsPayload(opts: { readonly now?: () => number; }): Promise { const now = opts.now ?? Date.now; + // The generation is read before each build, so a refresh that completes + // while building wakes the next wait at once instead of being missed. + let seen = opts.refreshGeneration(); const tokenOf = async () => { + seen = opts.refreshGeneration(); const payload = await opts.build(); return { payload, token: projectsStateToken(payload) }; }; @@ -135,7 +145,7 @@ export async function resolveProjectsPayload(opts: { waiters++; try { while (!opts.signal.aborted && now() < deadlineMs) { - await opts.nextRefresh({ signal: opts.signal, deadlineMs }); + await opts.nextRefresh({ after: seen, signal: opts.signal, deadlineMs }); if (opts.signal.aborted || now() >= deadlineMs) { break; } diff --git a/src/daemon/runtime-cache.ts b/src/daemon/runtime-cache.ts index 40023901a..af1d7b09d 100644 --- a/src/daemon/runtime-cache.ts +++ b/src/daemon/runtime-cache.ts @@ -123,11 +123,15 @@ export interface RuntimeCache { }): PsPayload; getSnapshot(): RuntimeSnapshot | null; getDiagnostics(): RuntimeCacheDiagnostics; + /** Count of completed refreshes; read it before building from the cache. */ + refreshGeneration(): number; /** - * Resolves after the next completed refresh, at `deadlineMs`, or when - * `signal` aborts, whichever is first. Never rejects. + * Resolves once a refresh newer than generation `after` has completed (at + * once if one already has), at `deadlineMs`, or when `signal` aborts, + * whichever is first. Never rejects. */ nextRefresh(opts: { + readonly after: number; readonly signal: AbortSignal; readonly deadlineMs: number; }): Promise; @@ -171,6 +175,7 @@ export function createRuntimeCache(opts: { let lastRefreshPhasesMs: Readonly> = {}; let snapshot: RuntimeSnapshot | null = null; const refreshWaiters = new Set<() => void>(); + let generation = 0; let refreshTask: Promise | null = null; let pendingRefresh: QueuedRefresh | null = null; let lastRefreshDurationMs: number | null = null; @@ -290,6 +295,7 @@ export function createRuntimeCache(opts: { health = refreshed.health; snapshot = nextSnapshot; opts.onRefresh?.(nextSnapshot); + generation++; for (const wake of [...refreshWaiters]) { wake(); } @@ -478,7 +484,11 @@ export function createRuntimeCache(opts: { } } - const nextRefresh: RuntimeCache["nextRefresh"] = ({ signal, deadlineMs }) => + const nextRefresh: RuntimeCache["nextRefresh"] = ({ + after, + signal, + deadlineMs, + }) => new Promise((resolve) => { const wake = () => { clearTimeout(timer); @@ -487,7 +497,8 @@ export function createRuntimeCache(opts: { resolve(); }; const timer = setTimeout(wake, Math.max(0, deadlineMs - Date.now())); - if (signal.aborted) { + // A refresh that completed while the caller was building counts. + if (signal.aborted || generation > after) { wake(); return; } @@ -500,6 +511,7 @@ export function createRuntimeCache(opts: { getProjectsPayload, getPsPayload, nextRefresh, + refreshGeneration: () => generation, getSnapshot: () => snapshot, getDiagnostics: () => { const inspect = getRuntimeInspectCacheDiagnostics({ diff --git a/src/daemon/server.ts b/src/daemon/server.ts index a159850f2..ed4832cad 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -518,6 +518,7 @@ async function handleRequest({ profile, }), wait, + refreshGeneration: cache.refreshGeneration, nextRefresh: cache.nextRefresh, signal: req.signal, }); diff --git a/tests/projects-wait.test.ts b/tests/projects-wait.test.ts index c0236533b..48361349b 100644 --- a/tests/projects-wait.test.ts +++ b/tests/projects-wait.test.ts @@ -82,11 +82,14 @@ function source() { }; } -/** Manually driven refresh events, honouring deadline and abort like the cache. */ +/** Manually driven refreshes with the cache's generation and wake rules. */ function refreshes() { const pending = new Set<() => void>(); + let generation = 0; return { + refreshGeneration: () => generation, nextRefresh: (opts: { + readonly after: number; readonly signal: AbortSignal; readonly deadlineMs: number; }) => @@ -100,10 +103,15 @@ function refreshes() { wake, Math.max(0, opts.deadlineMs - Date.now()) ); + if (opts.signal.aborted || generation > opts.after) { + wake(); + return; + } opts.signal.addEventListener("abort", wake, { once: true }); pending.add(wake); }), fire: () => { + generation++; for (const wake of [...pending]) { wake(); } @@ -119,6 +127,7 @@ test("without wait, or with a stale token, the answer is immediate", async () => const plain = await resolveProjectsPayload({ build: data.build, wait: { kind: "none" }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }); @@ -126,6 +135,7 @@ test("without wait, or with a stale token, the answer is immediate", async () => const stale = await resolveProjectsPayload({ build: data.build, wait: { kind: "wait", token: TOKEN, timeoutMs: 55_000 }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }); @@ -139,6 +149,7 @@ test("a current token waits for a refresh that changes the content", async () => const first = await resolveProjectsPayload({ build: data.build, wait: { kind: "none" }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }); @@ -146,6 +157,7 @@ test("a current token waits for a refresh that changes the content", async () => const waiting = resolveProjectsPayload({ build: data.build, wait: { kind: "wait", token: first.state_token, timeoutMs: 55_000 }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }).then((result) => { @@ -172,6 +184,7 @@ test("a timeout answers with a fresh build; an abort stops waiting", async () => const first = await resolveProjectsPayload({ build: data.build, wait: { kind: "none" }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }); @@ -180,6 +193,7 @@ test("a timeout answers with a fresh build; an abort stops waiting", async () => const timedOut = await resolveProjectsPayload({ build: data.build, wait: { kind: "wait", token: first.state_token, timeoutMs: 1000 }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }); @@ -189,6 +203,7 @@ test("a timeout answers with a fresh build; an abort stops waiting", async () => const same = await resolveProjectsPayload({ build: data.build, wait: { kind: "none" }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }); @@ -196,6 +211,7 @@ test("a timeout answers with a fresh build; an abort stops waiting", async () => const unchanged = await resolveProjectsPayload({ build: data.build, wait: { kind: "wait", token: same.state_token, timeoutMs: 1000 }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }); @@ -206,6 +222,7 @@ test("a timeout answers with a fresh build; an abort stops waiting", async () => const aborted = resolveProjectsPayload({ build: data.build, wait: { kind: "wait", token: same.state_token, timeoutMs: 55_000 }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: controller.signal, }); @@ -223,6 +240,7 @@ test("past the waiter cap a matching request is answered at once", async () => { const first = await resolveProjectsPayload({ build: data.build, wait: { kind: "none" }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }); @@ -231,6 +249,7 @@ test("past the waiter cap a matching request is answered at once", async () => { resolveProjectsPayload({ build: data.build, wait: { kind: "wait", token: first.state_token, timeoutMs: 55_000 }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: controller.signal, }) @@ -240,6 +259,7 @@ test("past the waiter cap a matching request is answered at once", async () => { const extra = await resolveProjectsPayload({ build: data.build, wait: { kind: "wait", token: first.state_token, timeoutMs: 55_000 }, + refreshGeneration: events.refreshGeneration, nextRefresh: events.nextRefresh, signal: neverAborted(), }); @@ -248,3 +268,35 @@ test("past the waiter cap a matching request is answered at once", async () => { await Promise.all(held); expect(projectsWaiterCount()).toBe(0); }); + +test("a refresh that completes during the build is not missed", async () => { + const data = source(); + const events = refreshes(); + const first = await resolveProjectsPayload({ + build: data.build, + wait: { kind: "none" }, + refreshGeneration: events.refreshGeneration, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + let builds = 0; + const started = Date.now(); + const result = await resolveProjectsPayload({ + // The first build reads the old state; a refresh then lands before the + // waiter subscribes (as when Docker events arrive during project views). + build: async () => { + const payload = await data.build(); + if (builds++ === 0) { + data.advance(); + events.fire(); + } + return payload; + }, + wait: { kind: "wait", token: first.state_token, timeoutMs: 55_000 }, + refreshGeneration: events.refreshGeneration, + nextRefresh: events.nextRefresh, + signal: neverAborted(), + }); + expect(result.projects).toEqual([1]); + expect(Date.now() - started).toBeLessThan(5000); +}); diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 5e2beed2f..3bfeefe62 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -967,8 +967,13 @@ test("nextRefresh wakes on a completed refresh, its deadline or an abort", async const never = new AbortController().signal; let woke = false; + const before = cache.refreshGeneration(); const waiting = cache - .nextRefresh({ signal: never, deadlineMs: Date.now() + 60_000 }) + .nextRefresh({ + after: before, + signal: never, + deadlineMs: Date.now() + 60_000, + }) .then(() => { woke = true; }); @@ -977,13 +982,28 @@ test("nextRefresh wakes on a completed refresh, its deadline or an abort", async await cache.refresh({ reason: "test" }); await waiting; expect(woke).toBe(true); + expect(cache.refreshGeneration()).toBe(before + 1); + + // A refresh already newer than `after` resolves at once. + const quick = Date.now(); + await cache.nextRefresh({ + after: before, + signal: never, + deadlineMs: Date.now() + 60_000, + }); + expect(Date.now() - quick).toBeLessThan(1000); const started = Date.now(); - await cache.nextRefresh({ signal: never, deadlineMs: Date.now() + 20 }); + await cache.nextRefresh({ + after: cache.refreshGeneration(), + signal: never, + deadlineMs: Date.now() + 20, + }); expect(Date.now() - started).toBeLessThan(5000); const controller = new AbortController(); const aborted = cache.nextRefresh({ + after: cache.refreshGeneration(), signal: controller.signal, deadlineMs: Date.now() + 60_000, }); @@ -992,6 +1012,7 @@ test("nextRefresh wakes on a completed refresh, its deadline or an abort", async const already = new AbortController(); already.abort(); await cache.nextRefresh({ + after: cache.refreshGeneration(), signal: already.signal, deadlineMs: Date.now() + 60_000, }); From a50b4d739fd92684ff16e049c736c6140f93da79 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:43:38 +0000 Subject: [PATCH 4/5] test(daemon): allow the synthetic long-poll token in secret scanning The fixed 32-hex test token in tests/projects-wait.test.ts matches gitleaks' generic-api-key rule. It is a synthetic state digest, not a credential; allow that exact line in that file, like the existing relay test token. History is scanned in full, so the line itself stays. Co-Authored-By: Claude Opus 5.5 --- .gitleaks.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index 2aa6c1658..9cbb8bd20 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -23,6 +23,13 @@ paths = ['''^packages/runtime-core/tests/stream_relay_contract\.rs$'''] regexTarget = "line" regexes = ['''^\s*let token = "0123456789abcdef0123456789abcdef";\s*$'''] +[[rules.allowlists]] +description = "Projects long-poll test uses a fixed synthetic hexadecimal state token" +condition = "AND" +paths = ['''^tests/projects-wait\.test\.ts$'''] +regexTarget = "line" +regexes = ['''^\s*const TOKEN = "0123456789abcdef0123456789abcdef";\s*$'''] + [[rules.allowlists]] description = "MCP inspect-tool tests use a fixed synthetic canary to prove values are never echoed" condition = "AND" From 3323a32470dedc564bcd6bde91530af0b00b9400 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:20:39 +0000 Subject: [PATCH 5/5] fix(daemon): keep container health and exit code in the state token The token dropped Docker's whole container status to ignore elapsed time, so a branch container going from healthy to unhealthy (health is carried only in that text for branches) could not wake a long-poll. Keep the status's leading word and parenthesized parts ("Up (healthy)", "Exited (0)") and drop only the elapsed time. Addresses the Codex review. Co-Authored-By: Claude Opus 5.5 --- docs/reference/project-state.md | 8 +++++--- src/daemon/projects-wait.ts | 22 +++++++++++++++++----- tests/projects-wait.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/docs/reference/project-state.md b/docs/reference/project-state.md index 747b9372f..b36d56b59 100644 --- a/docs/reference/project-state.md +++ b/docs/reference/project-state.md @@ -94,9 +94,11 @@ native runtime selection is configured; those reads do not start a VM or graph. hackd's `GET /v1/projects` response also carries `state_token`, an opaque 32-character digest of its content. It leaves out values that change without a state change: `generated_at`, `profiling`, `runtime_checked_at`, -`runtime_last_ok_at` and Docker's humanized container `status` text (container -`state` and the service state and health fields above still count). The CLI's -`hack projects --json` output does not include it. +`runtime_last_ok_at` and the elapsed time in Docker's container `status` text. +The status's leading word, health and exit code still count ("Up 12 seconds +(healthy)" is digested as "Up (healthy)"), as do container `state` and the +service state and health fields above. The CLI's `hack projects --json` output +does not include it. A client can send the token back to wait for a change: diff --git a/src/daemon/projects-wait.ts b/src/daemon/projects-wait.ts index 1a502caf2..edc95efd4 100644 --- a/src/daemon/projects-wait.ts +++ b/src/daemon/projects-wait.ts @@ -33,11 +33,21 @@ const VOLATILE_FIELDS = new Set([ "runtime_last_ok_at", ]); +const PARENTHESIZED = /\([^)]*\)/g; +const WHITESPACE = /\s+/; + /** - * Docker's container `status` is humanized text ("Up 12 seconds") that changes - * on every refresh. The container's `state` and the shared service state and - * health fields still count. + * Docker's container `status` mixes elapsed time, which changes on every + * refresh, with health and exit code, which are state: "Up 12 seconds + * (healthy)", "Exited (0) 3 minutes ago". Keep the leading word and every + * parenthesized part ("Up (healthy)", "Exited (0)"), so a health change still + * changes the token. Branch containers carry health only in this text. */ +export function stableContainerStatus(status: string): string { + const head = status.trim().split(WHITESPACE, 1)[0] ?? ""; + return [head, ...(status.match(PARENTHESIZED) ?? [])].join(" "); +} + function stableValue(_key: string, value: unknown): unknown { if ( value !== null && @@ -47,8 +57,10 @@ function stableValue(_key: string, value: unknown): unknown { "state" in value && "status" in value ) { - const { status: _status, ...rest } = value as Record; - return rest; + const container = value as Record; + return typeof container.status === "string" + ? { ...container, status: stableContainerStatus(container.status) } + : container; } return value; } diff --git a/tests/projects-wait.test.ts b/tests/projects-wait.test.ts index 48361349b..11768c47d 100644 --- a/tests/projects-wait.test.ts +++ b/tests/projects-wait.test.ts @@ -6,6 +6,7 @@ import { projectsStateToken, projectsWaiterCount, resolveProjectsPayload, + stableContainerStatus, } from "../src/daemon/projects-wait.ts"; const TOKEN = "0123456789abcdef0123456789abcdef"; @@ -37,6 +38,17 @@ test("wait parameters are closed: a 32-hex token and a bounded timeout", () => { } }); +test("container status keeps its word, health and exit code, not elapsed time", () => { + expect(stableContainerStatus("Up 12 seconds (healthy)")).toBe("Up (healthy)"); + expect(stableContainerStatus("Up About an hour (health: starting)")).toBe( + "Up (health: starting)" + ); + expect(stableContainerStatus("Exited (137) 2 days ago")).toBe("Exited (137)"); + expect(stableContainerStatus("Up 2 hours (Paused)")).toBe("Up (Paused)"); + expect(stableContainerStatus("Created")).toBe("Created"); + expect(stableContainerStatus("")).toBe(""); +}); + test("the token ignores per-response fields and changes with content", () => { const a = { generated_at: "t1", profiling: { x: 1 }, projects: [1] }; const b = { generated_at: "t2", projects: [1] }; @@ -60,6 +72,19 @@ test("the token ignores per-response fields and changes with content", () => { expect(projectsStateToken(container("Up 1 second"))).not.toBe( projectsStateToken(container("Exited (0)", "exited")) ); + // Health and exit code in the status text are state (a branch container + // carries health nowhere else); only the elapsed time is ignored. + expect(projectsStateToken(container("Up 5 seconds (healthy)"))).toBe( + projectsStateToken(container("Up About a minute (healthy)")) + ); + expect(projectsStateToken(container("Up 5 seconds (healthy)"))).not.toBe( + projectsStateToken(container("Up 5 seconds (unhealthy)")) + ); + expect( + projectsStateToken(container("Exited (0) 3 minutes ago", "exited")) + ).not.toBe( + projectsStateToken(container("Exited (1) 3 minutes ago", "exited")) + ); // A `status` field elsewhere (not a container entry) still counts. expect(projectsStateToken({ projects: [{ status: "running" }] })).not.toBe( projectsStateToken({ projects: [{ status: "stopped" }] })