feat(sf): S03/T04 — add UOK gate health to sf headless status uok
Adds a new `sf headless status uok` subcommand that queries gate-run stats and circuit-breaker state from sf.db and formats them as a markdown table or JSON (--json flag). - src/headless-uok-status.ts: handler that loads sf-db-gates directly (avoids the unimported getDistinctGateIds in gate-runner) - src/headless.ts: bypass RPC, route 'status uok' to handler - src/help-text.ts: document the new subcommand - tests/headless-uok-status.test.mjs: 19 node:test coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
4132ecc1db
commit
797db16ae8
4 changed files with 299 additions and 0 deletions
138
src/headless-uok-status.ts
Normal file
138
src/headless-uok-status.ts
Normal file
|
|
@ -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<UokStatusResult> {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -251,6 +251,8 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
" 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"),
|
||||
|
|
|
|||
149
src/resources/extensions/sf/tests/headless-uok-status.test.mjs
Normal file
149
src/resources/extensions/sf/tests/headless-uok-status.test.mjs
Normal file
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue