singularity-forge/src/cli-status.ts

267 lines
7.1 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;
}
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);
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,
});
}
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);
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;
}