singularity-forge/src/cli-status.ts
Mikael Hugo d33e30e885 feat(notifications): NOTICE_KIND enum, schema v2 dedup, sf-db cleanup
- notification-store: schema v2 — repeatCount/lastTs merge for non-blocking
  notices; NOTICE_KIND enum (SYSTEM_NOTICE, TOOL_NOTICE, BLOCKING_NOTICE,
  USER_VISIBLE) for renderer classification without message parsing
- sf-db: remove gate_runs and audit_events tables (replaced by uok audit.js
  and trace-writer); schema reduced by ~370 lines
- notify-interceptor: tag auto-mode system notices with NOTICE_KIND.SYSTEM_NOTICE
- auto-prompts, guided-flow, system-context: use NOTICE_KIND on emit calls
- cli-status: expanded headless status surface + test coverage
- headless-types: new status fields
- Makefile/justfile: dev workflow improvements
- record-promoter, requirement-promoter: minor cleanup
- sf-db-migration tests: updated for dropped tables
- uok-gate-runner, uok-metrics, uok-outcome, uok-status tests: updated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 20:13:58 +02:00

417 lines
11 KiB
TypeScript

import { readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
collectRecentLogEvents,
formatMergedLogEvent,
getProjectSessionKey,
} from "./cli-logs.js";
import type { QuerySnapshot } from "./headless-query.js";
interface StatusArgs {
watch: boolean;
recoveryMode?: boolean;
recoveryUnitId?: string;
}
interface StatusDeps {
basePath: string;
sfHome?: string;
stdout?: Pick<typeof process.stdout, "write" | "isTTY">;
stderr?: Pick<typeof process.stderr, "write">;
}
interface CurrentModel {
provider: string;
model: string;
}
function parseStatusArgs(argv: string[]): StatusArgs {
const args = argv.slice(1);
if (args[0] === "recovery") {
return {
watch: false,
recoveryMode: true,
recoveryUnitId: args[1],
};
}
return {
watch: args.includes("--watch"),
};
}
function formatRef(
ref: { id: string; title?: string } | string | null | undefined,
): string {
if (!ref) return "n/a";
if (typeof ref === "string") return ref;
return ref.title ? `${ref.id} ${ref.title}` : ref.id;
}
function formatDispatch(snapshot: QuerySnapshot): string {
const next = snapshot.next;
if (next.action === "dispatch") {
return `${next.unitType ?? "unit"} ${next.unitId ?? "n/a"}`;
}
if (next.action === "stop")
return `stop: ${next.reason ?? snapshot.state.nextAction}`;
return `skip: ${next.reason ?? snapshot.state.nextAction}`;
}
function formatCost(snapshot: QuerySnapshot): string {
const total = snapshot.cost.total;
const workers = snapshot.cost.workers.length;
return `$${total.toFixed(4)}${workers > 0 ? ` (${workers} worker${workers === 1 ? "" : "s"})` : ""}`;
}
function readSolverStatus(basePath: string): string | null {
let state: Record<string, any>;
try {
state = JSON.parse(
readFileSync(
join(basePath, ".sf", "runtime", "autonomous-solver", "active.json"),
"utf-8",
),
);
} catch {
return null;
}
const checkpoint = state.latestCheckpoint ?? {};
const parts = [
`${state.unitType ?? "unit"} ${state.unitId ?? "n/a"}`,
`iter ${state.iteration ?? "?"}/${state.maxIterations ?? "?"}`,
`outcome ${checkpoint.outcome ?? "none"}`,
];
const remaining = Array.isArray(checkpoint.remainingItems)
? checkpoint.remainingItems.length
: null;
if (remaining !== null) parts.push(`${remaining} remaining`);
if (checkpoint.blockerReason)
parts.push(`blocker: ${checkpoint.blockerReason}`);
if (checkpoint.decisionQuestion)
parts.push(`decision: ${checkpoint.decisionQuestion}`);
if (checkpoint.summary) parts.push(String(checkpoint.summary));
return parts.join(" · ");
}
function latestJsonlFile(dir: string): string | null {
try {
const entries = readdirSync(dir)
.filter((file) => file.endsWith(".jsonl"))
.map((file) => {
const path = join(dir, file);
try {
return { path, mtimeMs: statSync(path).mtimeMs };
} catch {
return null;
}
})
.filter(
(entry): entry is { path: string; mtimeMs: number } => entry !== null,
);
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
return entries[0]?.path ?? null;
} catch {
return null;
}
}
function extractCurrentModelFromSession(filePath: string): CurrentModel | null {
let current: CurrentModel | null = null;
let raw: string;
try {
raw = readFileSync(filePath, "utf-8");
} catch {
return null;
}
for (const line of raw.split(/\n/)) {
if (!line.trim()) continue;
let entry: Record<string, unknown>;
try {
entry = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
if (entry.type === "model_change") {
const provider = String(entry.provider ?? "");
const model = String(entry.modelId ?? "");
if (provider || model) current = { provider, model };
continue;
}
if (entry.type === "message") {
const message = entry.message as Record<string, unknown> | undefined;
if (message?.role === "assistant") {
const provider = String(message.provider ?? "");
const model = String(message.model ?? "");
if (provider || model) current = { provider, model };
}
}
}
return current;
}
function getCurrentModel(
basePath: string,
sfHome: string,
): CurrentModel | null {
const key = getProjectSessionKey(basePath);
const sessionFile =
latestJsonlFile(join(sfHome, "agent", "sessions", key)) ??
latestJsonlFile(join(sfHome, "sessions", key));
return sessionFile ? extractCurrentModelFromSession(sessionFile) : null;
}
function formatModel(model: CurrentModel | null): string {
if (!model) return "n/a";
if (model.provider && model.model) return `${model.provider}/${model.model}`;
return model.model || model.provider || "n/a";
}
export function renderLiveStatus(
snapshot: QuerySnapshot,
opts: {
basePath?: string;
model: CurrentModel | null;
recentEvents: string[];
},
): string {
const lines: string[] = [];
lines.push("SF Status");
lines.push("---------");
lines.push(
`Milestone: ${formatRef(snapshot.state.activeMilestone ?? snapshot.state.lastCompletedMilestone)}`,
);
lines.push(`Slice: ${formatRef(snapshot.state.activeSlice)}`);
lines.push(`Task: ${formatRef(snapshot.state.activeTask)}`);
lines.push(`Phase: ${snapshot.state.phase}`);
lines.push(`Dispatch: ${formatDispatch(snapshot)}`);
lines.push(`Cost: ${formatCost(snapshot)}`);
lines.push(`Model: ${formatModel(opts.model)}`);
const solverStatus = opts.basePath ? readSolverStatus(opts.basePath) : null;
if (solverStatus) lines.push(`Solver: ${solverStatus}`);
lines.push("");
lines.push("Last Events:");
if (opts.recentEvents.length === 0) {
lines.push(" n/a");
} else {
for (const event of opts.recentEvents) {
lines.push(` ${event.trimEnd()}`);
}
}
return lines.join("\n") + "\n";
}
async function buildStatusText(
basePath: string,
sfHome: string,
): Promise<string> {
const { buildQuerySnapshot } = await import("./headless-query.js");
const snapshot = await buildQuerySnapshot(basePath);
const recentEvents = collectRecentLogEvents({
basePath,
sfHome,
limit: 50,
})
.filter((event) => event.source === "notif" || event.source === "activity")
.slice(-10)
.map(formatMergedLogEvent);
return renderLiveStatus(snapshot, {
basePath,
model: getCurrentModel(basePath, sfHome),
recentEvents,
});
}
type RuntimeSummaryRow = {
unitType?: unknown;
unitId?: unknown;
updatedAt?: unknown;
};
/**
* Resolve which on-disk runtime record recovery output should describe.
*
* Purpose: `.sf/runtime/units/` names files `${unitType}-${unitId}.json`; using a
* hard-coded unit type misses non-execute-task rows when auto-picking the latest
* record or when querying by unit id alone.
*
* Consumer: sf status recovery.
*/
export function resolveRecoveryPick(
basePath: string,
records: RuntimeSummaryRow[],
explicitUnitId: string | undefined,
readRecord: (
root: string,
unitType: string,
unitId: string,
) => unknown | null,
): { unitType: string; unitId: string } | null {
const valid = records.filter(
(r): r is { unitType: string; unitId: string; updatedAt?: number } =>
typeof r.unitType === "string" &&
r.unitType.length > 0 &&
typeof r.unitId === "string" &&
r.unitId.length > 0,
);
if (explicitUnitId) {
const matches = valid
.filter((r) => r.unitId === explicitUnitId)
.sort((a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0));
if (matches[0]) {
return {
unitType: matches[0].unitType,
unitId: matches[0].unitId,
};
}
if (readRecord(basePath, "execute-task", explicitUnitId)) {
return { unitType: "execute-task", unitId: explicitUnitId };
}
return null;
}
if (valid.length === 0) return null;
const sorted = [...valid].sort(
(a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0),
);
return { unitType: sorted[0].unitType, unitId: sorted[0].unitId };
}
async function renderRecoveryDiagnostics(
basePath: string,
unitId: string | undefined,
stdout: Pick<typeof process.stdout, "write">,
stderr: Pick<typeof process.stderr, "write">,
): Promise<number> {
try {
const {
getRecoveryDiagnostics,
listUnitRuntimeRecords,
readUnitRuntimeRecord,
} = await import("./resources/extensions/sf/uok/unit-runtime.js");
const rows = listUnitRuntimeRecords(basePath);
const picked = resolveRecoveryPick(
basePath,
rows,
unitId,
readUnitRuntimeRecord,
);
if (!picked) {
if (rows.length === 0) {
stderr.write("sf status recovery: no runtime records found\n");
} else {
stderr.write(
unitId
? `sf status recovery: no runtime record for ${unitId}\n`
: "sf status recovery: no usable runtime records found\n",
);
}
return 1;
}
const diagnostics = getRecoveryDiagnostics(
basePath,
picked.unitType,
picked.unitId,
);
if (!diagnostics) {
stderr.write(
`sf status recovery: no runtime record for ${picked.unitType} ${picked.unitId}\n`,
);
return 1;
}
const lines: string[] = [];
lines.push("Recovery Diagnostics");
lines.push("--------------------");
lines.push(`Unit: ${diagnostics.unitType} ${diagnostics.unitId}`);
lines.push(`Status: ${diagnostics.status}`);
lines.push(
`Retries: ${diagnostics.retryCount}/${diagnostics.maxRetries}`,
);
lines.push(
`Progress: ${diagnostics.progressCount} (${diagnostics.lastProgressKind})`,
);
lines.push(`Recovery attempts: ${diagnostics.recoveryAttempts}`);
if (diagnostics.lastRecoveryReason) {
lines.push(`Last recovery reason: ${diagnostics.lastRecoveryReason}`);
}
if (diagnostics.lineageSummary) {
lines.push(
`Lineage: ${diagnostics.lineageSummary.status} · ${diagnostics.lineageSummary.workerCount} worker(s) · ${diagnostics.lineageSummary.eventCount} event(s)`,
);
}
lines.push(
`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toISOString() : "n/a"}`,
);
lines.push(
`Updated: ${diagnostics.updatedAt ? new Date(diagnostics.updatedAt).toISOString() : "n/a"}`,
);
stdout.write(lines.join("\n") + "\n");
return 0;
} catch (err) {
stderr.write(
`sf status recovery: ${err instanceof Error ? err.message : String(err)}\n`,
);
return 1;
}
}
export async function runStatusCli(
argv: string[],
deps: StatusDeps,
): Promise<number> {
const stdout = deps.stdout ?? process.stdout;
const stderr = deps.stderr ?? process.stderr;
const sfHome = deps.sfHome ?? process.env.SF_HOME ?? join(homedir(), ".sf");
const args = parseStatusArgs(argv);
if (args.recoveryMode) {
return renderRecoveryDiagnostics(
deps.basePath,
args.recoveryUnitId,
stdout,
stderr,
);
}
const renderOnce = async () => {
try {
const text = await buildStatusText(deps.basePath, sfHome);
if (args.watch && stdout.isTTY) stdout.write("\x1b[2J\x1b[H");
stdout.write(text);
} catch (err) {
stderr.write(
`sf status: ${err instanceof Error ? err.message : String(err)}\n`,
);
throw err;
}
};
if (!args.watch) {
await renderOnce();
return 0;
}
await renderOnce();
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
renderOnce().catch(() => {
clearInterval(timer);
resolve();
});
}, 2000);
const stop = () => {
clearInterval(timer);
process.off("SIGINT", stop);
process.off("SIGTERM", stop);
resolve();
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
});
return 0;
}