From 4e5fc12e817d6d56977cc36a2832f7dc31d8146b Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Mon, 11 May 2026 18:47:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(sf):=20fix=20gate=20health=20=E2=80=94=20i?= =?UTF-8?q?mport,=20DB=20fallback,=20and=20enrich=20status=20uok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- src/headless-uok-status.ts | 46 +++++++-- .../extensions/sf/sf-db/sf-db-gates.js | 99 +++++++++++++++---- .../sf/tests/headless-uok-status.test.mjs | 31 ++++-- .../extensions/sf/uok/gate-runner.js | 3 +- 4 files changed, 141 insertions(+), 38 deletions(-) diff --git a/src/headless-uok-status.ts b/src/headless-uok-status.ts index 3467a656d..b84be5ecd 100644 --- a/src/headless-uok-status.ts +++ b/src/headless-uok-status.ts @@ -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)); diff --git a/src/resources/extensions/sf/sf-db/sf-db-gates.js b/src/resources/extensions/sf/sf-db/sf-db-gates.js index d2ca3302c..f6a055b00 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-gates.js +++ b/src/resources/extensions/sf/sf-db/sf-db-gates.js @@ -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 []; } diff --git a/src/resources/extensions/sf/tests/headless-uok-status.test.mjs b/src/resources/extensions/sf/tests/headless-uok-status.test.mjs index 2a30df2f9..ec1c6c9b6 100644 --- a/src/resources/extensions/sf/tests/headless-uok-status.test.mjs +++ b/src/resources/extensions/sf/tests/headless-uok-status.test.mjs @@ -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")); }); }); diff --git a/src/resources/extensions/sf/uok/gate-runner.js b/src/resources/extensions/sf/uok/gate-runner.js index 08ed74f16..5c5518be0 100644 --- a/src/resources/extensions/sf/uok/gate-runner.js +++ b/src/resources/extensions/sf/uok/gate-runner.js @@ -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,