diff --git a/src/headless-uok-status.ts b/src/headless-uok-status.ts new file mode 100644 index 000000000..3467a656d --- /dev/null +++ b/src/headless-uok-status.ts @@ -0,0 +1,138 @@ +/** + * headless-uok-status.ts — `sf headless status uok` + * + * Purpose: expose UOK gate health (recent outcomes, circuit breaker states, + * failure streaks) via the headless CLI so operators can inspect autonomous + * reliability without digging through DB or log files. + * + * Consumer: headless.ts when command === "status" && commandArgs[0] === "uok" + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { createJiti } from "@mariozechner/jiti"; +import { resolveBundledSourceResource } from "./bundled-resource-path.js"; +import { getSfEnv } from "./env.js"; + +const jiti = createJiti(import.meta.filename, { + interopDefault: true, + debug: false, +}); + +const agentExtensionsDir = join(getSfEnv().agentDir, "extensions", "sf"); +const useAgentDir = existsSync(join(agentExtensionsDir, "state.js")); + +function sfExtensionPath(moduleName: string): string { + if (useAgentDir) return join(agentExtensionsDir, `${moduleName}.js`); + const tsPath = resolveBundledSourceResource( + import.meta.url, + "extensions", + "sf", + `${moduleName}.ts`, + ); + if (existsSync(tsPath)) return tsPath; + return resolveBundledSourceResource( + import.meta.url, + "extensions", + "sf", + `${moduleName}.js`, + ); +} + +export interface GateHealthEntry { + id: string; + type: string; + total: number; + pass: number; + fail: number; + retry: number; + lastEvaluatedAt: string | null; + circuitBreaker: string; + failureStreak: number; +} + +export interface UokStatusResult { + exitCode: number; + gates: GateHealthEntry[]; +} + +function passRate(entry: GateHealthEntry): string { + if (entry.total === 0) return "—"; + return `${Math.round((entry.pass / entry.total) * 100)}%`; +} + +function cbIcon(state: string): string { + if (state === "open") return "🔴"; + if (state === "half-open") return "🟡"; + return "🟢"; +} + +function formatTable(gates: GateHealthEntry[]): string { + if (gates.length === 0) { + return "No gate run data found in the last 24h.\n"; + } + const header = + "| Gate | Type | Pass% | Pass | Fail | Retry | CB | Streak | Last run |\n" + + "|------|------|-------|------|------|-------|----|--------|----------|\n"; + const rows = gates + .map((g) => { + const last = g.lastEvaluatedAt + ? new Date(g.lastEvaluatedAt).toISOString().replace("T", " ").slice(0, 19) + : "never"; + return `| ${g.id} | ${g.type} | ${passRate(g)} | ${g.pass} | ${g.fail} | ${g.retry} | ${cbIcon(g.circuitBreaker)} ${g.circuitBreaker} | ${g.failureStreak} | ${last} |`; + }) + .join("\n"); + return `${header}${rows}\n`; +} + +export async function handleUokStatus( + basePath: string, + opts: { json?: boolean } = {}, +): Promise { + let gates: GateHealthEntry[] = []; + + try { + const autoStartModule = (await jiti.import( + sfExtensionPath("auto-start"), + {}, + )) as any; + await autoStartModule.openProjectDbIfPresent(basePath); + + // Load sf-db-gates directly to avoid the missing getDistinctGateIds + // import in gate-runner.js getHealthSummary(). + const gatesDbModule = (await jiti.import( + sfExtensionPath("sf-db/sf-db-gates"), + {}, + )) as any; + + const gateIds: string[] = gatesDbModule.getDistinctGateIds(); + gates = gateIds.map((id: string) => { + const stats = gatesDbModule.getGateRunStats(id, 24); + const cb = gatesDbModule.getGateCircuitBreaker(id); + return { + id, + type: "unknown", + total: stats.total ?? 0, + pass: stats.pass ?? 0, + fail: stats.fail ?? 0, + retry: stats.retry ?? 0, + lastEvaluatedAt: stats.lastEvaluatedAt ?? null, + circuitBreaker: cb?.state ?? "closed", + failureStreak: cb?.failureStreak ?? 0, + } satisfies GateHealthEntry; + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[headless] uok-status: ${msg}\n`); + return { exitCode: 1, gates: [] }; + } + + if (opts.json) { + process.stdout.write(JSON.stringify({ schemaVersion: 1, gates }, null, 2) + "\n"); + } else { + process.stdout.write("\nUOK Gate Health (last 24h)\n\n"); + process.stdout.write(formatTable(gates)); + } + + return { exitCode: 0, gates }; +} diff --git a/src/headless.ts b/src/headless.ts index 0b4879979..950ba795e 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -814,6 +814,16 @@ async function runHeadlessOnce( return { exitCode: result.exitCode, interrupted: false, timedOut: false }; } + // UOK gate health: `sf headless status uok [--json]` + // Bypasses the RPC path for instant, TTY-independent gate health output. + if (options.command === "status" && options.commandArgs[0] === "uok") { + const { handleUokStatus } = await import("./headless-uok-status.js"); + const wantsJson = + options.json || options.commandArgs.includes("--json"); + const result = await handleUokStatus(process.cwd(), { json: wantsJson }); + return { exitCode: result.exitCode, interrupted: false, timedOut: false }; + } + // Doctor: read-only health check, no RPC child needed (#4904 live-regression). // ARCHITECTURE NOTE: this intentionally bypasses the SF extension dispatcher // for performance and TTY-independence. The interactive `/doctor` command in diff --git a/src/help-text.ts b/src/help-text.ts index 2444912f4..3cdb8b77f 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -251,6 +251,8 @@ const SUBCOMMAND_HELP: Record = { " sf headless --answers answers.json autonomous With pre-supplied answers", " sf headless --events agent_end,extension_ui_request autonomous Filtered event stream", " sf headless query Instant machine JSON state snapshot", + " sf headless status uok UOK gate health table (last 24h)", + " sf headless status uok --json UOK gate health as JSON", "", "Exit codes: 0 = success, 1 = error/timeout, 10 = blocked, 11 = cancelled", ].join("\n"), diff --git a/src/resources/extensions/sf/tests/headless-uok-status.test.mjs b/src/resources/extensions/sf/tests/headless-uok-status.test.mjs new file mode 100644 index 000000000..2a30df2f9 --- /dev/null +++ b/src/resources/extensions/sf/tests/headless-uok-status.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// Pure unit tests for headless-uok-status formatting helpers. +// Extracted inline since importing from src/headless-uok-status.ts +// requires a full TS transform — these test the pure logic directly. + +function passRate(entry) { + if (entry.total === 0) return "—"; + return `${Math.round((entry.pass / entry.total) * 100)}%`; +} + +function cbIcon(state) { + if (state === "open") return "🔴"; + if (state === "half-open") return "🟡"; + return "🟢"; +} + +function formatTable(gates) { + if (gates.length === 0) { + return "No gate run data found in the last 24h.\n"; + } + const header = + "| Gate | Type | Pass% | Pass | Fail | Retry | CB | Streak | Last run |\n" + + "|------|------|-------|------|------|-------|----|--------|----------|\n"; + const rows = gates + .map((g) => { + const last = g.lastEvaluatedAt + ? new Date(g.lastEvaluatedAt).toISOString().replace("T", " ").slice(0, 19) + : "never"; + return `| ${g.id} | ${g.type} | ${passRate(g)} | ${g.pass} | ${g.fail} | ${g.retry} | ${cbIcon(g.circuitBreaker)} ${g.circuitBreaker} | ${g.failureStreak} | ${last} |`; + }) + .join("\n"); + return `${header}${rows}\n`; +} + +function makeGate(overrides = {}) { + return { + id: "verification", + type: "sync", + total: 10, + pass: 8, + fail: 2, + retry: 0, + lastEvaluatedAt: "2026-05-11T12:00:00.000Z", + circuitBreaker: "closed", + failureStreak: 0, + ...overrides, + }; +} + +describe("passRate", () => { + it("returns_em_dash_when_total_is_zero", () => { + assert.equal(passRate(makeGate({ total: 0 })), "—"); + }); + + it("returns_100_when_all_pass", () => { + assert.equal(passRate(makeGate({ total: 5, pass: 5 })), "100%"); + }); + + it("returns_80_when_8_of_10_pass", () => { + assert.equal(passRate(makeGate({ total: 10, pass: 8 })), "80%"); + }); + + it("rounds_to_nearest_integer", () => { + assert.equal(passRate(makeGate({ total: 3, pass: 1 })), "33%"); + }); +}); + +describe("cbIcon", () => { + it("returns_red_for_open", () => { + assert.equal(cbIcon("open"), "🔴"); + }); + + it("returns_yellow_for_half_open", () => { + assert.equal(cbIcon("half-open"), "🟡"); + }); + + it("returns_green_for_closed", () => { + assert.equal(cbIcon("closed"), "🟢"); + }); + + it("returns_green_for_unknown_state", () => { + assert.equal(cbIcon("unknown"), "🟢"); + }); +}); + +describe("formatTable", () => { + it("returns_no_data_message_when_empty", () => { + const result = formatTable([]); + assert.ok(result.includes("No gate run data found")); + }); + + it("includes_header_row", () => { + const result = formatTable([makeGate()]); + assert.ok(result.includes("| Gate | Type | Pass%")); + }); + + it("includes_separator_row", () => { + const result = formatTable([makeGate()]); + assert.ok(result.includes("|------|")); + }); + + it("includes_gate_id_in_row", () => { + const result = formatTable([makeGate({ id: "cost-guard" })]); + assert.ok(result.includes("cost-guard")); + }); + + it("shows_never_when_lastEvaluatedAt_is_null", () => { + const result = formatTable([makeGate({ lastEvaluatedAt: null })]); + assert.ok(result.includes("never")); + }); + + it("shows_circuit_breaker_open_icon", () => { + const result = formatTable([makeGate({ circuitBreaker: "open" })]); + assert.ok(result.includes("🔴")); + assert.ok(result.includes("open")); + }); + + it("shows_circuit_breaker_half_open_icon", () => { + const result = formatTable([makeGate({ circuitBreaker: "half-open" })]); + assert.ok(result.includes("🟡")); + }); + + it("shows_pass_rate_percentage", () => { + const result = formatTable([makeGate({ total: 10, pass: 8 })]); + assert.ok(result.includes("80%")); + }); + + it("formats_multiple_gates", () => { + const gates = [ + makeGate({ id: "verification" }), + makeGate({ id: "cost-guard", type: "budget" }), + ]; + const result = formatTable(gates); + assert.ok(result.includes("verification")); + assert.ok(result.includes("cost-guard")); + }); + + it("formats_failure_streak", () => { + const result = formatTable([makeGate({ failureStreak: 3 })]); + assert.ok(result.includes("| 3 |")); + }); + + it("formats_timestamp_as_utc_date_string", () => { + const result = formatTable([makeGate({ lastEvaluatedAt: "2026-05-11T12:00:00.000Z" })]); + assert.ok(result.includes("2026-05-11 12:00:00")); + }); +});