feat(sf): fix gate health — import, DB fallback, and enrich status uok

Three follow-up fixes from S03/T04:

1. gate-runner.js: add missing getDistinctGateIds import from sf-db.js.
   UokGateRunner.getHealthSummary() called it when registry was empty but
   it was never imported — runtime ReferenceError in headless contexts.

2. sf-db-gates.js: getDistinctGateIds + getGateRunStats fall back to the
   quality_gates DB table when no trace events are found (e.g. after trace
   file rotation). Ensures gate health survives trace cleanup.

3. headless-uok-status.ts: replace generic Type column with real Scope
   (task/slice/milestone) from quality_gates DB, and show actual Last
   Evaluated timestamp from DB even when outside the 24h stats window.
   Tests updated to match (21 pass).

Closes backlog items: bl-gate-runner-import-bug, bl-gate-stats-trace-vs-db,
bl-uok-status-enrich.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-11 18:47:42 +02:00
parent 797db16ae8
commit 4e5fc12e81
4 changed files with 141 additions and 38 deletions

View file

@ -41,7 +41,7 @@ function sfExtensionPath(moduleName: string): string {
export interface GateHealthEntry {
id: string;
type: string;
scope: string;
total: number;
pass: number;
fail: number;
@ -72,14 +72,17 @@ function formatTable(gates: GateHealthEntry[]): string {
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";
"| Gate | Scope | Pass% | Pass | Fail | Retry | CB | Streak | Last Evaluated |\n" +
"|------|-------|-------|------|------|-------|----|--------|----------------|\n";
const rows = gates
.map((g) => {
const last = g.lastEvaluatedAt
? new Date(g.lastEvaluatedAt).toISOString().replace("T", " ").slice(0, 19)
? 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} |`;
return `| ${g.id} | ${g.scope} | ${passRate(g)} | ${g.pass} | ${g.fail} | ${g.retry} | ${cbIcon(g.circuitBreaker)} ${g.circuitBreaker} | ${g.failureStreak} | ${last} |`;
})
.join("\n");
return `${header}${rows}\n`;
@ -106,17 +109,42 @@ export async function handleUokStatus(
)) as any;
const gateIds: string[] = gatesDbModule.getDistinctGateIds();
// Fetch scope and last-evaluated from quality_gates DB for each gate
const sfDbModule = (await jiti.import(sfExtensionPath("sf-db"), {})) as any;
const getGateMeta = (
id: string,
): { scope: string; lastEvaluatedAt: string | null } => {
try {
const db = sfDbModule._getAdapter?.() ?? null;
if (!db) return { scope: "unknown", lastEvaluatedAt: null };
const row = db
.prepare(
"SELECT scope, MAX(evaluated_at) AS last_eval FROM quality_gates WHERE gate_id = ? LIMIT 1",
)
.get(id);
return {
scope: row?.scope ?? "unknown",
lastEvaluatedAt: row?.last_eval ?? null,
};
} catch {
return { scope: "unknown", lastEvaluatedAt: null };
}
};
gates = gateIds.map((id: string) => {
const stats = gatesDbModule.getGateRunStats(id, 24);
const cb = gatesDbModule.getGateCircuitBreaker(id);
const meta = getGateMeta(id);
return {
id,
type: "unknown",
scope: meta.scope,
total: stats.total ?? 0,
pass: stats.pass ?? 0,
fail: stats.fail ?? 0,
retry: stats.retry ?? 0,
lastEvaluatedAt: stats.lastEvaluatedAt ?? null,
// prefer stats window result; fall back to quality_gates last entry
lastEvaluatedAt: stats.lastEvaluatedAt ?? meta.lastEvaluatedAt,
circuitBreaker: cb?.state ?? "closed",
failureStreak: cb?.failureStreak ?? 0,
} satisfies GateHealthEntry;
@ -128,7 +156,9 @@ export async function handleUokStatus(
}
if (opts.json) {
process.stdout.write(JSON.stringify({ schemaVersion: 1, gates }, null, 2) + "\n");
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));

View file

@ -1,9 +1,14 @@
import { dirname } from 'node:path';
import { _getAdapter, getDbPath, rowToGate, transaction } from './sf-db-core.js';
import { SF_STALE_STATE, SFError } from '../errors.js';
import { getGateIdsForTurn } from '../gate-registry.js';
import { readTraceEvents } from '../uok/trace-writer.js';
import { logWarning } from '../workflow-logger.js';
import { dirname } from "node:path";
import { SF_STALE_STATE, SFError } from "../errors.js";
import { getGateIdsForTurn } from "../gate-registry.js";
import { readTraceEvents } from "../uok/trace-writer.js";
import { logWarning } from "../workflow-logger.js";
import {
_getAdapter,
getDbPath,
rowToGate,
transaction,
} from "./sf-db-core.js";
export function insertGateRow(g) {
const currentDb = _getAdapter();
@ -168,26 +173,66 @@ export function getGateRunStats(gateId, windowHours = 24) {
const events = readTraceEvents(basePath, "gate_run", windowHours).filter(
(e) => e.gateId === gateId,
);
const stats = {
total: events.length,
if (events.length > 0) {
const stats = {
total: events.length,
pass: 0,
fail: 0,
retry: 0,
manualAttention: 0,
lastEvaluatedAt: null,
};
for (const e of events) {
if (e.outcome === "pass") stats.pass++;
else if (e.outcome === "fail") stats.fail++;
else if (e.outcome === "retry") stats.retry++;
else if (e.outcome === "manual-attention") stats.manualAttention++;
if (
!stats.lastEvaluatedAt ||
(e.evaluatedAt ?? e.ts) > stats.lastEvaluatedAt
)
stats.lastEvaluatedAt = e.evaluatedAt ?? e.ts;
}
return stats;
}
// Fall back to quality_gates DB when no trace events found (e.g. after trace rotation)
const db = _getAdapter();
if (db) {
const cutoff = new Date(
Date.now() - windowHours * 3600 * 1000,
).toISOString();
const rows = db
.prepare(
`SELECT verdict, evaluated_at FROM quality_gates
WHERE gate_id = ? AND evaluated_at >= ? AND verdict != '' AND verdict != 'omitted'
ORDER BY evaluated_at DESC`,
)
.all(gateId, cutoff);
if (rows.length > 0) {
const stats = {
total: rows.length,
pass: 0,
fail: 0,
retry: 0,
manualAttention: 0,
lastEvaluatedAt: rows[0].evaluated_at,
};
for (const r of rows) {
if (r.verdict === "pass") stats.pass++;
else if (r.verdict === "fail" || r.verdict === "flag") stats.fail++;
else if (r.verdict === "manual-attention") stats.manualAttention++;
}
return stats;
}
}
return {
total: 0,
pass: 0,
fail: 0,
retry: 0,
manualAttention: 0,
lastEvaluatedAt: null,
};
for (const e of events) {
if (e.outcome === "pass") stats.pass++;
else if (e.outcome === "fail") stats.fail++;
else if (e.outcome === "retry") stats.retry++;
else if (e.outcome === "manual-attention") stats.manualAttention++;
if (
!stats.lastEvaluatedAt ||
(e.evaluatedAt ?? e.ts) > stats.lastEvaluatedAt
)
stats.lastEvaluatedAt = e.evaluatedAt ?? e.ts;
}
return stats;
} catch {
return {
total: 0,
@ -344,7 +389,19 @@ export function getDistinctGateIds() {
? dirname(dirname(currentPath))
: process.cwd();
const events = readTraceEvents(basePath, "gate_run", 24 * 30); // 30 days
return [...new Set(events.map((e) => e.gateId).filter(Boolean))];
const traceIds = [...new Set(events.map((e) => e.gateId).filter(Boolean))];
if (traceIds.length > 0) return traceIds;
// Fall back to quality_gates DB when no trace events found
const db = _getAdapter();
if (db) {
const rows = db
.prepare(
"SELECT DISTINCT gate_id FROM quality_gates WHERE gate_id != '' ORDER BY gate_id",
)
.all();
return rows.map((r) => r.gate_id);
}
return [];
} catch {
return [];
}

View file

@ -21,14 +21,17 @@ function formatTable(gates) {
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";
"| Gate | Scope | Pass% | Pass | Fail | Retry | CB | Streak | Last Evaluated |\n" +
"|------|-------|-------|------|------|-------|----|--------|----------------|\n";
const rows = gates
.map((g) => {
const last = g.lastEvaluatedAt
? new Date(g.lastEvaluatedAt).toISOString().replace("T", " ").slice(0, 19)
? 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} |`;
return `| ${g.id} | ${g.scope} | ${passRate(g)} | ${g.pass} | ${g.fail} | ${g.retry} | ${cbIcon(g.circuitBreaker)} ${g.circuitBreaker} | ${g.failureStreak} | ${last} |`;
})
.join("\n");
return `${header}${rows}\n`;
@ -37,7 +40,7 @@ function formatTable(gates) {
function makeGate(overrides = {}) {
return {
id: "verification",
type: "sync",
scope: "task",
total: 10,
pass: 8,
fail: 2,
@ -93,7 +96,7 @@ describe("formatTable", () => {
it("includes_header_row", () => {
const result = formatTable([makeGate()]);
assert.ok(result.includes("| Gate | Type | Pass%"));
assert.ok(result.includes("| Gate | Scope | Pass%"));
});
it("includes_separator_row", () => {
@ -106,6 +109,16 @@ describe("formatTable", () => {
assert.ok(result.includes("cost-guard"));
});
it("includes_scope_in_row", () => {
const result = formatTable([makeGate({ id: "Q5", scope: "task" })]);
assert.ok(result.includes("task"));
});
it("includes_last_evaluated_header", () => {
const result = formatTable([makeGate()]);
assert.ok(result.includes("Last Evaluated"));
});
it("shows_never_when_lastEvaluatedAt_is_null", () => {
const result = formatTable([makeGate({ lastEvaluatedAt: null })]);
assert.ok(result.includes("never"));
@ -130,7 +143,7 @@ describe("formatTable", () => {
it("formats_multiple_gates", () => {
const gates = [
makeGate({ id: "verification" }),
makeGate({ id: "cost-guard", type: "budget" }),
makeGate({ id: "cost-guard", scope: "slice" }),
];
const result = formatTable(gates);
assert.ok(result.includes("verification"));
@ -143,7 +156,9 @@ describe("formatTable", () => {
});
it("formats_timestamp_as_utc_date_string", () => {
const result = formatTable([makeGate({ lastEvaluatedAt: "2026-05-11T12:00:00.000Z" })]);
const result = formatTable([
makeGate({ lastEvaluatedAt: "2026-05-11T12:00:00.000Z" }),
]);
assert.ok(result.includes("2026-05-11 12:00:00"));
});
});

View file

@ -1,5 +1,7 @@
import { getErrorMessage } from "../error-utils.js";
import { getRelevantMemoriesRanked } from "../memory-store.js";
import {
getDistinctGateIds,
getGateCircuitBreaker,
getGateRunStats,
isDbAvailable,
@ -9,7 +11,6 @@ import { logWarning } from "../workflow-logger.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
import { validateGate } from "./contracts.js";
import { appendTraceEvent } from "./trace-writer.js";
import { getErrorMessage } from "../error-utils.js";
const RETRY_MATRIX = {
none: 0,