/** * Workflow run state persistence for pause/resume support. */ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import type { AgentContextWindowStats } from "./agent.js"; import type { AgentHistoryEntry } from "./agent-history.js"; import type { ConductorRunStatus } from "./conductor-types.js"; import type { WorkflowErrorCode } from "./errors.js"; import type { HarnessSelection } from "./harness-selector.js"; import type { JournalEntry, WorkflowRunOptions } from "./workflow.js"; export type RunStatus = "pending" | "running" | "paused" | "completed" | "failed" | "aborted"; export interface PersistedAgentState { id: number; label: string; phase?: string; prompt: string; status: "queued" | "running" | "done" | "error" | "skipped"; result?: unknown; error?: string; errorCode?: WorkflowErrorCode; recoverable?: boolean; history?: AgentHistoryEntry[]; startedAt?: string; endedAt?: string; /** Tokens used by this agent, when known. */ tokens?: number; /** Context-window occupancy stats for this agent, when known. */ contextWindow?: AgentContextWindowStats; /** The model this agent ran on (provider/id), when known. */ model?: string; } export interface PersistedRunState { runId: string; workflowName: string; script: string; args?: unknown; /** The pi session this run belongs to. Runs persist on disk across sessions but * the navigator shows only the current session's runs (undefined = legacy/global). */ sessionId?: string; status: RunStatus; /** Optional conductor-level semantic status, layered on top of the engine * `status` above. Older persisted runs may omit this; loaders must not reject * runs that lack it. When present it is round-tripped verbatim on save/load. */ semanticStatus?: ConductorRunStatus; /** Why a paused run is paused (e.g. "usage_limit" when a provider quota was hit). */ pauseReason?: string; /** Provider reset hint for a usage-limit pause, e.g. "Resets in ~3h" (verbatim). */ resetHint?: string; phases: string[]; currentPhase?: string; agents: PersistedAgentState[]; logs: string[]; result?: unknown; startedAt: string; updatedAt: string; completedAt?: string; durationMs?: number; /** Absolute path to this persisted run-state JSON, when recorded by WorkflowManager. */ runStatePath?: string; /** Effective run-wide wall-clock timeout (ms) captured at start, so resume * keeps the original explicit/settings value. null disables the timeout; * absent (old runs) means the runtime default still applies. */ workflowTimeoutMs?: number | null; /** Effective run-level hard per-agent context cap captured at start. */ agentMaxContextTokens?: number | null; /** Effective run-level context reserve override captured at start. */ agentContextReserveTokens?: number | null; /** Effective run-level compaction policy captured at start. */ compactionPolicy?: WorkflowRunOptions["compactionPolicy"]; /** Effective run-level loop-guard policy captured at start/resume. */ loopGuard?: WorkflowRunOptions["loopGuard"]; /** Snapshot of the harness selection detected at run start. * * Persisted as a canonical serialized string (via serializeHarnessSelection) * so the on-disk snapshot is deterministic and resume can reuse it instead of * re-running detection. The field also tolerates a plain `HarnessSelection` * object form for backward compatibility with older persisted runs. Either * form is validated back through parseHarnessSelection() on load. * Optional — older persisted runs may omit it, in which case resume falls * back to a fresh selectHarness() call. */ harnessSelection?: HarnessSelection | string; tokenUsage?: { input: number; output: number; total: number; cost?: number; cacheRead?: number; cacheWrite?: number; }; /** Cached agent results and replay metadata, keyed by deterministic call index. */ journal?: JournalEntry[]; /** * Run-level isolation worktree (when the run was launched with * `isolation: { worktree: true }`/`worktreeRequired`). Persisted so a paused * run keeps its worktree across a resume (edits live in the worktree, not the * primary checkout); resume reuses it via `reuseWorktree`. */ worktree?: { cwd: string; branch?: string; repoRoot?: string; }; /** Persisted herdr pane id for a pane-spawn run, so resume can recreate the * pane handle and keep driving the pane's lifecycle/finalization. Absent on * non-pane-spawn and older runs. */ paneId?: string; } export interface RunPersistence { /** Save current run state. */ save(state: PersistedRunState): void; /** Load a persisted run by ID. */ load(runId: string): PersistedRunState | null; /** List all persisted runs. */ list(): PersistedRunState[]; /** Delete a persisted run. */ delete(runId: string): boolean; /** * Acquire an exclusive cross-process lease for a run. Returns null when another * live process owns the run; stale/corrupt lock files are removed and retried. */ acquireRunLease(runId: string): RunLease | null; /** Release a lease previously returned by acquireRunLease(). */ releaseRunLease(lease: RunLease): void; /** Get runs directory path. */ getRunsDir(): string; } export interface RunLease { runId: string; token: string; } /** * Persisted run IDs become filenames and lock names, so keep them deliberately * boring: generated ids already use lowercase base36 + hyphen. Rejecting dots, * slashes, backslashes, controls, and absolute-looking values closes traversal * through every run-state/lease/resume/delete path. */ export declare const RUN_ID_PATTERN: RegExp; export declare function isValidRunId(runId: unknown): runId is string; export declare function assertValidRunId(runId: unknown): asserts runId is string; /** * Filesystem operations used by run persistence. * Exposed for testing – pass overrides to inject mock implementations. */ export type FsLayer = { existsSync: typeof existsSync; mkdirSync: typeof mkdirSync; readdirSync: typeof readdirSync; readFileSync: typeof readFileSync; renameSync: typeof renameSync; unlinkSync: typeof unlinkSync; writeFileSync: typeof writeFileSync; }; /** Shared formula for a run-state JSON file path: runsDir/runId.json. * Used by both RunPersistence (primaryRunPath) and WorkflowManager.runStatePathFor * so the log link can never drift from where the run state is actually * written. */ export declare function runStateJsonPath(runsDir: string, runId: string): string; export declare function createRunPersistence(cwd: string, fsOverride?: Partial): RunPersistence; /** * Read a persisted harness-selection snapshot back into a validated * `HarnessSelection`, so resume can reuse the snapshot instead of re-running * `selectHarness()`. * * Accepts either stored form: * - canonical serialized string (current writers, via serializeHarnessSelection) * - plain `HarnessSelection` object (legacy/compatible writers) * * Returns `undefined` when the field is absent (old run) or malformed, so the * caller falls back to a fresh `selectHarness()` call. This keeps the load path * backward-compatible: a persisted run without the field still loads. */ export declare function loadHarnessSelection(state: PersistedRunState): HarnessSelection | undefined; /** * Serialize a harness selection for persistence into the run-metadata record. * Returns the canonical serialized string form (via serializeHarnessSelection) * that round-trips through `loadHarnessSelection()`, or `undefined` when `sel` * is `undefined` (so the field stays absent on disk for runs without a snapshot). * * runWorkflow should assign the result onto `PersistedRunState.harnessSelection` * for a freshly-detected selection before `save()`. */ export declare function saveHarnessSelection(sel: HarnessSelection | undefined): string | undefined; /** * Generate a unique run ID. */ export declare function generateRunId(): string;