diff --git a/packages/cli/src/commands/crash-cart.ts b/packages/cli/src/commands/crash-cart.ts new file mode 100644 index 000000000..a3ddbd221 --- /dev/null +++ b/packages/cli/src/commands/crash-cart.ts @@ -0,0 +1,47 @@ +import { Command } from "commander"; + +// `rig crash-cart --json` — the daemon-DOWN recovery verdict emit (plan c015d9ed §C3, coupling ruling +// option A). Prints ONE JSON = the 3-state detector verdict + (on DOWN) the discovery — READ-ONLY, a +// public agent-readable surface (the 4 rails). A fail-closed refusal still prints STRUCTURED JSON +// (never exit-code-only). The `emit` is injected: the real wiring imports emitCrashCartState from the +// scoped @openrig/daemon/crash-cart subpath (reuse VERBATIM) — set up alongside this command. +// +// NOTE: this CLI-local CrashCartEmit is the documented parse/print contract; it matches the daemon's +// emit shape (the JSON contract). When the subpath export lands, the real emit is wired as the default. + +export type DaemonState = "up" | "down" | "unverified"; + +export interface CrashCartEmit { + state: DaemonState; + evidence?: { pidState: string; probeResult: string; failedSignal: string }; + discovery?: unknown; + refusal?: string; +} + +export interface CrashCartCommandDeps { + /** Produce the verdict (the real wiring: emitCrashCartState with live probes + the C2 read). */ + emit: () => Promise; + /** Emit one line (default: stdout). */ + write: (line: string) => void; +} + +export function crashCartCommand(deps?: Partial): Command { + const cmd = new Command("crash-cart").description( + "Emit the daemon-down recovery verdict + discovery as JSON (read-only).", + ); + cmd.option("--json", "emit JSON (the default machine-readable form)"); + cmd.action(async () => { + const emit = + deps?.emit ?? + (() => { + throw new Error("crash-cart: real emit wiring pending the @openrig/daemon/crash-cart subpath export"); + }); + const write = deps?.write ?? ((line: string) => process.stdout.write(line + "\n")); + const result = await emit(); + // Rail 3: the JSON is the truth (verdict + discovery, or a structured refusal). Always printed. + write(JSON.stringify(result)); + // A non-zero exit is a HINT only (not-cleanly-up); the JSON remains the contract. + process.exitCode = result.state === "up" ? 0 : 1; + }); + return cmd; +} diff --git a/packages/cli/test/crash-cart-command.test.ts b/packages/cli/test/crash-cart-command.test.ts new file mode 100644 index 000000000..103e0acaf --- /dev/null +++ b/packages/cli/test/crash-cart-command.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from "vitest"; +import { crashCartCommand, type CrashCartEmit } from "../src/commands/crash-cart.js"; + +// Crash-cart C3 unit-B — the `rig crash-cart --json` verb (coupling ruling C, 4 rails). It prints ONE +// JSON = emitCrashCartState's verdict (read-only). A fail-closed refusal still prints STRUCTURED JSON +// (never exit-code-only). emit + write are injected → the action is deterministic; the real-dep +// assembly is glue proven by the real daemon-down run. + +function run(emit: () => Promise) { + const write = vi.fn(); + const cmd = crashCartCommand({ emit, write }); + return { write, done: cmd.parseAsync(["--json"], { from: "user" }) }; +} + +describe("crashCartCommand — prints the emit verdict as ONE JSON", () => { + it("UP → JSON {state:'up'}", async () => { + const { write, done } = run(async () => ({ state: "up" })); + await done; + expect(JSON.parse(write.mock.calls[0][0])).toEqual({ state: "up" }); + }); + + it("DOWN → JSON with the discovery", async () => { + const emit: CrashCartEmit = { state: "down", discovery: { header: { lastActivityAt: null, lastBootAt: null, firstBootAt: null, hostId: null, stopReason: null, priorUptimeMs: null }, foundOnHost: [], whereWorkStopped: [] } }; + const { write, done } = run(async () => emit); + await done; + expect(JSON.parse(write.mock.calls[0][0])).toEqual(emit); + }); + + it("DOWN + refusal → STRUCTURED JSON (refusal note, no discovery); still printed, not exit-code-only", async () => { + const { write, done } = run(async () => ({ state: "down", refusal: "a daemon answered /healthz — refusing" })); + await done; + const out = JSON.parse(write.mock.calls[0][0]); + expect(out.state).toBe("down"); + expect(out.refusal).toContain("refusing"); + expect(out.discovery).toBeUndefined(); + expect(write).toHaveBeenCalledTimes(1); + }); + + it("UNVERIFIED → JSON with the evidence", async () => { + const { write, done } = run(async () => ({ state: "unverified", evidence: { pidState: "alive", probeResult: "timeout", failedSignal: "healthz timed out" } })); + await done; + expect(JSON.parse(write.mock.calls[0][0]).evidence.probeResult).toBe("timeout"); + }); +});