From 8b8be792112fa0b8f9f1a7a74ea9987cce919e43 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:21:46 +0000 Subject: [PATCH 01/25] feat(projects): add registry-preserving observe-only JSON listing --- apps/desktop/DESIGN.md | 2 +- docs/cli.md | 18 ++- docs/reference/cli.md | 1 + docs/reference/projects-observe.mmd | 13 ++ docs/reference/projects-observe.svg | 66 ++++++++ docs/versions/v5/getting-started.md | 5 + src/commands/projects.ts | 36 ++++- src/lib/project-observation.ts | 129 ++++++++++++++++ tests/projects-observe.test.ts | 232 ++++++++++++++++++++++++++++ 9 files changed, 495 insertions(+), 7 deletions(-) create mode 100644 docs/reference/projects-observe.mmd create mode 100644 docs/reference/projects-observe.svg create mode 100644 src/lib/project-observation.ts create mode 100644 tests/projects-observe.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 1f499e2e6..bb1b26cae 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -390,4 +390,4 @@ Each is small enough for one PR. 21. **R15** `--json` for `hack daemon start|stop|restart|install`. 22. **R16** Trust status: a doctor check that reports whether the system trust store (and NSS on Linux) trusts the local CA, or `unknown`. 23. **R17** `kind: safe | admin | manual` on #790's findings and plan steps, so the app knows which to offer as Run. -24. **R18** Observe-only project listing, including registered native projects: no auto-registration, `lastSeenAt` updates or registry writes. Until it exists, the desktop refuses CLI listing fallback and reports Unknown when a matching hackd snapshot is unavailable. The daemon-off screen names `hack daemon start` and offers Start hackd as its first recovery action. +24. **R18 — exists** `hack projects --json --observe` (also `--summary`, `--project`, `--all`): no auto-registration, `lastSeenAt` update, registry write or daemon/engine start. Reads Docker directly and includes registered native rows. Rows add the R2 field vocabulary with null/unknown for unavailable evidence; native state still comes from native `ps` until R2 integrates that observer. Always-on fields follow their persistence owner. Desktop fallback can use this command when a matching hackd snapshot is unavailable; adopting it in the app is separate work. The daemon-off screen still offers Start hackd. diff --git a/docs/cli.md b/docs/cli.md index 7df877ac1..0d3f69167 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,6 +16,7 @@ terminal). - `hack logs` — tail logs (compose by default; Loki via `--loki`/`--query`) - `hack ps` / `hack status` — project status - `hack projects` — registry + running instances; `hack projects prune --project ` safely scopes stale registry/container cleanup to one project family (omit `--project` only for an intentional machine-wide prune) +- `hack projects --json --observe` — read-only listing: no registration, `lastSeenAt` update, registry creation or write, and no daemon, Docker or VM start. Works with `--summary`, `--project` and `--all`; rejects `--meta`. Ordinary listing retains discovery writes for compatibility. - `hack env` — env values and local secrets - `hack host exec` / `hack host shell` — host commands/shells with Hack-resolved env injected - `hack run [cmd...]` — one-off command in a fresh service container @@ -125,7 +126,22 @@ does not refresh the global project registry. A held registration lock therefore does not block their resolution or require a retry just to update last-seen metadata. Use a checkout/path directly, or an existing registry entry with `--project`; `hack init`, project lifecycle registration and `hack projects` discovery maintain -registrations. Optional `hack projects` discovery coalesces unchanged observations +registrations. `--json --observe` skips registration entirely. Observe reads +Docker directly and never contacts hackd, including when a daemon is available. +It returns the normal listing fields plus `observe: true`, and each full or +summary row adds `backend`, `state`, `state_reason`, `started_at` and `services`. +Each service has `name`, `state`, `ready`, `completed`, `health`, `exit_code` and +`started_at`. Missing evidence is null or unknown: Docker activity is not a +readiness/completion guarantee, and an exited container without an observed +exit code is unknown. Registered native rows remain visible with +`backend: native`, `state: unknown`, and `state_reason: native-observer-required`; +use the native `ps` observer for their current state. R2 will complete the shared +backend-aware projection. Always-on fields will be supplied when their owner +exists; absence of `autostart` is not an off-state claim. + +![Observe-only listing](reference/projects-observe.svg) ([source](reference/projects-observe.mmd)) + +Ordinary listing coalesces unchanged observations for one minute and defers refresh when the registry lock is busy. Stale optional upserts check before Git and configuration discovery, then again before staging an owner, so an observed busy lock avoids work that would be discarded. These diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4ef9c1a37..1d6c057bf 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1526,6 +1526,7 @@ hack projects [options] | `--summary` | Return compact project counts with --json; load details with --project | | `--timings` | Write numeric listing phase timings to stderr (requires --json) | | `--no-daemon` | Read runtime directly instead of the daemon cache | +| `--observe` | Read projects without registry writes or starting hackd, Docker or a VM (requires --json) | | `--meta` | Include git/worktree/session/env metadata (implies --details) | | `--include-global` | Include global infra projects under ~/.hack (e.g. logging stack) | | `--all` | Include unregistered docker compose projects (best-effort) | diff --git a/docs/reference/projects-observe.mmd b/docs/reference/projects-observe.mmd new file mode 100644 index 000000000..34cf6ced6 --- /dev/null +++ b/docs/reference/projects-observe.mmd @@ -0,0 +1,13 @@ +--- +title: Observe projects without registration writes +--- +flowchart TD + CLI[projects --json --observe] --> Registry[Read registry] + CLI --> Runtime[Read Docker ps and inspect] + Registry --> Views[Build registered project views] + Runtime --> Views + Views --> Native{Native inputs?} + Native -->|Yes| Unknown[Keep row, native observer required] + Native -->|No| Project[Project activity or unknown] + Unknown --> JSON[Full or summary JSON] + Project --> JSON diff --git a/docs/reference/projects-observe.svg b/docs/reference/projects-observe.svg new file mode 100644 index 000000000..d4db66334 --- /dev/null +++ b/docs/reference/projects-observe.svg @@ -0,0 +1,66 @@ +Observe projects without registration writesYesNoprojects --json --observeRead registryRead Docker ps andinspectBuild registered projectviewsNative inputs?Keep row, native observerrequiredProject activity or unknownFull or summary JSON \ No newline at end of file diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 52efb8442..7b949a8ca 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -7,6 +7,11 @@ Use the terminal from Install: `hack` selects v5 and `HACK_RUNTIME_BACKEND=compose` is exported. Docker must be running. An existing v4 project should follow the upgrade page before these steps. +For a read-only inventory, use `hack projects --json --observe`. It lists +registered projects even when hackd is off, preserves the registry, and starts +no services. Unknown runtime state stays unknown; native projects use their +native `ps` observer. See the [listing contract](../cli.md). + ```mermaid --- title: First Compose run diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 72f0263a8..7c2892e1a 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -43,6 +43,7 @@ import { AmbiguousProjectNameError, normalizeProjectName, } from "../lib/project-name.ts"; +import { projectObservation } from "../lib/project-observation.ts"; import { assertPruneRuntimeOwnership } from "../lib/project-prune-ownership.ts"; import { findMissingRegistryEntries, @@ -127,6 +128,13 @@ const optNoDaemon = defineOption({ long: "--no-daemon", description: "Read runtime directly instead of the daemon cache", } as const); +const optObserve = defineOption({ + name: "observe", + type: "boolean", + long: "--observe", + description: + "Read projects without registry writes or starting hackd, Docker or a VM (requires --json)", +} as const); const optDryRun = defineOption({ name: "dryRun", type: "boolean", @@ -141,6 +149,7 @@ const options = [ optSummary, optTimings, optNoDaemon, + optObserve, optMeta, optIncludeGlobal, optAll, @@ -195,6 +204,9 @@ const handleProjects: CommandHandlerFor = async ({ ctx, args, }): Promise => { + if (args.options.observe && (!args.options.json || args.options.meta)) { + throw new CliUsageError("--observe requires --json and cannot use --meta."); + } if ((args.options.summary || args.options.timings) && !args.options.json) { throw new CliUsageError("--summary and --timings require --json."); } @@ -206,15 +218,18 @@ const handleProjects: CommandHandlerFor = async ({ const profiler = createOperationTimings(); const started = performance.now(); const selector = parseProjectFilter(args.options.project); - await profiler.measure("cwd_registration_ms", () => - touchCwdProjectRegistration({ cwd: ctx.cwd }) - ); + if (!args.options.observe) { + await profiler.measure("cwd_registration_ms", () => + touchCwdProjectRegistration({ cwd: ctx.cwd }) + ); + } return await runProjects({ profiler, started, summary: args.options.summary === true, timings: args.options.timings === true, - noDaemon: args.options.noDaemon === true, + noDaemon: args.options.noDaemon === true || args.options.observe === true, + observe: args.options.observe === true, selector, includeGlobal: args.options.includeGlobal === true, includeUnregistered: args.options.all === true, @@ -561,6 +576,7 @@ async function runProjects(opts: { readonly summary?: boolean; readonly timings?: boolean; readonly noDaemon?: boolean; + readonly observe?: boolean; }): Promise { const profiler = opts.profiler ?? createOperationTimings(); const started = opts.started ?? performance.now(); @@ -586,7 +602,7 @@ async function runProjects(opts: { profiler, }); - if (runtime.ok) { + if (runtime.ok && !opts.observe) { await profiler.measure("auto_register_ms", () => autoRegisterRuntimeHackProjects({ runtime: runtime.runtime }) ); @@ -609,6 +625,7 @@ async function runProjects(opts: { : new Map(); if (opts.json) { outputProjectsJson({ + observe: opts.observe === true, summary: opts.summary === true, profiler, started, @@ -756,6 +773,7 @@ type RuntimeRecoveryNotice = { }; function outputProjectsJson(opts: { + readonly observe: boolean; readonly summary: boolean; readonly profiler: OperationTimings; readonly started: number; @@ -773,6 +791,7 @@ function outputProjectsJson(opts: { }): void { const runtimeMeta = formatRuntimeMeta({ runtime: opts.runtime }); const payload = { + ...(opts.observe ? { observe: true } : {}), ...(opts.summary ? { detail_level: "summary" } : {}), generated_at: new Date().toISOString(), filter: opts.filterLabel, @@ -797,6 +816,13 @@ function outputProjectsJson(opts: { ...(opts.summary ? serializeProjectSummary(view) : serializeProjectView(view)), + ...(opts.observe + ? projectObservation({ + view, + runtimeOk: opts.runtime.ok, + nativeSelected: process.env.HACK_RUNTIME_BACKEND === "native", + }) + : {}), ...(opts.includeMeta ? { meta: opts.metaByName.get(view.name) ?? null } : {}), diff --git a/src/lib/project-observation.ts b/src/lib/project-observation.ts new file mode 100644 index 000000000..494e29425 --- /dev/null +++ b/src/lib/project-observation.ts @@ -0,0 +1,129 @@ +import type { ProjectView } from "./project-views.ts"; +import type { RuntimeContainer } from "./runtime-projects.ts"; + +/** Shared vocabulary for the desktop listing; unknown evidence is never a stopped claim. */ +export type ObservedProjectState = + | "running" + | "degraded" + | "starting" + | "stopping" + | "stopped" + | "failed" + | "unknown"; + +export type ObservedService = { + readonly name: string; + readonly state: ObservedProjectState; + readonly ready: boolean | null; + readonly completed: boolean | null; + readonly health: string | null; + readonly exit_code: number | null; + readonly started_at: string | null; +}; + +export type ProjectObservation = { + readonly backend: "compose" | "native" | "unknown"; + readonly state: ObservedProjectState; + readonly state_reason: + | "native-observer-required" + | "observation-unavailable" + | null; + readonly started_at: string | null; + readonly services: readonly ObservedService[]; +}; + +/** + * Read-only listing projection. Docker's listing does not carry authenticated + * exit codes, readiness roles or start times: retain null until the R2 owner + * supplies them. Native inputs need their own observer, never Docker inference. + */ +export function projectObservation(input: { + readonly view: ProjectView; + readonly runtimeOk: boolean; + readonly nativeSelected: boolean; +}): ProjectObservation { + const { view } = input; + const native = + (input.nativeSelected && + view.kind === "registered" && + !view.inputDiagnostic) || + view.inputDiagnostic?.code === "E_NATIVE_PROJECT_UNSUPPORTED"; + let backend: ProjectObservation["backend"] = "compose"; + if (view.inputDiagnostic || view.status === "missing") { + backend = "unknown"; + } + if (native) { + backend = "native"; + } + const observable = backend === "compose" && input.runtimeOk; + let reason: ProjectObservation["state_reason"] = observable + ? null + : "observation-unavailable"; + if (native) { + reason = "native-observer-required"; + } + const names = new Set([ + ...(view.definedServices ?? []), + ...(observable ? (view.runtime?.services.keys() ?? []) : []), + ]); + const services: ObservedService[] = [...names].sort().map((name) => ({ + name, + state: observable + ? serviceState(view.runtime?.services.get(name)?.containers ?? []) + : "unknown", + ready: null, + completed: null, + health: null, + exit_code: null, + started_at: null, + })); + return { + backend, + state: observable ? projectState(services) : "unknown", + state_reason: reason, + started_at: null, + services, + }; +} + +function serviceState( + containers: readonly RuntimeContainer[] +): ObservedProjectState { + if (containers.length === 0) { + return "stopped"; + } + if (containers.every((container) => container.state === "running")) { + return "running"; + } + if (containers.some((container) => container.state === "running")) { + return "degraded"; + } + if (containers.some((container) => container.state === "restarting")) { + return "starting"; + } + if (containers.every((container) => container.state === "created")) { + return "stopped"; + } + return "unknown"; +} + +function projectState( + services: readonly ObservedService[] +): ObservedProjectState { + if (services.length === 0) { + return "unknown"; + } + if (services.every((service) => service.state === "running")) { + return "running"; + } + if (services.some((service) => service.state === "unknown")) { + return "unknown"; + } + if (services.some((service) => service.state === "running")) { + return "degraded"; + } + if (services.some((service) => service.state === "starting")) { + return "starting"; + } + return "stopped"; +} diff --git a/tests/projects-observe.test.ts b/tests/projects-observe.test.ts new file mode 100644 index 000000000..f52e95605 --- /dev/null +++ b/tests/projects-observe.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +let root = ""; +let state = ""; +let bin = ""; +let registry = ""; +let cwd = ""; +const entrypoint = resolve(import.meta.dir, "..", "index.ts"); + +async function project(name: string, native = false) { + const repoRoot = join(root, name); + const projectDir = join(repoRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + if (native) { + await writeFile(join(projectDir, "hack.project.json"), "{}\n"); + } else { + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ name }) + ); + await writeFile( + join(projectDir, "docker-compose.yml"), + "services:\n web:\n image: example/web:1\n" + ); + } + return { + id: `id-${name}`, + name, + repoRoot, + projectDir, + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00.000Z", + lastSeenAt: "2026-01-01T00:00:00.000Z", + }; +} + +beforeEach(async () => { + root = await realpath( + await mkdtemp(join(tmpdir(), "hack-projects-observe-")) + ); + state = join(root, "state"); + bin = join(root, "bin"); + await mkdir(state); + await mkdir(bin); + registry = join(state, "projects.json"); + const compose = await project("compose-app"); + const native = await project("native-app", true); + const unregistered = await project("unregistered-app"); + cwd = compose.repoRoot; + await writeFile( + registry, + JSON.stringify({ version: 1, projects: [compose, native] }) + ); + await utimes(registry, new Date("2026-01-01"), new Date("2026-01-01")); + const docker = join(bin, "docker"); + await writeFile( + docker, + `#!${process.execPath}\nconst args=process.argv.slice(2);\nawait Bun.write(Bun.file(process.env.CALLS), (await Bun.file(process.env.CALLS).text().catch(()=>""))+JSON.stringify(args)+"\\n");\nif(args[0]==="ps") {\n if(process.env.OFFLINE==="1") process.exit(1);\n console.log(JSON.stringify({ID:"fixture-container",State:"running",Status:"Up",Names:"fixture-web",Labels:${JSON.stringify(`com.docker.compose.project=unregistered-app,com.docker.compose.service=web,com.docker.compose.project.working_dir=${unregistered.projectDir}`)}}));\n} else if(args[0]!=="inspect") process.exit(99);\n` + ); + await chmod(docker, 0o755); + for (const name of [ + "hack", + "open", + "systemctl", + "launchctl", + "native-runtime", + ]) { + await writeFile( + join(bin, name), + `#!${process.execPath}\nawait Bun.write(process.env.FORBIDDEN, "unexpected effect"); process.exit(99);\n` + ); + await chmod(join(bin, name), 0o755); + } +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function run(args: string[], extra: Record = {}) { + const child = Bun.spawn([process.execPath, entrypoint, "projects", ...args], { + cwd, + env: privateCliEnv({ + root, + env: { + PATH: bin, + HACK_HOME: state, + HACK_GLOBAL_CONFIG_PATH: join(state, "hack.config.json"), + CALLS: join(root, "calls"), + FORBIDDEN: join(root, "forbidden"), + ...extra, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { code, stdout, stderr }; +} + +test("observe full/summary/filter listings preserve registry bytes and mtime without starting services", async () => { + const before = await readFile(registry); + const time = (await stat(registry)).mtimeMs; + for (const flags of [ + [], + ["--summary"], + ["--project", "native-app"], + ["--all"], + ]) { + const result = await run(["--json", "--observe", ...flags]); + expect(result.code).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.observe).toBe(true); + const native = payload.projects.find( + (row: { name: string }) => row.name === "native-app" + ); + expect(native.backend).toBe("native"); + expect(native.state).toBe("unknown"); + expect(native.state_reason).toBe("native-observer-required"); + expect(native.started_at).toBeNull(); + expect(native.services).toEqual([]); + if (flags.includes("--all")) { + expect( + payload.projects.some( + (row: { name: string }) => row.name === "unregistered-app" + ) + ).toBe(true); + expect( + payload.projects.find( + (row: { name: string }) => row.name === "unregistered-app" + ).state + ).toBe("running"); + } + expect(await readFile(registry)).toEqual(before); + expect((await stat(registry)).mtimeMs).toBe(time); + } + const calls = (await readFile(join(root, "calls"), "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect( + calls.every((args: string[]) => ["ps", "inspect"].includes(args[0] ?? "")) + ).toBe(true); + expect(await stat(join(root, "forbidden")).catch(() => null)).toBeNull(); + expect(await stat(join(state, "daemon")).catch(() => null)).toBeNull(); +}); + +test("observe keeps native rows and unknown Compose services when Docker is unavailable", async () => { + const before = await readFile(registry); + const time = (await stat(registry)).mtimeMs; + const result = await run(["--json", "--observe"], { OFFLINE: "1" }); + expect(result.code).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.runtime_ok).toBe(false); + const compose = payload.projects.find( + (row: { name: string }) => row.name === "compose-app" + ); + expect(compose.backend).toBe("compose"); + expect(compose.state).toBe("unknown"); + expect(compose.services).toEqual([ + { + name: "web", + state: "unknown", + ready: null, + completed: null, + health: null, + exit_code: null, + started_at: null, + }, + ]); + expect(payload.projects).toHaveLength(2); + expect(await readFile(registry)).toEqual(before); + expect((await stat(registry)).mtimeMs).toBe(time); +}); + +test("observe never creates a missing registry or registers the current checkout", async () => { + await rm(registry); + const result = await run(["--json", "--observe"]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).projects).toEqual([]); + expect(await stat(registry).catch(() => null)).toBeNull(); +}); + +test("observe rejects text and metadata before touching registration", async () => { + const before = await readFile(registry); + const time = (await stat(registry)).mtimeMs; + for (const args of [["--observe"], ["--observe", "--json", "--meta"]]) { + expect((await run(args)).code).not.toBe(0); + expect(await readFile(registry)).toEqual(before); + expect((await stat(registry)).mtimeMs).toBe(time); + } +}); + +test("observe does not query an available daemon cache", async () => { + const daemonDir = join(state, "daemon"); + await mkdir(daemonDir); + let requests = 0; + const server = Bun.serve({ + unix: join(daemonDir, "hackd.sock"), + fetch() { + requests += 1; + return Response.json({ projects: [{ name: "stale-cache" }] }); + }, + }); + try { + const result = await run(["--json", "--observe"]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).projects).toHaveLength(2); + expect(requests).toBe(0); + } finally { + await server.stop(true); + } +}); From b0dcc4d9ba77488155381f65292d291ed07a46b2 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:27:46 +0000 Subject: [PATCH 02/25] fix(projects): require explicit engine contact during observation --- apps/desktop/DESIGN.md | 2 +- docs/cli.md | 14 +++-- docs/reference/cli.md | 3 +- docs/reference/projects-observe.mmd | 5 +- docs/reference/projects-observe.svg | 4 +- docs/versions/v5/getting-started.md | 3 +- src/commands/projects.ts | 54 +++++++++++++++--- tests/projects-observe.test.ts | 86 +++++++++++++++++++++++------ 8 files changed, 136 insertions(+), 35 deletions(-) diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index bb1b26cae..f1b9d39ed 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -390,4 +390,4 @@ Each is small enough for one PR. 21. **R15** `--json` for `hack daemon start|stop|restart|install`. 22. **R16** Trust status: a doctor check that reports whether the system trust store (and NSS on Linux) trusts the local CA, or `unknown`. 23. **R17** `kind: safe | admin | manual` on #790's findings and plan steps, so the app knows which to offer as Run. -24. **R18 — exists** `hack projects --json --observe` (also `--summary`, `--project`, `--all`): no auto-registration, `lastSeenAt` update, registry write or daemon/engine start. Reads Docker directly and includes registered native rows. Rows add the R2 field vocabulary with null/unknown for unavailable evidence; native state still comes from native `ps` until R2 integrates that observer. Always-on fields follow their persistence owner. Desktop fallback can use this command when a matching hackd snapshot is unavailable; adopting it in the app is separate work. The daemon-off screen still offers Start hackd. +24. **R18 — exists** `hack projects --json --observe` (also `--summary`, `--project`, `--all`): no auto-registration, `lastSeenAt` update, registry write, daemon contact or engine contact. Includes registered native rows. Compose state defaults to unknown; `--runtime` explicitly opts in to Docker reads that can start a socket-activated engine. Rows add the R2 field vocabulary with null/unknown for unavailable evidence; native state still comes from native `ps` until R2 integrates that observer. Always-on fields follow their persistence owner. Desktop fallback can use the default non-contact command when a matching hackd snapshot is unavailable; adopting it in the app is separate work. The daemon-off screen still offers Start hackd. diff --git a/docs/cli.md b/docs/cli.md index 0d3f69167..e94a39fd5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,7 +16,7 @@ terminal). - `hack logs` — tail logs (compose by default; Loki via `--loki`/`--query`) - `hack ps` / `hack status` — project status - `hack projects` — registry + running instances; `hack projects prune --project ` safely scopes stale registry/container cleanup to one project family (omit `--project` only for an intentional machine-wide prune) -- `hack projects --json --observe` — read-only listing: no registration, `lastSeenAt` update, registry creation or write, and no daemon, Docker or VM start. Works with `--summary`, `--project` and `--all`; rejects `--meta`. Ordinary listing retains discovery writes for compatibility. +- `hack projects --json --observe` — read-only listing: no registration, `lastSeenAt` update, registry creation or write, daemon contact or engine contact. Works with `--summary`, `--project` and `--all`; rejects `--meta`. Add `--runtime` to query Docker explicitly; socket contact can start an on-demand engine. Ordinary listing retains discovery writes for compatibility. - `hack env` — env values and local secrets - `hack host exec` / `hack host shell` — host commands/shells with Hack-resolved env injected - `hack run [cmd...]` — one-off command in a fresh service container @@ -126,9 +126,15 @@ does not refresh the global project registry. A held registration lock therefore does not block their resolution or require a retry just to update last-seen metadata. Use a checkout/path directly, or an existing registry entry with `--project`; `hack init`, project lifecycle registration and `hack projects` discovery maintain -registrations. `--json --observe` skips registration entirely. Observe reads -Docker directly and never contacts hackd, including when a daemon is available. -It returns the normal listing fields plus `observe: true`, and each full or +registrations. `--json --observe` skips registration and engine queries entirely. +Compose state is unknown by default. `--runtime` explicitly opts in to Docker +ps/inspect reads, which can start a socket-activated engine (for example a +systemd Docker socket or an on-demand macOS engine). Both modes bypass hackd, +including when a daemon is available. `--all` discovers runtime-only projects +only with `--runtime`. +Observe returns the normal listing fields plus `observe: true` and +`runtime_requested: false|true`. Without runtime queries, `runtime_error` and +`runtime_checked_at` are null rather than claiming Docker failed. Each full or summary row adds `backend`, `state`, `state_reason`, `started_at` and `services`. Each service has `name`, `state`, `ready`, `completed`, `health`, `exit_code` and `started_at`. Missing evidence is null or unknown: Docker activity is not a diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1d6c057bf..d70a41886 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1526,7 +1526,8 @@ hack projects [options] | `--summary` | Return compact project counts with --json; load details with --project | | `--timings` | Write numeric listing phase timings to stderr (requires --json) | | `--no-daemon` | Read runtime directly instead of the daemon cache | -| `--observe` | Read projects without registry writes or starting hackd, Docker or a VM (requires --json) | +| `--observe` | Read projects without registry writes or engine contact; --runtime opts in to Docker queries (requires --json) | +| `--runtime` | With --observe, query Docker for live state; socket contact can start an on-demand engine | | `--meta` | Include git/worktree/session/env metadata (implies --details) | | `--include-global` | Include global infra projects under ~/.hack (e.g. logging stack) | | `--all` | Include unregistered docker compose projects (best-effort) | diff --git a/docs/reference/projects-observe.mmd b/docs/reference/projects-observe.mmd index 34cf6ced6..2a450ed38 100644 --- a/docs/reference/projects-observe.mmd +++ b/docs/reference/projects-observe.mmd @@ -3,9 +3,12 @@ title: Observe projects without registration writes --- flowchart TD CLI[projects --json --observe] --> Registry[Read registry] - CLI --> Runtime[Read Docker ps and inspect] + CLI --> Opt{Explicit --runtime?} + Opt -->|Yes| Runtime[Read Docker ps and inspect] + Opt -->|No| NoRuntime[No engine contact] Registry --> Views[Build registered project views] Runtime --> Views + NoRuntime --> Views Views --> Native{Native inputs?} Native -->|Yes| Unknown[Keep row, native observer required] Native -->|No| Project[Project activity or unknown] diff --git a/docs/reference/projects-observe.svg b/docs/reference/projects-observe.svg index d4db66334..f7cf8a424 100644 --- a/docs/reference/projects-observe.svg +++ b/docs/reference/projects-observe.svg @@ -1,4 +1,4 @@ -Observe projects without registration writesYesNoprojects --json --observeRead registryRead Docker ps andinspectBuild registered projectviewsNative inputs?Keep row, native observerrequiredProject activity or unknownFull or summary JSON \ No newline at end of file +]]>Observe projects without registration writesYesNoYesNoprojects --json --observeRead registryExplicit --runtime?Read Docker ps andinspectNo engine contactBuild registered projectviewsNative inputs?Keep row, native observerrequiredProject activity or unknownFull or summary JSON \ No newline at end of file diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 7b949a8ca..1b5d73720 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -9,7 +9,8 @@ An existing v4 project should follow the upgrade page before these steps. For a read-only inventory, use `hack projects --json --observe`. It lists registered projects even when hackd is off, preserves the registry, and starts -no services. Unknown runtime state stays unknown; native projects use their +no services or engine connection. Add `--runtime` only to opt in to Docker +queries: contacting an on-demand engine can start it. Unknown runtime state stays unknown; native projects use their native `ps` observer. See the [listing contract](../cli.md). ```mermaid diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 7c2892e1a..191aa8b0e 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -133,7 +133,14 @@ const optObserve = defineOption({ type: "boolean", long: "--observe", description: - "Read projects without registry writes or starting hackd, Docker or a VM (requires --json)", + "Read projects without registry writes or engine contact; --runtime opts in to Docker queries (requires --json)", +} as const); +const optRuntime = defineOption({ + name: "runtime", + type: "boolean", + long: "--runtime", + description: + "With --observe, query Docker for live state; socket contact can start an on-demand engine", } as const); const optDryRun = defineOption({ name: "dryRun", @@ -150,6 +157,7 @@ const options = [ optTimings, optNoDaemon, optObserve, + optRuntime, optMeta, optIncludeGlobal, optAll, @@ -207,6 +215,9 @@ const handleProjects: CommandHandlerFor = async ({ if (args.options.observe && (!args.options.json || args.options.meta)) { throw new CliUsageError("--observe requires --json and cannot use --meta."); } + if (args.options.runtime && !args.options.observe) { + throw new CliUsageError("--runtime requires --observe."); + } if ((args.options.summary || args.options.timings) && !args.options.json) { throw new CliUsageError("--summary and --timings require --json."); } @@ -230,6 +241,7 @@ const handleProjects: CommandHandlerFor = async ({ timings: args.options.timings === true, noDaemon: args.options.noDaemon === true || args.options.observe === true, observe: args.options.observe === true, + observeRuntime: args.options.runtime === true, selector, includeGlobal: args.options.includeGlobal === true, includeUnregistered: args.options.all === true, @@ -577,6 +589,7 @@ async function runProjects(opts: { readonly timings?: boolean; readonly noDaemon?: boolean; readonly observe?: boolean; + readonly observeRuntime?: boolean; }): Promise { const profiler = opts.profiler ?? createOperationTimings(); const started = opts.started ?? performance.now(); @@ -597,10 +610,7 @@ async function runProjects(opts: { return 0; } - const runtime = await readRuntimeProjects({ - includeGlobal: opts.includeGlobal, - profiler, - }); + const runtime = await readListingRuntime(opts, profiler); if (runtime.ok && !opts.observe) { await profiler.measure("auto_register_ms", () => @@ -626,6 +636,7 @@ async function runProjects(opts: { if (opts.json) { outputProjectsJson({ observe: opts.observe === true, + observeRuntime: opts.observeRuntime === true, summary: opts.summary === true, profiler, started, @@ -697,6 +708,25 @@ async function runProjects(opts: { return 0; } +/** Socket contact can activate a stopped engine; default observation must not dial it. */ +async function readListingRuntime( + opts: Parameters[0], + profiler: OperationTimings +): ReturnType { + if (opts.observe && !opts.observeRuntime) { + return { + ok: false, + runtime: [], + error: "Runtime observation was not requested.", + checkedAtMs: Date.now(), + }; + } + return await readRuntimeProjects({ + includeGlobal: opts.includeGlobal, + profiler, + }); +} + async function renderProjectInputDiagnostics( views: readonly ProjectView[] ): Promise { @@ -774,6 +804,7 @@ type RuntimeRecoveryNotice = { function outputProjectsJson(opts: { readonly observe: boolean; + readonly observeRuntime: boolean; readonly summary: boolean; readonly profiler: OperationTimings; readonly started: number; @@ -791,7 +822,9 @@ function outputProjectsJson(opts: { }): void { const runtimeMeta = formatRuntimeMeta({ runtime: opts.runtime }); const payload = { - ...(opts.observe ? { observe: true } : {}), + ...(opts.observe + ? { observe: true, runtime_requested: opts.observeRuntime } + : {}), ...(opts.summary ? { detail_level: "summary" } : {}), generated_at: new Date().toISOString(), filter: opts.filterLabel, @@ -800,8 +833,10 @@ function outputProjectsJson(opts: { include_unregistered: opts.includeUnregistered, include_meta: opts.includeMeta, runtime_ok: runtimeMeta.ok, - runtime_error: runtimeMeta.error, - runtime_checked_at: runtimeMeta.checkedAt, + runtime_error: + opts.observe && !opts.observeRuntime ? null : runtimeMeta.error, + runtime_checked_at: + opts.observe && !opts.observeRuntime ? null : runtimeMeta.checkedAt, runtime_last_ok_at: runtimeMeta.lastOkAt, runtime_reset_at: runtimeMeta.lastResetAt, runtime_reset_count: runtimeMeta.resetCount, @@ -820,7 +855,8 @@ function outputProjectsJson(opts: { ? projectObservation({ view, runtimeOk: opts.runtime.ok, - nativeSelected: process.env.HACK_RUNTIME_BACKEND === "native", + nativeSelected: + process.env.HACK_RUNTIME_BACKEND?.trim() === "native", }) : {}), ...(opts.includeMeta diff --git a/tests/projects-observe.test.ts b/tests/projects-observe.test.ts index f52e95605..2d0e9dbe3 100644 --- a/tests/projects-observe.test.ts +++ b/tests/projects-observe.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { chmod, + lstat, mkdir, mkdtemp, + readdir, readFile, realpath, rm, @@ -85,6 +87,7 @@ beforeEach(async () => { ); await chmod(join(bin, name), 0o755); } + privateCliEnv({ root }); }); afterEach(async () => { @@ -117,9 +120,24 @@ async function run(args: string[], extra: Record = {}) { return { code, stdout, stderr }; } -test("observe full/summary/filter listings preserve registry bytes and mtime without starting services", async () => { +async function tree(path: string): Promise { + const info = await lstat(path); + if (!info.isDirectory()) { + return { + mode: info.mode, + bytes: (await readFile(path)).toString("base64"), + }; + } + const children = await readdir(path); + return await Promise.all( + children.sort().map(async (name) => [name, await tree(join(path, name))]) + ); +} + +test("observe default preserves HOME and Hack home and never contacts Docker", async () => { const before = await readFile(registry); const time = (await stat(registry)).mtimeMs; + const homes = [await tree(state), await tree(join(root, "user-home"))]; for (const flags of [ [], ["--summary"], @@ -130,6 +148,9 @@ test("observe full/summary/filter listings preserve registry bytes and mtime wit expect(result.code).toBe(0); const payload = JSON.parse(result.stdout); expect(payload.observe).toBe(true); + expect(payload.runtime_requested).toBe(false); + expect(payload.runtime_error).toBeNull(); + expect(payload.runtime_checked_at).toBeNull(); const native = payload.projects.find( (row: { name: string }) => row.name === "native-app" ); @@ -138,36 +159,65 @@ test("observe full/summary/filter listings preserve registry bytes and mtime wit expect(native.state_reason).toBe("native-observer-required"); expect(native.started_at).toBeNull(); expect(native.services).toEqual([]); - if (flags.includes("--all")) { - expect( - payload.projects.some( - (row: { name: string }) => row.name === "unregistered-app" - ) - ).toBe(true); - expect( - payload.projects.find( - (row: { name: string }) => row.name === "unregistered-app" - ).state - ).toBe("running"); - } + expect( + payload.projects.some( + (row: { name: string }) => row.name === "unregistered-app" + ) + ).toBe(false); + expect(await readFile(registry)).toEqual(before); + expect((await stat(registry)).mtimeMs).toBe(time); + expect([await tree(state), await tree(join(root, "user-home"))]).toEqual( + homes + ); + } + expect(await stat(join(root, "calls")).catch(() => null)).toBeNull(); + expect(await stat(join(root, "forbidden")).catch(() => null)).toBeNull(); + expect(await stat(join(state, "daemon")).catch(() => null)).toBeNull(); +}); + +test("observe runtime explicitly opts in to Docker reads without registering discoveries", async () => { + const before = await readFile(registry); + const time = (await stat(registry)).mtimeMs; + const homes = [await tree(state), await tree(join(root, "user-home"))]; + for (const flags of [[], ["--summary"]]) { + const result = await run([ + "--json", + "--observe", + "--runtime", + "--all", + ...flags, + ]); + expect(result.code).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.runtime_requested).toBe(true); + expect( + payload.projects.find( + (row: { name: string }) => row.name === "unregistered-app" + ).state + ).toBe("running"); expect(await readFile(registry)).toEqual(before); expect((await stat(registry)).mtimeMs).toBe(time); + expect([await tree(state), await tree(join(root, "user-home"))]).toEqual( + homes + ); } const calls = (await readFile(join(root, "calls"), "utf8")) .trim() .split("\n") .map((line) => JSON.parse(line)); + expect(calls.length).toBeGreaterThan(0); expect( calls.every((args: string[]) => ["ps", "inspect"].includes(args[0] ?? "")) ).toBe(true); expect(await stat(join(root, "forbidden")).catch(() => null)).toBeNull(); - expect(await stat(join(state, "daemon")).catch(() => null)).toBeNull(); }); test("observe keeps native rows and unknown Compose services when Docker is unavailable", async () => { const before = await readFile(registry); const time = (await stat(registry)).mtimeMs; - const result = await run(["--json", "--observe"], { OFFLINE: "1" }); + const result = await run(["--json", "--observe", "--runtime"], { + OFFLINE: "1", + }); expect(result.code).toBe(0); const payload = JSON.parse(result.stdout); expect(payload.runtime_ok).toBe(false); @@ -203,7 +253,11 @@ test("observe never creates a missing registry or registers the current checkout test("observe rejects text and metadata before touching registration", async () => { const before = await readFile(registry); const time = (await stat(registry)).mtimeMs; - for (const args of [["--observe"], ["--observe", "--json", "--meta"]]) { + for (const args of [ + ["--observe"], + ["--observe", "--json", "--meta"], + ["--runtime", "--json"], + ]) { expect((await run(args)).code).not.toBe(0); expect(await readFile(registry)).toEqual(before); expect((await stat(registry)).mtimeMs).toBe(time); From 99ce916bf7d9e53b89a376d37761e99d423079ba Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:30:29 +0000 Subject: [PATCH 03/25] fix(projects): retain degraded replica state in project summaries --- docs/cli.md | 3 ++- src/lib/project-observation.ts | 6 +++++- tests/projects-observe.test.ts | 17 ++++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index e94a39fd5..dc4b4c9ff 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -139,7 +139,8 @@ summary row adds `backend`, `state`, `state_reason`, `started_at` and `services` Each service has `name`, `state`, `ready`, `completed`, `health`, `exit_code` and `started_at`. Missing evidence is null or unknown: Docker activity is not a readiness/completion guarantee, and an exited container without an observed -exit code is unknown. Registered native rows remain visible with +exit code is unknown. Mixed running and non-running replicas make their service +and project degraded. Registered native rows remain visible with `backend: native`, `state: unknown`, and `state_reason: native-observer-required`; use the native `ps` observer for their current state. R2 will complete the shared backend-aware projection. Always-on fields will be supplied when their owner diff --git a/src/lib/project-observation.ts b/src/lib/project-observation.ts index 494e29425..a558f9b1a 100644 --- a/src/lib/project-observation.ts +++ b/src/lib/project-observation.ts @@ -119,7 +119,11 @@ function projectState( if (services.some((service) => service.state === "unknown")) { return "unknown"; } - if (services.some((service) => service.state === "running")) { + if ( + services.some( + (service) => service.state === "running" || service.state === "degraded" + ) + ) { return "degraded"; } if (services.some((service) => service.state === "starting")) { diff --git a/tests/projects-observe.test.ts b/tests/projects-observe.test.ts index 2d0e9dbe3..0a5592b35 100644 --- a/tests/projects-observe.test.ts +++ b/tests/projects-observe.test.ts @@ -71,7 +71,7 @@ beforeEach(async () => { const docker = join(bin, "docker"); await writeFile( docker, - `#!${process.execPath}\nconst args=process.argv.slice(2);\nawait Bun.write(Bun.file(process.env.CALLS), (await Bun.file(process.env.CALLS).text().catch(()=>""))+JSON.stringify(args)+"\\n");\nif(args[0]==="ps") {\n if(process.env.OFFLINE==="1") process.exit(1);\n console.log(JSON.stringify({ID:"fixture-container",State:"running",Status:"Up",Names:"fixture-web",Labels:${JSON.stringify(`com.docker.compose.project=unregistered-app,com.docker.compose.service=web,com.docker.compose.project.working_dir=${unregistered.projectDir}`)}}));\n} else if(args[0]!=="inspect") process.exit(99);\n` + `#!${process.execPath}\nconst args=process.argv.slice(2);\nawait Bun.write(Bun.file(process.env.CALLS), (await Bun.file(process.env.CALLS).text().catch(()=>""))+JSON.stringify(args)+"\\n");\nif(args[0]==="ps") {\n if(process.env.OFFLINE==="1") process.exit(1);\n const row={ID:"fixture-container",State:"running",Status:"Up",Names:"fixture-web",Labels:${JSON.stringify(`com.docker.compose.project=unregistered-app,com.docker.compose.service=web,com.docker.compose.project.working_dir=${unregistered.projectDir}`)}};\n console.log(JSON.stringify(row));\n if(process.env.MIXED==="1") console.log(JSON.stringify({...row,ID:"fixture-replica",State:"exited"}));\n} else if(args[0]!=="inspect") process.exit(99);\n` ); await chmod(docker, 0o755); for (const name of [ @@ -242,6 +242,21 @@ test("observe keeps native rows and unknown Compose services when Docker is unav expect((await stat(registry)).mtimeMs).toBe(time); }); +test("mixed running and exited replicas keep service and project degraded", async () => { + for (const flags of [[], ["--summary"]]) { + const result = await run( + ["--json", "--observe", "--runtime", "--all", ...flags], + { MIXED: "1" } + ); + expect(result.code).toBe(0); + const row = JSON.parse(result.stdout).projects.find( + (project: { name: string }) => project.name === "unregistered-app" + ); + expect(row.services[0].state).toBe("degraded"); + expect(row.state).toBe("degraded"); + } +}); + test("observe never creates a missing registry or registers the current checkout", async () => { await rm(registry); const result = await run(["--json", "--observe"]); From dbde680abdfd9c84d75eea6ad143629856ef5d3e Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:56:50 +0000 Subject: [PATCH 04/25] feat(projects): retain current service evidence for state projection --- src/lib/project-state.ts | 165 ++++++++++++++++++++++++++++ src/lib/runtime-projects.ts | 43 ++++++-- src/lib/runtime-service-state.ts | 35 ++++++ tests/project-state.test.ts | 113 +++++++++++++++++++ tests/runtime-projects.test.ts | 55 ++++++++++ tests/runtime-service-state.test.ts | 40 +++++++ 6 files changed, 443 insertions(+), 8 deletions(-) create mode 100644 src/lib/project-state.ts create mode 100644 src/lib/runtime-service-state.ts create mode 100644 tests/project-state.test.ts create mode 100644 tests/runtime-service-state.test.ts diff --git a/src/lib/project-state.ts b/src/lib/project-state.ts new file mode 100644 index 000000000..2a7ebfd79 --- /dev/null +++ b/src/lib/project-state.ts @@ -0,0 +1,165 @@ +/** Shared public state vocabulary. Absence of trustworthy evidence is unknown. */ +export type ProjectState = + | "running" + | "degraded" + | "starting" + | "stopping" + | "stopped" + | "failed" + | "unknown"; + +export type ServiceObservation = { + readonly name: string; + readonly state: ProjectState; + readonly ready: boolean | null; + readonly completed: boolean | null; + readonly health: string | null; + readonly exit_code: number | null; + readonly started_at: string | null; +}; + +/** Current member evidence, separate from historical run/ready receipts. */ +export type ServiceMemberEvidence = { + readonly state: string; + readonly health: string | null; + readonly exitCode: number | null; + readonly startedAt: string | null; +}; + +export type ServiceReadiness = "started" | "healthy" | "completed"; + +function memberState(member: ServiceMemberEvidence): ProjectState { + if (member.state === "running") { + if (member.health === "unhealthy") { + return "degraded"; + } + return member.health === "starting" ? "starting" : "running"; + } + if (member.state === "restarting" || member.state === "starting") { + return "starting"; + } + if (member.state === "stopping") { + return "stopping"; + } + if (member.state === "dead" || member.state === "failed") { + return "failed"; + } + if (member.state === "exited" || member.state === "stopped") { + return member.exitCode !== null && member.exitCode !== 0 + ? "failed" + : "stopped"; + } + return member.state === "created" ? "stopped" : "unknown"; +} + +/** A completed job can satisfy readiness, but exit zero without its role cannot. */ +export function observeService(opts: { + readonly name: string; + readonly available: boolean; + readonly members: readonly ServiceMemberEvidence[]; + readonly readiness: ServiceReadiness | null; +}): ServiceObservation { + const unknown: ServiceObservation = { + name: opts.name, + state: "unknown", + ready: null, + completed: null, + health: null, + exit_code: null, + started_at: null, + }; + if (!opts.available) { + return unknown; + } + if (opts.members.length === 0) { + return { ...unknown, state: "stopped", ready: false, completed: false }; + } + const states = opts.members.map(memberState); + const completed = + opts.readiness === "completed" + ? allEvidence( + opts.members.map((member) => { + const state = memberState(member); + if (state === "unknown") { + return null; + } + if (state !== "stopped" || member.state === "created") { + return false; + } + return member.exitCode === null ? null : member.exitCode === 0; + }) + ) + : null; + let ready: boolean | null = null; + if (opts.readiness === "completed") { + ready = completed; + } else if (opts.readiness === "healthy") { + ready = allEvidence( + opts.members.map((member) => { + if (memberState(member) === "unknown") { + return null; + } + if (member.state !== "running") { + return false; + } + return member.health === null ? null : member.health === "healthy"; + }) + ); + } else if (opts.readiness === "started") { + ready = allEvidence( + states.map((state) => (state === "unknown" ? null : state === "running")) + ); + } + const health = sameValue(opts.members.map((member) => member.health)); + const exitCode = sameValue(opts.members.map((member) => member.exitCode)); + return { + name: opts.name, + state: aggregateProjectState(states), + ready, + completed, + health, + exit_code: exitCode, + started_at: earliestStart(opts.members.map((member) => member.startedAt)), + }; +} + +/** Unknown members prevent a complete claim; observed mixed states are degraded. */ +export function aggregateProjectState( + states: readonly ProjectState[] +): ProjectState { + if (states.length === 0 || states.includes("unknown")) { + return "unknown"; + } + const first = states[0]; + if (first && states.every((state) => state === first)) { + return first; + } + return "degraded"; +} + +function allEvidence(values: readonly (boolean | null)[]): boolean | null { + if (values.includes(false)) { + return false; + } + return values.includes(null) ? null : true; +} + +function sameValue(values: readonly (T | null)[]): T | null { + const first = values[0] ?? null; + return values.every((value) => value === first) ? first : null; +} + +/** Missing timestamps remain missing; never substitute the receipt or observation time. */ +export function earliestStart( + values: readonly (string | null)[] +): string | null { + const known = values.filter( + (value): value is string => + value !== null && + Number.isFinite(Date.parse(value)) && + !value.startsWith("0001-") + ); + return ( + known.sort((left, right) => Date.parse(left) - Date.parse(right))[0] ?? null + ); +} diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts index d85a30822..068c31b02 100644 --- a/src/lib/runtime-projects.ts +++ b/src/lib/runtime-projects.ts @@ -18,10 +18,14 @@ import { inspectProjectInputsAtRoot, ProjectInputSelectionError, } from "./project-input-selection.ts"; +import type { ServiceMemberEvidence } from "./project-state.ts"; import { upsertProjectRegistration } from "./projects-registry.ts"; +import { readContainerState } from "./runtime-service-state.ts"; import { exec, findExecutableInPath } from "./shell.ts"; export type RuntimeContainer = { + /** Fresh inspect evidence only; cached static metadata never proves current health/exit. */ + readonly observation?: ServiceMemberEvidence | null; readonly id: string; readonly project: string; readonly service: string; @@ -79,6 +83,7 @@ export type RuntimeProjectsResult = }; export type ContainerInspectData = { + readonly observation?: ServiceMemberEvidence | null; readonly labels: Record; readonly image: string | null; readonly mounts: readonly RuntimeContainerMount[]; @@ -248,6 +253,8 @@ export async function readRuntimeProjects(opts: { } containers.push({ + observation: + inspect?.observation?.state === state ? inspect.observation : null, id, project, service, @@ -616,7 +623,7 @@ export async function readContainerLabels(opts: { // Request only fields used by the runtime model; Docker must not return env values or command arguments. const RUNTIME_INSPECT_FORMAT = - '{"Id":{{json .Id}},"Config":{"Labels":{{json .Config.Labels}},"Image":{{json .Config.Image}}},"Mounts":{{json .Mounts}},"NetworkSettings":{"Networks":{{json .NetworkSettings.Networks}}}}'; + '{"Id":{{json .Id}},"Config":{"Labels":{{json .Config.Labels}},"Image":{{json .Config.Image}}},"State":{"Status":{{json .State.Status}},"ExitCode":{{json .State.ExitCode}},"StartedAt":{{json .State.StartedAt}},"Health":{{if .State.Health}}{"Status":{{json .State.Health.Status}}}{{else}}null{{end}}},"Mounts":{{json .Mounts}},"NetworkSettings":{"Networks":{{json .NetworkSettings.Networks}}}}'; async function readContainerInspectData(opts: { readonly ids: readonly string[]; @@ -663,6 +670,7 @@ async function readContainerInspectData(opts: { const networks = parseInspectNetworks(item.NetworkSettings); const detail: ContainerInspectData = { + observation: readContainerState(item.State), labels, image, mounts, @@ -703,19 +711,21 @@ async function readCachedContainerInspectData(opts: { opts.cache.diagnostics.inspectCalls += 1; opts.cache.diagnostics.inspectIds += idsToInspect.length; const inspected = await readContainerInspectData({ ids: idsToInspect }); - for (const id of idsToInspect) { - const detail = inspected.get(id); - if (detail) { - opts.cache.entries.set(id, detail); - } - } + reconcileInspectEvidence({ + cache: opts.cache, + inspected, + ids: idsToInspect, + }); } const out = new Map(); for (const id of opts.ids) { const detail = opts.cache.entries.get(id); if (detail) { - out.set(id, detail); + out.set( + id, + idsToInspect.includes(id) ? detail : { ...detail, observation: null } + ); } } return out; @@ -846,3 +856,20 @@ function formatDockerError(opts: { } return `docker ps failed (exit ${opts.exitCode})`; } + +/** Failed reinspection can retain static labels, never a previous health result. */ +function reconcileInspectEvidence(opts: { + readonly cache: RuntimeInspectCache; + readonly inspected: ReadonlyMap; + readonly ids: readonly string[]; +}): void { + for (const id of opts.ids) { + const detail = opts.inspected.get(id); + const previous = opts.cache.entries.get(id); + if (detail) { + opts.cache.entries.set(id, detail); + } else if (previous) { + opts.cache.entries.set(id, { ...previous, observation: null }); + } + } +} diff --git a/src/lib/runtime-service-state.ts b/src/lib/runtime-service-state.ts new file mode 100644 index 000000000..59e45c516 --- /dev/null +++ b/src/lib/runtime-service-state.ts @@ -0,0 +1,35 @@ +import { getString, isRecord } from "./guards.ts"; +import type { ServiceMemberEvidence } from "./project-state.ts"; + +/** Docker's inspect State is current evidence; never parse prose from ps Status. */ +export function readContainerState( + value: unknown +): ServiceMemberEvidence | null { + if (!isRecord(value)) { + return null; + } + const state = getString(value, "Status") ?? null; + if (state === null) { + return null; + } + const health = isRecord(value.Health) + ? (getString(value.Health, "Status") ?? null) + : null; + const exited = state === "exited" || state === "dead"; + const code = value.ExitCode; + const started = getString(value, "StartedAt") ?? null; + return { + state, + health, + exitCode: + exited && typeof code === "number" && Number.isSafeInteger(code) + ? code + : null, + startedAt: + started !== null && + !started.startsWith("0001-") && + Number.isFinite(Date.parse(started)) + ? started + : null, + }; +} diff --git a/tests/project-state.test.ts b/tests/project-state.test.ts new file mode 100644 index 000000000..dbd0b92c5 --- /dev/null +++ b/tests/project-state.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test"; +import { + aggregateProjectState, + earliestStart, + observeService, + type ServiceMemberEvidence, +} from "../src/lib/project-state.ts"; + +const running: ServiceMemberEvidence = { + state: "running", + health: null, + exitCode: null, + startedAt: null, +}; +const exited = { ...running, state: "exited", exitCode: 0 }; + +test("an ordinary exit zero does not invent a completion role or readiness", () => { + const service = observeService({ + name: "task", + available: true, + members: [exited], + readiness: null, + }); + expect(service.state).toBe("stopped"); + expect(service.ready).toBeNull(); + expect(service.completed).toBeNull(); + const job = observeService({ + name: "task", + available: true, + members: [exited], + readiness: "completed", + }); + expect(job.completed).toBe(true); + expect(job.ready).toBe(true); +}); + +test("unavailable evidence cannot turn stale running or exited members into current state", () => { + const service = observeService({ + name: "api", + available: false, + members: [running, exited], + readiness: "completed", + }); + expect(service.state).toBe("unknown"); + expect(service.ready).toBeNull(); + expect(service.completed).toBeNull(); + expect(service.exit_code).toBeNull(); +}); + +test("readiness follows current health rather than a historical ready phase", () => { + const healthy = observeService({ + name: "api", + available: true, + members: [{ ...running, health: "healthy" }], + readiness: "healthy", + }); + expect(healthy.ready).toBe(true); + const unhealthy = observeService({ + name: "api", + available: true, + members: [{ ...running, health: "unhealthy" }], + readiness: "healthy", + }); + expect(unhealthy.ready).toBe(false); + expect(unhealthy.state).toBe("degraded"); +}); + +test("failure and partial replica sets never become all running or complete", () => { + const failed = observeService({ + name: "task", + available: true, + members: [{ ...exited, exitCode: 2 }], + readiness: "completed", + }); + expect(failed.state).toBe("failed"); + expect(failed.completed).toBe(false); + expect( + observeService({ + name: "api", + available: true, + members: [running, exited], + readiness: "started", + }).state + ).toBe("degraded"); + expect(aggregateProjectState(["running", "unknown"])).toBe("unknown"); + expect(aggregateProjectState([])).toBe("unknown"); +}); + +test("start time uses only valid observed timestamps, never a zero date", () => { + expect(earliestStart([null, "0001-01-01T00:00:00Z", "invalid"])).toBeNull(); + expect(earliestStart(["2026-01-02T00:00:00Z", "2026-01-01T00:00:00Z"])).toBe( + "2026-01-01T00:00:00Z" + ); +}); + +test("missing exit or health evidence remains unknown even with a declared role", () => { + expect( + observeService({ + name: "task", + available: true, + members: [{ ...exited, exitCode: null }], + readiness: "completed", + }).completed + ).toBeNull(); + expect( + observeService({ + name: "api", + available: true, + members: [running], + readiness: "healthy", + }).ready + ).toBeNull(); +}); diff --git a/tests/runtime-projects.test.ts b/tests/runtime-projects.test.ts index 06b69b7c2..e38f1033d 100644 --- a/tests/runtime-projects.test.ts +++ b/tests/runtime-projects.test.ts @@ -61,6 +61,9 @@ const shellMock = await registerScopedModuleMock({ if (command[1] === "inspect") { expect(command[2]).toBe("--format"); expect(command[3]).not.toContain(".Env"); + expect(command[3]).not.toContain(".Log"); + expect(command[3]).not.toContain(".Error"); + expect(command[3]).toContain(".State.StartedAt"); const ids = [...command.slice(4)]; inspectCalls.push(ids); const returnedIds = inspectExitCode === 0 ? ids : ids.slice(0, 1); @@ -271,6 +274,12 @@ function makeInspectRow(opts: { }): Record { return { Id: `${opts.id}${"0".repeat(52)}`, + State: { + Status: "running", + ExitCode: 0, + StartedAt: "2026-01-01T00:00:00Z", + Health: { Status: "healthy" }, + }, Config: { Image: `image:${opts.id}`, Labels: { @@ -291,3 +300,49 @@ function makeInspectRow(opts: { }, }; } + +test("cached static metadata never becomes fresh service health or start evidence", async () => { + dockerAvailable = true; + currentIds = ["aaaaaaaaaaaa"]; + const cache = createRuntimeInspectCache(); + const first = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: true, + }); + const cached = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: false, + }); + const member = (result: typeof first) => + [...(result.runtime[0]?.services.values() ?? [])][0]?.containers[0]; + expect(member(first)?.observation?.health).toBe("healthy"); + expect(member(first)?.observation?.exitCode).toBeNull(); + expect(member(cached)?.observation).toBeNull(); + expect(inspectCalls).toHaveLength(1); +}); + +test("failed reinspection keeps static identity but clears old current-state evidence", async () => { + dockerAvailable = true; + currentIds = ["aaaaaaaaaaaa", "bbbbbbbbbbbb"]; + const cache = createRuntimeInspectCache(); + await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: true, + }); + inspectExitCode = 1; + const result = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: true, + }); + const services = result.runtime[0]?.services; + expect( + services?.get("service-aaaaaaaaaaaa")?.containers[0]?.observation?.health + ).toBe("healthy"); + const second = services?.get("service-bbbbbbbbbbbb")?.containers[0]; + expect(second?.image).toBe("image:bbbbbbbbbbbb"); + expect(second?.observation).toBeNull(); +}); diff --git a/tests/runtime-service-state.test.ts b/tests/runtime-service-state.test.ts new file mode 100644 index 000000000..1af8d6c04 --- /dev/null +++ b/tests/runtime-service-state.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { readContainerState } from "../src/lib/runtime-service-state.ts"; + +test("inspect state exposes exit codes only for terminated containers", () => { + expect( + readContainerState({ + Status: "running", + ExitCode: 0, + Health: { Status: "healthy" }, + StartedAt: "2026-01-01T00:00:00Z", + }) + ).toEqual({ + state: "running", + health: "healthy", + exitCode: null, + startedAt: "2026-01-01T00:00:00Z", + }); + expect(readContainerState({ Status: "exited", ExitCode: 2 })?.exitCode).toBe( + 2 + ); +}); + +test("missing or invalid metadata stays unknown instead of successful or epoch-started", () => { + expect(readContainerState(undefined)).toBeNull(); + expect(readContainerState({})).toBeNull(); + expect( + readContainerState({ + Status: "exited", + ExitCode: "0", + StartedAt: "0001-01-01T00:00:00Z", + }) + ).toEqual({ state: "exited", health: null, exitCode: null, startedAt: null }); + expect( + readContainerState({ + Status: "exited", + ExitCode: Number.NaN, + StartedAt: "invalid", + })?.exitCode + ).toBeNull(); +}); From bbdfe7d306a2522df626ba73885b6d66d8395584 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:09:21 +0000 Subject: [PATCH 05/25] feat(projects): share backend-aware service state across CLI and daemon --- apps/desktop/DESIGN.md | 2 +- docs/reference.md | 1 + docs/reference/project-state.md | 65 +++++++ docs/reference/project-state.mmd | 14 ++ docs/reference/project-state.svg | 66 +++++++ docs/versions/v5/getting-started.md | 6 + .../native-authored-project-observe.ts | 5 + src/commands/projects.ts | 10 +- src/daemon/runtime-cache.ts | 1 + src/lib/compose-service-readiness.ts | 46 +++++ src/lib/project-native-observation.ts | 95 ++++++++++ src/lib/project-observation.ts | 114 ++++-------- src/lib/project-state.ts | 51 +++++- src/lib/project-views.ts | 77 +++++++- tests/compose-service-readiness.test.ts | 30 ++++ tests/project-native-observation.test.ts | 148 +++++++++++++++ tests/project-views.test.ts | 168 ++++++++++++++++++ tests/runtime-cache.test.ts | 70 +++++++- 18 files changed, 859 insertions(+), 110 deletions(-) create mode 100644 docs/reference/project-state.md create mode 100644 docs/reference/project-state.mmd create mode 100644 docs/reference/project-state.svg create mode 100644 src/lib/compose-service-readiness.ts create mode 100644 src/lib/project-native-observation.ts create mode 100644 tests/compose-service-readiness.test.ts create mode 100644 tests/project-native-observation.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index f1b9d39ed..21a13f6a2 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -368,7 +368,7 @@ A macOS system prompt could come later for single root-only lines from R11, neve Each is small enough for one PR. 1. **R1** `hack version --json`: `{version, channel, install_kind, binary_path}`. -2. **R2** Backend-aware project state in `/v1/projects` and `hack projects --json` (full and summary rows): `backend`, `state` (`running | degraded | starting | stopping | stopped | failed | unknown`), `started_at`, and per service `{state, ready, completed, health, exit_code, started_at}`; native projects included. +2. **R2** Exists (see [project state](../../docs/reference/project-state.md)): backend-aware project state in `/v1/projects` and `hack projects --json` (full and summary rows): `backend`, `state` (`running | degraded | starting | stopping | stopped | failed | unknown`), `started_at`, and per service `{state, ready, completed, health, exit_code, started_at}`; native projects included. 3. **R2b** Native lifecycle the app can drive: a `--json` contract for native `restart` and authored `down`/`restart`, and qualification of the existing detached `up --json` for app use. 4. **R3** Per-service usage: `hack usage --json --path --by-service` with `cpuPercent`, `memUsedBytes`. 5. **R4** Logs: document the `--json` ndjson as stable; `--services a,b` honoured by every backend; `--max-line-bytes ` setting `entry.truncated`. diff --git a/docs/reference.md b/docs/reference.md index 44e790649..0e868a63b 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -10,6 +10,7 @@ ## Environment & sessions +- [Project state](./reference/project-state.md) — shared backend-aware CLI and daemon project/service observations - [Environment model](./env.md) - [Lifecycle & startup](./lifecycle.md) - [Activity log](./reference/activity-log.md) — the `up`/`down`/`restart` history `hack activity` reads: records, origins, concurrency, rotation and what is never recorded diff --git a/docs/reference/project-state.md b/docs/reference/project-state.md new file mode 100644 index 000000000..51ad8e05f --- /dev/null +++ b/docs/reference/project-state.md @@ -0,0 +1,65 @@ +# Backend-aware project state + +Full and summary `hack projects --json` rows and hackd's `GET /v1/projects` +share these additional fields. Older `status` and `runtime_status` fields remain +for compatibility; consumers of this contract use `state`. + +```json +{ + "backend": "compose", + "state": "running", + "state_reason": null, + "started_at": "2026-01-01T00:00:00Z", + "services": [{"name":"api","state":"running","ready":true,"completed":null,"health":null,"exit_code":null,"started_at":"2026-01-01T00:00:00Z"}] +} +``` + +`backend` is `compose`, `native` or `unknown`. Project and service `state` is +`running`, `degraded`, `starting`, `stopping`, `stopped`, `failed` or `unknown`. +Mixed running/terminated replicas are degraded. When no live member remains, +known failure wins over stopped members. Unknown evidence prevents a complete +claim. Successful declared jobs do not degrade other running services. + +`ready` and `completed` are nullable booleans. Unknown is not false: + +- Compose roles come from current service configuration: a health check or a + `service_healthy` dependency requires health; a + `service_completed_successfully` dependency declares a completion role; + otherwise the role is started. A successful exit alone never declares a job. +- Native roles come from the owned run's retained readiness declarations, + combined with fresh authenticated member observations. A historical ready + receipt does not prove current readiness. +- A completion role succeeds only when every member has an observed zero exit. + Missing exit codes or required health remain null. Mixed replica exit codes + or health strings are null rather than selecting an arbitrary member. + +`health` and `exit_code` come from current observations, never log text. An exit +code is emitted only for a terminated member. Docker inspect selects status, +exit code, start time and health status, excluding environment values, commands, +error text and health-check logs. The daemon's static inspect cache cannot +supply current health/exit/start evidence; fields are null on a cache-only +refresh. Failed reinspection clears prior state evidence while retaining static +identity. No extra inspect calls are introduced for cache hits. + +`started_at` is the earliest valid observed member start time, or null. It is +never the observation time or a historical readiness time. Native status does +not currently expose start timestamps, so native timestamps remain null. + +Registered native projects remain in the listing. Authored native markers and +explicit native runtime selection choose the native observer. An absent runtime +selection, unavailable/busy provider, changed provider pin or unconfirmed owner +produces unknown state with `state_reason: "observation-unavailable"`. Raw +provider errors are omitted. An explicitly verified native runtime degradation +reports `degraded` with `state_reason: "native-runtime-degraded"`, without claiming +service readiness. Native observations cover the registered root's +base instance; branch observations remain a separate interface. At most four +native project status reads run concurrently. + +Observation boundaries from [observe-only listing](../cli.md) are preserved: +`hack projects --json --observe` reads no engine or native provider and leaves +live state unknown. `--observe --runtime` explicitly opts in to runtime contact, +which can start a socket-activated engine. Both bypass hackd and registration +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) diff --git a/docs/reference/project-state.mmd b/docs/reference/project-state.mmd new file mode 100644 index 000000000..aa721829c --- /dev/null +++ b/docs/reference/project-state.mmd @@ -0,0 +1,14 @@ +flowchart TD + S[Registered project] --> G{Runtime contact allowed} + G -->|no| U[Unknown live state] + G -->|yes| B{Selected backend} + B -->|Compose| D[Current ps and selected inspect fields] + B -->|Native| N[Owned run and current authenticated observation] + D --> P[Shared service projection] + N --> P + P --> J{Declared job and observed zero exit} + J -->|yes| C[Completed and ready] + J -->|no| E[Current state and nullable readiness] + C --> R[Shared full and summary rows] + E --> R + U --> R diff --git a/docs/reference/project-state.svg b/docs/reference/project-state.svg new file mode 100644 index 000000000..afd5ef274 --- /dev/null +++ b/docs/reference/project-state.svg @@ -0,0 +1,66 @@ +noyesComposeNativeyesnoRegistered projectRuntime contact allowedUnknown live stateSelected backendCurrent ps and selectedinspect fieldsOwned run and currentauthenticated observationShared service projectionDeclared job and observedzero exitCompleted and readyCurrent state and nullablereadinessShared full and summaryrows \ No newline at end of file diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 1b5d73720..29c7b7e40 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -112,3 +112,9 @@ This stops the project. It does not delete its named volumes or stop shared routing services. See [Run and inspect](guides/run-project.md) for service selection, log history and restarts. When this Compose run works, try native only on a [disposable project](native/start.md). + +Project JSON rows and hackd project snapshots share `backend`, `state`, +`started_at` and per-service readiness, completion, health and exit fields. +Unavailable evidence stays unknown/null. A zero exit is a completed job only +with a declared completion role. Default `projects --json --observe` still +contacts no runtime; live observations require `--runtime`. diff --git a/src/backends/native-authored-project-observe.ts b/src/backends/native-authored-project-observe.ts index ce0ecb9d1..c71d776cb 100644 --- a/src/backends/native-authored-project-observe.ts +++ b/src/backends/native-authored-project-observe.ts @@ -28,6 +28,7 @@ type Item = { readonly state: string; readonly health: string | null; readonly exitCode: number | null; + readonly readiness?: "started" | "healthy" | "completed"; }; export type NativeAuthoredProjectStatus = { readonly backend: "native"; @@ -52,6 +53,7 @@ export async function nativeAuthoredProjectPs(input: { readonly runtime: NativeRuntimeSelection; readonly scope: NativeAuthoredProjectRunScope; readonly signal?: AbortSignal; + readonly includeReadiness?: boolean; readonly invoke?: typeof invokeNativeRuntime; /** Control seam; production always uses the held saved-status owner. */ readonly withStatus?: typeof withNativeAuthoredProjectStatus; @@ -145,6 +147,9 @@ export async function nativeAuthoredProjectPs(input: { } return { service, + ...(input.includeReadiness + ? { readiness: current.receipt.readiness[service] } + : {}), container: resource.id, state: observation?.state ?? "absent", health: diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 191aa8b0e..d007a3b06 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -43,7 +43,6 @@ import { AmbiguousProjectNameError, normalizeProjectName, } from "../lib/project-name.ts"; -import { projectObservation } from "../lib/project-observation.ts"; import { assertPruneRuntimeOwnership } from "../lib/project-prune-ownership.ts"; import { findMissingRegistryEntries, @@ -621,6 +620,7 @@ async function runProjects(opts: { const views = await profiler.measure("project_views_ms", () => buildProjectViews({ + observeNative: !opts.observe || opts.observeRuntime === true, registryProjects: registry.projects, runtime: runtime.runtime, runtimeOk: runtime.ok, @@ -851,14 +851,6 @@ function outputProjectsJson(opts: { ...(opts.summary ? serializeProjectSummary(view) : serializeProjectView(view)), - ...(opts.observe - ? projectObservation({ - view, - runtimeOk: opts.runtime.ok, - nativeSelected: - process.env.HACK_RUNTIME_BACKEND?.trim() === "native", - }) - : {}), ...(opts.includeMeta ? { meta: opts.metaByName.get(view.name) ?? null } : {}), diff --git a/src/daemon/runtime-cache.ts b/src/daemon/runtime-cache.ts index d8cb76dac..3720470e2 100644 --- a/src/daemon/runtime-cache.ts +++ b/src/daemon/runtime-cache.ts @@ -318,6 +318,7 @@ export function createRuntimeCache(opts: { }); const views = await profiler.measure("project_views_ms", () => deps.buildProjectViews({ + observeNative: true, registryProjects: registry.projects, runtime, runtimeOk: health.ok, diff --git a/src/lib/compose-service-readiness.ts b/src/lib/compose-service-readiness.ts new file mode 100644 index 000000000..340f1c158 --- /dev/null +++ b/src/lib/compose-service-readiness.ts @@ -0,0 +1,46 @@ +import { isRecord } from "./guards.ts"; +import type { ServiceReadiness } from "./project-state.ts"; + +/** Current declared readiness roles; exit zero alone never declares a job. */ +export function composeServiceReadiness( + document: unknown +): Readonly> { + if (!(isRecord(document) && isRecord(document.services))) { + return {}; + } + const roles: Record = {}; + for (const [name, service] of Object.entries(document.services)) { + if (!isRecord(service)) { + continue; + } + const health = service.healthcheck; + roles[name] = + isRecord(health) && health.disable !== true ? "healthy" : "started"; + } + applyDependencyRoles(document.services, roles); + return roles; +} + +function applyDependencyRoles( + services: Readonly>, + roles: Record +): void { + for (const service of Object.values(services)) { + if (!(isRecord(service) && isRecord(service.depends_on))) { + continue; + } + for (const [name, dependency] of Object.entries(service.depends_on)) { + if (!(roles[name] && isRecord(dependency))) { + continue; + } + if (dependency.condition === "service_completed_successfully") { + roles[name] = "completed"; + } else if ( + roles[name] !== "completed" && + dependency.condition === "service_healthy" + ) { + roles[name] = "healthy"; + } + } + } +} diff --git a/src/lib/project-native-observation.ts b/src/lib/project-native-observation.ts new file mode 100644 index 000000000..49a3a7c95 --- /dev/null +++ b/src/lib/project-native-observation.ts @@ -0,0 +1,95 @@ +import { nativeAuthoredProjectPs } from "../backends/native-authored-project-observe.ts"; +import { nativeProjectPs } from "../backends/native-project-observe.ts"; +import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; +import type { ProjectObservation } from "./project-observation.ts"; +import { + earliestStart, + observedProjectState, + observeService, +} from "./project-state.ts"; +import type { ProjectView } from "./project-views.ts"; + +/** Optional live native reads. The default observe-only CLI never calls this boundary. */ +export async function observeNativeProject(opts: { + readonly view: ProjectView; + readonly ps?: typeof nativeProjectPs; + readonly authoredPs?: typeof nativeAuthoredProjectPs; +}): Promise { + const unknown: ProjectObservation = { + backend: "native", + state: "unknown", + state_reason: "observation-unavailable", + started_at: null, + services: [], + }; + const { view } = opts; + if (!(view.repoRoot && view.projectDir)) { + return unknown; + } + try { + const runtime = resolveNativeRuntimeSelection(); + if (!runtime) { + return unknown; + } + const scope = { + projectRoot: view.repoRoot, + projectDir: view.projectDir, + nativeHome: runtime.home, + branch: null, + }; + const result = + view.inputDiagnostic?.code === "E_NATIVE_PROJECT_UNSUPPORTED" + ? await (opts.authoredPs ?? nativeAuthoredProjectPs)({ + runtime, + scope, + includeReadiness: true, + }) + : await (opts.ps ?? nativeProjectPs)({ + runtime, + scope, + includeReadiness: true, + }); + if (result.status === "not_started" || result.status === "pending") { + return { + ...unknown, + state: result.status === "pending" ? "starting" : "stopped", + state_reason: null, + }; + } + if (result.status === "runtime_degraded") { + return { + ...unknown, + state: "degraded", + state_reason: "native-runtime-degraded", + }; + } + if (result.status !== "observed") { + return unknown; + } + const services = result.items.map((item) => + observeService({ + name: item.service, + available: true, + readiness: item.readiness ?? null, + members: [ + { + state: item.state, + health: item.health, + exitCode: item.exitCode, + startedAt: null, + }, + ], + }) + ); + return { + backend: "native", + state: observedProjectState(services), + state_reason: null, + started_at: earliestStart(services.map((service) => service.started_at)), + services, + }; + } catch { + // Status refusals are missing evidence, never raw provider diagnostics. + return unknown; + } +} diff --git a/src/lib/project-observation.ts b/src/lib/project-observation.ts index a558f9b1a..a4a4cc8e2 100644 --- a/src/lib/project-observation.ts +++ b/src/lib/project-observation.ts @@ -1,25 +1,13 @@ +import { + earliestStart, + observedProjectState, + observeService, + type ProjectState, + type ServiceObservation, +} from "./project-state.ts"; import type { ProjectView } from "./project-views.ts"; -import type { RuntimeContainer } from "./runtime-projects.ts"; - -/** Shared vocabulary for the desktop listing; unknown evidence is never a stopped claim. */ -export type ObservedProjectState = - | "running" - | "degraded" - | "starting" - | "stopping" - | "stopped" - | "failed" - | "unknown"; - -export type ObservedService = { - readonly name: string; - readonly state: ObservedProjectState; - readonly ready: boolean | null; - readonly completed: boolean | null; - readonly health: string | null; - readonly exit_code: number | null; - readonly started_at: string | null; -}; +export type ObservedProjectState = ProjectState; +export type ObservedService = ServiceObservation; export type ProjectObservation = { readonly backend: "compose" | "native" | "unknown"; @@ -27,15 +15,15 @@ export type ProjectObservation = { readonly state_reason: | "native-observer-required" | "observation-unavailable" + | "native-runtime-degraded" | null; readonly started_at: string | null; readonly services: readonly ObservedService[]; }; /** - * Read-only listing projection. Docker's listing does not carry authenticated - * exit codes, readiness roles or start times: retain null until the R2 owner - * supplies them. Native inputs need their own observer, never Docker inference. + * Shared listing projection. Fresh inspect data supplies state details; static + * cached metadata never supplies current health, exit codes or start time. */ export function projectObservation(input: { readonly view: ProjectView; @@ -66,68 +54,28 @@ export function projectObservation(input: { ...(view.definedServices ?? []), ...(observable ? (view.runtime?.services.keys() ?? []) : []), ]); - const services: ObservedService[] = [...names].sort().map((name) => ({ - name, - state: observable - ? serviceState(view.runtime?.services.get(name)?.containers ?? []) - : "unknown", - ready: null, - completed: null, - health: null, - exit_code: null, - started_at: null, - })); + const services: ObservedService[] = [...names].sort().map((name) => { + const containers = view.runtime?.services.get(name)?.containers ?? []; + return observeService({ + name, + available: observable, + readiness: view.serviceReadiness?.[name] ?? null, + members: containers.map( + (container) => + container.observation ?? { + state: container.state, + health: null, + exitCode: null, + startedAt: null, + } + ), + }); + }); return { backend, - state: observable ? projectState(services) : "unknown", + state: observable ? observedProjectState(services) : "unknown", state_reason: reason, - started_at: null, + started_at: earliestStart(services.map((service) => service.started_at)), services, }; } - -function serviceState( - containers: readonly RuntimeContainer[] -): ObservedProjectState { - if (containers.length === 0) { - return "stopped"; - } - if (containers.every((container) => container.state === "running")) { - return "running"; - } - if (containers.some((container) => container.state === "running")) { - return "degraded"; - } - if (containers.some((container) => container.state === "restarting")) { - return "starting"; - } - if (containers.every((container) => container.state === "created")) { - return "stopped"; - } - return "unknown"; -} - -function projectState( - services: readonly ObservedService[] -): ObservedProjectState { - if (services.length === 0) { - return "unknown"; - } - if (services.every((service) => service.state === "running")) { - return "running"; - } - if (services.some((service) => service.state === "unknown")) { - return "unknown"; - } - if ( - services.some( - (service) => service.state === "running" || service.state === "degraded" - ) - ) { - return "degraded"; - } - if (services.some((service) => service.state === "starting")) { - return "starting"; - } - return "stopped"; -} diff --git a/src/lib/project-state.ts b/src/lib/project-state.ts index 2a7ebfd79..239a84c15 100644 --- a/src/lib/project-state.ts +++ b/src/lib/project-state.ts @@ -44,10 +44,14 @@ function memberState(member: ServiceMemberEvidence): ProjectState { if (member.state === "dead" || member.state === "failed") { return "failed"; } - if (member.state === "exited" || member.state === "stopped") { - return member.exitCode !== null && member.exitCode !== 0 - ? "failed" - : "stopped"; + if (member.state === "exited") { + if (member.exitCode === null) { + return "unknown"; + } + return member.exitCode === 0 ? "stopped" : "failed"; + } + if (member.state === "stopped" || member.state === "absent") { + return "stopped"; } return member.state === "created" ? "stopped" : "unknown"; } @@ -75,6 +79,11 @@ export function observeService(opts: { return { ...unknown, state: "stopped", ready: false, completed: false }; } const states = opts.members.map(memberState); + const mixedLive = + opts.members.some((member) => member.state === "running") && + opts.members.some((member) => + ["exited", "dead", "stopped", "absent"].includes(member.state) + ); const completed = opts.readiness === "completed" ? allEvidence( @@ -83,7 +92,10 @@ export function observeService(opts: { if (state === "unknown") { return null; } - if (state !== "stopped" || member.state === "created") { + if ( + state !== "stopped" || + (member.state !== "exited" && member.state !== "stopped") + ) { return false; } return member.exitCode === null ? null : member.exitCode === 0; @@ -114,7 +126,7 @@ export function observeService(opts: { const exitCode = sameValue(opts.members.map((member) => member.exitCode)); return { name: opts.name, - state: aggregateProjectState(states), + state: mixedLive ? "degraded" : aggregateProjectState(states), ready, completed, health, @@ -123,7 +135,7 @@ export function observeService(opts: { }; } -/** Unknown members prevent a complete claim; observed mixed states are degraded. */ +/** Unknown prevents a complete claim; mixed live replicas are degraded. */ export function aggregateProjectState( states: readonly ProjectState[] ): ProjectState { @@ -134,7 +146,19 @@ export function aggregateProjectState( if (first && states.every((state) => state === first)) { return first; } - return "degraded"; + if (states.includes("running") || states.includes("degraded")) { + return "degraded"; + } + if (states.includes("failed")) { + return "failed"; + } + if (states.includes("stopping")) { + return "stopping"; + } + if (states.includes("starting")) { + return "starting"; + } + return "unknown"; } function allEvidence(values: readonly (boolean | null)[]): boolean | null { @@ -163,3 +187,14 @@ export function earliestStart( known.sort((left, right) => Date.parse(left) - Date.parse(right))[0] ?? null ); } + +/** Successful declared jobs do not degrade otherwise-ready long-running services. */ +export function observedProjectState( + services: readonly ServiceObservation[] +): ProjectState { + const active = services.filter((service) => service.completed !== true); + if (services.length > 0 && active.length === 0) { + return "stopped"; + } + return aggregateProjectState(active.map((service) => service.state)); +} diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index 40d95b80d..5aca62a2d 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -9,6 +9,7 @@ import { } from "../constants.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { parseSessionBase } from "../mux/session-names.ts"; +import { composeServiceReadiness } from "./compose-service-readiness.ts"; import { pathExists, readTextFile } from "./fs.ts"; import { isRecord } from "./guards.ts"; import { @@ -23,6 +24,12 @@ import { ProjectInputSelectionError, } from "./project-input-selection.ts"; import { normalizeProjectName } from "./project-name.ts"; +import { observeNativeProject } from "./project-native-observation.ts"; +import { + type ProjectObservation, + projectObservation, +} from "./project-observation.ts"; +import type { ServiceReadiness } from "./project-state.ts"; import type { RegisteredProject, RegisteredProjectWorktree, @@ -35,6 +42,8 @@ import { } from "./runtime-projects.ts"; import { exec, findExecutableInPath } from "./shell.ts"; +const NATIVE_OBSERVATION_CONCURRENCY = 4; + export type BranchRuntime = { readonly branch: string; readonly runtime: RuntimeProject; @@ -83,6 +92,8 @@ export type ProjectLifecycleView = { }; export type ProjectView = { + readonly observation?: ProjectObservation; + readonly serviceReadiness?: Readonly>; readonly projectId?: string; readonly name: string; readonly devHost: string | null; @@ -123,6 +134,7 @@ export type ProjectRuntimeStatus = | "not_configured"; type BuildProjectViewsOptions = { + readonly observeNative?: boolean; readonly registryProjects: readonly RegisteredProject[]; readonly runtime: readonly RuntimeProject[]; readonly runtimeOk: boolean; @@ -225,7 +237,34 @@ export async function buildProjectViews( } } - return out; + const nativeSelected = process.env.HACK_RUNTIME_BACKEND?.trim() === "native"; + const observed: ProjectView[] = []; + // Native status reads own subprocesses; keep registry size from becoming fan-out. + for ( + let offset = 0; + offset < out.length; + offset += NATIVE_OBSERVATION_CONCURRENCY + ) { + observed.push( + ...(await Promise.all( + out + .slice(offset, offset + NATIVE_OBSERVATION_CONCURRENCY) + .map(async (view) => { + const base = projectObservation({ + view, + runtimeOk: opts.runtimeOk, + nativeSelected, + }); + const observation = + opts.observeNative && base.backend === "native" + ? await observeNativeProject({ view }) + : base; + return { ...view, observation }; + }) + )) + ); + } + return observed; } function resolveRegisteredRuntime(opts: { @@ -270,9 +309,10 @@ async function buildRegisteredProjectView(opts: { PROJECT_COMPOSE_FILENAME ); const composeExists = projectDirOk && (await pathExists(composeFile)); - const definedServices = composeExists + const composeDefinition = composeExists ? await readComposeServices({ composeFile }) : null; + const definedServices = composeDefinition?.names ?? null; const serviceHosts = composeExists ? await readComposeServiceHosts({ composeFile }) : null; @@ -318,6 +358,7 @@ async function buildRegisteredProjectView(opts: { projectDir: opts.registration.projectDir, ownership: config && !config.parseError ? config.ownership : null, definedServices, + serviceReadiness: composeDefinition?.readiness, extensionsEnabled: extensions?.enabled ?? null, features: extensions?.features ?? null, serviceHosts, @@ -406,6 +447,14 @@ export function serializeProjectSummary( [...runtime.services.values()].flatMap((service) => service.containers) ); return { + ...(view.observation ?? + projectObservation({ + view, + runtimeOk: + view.runtimeStatus !== "unknown" && + view.runtimeStatus !== "unavailable", + nativeSelected: false, + })), project_id: view.projectId ?? null, name: view.name, repo_root: view.repoRoot, @@ -434,6 +483,14 @@ export function serializeProjectView( view: ProjectView ): Record { return { + ...(view.observation ?? + projectObservation({ + view, + runtimeOk: + view.runtimeStatus !== "unknown" && + view.runtimeStatus !== "unavailable", + nativeSelected: false, + })), project_id: view.projectId ?? null, name: view.name, dev_host: view.devHost ?? null, @@ -882,7 +939,10 @@ function parseZellijSessionName(line: string): string | null { async function readComposeServices(opts: { readonly composeFile: string; -}): Promise { +}): Promise<{ + names: readonly string[]; + readiness: Readonly>; +} | null> { const text = await readTextFile(opts.composeFile); if (!text) { return null; @@ -899,11 +959,12 @@ async function readComposeServices(opts: { } const servicesRaw = parsed.services; - if (!isRecord(servicesRaw)) { - return []; - } - - return Object.keys(servicesRaw).sort((a, b) => a.localeCompare(b)); + return { + names: isRecord(servicesRaw) + ? Object.keys(servicesRaw).sort((a, b) => a.localeCompare(b)) + : [], + readiness: composeServiceReadiness(parsed), + }; } async function readComposeServiceHosts(opts: { diff --git a/tests/compose-service-readiness.test.ts b/tests/compose-service-readiness.test.ts new file mode 100644 index 000000000..a5f63e8f0 --- /dev/null +++ b/tests/compose-service-readiness.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { composeServiceReadiness } from "../src/lib/compose-service-readiness.ts"; + +test("completion is declared by a dependency, never inferred from a stopped service", () => { + expect( + composeServiceReadiness({ + services: { + api: { + depends_on: { + migrate: { condition: "service_completed_successfully" }, + db: { condition: "service_healthy" }, + }, + }, + migrate: {}, + db: {}, + idle: {}, + checked: { healthcheck: { test: ["CMD", "true"] } }, + disabled: { healthcheck: { disable: true } }, + }, + }) + ).toEqual({ + api: "started", + migrate: "completed", + db: "healthy", + idle: "started", + checked: "healthy", + disabled: "started", + }); + expect(composeServiceReadiness(null)).toEqual({}); +}); diff --git a/tests/project-native-observation.test.ts b/tests/project-native-observation.test.ts new file mode 100644 index 000000000..990010d55 --- /dev/null +++ b/tests/project-native-observation.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import type { NativeProjectPsResult } from "../src/backends/native-project-observe.ts"; +import { observeNativeProject } from "../src/lib/project-native-observation.ts"; +import type { ProjectView } from "../src/lib/project-views.ts"; + +let original: Record; +beforeEach(() => { + original = { + HACK_RUNTIME_BACKEND: process.env.HACK_RUNTIME_BACKEND, + HACK_NATIVE_BINARY: process.env.HACK_NATIVE_BINARY, + HACK_NATIVE_HOME: process.env.HACK_NATIVE_HOME, + }; + process.env.HACK_RUNTIME_BACKEND = "native"; + process.env.HACK_NATIVE_BINARY = "/example/native"; + process.env.HACK_NATIVE_HOME = "/example/native-home"; +}); +afterEach(() => { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); +const view: ProjectView = { + name: "sample", + repoRoot: "/example/project", + projectDir: "/example/project/.hack", + devHost: null, + ownership: null, + definedServices: ["api", "task"], + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: true, + runtimeStatus: "unknown", + runtime: null, + branchRuntime: [], + sessions: [], + lifecycle: null, + worktrees: null, + kind: "registered", + status: "unknown", +}; + +test("native projection requires fresh current state and the retained readiness role", async () => { + let calls = 0; + const report = await observeNativeProject({ + view, + ps: async (opts) => { + calls++; + expect(opts.includeReadiness).toBe(true); + expect(opts.scope.projectRoot).toBe("/example/project"); + return { + backend: "native", + status: "observed", + run: "run", + phase: "ready-observed", + items: [ + { + service: "api", + container: "member", + phase: "ready", + state: "running", + health: "healthy", + exitCode: null, + readiness: "healthy", + }, + { + service: "task", + container: "task", + phase: "ready", + state: "exited", + health: null, + exitCode: 0, + readiness: "completed", + }, + ], + }; + }, + }); + expect(calls).toBe(1); + expect(report.backend).toBe("native"); + expect(report.state).toBe("running"); + expect(report.services[1]?.completed).toBe(true); + expect(report.started_at).toBeNull(); +}); + +test.each([ + "runtime_not_running", + "runtime_busy", + "owner_unconfirmed", +] as const)("native %s is unknown, never stopped or a stale ready claim", async (status) => { + const report = await observeNativeProject({ + view, + ps: async () => ({ + backend: "native", + status, + run: "run", + phase: "ready-observed", + items: [], + }), + }); + expect(report.state).toBe("unknown"); + expect(report.state_reason).toBe("observation-unavailable"); +}); + +test.each([ + "pending", + "not_started", +] as const)("native %s comes from the owned run observer", async (status) => { + const result: NativeProjectPsResult = { + backend: "native", + status, + run: null, + phase: null, + items: [], + }; + const report = await observeNativeProject({ view, ps: async () => result }); + expect(report.state).toBe(status === "pending" ? "starting" : "stopped"); +}); + +test("native refusal omits raw provider diagnostics", async () => { + const report = await observeNativeProject({ + view, + ps: async () => { + throw new Error("private diagnostic placeholder"); + }, + }); + expect(report.state).toBe("unknown"); + expect(JSON.stringify(report)).not.toContain("diagnostic placeholder"); +}); + +test("verified native runtime degradation is distinct from unavailable observation", async () => { + const report = await observeNativeProject({ + view, + ps: async () => ({ + backend: "native", + status: "runtime_degraded", + run: "run", + phase: "ready-observed", + items: [], + }), + }); + expect(report.state).toBe("degraded"); + expect(report.state_reason).toBe("native-runtime-degraded"); +}); diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index a6d5d4297..5955185bf 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -32,6 +32,39 @@ let tempDir: string | null = null; let originalGlobalConfigPath: string | undefined; let blockedReadRoot: string | null = null; const blockedReadPaths: string[] = []; +let nativeReadCount = 0; +let nativeActive = 0; +let nativePeak = 0; +const nativeObservationMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/project-native-observation.ts", + overrides: { + observeNativeProject: async () => { + nativeReadCount++; + nativeActive++; + nativePeak = Math.max(nativePeak, nativeActive); + await new Promise((resolve) => setTimeout(resolve, 5)); + nativeActive--; + return { + backend: "native", + state: "running", + state_reason: null, + started_at: null, + services: [ + { + name: "api", + state: "running", + ready: true, + completed: null, + health: "healthy", + exit_code: null, + started_at: null, + }, + ], + }; + }, + }, +}); const projectReadMock = await registerScopedModuleMock({ importerPath: import.meta.path, @@ -53,13 +86,18 @@ const projectReadMock = await registerScopedModuleMock({ beforeAll(() => { projectReadMock.activate(); + nativeObservationMock.activate(); }); afterAll(() => { projectReadMock.deactivate(); + nativeObservationMock.deactivate(); }); beforeEach(async () => { + nativeReadCount = 0; + nativeActive = 0; + nativePeak = 0; blockedReadRoot = null; blockedReadPaths.length = 0; tempDir = await mkdtemp(join(tmpdir(), "hack-views-")); @@ -763,3 +801,133 @@ test("project views accept an unambiguous legacy alias without rewriting runtime }) ).rejects.toThrow("Ambiguous project name"); }); + +test("full and summary rows share Compose state, readiness, completion and timestamps", async () => { + const registration = await createProject({ + name: "state-fixture", + services: ["api", "task"], + }); + await writeFile( + join(registration.projectDir, PROJECT_COMPOSE_FILENAME), + "services:\n api:\n depends_on:\n task: {condition: service_completed_successfully}\n task: {}\n" + ); + const api = { + ...makeContainer({ + project: registration.name, + service: "api", + name: "api", + state: "running", + }), + observation: { + state: "running", + health: null, + exitCode: null, + startedAt: "2026-01-01T00:00:00Z", + }, + }; + const task = { + ...makeContainer({ + project: registration.name, + service: "task", + name: "task", + state: "exited", + }), + observation: { + state: "exited", + health: null, + exitCode: 0, + startedAt: "2026-01-01T00:00:01Z", + }, + }; + const views = await buildProjectViews({ + registryProjects: [registration], + runtime: [ + makeRuntimeProject({ + name: registration.name, + workingDir: registration.projectDir, + containersByService: { api: [api], task: [task] }, + }), + ], + runtimeOk: true, + filter: null, + includeUnregistered: false, + muxSessions: [], + }); + const view = views[0]; + if (!view) { + throw new Error("missing fixture view"); + } + const full = serializeProjectView(view); + const summary = serializeProjectSummary(view); + for (const key of ["backend", "state", "services", "started_at"]) { + expect(summary[key]).toEqual(full[key]); + } + expect(full.backend).toBe("compose"); + expect(full.state).toBe("running"); + expect(full.started_at).toBe("2026-01-01T00:00:00Z"); + expect(full.services).toMatchObject([ + { name: "api", ready: true }, + { name: "task", completed: true, exit_code: 0 }, + ]); +}); + +test("registered authored native rows use the observer only with explicit live-read admission", async () => { + const registration = await createProject({ + name: "native-observed", + services: ["api"], + }); + await rm(join(registration.projectDir, PROJECT_COMPOSE_FILENAME)); + await writeFile(join(registration.projectDir, "hack.project.json"), "{}\n"); + blockedReadRoot = registration.repoRoot; + const options = { + registryProjects: [registration], + runtime: [], + runtimeOk: false, + filter: null, + includeUnregistered: false, + muxSessions: [], + }; + const passive = await buildProjectViews(options); + expect(nativeReadCount).toBe(0); + expect(passive[0]?.observation?.state).toBe("unknown"); + const live = await buildProjectViews({ ...options, observeNative: true }); + expect(nativeReadCount).toBe(1); + const row = live[0]; + if (!row) { + throw new Error("missing native row"); + } + expect(serializeProjectView(row)).toMatchObject({ + backend: "native", + state: "running", + }); + expect(serializeProjectSummary(row).services).toEqual( + serializeProjectView(row).services + ); + expect(blockedReadPaths).toEqual([]); +}); + +test("native listing bounds status subprocess fan-out regardless of registry size", async () => { + const registrations: RegisteredProject[] = []; + for (let index = 0; index < 9; index++) { + const registration = await createProject({ + name: `native-${index}`, + services: [], + }); + await rm(join(registration.projectDir, PROJECT_COMPOSE_FILENAME)); + await writeFile(join(registration.projectDir, "hack.project.json"), "{}\n"); + registrations.push(registration); + } + const views = await buildProjectViews({ + registryProjects: registrations, + runtime: [], + runtimeOk: false, + filter: null, + includeUnregistered: false, + muxSessions: [], + observeNative: true, + }); + expect(views).toHaveLength(9); + expect(nativeReadCount).toBe(9); + expect(nativePeak).toBe(4); + expect(nativeActive).toBe(0); +}); diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 38c19b0b5..5a0084387 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -2,7 +2,11 @@ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; import { HACK_PROJECT_DIR_PRIMARY } from "../src/constants.ts"; import type { ProjectMeta } from "../src/lib/project-meta.ts"; -import type { ProjectView } from "../src/lib/project-views.ts"; +import { + type ProjectView, + serializeProjectSummary, + serializeProjectView, +} from "../src/lib/project-views.ts"; import type { RegisteredProject } from "../src/lib/projects-registry.ts"; import type { RuntimeProject } from "../src/lib/runtime-projects.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; @@ -804,3 +808,67 @@ test("summary payload keeps project identity and omits detailed runtime payloads "auto_register_ms" ); }); + +test("daemon full and summary preserve the shared backend/service observation", async () => { + const observation = { + backend: "native", + state: "running", + state_reason: null, + started_at: null, + services: [ + { + name: "api", + state: "running", + ready: true, + completed: null, + health: "healthy", + exit_code: null, + started_at: null, + }, + ], + } as const; + const view: ProjectView = { + name: "native-fixture", + devHost: null, + repoRoot: "/fixture", + projectDir: "/fixture/.hack", + definedServices: ["api"], + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: true, + runtimeStatus: "unknown", + runtime: null, + branchRuntime: [], + sessions: [], + lifecycle: null, + ownership: null, + worktrees: null, + kind: "registered", + status: "unknown", + observation, + }; + const cache = createRuntimeCache({ + deps: { + readProjectsRegistry: async () => ({ version: 1, projects: [] }), + buildProjectViews: async (opts) => { + expect(opts.observeNative).toBe(true); + return [view]; + }, + }, + }); + for (const summary of [false, true]) { + const payload = await cache.getProjectsPayload({ + filter: null, + includeGlobal: false, + includeUnregistered: false, + includeMeta: false, + summary, + }); + const cli = summary + ? serializeProjectSummary(view) + : serializeProjectView(view); + expect(payload.projects[0]).toEqual(cli); + expect(payload.projects[0]).toMatchObject(observation); + } +}); From 811bc74619e042819abd4069046c599282508eec Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:20:20 +0000 Subject: [PATCH 06/25] fix(projects): preserve profile activation and current exit evidence --- docs/reference/project-state.md | 24 +++++-- src/lib/compose-service-activation.ts | 67 ++++++++++++++++++ src/lib/project-observation.ts | 15 +++- src/lib/project-views.ts | 8 +++ src/lib/runtime-projects.ts | 17 ++++- tests/compose-service-activation.test.ts | 29 ++++++++ tests/project-views.test.ts | 76 ++++++++++++++++++++ tests/runtime-cache.test.ts | 89 ++++++++++++++++++++++++ tests/runtime-projects.test.ts | 47 ++++++++++++- 9 files changed, 362 insertions(+), 10 deletions(-) create mode 100644 src/lib/compose-service-activation.ts create mode 100644 tests/compose-service-activation.test.ts diff --git a/docs/reference/project-state.md b/docs/reference/project-state.md index 51ad8e05f..7fb21b70c 100644 --- a/docs/reference/project-state.md +++ b/docs/reference/project-state.md @@ -16,7 +16,14 @@ for compatibility; consumers of this contract use `state`. `backend` is `compose`, `native` or `unknown`. Project and service `state` is `running`, `degraded`, `starting`, `stopping`, `stopped`, `failed` or `unknown`. -Mixed running/terminated replicas are degraded. When no live member remains, +Profile services without observed containers are not required for project-state +aggregation unless a required service depends on them (transitively; +`required: false` dependencies do not activate them). Their service rows remain +visible. Observed profile containers participate even after they exit. The +listing process's environment is not used to guess a past profile selection. +`paused` and `removing` members remain unknown. Image-provided health checks +can conservatively make a started-role service degraded/not ready even without +a Compose health dependency. Mixed running/terminated replicas are degraded. When no live member remains, known failure wins over stopped members. Unknown evidence prevents a complete claim. Successful declared jobs do not degrade other running services. @@ -39,14 +46,23 @@ exit code, start time and health status, excluding environment values, commands, error text and health-check logs. The daemon's static inspect cache cannot supply current health/exit/start evidence; fields are null on a cache-only refresh. Failed reinspection clears prior state evidence while retaining static -identity. No extra inspect calls are introduced for cache hits. +identity. Unchanged running IDs remain query-free. Changed states and non-running IDs +are re-inspected during event refreshes, including repeated exited states: a +restart and second exit can reuse the same container ID. This keeps job completion +and failure known without waiting for the periodic full refresh. `started_at` is the earliest valid observed member start time, or null. It is never the observation time or a historical readiness time. Native status does not currently expose start timestamps, so native timestamps remain null. -Registered native projects remain in the listing. Authored native markers and -explicit native runtime selection choose the native observer. An absent runtime +Registered native projects remain in the listing. A Compose-format project's +backend is chosen by the observing process's runtime selection, not persisted +per project. Without native selection it is observed through Compose and can +report stopped even if that checkout is running on native elsewhere. Hackd uses +its own environment, including when started at login; it does not inherit later +shell selections. Consumers must not interpret a Compose observation as evidence +about a native run. Persisting backend identity is outside this contract. Authored native markers and +explicit native runtime selection choose the native observer. For authored native projects, an absent runtime selection, unavailable/busy provider, changed provider pin or unconfirmed owner produces unknown state with `state_reason: "observation-unavailable"`. Raw provider errors are omitted. An explicitly verified native runtime degradation diff --git a/src/lib/compose-service-activation.ts b/src/lib/compose-service-activation.ts new file mode 100644 index 000000000..2d56db87e --- /dev/null +++ b/src/lib/compose-service-activation.ts @@ -0,0 +1,67 @@ +import { isRecord } from "./guards.ts"; + +export type ComposeServiceActivation = { + readonly profiles: Readonly>; + readonly requiredDependencies: Readonly>; +}; + +/** Definition metadata only; a listing's environment does not prove which profiles ran. */ +export function composeServiceActivation( + document: unknown +): ComposeServiceActivation { + const profiles: Record = {}; + const requiredDependencies: Record = {}; + if (!(isRecord(document) && isRecord(document.services))) { + return { profiles, requiredDependencies }; + } + for (const [name, service] of Object.entries(document.services)) { + if (!isRecord(service)) { + continue; + } + profiles[name] = Array.isArray(service.profiles) + ? service.profiles.filter( + (profile): profile is string => + typeof profile === "string" && profile.length > 0 + ) + : []; + requiredDependencies[name] = dependencies(service.depends_on); + } + return { profiles, requiredDependencies }; +} + +function dependencies(value: unknown): readonly string[] { + if (Array.isArray(value)) { + return value.filter((name): name is string => typeof name === "string"); + } + if (!isRecord(value)) { + return []; + } + return Object.entries(value) + .filter( + ([, dependency]) => + !(isRecord(dependency) && dependency.required === false) + ) + .map(([name]) => name); +} + +/** Unprofiled, observed, and transitively required services contribute to project state. */ +export function requiredObservedServices(opts: { + readonly names: readonly string[]; + readonly observed: readonly string[]; + readonly activation?: ComposeServiceActivation; +}): ReadonlySet { + const required = new Set( + opts.names.filter((name) => !opts.activation?.profiles[name]?.length) + ); + for (const name of opts.observed) { + required.add(name); + } + // Set iteration visits newly inserted dependencies, including transitive ones. + for (const name of required) { + for (const dependency of opts.activation?.requiredDependencies[name] ?? + []) { + required.add(dependency); + } + } + return required; +} diff --git a/src/lib/project-observation.ts b/src/lib/project-observation.ts index a4a4cc8e2..226190bac 100644 --- a/src/lib/project-observation.ts +++ b/src/lib/project-observation.ts @@ -1,3 +1,4 @@ +import { requiredObservedServices } from "./compose-service-activation.ts"; import { earliestStart, observedProjectState, @@ -71,9 +72,21 @@ export function projectObservation(input: { ), }); }); + const required = requiredObservedServices({ + names: [...names], + observed: observable ? [...(view.runtime?.services.keys() ?? [])] : [], + activation: view.serviceActivation, + }); + const requiredServices = services.filter((service) => + required.has(service.name) + ); + const state = + requiredServices.length === 0 && services.length > 0 + ? "stopped" + : observedProjectState(requiredServices); return { backend, - state: observable ? observedProjectState(services) : "unknown", + state: observable ? state : "unknown", state_reason: reason, started_at: earliestStart(services.map((service) => service.started_at)), services, diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index 5aca62a2d..37c942efb 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -9,6 +9,10 @@ import { } from "../constants.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { parseSessionBase } from "../mux/session-names.ts"; +import { + type ComposeServiceActivation, + composeServiceActivation, +} from "./compose-service-activation.ts"; import { composeServiceReadiness } from "./compose-service-readiness.ts"; import { pathExists, readTextFile } from "./fs.ts"; import { isRecord } from "./guards.ts"; @@ -93,6 +97,7 @@ export type ProjectLifecycleView = { export type ProjectView = { readonly observation?: ProjectObservation; + readonly serviceActivation?: ComposeServiceActivation; readonly serviceReadiness?: Readonly>; readonly projectId?: string; readonly name: string; @@ -359,6 +364,7 @@ async function buildRegisteredProjectView(opts: { ownership: config && !config.parseError ? config.ownership : null, definedServices, serviceReadiness: composeDefinition?.readiness, + serviceActivation: composeDefinition?.activation, extensionsEnabled: extensions?.enabled ?? null, features: extensions?.features ?? null, serviceHosts, @@ -942,6 +948,7 @@ async function readComposeServices(opts: { }): Promise<{ names: readonly string[]; readiness: Readonly>; + activation: ComposeServiceActivation; } | null> { const text = await readTextFile(opts.composeFile); if (!text) { @@ -964,6 +971,7 @@ async function readComposeServices(opts: { ? Object.keys(servicesRaw).sort((a, b) => a.localeCompare(b)) : [], readiness: composeServiceReadiness(parsed), + activation: composeServiceActivation(parsed), }; } diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts index 068c31b02..37c50d204 100644 --- a/src/lib/runtime-projects.ts +++ b/src/lib/runtime-projects.ts @@ -216,6 +216,12 @@ export async function readRuntimeProjects(opts: { ? await readCachedContainerInspectData({ cache: opts.inspectCache, forceInspect: opts.forceInspect ?? true, + states: new Map( + baseRows.map((row) => [ + getString(row, "ID") ?? getString(row, "Id") ?? "", + getString(row, "State") ?? "", + ]) + ), ids, }) : await readContainerInspectData({ ids }) @@ -688,6 +694,7 @@ async function readContainerInspectData(opts: { async function readCachedContainerInspectData(opts: { readonly cache: RuntimeInspectCache; readonly forceInspect: boolean; + readonly states: ReadonlyMap; readonly ids: readonly string[]; }): Promise> { const currentIds = new Set(opts.ids); @@ -701,10 +708,16 @@ async function readCachedContainerInspectData(opts: { opts.cache.diagnostics.fullRefreshes += 1; } const missingIds = opts.ids.filter((id) => !opts.cache.entries.has(id)); - const idsToInspect = opts.forceInspect ? opts.ids : missingIds; + const idsToInspect = opts.forceInspect + ? opts.ids + : opts.ids.filter((id) => { + const previous = opts.cache.entries.get(id)?.observation; + const state = opts.states.get(id); + return !previous || state !== "running" || previous.state !== state; + }); opts.cache.diagnostics.cacheHits += opts.forceInspect ? 0 - : opts.ids.length - missingIds.length; + : opts.ids.length - idsToInspect.length; opts.cache.diagnostics.cacheMisses += missingIds.length; if (idsToInspect.length > 0) { diff --git a/tests/compose-service-activation.test.ts b/tests/compose-service-activation.test.ts new file mode 100644 index 000000000..96ca2e4f6 --- /dev/null +++ b/tests/compose-service-activation.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { + composeServiceActivation, + requiredObservedServices, +} from "../src/lib/compose-service-activation.ts"; + +test("inactive profiles stay optional while observed and transitively required services participate", () => { + const activation = composeServiceActivation({ + services: { + api: { depends_on: ["helper"] }, + helper: { + profiles: ["tools"], + depends_on: { init: {}, extra: { required: false } }, + }, + init: { profiles: ["tools"], depends_on: ["helper"] }, + extra: { profiles: ["tools"] }, + docs: { profiles: ["docs"] }, + }, + }); + const names = Object.keys(activation.profiles); + expect( + [...requiredObservedServices({ names, observed: [], activation })].sort() + ).toEqual(["api", "helper", "init"]); + expect( + [ + ...requiredObservedServices({ names, observed: ["docs"], activation }), + ].sort() + ).toEqual(["api", "docs", "helper", "init"]); +}); diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index 5955185bf..f497c1e0c 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -931,3 +931,79 @@ test("native listing bounds status subprocess fan-out regardless of registry siz expect(nativePeak).toBe(4); expect(nativeActive).toBe(0); }); + +test.each([ + { dependency: "", member: "absent", expected: "running" }, + { + dependency: " depends_on: [tool]\n", + member: "absent", + expected: "degraded", + }, + { + dependency: " depends_on:\n tool: {required: false}\n", + member: "absent", + expected: "running", + }, + { dependency: "", member: "exited", expected: "degraded" }, +])("profile activation keeps required aggregation honest: %j", async ({ + dependency, + member, + expected, +}) => { + const registration = await createProject({ + name: "profiles-fixture", + services: ["api", "tool"], + }); + await writeFile( + join(registration.projectDir, PROJECT_COMPOSE_FILENAME), + `services:\n api:\n image: example\n${dependency} tool:\n image: example\n profiles: [tools]\n` + ); + const api = makeContainer({ + project: registration.name, + service: "api", + name: "api", + state: "running", + }); + const tools = + member === "absent" + ? [] + : [ + { + ...makeContainer({ + project: registration.name, + service: "tool", + name: "tool", + state: member, + }), + observation: { + state: member, + health: null, + exitCode: 0, + startedAt: null, + }, + }, + ]; + const [view] = await buildProjectViews({ + registryProjects: [registration], + runtime: [ + makeRuntimeProject({ + name: registration.name, + workingDir: registration.projectDir, + containersByService: { + api: [api], + ...(tools.length ? { tool: tools } : {}), + }, + }), + ], + runtimeOk: true, + filter: null, + includeUnregistered: false, + muxSessions: [], + }); + if (!view) { + throw new Error("missing fixture view"); + } + expect(serializeProjectView(view).state).toBe(expected); + expect(serializeProjectSummary(view).state).toBe(expected); + expect(view.observation?.services).toHaveLength(2); +}); diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 5a0084387..77f73fae2 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -872,3 +872,92 @@ test("daemon full and summary preserve the shared backend/service observation", expect(payload.projects[0]).toMatchObject(observation); } }); + +test("an event refresh retains known job completion and publishes a subsequent crash", async () => { + function runtime(code: number): RuntimeProject { + const member = (service: string, state: string) => ({ + id: service, + project: "event-fixture", + service, + state, + status: state, + name: service, + ports: "", + workingDir: "/fixture/.hack", + image: null, + labels: null, + mounts: [], + networks: [], + observation: { + state, + health: null, + exitCode: state === "exited" ? code : null, + startedAt: null, + }, + }); + return { + project: "event-fixture", + workingDir: "/fixture/.hack", + isGlobal: false, + services: new Map([ + ["api", { service: "api", containers: [member("api", "running")] }], + ["task", { service: "task", containers: [member("task", "exited")] }], + ]), + }; + } + const cache = createRuntimeCache({ + deps: { + readProjectsRegistry: async () => ({ version: 1, projects: [] }), + buildProjectViews: async (opts) => [ + { + name: "event-fixture", + devHost: null, + repoRoot: "/fixture", + projectDir: "/fixture/.hack", + definedServices: ["api", "task"], + serviceReadiness: { api: "started", task: "completed" }, + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: true, + runtimeStatus: opts.runtimeOk ? "running" : "unknown", + runtime: opts.runtime[0] ?? null, + branchRuntime: [], + sessions: [], + lifecycle: null, + ownership: null, + worktrees: null, + kind: "registered", + status: "running", + }, + ], + }, + }); + for (const [index, code] of [0, 0, 2].entries()) { + runtimeQueue.push({ + ok: true, + runtime: [runtime(code)], + error: null, + checkedAtMs: Date.now(), + }); + await cache.refresh({ reason: "docker-event", forceInspect: index === 0 }); + const payload = await cache.getProjectsPayload({ + filter: null, + includeGlobal: false, + includeUnregistered: false, + includeMeta: false, + }); + expect(payload.projects[0]?.state).toBe( + code === 0 ? "running" : "degraded" + ); + expect(payload.projects[0]?.services).toMatchObject([ + { name: "api" }, + { name: "task", completed: code === 0, exit_code: code }, + ]); + } + expect(runtimeReadCalls.map((call) => call.forceInspect)).toEqual([ + true, + false, + false, + ]); +}); diff --git a/tests/runtime-projects.test.ts b/tests/runtime-projects.test.ts index e38f1033d..072e3acaa 100644 --- a/tests/runtime-projects.test.ts +++ b/tests/runtime-projects.test.ts @@ -18,6 +18,8 @@ import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; let dockerAvailable = false; let currentIds: string[] = []; let inspectExitCode = 0; +const memberStates = new Map(); +const memberExitCodes = new Map(); const inspectCalls: string[][] = []; let tempDir: string | null = null; let originalHackHome: string | undefined; @@ -105,6 +107,8 @@ beforeEach(async () => { dockerAvailable = false; currentIds = []; inspectExitCode = 0; + memberStates.clear(); + memberExitCodes.clear(); inspectCalls.length = 0; }); @@ -257,7 +261,7 @@ test("runtime auto-registration skips an input family that changes before regist function makePsRow(opts: { readonly id: string }): Record { return { ID: opts.id, - State: "running", + State: memberStates.get(opts.id) ?? "running", Status: "Up 10 seconds", Names: `container-${opts.id}`, Ports: "3000/tcp", @@ -275,8 +279,8 @@ function makeInspectRow(opts: { return { Id: `${opts.id}${"0".repeat(52)}`, State: { - Status: "running", - ExitCode: 0, + Status: memberStates.get(opts.id) ?? "running", + ExitCode: memberExitCodes.get(opts.id) ?? 0, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" }, }, @@ -346,3 +350,40 @@ test("failed reinspection keeps static identity but clears old current-state evi expect(second?.image).toBe("image:bbbbbbbbbbbb"); expect(second?.observation).toBeNull(); }); + +test("event refresh reinspects changed and terminated IDs without polling unchanged running peers", async () => { + dockerAvailable = true; + const job = "aaaaaaaaaaaa"; + const api = "bbbbbbbbbbbb"; + currentIds = [job, api]; + const cache = createRuntimeInspectCache(); + await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: true, + }); + memberStates.set(job, "exited"); + const finished = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: false, + }); + const completed = finished.runtime[0]?.services.get(`service-${job}`) + ?.containers[0]; + expect(completed?.observation?.exitCode).toBe(0); + expect( + finished.runtime[0]?.services.get(`service-${api}`)?.containers[0] + ?.observation + ).toBeNull(); + memberExitCodes.set(job, 2); + const failed = await readRuntimeProjects({ + includeGlobal: true, + inspectCache: cache, + forceInspect: false, + }); + expect( + failed.runtime[0]?.services.get(`service-${job}`)?.containers[0] + ?.observation?.exitCode + ).toBe(2); + expect(inspectCalls).toEqual([[job, api], [job], [job]]); +}); From 1dd47a614fe6ade0790d21c3ddd9062e7744895d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:26:43 +0000 Subject: [PATCH 07/25] fix(projects): honor explicitly disabled NONE health checks --- docs/reference/project-state.md | 3 ++- src/lib/compose-service-readiness.ts | 9 +++++++-- tests/compose-service-readiness.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/reference/project-state.md b/docs/reference/project-state.md index 7fb21b70c..972ecf426 100644 --- a/docs/reference/project-state.md +++ b/docs/reference/project-state.md @@ -29,7 +29,8 @@ claim. Successful declared jobs do not degrade other running services. `ready` and `completed` are nullable booleans. Unknown is not false: -- Compose roles come from current service configuration: a health check or a +- Compose roles come from current service configuration: `disable: true` and + `test: ["NONE"]` both disable a health check. An enabled health check or a `service_healthy` dependency requires health; a `service_completed_successfully` dependency declares a completion role; otherwise the role is started. A successful exit alone never declares a job. diff --git a/src/lib/compose-service-readiness.ts b/src/lib/compose-service-readiness.ts index 340f1c158..957fe065f 100644 --- a/src/lib/compose-service-readiness.ts +++ b/src/lib/compose-service-readiness.ts @@ -14,8 +14,13 @@ export function composeServiceReadiness( continue; } const health = service.healthcheck; - roles[name] = - isRecord(health) && health.disable !== true ? "healthy" : "started"; + const disabled = + isRecord(health) && + (health.disable === true || + (Array.isArray(health.test) && + health.test.length === 1 && + health.test[0] === "NONE")); + roles[name] = isRecord(health) && !disabled ? "healthy" : "started"; } applyDependencyRoles(document.services, roles); return roles; diff --git a/tests/compose-service-readiness.test.ts b/tests/compose-service-readiness.test.ts index a5f63e8f0..3446f3237 100644 --- a/tests/compose-service-readiness.test.ts +++ b/tests/compose-service-readiness.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { composeServiceReadiness } from "../src/lib/compose-service-readiness.ts"; +import { observeService } from "../src/lib/project-state.ts"; test("completion is declared by a dependency, never inferred from a stopped service", () => { expect( @@ -28,3 +29,27 @@ test("completion is declared by a dependency, never inferred from a stopped serv }); expect(composeServiceReadiness(null)).toEqual({}); }); + +test("NONE disables the health requirement while explicit dependency roles still apply", () => { + const roles = composeServiceReadiness({ + services: { + plain: { healthcheck: { test: ["NONE"] } }, + dependency: { healthcheck: { test: ["NONE"] } }, + consumer: { + depends_on: { dependency: { condition: "service_healthy" } }, + }, + }, + }); + expect(roles.plain).toBe("started"); + expect(roles.dependency).toBe("healthy"); + expect( + observeService({ + name: "plain", + available: true, + readiness: roles.plain ?? null, + members: [ + { state: "running", health: null, exitCode: null, startedAt: null }, + ], + }).ready + ).toBe(true); +}); From 47aee20e1cd1efe7574e466bb598cf9d4aa434c4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:37:11 +0000 Subject: [PATCH 08/25] fix(cli): standardize unpublished machine output contracts --- apps/desktop/README.md | 5 + apps/desktop/core/src/runner.rs | 91 ++++++++++-- apps/desktop/core/src/source.rs | 6 +- apps/desktop/core/tests/dump_state.rs | 4 +- .../machine-output/global-install-plan.json | 12 ++ .../machine-output/global-status-observe.json | 13 ++ .../machine-output/global-trust-plan.json | 12 ++ .../machine-output/plan-incomplete.json | 18 +++ .../machine-output/projects-observe.json | 10 ++ .../fixtures/machine-output/version.json | 10 ++ apps/desktop/core/tests/machine_output.rs | 68 +++++++++ docs/cli.md | 3 +- docs/reference.md | 1 + docs/reference/cli.md | 2 +- docs/reference/global-admin-plan.md | 94 ++++++++++--- docs/reference/global-status-observe.md | 68 ++++++--- docs/reference/machine-readable-output.md | 132 ++++++++++++++++++ docs/reference/machine-readable-output.mmd | 7 + docs/reference/machine-readable-output.svg | 66 +++++++++ docs/reference/version.md | 15 +- docs/versions/v5/guides/agent-first-setup.md | 5 + .../hack-diagnose/references/error-codes.md | 4 +- scripts/generate-machine-output-reference.ts | 30 ++++ scripts/lib/machine-output-fixtures.ts | 56 ++++++++ src/agents/error-code-reference.ts | 10 ++ src/cli/json-contracts.ts | 107 ++++++++++++++ src/cli/json-reference.ts | 65 +++++++++ src/cli/reference.ts | 2 +- src/commands/global-admin-plan.ts | 3 +- src/commands/global-status-observe.ts | 3 +- src/commands/global.ts | 52 +++++-- src/commands/projects.ts | 8 +- src/commands/version.ts | 7 +- src/lib/cli-result.ts | 9 +- src/lib/machine-output.ts | 16 +++ tests/admin-plan-json-contract.test.ts | 114 +++++++++++++++ tests/global-command.macos.test.ts | 9 +- tests/global-command.test.ts | 16 ++- tests/global-status-observe.test.ts | 21 ++- tests/helpers/machine-output.ts | 19 +++ tests/machine-output-contract.test.ts | 118 ++++++++++++++++ tests/projects-observe.test.ts | 30 +++- tests/version-report.test.ts | 4 +- 43 files changed, 1249 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/projects-observe.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/version.json create mode 100644 apps/desktop/core/tests/machine_output.rs create mode 100644 docs/reference/machine-readable-output.md create mode 100644 docs/reference/machine-readable-output.mmd create mode 100644 docs/reference/machine-readable-output.svg create mode 100644 scripts/generate-machine-output-reference.ts create mode 100644 scripts/lib/machine-output-fixtures.ts create mode 100644 src/cli/json-contracts.ts create mode 100644 src/cli/json-reference.ts create mode 100644 src/lib/machine-output.ts create mode 100644 tests/admin-plan-json-contract.test.ts create mode 100644 tests/helpers/machine-output.ts create mode 100644 tests/machine-output-contract.test.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 03fe99700..af83baccf 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -31,6 +31,11 @@ cargo run -p hack-desktop -- --list-fixtures cargo run -p hack-desktop -- --fixtures failed-start --theme dark ``` +The core reads version identity through `hack version --json` and validates its +`hack.version.v1` schema inside the success envelope. New schema-tagged commands +use the strict parser; daemon and published legacy payloads keep their existing +parsers. Shared boundary fixtures are checked against the CLI producers. + ## Check the live data path without a window ```sh diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index e41886c2a..67c4591ff 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -274,19 +274,41 @@ impl CliRunner { }) } - /// `hack --version` → `"5.0.0"` (text output until request R1). + /// Run a HACK-2218 command with a required envelope and payload schema. + /// Legacy JSON commands continue to use `run_json`. + pub fn run_schema_json( + &self, + args: &[&str], + path: Option<&Path>, + timeout: Duration, + schema: &str, + ) -> Result { + let (stdout, stderr, code) = self.run_raw(args, path, timeout)?; + let value = parse_schema_result(&stdout, &stderr, code, schema)?; + serde_json::from_value(value).map_err(|e| RunError::BadJson { + error: e.to_string(), + stdout_head: head(&stdout), + }) + } + + /// Read the running CLI version through the schema-tagged JSON contract. /// /// # Errors - /// Any [`RunError`]. + /// Any [`RunError`], including an unsupported or missing schema. pub fn version(&self, timeout: Duration) -> Result { - let (stdout, stderr, code) = self.run_raw(&["--version"], None, timeout)?; - if code != Some(0) { - return Err(RunError::Exit { code, stderr }); + #[derive(serde::Deserialize)] + struct Version { + version: String, } - parse_version(&stdout).ok_or_else(|| RunError::BadJson { - error: "no version in output".into(), - stdout_head: head(&stdout), - }) + let report: Version = + self.run_schema_json(&["version", "--json"], None, timeout, "hack.version.v1")?; + if report.version.is_empty() { + return Err(RunError::BadJson { + error: "empty version".into(), + stdout_head: String::new(), + }); + } + Ok(report.version) } /// Start a long-running ndjson stream (`hack logs --json ...`). @@ -535,6 +557,53 @@ pub fn parse_result(stdout: &str, stderr: &str, code: Option) -> Result, + schema: &str, +) -> Result { + let invalid = || RunError::BadJson { + error: format!("expected CLI envelope with schema {schema}"), + stdout_head: head(stdout), + }; + let value: Value = serde_json::from_str(stdout).map_err(|_| invalid())?; + if value.as_object().is_none_or(|object| object.len() != 2) { + return Err(invalid()); + } + match value.get("ok").and_then(Value::as_bool) { + Some(false) if code.is_some_and(|code| code != 0) => { + let error = value.get("error").ok_or_else(invalid)?; + if !error + .get("code") + .and_then(Value::as_str) + .is_some_and(|code| { + code.strip_prefix("E_").is_some_and(|suffix| { + !suffix.is_empty() + && suffix.bytes().all(|byte| { + byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_' + }) + }) + }) + || error.get("message").and_then(Value::as_str).is_none() + { + return Err(invalid()); + } + parse_result(stdout, stderr, code) + } + Some(true) if code == Some(0) => { + let data = value.get("data").ok_or_else(invalid)?; + if data.get("schema").and_then(Value::as_str) != Some(schema) { + return Err(invalid()); + } + Ok(data.clone()) + } + _ => Err(invalid()), + } +} + /// `hack v5.0.0` / `5.0.0-next.3` → the version string. #[must_use] pub fn parse_version(text: &str) -> Option { @@ -884,7 +953,9 @@ wait $!"#, Some("5.0.0-next.3") ); assert_eq!(parse_version("hack"), None); - let f = FakeHack::new("echo 'hack v5.0.0'"); + let f = FakeHack::new( + r#"echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}'"#, + ); assert_eq!(f.runner().version(T).unwrap(), "5.0.0"); } diff --git a/apps/desktop/core/src/source.rs b/apps/desktop/core/src/source.rs index 1c97ea5f0..410aef378 100644 --- a/apps/desktop/core/src/source.rs +++ b/apps/desktop/core/src/source.rs @@ -570,7 +570,7 @@ mod tests { &hack, r#"#!/bin/sh case "$1" in - --version) echo "hack v5.0.0" ;; + version) echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}' ;; projects) echo '{"runtime_ok":true,"projects":[{"name":"svc","status":"running","repo_root":"/work/svc"},{"name":"nat","status":"unavailable","repo_root":"/work/nat","input_diagnostic":{"code":"E_NATIVE_PROJECT_UNSUPPORTED","message":"m"}}]}' ;; ps) if [ "$HACK_RUNTIME_BACKEND" = native ]; then echo '{"ok":true,"data":{"backend":"native","status":"pending","items":[]}}'; else echo '{"ok":false,"error":{"code":"E_NATIVE_PROJECT_UNSUPPORTED","message":"m"}}'; exit 1; fi ;; esac @@ -579,7 +579,7 @@ esac .unwrap(); std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o755)).unwrap(); // Complete macOS first-launch scanning before the fake daemon's - // bounded accept window starts. --version has no fixture effects. + // bounded accept window starts. version --json has no fixture effects. CliRunner::new(&hack) .version(Duration::from_secs(60)) .unwrap(); @@ -629,7 +629,7 @@ esac let hack = dir.join("hack"); std::fs::write( &hack, - "#!/bin/sh\ncase \"$1\" in --version) echo 'hack v5.0.0' ;; projects) echo '{}' ;; esac\n", + "#!/bin/sh\ncase \"$1\" in version) echo '{\"ok\":true,\"data\":{\"schema\":\"hack.version.v1\",\"version\":\"5.0.0\"}}' ;; projects) echo '{}' ;; esac\n", ) .unwrap(); std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o755)).unwrap(); diff --git a/apps/desktop/core/tests/dump_state.rs b/apps/desktop/core/tests/dump_state.rs index 79135841f..f60333a03 100644 --- a/apps/desktop/core/tests/dump_state.rs +++ b/apps/desktop/core/tests/dump_state.rs @@ -73,7 +73,7 @@ fn dump_state_reads_the_cli_like_the_app_and_prints_view_models() { &hack, r#"#!/bin/sh case "$1" in - --version) echo "hack v5.0.0" ;; + version) echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}' ;; projects) echo called > "$HACK_HOME/unsafe-listing"; exit 2 ;; *) echo "unexpected: $*" >&2; exit 2 ;; esac @@ -145,7 +145,7 @@ fn dump_state_without_hack_reports_the_missing_cli() { fn missing_daemon_never_runs_the_registry_writing_cli_listing() { let home = temp_dir("observe"); let hack = home.join("hack"); - std::fs::write(&hack, "#!/bin/sh\ncase \"$1\" in --version) echo 'hack v5.0.0';; *) echo called > \"$HACK_HOME/unexpected\"; exit 1;; esac\n").unwrap(); + std::fs::write(&hack, "#!/bin/sh\ncase \"$1\" in version) echo '{\"ok\":true,\"data\":{\"schema\":\"hack.version.v1\",\"version\":\"5.0.0\"}}' ;; *) echo called > \"$HACK_HOME/unexpected\"; exit 1;; esac\n").unwrap(); std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o700)).unwrap(); let out = Command::new(env!("CARGO_BIN_EXE_hack-desktop-state")) .env("PATH", &home) diff --git a/apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json b/apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json new file mode 100644 index 000000000..a32920e8c --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "data": { + "command": "install", + "platform": "linux", + "refused": null, + "complete": true, + "blocked": [], + "steps": [], + "schema": "hack.global-admin-plan.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json b/apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json new file mode 100644 index 000000000..027202bba --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "data": { + "observe": true, + "docker": { + "state": "not_running" + }, + "summary": { + "ok": "unknown" + }, + "schema": "hack.global-status.observe.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json b/apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json new file mode 100644 index 000000000..1b6bf94a5 --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "data": { + "command": "trust", + "platform": "linux", + "refused": null, + "complete": true, + "blocked": [], + "steps": [], + "schema": "hack.global-admin-plan.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json b/apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json new file mode 100644 index 000000000..e930570d7 --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json @@ -0,0 +1,18 @@ +{ + "ok": false, + "error": { + "code": "E_PLAN_INCOMPLETE", + "message": "Administrator plan is incomplete.", + "detail": { + "plan": { + "command": "install", + "platform": "linux", + "refused": null, + "complete": false, + "blocked": [], + "steps": [], + "schema": "hack.global-admin-plan.v1" + } + } + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/projects-observe.json b/apps/desktop/core/tests/fixtures/machine-output/projects-observe.json new file mode 100644 index 000000000..87802e7ce --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/projects-observe.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "data": { + "observe": true, + "runtime_requested": false, + "runtime_ok": false, + "projects": [], + "schema": "hack.projects.observe.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/version.json b/apps/desktop/core/tests/fixtures/machine-output/version.json new file mode 100644 index 000000000..440c4288a --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/version.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "data": { + "version": "5.0.0", + "channel": "stable", + "install_kind": "standalone", + "binary_path": "/example/bin/hack", + "schema": "hack.version.v1" + } +} diff --git a/apps/desktop/core/tests/machine_output.rs b/apps/desktop/core/tests/machine_output.rs new file mode 100644 index 000000000..e307ed520 --- /dev/null +++ b/apps/desktop/core/tests/machine_output.rs @@ -0,0 +1,68 @@ +//! The same fixtures are checked against the CLI producers by Bun tests. +use hack_desktop_core::runner::{RunError, parse_schema_result}; +use serde_json::Value; + +#[test] +fn migrated_payloads_require_the_expected_envelope_and_schema() { + let cases = [ + ( + include_str!("fixtures/machine-output/version.json"), + "hack.version.v1", + ), + ( + include_str!("fixtures/machine-output/projects-observe.json"), + "hack.projects.observe.v1", + ), + ( + include_str!("fixtures/machine-output/global-status-observe.json"), + "hack.global-status.observe.v1", + ), + ( + include_str!("fixtures/machine-output/global-install-plan.json"), + "hack.global-admin-plan.v1", + ), + ( + include_str!("fixtures/machine-output/global-trust-plan.json"), + "hack.global-admin-plan.v1", + ), + ]; + for (text, schema) in cases { + let value = parse_schema_result(text, "", Some(0), schema).unwrap(); + assert_eq!(value["schema"], schema); + // Bare payloads, wrong schemas and success with a failing process are refused. + assert!(parse_schema_result(&value.to_string(), "", Some(0), schema).is_err()); + assert!(parse_schema_result(text, "", Some(0), "unknown.schema").is_err()); + assert!(parse_schema_result(text, "", Some(1), schema).is_err()); + } +} + +#[test] +fn incomplete_admin_plan_is_an_error_with_preserved_recovery_evidence() { + let text = include_str!("fixtures/machine-output/plan-incomplete.json"); + let error = parse_schema_result(text, "", Some(3), "hack.global-admin-plan.v1").unwrap_err(); + match error { + RunError::Cli(error) => { + assert_eq!(error.code, "E_PLAN_INCOMPLETE"); + assert_eq!( + error.detail.unwrap()["plan"]["schema"], + "hack.global-admin-plan.v1" + ); + } + other => panic!("unexpected result: {other:?}"), + } + assert!(parse_schema_result(text, "", Some(0), "hack.global-admin-plan.v1").is_err()); +} + +#[test] +fn malformed_envelopes_cannot_fall_back_to_legacy_parsing() { + for value in [ + serde_json::json!({"ok": true, "data": {}}), + serde_json::json!({"ok": false, "error": "legacy message"}), + serde_json::json!({"ok": false, "error": {"code": "BAD", "message": "message"}}), + serde_json::json!({"ok": false, "error": {"code": "E_", "message": "message"}}), + serde_json::json!({"ok": true, "data": {"schema": "hack.version.v1"}, "extra": true}), + Value::Null, + ] { + assert!(parse_schema_result(&value.to_string(), "", Some(1), "hack.version.v1").is_err()); + } +} diff --git a/docs/cli.md b/docs/cli.md index dc4b4c9ff..5dbf5bf57 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -132,7 +132,8 @@ ps/inspect reads, which can start a socket-activated engine (for example a systemd Docker socket or an on-demand macOS engine). Both modes bypass hackd, including when a daemon is available. `--all` discovers runtime-only projects only with `--runtime`. -Observe returns the normal listing fields plus `observe: true` and +Observe returns a success envelope with `data.schema: "hack.projects.observe.v1"`. +The `data` payload contains the normal listing fields plus `observe: true` and `runtime_requested: false|true`. Without runtime queries, `runtime_error` and `runtime_checked_at` are null rather than claiming Docker failed. Each full or summary row adds `backend`, `state`, `state_reason`, `started_at` and `services`. diff --git a/docs/reference.md b/docs/reference.md index 696fb696e..72070ff00 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -11,6 +11,7 @@ ## Environment & sessions - [Version identity](./reference/version.md) — `hack version --json` describes the running CLI without starting services +- [Machine-readable output](./reference/machine-readable-output.md) — envelope rule, schema identifiers and the complete JSON command inventory - [Environment model](./env.md) - [Lifecycle & startup](./lifecycle.md) - [Admin plans](./reference/global-admin-plan.md) — `hack global install|trust --plan --json`: the ordered root and user steps and exact root argv, without running anything diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af5b832cf..cc6f8fb96 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4,7 +4,7 @@ hack — Local development without the port-collision tax -Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); commands with `--json` emit a `{ok, data, error}` envelope on stdout. +Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); new JSON contracts use schema-tagged envelopes; published legacy and streaming formats are listed in [Machine-readable output](./machine-readable-output.md). ## Project diff --git a/docs/reference/global-admin-plan.md b/docs/reference/global-admin-plan.md index 456612238..b38872060 100644 --- a/docs/reference/global-admin-plan.md +++ b/docs/reference/global-admin-plan.md @@ -40,30 +40,79 @@ to run the listed commands. ```json { - "schema": "hack.global-admin-plan.v1", - "command": "trust", - "platform": "linux", - "refused": null, - "complete": true, - "blocked": [], - "steps": [ - { - "order": 1, - "privilege": "root", - "id": "system-trust-install", - "description": "Install the CA (SHA-256 …) into the debian trust anchors; root accepts only these exact bytes", - "argv": ["/usr/bin/sudo", "/usr/bin/env", "-i", "PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C", "LC_ALL=C", "/bin/sh", "-c", "", "hack-install", "", "", "/usr/local/share/ca-certificates/hack-caddy-local-ca.crt", "0644"], - "shell": "/usr/bin/sudo /usr/bin/env -i … 0644", - "stdin": { "bytes": 652, "sha256": "…", "content": "the verified Caddy Local CA (PEM)" }, - "when": "after you confirm the system-wide trust prompt on an interactive terminal; …" - }, - { "order": 2, "privilege": "root", "id": "system-trust-refresh", "argv": ["/usr/bin/sudo", "/usr/bin/env", "-i", "…", "/usr/sbin/update-ca-certificates"], "…": "…" }, - { "order": 3, "privilege": "user", "id": "nss-trust", "description": "Add or update Hack's CA in each browser NSS database you own (certutil, as you)", "when": "…" }, - { "order": 4, "privilege": "user", "id": "host-tls-env", "description": "…", "when": "…" } - ] + "ok": true, + "data": { + "schema": "hack.global-admin-plan.v1", + "command": "trust", + "platform": "linux", + "refused": null, + "complete": true, + "blocked": [], + "steps": [ + { + "order": 1, + "privilege": "root", + "id": "system-trust-install", + "description": "Install the CA (SHA-256 \u2026) into the debian trust anchors; root accepts only these exact bytes", + "argv": [ + "/usr/bin/sudo", + "/usr/bin/env", + "-i", + "PATH=/usr/sbin:/usr/bin:/sbin:/bin", + "LANG=C", + "LC_ALL=C", + "/bin/sh", + "-c", + "", + "hack-install", + "", + "", + "/usr/local/share/ca-certificates/hack-caddy-local-ca.crt", + "0644" + ], + "shell": "/usr/bin/sudo /usr/bin/env -i \u2026 0644", + "stdin": { + "bytes": 652, + "sha256": "\u2026", + "content": "the verified Caddy Local CA (PEM)" + }, + "when": "after you confirm the system-wide trust prompt on an interactive terminal; \u2026" + }, + { + "order": 2, + "privilege": "root", + "id": "system-trust-refresh", + "argv": [ + "/usr/bin/sudo", + "/usr/bin/env", + "-i", + "\u2026", + "/usr/sbin/update-ca-certificates" + ], + "\u2026": "\u2026" + }, + { + "order": 3, + "privilege": "user", + "id": "nss-trust", + "description": "Add or update Hack's CA in each browser NSS database you own (certutil, as you)", + "when": "\u2026" + }, + { + "order": 4, + "privilege": "user", + "id": "host-tls-env", + "description": "\u2026", + "when": "\u2026" + } + ] + } } ``` +Fields below are inside `data` on success. Refused or incomplete plans use an +error envelope with `error.detail.plan` retaining the same plan payload. + | Field | Meaning | |---|---| | `refused` | the real command would refuse (native runtime, slim mode, an invalid CA); no steps | @@ -72,7 +121,8 @@ to run the listed commands. | `steps[].when` | when the real command takes the step: after a confirmation, only interactively, only if a check passes | | `steps[].gates` | on a probe: the step it can skip (macOS non-interactive runs check `sudo -n true` before the keychain step) | -Exit code: 0 for a complete plan, 1 when `refused`, 3 when incomplete; `--plan` +Exit code: 0 for a complete success envelope, 1 with `E_PLAN_REFUSED` when +refused, 3 with `E_PLAN_INCOMPLETE` when incomplete; `--plan` without `--json` exits 2. `--json` without `--plan` also exits 2 before any effect. ## What `hack global trust` plans diff --git a/docs/reference/global-status-observe.md b/docs/reference/global-status-observe.md index 6cf8eb5c8..364f43e81 100644 --- a/docs/reference/global-status-observe.md +++ b/docs/reference/global-status-observe.md @@ -49,24 +49,50 @@ could not tell; it is never reported as absent or as a failure of the thing. ```json { - "schema": "hack.global-status.observe.v1", - "generated_at": "2026-11-14T00:00:00.000Z", - "observe": true, - "platform": "linux", - "docker": { "state": "running", "detail": null }, - "caddy": { "ok": true, "error": null, "services": [] }, - "logging": { "ok": true, "error": null, "services": [] }, - "networks": { "ok": true, "missing": [], "networks": [] }, - "proxy_network": { "ok": true, "readable": true, "issues": [] }, - "host_dns": { "platform": "linux", "state": "configured", "ok": true }, - "summary": { - "docker_ok": true, - "caddy_ok": true, - "logging_ok": true, - "networks_ok": true, - "proxy_network_ok": true, - "host_dns_ok": true, - "ok": true + "ok": true, + "data": { + "schema": "hack.global-status.observe.v1", + "generated_at": "2026-11-14T00:00:00.000Z", + "observe": true, + "platform": "linux", + "docker": { + "state": "running", + "detail": null + }, + "caddy": { + "ok": true, + "error": null, + "services": [] + }, + "logging": { + "ok": true, + "error": null, + "services": [] + }, + "networks": { + "ok": true, + "missing": [], + "networks": [] + }, + "proxy_network": { + "ok": true, + "readable": true, + "issues": [] + }, + "host_dns": { + "platform": "linux", + "state": "configured", + "ok": true + }, + "summary": { + "docker_ok": true, + "caddy_ok": true, + "logging_ok": true, + "networks_ok": true, + "proxy_network_ok": true, + "host_dns_ok": true, + "ok": true + } } } ``` @@ -83,8 +109,10 @@ could not tell; it is never reported as absent or as a failure of the thing. | `host_dns` (other) | `state: "unknown"`, `ok: "unknown"` | | `summary.ok` | `false` if any part is `false`; otherwise `"unknown"` if any part is; otherwise `true` | -Exit code: 0 whenever the JSON was printed, whatever it says; read the `ok` -fields. +Exit code 0 means the observation was collected, with the report inside `data` +and `data.schema: "hack.global-status.observe.v1"`. Read `data.summary.ok` and +the individual report parts for health; top-level `ok` is not a health claim. +A command failure uses the standard error envelope and a nonzero exit. Not included: the gateway section of plain `hack global status --json` (it reads the daemon and token store and is not routing state). diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md new file mode 100644 index 000000000..d98b17767 --- /dev/null +++ b/docs/reference/machine-readable-output.md @@ -0,0 +1,132 @@ +# Machine-readable output + +New `--json` outputs added for HACK-2218 use one success envelope: + +```json +{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}} +``` + +The stable schema identifier belongs inside `data`. A payload schema changes +when its incompatible contract changes. Consumers unwrap `data` and validate +its schema before using it. Human diagnostics go to stderr. + +Failures use a stable error code and a nonzero exit status: + +```json +{"ok":false,"error":{"code":"E_USAGE","message":"Use a supported option combination."}} +``` + +Errors may carry `error.detail`. A refused administrator plan uses +`E_PLAN_REFUSED` (exit 1); an incomplete plan uses `E_PLAN_INCOMPLETE` (exit 3). +Both retain the partial plan, including its schema, at `error.detail.plan`. +Unknown/offline state in an observation is still a successful observation; +it is not a claim that the runtime is healthy. + +The previously unpublished version, observe-only project/status and admin-plan +outputs now follow this rule. Plain `projects --json` and other published legacy +outputs keep their shapes. A consumer must not use the new observe-only parser +for a plain projects result. Streaming interfaces remain NDJSON and have their +own event contracts. No published payload is silently wrapped by this change. + +![JSON command boundary](./machine-readable-output.svg) + +## Command inventory + +This inventory includes every canonical command exposing `--json`, including +retained unsupported command groups. Aliases have the same contract as their +canonical command. “Legacy” records the existing output; it does not recommend +that shape for a new interface. Backend-dependent entries need their existing +command-specific parser. Do not infer a success shape from an error envelope. + +Regenerate with `bun scripts/generate-machine-output-reference.ts`. Tests compare +the inventory to the command definitions and exercise every command's shared +usage-error boundary without entering its handler. The migrated success variants +also have real CLI-boundary contract tests; existing legacy success behavior +remains covered by its command-specific suites. + + +| Canonical command / variant | Success shape | +|---|---| +| `hack activity --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack commands cancel --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack commands read --json` | NDJSON events / terminal event, not a single envelope | +| `hack commands show --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack config adopt --json` | Legacy bare object (no `data` envelope) | +| `hack config import --json` | Legacy bare object (no `data` envelope) | +| `hack config plan --json` | Native compiler protocol object (`transport_version`, `ok`, result / diagnostics) | +| `hack config validate --json` | Native compiler protocol object (`transport_version`, `ok`, result / diagnostics) | +| `hack daemon status --json` | Legacy bare object (no `data` envelope) | +| `hack dispatch logs --json` | Legacy bare object (no `data` envelope) | +| `hack dispatch run --json` | Legacy bare object (no `data` envelope) | +| `hack dispatch status --json` | Legacy bare object (no `data` envelope) | +| `hack doctor --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack down --json` | Backend-dependent: envelope or legacy bare object | +| `hack env apply --json` | Legacy bare object (no `data` envelope) | +| `hack env backend status --json` | Legacy bare object (no `data` envelope) | +| `hack env backend use --json` | Legacy bare object (no `data` envelope) | +| `hack env explain --json` | Legacy bare object (no `data` envelope) | +| `hack env hydrate --json` | Legacy bare object (no `data` envelope) | +| `hack env list --json` | Legacy bare object (no `data` envelope) | +| `hack env protect --json` | Legacy bare object (no `data` envelope) | +| `hack exec --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack exec-sessions --json` | Legacy bare object (no `data` envelope) | +| `hack global dns preview --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack global install --plan --json` | Envelope; versioned `data.schema`: `hack.global-admin-plan.v1` | +| `hack global status --json` | Legacy bare object (no `data` envelope) | +| `hack global trust --plan --json` | Envelope; versioned `data.schema`: `hack.global-admin-plan.v1` | +| `hack host ps --json` | Legacy bare object (no `data` envelope) | +| `hack logs --json` | NDJSON logs; finite native modes may return a legacy snapshot object | +| `hack node add --json` | Legacy bare object (no `data` envelope) | +| `hack node devcontainer attach --json` | Legacy bare object (no `data` envelope) | +| `hack node devcontainer down --json` | Legacy bare object (no `data` envelope) | +| `hack node devcontainer up --json` | Legacy bare object (no `data` envelope) | +| `hack node init --json` | Legacy bare object (no `data` envelope) | +| `hack node list --json` | Legacy bare object (no `data` envelope) | +| `hack node pair --json` | Legacy bare object (no `data` envelope) | +| `hack node pair approve --json` | Legacy bare object (no `data` envelope) | +| `hack node pair cancel --json` | Legacy bare object (no `data` envelope) | +| `hack node pair complete --json` | Legacy bare object (no `data` envelope) | +| `hack node pair fulfill --json` | Legacy bare object (no `data` envelope) | +| `hack node pair list --json` | Legacy bare object (no `data` envelope) | +| `hack node pair request --json` | Legacy bare object (no `data` envelope) | +| `hack node pair start --json` | Legacy bare object (no `data` envelope) | +| `hack node pair walkthrough --json` | Legacy bare object (no `data` envelope) | +| `hack node provider railway bootstrap --json` | Legacy bare object (no `data` envelope) | +| `hack node remove --json` | Legacy bare object (no `data` envelope) | +| `hack node routes repair --json` | Legacy bare object (no `data` envelope) | +| `hack node routes status --json` | Legacy bare object (no `data` envelope) | +| `hack node ssh setup --json` | Legacy bare object (no `data` envelope) | +| `hack node status --json` | Legacy bare object (no `data` envelope) | +| `hack node use --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace attach --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace list --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace remove --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace resolve --json` | Legacy bare object (no `data` envelope) | +| `hack open --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack project owner show --json` | Legacy bare object (no `data` envelope) | +| `hack project target --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack project task inspect --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack project task list --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack projects --json` | Legacy bare object (no `data` envelope) | +| `hack projects prune --json` | Legacy bare object (no `data` envelope) | +| `hack ps --json` | Backend-dependent: envelope or legacy bare object | +| `hack restart --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack run --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack session capture --json` | NDJSON events / terminal event, not a single envelope | +| `hack session panes --json` | NDJSON events / terminal event, not a single envelope | +| `hack session tail --json` | NDJSON events / terminal event, not a single envelope | +| `hack setup migrate --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack setup sync --json` | Retired: usage-error envelope, no success payload | +| `hack status --json` | Backend-dependent: envelope or legacy bare object | +| `hack up --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack update --json` | Legacy result (`ok` plus fields, no `data`; error may be a string) | +| `hack upgrade apply --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade check --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade review --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade rollback --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade snapshot --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack usage --json` | Legacy bare object (no `data` envelope) | +| `hack version --json` | Envelope; versioned `data.schema`: `hack.version.v1` | +| `hack projects --json --observe` | Envelope; `data.schema: hack.projects.observe.v1` | +| `hack global status --json --observe` | Envelope; `data.schema: hack.global-status.observe.v1` | + diff --git a/docs/reference/machine-readable-output.mmd b/docs/reference/machine-readable-output.mmd new file mode 100644 index 000000000..2522472fb --- /dev/null +++ b/docs/reference/machine-readable-output.mmd @@ -0,0 +1,7 @@ +flowchart LR + C[New JSON command] --> O{Command outcome} + O -->|success| D[ok true with data and schema] + O -->|failure| E[ok false with stable error code] + D --> V[Client validates schema] + E --> F[Nonzero exit and optional recovery detail] + L[Published legacy command] --> P[Existing parser unchanged] diff --git a/docs/reference/machine-readable-output.svg b/docs/reference/machine-readable-output.svg new file mode 100644 index 000000000..10335d781 --- /dev/null +++ b/docs/reference/machine-readable-output.svg @@ -0,0 +1,66 @@ +successfailureNew JSON commandCommand outcomeok true with data andschemaok false with stable errorcodeClient validates schemaNonzero exit and optionalrecovery detailPublished legacycommandExisting parser unchanged \ No newline at end of file diff --git a/docs/reference/version.md b/docs/reference/version.md index f88e84202..90ac8b500 100644 --- a/docs/reference/version.md +++ b/docs/reference/version.md @@ -5,7 +5,16 @@ lookup, project discovery, registry writes or starting Docker, a VM or hackd. Plain `hack version` and `hack --version` keep their text output. ```json -{"version":"5.0.0-next.1","channel":"next","install_kind":"standalone","binary_path":"/example/bin/hack"} +{ + "ok": true, + "data": { + "version": "5.0.0-next.1", + "channel": "next", + "install_kind": "standalone", + "binary_path": "/example/bin/hack", + "schema": "hack.version.v1" + } +} ``` - `version` is the running CLI's embedded version. @@ -23,8 +32,8 @@ Plain `hack version` and `hack --version` keep their text output. The OS may already have resolved a launch symlink, so its original spelling is not guaranteed. -The JSON is a bare object with these four fields, without an `ok` or `data` -envelope. +The four identity fields and `schema: "hack.version.v1"` are inside `data` +in the standard success envelope. See [Machine-readable output](./machine-readable-output.md). The report inspects only local executable metadata and, for compiled installs, its initial bytes. It does not check whether a newer version exists. diff --git a/docs/versions/v5/guides/agent-first-setup.md b/docs/versions/v5/guides/agent-first-setup.md index 2e7c00acd..b5a992b85 100644 --- a/docs/versions/v5/guides/agent-first-setup.md +++ b/docs/versions/v5/guides/agent-first-setup.md @@ -44,3 +44,8 @@ install kind and executable path without starting services or checking the network. The JSON fields are `version`, `channel`, `install_kind` and `binary_path`; a source run reports Bun as its executable and `source` as its install kind. + +New machine-readable contracts return `{ok:true,data:{schema,...}}`; failures +return `{ok:false,error:{code,message}}` with a nonzero exit. Version identity, +observe-only projects/global status and administrator plans use this envelope. +Published plain project listing and existing streaming formats keep their shapes. diff --git a/plugins/hack/skills/hack-diagnose/references/error-codes.md b/plugins/hack/skills/hack-diagnose/references/error-codes.md index 36e09998b..200685bee 100644 --- a/plugins/hack/skills/hack-diagnose/references/error-codes.md +++ b/plugins/hack/skills/hack-diagnose/references/error-codes.md @@ -19,6 +19,8 @@ stay within the request. | `E_COMPOSE_FAILED` | A Compose operation failed or left a stop incomplete. | `hack logs --json --no-follow --tail 200` | | `E_COMPOSE_OUTPUT_INVALID` | Compose exited successfully but printed output Hack cannot parse as JSON, usually an unsupported Compose version. | `docker compose version` | | `E_STARTUP_INCOMPLETE` | After start or restart, a service is neither running nor a successful one-shot. | `hack ps --json` | +| `E_PLAN_REFUSED` | A read-only administrator plan established that the command would refuse to run. | Read `error.detail.plan.refused`; no planned step ran. | +| `E_PLAN_INCOMPLETE` | An administrator plan cannot yet determine every required phase. | Read `error.detail.plan.blocked` and re-plan after its prerequisites are satisfied. | | `E_DEPENDENCY_BOOTSTRAP_FAILED` | A dependency installer service did not complete successfully. | `hack logs --json --no-follow --tail 200` | | `E_STARTUP_TIMEOUT` | Bounded startup ran out of time before services settled. | `hack ps --json` | | `E_CANCELLED` | The caller cancelled the operation; pending ownership is retained. | `hack ps --json` | @@ -43,4 +45,4 @@ stay within the request. --- -Content revision: `e104e92c08a5`. Generated by Hack; edit the canonical source and regenerate the plugin. +Content revision: `3dd842ac3170`. Generated by Hack; edit the canonical source and regenerate the plugin. diff --git a/scripts/generate-machine-output-reference.ts b/scripts/generate-machine-output-reference.ts new file mode 100644 index 000000000..2f81ec397 --- /dev/null +++ b/scripts/generate-machine-output-reference.ts @@ -0,0 +1,30 @@ +import { renderJsonInventory } from "../src/cli/json-reference.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { machineOutputFixtures } from "./lib/machine-output-fixtures.ts"; + +const path = new URL( + "../docs/reference/machine-readable-output.md", + import.meta.url +); +const text = await Bun.file(path).text(); +const start = ""; +const end = ""; +if ( + text.split(start).length !== 2 || + text.split(end).length !== 2 || + text.indexOf(end) <= text.indexOf(start) +) { + throw new Error("Machine output inventory markers must occur once, in order"); +} +const next = `${text.slice(0, text.indexOf(start) + start.length)}\n${renderJsonInventory(CLI_SPEC)}${text.slice(text.indexOf(end))}`; +await Bun.write(path, next); + +for (const [name, fixture] of Object.entries(machineOutputFixtures())) { + await Bun.write( + new URL( + `../apps/desktop/core/tests/fixtures/machine-output/${name}`, + import.meta.url + ), + `${JSON.stringify(fixture, null, 2)}\n` + ); +} diff --git a/scripts/lib/machine-output-fixtures.ts b/scripts/lib/machine-output-fixtures.ts new file mode 100644 index 000000000..2233745e9 --- /dev/null +++ b/scripts/lib/machine-output-fixtures.ts @@ -0,0 +1,56 @@ +import { errorResult } from "../../src/lib/cli-result.ts"; +import { + MACHINE_SCHEMAS, + machineSuccess, +} from "../../src/lib/machine-output.ts"; + +/** Minimal payloads for cross-language boundary tests, not runtime-state fixtures. */ +export function machineOutputFixtures() { + const plan = { + command: "install", + platform: "linux", + refused: null, + complete: true, + blocked: [], + steps: [], + }; + const success = ( + schema: (typeof MACHINE_SCHEMAS)[keyof typeof MACHINE_SCHEMAS], + data: object + ) => machineSuccess({ schema, data }); + return { + "version.json": success(MACHINE_SCHEMAS.version, { + version: "5.0.0", + channel: "stable", + install_kind: "standalone", + binary_path: "/example/bin/hack", + }), + "projects-observe.json": success(MACHINE_SCHEMAS.projectsObserve, { + observe: true, + runtime_requested: false, + runtime_ok: false, + projects: [], + }), + "global-status-observe.json": success(MACHINE_SCHEMAS.globalStatusObserve, { + observe: true, + docker: { state: "not_running" }, + summary: { ok: "unknown" }, + }), + "global-install-plan.json": success(MACHINE_SCHEMAS.globalAdminPlan, plan), + "global-trust-plan.json": success(MACHINE_SCHEMAS.globalAdminPlan, { + ...plan, + command: "trust", + }), + "plan-incomplete.json": errorResult({ + code: "E_PLAN_INCOMPLETE", + message: "Administrator plan is incomplete.", + detail: { + plan: { + ...plan, + schema: MACHINE_SCHEMAS.globalAdminPlan, + complete: false, + }, + }, + }), + }; +} diff --git a/src/agents/error-code-reference.ts b/src/agents/error-code-reference.ts index 72fa4f670..4695c63f0 100644 --- a/src/agents/error-code-reference.ts +++ b/src/agents/error-code-reference.ts @@ -65,6 +65,16 @@ const ERROR_CODE_GUIDES: Readonly> = { "Compose exited successfully but printed output Hack cannot parse as JSON, usually an unsupported Compose version.", readFirst: "`docker compose version`", }, + E_PLAN_REFUSED: { + meaning: + "A read-only administrator plan established that the command would refuse to run.", + readFirst: "Read `error.detail.plan.refused`; no planned step ran.", + }, + E_PLAN_INCOMPLETE: { + meaning: "An administrator plan cannot yet determine every required phase.", + readFirst: + "Read `error.detail.plan.blocked` and re-plan after its prerequisites are satisfied.", + }, E_STARTUP_INCOMPLETE: { meaning: "After start or restart, a service is neither running nor a successful one-shot.", diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts new file mode 100644 index 000000000..7e2e48383 --- /dev/null +++ b/src/cli/json-contracts.ts @@ -0,0 +1,107 @@ +/** Canonical --json inventory. New command paths require an explicit shape decision. */ +export const JSON_COMMAND_SHAPES = { + "global install": "schema-envelope", + "global dns preview": "envelope", + "global status": "legacy-object", + "global trust": "schema-envelope", + status: "backend-dependent", + usage: "legacy-object", + projects: "legacy-object", + "projects prune": "legacy-object", + "project owner show": "legacy-object", + "project target": "envelope", + "project task list": "envelope", + "project task inspect": "envelope", + up: "envelope", + down: "backend-dependent", + restart: "envelope", + ps: "backend-dependent", + run: "envelope", + exec: "envelope", + "exec-sessions": "legacy-object", + "commands read": "ndjson", + "commands show": "envelope", + "commands cancel": "envelope", + logs: "logs", + open: "envelope", + activity: "envelope", + doctor: "envelope", + "daemon status": "legacy-object", + "dispatch run": "legacy-object", + "dispatch status": "legacy-object", + "dispatch logs": "legacy-object", + "env list": "legacy-object", + "env protect": "legacy-object", + "env explain": "legacy-object", + "env apply": "legacy-object", + "env hydrate": "legacy-object", + "env backend status": "legacy-object", + "env backend use": "legacy-object", + "host ps": "legacy-object", + "config validate": "transport", + "config plan": "transport", + "config import": "legacy-object", + "config adopt": "legacy-object", + "setup sync": "error-only", + "setup migrate": "envelope", + "session panes": "ndjson", + "session capture": "ndjson", + "session tail": "ndjson", + "node init": "legacy-object", + "node pair": "legacy-object", + "node pair start": "legacy-object", + "node pair request": "legacy-object", + "node pair approve": "legacy-object", + "node pair complete": "legacy-object", + "node pair cancel": "legacy-object", + "node pair walkthrough": "legacy-object", + "node pair list": "legacy-object", + "node pair fulfill": "legacy-object", + "node ssh setup": "legacy-object", + "node add": "legacy-object", + "node list": "legacy-object", + "node status": "legacy-object", + "node use": "legacy-object", + "node remove": "legacy-object", + "node workspace list": "legacy-object", + "node workspace resolve": "legacy-object", + "node workspace remove": "legacy-object", + "node workspace attach": "legacy-object", + "node routes status": "legacy-object", + "node routes repair": "legacy-object", + "node provider railway bootstrap": "legacy-object", + "node devcontainer up": "legacy-object", + "node devcontainer down": "legacy-object", + "node devcontainer attach": "legacy-object", + update: "legacy-result", + "upgrade check": "envelope", + "upgrade apply": "envelope", + "upgrade review": "envelope", + "upgrade snapshot": "envelope", + "upgrade rollback": "envelope", + version: "schema-envelope", +} as const; + +export const SCHEMA_JSON_VARIANTS = [ + { command: "version", flags: [], schema: "hack.version.v1" }, + { + command: "projects", + flags: ["--observe"], + schema: "hack.projects.observe.v1", + }, + { + command: "global status", + flags: ["--observe"], + schema: "hack.global-status.observe.v1", + }, + { + command: "global install", + flags: ["--plan"], + schema: "hack.global-admin-plan.v1", + }, + { + command: "global trust", + flags: ["--plan"], + schema: "hack.global-admin-plan.v1", + }, +] as const; diff --git a/src/cli/json-reference.ts b/src/cli/json-reference.ts new file mode 100644 index 000000000..bd22f2795 --- /dev/null +++ b/src/cli/json-reference.ts @@ -0,0 +1,65 @@ +import type { AnyCommandSpec, CliSpec } from "./command.ts"; +import { JSON_COMMAND_SHAPES, SCHEMA_JSON_VARIANTS } from "./json-contracts.ts"; + +export function jsonCommandPaths(cli: CliSpec): readonly string[] { + const out: string[] = []; + function visit(command: AnyCommandSpec, parents: readonly string[]): void { + const path = [...parents, command.name]; + if (command.options.some((option) => option.name === "json")) { + out.push(path.join(" ")); + } + for (const child of command.subcommands) { + visit(child, path); + } + } + for (const command of cli.commands) { + visit(command, []); + } + return out.sort(); +} + +const SHAPES: Record< + (typeof JSON_COMMAND_SHAPES)[keyof typeof JSON_COMMAND_SHAPES], + string +> = { + "schema-envelope": "Envelope; versioned `data.schema`", + envelope: + "Envelope (`ok`, `data` / `error`); legacy payload has no required schema", + "legacy-object": "Legacy bare object (no `data` envelope)", + "legacy-result": + "Legacy result (`ok` plus fields, no `data`; error may be a string)", + "backend-dependent": "Backend-dependent: envelope or legacy bare object", + ndjson: "NDJSON events / terminal event, not a single envelope", + logs: "NDJSON logs; finite native modes may return a legacy snapshot object", + transport: + "Native compiler protocol object (`transport_version`, `ok`, result / diagnostics)", + "error-only": "Retired: usage-error envelope, no success payload", +}; + +export function renderJsonInventory(cli: CliSpec): string { + const rows = jsonCommandPaths(cli).map((command) => { + const kind = + JSON_COMMAND_SHAPES[command as keyof typeof JSON_COMMAND_SHAPES]; + if (kind === undefined) { + throw new Error(`Missing JSON shape decision: ${command}`); + } + const variant = SCHEMA_JSON_VARIANTS.find( + (entry) => entry.command === command && kind === "schema-envelope" + ); + const flags = variant?.flags.join(" ") ?? ""; + return `| \`hack ${command}${flags ? ` ${flags}` : ""} --json\` | ${SHAPES[kind]}${variant ? `: \`${variant.schema}\`` : ""} |`; + }); + for (const variant of SCHEMA_JSON_VARIANTS.filter( + (entry) => entry.command === "projects" || entry.command === "global status" + )) { + rows.push( + `| \`hack ${variant.command} --json ${variant.flags.join(" ")}\` | Envelope; \`data.schema: ${variant.schema}\` |` + ); + } + return [ + "| Canonical command / variant | Success shape |", + "|---|---|", + ...rows, + "", + ].join("\n"); +} diff --git a/src/cli/reference.ts b/src/cli/reference.ts index 29ebb189f..cfc75047d 100644 --- a/src/cli/reference.ts +++ b/src/cli/reference.ts @@ -46,7 +46,7 @@ export function renderCliReferenceMarkdown(opts: { "", `${opts.cli.name} — ${opts.cli.summary}`, "", - "Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); commands with `--json` emit a `{ok, data, error}` envelope on stdout.", + "Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); new JSON contracts use schema-tagged envelopes; published legacy and streaming formats are listed in [Machine-readable output](./machine-readable-output.md).", "", ]; diff --git a/src/commands/global-admin-plan.ts b/src/commands/global-admin-plan.ts index 05d11ec42..973277d3c 100644 --- a/src/commands/global-admin-plan.ts +++ b/src/commands/global-admin-plan.ts @@ -35,6 +35,7 @@ import type { LinuxTrustHost, PreparedLinuxSystemTrust, } from "../lib/linux-trust-setup.ts"; +import { MACHINE_SCHEMAS } from "../lib/machine-output.ts"; import { trustedSudoInvocation } from "../lib/privileged-sudo.ts"; import { type DockerBackend, @@ -54,7 +55,7 @@ import { type PreparedMacTrustPlan, } from "./global-mac-trust.ts"; -export const ADMIN_PLAN_SCHEMA = "hack.global-admin-plan.v1"; +export const ADMIN_PLAN_SCHEMA = MACHINE_SCHEMAS.globalAdminPlan; /** * A bounded `docker info` result as the plan reads it: exit 0 answers; diff --git a/src/commands/global-status-observe.ts b/src/commands/global-status-observe.ts index f1b752a55..5c7a37476 100644 --- a/src/commands/global-status-observe.ts +++ b/src/commands/global-status-observe.ts @@ -13,6 +13,7 @@ * reported as absent or as a failure of the thing. */ import type { LinuxDnsInspection } from "../lib/linux-dns-setup.ts"; +import { MACHINE_SCHEMAS } from "../lib/machine-output.ts"; import type { ExecResult } from "../lib/shell.ts"; import type { ObservedCheck } from "./global-mac-dns.ts"; import type { @@ -21,7 +22,7 @@ import type { ProxyObservation, } from "./global-status-observe-host.ts"; -export const OBSERVE_STATUS_SCHEMA = "hack.global-status.observe.v1"; +export const OBSERVE_STATUS_SCHEMA = MACHINE_SCHEMAS.globalStatusObserve; /** A yes/no answer, or "unknown" when it could not be determined. */ export type Tri = true | false | "unknown"; diff --git a/src/commands/global.ts b/src/commands/global.ts index 10374da53..b2217accb 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -11,7 +11,6 @@ import type { CommandHandlerFor, } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; - import { optFollow, optJson, @@ -67,6 +66,7 @@ import { inspectCaddyCaIdentity, } from "../lib/caddy-ca-identity.ts"; import { observeGlobalCaddyIp } from "../lib/caddy-hosts.ts"; +import { emitCliResult, errorResult } from "../lib/cli-result.ts"; import { resolveGlobalConfigPath, resolveGlobalHackDir, @@ -107,6 +107,7 @@ import { resolveHackLocalCaCertPath, } from "../lib/local-ca.ts"; import { inspectMacCaEligibility } from "../lib/mac-ca-trust.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { ensureBundledMutagenInstalled, getMutagenPath, @@ -483,8 +484,12 @@ export const globalCommand = defineCommand({ subcommands: [ withHandler(globalInstallSpec, async ({ args }) => { if (args.options.json === true && args.options.plan !== true) { - logger.error({ - message: "Use --json with --plan: hack global install --plan --json", + emitCliResult({ + result: errorResult({ + code: "E_USAGE", + message: + "Use --json with --plan: hack global install --plan --json", + }), }); return 2; } @@ -505,8 +510,11 @@ export const globalCommand = defineCommand({ withHandler(globalCertSpec, handleGlobalCert), withHandler(globalTrustSpec, async ({ args }) => { if (args.options.json === true && args.options.plan !== true) { - logger.error({ - message: "Use --json with --plan: hack global trust --plan --json", + emitCliResult({ + result: errorResult({ + code: "E_USAGE", + message: "Use --json with --plan: hack global trust --plan --json", + }), }); return 2; } @@ -2572,7 +2580,12 @@ async function globalStatusObserve(): Promise { observeMacDns: observeMacHostDns, now: () => new Date(), }); - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + emitCliResult({ + result: machineSuccess({ + schema: MACHINE_SCHEMAS.globalStatusObserve, + data: payload, + }), + }); return 0; } @@ -3382,11 +3395,34 @@ async function printAdminPlan(opts: { return 2; } const result = await opts.build(); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); if (result.refused !== null) { + emitCliResult({ + result: errorResult({ + code: "E_PLAN_REFUSED", + message: result.refused, + detail: { plan: result }, + }), + }); return 1; } - return result.complete ? 0 : 3; + if (!result.complete) { + emitCliResult({ + result: errorResult({ + code: "E_PLAN_INCOMPLETE", + message: + "Administrator plan is incomplete; inspect error.detail.plan.blocked before proceeding.", + detail: { plan: result }, + }), + }); + return 3; + } + emitCliResult({ + result: machineSuccess({ + schema: MACHINE_SCHEMAS.globalAdminPlan, + data: result, + }), + }); + return 0; } /** Install's read-only snapshot. Never enter the mutating install handler. */ diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 191aa8b0e..06e9348ea 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -27,6 +27,7 @@ import { import { emitCliResult, okResult } from "../lib/cli-result.ts"; import { confirmSafe } from "../lib/interactivity.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { tryNativeComposeCommand } from "../lib/native-compose-command.ts"; import { createOperationTimings, @@ -866,7 +867,12 @@ function outputProjectsJson(opts: { ), }; writeProfiledProjects({ - payload, + payload: opts.observe + ? machineSuccess({ + schema: MACHINE_SCHEMAS.projectsObserve, + data: payload, + }) + : payload, profiler: opts.profiler, started: opts.started, enabled: opts.timings, diff --git a/src/commands/version.ts b/src/commands/version.ts index 8c4cabf76..a74d2a1a1 100644 --- a/src/commands/version.ts +++ b/src/commands/version.ts @@ -1,7 +1,8 @@ import type { CommandHandlerFor } from "../cli/command.ts"; import { defineCommand, withHandler } from "../cli/command.ts"; - import { optJson } from "../cli/options.ts"; +import { emitCliResult } from "../lib/cli-result.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { buildVersionReport } from "../lib/version-report.ts"; const versionSpec = defineCommand({ @@ -23,7 +24,9 @@ const handleVersion: CommandHandlerFor = async ({ executable: process.execPath, compiled: Bun.main.startsWith("/$bunfs/"), }); - console.log(JSON.stringify(report)); + emitCliResult({ + result: machineSuccess({ schema: MACHINE_SCHEMAS.version, data: report }), + }); return 0; } console.log(`${ctx.cli.name} v${ctx.cli.version}`); diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index 099e20fb7..dc6af93bd 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -1,10 +1,11 @@ /** * Machine-readable CLI result envelope. * - * Commands that support `--json` print exactly one envelope object to stdout: + * New structured `--json` contracts print one envelope object to stdout: * `{ok: true, data}` on success or `{ok: false, error: {code, message, - * detail?}}` on failure. Exit codes keep their existing 0/1 semantics; the - * envelope only adds structure, never changes them. + * detail?}}` on failure. New HACK-2218 successes also carry `data.schema`. + * Errors exit nonzero. Published legacy and streaming formats are inventoried + * in docs/reference/machine-readable-output.md and are not silently wrapped. */ /** @@ -28,6 +29,8 @@ export const HACK_ERROR_CODES = [ "E_COMPOSE_FAILED", "E_COMPOSE_OUTPUT_INVALID", "E_STARTUP_INCOMPLETE", + "E_PLAN_REFUSED", + "E_PLAN_INCOMPLETE", "E_DEPENDENCY_BOOTSTRAP_FAILED", "E_STARTUP_TIMEOUT", "E_CANCELLED", diff --git a/src/lib/machine-output.ts b/src/lib/machine-output.ts new file mode 100644 index 000000000..1d20a97ce --- /dev/null +++ b/src/lib/machine-output.ts @@ -0,0 +1,16 @@ +import { type CliOkResult, okResult } from "./cli-result.ts"; + +/** Unpublished HACK-2218 contracts use an envelope and a versioned payload schema. */ +export const MACHINE_SCHEMAS = { + version: "hack.version.v1", + projectsObserve: "hack.projects.observe.v1", + globalAdminPlan: "hack.global-admin-plan.v1", + globalStatusObserve: "hack.global-status.observe.v1", +} as const; + +export function machineSuccess(opts: { + readonly schema: (typeof MACHINE_SCHEMAS)[keyof typeof MACHINE_SCHEMAS]; + readonly data: T; +}): CliOkResult { + return okResult({ data: { ...opts.data, schema: opts.schema } }); +} diff --git a/tests/admin-plan-json-contract.test.ts b/tests/admin-plan-json-contract.test.ts new file mode 100644 index 000000000..e3a497fef --- /dev/null +++ b/tests/admin-plan-json-contract.test.ts @@ -0,0 +1,114 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AdminPlan } from "../src/commands/global-admin-plan.ts"; +import { MACHINE_SCHEMAS } from "../src/lib/machine-output.ts"; +import { machineData, machineError } from "./helpers/machine-output.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +let plan: AdminPlan; +let root: string; +let env: Record; +const planner = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/commands/global-admin-plan.ts", + overrides: { + planGlobalInstall: async () => plan, + planGlobalTrust: async () => plan, + }, +}); +beforeAll(() => planner.activate()); +afterAll(() => planner.deactivate()); +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-plan-json-")); + const keys = [ + "HOME", + "HACK_HOME", + "HACK_RUNTIME_BACKEND", + "HACK_CANDIDATE_ENTRYPOINT", + "HACK_LOGGER", + "HACK_DAEMON_AUTOSTART_DISABLE", + ]; + env = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + for (const key of keys) { + Reflect.deleteProperty(process.env, key); + } + process.env.HOME = root; + process.env.HACK_HOME = join(root, "state"); + process.env.HACK_LOGGER = "console"; + process.env.HACK_DAEMON_AUTOSTART_DISABLE = "1"; +}); +afterEach(async () => { + mock.restore(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } + await rm(root, { recursive: true, force: true }); +}); + +for (const command of ["install", "trust"] as const) { + test.each([ + "complete", + "refused", + "incomplete", + ] as const)(`${command} plan %s follows its JSON result contract`, async (outcome) => { + plan = { + schema: MACHINE_SCHEMAS.globalAdminPlan, + command, + platform: "linux", + complete: outcome === "complete", + refused: outcome === "refused" ? "Host selection refused" : null, + blocked: + outcome === "incomplete" + ? [ + { + phase: "host", + reason: "Unknown", + prerequisite: "Re-check host state", + }, + ] + : [], + steps: [], + }; + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("plan contract executed a child"); + }); + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", command, "--plan", "--json"]); + expect(code).toBe( + outcome === "complete" ? 0 : outcome === "refused" ? 1 : 3 + ); + if (outcome === "complete") { + expect(machineData(stdout, MACHINE_SCHEMAS.globalAdminPlan)).toEqual( + plan + ); + } else { + expect( + machineError( + stdout, + outcome === "refused" ? "E_PLAN_REFUSED" : "E_PLAN_INCOMPLETE" + ).detail.plan + ).toEqual(plan); + } + expect(spawn).not.toHaveBeenCalled(); + }); +} diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index cefc00a39..0c900a6d3 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -26,6 +26,7 @@ import { resetNoInteractiveFlagForTests } from "../src/lib/interactivity.ts"; import { resetGumPathCacheForTests } from "../src/ui/gum.ts"; import { CURRENT_CA_PEM, OLD_CA_PEM } from "./helpers/ca-certificates.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { machineData, machineError } from "./helpers/machine-output.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; import { asExecStdout, tarArchive } from "./helpers/tar.ts"; @@ -440,7 +441,10 @@ test("global install plan root argv equals the command's fake-root execution, in value: true, }); } - const planned = JSON.parse(chunks.join("")) as AdminPlan; + const planned = machineData( + chunks.join(""), + "hack.global-admin-plan.v1" + ) as AdminPlan; expect(planCode).toBe(0); expect(planned.complete).toBe(true); expect(runCalls).toEqual([]); @@ -475,7 +479,8 @@ test("global install plan keeps JSON stdout pure when helper ownership refuses r } finally { stdout.mockRestore(); } - const planned: unknown = JSON.parse(chunks.join("")); + const planned: unknown = machineError(chunks.join(""), "E_PLAN_INCOMPLETE") + .detail.plan; expect(planned).toMatchObject({ command: "install", steps: expect.arrayContaining([ diff --git a/tests/global-command.test.ts b/tests/global-command.test.ts index 89f32e555..b0a5945b6 100644 --- a/tests/global-command.test.ts +++ b/tests/global-command.test.ts @@ -10,7 +10,6 @@ import { import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; - import { DEFAULT_CADDY_IP, DEFAULT_COREDNS_IP, @@ -28,6 +27,7 @@ import { resolveGlobalConfigPath } from "../src/lib/config-paths.ts"; import * as interactivity from "../src/lib/interactivity.ts"; import { resetGumPathCacheForTests } from "../src/ui/gum.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { machineData, machineError } from "./helpers/machine-output.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; let tempDir: string | null = null; @@ -187,6 +187,11 @@ test.each([ "install", "trust", ])("global %s --json without --plan refuses before any effect", async (command) => { + const chunks: string[] = []; + const output = spyOn(process.stdout, "write").mockImplementation((chunk) => { + chunks.push(String(chunk)); + return true; + }); const confirm = spyOn(interactivity, "confirmSafe").mockImplementation(() => { throw new Error("No confirmation expected"); }); @@ -197,8 +202,10 @@ test.each([ expect(runCalls).toEqual([]); expect(linuxDnsPrivileged).toEqual([]); expect(confirm).not.toHaveBeenCalled(); + machineError(chunks.join(""), "E_USAGE"); } finally { confirm.mockRestore(); + output.mockRestore(); } }); @@ -221,7 +228,9 @@ test.skipIf(process.platform !== "linux")( } finally { stdout.mockRestore(); } - expect(JSON.parse(chunks.join(""))).toMatchObject({ + expect( + machineData(chunks.join(""), "hack.global-admin-plan.v1") + ).toMatchObject({ schema: "hack.global-admin-plan.v1", command: "install", complete: true, @@ -962,7 +971,8 @@ test.skipIf(process.platform !== "linux")( } expect(dockerStarted).toBe(false); expect(runCalls).toEqual([]); - const planned: unknown = JSON.parse(chunks.join("")); + const planned: unknown = machineError(chunks.join(""), "E_PLAN_INCOMPLETE") + .detail.plan; expect(planned).toMatchObject({ steps: expect.arrayContaining([ expect.objectContaining({ diff --git a/tests/global-status-observe.test.ts b/tests/global-status-observe.test.ts index f59b73cc8..81e83db74 100644 --- a/tests/global-status-observe.test.ts +++ b/tests/global-status-observe.test.ts @@ -32,6 +32,7 @@ import type { LinuxDnsInspection } from "../src/lib/linux-dns-setup.ts"; import * as shell from "../src/lib/shell.ts"; import { isolatedDockerClientEnv } from "./helpers/docker-client.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { machineData } from "./helpers/machine-output.ts"; // `hack global status --json --observe` (HACK-2218, R13): never starts // anything, and never reports what it could not read as absent or failed. @@ -1137,7 +1138,10 @@ test( // Nothing outside the listed read-only probes, checked first. expect(result.spawned.filter((argv) => !allowed(argv))).toEqual([]); expect(result.exitCode).toBe(0); - const payload = JSON.parse(result.stdout) as { + const payload = machineData( + result.stdout, + "hack.global-status.observe.v1" + ) as { readonly docker: { readonly state: string }; readonly summary: Record; }; @@ -1173,7 +1177,10 @@ test( // Nothing outside the listed read-only probes, checked first. expect(result.spawned.filter((argv) => !allowed(argv))).toEqual([]); expect(result.exitCode).toBe(0); - const payload = JSON.parse(result.stdout) as { + const payload = machineData( + result.stdout, + "hack.global-status.observe.v1" + ) as { readonly docker: { readonly state: string }; readonly networks: { readonly ok: unknown }; }; @@ -1201,7 +1208,10 @@ test( const result = await runObserve(base); expect(result.spawned.filter((argv) => !allowed(argv))).toEqual([]); expect(result.exitCode).toBe(0); - const payload = JSON.parse(result.stdout) as { + const payload = machineData( + result.stdout, + "hack.global-status.observe.v1" + ) as { readonly host_dns: { readonly ok: unknown }; readonly summary: { readonly host_dns_ok: unknown }; }; @@ -1211,7 +1221,10 @@ test( // state. await writeFile(join(hackHome, "hack.config.json"), "{}\n"); const control = await runObserve(base); - const known = JSON.parse(control.stdout) as { + const known = machineData( + control.stdout, + "hack.global-status.observe.v1" + ) as { readonly summary: { readonly host_dns_ok: unknown }; }; expect(known.summary.host_dns_ok).not.toBe("unknown"); diff --git a/tests/helpers/machine-output.ts b/tests/helpers/machine-output.ts new file mode 100644 index 000000000..940e8909e --- /dev/null +++ b/tests/helpers/machine-output.ts @@ -0,0 +1,19 @@ +import { expect } from "bun:test"; + +/** Assert the public boundary before tests inspect command-specific payload fields. */ +export function machineData(text: string, schema: string) { + const result = JSON.parse(text); + expect(Object.keys(result).sort()).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data.schema).toBe(schema); + return result.data; +} + +export function machineError(text: string, code: string) { + const result = JSON.parse(text); + expect(Object.keys(result).sort()).toEqual(["error", "ok"]); + expect(result.ok).toBe(false); + expect(result.error.code).toBe(code); + expect(result.error.message).toBeString(); + return result.error; +} diff --git a/tests/machine-output-contract.test.ts b/tests/machine-output-contract.test.ts new file mode 100644 index 000000000..71250780c --- /dev/null +++ b/tests/machine-output-contract.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { machineOutputFixtures } from "../scripts/lib/machine-output-fixtures.ts"; +import { + JSON_COMMAND_SHAPES, + SCHEMA_JSON_VARIANTS, +} from "../src/cli/json-contracts.ts"; +import { + jsonCommandPaths, + renderJsonInventory, +} from "../src/cli/json-reference.ts"; +import { runCli } from "../src/cli/run.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { MACHINE_SCHEMAS } from "../src/lib/machine-output.ts"; +import { machineError } from "./helpers/machine-output.ts"; + +let root: string; +let original: Record; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-json-contract-")); + original = Object.fromEntries( + ["HOME", "HACK_HOME", "HACK_LOGGER", "HACK_DAEMON_AUTOSTART_DISABLE"].map( + (key) => [key, process.env[key]] + ) + ); + process.env.HOME = root; + process.env.HACK_HOME = join(root, "state"); + process.env.HACK_LOGGER = "console"; + process.env.HACK_DAEMON_AUTOSTART_DISABLE = "1"; +}); +afterEach(async () => { + mock.restore(); + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } + await rm(root, { recursive: true, force: true }); +}); + +test("every canonical JSON command has an explicit inventory shape decision", () => { + expect(Object.keys(JSON_COMMAND_SHAPES).sort()).toEqual([ + ...jsonCommandPaths(CLI_SPEC), + ]); + for (const variant of SCHEMA_JSON_VARIANTS) { + expect(JSON_COMMAND_SHAPES).toHaveProperty(variant.command); + } +}); + +test("machine-readable reference table is generated from the command inventory", async () => { + const doc = await Bun.file( + join(import.meta.dir, "..", "docs/reference/machine-readable-output.md") + ).text(); + const table = doc + .split("\n")[1] + ?.split("")[0]; + expect(table).toBe(renderJsonInventory(CLI_SPEC)); +}); + +test.each( + Object.keys(JSON_COMMAND_SHAPES) +)("%s keeps its pre-handler JSON usage-error contract", async (command) => { + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + spyOn(process.stderr, "write").mockReturnValue(true); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("contract probe entered a runtime handler"); + }); + const spawnSync = spyOn(Bun, "spawnSync").mockImplementation(() => { + throw new Error("contract probe entered a synchronous handler"); + }); + const fetch = spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + () => { + throw new Error("contract probe entered a network handler"); + }, + { + preconnect: () => { + throw new Error("unexpected preconnect"); + }, + } + ) + ); + const code = await runCli([ + ...command.split(" "), + "--json", + "--__contract_refuse", + ]); + expect(code).not.toBe(0); + machineError(stdout, "E_USAGE"); + expect(spawn).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); +}); + +test("desktop boundary fixtures and schema inventory agree with CLI producers", async () => { + expect( + [...new Set(SCHEMA_JSON_VARIANTS.map((variant) => variant.schema))].sort() + ).toEqual(Object.values(MACHINE_SCHEMAS).sort()); + for (const [name, fixture] of Object.entries(machineOutputFixtures())) { + const text = await Bun.file( + join( + import.meta.dir, + "..", + "apps/desktop/core/tests/fixtures/machine-output", + name + ) + ).text(); + expect(text).toBe(`${JSON.stringify(fixture, null, 2)}\n`); + } +}); diff --git a/tests/projects-observe.test.ts b/tests/projects-observe.test.ts index 0a5592b35..ae88c4009 100644 --- a/tests/projects-observe.test.ts +++ b/tests/projects-observe.test.ts @@ -14,6 +14,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { machineData } from "./helpers/machine-output.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; let root = ""; @@ -146,7 +147,7 @@ test("observe default preserves HOME and Hack home and never contacts Docker", a ]) { const result = await run(["--json", "--observe", ...flags]); expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); + const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.observe).toBe(true); expect(payload.runtime_requested).toBe(false); expect(payload.runtime_error).toBeNull(); @@ -188,7 +189,7 @@ test("observe runtime explicitly opts in to Docker reads without registering dis ...flags, ]); expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); + const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.runtime_requested).toBe(true); expect( payload.projects.find( @@ -219,7 +220,7 @@ test("observe keeps native rows and unknown Compose services when Docker is unav OFFLINE: "1", }); expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); + const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.runtime_ok).toBe(false); const compose = payload.projects.find( (row: { name: string }) => row.name === "compose-app" @@ -249,7 +250,10 @@ test("mixed running and exited replicas keep service and project degraded", asyn { MIXED: "1" } ); expect(result.code).toBe(0); - const row = JSON.parse(result.stdout).projects.find( + const row = machineData( + result.stdout, + "hack.projects.observe.v1" + ).projects.find( (project: { name: string }) => project.name === "unregistered-app" ); expect(row.services[0].state).toBe("degraded"); @@ -261,7 +265,9 @@ test("observe never creates a missing registry or registers the current checkout await rm(registry); const result = await run(["--json", "--observe"]); expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).projects).toEqual([]); + expect( + machineData(result.stdout, "hack.projects.observe.v1").projects + ).toEqual([]); expect(await stat(registry).catch(() => null)).toBeNull(); }); @@ -293,9 +299,21 @@ test("observe does not query an available daemon cache", async () => { try { const result = await run(["--json", "--observe"]); expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).projects).toHaveLength(2); + expect( + machineData(result.stdout, "hack.projects.observe.v1").projects + ).toHaveLength(2); expect(requests).toBe(0); } finally { await server.stop(true); } }); + +test("published plain projects JSON retains its unwrapped shape", async () => { + const result = await run(["--json", "--no-daemon"]); + expect(result.code).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.projects).toBeArray(); + expect(payload).not.toHaveProperty("ok"); + expect(payload).not.toHaveProperty("data"); + expect(payload).not.toHaveProperty("schema"); +}); diff --git a/tests/version-report.test.ts b/tests/version-report.test.ts index 62d7ad27f..ec8e41ea3 100644 --- a/tests/version-report.test.ts +++ b/tests/version-report.test.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildVersionReport } from "../src/lib/version-report.ts"; +import { machineData } from "./helpers/machine-output.ts"; const owned: string[] = []; afterEach(async () => { @@ -74,11 +75,12 @@ test("source CLI JSON is pure, local and leaves a private home unchanged", async const stderr = await new Response(child.stderr).text(); expect(await child.exited).toBe(0); expect(stderr).toBe(""); - const report = JSON.parse(stdout); + const report = machineData(stdout, "hack.version.v1"); expect(Object.keys(report).sort()).toEqual([ "binary_path", "channel", "install_kind", + "schema", "version", ]); expect(report.install_kind).toBe("source"); From bce84af562e5bbec8c2557be9ecd396758bd02de Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:45:21 +0000 Subject: [PATCH 09/25] fix(cli): include activity in schema contract and correct inventory --- .../fixtures/machine-output/activity.json | 8 ++++ apps/desktop/core/tests/machine_output.rs | 4 ++ docs/reference/activity-log.md | 2 +- docs/reference/machine-readable-output.md | 7 +++- scripts/check-native-env-plan-cli.ts | 9 ++++- scripts/lib/machine-output-fixtures.ts | 4 ++ src/cli/json-contracts.ts | 5 ++- src/commands/activity.ts | 8 +++- src/lib/machine-output.ts | 1 + tests/activity-cli.test.ts | 11 ++---- tests/machine-output-contract.test.ts | 38 ++++++++++++++++++- 11 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/core/tests/fixtures/machine-output/activity.json diff --git a/apps/desktop/core/tests/fixtures/machine-output/activity.json b/apps/desktop/core/tests/fixtures/machine-output/activity.json new file mode 100644 index 000000000..0c762508e --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/activity.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "data": { + "version": 1, + "entries": [], + "schema": "hack.activity.v1" + } +} diff --git a/apps/desktop/core/tests/machine_output.rs b/apps/desktop/core/tests/machine_output.rs index e307ed520..3abaa24bd 100644 --- a/apps/desktop/core/tests/machine_output.rs +++ b/apps/desktop/core/tests/machine_output.rs @@ -5,6 +5,10 @@ use serde_json::Value; #[test] fn migrated_payloads_require_the_expected_envelope_and_schema() { let cases = [ + ( + include_str!("fixtures/machine-output/activity.json"), + "hack.activity.v1", + ), ( include_str!("fixtures/machine-output/version.json"), "hack.version.v1", diff --git a/docs/reference/activity-log.md b/docs/reference/activity-log.md index ee584c4c9..c3c9b41ef 100644 --- a/docs/reference/activity-log.md +++ b/docs/reference/activity-log.md @@ -18,7 +18,7 @@ hack activity --json --project shop --limit 100 Historical entries remain selectable after a project leaves the registry. - `--limit `: at most `n` entries, newest first (default 50, at most 10,000). -- `--json`: the standard envelope, `{"ok": true, "data": {"version": 1, +- `--json`: the standard envelope, `{"ok": true, "data": {"schema": "hack.activity.v1", "version": 1, "entries": [...]}}`. A log that exists but cannot be read (not this user's regular file, a symlink, or a read error) is `{"ok": false, "error": {"code": "E_PATH_CONFLICT", ...}}` with exit 1, never an empty history. diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index d98b17767..494a713bf 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -9,6 +9,8 @@ New `--json` outputs added for HACK-2218 use one success envelope: The stable schema identifier belongs inside `data`. A payload schema changes when its incompatible contract changes. Consumers unwrap `data` and validate its schema before using it. Human diagnostics go to stderr. +The envelope has exactly two top-level keys; additional metadata belongs inside +the schema-tagged payload, not alongside `ok` and `data`. Failures use a stable error code and a nonzero exit status: @@ -27,6 +29,7 @@ outputs now follow this rule. Plain `projects --json` and other published legacy outputs keep their shapes. A consumer must not use the new observe-only parser for a plain projects result. Streaming interfaces remain NDJSON and have their own event contracts. No published payload is silently wrapped by this change. +The unpublished Activity output also carries `data.schema: "hack.activity.v1"`. ![JSON command boundary](./machine-readable-output.svg) @@ -47,7 +50,7 @@ remains covered by its command-specific suites. | Canonical command / variant | Success shape | |---|---| -| `hack activity --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack activity --json` | Envelope; versioned `data.schema`: `hack.activity.v1` | | `hack commands cancel --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack commands read --json` | NDJSON events / terminal event, not a single envelope | | `hack commands show --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | @@ -108,7 +111,7 @@ remains covered by its command-specific suites. | `hack project task inspect --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack project task list --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack projects --json` | Legacy bare object (no `data` envelope) | -| `hack projects prune --json` | Legacy bare object (no `data` envelope) | +| `hack projects prune --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack ps --json` | Backend-dependent: envelope or legacy bare object | | `hack restart --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack run --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index 04e189bc0..71394a1ce 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -264,6 +264,8 @@ try { ), "native detached service selection refuses before input or runtime work"); // Whole-project detach is admitted. Invalid input keeps this relocated-bundle // check offline on supported hosts; unsupported hosts also fail before ready. + require((await readdir(home)).length === + 0, "validation and refused service selection write no home state"); await Bun.write(join(checkout, ".hack/hack.project.json"), "{"); const detached = await invoke(["up", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", @@ -276,7 +278,12 @@ try { detachedResult.ok === false && record(detachedResult.error).code === "E_STARTUP_INCOMPLETE", "admitted detached startup returns one JSON failure envelope without readiness"); - require((await readdir(home)).length === 0 && + // The admitted lifecycle action records its failed outcome. This is not + // registry or generated runtime state; permit only that named audit file. + const homeEntries = (await readdir(home, { recursive: true })).sort(); + require((homeEntries.length === 0 || + JSON.stringify(homeEntries) === + JSON.stringify([".hack", ".hack/activity.jsonl"])) && !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ), "no registry or generated runtime state"); diff --git a/scripts/lib/machine-output-fixtures.ts b/scripts/lib/machine-output-fixtures.ts index 2233745e9..a213655a8 100644 --- a/scripts/lib/machine-output-fixtures.ts +++ b/scripts/lib/machine-output-fixtures.ts @@ -19,6 +19,10 @@ export function machineOutputFixtures() { data: object ) => machineSuccess({ schema, data }); return { + "activity.json": success(MACHINE_SCHEMAS.activity, { + version: 1, + entries: [], + }), "version.json": success(MACHINE_SCHEMAS.version, { version: "5.0.0", channel: "stable", diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 7e2e48383..be69107b4 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -7,7 +7,7 @@ export const JSON_COMMAND_SHAPES = { status: "backend-dependent", usage: "legacy-object", projects: "legacy-object", - "projects prune": "legacy-object", + "projects prune": "envelope", "project owner show": "legacy-object", "project target": "envelope", "project task list": "envelope", @@ -24,7 +24,7 @@ export const JSON_COMMAND_SHAPES = { "commands cancel": "envelope", logs: "logs", open: "envelope", - activity: "envelope", + activity: "schema-envelope", doctor: "envelope", "daemon status": "legacy-object", "dispatch run": "legacy-object", @@ -83,6 +83,7 @@ export const JSON_COMMAND_SHAPES = { } as const; export const SCHEMA_JSON_VARIANTS = [ + { command: "activity", flags: [], schema: "hack.activity.v1" }, { command: "version", flags: [], schema: "hack.version.v1" }, { command: "projects", diff --git a/src/commands/activity.ts b/src/commands/activity.ts index b5094f03f..f0b39644b 100644 --- a/src/commands/activity.ts +++ b/src/commands/activity.ts @@ -12,7 +12,8 @@ import { type ActivityRecord, readActivity, } from "../lib/activity-log.ts"; -import { emitCliResult, errorResult, okResult } from "../lib/cli-result.ts"; +import { emitCliResult, errorResult } from "../lib/cli-result.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; const WHOLE_NUMBER = /^[1-9][0-9]*$/; @@ -97,7 +98,10 @@ async function handleActivity({ } if (json) { emitCliResult({ - result: okResult({ data: { version: ACTIVITY_SCHEMA_VERSION, entries } }), + result: machineSuccess({ + schema: MACHINE_SCHEMAS.activity, + data: { version: ACTIVITY_SCHEMA_VERSION, entries }, + }), }); return 0; } diff --git a/src/lib/machine-output.ts b/src/lib/machine-output.ts index 1d20a97ce..ca0fdf8e9 100644 --- a/src/lib/machine-output.ts +++ b/src/lib/machine-output.ts @@ -2,6 +2,7 @@ import { type CliOkResult, okResult } from "./cli-result.ts"; /** Unpublished HACK-2218 contracts use an envelope and a versioned payload schema. */ export const MACHINE_SCHEMAS = { + activity: "hack.activity.v1", version: "hack.version.v1", projectsObserve: "hack.projects.observe.v1", globalAdminPlan: "hack.global-admin-plan.v1", diff --git a/tests/activity-cli.test.ts b/tests/activity-cli.test.ts index 68a6a9a4b..3d99ac034 100644 --- a/tests/activity-cli.test.ts +++ b/tests/activity-cli.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; +import { machineData } from "./helpers/machine-output.ts"; import { privateCliEnv, privateCliHome } from "./helpers/private-cli-env.ts"; /** @@ -129,13 +130,9 @@ type Entry = Record; async function activity(args: readonly string[] = []): Promise { const result = await runCli({ args: ["activity", "--json", ...args] }); expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout) as { - ok: boolean; - data: { version: number; entries: Entry[] }; - }; - expect(envelope.ok).toBe(true); - expect(envelope.data.version).toBe(1); - return envelope.data.entries; + const data = machineData(result.stdout, "hack.activity.v1"); + expect(data.version).toBe(1); + return data.entries; } test("up, a failed up, restart and down are recorded with names only", async () => { diff --git a/tests/machine-output-contract.test.ts b/tests/machine-output-contract.test.ts index 71250780c..f45039edd 100644 --- a/tests/machine-output-contract.test.ts +++ b/tests/machine-output-contract.test.ts @@ -1,4 +1,13 @@ -import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + mock, + spyOn, + test, +} from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -15,6 +24,15 @@ import { runCli } from "../src/cli/run.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; import { MACHINE_SCHEMAS } from "../src/lib/machine-output.ts"; import { machineError } from "./helpers/machine-output.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +const runtime = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/runtime-projects.ts", + overrides: { readRuntimeProjects: async () => ({ ok: true, runtime: [] }) }, +}); +beforeAll(() => runtime.activate()); +afterAll(() => runtime.deactivate()); let root: string; let original: Record; @@ -51,6 +69,24 @@ test("every canonical JSON command has an explicit inventory shape decision", () } }); +test.each([ + { flags: [] }, + { flags: ["--dry-run"] }, +])("projects prune %j matches its envelope inventory", async ({ flags }) => { + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const code = await runCli(["projects", "prune", "--json", ...flags]); + expect(code).toBe(0); + const result = JSON.parse(stdout); + expect(JSON_COMMAND_SHAPES["projects prune"]).toBe("envelope"); + expect(Object.keys(result).sort()).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data).toBeObject(); +}); + test("machine-readable reference table is generated from the command inventory", async () => { const doc = await Bun.file( join(import.meta.dir, "..", "docs/reference/machine-readable-output.md") From 2be5183452af4c97ea3af110441c90d53bf26eb5 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:53:40 +0000 Subject: [PATCH 10/25] test(config): account for both failed lifecycle audit records --- scripts/check-native-env-plan-cli.ts | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index 71394a1ce..d13fd2e68 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readdir, + readFile, rm, symlink, } from "node:fs/promises"; @@ -249,6 +250,8 @@ try { await Bun.write(join(checkout, ".hack/hack.config.json"), "{}"); await refused([], "E_NATIVE_PROJECT_CONFLICT", checkout); await rm(join(checkout, ".hack/hack.config.json")); + require((await readdir(home)).length === + 0, "validation and planning write no home state"); const unsupported = await invoke( ["up", "web", "--detach", "--json"], checkout, @@ -264,8 +267,6 @@ try { ), "native detached service selection refuses before input or runtime work"); // Whole-project detach is admitted. Invalid input keeps this relocated-bundle // check offline on supported hosts; unsupported hosts also fail before ready. - require((await readdir(home)).length === - 0, "validation and refused service selection write no home state"); await Bun.write(join(checkout, ".hack/hack.project.json"), "{"); const detached = await invoke(["up", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", @@ -287,6 +288,31 @@ try { !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ), "no registry or generated runtime state"); + if (homeEntries.length > 0) { + const entries = (await readFile(join(home, ".hack/activity.jsonl"), "utf8")) + .trim() + .split("\n") + .map((line) => record(JSON.parse(line))); + require(entries.length === 2 && + entries.every( + (entry) => + entry.action === "up" && + entry.outcome === "failed" && + JSON.stringify(Object.keys(entry).sort()) === + JSON.stringify([ + "action", + "branch", + "duration_ms", + "failed_services", + "origin", + "outcome", + "project", + "ts", + "v", + ]) && + !JSON.stringify(entry).includes(directory) + ), "only the two failed lifecycle actions are recorded without private paths"); + } process.stdout.write( "Relocated native env plan acceptance: offline validation, metadata-only output, missing overlays/refs, immutable directives, separate hashes, real linked-worktree layers, CI/slim/opt-out, unsafe-input and runtime refusal passed\n" ); From 82de876997bcd039e4ee61c515d73a25d14b1d13 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:56:24 +0000 Subject: [PATCH 11/25] test(config): verify audit-only effects across native plan probes --- scripts/check-native-acquisition-plan-cli.ts | 15 ++++- scripts/check-native-endpoint-plan-cli.ts | 19 ++++++- scripts/check-native-env-plan-cli.ts | 47 ++++----------- scripts/check-native-host-plan-cli.ts | 15 ++++- scripts/check-native-process-plan-cli.ts | 15 ++++- scripts/check-native-routing-plan-cli.ts | 18 +++++- scripts/lib/native-plan-home.ts | 52 +++++++++++++++++ tests/native-plan-home.test.ts | 60 ++++++++++++++++++++ 8 files changed, 200 insertions(+), 41 deletions(-) create mode 100644 scripts/lib/native-plan-home.ts create mode 100644 tests/native-plan-home.test.ts diff --git a/scripts/check-native-acquisition-plan-cli.ts b/scripts/check-native-acquisition-plan-cli.ts index d38b95270..788698c8f 100644 --- a/scripts/check-native-acquisition-plan-cli.ts +++ b/scripts/check-native-acquisition-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Qualify pure acquisition intent in a relocated bundle and a real linked worktree. */ const root = resolve(import.meta.dir, ".."); @@ -264,6 +265,14 @@ try { ], checkout ); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke(["up", "web", "--detach"], checkout, false, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -283,7 +292,11 @@ try { ); } assert( - (await readdir(join(home, ".hack")).catch(() => [])).length === 0, + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 1, + }), "planning creates no registry or runtime home state" ); process.stdout.write( diff --git a/scripts/check-native-endpoint-plan-cli.ts b/scripts/check-native-endpoint-plan-cli.ts index 8b98031cd..7c9463d8f 100644 --- a/scripts/check-native-endpoint-plan-cli.ts +++ b/scripts/check-native-endpoint-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Exercise pure endpoint planning through the relocated CLI and real Git linkage. */ const root = resolve(import.meta.dir, ".."); @@ -367,6 +368,14 @@ try { "actual resolution rejects unbound logical reference" ); await authored(checkout, project); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -393,8 +402,14 @@ try { "no generated runtime state" ); } - const homeEntries = await readdir(join(home, ".hack")).catch(() => []); - assert(homeEntries.length === 0, "no registry or runtime home writes"); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 1, + }), + "no registry or runtime home writes" + ); process.stdout.write( `Relocated native endpoint planning: ${checks} checks passed; no hooks/DNS/trust/runtime effects\n` ); diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index d13fd2e68..6c5a1a141 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -4,13 +4,13 @@ import { mkdir, mkdtemp, readdir, - readFile, rm, symlink, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Real compiled CLI/sidecar, isolated HOME and Git worktrees, no source or toolchain on PATH. */ const root = resolve(import.meta.dir, ".."); @@ -250,8 +250,11 @@ try { await Bun.write(join(checkout, ".hack/hack.config.json"), "{}"); await refused([], "E_NATIVE_PROJECT_CONFLICT", checkout); await rm(join(checkout, ".hack/hack.config.json")); - require((await readdir(home)).length === - 0, "validation and planning write no home state"); + require(await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), "validation and planning write no home state"); const unsupported = await invoke( ["up", "web", "--detach", "--json"], checkout, @@ -279,40 +282,14 @@ try { detachedResult.ok === false && record(detachedResult.error).code === "E_STARTUP_INCOMPLETE", "admitted detached startup returns one JSON failure envelope without readiness"); - // The admitted lifecycle action records its failed outcome. This is not - // registry or generated runtime state; permit only that named audit file. - const homeEntries = (await readdir(home, { recursive: true })).sort(); - require((homeEntries.length === 0 || - JSON.stringify(homeEntries) === - JSON.stringify([".hack", ".hack/activity.jsonl"])) && + require((await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 2, + })) && !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" - ), "no registry or generated runtime state"); - if (homeEntries.length > 0) { - const entries = (await readFile(join(home, ".hack/activity.jsonl"), "utf8")) - .trim() - .split("\n") - .map((line) => record(JSON.parse(line))); - require(entries.length === 2 && - entries.every( - (entry) => - entry.action === "up" && - entry.outcome === "failed" && - JSON.stringify(Object.keys(entry).sort()) === - JSON.stringify([ - "action", - "branch", - "duration_ms", - "failed_services", - "origin", - "outcome", - "project", - "ts", - "v", - ]) && - !JSON.stringify(entry).includes(directory) - ), "only the two failed lifecycle actions are recorded without private paths"); - } + ), "only failed lifecycle audit records; no registry or generated runtime state"); process.stdout.write( "Relocated native env plan acceptance: offline validation, metadata-only output, missing overlays/refs, immutable directives, separate hashes, real linked-worktree layers, CI/slim/opt-out, unsafe-input and runtime refusal passed\n" ); diff --git a/scripts/check-native-host-plan-cli.ts b/scripts/check-native-host-plan-cli.ts index de6dd3033..23b7fa1e4 100644 --- a/scripts/check-native-host-plan-cli.ts +++ b/scripts/check-native-host-plan-cli.ts @@ -3,6 +3,7 @@ import { copyFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Actual relocated CLI and sidecar; no Bun/Rust on PATH and no host effects. */ const root = resolve(import.meta.dir, ".."); @@ -277,6 +278,14 @@ try { "local settings cannot inject host commands" ); await rm(join(checkout, ".hack/hack.local.json")); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -288,7 +297,11 @@ try { ); assert( !(await Bun.file(marker).exists()) && - (await readdir(home)).length === 0 && + (await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 1, + })) && !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ), diff --git a/scripts/check-native-process-plan-cli.ts b/scripts/check-native-process-plan-cli.ts index a9e2d0c8b..cc7aac0c6 100644 --- a/scripts/check-native-process-plan-cli.ts +++ b/scripts/check-native-process-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Qualify pure process planning through a relocated bundle and real Git worktree. */ const root = resolve(import.meta.dir, ".."); @@ -262,6 +263,14 @@ try { ], checkout ); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -289,7 +298,11 @@ try { ); } assert( - (await readdir(join(home, ".hack")).catch(() => [])).length === 0, + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 1, + }), "planning creates no registry or runtime home state" ); process.stdout.write( diff --git a/scripts/check-native-routing-plan-cli.ts b/scripts/check-native-routing-plan-cli.ts index f85076287..8c21094b1 100644 --- a/scripts/check-native-routing-plan-cli.ts +++ b/scripts/check-native-routing-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Exercise the relocated executable/sidecar with isolated policy and real linked Git worktrees. */ const root = resolve(import.meta.dir, ".."); @@ -378,6 +379,15 @@ try { "local route injection refused" ); await rm(join(checkout, ".hack/hack.local.json")); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + existing: [".hack", ".hack/hack.config.json"], + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -393,7 +403,13 @@ try { (await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ) - ) && (await readdir(join(home, ".hack"))).join() === "hack.config.json", + ) && + (await nativePlanHomeMatches({ + home, + privateRoot: directory, + existing: [".hack", ".hack/hack.config.json"], + failedActions: 1, + })), "no hook, registry, route publication or generated-state effects" ); process.stdout.write( diff --git a/scripts/lib/native-plan-home.ts b/scripts/lib/native-plan-home.ts new file mode 100644 index 000000000..5ef439dd6 --- /dev/null +++ b/scripts/lib/native-plan-home.ts @@ -0,0 +1,52 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../src/lib/guards.ts"; + +/** Planning stays read-only; subsequent refused lifecycle probes may append only their audit records. */ +export async function nativePlanHomeMatches(opts: { + readonly home: string; + readonly privateRoot: string; + readonly failedActions: number; + readonly existing?: readonly string[]; +}): Promise { + const actual = (await readdir(opts.home, { recursive: true })).sort(); + const expected = [...(opts.existing ?? [])]; + if (opts.failedActions > 0) { + expected.push(".hack", ".hack/activity.jsonl"); + } + if ( + JSON.stringify(actual) !== JSON.stringify([...new Set(expected)].sort()) + ) { + return false; + } + if (opts.failedActions === 0) { + return true; + } + const text = await readFile(join(opts.home, ".hack/activity.jsonl"), "utf8"); + const entries: unknown[] = text + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + return ( + entries.length === opts.failedActions && + entries.every( + (entry) => + isRecord(entry) && + entry.action === "up" && + entry.outcome === "failed" && + JSON.stringify(Object.keys(entry).sort()) === + JSON.stringify([ + "action", + "branch", + "duration_ms", + "failed_services", + "origin", + "outcome", + "project", + "ts", + "v", + ]) && + !JSON.stringify(entry).includes(opts.privateRoot) + ) + ); +} diff --git a/tests/native-plan-home.test.ts b/tests/native-plan-home.test.ts new file mode 100644 index 000000000..5d7ab7ab1 --- /dev/null +++ b/tests/native-plan-home.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { nativePlanHomeMatches } from "../scripts/lib/native-plan-home.ts"; + +let root: string; +let home: string; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-plan-audit-")); + home = join(root, "home"); + await mkdir(home); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); +const entry = { + v: 1, + ts: "2026-01-01T00:00:00Z", + project: null, + branch: null, + action: "up", + origin: "cli", + outcome: "failed", + failed_services: [], + duration_ms: 1, +}; +async function write(records: readonly object[]) { + await mkdir(join(home, ".hack"), { recursive: true }); + await Bun.write( + join(home, ".hack/activity.jsonl"), + `${records.map((value) => JSON.stringify(value)).join("\n")}\n` + ); +} +function matches(failedActions: number) { + return nativePlanHomeMatches({ home, privateRoot: root, failedActions }); +} +test("planning must leave home empty; lifecycle probes have an exact audit count", async () => { + expect(await matches(0)).toBe(true); + expect(await matches(1)).toBe(false); + await write([entry]); + expect(await matches(0)).toBe(false); + expect(await matches(1)).toBe(true); + expect(await matches(2)).toBe(false); + await write([entry, entry]); + expect(await matches(2)).toBe(true); +}); +test("audit allowance refuses registry files, unexpected fields, paths and successful actions", async () => { + for (const record of [ + { ...entry, outcome: "ok" }, + { ...entry, path: "extra" }, + { ...entry, project: root }, + ]) { + await write([record]); + expect(await matches(1)).toBe(false); + } + await write([entry]); + await Bun.write(join(home, ".hack/projects.json"), "{}"); + expect(await matches(1)).toBe(false); +}); From 0c69a6ff8e0549cb143af1a99c0ef6cdd03de03a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:02:13 +0000 Subject: [PATCH 12/25] docs(cli): classify published open output as a bare object --- docs/reference/machine-readable-output.md | 2 +- src/cli/json-contracts.ts | 2 +- tests/project-open-loopback.test.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 494a713bf..2b87ba49a 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -105,7 +105,7 @@ remains covered by its command-specific suites. | `hack node workspace list --json` | Legacy bare object (no `data` envelope) | | `hack node workspace remove --json` | Legacy bare object (no `data` envelope) | | `hack node workspace resolve --json` | Legacy bare object (no `data` envelope) | -| `hack open --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack open --json` | Legacy bare object (no `data` envelope) | | `hack project owner show --json` | Legacy bare object (no `data` envelope) | | `hack project target --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack project task inspect --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index be69107b4..93359552d 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -23,7 +23,7 @@ export const JSON_COMMAND_SHAPES = { "commands show": "envelope", "commands cancel": "envelope", logs: "logs", - open: "envelope", + open: "legacy-object", activity: "schema-envelope", doctor: "envelope", "daemon status": "legacy-object", diff --git a/tests/project-open-loopback.test.ts b/tests/project-open-loopback.test.ts index cbe1469d9..ae222cf21 100644 --- a/tests/project-open-loopback.test.ts +++ b/tests/project-open-loopback.test.ts @@ -1,4 +1,5 @@ import { expect, spyOn, test } from "bun:test"; +import { JSON_COMMAND_SHAPES } from "../src/cli/json-contracts.ts"; import { emitOpenOrigin } from "../src/commands/project.ts"; test("open refuses a confirmed loopback failure before JSON or browser output", async () => { @@ -25,6 +26,7 @@ test("open refuses a confirmed loopback failure before JSON or browser output", }); test("native loopback origin bypasses Compose DNS, Docker and CA probes even if a proxy failure would be reported", async () => { + expect(JSON_COMMAND_SHAPES.open).toBe("legacy-object"); const write = spyOn(process.stdout, "write").mockImplementation(() => true); let checks = 0; try { From d6372ba7e256a284987e48760b2b7518c6bd28c4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:51:31 +0000 Subject: [PATCH 13/25] test(desktop): establish log child before stream drop --- apps/desktop/core/src/runner.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 67c4591ff..53492e5ad 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -961,11 +961,15 @@ wait $!"#, #[test] fn log_stream_parses_ndjson_and_stops_on_drop() { + // Establish the pipe-holding descendant before publishing events. If + // sleep starts after the last event, drop can race that fork rather + // than exercise cleanup of an already-running process group. let f = FakeHack::new( - r#"echo '{"type":"start","backend":"compose"}' + r#"sleep 30 & +echo '{"type":"start","backend":"compose"}' echo '{"type":"log","entry":{"service":"api","message":"hi"}}' echo 'plain text' -sleep 30"#, +wait $!"#, ); let s = f.runner().stream_logs(&["logs", "--json"], None).unwrap(); let mut got = Vec::new(); From 3ffc59744142aef93a1b412def28d866bf41adea Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:56:24 +0000 Subject: [PATCH 14/25] fix(desktop): bound log stream reader cleanup on drop --- apps/desktop/README.md | 5 +++ apps/desktop/core/src/runner.rs | 80 +++++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index af83baccf..01a3153a1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -36,6 +36,11 @@ The core reads version identity through `hack version --json` and validates its use the strict parser; daemon and published legacy payloads keep their existing parsers. Shared boundary fixtures are checked against the CLI producers. +Dropping a log stream retries killing its owned process group for up to two +seconds before detaching a reader still waiting for EOF. A descendant that +leaves that group cannot be retired by group signals; it no longer blocks the +dropping thread, and its reader exits when the descendant closes stdout. + ## Check the live data path without a window ```sh diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 53492e5ad..97caf7c33 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -344,7 +344,8 @@ impl CliRunner { } } -/// A running log stream. Dropping it kills the process group. +/// A running log stream. Drop retries group cleanup for at most two seconds, +/// then detaches a reader still held open by a descendant outside that group. pub struct LogStream { child: Child, rx: mpsc::Receiver, @@ -365,10 +366,23 @@ impl LogStream { impl Drop for LogStream { fn drop(&mut self) { - kill_group(&mut self.child); + signal_kill_group(&mut self.child); if let Some(r) = self.reader.take() { - let _ = r.join(); + // A group signal can race a descendant fork on macOS. Keep the + // leader unreaped while retrying, preserving ownership of its PID + // and catching late members of the same process group. + let until = Instant::now() + Duration::from_secs(2); + while !r.is_finished() && Instant::now() < until { + thread::sleep(Duration::from_millis(20)); + signal_kill_group(&mut self.child); + } + // A descendant may have left our group while keeping stdout open. + // Detach that reader rather than block the dropping UI thread. + if r.is_finished() { + let _ = r.join(); + } } + let _ = self.child.wait(); } } @@ -498,6 +512,18 @@ fn kill_group(child: &mut Child) { let _ = child.wait(); } +/// Only for the log-drop path, which has not reaped its child. Avoid +/// Child::kill here: its status check may reap and release the PID reservation. +fn signal_kill_group(child: &mut Child) { + if let Ok(pid) = i32::try_from(child.id()) { + // SAFETY: plain syscall on the group we created with process_group(0). + unsafe { + libc::kill(-pid, libc::SIGKILL); + libc::kill(pid, libc::SIGKILL); + } + } +} + fn head(s: &str) -> String { s.chars().take(200).collect() } @@ -983,4 +1009,52 @@ wait $!"#, drop(s); assert!(t.elapsed() < Duration::from_secs(5)); } + + #[test] + #[ignore = "subprocess fixture for log_stream_drop_is_bounded_with_an_escaped_writer"] + fn escaped_log_pipe_helper() { + // SAFETY: this fixture subprocess leaves its parent's group so the + // parent can test a writer that its owned-group signal cannot reach. + assert_eq!(unsafe { libc::setpgid(0, 0) }, 0); + let path = std::env::var("HACK_TEST_ESCAPED_LOG_PID").unwrap(); + std::fs::write(path, std::process::id().to_string()).unwrap(); + println!("escaped-ready"); + thread::sleep(Duration::from_secs(30)); + } + + #[test] + fn log_stream_drop_is_bounded_with_an_escaped_writer() { + struct EscapedWriter(i32); + impl Drop for EscapedWriter { + fn drop(&mut self) { + // SAFETY: PID supplied only by our fixture subprocess, which + // remains alive until this guard retires it. + unsafe { libc::kill(self.0, libc::SIGKILL) }; + } + } + let executable = std::env::current_exe().unwrap(); + let quoted = executable.to_string_lossy().replace('\'', "'\\''"); + let f = FakeHack::new(&format!( + "'{quoted}' --exact runner::tests::escaped_log_pipe_helper --ignored --nocapture &\nwait $!" + )); + let pid_file = f.dir.join("escaped.pid"); + let runner = f + .runner() + .with_env("HACK_TEST_ESCAPED_LOG_PID", &pid_file.to_string_lossy()); + let s = runner.stream_logs(&["logs", "--json"], None).unwrap(); + loop { + let event = s.next_timeout(T).expect("escaped writer ready"); + if event.message.as_deref() == Some("escaped-ready") { + break; + } + } + let escaped = EscapedWriter(std::fs::read_to_string(pid_file).unwrap().parse().unwrap()); + let t = Instant::now(); + drop(s); + assert!(t.elapsed() < Duration::from_secs(5)); + // The fixture still holds stdout: bounded return did not require EOF. + // SAFETY: signal zero only checks our still-owned fixture process. + assert_eq!(unsafe { libc::kill(escaped.0, 0) }, 0); + drop(escaped); + } } From 6ca7b5efd4572d3ee7360aac62f51535b76a89ef Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:04:40 +0000 Subject: [PATCH 15/25] docs(projects): distinguish observation envelope from legacy listing --- docs/reference/project-state.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/reference/project-state.md b/docs/reference/project-state.md index 972ecf426..fb08fb567 100644 --- a/docs/reference/project-state.md +++ b/docs/reference/project-state.md @@ -1,5 +1,10 @@ # Backend-aware project state +Plain `hack projects --json` retains its published top-level object. With +`--observe`, the payload is inside the [machine-output envelope](./machine-readable-output.md) +with `data.schema: "hack.projects.observe.v1"`. The row fields below are shared +by both CLI forms and the daemon payload. + Full and summary `hack projects --json` rows and hackd's `GET /v1/projects` share these additional fields. Older `status` and `runtime_status` fields remain for compatibility; consumers of this contract use `state`. From 253a71393d874e30c80aa4fab3cb846857a3f563 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:08:36 +0000 Subject: [PATCH 16/25] fix(projects): retain computed native observation in JSON output --- src/commands/projects.ts | 9 --------- tests/projects-observe.test.ts | 13 +++++++++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 645a4a24c..28a58bc4a 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -44,7 +44,6 @@ import { AmbiguousProjectNameError, normalizeProjectName, } from "../lib/project-name.ts"; -import { projectObservation } from "../lib/project-observation.ts"; import { assertPruneRuntimeOwnership } from "../lib/project-prune-ownership.ts"; import { findMissingRegistryEntries, @@ -853,14 +852,6 @@ function outputProjectsJson(opts: { ...(opts.summary ? serializeProjectSummary(view) : serializeProjectView(view)), - ...(opts.observe - ? projectObservation({ - view, - runtimeOk: opts.runtime.ok, - nativeSelected: - process.env.HACK_RUNTIME_BACKEND?.trim() === "native", - }) - : {}), ...(opts.includeMeta ? { meta: opts.metaByName.get(view.name) ?? null } : {}), diff --git a/tests/projects-observe.test.ts b/tests/projects-observe.test.ts index ae88c4009..c8fe28470 100644 --- a/tests/projects-observe.test.ts +++ b/tests/projects-observe.test.ts @@ -191,6 +191,19 @@ test("observe runtime explicitly opts in to Docker reads without registering dis expect(result.code).toBe(0); const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.runtime_requested).toBe(true); + // R2's observer has run even without a native selection. Re-projecting + // here would replace its result with native-observer-required (and discard + // live service evidence when a native selection is available). + expect( + payload.projects.find( + (row: { name: string }) => row.name === "native-app" + ) + ).toMatchObject({ + backend: "native", + state: "unknown", + state_reason: "observation-unavailable", + services: [], + }); expect( payload.projects.find( (row: { name: string }) => row.name === "unregistered-app" From d1aa159713c54a470284ae7cd53d87568fd91a73 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:43:43 +0000 Subject: [PATCH 17/25] fix(runtime): inspect containers without health checks safely --- src/lib/runtime-projects.ts | 2 +- tests/e2e/scenarios/cache-prune.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts index 37c50d204..b5ea948b9 100644 --- a/src/lib/runtime-projects.ts +++ b/src/lib/runtime-projects.ts @@ -629,7 +629,7 @@ export async function readContainerLabels(opts: { // Request only fields used by the runtime model; Docker must not return env values or command arguments. const RUNTIME_INSPECT_FORMAT = - '{"Id":{{json .Id}},"Config":{"Labels":{{json .Config.Labels}},"Image":{{json .Config.Image}}},"State":{"Status":{{json .State.Status}},"ExitCode":{{json .State.ExitCode}},"StartedAt":{{json .State.StartedAt}},"Health":{{if .State.Health}}{"Status":{{json .State.Health.Status}}}{{else}}null{{end}}},"Mounts":{{json .Mounts}},"NetworkSettings":{"Networks":{{json .NetworkSettings.Networks}}}}'; + '{"Id":{{json .Id}},"Config":{"Labels":{{json .Config.Labels}},"Image":{{json .Config.Image}}},"State":{"Status":{{json .State.Status}},"ExitCode":{{json .State.ExitCode}},"StartedAt":{{json .State.StartedAt}},"Health":{{with index .State "Health"}}{"Status":{{json .Status}}}{{else}}null{{end}}},"Mounts":{{json .Mounts}},"NetworkSettings":{"Networks":{{json .NetworkSettings.Networks}}}}'; async function readContainerInspectData(opts: { readonly ids: readonly string[]; diff --git a/tests/e2e/scenarios/cache-prune.ts b/tests/e2e/scenarios/cache-prune.ts index 290dda491..cd784b60f 100644 --- a/tests/e2e/scenarios/cache-prune.ts +++ b/tests/e2e/scenarios/cache-prune.ts @@ -167,6 +167,10 @@ function renderCacheCompose(opts: { readonly name: string }): string { " web:", " image: oven/bun:1", " command: bun -e 'setInterval(() => {}, 60000)'", + // An absent State.Health must not make the runtime inspect template lose + // mount/ownership evidence required by the cache-pruning assertions. + " healthcheck:", + " disable: true", " volumes:", ` - ${DISPOSABLE_VOLUME}:/app/.turbo`, ` - ${DURABLE_VOLUME}:/app/data`, From 337f005f0a0843a9a29b8a0060ebb8252d67d7ed Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:37:36 +0000 Subject: [PATCH 18/25] test(desktop): keep stream fixture alive past cleanup deadline --- apps/desktop/core/src/runner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 0d7e37453..a33404abc 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -1012,7 +1012,7 @@ wait $!"#, // sleep starts after the last event, drop can race that fork rather // than exercise cleanup of an already-running process group. let f = FakeHack::new( - r#"sleep 30 & + r#"sleep 300 & echo '{"type":"start","backend":"compose"}' echo '{"type":"log","entry":{"service":"api","message":"hi"}}' echo 'plain text' From 6303c0bfd32eddac2c7e08564b0a280a0bded22d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:41:01 +0000 Subject: [PATCH 19/25] test(desktop): assert log pipe descendant is retired --- apps/desktop/core/src/runner.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index a33404abc..505b68212 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -1013,6 +1013,7 @@ wait $!"#, // than exercise cleanup of an already-running process group. let f = FakeHack::new( r#"sleep 300 & +echo $! > "$HACK_HOME/holder" echo '{"type":"start","backend":"compose"}' echo '{"type":"log","entry":{"service":"api","message":"hi"}}' echo 'plain text' @@ -1027,6 +1028,12 @@ wait $!"#, assert_eq!(got[1].entry.as_ref().unwrap().message, "hi"); assert_eq!(got[2].message.as_deref(), Some("plain text")); let pid = i32::try_from(s.child.id()).unwrap(); + let holder: i32 = std::fs::read_to_string(f.dir.join("holder")) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(!process_gone(holder), "pipe holder is alive"); assert!( !process_gone(pid), "positive control: stream child is alive" @@ -1037,6 +1044,7 @@ wait $!"#, ); completes(move || drop(s)); assert_retired(pid); + assert_retired(holder); } #[test] From f4d2b55887fe25ece662f490e38def9bb74f9e68 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 17:13:27 +0000 Subject: [PATCH 20/25] test(cli): check real success shapes of hermetic JSON commands The inventory test compared command names and usage errors only, so a listed command could change its success shape unnoticed. Run each listed command that succeeds without a runtime, engine or project and check its output against its inventory row; legacy objects pin their top-level keys. Co-Authored-By: Claude Opus 5.5 --- docs/reference/machine-readable-output.md | 7 +- tests/machine-output-success-shapes.test.ts | 157 ++++++++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 tests/machine-output-success-shapes.test.ts diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 2b87ba49a..817669e62 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -43,9 +43,10 @@ command-specific parser. Do not infer a success shape from an error envelope. Regenerate with `bun scripts/generate-machine-output-reference.ts`. Tests compare the inventory to the command definitions and exercise every command's shared -usage-error boundary without entering its handler. The migrated success variants -also have real CLI-boundary contract tests; existing legacy success behavior -remains covered by its command-specific suites. +usage-error boundary without entering its handler. Every listed command that can +succeed without a runtime, engine or project is also run for real and checked +against its row; legacy objects there pin their top-level keys. The remaining +success behavior stays covered by command-specific suites. | Canonical command / variant | Success shape | diff --git a/tests/machine-output-success-shapes.test.ts b/tests/machine-output-success-shapes.test.ts new file mode 100644 index 000000000..00796225e --- /dev/null +++ b/tests/machine-output-success-shapes.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + JSON_COMMAND_SHAPES, + SCHEMA_JSON_VARIANTS, +} from "../src/cli/json-contracts.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * Real success output of every listed command that can succeed hermetically + * (temp HOME, empty working directory, no daemon, engine or network). Each case + * checks the shape its inventory row declares, so changing either the output + * or the row without the other fails. Legacy objects pin their top-level keys: + * adding, removing or wrapping one is a contract change that must be deliberate. + * Commands needing a runtime, Docker engine, project or macOS (such as + * `usage`, `doctor` and admin plans) keep their command-specific + * suites; the usage-error boundary for all of them is in + * machine-output-contract.test.ts. + */ +const entrypoint = resolve(import.meta.dir, "..", "index.ts"); + +type Case = { + readonly command: string; + readonly flags?: readonly string[]; + readonly keys?: readonly string[]; +}; + +const CASES: readonly Case[] = [ + { command: "version" }, + { command: "activity" }, + { command: "projects", flags: ["--observe"] }, + { command: "global status", flags: ["--observe"] }, + { command: "projects prune" }, + { command: "upgrade check" }, + { + command: "projects", + keys: [ + "filter", + "generated_at", + "include_global", + "include_meta", + "include_unregistered", + "projects", + "runtime_checked_at", + "runtime_error", + "runtime_last_ok_at", + "runtime_last_repair_at", + "runtime_next_step", + "runtime_ok", + "runtime_repair_action", + "runtime_repair_outcome", + "runtime_reset_at", + "runtime_reset_changes", + "runtime_reset_count", + "runtime_reset_summary", + ], + }, + { + command: "host ps", + keys: ["commands", "generatedAt", "lifecycle", "snapshotAvailable"], + }, + { command: "node list", keys: ["defaultNodeId", "nodes"] }, + { + command: "env backend status", + keys: [ + "allow_env_auth_refs", + "backend", + "cloud", + "encrypted_file", + "encrypted_file_key_env", + "status", + "store_plaintext_in_backend", + ], + }, +]; + +function schemaFor(item: Case): string | undefined { + const flags = item.flags ?? []; + return SCHEMA_JSON_VARIANTS.find( + (variant) => + variant.command === item.command && + variant.flags.join(" ") === flags.join(" ") + )?.schema; +} + +/** A versioned flag variant overrides its base row (plain `projects` is legacy). */ +function declaredShape(item: Case): string | undefined { + if (schemaFor(item)) { + return "schema-envelope"; + } + return JSON_COMMAND_SHAPES[item.command as keyof typeof JSON_COMMAND_SHAPES]; +} + +async function runJson(item: Case): Promise<{ code: number; json: unknown }> { + const root = await mkdtemp(join(tmpdir(), "hack-json-shape-")); + try { + const cwd = join(root, "work"); + await mkdir(cwd); + const child = Bun.spawn( + [ + process.execPath, + entrypoint, + ...item.command.split(" "), + ...(item.flags ?? []), + "--json", + ], + { + cwd, + env: privateCliEnv({ + root, + env: { HACK_HOME: join(root, "state"), HACK_LOGGER: "console" }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(code, stderr).toBe(0); + return { code, json: JSON.parse(stdout) }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test.each( + CASES.map((item) => [ + [item.command, ...(item.flags ?? []), "--json"].join(" "), + item, + ]) +)("%s keeps its inventory success shape", async (_label, item) => { + const shape = declaredShape(item); + const { json } = await runJson(item); + expect(json).toBeObject(); + const result = json as Record; + const keys = Object.keys(result).sort(); + if (shape === "schema-envelope" || shape === "envelope") { + expect(item.keys).toBeUndefined(); + expect(keys).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data).toBeObject(); + const data = result.data as Record; + if (shape === "schema-envelope") { + expect(data.schema).toBe(schemaFor(item)); + } + return; + } + expect(shape).toBe("legacy-object"); + expect(item.keys).toBeDefined(); + expect(keys).toEqual([...(item.keys ?? [])].sort()); +}); From cda79803acc93f3dd368846de252890d94391cd7 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 17:14:40 +0000 Subject: [PATCH 21/25] test(cli): take plain command shapes from their inventory row Co-Authored-By: Claude Opus 5.5 --- tests/machine-output-success-shapes.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/machine-output-success-shapes.test.ts b/tests/machine-output-success-shapes.test.ts index 00796225e..4c6b84cb0 100644 --- a/tests/machine-output-success-shapes.test.ts +++ b/tests/machine-output-success-shapes.test.ts @@ -85,10 +85,14 @@ function schemaFor(item: Case): string | undefined { )?.schema; } -/** A versioned flag variant overrides its base row (plain `projects` is legacy). */ +/** + * The inventory row decides a plain command's shape. Only a flag variant + * (`projects --observe`) may differ from its base row, and then only through + * its own SCHEMA_JSON_VARIANTS entry. + */ function declaredShape(item: Case): string | undefined { - if (schemaFor(item)) { - return "schema-envelope"; + if ((item.flags ?? []).length > 0) { + return schemaFor(item) ? "schema-envelope" : undefined; } return JSON_COMMAND_SHAPES[item.command as keyof typeof JSON_COMMAND_SHAPES]; } From 2058a977bef8f0513d57254f1fe20eb0e2bf44dc Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 02:32:25 +0000 Subject: [PATCH 22/25] docs(cli): list native setup in the machine output inventory `native setup --json` (from #794) passes the runtime result through as a bare `{status, files, probe}` object. Record that shape so the inventory matches the command definitions; it does not follow the envelope rule. Co-Authored-By: Claude Opus 5.5 --- docs/reference/machine-readable-output.md | 1 + src/cli/json-contracts.ts | 1 + src/cli/json-reference.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 817669e62..478a2cdd1 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -80,6 +80,7 @@ success behavior stays covered by command-specific suites. | `hack global trust --plan --json` | Envelope; versioned `data.schema`: `hack.global-admin-plan.v1` | | `hack host ps --json` | Legacy bare object (no `data` envelope) | | `hack logs --json` | NDJSON logs; finite native modes may return a legacy snapshot object | +| `hack native setup --json` | Native runtime result passed through unchanged (`status`, `files`, `probe`; no `data` envelope) | | `hack node add --json` | Legacy bare object (no `data` envelope) | | `hack node devcontainer attach --json` | Legacy bare object (no `data` envelope) | | `hack node devcontainer down --json` | Legacy bare object (no `data` envelope) | diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 93359552d..843f71f92 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -44,6 +44,7 @@ export const JSON_COMMAND_SHAPES = { "config adopt": "legacy-object", "setup sync": "error-only", "setup migrate": "envelope", + "native setup": "runtime-passthrough", "session panes": "ndjson", "session capture": "ndjson", "session tail": "ndjson", diff --git a/src/cli/json-reference.ts b/src/cli/json-reference.ts index bd22f2795..ba336aa2e 100644 --- a/src/cli/json-reference.ts +++ b/src/cli/json-reference.ts @@ -34,6 +34,8 @@ const SHAPES: Record< transport: "Native compiler protocol object (`transport_version`, `ok`, result / diagnostics)", "error-only": "Retired: usage-error envelope, no success payload", + "runtime-passthrough": + "Native runtime result passed through unchanged (`status`, `files`, `probe`; no `data` envelope)", }; export function renderJsonInventory(cli: CliSpec): string { From 36fba169bfa0d783b56af776d53800d85c51c65c Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 02:48:53 +0000 Subject: [PATCH 23/25] fix(projects): bound native status reads across concurrent callers The four-read limit applied per request, so parallel /v1/projects calls to hackd could multiply native status subprocesses. Native observation now takes a slot from one process-wide limit of four, and concurrent reads of the same project share one read. Settled results are not cached. Addresses the Codex security review on this PR. Co-Authored-By: Claude Opus 5.5 --- docs/reference/project-state.md | 5 +- src/lib/project-native-observation.ts | 69 ++++++++++++++++++++++-- tests/project-native-observation.test.ts | 52 +++++++++++++++++- 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/docs/reference/project-state.md b/docs/reference/project-state.md index fb08fb567..df1f248ca 100644 --- a/docs/reference/project-state.md +++ b/docs/reference/project-state.md @@ -75,7 +75,10 @@ provider errors are omitted. An explicitly verified native runtime degradation reports `degraded` with `state_reason: "native-runtime-degraded"`, without claiming service readiness. Native observations cover the registered root's base instance; branch observations remain a separate interface. At most four -native project status reads run concurrently. +native project status reads run at once in one process, across all concurrent +callers (for example parallel `/v1/projects` requests to hackd). Callers that +ask for the same project at the same time share one read; settled results are +not cached. Observation boundaries from [observe-only listing](../cli.md) are preserved: `hack projects --json --observe` reads no engine or native provider and leaves diff --git a/src/lib/project-native-observation.ts b/src/lib/project-native-observation.ts index 49a3a7c95..2e0caa785 100644 --- a/src/lib/project-native-observation.ts +++ b/src/lib/project-native-observation.ts @@ -9,12 +9,75 @@ import { } from "./project-state.ts"; import type { ProjectView } from "./project-views.ts"; -/** Optional live native reads. The default observe-only CLI never calls this boundary. */ -export async function observeNativeProject(opts: { +type ObserveOptions = { readonly view: ProjectView; readonly ps?: typeof nativeProjectPs; readonly authoredPs?: typeof nativeAuthoredProjectPs; -}): Promise { +}; + +/** + * Native status reads own subprocesses. The limit is process-wide, not per + * request, so parallel `/v1/projects` callers cannot multiply the fan-out. + */ +export const NATIVE_OBSERVATION_LIMIT = 4; +let active = 0; +const waiting: (() => void)[] = []; +/** Concurrent reads of the same project share one subprocess and result. */ +const inFlight = new WeakMap< + object, + Map> +>(); + +async function withObservationSlot(run: () => Promise): Promise { + if (active >= NATIVE_OBSERVATION_LIMIT) { + await new Promise((resolve) => waiting.push(resolve)); + } else { + active++; + } + try { + return await run(); + } finally { + const next = waiting.shift(); + if (next) { + // Hand the slot over directly; `active` is unchanged. + next(); + } else { + active--; + } + } +} + +/** Optional live native reads. The default observe-only CLI never calls this boundary. */ +export function observeNativeProject( + opts: ObserveOptions +): Promise { + // Share only reads that would run the same reader for the same project. + const reader: object = + opts.view.inputDiagnostic?.code === "E_NATIVE_PROJECT_UNSUPPORTED" + ? (opts.authoredPs ?? nativeAuthoredProjectPs) + : (opts.ps ?? nativeProjectPs); + let shared = inFlight.get(reader); + if (!shared) { + shared = new Map(); + inFlight.set(reader, shared); + } + const key = JSON.stringify([ + opts.view.repoRoot, + opts.view.projectDir, + opts.view.inputDiagnostic?.code ?? null, + ]); + const existing = shared.get(key); + if (existing) { + return existing; + } + const pending = withObservationSlot(() => observeOnce(opts)).finally(() => + shared.delete(key) + ); + shared.set(key, pending); + return pending; +} + +async function observeOnce(opts: ObserveOptions): Promise { const unknown: ProjectObservation = { backend: "native", state: "unknown", diff --git a/tests/project-native-observation.test.ts b/tests/project-native-observation.test.ts index 990010d55..ed7f745e0 100644 --- a/tests/project-native-observation.test.ts +++ b/tests/project-native-observation.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import type { NativeProjectPsResult } from "../src/backends/native-project-observe.ts"; -import { observeNativeProject } from "../src/lib/project-native-observation.ts"; +import { + NATIVE_OBSERVATION_LIMIT, + observeNativeProject, +} from "../src/lib/project-native-observation.ts"; import type { ProjectView } from "../src/lib/project-views.ts"; let original: Record; @@ -146,3 +149,50 @@ test("verified native runtime degradation is distinct from unavailable observati expect(report.state).toBe("degraded"); expect(report.state_reason).toBe("native-runtime-degraded"); }); + +const notStarted = (): NativeProjectPsResult => + ({ backend: "native", status: "not_started" }) as NativeProjectPsResult; + +test("native reads are bounded process-wide across concurrent callers", async () => { + let running = 0; + let peak = 0; + const ps = async () => { + running++; + peak = Math.max(peak, running); + await Bun.sleep(5); + running--; + return notStarted(); + }; + // Separate "requests", each with its own projects, all at once. + const reports = await Promise.all( + Array.from({ length: 3 * NATIVE_OBSERVATION_LIMIT + 1 }, (_, index) => + observeNativeProject({ + view: { + ...view, + repoRoot: `/example/project-${index}`, + projectDir: `/example/project-${index}/.hack`, + }, + ps, + }) + ) + ); + expect(peak).toBe(NATIVE_OBSERVATION_LIMIT); + expect(reports.every((report) => report.state === "stopped")).toBe(true); +}); + +test("concurrent reads of one project share a single native read", async () => { + let calls = 0; + const ps = async () => { + calls++; + await Bun.sleep(5); + return notStarted(); + }; + const reports = await Promise.all( + [1, 2, 3].map(() => observeNativeProject({ view, ps })) + ); + expect(calls).toBe(1); + expect(new Set(reports).size).toBe(1); + // Settled reads are not cached: the next caller reads fresh state. + await observeNativeProject({ view, ps }); + expect(calls).toBe(2); +}); From 4e5538263afd8d5e104d9db8a157c1a227e6f1df Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:08:33 +0000 Subject: [PATCH 24/25] fix(cli)!: record env migrate and usage by-service; note the next.17 migration `env migrate --json` (#797) is an envelope without `data.schema`; `usage --json --by-service` (#852) is a schema-tagged variant, so its schema id moves into MACHINE_SCHEMAS. Flag variants whose base command keeps another shape now get their own reference row instead of a hard-coded list. The outputs this branch migrates first shipped in 5.0.0-next.17, so the reference page no longer calls them unpublished and the next.18 release notes describe the change for scripts. BREAKING CHANGE: version, activity, projects --observe, global status --observe and global install|trust --plan JSON move into the {ok,data:{schema}} / {ok:false,error} envelope. Co-Authored-By: Claude Opus 5.5 --- docs/guides/v5-release-notes.md | 11 +++++++++++ docs/reference/machine-readable-output.md | 14 ++++++++------ src/cli/json-contracts.ts | 6 ++++++ src/cli/json-reference.ts | 5 ++++- src/commands/usage.ts | 3 ++- src/lib/machine-output.ts | 3 ++- 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/guides/v5-release-notes.md b/docs/guides/v5-release-notes.md index 11af785e2..039a6f04c 100644 --- a/docs/guides/v5-release-notes.md +++ b/docs/guides/v5-release-notes.md @@ -35,6 +35,17 @@ 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). +### Breaking change: next.17 machine output moves into the JSON envelope + +Five `--json` outputs that first shipped in next.17 now use the documented +envelope (HACK-2238). Plain `hack projects --json` and other older outputs keep +their shapes. See [machine-readable output](../reference/machine-readable-output.md). + +| Change | What to do | +| --- | --- | +| **`hack version --json`, `hack activity --json`, `hack projects --json --observe`, `hack global status --json --observe` and `hack global install\|trust --plan --json` print `{"ok":true,"data":{...}}`.** The payload is under `data` and carries a string `data.schema` (for example `hack.version.v1`). | Read the payload from `data` and check `data.schema` before using it. | +| **Their failures print `{"ok":false,"error":{"code":"E_...","message":"..."}}` and exit nonzero.** A refused or incomplete administrator plan is `E_PLAN_REFUSED` (exit 1) or `E_PLAN_INCOMPLETE` (exit 3), with the partial plan at `error.detail.plan`. | Branch on `ok` and `error.code`, not on top-level payload fields. | + ## next.17: what changed and what to do These are source changes intended for `5.0.0-next.17`, not a claim that the diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 478a2cdd1..560859956 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -24,12 +24,12 @@ Both retain the partial plan, including its schema, at `error.detail.plan`. Unknown/offline state in an observation is still a successful observation; it is not a claim that the runtime is healthy. -The previously unpublished version, observe-only project/status and admin-plan -outputs now follow this rule. Plain `projects --json` and other published legacy -outputs keep their shapes. A consumer must not use the new observe-only parser -for a plain projects result. Streaming interfaces remain NDJSON and have their -own event contracts. No published payload is silently wrapped by this change. -The unpublished Activity output also carries `data.schema: "hack.activity.v1"`. +The version, Activity, observe-only project/status and admin-plan outputs +follow this rule from next.18. They first shipped in 5.0.0-next.17 with other +shapes; the [next.18 release notes](../guides/v5-release-notes.md) say how to +migrate. Plain `projects --json` and other older legacy outputs keep their +shapes. A consumer must not use the new observe-only parser for a plain projects +result. Streaming interfaces remain NDJSON and have their own event contracts. ![JSON command boundary](./machine-readable-output.svg) @@ -71,6 +71,7 @@ success behavior stays covered by command-specific suites. | `hack env explain --json` | Legacy bare object (no `data` envelope) | | `hack env hydrate --json` | Legacy bare object (no `data` envelope) | | `hack env list --json` | Legacy bare object (no `data` envelope) | +| `hack env migrate --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack env protect --json` | Legacy bare object (no `data` envelope) | | `hack exec --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack exec-sessions --json` | Legacy bare object (no `data` envelope) | @@ -134,4 +135,5 @@ success behavior stays covered by command-specific suites. | `hack version --json` | Envelope; versioned `data.schema`: `hack.version.v1` | | `hack projects --json --observe` | Envelope; `data.schema: hack.projects.observe.v1` | | `hack global status --json --observe` | Envelope; `data.schema: hack.global-status.observe.v1` | +| `hack usage --json --by-service` | Envelope; `data.schema: hack.usage.by-service.v1` | diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 843f71f92..1bd5047de 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -31,6 +31,7 @@ export const JSON_COMMAND_SHAPES = { "dispatch status": "legacy-object", "dispatch logs": "legacy-object", "env list": "legacy-object", + "env migrate": "envelope", "env protect": "legacy-object", "env explain": "legacy-object", "env apply": "legacy-object", @@ -106,4 +107,9 @@ export const SCHEMA_JSON_VARIANTS = [ flags: ["--plan"], schema: "hack.global-admin-plan.v1", }, + { + command: "usage", + flags: ["--by-service"], + schema: "hack.usage.by-service.v1", + }, ] as const; diff --git a/src/cli/json-reference.ts b/src/cli/json-reference.ts index ba336aa2e..e4367baf3 100644 --- a/src/cli/json-reference.ts +++ b/src/cli/json-reference.ts @@ -51,8 +51,11 @@ export function renderJsonInventory(cli: CliSpec): string { const flags = variant?.flags.join(" ") ?? ""; return `| \`hack ${command}${flags ? ` ${flags}` : ""} --json\` | ${SHAPES[kind]}${variant ? `: \`${variant.schema}\`` : ""} |`; }); + // A flag variant whose base command keeps another shape gets its own row. for (const variant of SCHEMA_JSON_VARIANTS.filter( - (entry) => entry.command === "projects" || entry.command === "global status" + (entry) => + JSON_COMMAND_SHAPES[entry.command as keyof typeof JSON_COMMAND_SHAPES] !== + "schema-envelope" )) { rows.push( `| \`hack ${variant.command} --json ${variant.flags.join(" ")}\` | Envelope; \`data.schema: ${variant.schema}\` |` diff --git a/src/commands/usage.ts b/src/commands/usage.ts index 84001c938..0add0619b 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -22,6 +22,7 @@ import { readHostCommandRecords, readObservedProcesses, } from "../lib/host-command-observation.ts"; +import { MACHINE_SCHEMAS } from "../lib/machine-output.ts"; import { MissingProjectContextError } from "../lib/missing-project-error.ts"; import { findProjectContext, @@ -90,7 +91,7 @@ const optByService = defineOption({ } as const); /** Schema id of `hack usage --json --by-service`; see docs/reference/usage-by-service.md. */ -export const USAGE_BY_SERVICE_SCHEMA = "hack.usage.by-service.v1"; +export const USAGE_BY_SERVICE_SCHEMA = MACHINE_SCHEMAS.usageByService; const options = [ optProject, diff --git a/src/lib/machine-output.ts b/src/lib/machine-output.ts index ca0fdf8e9..1b8561c5c 100644 --- a/src/lib/machine-output.ts +++ b/src/lib/machine-output.ts @@ -1,12 +1,13 @@ import { type CliOkResult, okResult } from "./cli-result.ts"; -/** Unpublished HACK-2218 contracts use an envelope and a versioned payload schema. */ +/** HACK-2218 machine contracts use an envelope and a versioned payload schema. */ export const MACHINE_SCHEMAS = { activity: "hack.activity.v1", version: "hack.version.v1", projectsObserve: "hack.projects.observe.v1", globalAdminPlan: "hack.global-admin-plan.v1", globalStatusObserve: "hack.global-status.observe.v1", + usageByService: "hack.usage.by-service.v1", } as const; export function machineSuccess(opts: { From db01be6341412345ba1a1b35f0fc1ea82169bf4a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:09:33 +0000 Subject: [PATCH 25/25] docs(desktop): drop the stale unpublished claim from the schema parser Co-Authored-By: Claude Opus 5.5 --- apps/desktop/core/src/runner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 384caee5a..a86a9738e 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -584,7 +584,7 @@ pub fn parse_result(stdout: &str, stderr: &str, code: Option) -> Result