// SPEC-6-3 — the per-workflow positional-call-index journal. // Append-only JSONL at /.jsonl. Crash-safe: a partial last line is discarded. // Separate from RunLog (per-agent conversations/) and RunJournal (per-lifecycle runs/). import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; export interface WorkflowStartedEvent { type: "wf:started"; runId: string; script: string; args?: unknown; phases?: { title: string }[]; mode: "auto" | "checkpointed"; ts: number; } export interface AgentCallEvent { type: "agent:call"; callIndex: number; label: string; phase: string; prompt: string; opts: Record; childRunId?: string; ts: number; } export interface AgentResultEvent { type: "agent:result"; callIndex: number; childRunId: string; result: unknown; status: "completed" | "failed"; costTotal?: number; tokenTotal?: number; ts: number; } export interface HelperCallEvent { type: "helper:call"; callIndex: number; name: string; args: unknown; ts: number; } export interface HelperResultEvent { type: "helper:result"; callIndex: number; name: string; result: unknown; ts: number; } export interface CheckpointEvent { type: "checkpoint"; callIndex: number; prompt: string; response: unknown; ts: number; } export interface WorkflowCompletedEvent { type: "wf:completed"; runId: string; result: unknown; costTotal?: number; tokenTotal?: number; ts: number; } export interface WorkflowAbortedEvent { type: "wf:aborted"; runId: string; reason: string; ts: number; } export interface WorkflowProgressJournalEvent { type: "wf:progress"; kind: "started" | "phase" | "child-started" | "child-completed" | "child-failed" | "helper-started" | "helper-completed" | "log" | "checkpoint" | "checkpoint-resolved" | "completed" | "failed" | "aborted"; runId: string; status: string; currentPhase: string; phases: Array<{ title: string; agents: number; cached: number; reRun: number }>; childRunIds: string[]; logs: string[]; tokenTotal: number; costTotal: number; checkpoint?: { prompt: string; opts: Record }; ts: number; } export type WorkflowJournalEvent = | WorkflowStartedEvent | AgentCallEvent | AgentResultEvent | HelperCallEvent | HelperResultEvent | CheckpointEvent | WorkflowCompletedEvent | WorkflowAbortedEvent | WorkflowProgressJournalEvent; const WORKFLOW_TERMINAL = new Set(["wf:completed", "wf:aborted"]); export class WorkflowJournal { constructor(private readonly dir: string) {} private file(runId: string): string { return join(this.dir, `${runId}.jsonl`); } append(runId: string, event: WorkflowJournalEvent): void { try { mkdirSync(this.dir, { recursive: true }); appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8"); } catch { // best-effort: never fail the workflow because the journal couldn't persist. } } replay(runId: string): WorkflowJournalEvent[] { const f = this.file(runId); if (!existsSync(f)) return []; const events: WorkflowJournalEvent[] = []; for (const line of readFileSync(f, "utf8").split("\n")) { if (!line) continue; try { events.push(JSON.parse(line) as WorkflowJournalEvent); } catch { /* partial last line (crash mid-append) — discard */ } } return events; } scanNonTerminal(): string[] { if (!existsSync(this.dir)) return []; const ids: string[] = []; for (const f of readdirSync(this.dir)) { if (!f.endsWith(".jsonl")) continue; const runId = f.slice(0, -".jsonl".length); const events = this.replay(runId); const last = events[events.length - 1]; if (last && !WORKFLOW_TERMINAL.has(last.type)) ids.push(runId); } return ids; } }