mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
feat(crash-cart): C3 unit-C — main.ts probes the verb + renders the cockpit (daemon-down e2e wired)
The TUI now shows the recovery cockpit when the daemon is down:
- from-emit.ts: crashCartRenderOpts maps the parsed verdict → renderScreen opts
(DOWN+discovery → cockpit; UNVERIFIED+evidence → cannot-verify; UP or DOWN+refusal
→ normal TUI — rail 3: never a cockpit from a refusal); probeCrashCart runs the verb
+ maps, and ANY failure → normal TUI (never a fabricated cockpit). RED-first 7/7.
- main.ts: on launch, probe {"ok":false,"error":{"code":"commander.unknownCommand","message":"unknown command 'crash-cart'"}} (its JSON is the truth even on a
hint non-zero exit) → feed crashCartOpts into draw(). Key-triggered re-probe +
the cockpit action keys (s/i/n/⏎, needing daemon-start exec + the C1-excluded
RESTORE) are the follow-on increment.
TUI tsc 0 + 71/71 (crash-cart + pulse/stylize regression). The spawn glue is
real-run-proven (a real daemon-down run); the parse+map cores are unit-tested.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
17a8935d8f
commit
2760f5c49d
3 files changed
+124
-1
No files matched your search
@@ -0,0 +1,48 @@
|
||||
// Crash-cart C3 unit-C — map the parsed `rig crash-cart --json` verdict onto the renderScreen
|
||||
// daemon-down opts. The verb is the SSOT; this is the TUI's thin interpretation. RAIL 3: a DOWN
|
||||
// verdict that carries a REFUSAL (the read fail-closed because a daemon actually answered) NEVER
|
||||
// renders the cockpit — it falls to the normal TUI.
|
||||
import type { DaemonState, DaemonUnverifiedEvidence } from "./contract.js";
|
||||
import { buildCrashCartModel, type CrashCartDiscoveryInput, type CrashCartModel } from "./crash-cart-model.js";
|
||||
|
||||
/** The `rig crash-cart --json` payload (mirrors the daemon verb's emit — the documented JSON contract). */
|
||||
export interface CrashCartEmit {
|
||||
state: DaemonState;
|
||||
evidence?: DaemonUnverifiedEvidence;
|
||||
discovery?: CrashCartDiscoveryInput;
|
||||
refusal?: string;
|
||||
}
|
||||
|
||||
/** The daemon-down subset of RenderOptions the TUI feeds renderScreen (empty ⇒ normal fleet views). */
|
||||
export interface CrashCartRenderOpts {
|
||||
daemonState?: DaemonState;
|
||||
crashCart?: CrashCartModel;
|
||||
daemonEvidence?: DaemonUnverifiedEvidence;
|
||||
}
|
||||
|
||||
/** Verdict → render opts. DOWN+discovery → cockpit; UNVERIFIED+evidence → cannot-verify; else (UP, or
|
||||
* DOWN+refusal) → normal TUI (never the cockpit from a refusal). */
|
||||
export function crashCartRenderOpts(emit: CrashCartEmit): CrashCartRenderOpts {
|
||||
if (emit.state === "down" && emit.discovery) {
|
||||
return { daemonState: "down", crashCart: buildCrashCartModel(emit.discovery) };
|
||||
}
|
||||
if (emit.state === "unverified" && emit.evidence) {
|
||||
return { daemonState: "unverified", daemonEvidence: emit.evidence };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `rig crash-cart --json` verb (injected) + map its JSON → render opts. Any failure (the verb
|
||||
* erroring, or unparseable output) yields normal-TUI opts — the probe NEVER fabricates a cockpit from a
|
||||
* failed run (honest-degraded).
|
||||
*/
|
||||
export async function probeCrashCart(runVerb: () => Promise<string>): Promise<CrashCartRenderOpts> {
|
||||
try {
|
||||
const emit = JSON.parse(await runVerb()) as CrashCartEmit;
|
||||
if (!emit || typeof emit.state !== "string") return {};
|
||||
return crashCartRenderOpts(emit);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { demoSnapshot } from "./demo-data.js";
|
||||
import { DaemonClient, launchNodeNotice } from "./daemon-client.js";
|
||||
import { hydrateSnapshot } from "./hydrate.js";
|
||||
import { createLiveRefresh } from "./live.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { probeCrashCart, type CrashCartRenderOpts } from "./crash-cart/from-emit.js";
|
||||
import type { Action, FleetSnapshot, Screen } from "./types.js";
|
||||
import type { SpecReviewCache } from "./hydrate.js";
|
||||
|
||||
@@ -40,6 +42,9 @@ async function run(): Promise<void> {
|
||||
|
||||
let inputLine = "";
|
||||
let lastScreen: Screen | null = null;
|
||||
// 5.2 crash-cart: the daemon-down verdict (probed from the `rig crash-cart --json` verb). Empty ⇒
|
||||
// normal fleet views; DOWN ⇒ the recovery cockpit; UNVERIFIED ⇒ the cannot-verify screen.
|
||||
let crashCartOpts: CrashCartRenderOpts = {};
|
||||
const inputDecoder = createInputDecoder();
|
||||
const style = createStyle(args.includes("--no-color") ? "none" : detectColorMode());
|
||||
|
||||
@@ -58,7 +63,7 @@ async function run(): Promise<void> {
|
||||
const rows = process.stdout.rows ?? 32;
|
||||
const nowMs = Date.now();
|
||||
if (live) snapshot = live.snapshot();
|
||||
const opts = { cols, rows, nowMs, colorMode: style.mode, ...(live ? { load: live.load(), rowFlashes: live.flashes() } : {}) };
|
||||
const opts = { cols, rows, nowMs, colorMode: style.mode, ...crashCartOpts, ...(live ? { load: live.load(), rowFlashes: live.flashes() } : {}) };
|
||||
lastScreen = renderScreen(view.get(), snapshot, opts, inputLine);
|
||||
if (view.get().contentMaxOffset !== lastScreen.contentMaxOffset || view.get().contentTargetCount !== lastScreen.contentTargets.length) {
|
||||
view.dispatch({ type: "layout", contentMaxOffset: lastScreen.contentMaxOffset, contentTargetCount: lastScreen.contentTargets.length });
|
||||
@@ -71,6 +76,20 @@ async function run(): Promise<void> {
|
||||
motionTimer = lastScreen.motionActive ? setTimeout(draw, 120) : null;
|
||||
}
|
||||
|
||||
// 5.2 crash-cart: probe the daemon-down verdict via the shipped `rig crash-cart --json` verb (its
|
||||
// JSON is the truth even on a hint non-zero exit). Any failure → normal TUI (never a fabricated cockpit).
|
||||
const runCrashCartVerb = (): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFile("rig", ["crash-cart", "--json"], { timeout: 5000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
|
||||
if (stdout && stdout.trim()) resolve(stdout);
|
||||
else reject(err ?? new Error("crash-cart: no output"));
|
||||
});
|
||||
});
|
||||
async function refreshCrashCart(): Promise<void> {
|
||||
crashCartOpts = await probeCrashCart(runCrashCartVerb);
|
||||
draw();
|
||||
}
|
||||
|
||||
const socketPath = argOf(args, "--socket") ?? defaultSocketPath(instanceId);
|
||||
const socket = await createControlSocket({ socketPath, view, onMutation: draw });
|
||||
|
||||
@@ -172,6 +191,9 @@ async function run(): Promise<void> {
|
||||
// state — the refresh starts after entering the alt screen, never before,
|
||||
// so loading is VISIBLE instead of awaited behind a blank terminal
|
||||
draw();
|
||||
// Probe the daemon-down verdict once on launch: bare `rig` with the daemon down renders the cockpit.
|
||||
// (Key-triggered re-probe after `s start daemon` / `r retry` is the follow-on increment.)
|
||||
void refreshCrashCart();
|
||||
if (live) {
|
||||
void live.refresh();
|
||||
refreshTimer = setInterval(() => void live.refresh(), REFRESH_MS);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { crashCartRenderOpts, probeCrashCart, type CrashCartEmit } from "../src/crash-cart/from-emit.js";
|
||||
|
||||
// Crash-cart C3 unit-C — map the `rig crash-cart --json` verdict → the renderScreen daemon-down opts.
|
||||
// DOWN+discovery → the cockpit; UNVERIFIED+evidence → the cannot-verify screen; UP → normal TUI. Rail 3:
|
||||
// a DOWN+refusal (the read fail-closed because a daemon answered) NEVER renders the cockpit → normal TUI.
|
||||
|
||||
describe("crashCartRenderOpts — verdict → render opts", () => {
|
||||
it("DOWN + discovery → daemonState down + a built cockpit model", () => {
|
||||
const emit: CrashCartEmit = {
|
||||
state: "down",
|
||||
discovery: {
|
||||
header: { lastActivityAt: "2026-08-06T08:12:00Z" },
|
||||
foundOnHost: [{ rigName: "alpha", seatCount: 2, resumableCount: 2, lastActiveAt: "2026-08-06T08:00:00Z" }],
|
||||
whereWorkStopped: [],
|
||||
},
|
||||
};
|
||||
const o = crashCartRenderOpts(emit);
|
||||
expect(o.daemonState).toBe("down");
|
||||
expect(o.crashCart?.foundOnHost[0]?.name).toBe("alpha");
|
||||
expect(o.crashCart?.header.lastSeen).toBe("08:12");
|
||||
});
|
||||
|
||||
it("UNVERIFIED + evidence → daemonState unverified + the evidence", () => {
|
||||
const o = crashCartRenderOpts({ state: "unverified", evidence: { pidState: "alive", probeResult: "timeout", failedSignal: "x" } });
|
||||
expect(o.daemonState).toBe("unverified");
|
||||
expect(o.daemonEvidence?.probeResult).toBe("timeout");
|
||||
expect(o.crashCart).toBeUndefined();
|
||||
});
|
||||
|
||||
it("UP → normal TUI (no daemon-down opts)", () => {
|
||||
expect(crashCartRenderOpts({ state: "up" })).toEqual({});
|
||||
});
|
||||
|
||||
it("DOWN + refusal → normal TUI, NEVER the cockpit (rail 3: a refusal means a daemon answered)", () => {
|
||||
const o = crashCartRenderOpts({ state: "down", refusal: "a daemon answered /healthz — refusing the direct read" });
|
||||
expect(o).toEqual({});
|
||||
expect(o.daemonState).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeCrashCart — run the verb + map; failures never fabricate a cockpit", () => {
|
||||
it("maps valid verb JSON to opts", async () => {
|
||||
const o = await probeCrashCart(async () => JSON.stringify({ state: "unverified", evidence: { pidState: "p", probeResult: "timeout", failedSignal: "s" } }));
|
||||
expect(o.daemonState).toBe("unverified");
|
||||
});
|
||||
it("verb errors → normal TUI ({})", async () => {
|
||||
expect(await probeCrashCart(async () => { throw new Error("spawn failed"); })).toEqual({});
|
||||
});
|
||||
it("unparseable output → normal TUI ({})", async () => {
|
||||
expect(await probeCrashCart(async () => "not json")).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user