diff --git a/src/resources/extensions/sf/tests/uok-metrics-exposition.test.mjs b/src/resources/extensions/sf/tests/uok-metrics-exposition.test.mjs new file mode 100644 index 000000000..77a5ed2b4 --- /dev/null +++ b/src/resources/extensions/sf/tests/uok-metrics-exposition.test.mjs @@ -0,0 +1,82 @@ +/** + * uok-metrics-exposition.test.mjs — Prometheus metrics cache behaviour. + * + * Purpose: prove cached UOK metric snapshots are explicitly refreshable so + * health surfaces can trade scrape cost for freshness without serving stale + * data after known DB writes. + */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "vitest"; +import { closeDatabase, insertGateRun, openDatabase } from "../sf-db.js"; +import { + invalidateMetricsCache, + readUokMetrics, + writeUokMetrics, +} from "../uok/metrics-exposition.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + invalidateMetricsCache(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-uok-metrics-")); + tmpDirs.push(dir); + openDatabase(":memory:"); + return dir; +} + +function recordGateRun(outcome, evaluatedAt = new Date().toISOString()) { + insertGateRun({ + traceId: `trace-${outcome}`, + turnId: `turn-${outcome}`, + gateId: "cache-gate", + gateType: "verification", + unitType: "execute-task", + unitId: "M001/S01/T01", + milestoneId: "M001", + sliceId: "S01", + taskId: "T01", + outcome, + failureClass: outcome === "pass" ? "" : "assertion", + rationale: outcome, + findings: "", + attempt: 1, + maxAttempts: 1, + retryable: false, + evaluatedAt, + durationMs: outcome === "pass" ? 10 : 20, + }); +} + +test("writeUokMetrics_when_cache_invalidated_refreshes_db_snapshot", () => { + const project = makeProject(); + recordGateRun("pass"); + + writeUokMetrics(project, ["cache-gate"]); + const first = readUokMetrics(project); + assert.match(first, /uok_gate_runs_total\{gate_id="cache-gate"\} 1/); + + recordGateRun("fail"); + writeUokMetrics(project, ["cache-gate"]); + const cached = readUokMetrics(project); + assert.match(cached, /uok_gate_runs_total\{gate_id="cache-gate"\} 1/); + + invalidateMetricsCache(); + writeUokMetrics(project, ["cache-gate"]); + const refreshed = readUokMetrics(project); + assert.match(refreshed, /uok_gate_runs_total\{gate_id="cache-gate"\} 2/); + assert.match( + refreshed, + /uok_gate_runs_failed_total\{gate_id="cache-gate"\} 1/, + ); +}); diff --git a/src/resources/extensions/sf/uok/metrics-exposition.js b/src/resources/extensions/sf/uok/metrics-exposition.js index a9f32b2b9..aeb7ecf96 100644 --- a/src/resources/extensions/sf/uok/metrics-exposition.js +++ b/src/resources/extensions/sf/uok/metrics-exposition.js @@ -99,7 +99,7 @@ function collectGateMetrics(gateIds) { return lines; } -function buildMetricsText(gateIds) { +export function buildMetricsText(gateIds) { const cacheKey = gateIds ? gateIds.join(",") : ""; const now = Date.now(); if (