singularity-forge/src/headless-query.ts
2026-04-30 07:41:24 +02:00

182 lines
5.2 KiB
TypeScript

/**
* Headless Query — `sf headless query`
*
* Single read-only command that returns the full project snapshot as JSON
* to stdout, without spawning an LLM session. Instant (~50ms).
*
* Output: { schemaVersion, state, next, cost }
* schemaVersion — output contract version
* state — deriveState() output (phase, milestones, progress, blockers)
* next — dry-run dispatch preview (what auto-mode would do next)
* cost — aggregated parallel worker costs
*
* Note: Extension modules are .ts files loaded via jiti (not compiled to .js).
* We use createJiti() here because this module is imported directly from cli.ts,
* bypassing the extension loader's jiti setup (#1137).
*/
import { homedir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "@mariozechner/jiti";
import { resolveBundledSourceResource } from "./bundled-resource-path.js";
import type { SFState } from "./resources/extensions/sf/types.js";
const jiti = createJiti(fileURLToPath(import.meta.url), {
interopDefault: true,
debug: false,
});
// Resolve extensions from the synced agent directory so headless-query
// loads the same extension copy as interactive/auto modes (#3471).
// Falls back to bundled source for source-tree dev workflows.
const agentExtensionsDir = join(
process.env.SF_AGENT_DIR || join(homedir(), ".sf", "agent"),
"extensions",
"sf",
);
const { existsSync } = await import("node:fs");
const useAgentDir = existsSync(join(agentExtensionsDir, "state.ts"));
const sfExtensionPath = (...segments: string[]) =>
useAgentDir
? join(agentExtensionsDir, ...segments)
: resolveBundledSourceResource(
import.meta.url,
"extensions",
"sf",
...segments,
);
async function loadExtensionModules() {
const stateModule = (await jiti.import(
sfExtensionPath("state.ts"),
{},
)) as any;
const dispatchModule = (await jiti.import(
sfExtensionPath("auto-dispatch.ts"),
{},
)) as any;
const sessionModule = (await jiti.import(
sfExtensionPath("session-status-io.ts"),
{},
)) as any;
const prefsModule = (await jiti.import(
sfExtensionPath("preferences.ts"),
{},
)) as any;
const autoStartModule = (await jiti.import(
sfExtensionPath("auto-start.ts"),
{},
)) as any;
return {
openProjectDbIfPresent: autoStartModule.openProjectDbIfPresent as (
basePath: string,
) => Promise<void>,
deriveState: stateModule.deriveState as (
basePath: string,
) => Promise<SFState>,
resolveDispatch: dispatchModule.resolveDispatch as (
opts: any,
) => Promise<any>,
readAllSessionStatuses: sessionModule.readAllSessionStatuses as (
basePath: string,
) => any[],
loadEffectiveSFPreferences:
prefsModule.loadEffectiveSFPreferences as () => any,
};
}
// ─── Types ──────────────────────────────────────────────────────────────────
export interface QuerySnapshot {
schemaVersion: 1;
state: SFState;
next: {
action: "dispatch" | "stop" | "skip";
unitType?: string;
unitId?: string;
reason?: string;
};
cost: {
workers: Array<{
milestoneId: string;
pid: number;
state: string;
cost: number;
lastHeartbeat: number;
}>;
total: number;
};
}
export interface QueryResult {
exitCode: number;
data?: QuerySnapshot;
}
// ─── Implementation ─────────────────────────────────────────────────────────
export async function buildQuerySnapshot(
basePath: string,
): Promise<QuerySnapshot> {
const {
openProjectDbIfPresent,
deriveState,
resolveDispatch,
readAllSessionStatuses,
loadEffectiveSFPreferences,
} = await loadExtensionModules();
await openProjectDbIfPresent(basePath);
const state = await deriveState(basePath);
// Derive next dispatch action
let next: QuerySnapshot["next"];
if (!state.activeMilestone?.id) {
next = {
action: "stop",
reason:
state.phase === "complete"
? "All milestones complete."
: state.nextAction,
};
} else {
const loaded = loadEffectiveSFPreferences();
const dispatch = await resolveDispatch({
basePath,
mid: state.activeMilestone.id,
midTitle: state.activeMilestone.title,
state,
prefs: loaded?.preferences,
});
next = {
action: dispatch.action,
unitType: dispatch.action === "dispatch" ? dispatch.unitType : undefined,
unitId: dispatch.action === "dispatch" ? dispatch.unitId : undefined,
reason: dispatch.action === "stop" ? dispatch.reason : undefined,
};
}
// Aggregate parallel worker costs
const statuses = readAllSessionStatuses(basePath);
const workers = statuses.map((s) => ({
milestoneId: s.milestoneId,
pid: s.pid,
state: s.state,
cost: s.cost,
lastHeartbeat: s.lastHeartbeat,
}));
const snapshot: QuerySnapshot = {
schemaVersion: 1,
state,
next,
cost: { workers, total: workers.reduce((sum, w) => sum + w.cost, 0) },
};
return snapshot;
}
export async function handleQuery(basePath: string): Promise<QueryResult> {
const snapshot = await buildQuerySnapshot(basePath);
process.stdout.write(JSON.stringify(snapshot) + "\n");
return { exitCode: 0, data: snapshot };
}