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:
parent
797db16ae8
commit
4e5fc12e81
4 changed files with 141 additions and 38 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue