fix: stabilize uok parity and startup widgets
This commit is contained in:
parent
3960e42b26
commit
fec9292104
8 changed files with 317 additions and 21 deletions
|
|
@ -21,6 +21,8 @@ import type {
|
|||
EditorTheme,
|
||||
KeyId,
|
||||
MarkdownTheme,
|
||||
OverlayHandle,
|
||||
OverlayOptions,
|
||||
SlashCommand,
|
||||
} from "@singularity-forge/pi-tui";
|
||||
import {
|
||||
|
|
@ -1837,6 +1839,64 @@ export class InteractiveMode {
|
|||
/**
|
||||
* Hide the extension selector.
|
||||
*/
|
||||
showExtensionCustom<T>(
|
||||
factory: (
|
||||
tui: TUI,
|
||||
theme: Theme,
|
||||
keybindings: KeybindingsManager,
|
||||
done: (result: T) => void,
|
||||
) =>
|
||||
| (Component & { dispose?(): void })
|
||||
| Promise<Component & { dispose?(): void }>,
|
||||
options?: {
|
||||
overlay?: boolean;
|
||||
overlayOptions?: OverlayOptions | (() => OverlayOptions);
|
||||
onHandle?: (handle: OverlayHandle) => void;
|
||||
},
|
||||
): Promise<T> {
|
||||
if (options?.overlay === false) {
|
||||
// Non-overlay custom components are not supported (yet)
|
||||
return Promise.reject(
|
||||
new Error("Non-overlay custom components are not supported"),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const done = (result: T) => {
|
||||
// Hide the overlay before resolving the promise
|
||||
this.ui.hideOverlay();
|
||||
this.ui.setFocus(this.editor);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const component = await factory(
|
||||
this.ui,
|
||||
theme,
|
||||
this.keybindings,
|
||||
done,
|
||||
);
|
||||
const overlayOptions =
|
||||
typeof options?.overlayOptions === "function"
|
||||
? options.overlayOptions()
|
||||
: options?.overlayOptions;
|
||||
const handle = this.ui.showOverlay(component, overlayOptions);
|
||||
options?.onHandle?.(handle);
|
||||
} catch (error) {
|
||||
this.ui.hideOverlay();
|
||||
this.ui.setFocus(this.editor);
|
||||
this.showError(
|
||||
`Failed to create custom UI component: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
resolve(undefined as T); // Resolve with undefined on error
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
private hideExtensionSelector(): void {
|
||||
this.extensionSelector?.dispose();
|
||||
this.editorContainer.clear();
|
||||
|
|
|
|||
|
|
@ -344,6 +344,8 @@ function registerSigtermHandler(currentBasePath) {
|
|||
// mismatch that occurs when auto-mode terminates via signal.
|
||||
writeParityHeartbeat(currentBasePath, {
|
||||
ts: new Date().toISOString(),
|
||||
...(s.currentUokRunId ? { runId: s.currentUokRunId } : {}),
|
||||
sessionId: s.cmdCtx?.sessionManager?.getSessionId?.(),
|
||||
path: pathLabel,
|
||||
flags: { ...flags },
|
||||
phase: "exit",
|
||||
|
|
|
|||
|
|
@ -92,6 +92,14 @@ function loadHealthWidgetData(basePath) {
|
|||
}
|
||||
// ── Widget init ────────────────────────────────────────────────────────────────
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
function safeSetWidget(ctx, key, content, options) {
|
||||
try {
|
||||
ctx.ui?.setWidget?.(key, content, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Initialize the always-on sf-health widget (belowEditor).
|
||||
* Call once from the extension entry point after context is available.
|
||||
|
|
@ -101,11 +109,16 @@ export function initHealthWidget(ctx) {
|
|||
const basePath = projectRoot();
|
||||
// String-array fallback — used in RPC mode (factory is a no-op there)
|
||||
const initialData = loadHealthWidgetData(basePath);
|
||||
ctx.ui.setWidget("sf-health", buildHealthLines(initialData), {
|
||||
placement: "belowEditor",
|
||||
});
|
||||
if (
|
||||
!safeSetWidget(ctx, "sf-health", buildHealthLines(initialData), {
|
||||
placement: "belowEditor",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Factory-based widget for TUI mode — replaces the string-array above
|
||||
ctx.ui.setWidget(
|
||||
safeSetWidget(
|
||||
ctx,
|
||||
"sf-health",
|
||||
(_tui, _theme) => {
|
||||
let data = initialData;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ export function buildNotificationWidgetLines() {
|
|||
}
|
||||
// ─── Widget init ────────────────────────────────────────────────────────
|
||||
const REFRESH_INTERVAL_MS = 30_000;
|
||||
function safeSetWidget(ctx, key, content, options) {
|
||||
try {
|
||||
ctx.ui?.setWidget?.(key, content, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Initialize the always-on notification widget (belowEditor).
|
||||
* Call once from session_start after the notification store is initialized.
|
||||
|
|
@ -28,11 +36,16 @@ const REFRESH_INTERVAL_MS = 30_000;
|
|||
export function initNotificationWidget(ctx) {
|
||||
if (!ctx.hasUI) return;
|
||||
// String-array fallback for RPC mode
|
||||
ctx.ui.setWidget("sf-notifications", buildNotificationWidgetLines(), {
|
||||
placement: "belowEditor",
|
||||
});
|
||||
if (
|
||||
!safeSetWidget(ctx, "sf-notifications", buildNotificationWidgetLines(), {
|
||||
placement: "belowEditor",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Factory-based widget for TUI mode
|
||||
ctx.ui.setWidget(
|
||||
safeSetWidget(
|
||||
ctx,
|
||||
"sf-notifications",
|
||||
(_tui, _theme) => {
|
||||
let cachedLines;
|
||||
|
|
|
|||
98
src/resources/extensions/sf/tests/uok-parity-report.test.mjs
Normal file
98
src/resources/extensions/sf/tests/uok-parity-report.test.mjs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
|
||||
import {
|
||||
buildParityReport,
|
||||
hasCurrentParityWarning,
|
||||
UNMATCHED_RUN_STALE_MS,
|
||||
} from "../uok/parity-report.js";
|
||||
|
||||
const NOW = Date.parse("2026-05-06T00:00:00.000Z");
|
||||
|
||||
test("buildParityReport_legacy_anonymous_missing_exit_is_historical_not_current", () => {
|
||||
const report = buildParityReport(
|
||||
[
|
||||
{ ts: "2026-05-05T17:21:52.209Z", path: "uok-kernel", phase: "enter" },
|
||||
{
|
||||
ts: "2026-05-05T17:25:52.439Z",
|
||||
path: "uok-kernel",
|
||||
phase: "exit",
|
||||
status: "ok",
|
||||
},
|
||||
{ ts: "2026-05-05T17:25:18.803Z", path: "uok-kernel", phase: "enter" },
|
||||
],
|
||||
"/tmp/uok-parity.jsonl",
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(report.legacyMissingExitEvents, 1);
|
||||
assert.equal(report.missingExitEvents, 0);
|
||||
assert.deepEqual(report.criticalMismatches, []);
|
||||
assert.equal(hasCurrentParityWarning(report), false);
|
||||
});
|
||||
|
||||
test("buildParityReport_fresh_run_missing_exit_is_current_warning", () => {
|
||||
const report = buildParityReport(
|
||||
[
|
||||
{
|
||||
ts: new Date(NOW - 5_000).toISOString(),
|
||||
runId: "uok-run-1",
|
||||
path: "uok-kernel",
|
||||
phase: "enter",
|
||||
},
|
||||
],
|
||||
"/tmp/uok-parity.jsonl",
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(report.missingExitEvents, 1);
|
||||
assert.equal(report.freshUnmatchedRuns[0].runId, "uok-run-1");
|
||||
assert.match(report.criticalMismatches[0], /uok-run-1/);
|
||||
assert.equal(hasCurrentParityWarning(report), true);
|
||||
});
|
||||
|
||||
test("buildParityReport_stale_run_missing_exit_is_historical_not_current", () => {
|
||||
const report = buildParityReport(
|
||||
[
|
||||
{
|
||||
ts: new Date(NOW - UNMATCHED_RUN_STALE_MS - 1_000).toISOString(),
|
||||
runId: "uok-old-run",
|
||||
path: "uok-kernel",
|
||||
phase: "enter",
|
||||
},
|
||||
],
|
||||
"/tmp/uok-parity.jsonl",
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(report.missingExitEvents, 0);
|
||||
assert.equal(report.historicalUnmatchedRuns[0].runId, "uok-old-run");
|
||||
assert.deepEqual(report.criticalMismatches, []);
|
||||
assert.equal(hasCurrentParityWarning(report), false);
|
||||
});
|
||||
|
||||
test("buildParityReport_run_exit_balances_enter", () => {
|
||||
const report = buildParityReport(
|
||||
[
|
||||
{
|
||||
ts: new Date(NOW - 10_000).toISOString(),
|
||||
runId: "uok-run-2",
|
||||
path: "uok-kernel",
|
||||
phase: "enter",
|
||||
},
|
||||
{
|
||||
ts: new Date(NOW - 5_000).toISOString(),
|
||||
runId: "uok-run-2",
|
||||
path: "uok-kernel",
|
||||
phase: "exit",
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
"/tmp/uok-parity.jsonl",
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(report.missingExitEvents, 0);
|
||||
assert.deepEqual(report.unmatchedRuns, []);
|
||||
assert.equal(hasCurrentParityWarning(report), false);
|
||||
});
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
hasCurrentParityWarning,
|
||||
writeParityReport,
|
||||
} from "./uok/parity-report.js";
|
||||
/**
|
||||
* Read the last UOK parity report from <basePath>/.sf/runtime/uok-parity-report.json
|
||||
* and surface any divergences or errors via ctx.ui?.notify?.().
|
||||
|
|
@ -11,14 +15,18 @@ export async function summarizeParityReport(basePath, ctx, pi) {
|
|||
if (!existsSync(reportPath)) return;
|
||||
let report;
|
||||
try {
|
||||
report = JSON.parse(readFileSync(reportPath, "utf-8"));
|
||||
report = writeParityReport(basePath);
|
||||
} catch {
|
||||
// Malformed JSON — silently skip
|
||||
return;
|
||||
try {
|
||||
report = JSON.parse(readFileSync(reportPath, "utf-8"));
|
||||
} catch {
|
||||
// Malformed JSON — silently skip
|
||||
return;
|
||||
}
|
||||
}
|
||||
const mismatches = report.criticalMismatches?.length ?? 0;
|
||||
const errors = report.statuses?.error ?? 0;
|
||||
if (mismatches > 0 || errors > 0) {
|
||||
if (hasCurrentParityWarning(report)) {
|
||||
const msg =
|
||||
`UOK parity report shows ${mismatches} critical mismatch${mismatches === 1 ? "" : "es"}, ` +
|
||||
`${errors} error${errors === 1 ? "" : "s"} since ${report.generatedAt}. ` +
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { debugLog } from "../debug-logger.js";
|
||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
||||
import { setAuditEnvelopeEnabled } from "./audit-toggle.js";
|
||||
|
|
@ -8,7 +9,11 @@ import {
|
|||
resetParityCommitBlock,
|
||||
signalKernelEnter,
|
||||
} from "./parity-diff-capture.js";
|
||||
import { writeParityHeartbeat, writeParityReport } from "./parity-report.js";
|
||||
import {
|
||||
hasCurrentParityWarning,
|
||||
writeParityHeartbeat,
|
||||
writeParityReport,
|
||||
} from "./parity-report.js";
|
||||
|
||||
function refreshParityReport(basePath) {
|
||||
try {
|
||||
|
|
@ -28,8 +33,14 @@ export async function runAutoLoopWithUok(args) {
|
|||
const prefs = deps.loadEffectiveSFPreferences()?.preferences;
|
||||
const flags = resolveUokFlags(prefs);
|
||||
const previousReport = refreshParityReport(s.basePath);
|
||||
const runId = `uok-${randomUUID()}`;
|
||||
s.currentUokRunId = runId;
|
||||
resetParityCommitBlock();
|
||||
if (previousReport && previousReport.missingExitEvents > 0) {
|
||||
if (
|
||||
previousReport &&
|
||||
previousReport.missingExitEvents > 0 &&
|
||||
hasCurrentParityWarning(previousReport)
|
||||
) {
|
||||
checkAndDrainMissingExit(
|
||||
previousReport.enterEvents,
|
||||
previousReport.exitEvents,
|
||||
|
|
@ -39,6 +50,8 @@ export async function runAutoLoopWithUok(args) {
|
|||
signalKernelEnter();
|
||||
writeParityHeartbeat(s.basePath, {
|
||||
ts: new Date().toISOString(),
|
||||
runId,
|
||||
sessionId: ctx.sessionManager?.getSessionId?.(),
|
||||
path: resolveKernelPathLabel(flags),
|
||||
flags: { ...flags },
|
||||
phase: "enter",
|
||||
|
|
@ -84,6 +97,8 @@ export async function runAutoLoopWithUok(args) {
|
|||
} finally {
|
||||
writeParityHeartbeat(s.basePath, {
|
||||
ts: new Date().toISOString(),
|
||||
runId,
|
||||
sessionId: ctx.sessionManager?.getSessionId?.(),
|
||||
path: resolveKernelPathLabel(flags),
|
||||
flags: { ...flags },
|
||||
phase: "exit",
|
||||
|
|
@ -91,5 +106,6 @@ export async function runAutoLoopWithUok(args) {
|
|||
...(error ? { error } : {}),
|
||||
});
|
||||
refreshParityReport(s.basePath);
|
||||
if (s.currentUokRunId === runId) s.currentUokRunId = undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
import { join } from "node:path";
|
||||
import { sfRoot } from "../paths.js";
|
||||
|
||||
export const UNMATCHED_RUN_STALE_MS = 30 * 60 * 1000;
|
||||
|
||||
function parityLogPath(basePath) {
|
||||
return join(sfRoot(basePath), "runtime", "uok-parity.jsonl");
|
||||
}
|
||||
|
|
@ -27,6 +29,14 @@ function isParityDiffEvent(value) {
|
|||
typeof value.plane === "string"
|
||||
);
|
||||
}
|
||||
function timestampMs(value) {
|
||||
const ms = Date.parse(String(value ?? ""));
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
}
|
||||
function isFreshTimestamp(value, nowMs, staleMs) {
|
||||
const ms = timestampMs(value);
|
||||
return ms !== undefined && nowMs - ms <= staleMs;
|
||||
}
|
||||
export function parseParityEvents(raw) {
|
||||
return raw
|
||||
.split("\n")
|
||||
|
|
@ -44,12 +54,20 @@ export function parseParityEvents(raw) {
|
|||
}
|
||||
});
|
||||
}
|
||||
export function buildParityReport(events, sourcePath) {
|
||||
export function buildParityReport(
|
||||
events,
|
||||
sourcePath,
|
||||
nowMs = Date.now(),
|
||||
staleMs = UNMATCHED_RUN_STALE_MS,
|
||||
) {
|
||||
const paths = {};
|
||||
const statuses = {};
|
||||
const criticalMismatches = [];
|
||||
const runs = new Map();
|
||||
let enterEvents = 0;
|
||||
let exitEvents = 0;
|
||||
let legacyEnterEvents = 0;
|
||||
let legacyExitEvents = 0;
|
||||
let totalDiffs = 0;
|
||||
const divergencesByPlane = {
|
||||
plan: 0,
|
||||
|
|
@ -78,20 +96,77 @@ export function buildParityReport(events, sourcePath) {
|
|||
const heartbeat = event;
|
||||
increment(paths, heartbeat.path);
|
||||
increment(statuses, heartbeat.status);
|
||||
if (heartbeat.phase === "enter") enterEvents += 1;
|
||||
if (heartbeat.phase === "exit") exitEvents += 1;
|
||||
const runId =
|
||||
typeof heartbeat.runId === "string" && heartbeat.runId.trim().length > 0
|
||||
? heartbeat.runId
|
||||
: undefined;
|
||||
if (heartbeat.phase === "enter") {
|
||||
enterEvents += 1;
|
||||
if (runId) {
|
||||
const current = runs.get(runId) ?? {
|
||||
runId,
|
||||
path: heartbeat.path,
|
||||
enterEvents: 0,
|
||||
exitEvents: 0,
|
||||
enteredAt: heartbeat.ts,
|
||||
exitedAt: undefined,
|
||||
};
|
||||
current.enterEvents += 1;
|
||||
current.path = current.path ?? heartbeat.path;
|
||||
current.enteredAt = current.enteredAt ?? heartbeat.ts;
|
||||
runs.set(runId, current);
|
||||
} else {
|
||||
legacyEnterEvents += 1;
|
||||
}
|
||||
}
|
||||
if (heartbeat.phase === "exit") {
|
||||
exitEvents += 1;
|
||||
if (runId) {
|
||||
const current = runs.get(runId) ?? {
|
||||
runId,
|
||||
path: heartbeat.path,
|
||||
enterEvents: 0,
|
||||
exitEvents: 0,
|
||||
enteredAt: undefined,
|
||||
exitedAt: heartbeat.ts,
|
||||
};
|
||||
current.exitEvents += 1;
|
||||
current.path = current.path ?? heartbeat.path;
|
||||
current.exitedAt = heartbeat.ts;
|
||||
runs.set(runId, current);
|
||||
} else {
|
||||
legacyExitEvents += 1;
|
||||
}
|
||||
}
|
||||
if (heartbeat.status === "error") {
|
||||
criticalMismatches.push(heartbeat.error ?? "parity event reported error");
|
||||
}
|
||||
}
|
||||
const missingExitEvents = Math.max(0, enterEvents - exitEvents);
|
||||
if (missingExitEvents > 0) {
|
||||
const unmatchedRuns = Array.from(runs.values()).filter(
|
||||
(run) => run.enterEvents > run.exitEvents,
|
||||
);
|
||||
const freshUnmatchedRuns = unmatchedRuns.filter((run) =>
|
||||
isFreshTimestamp(run.enteredAt, nowMs, staleMs),
|
||||
);
|
||||
const historicalUnmatchedRuns = unmatchedRuns.filter(
|
||||
(run) => !isFreshTimestamp(run.enteredAt, nowMs, staleMs),
|
||||
);
|
||||
const legacyMissingExitEvents = Math.max(
|
||||
0,
|
||||
legacyEnterEvents - legacyExitEvents,
|
||||
);
|
||||
const missingExitEvents = freshUnmatchedRuns.length;
|
||||
if (freshUnmatchedRuns.length > 0) {
|
||||
const exampleIds = freshUnmatchedRuns
|
||||
.slice(0, 3)
|
||||
.map((run) => run.runId)
|
||||
.join(", ");
|
||||
criticalMismatches.push(
|
||||
`uok enter/exit mismatch: ${enterEvents} enters / ${exitEvents} exits`,
|
||||
`uok run enter/exit mismatch: ${freshUnmatchedRuns.length} run(s) missing exit${exampleIds ? ` (${exampleIds})` : ""}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt: new Date(nowMs).toISOString(),
|
||||
sourcePath,
|
||||
totalEvents: events.length,
|
||||
paths,
|
||||
|
|
@ -100,10 +175,21 @@ export function buildParityReport(events, sourcePath) {
|
|||
enterEvents,
|
||||
exitEvents,
|
||||
missingExitEvents,
|
||||
legacyMissingExitEvents,
|
||||
unmatchedRuns,
|
||||
freshUnmatchedRuns,
|
||||
historicalUnmatchedRuns,
|
||||
totalDiffs,
|
||||
divergencesByPlane,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasCurrentParityWarning(report) {
|
||||
const criticalMismatches = report?.criticalMismatches ?? [];
|
||||
const errors = report?.statuses?.error ?? 0;
|
||||
return criticalMismatches.length > 0 || errors > 0;
|
||||
}
|
||||
|
||||
export function writeParityReport(basePath) {
|
||||
const sourcePath = parityLogPath(basePath);
|
||||
const raw = existsSync(sourcePath) ? readFileSync(sourcePath, "utf-8") : "";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue