/** * Workflow run state persistence for pause/resume support. */ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, 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 { FoundationHostCapability } from "./foundation-host.js"; import type { HarnessSelection } from "./harness-selector.js"; import type { JournalEntry, WorkflowRunOptions } from "./workflow.js"; export type RunStatus = "pending" | "running" | "paused" | "completed" | "failed" | "aborted"; /** * Operator/model control actions on a run (issue #136 §1). Each carries an * authorization reason and an originator, persisted so the navigator and a * cold start can show who did what and why. The engine `RunStatus` is the * source of truth for lifecycle; this is the control-surface audit trail. */ export type ControlAction = "pause" | "stop" | "resume" | "status" | "steer"; /** Who authorized a control action. `operator` = user/TUI; `system` = the * manager's usage-limit auto-resume or stale-run reconciliation. */ export type ControlAuthorizer = "operator" | "system"; /** A single control-action event, persisted into a bounded audit log. */ export interface ControlActionEvent { action: ControlAction; at: string; authorizer: ControlAuthorizer; /** Why the action was taken, e.g. "usage_limit", "operator_request", "cap_exceeded". */ reason?: string; /** Provider reset hint captured for usage-limit pauses (verbatim). */ resetHint?: string; } 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; /** * Bounded usage-limit resume accounting (issue #136 §5). Increments on each * resume of a usage_limit-paused run; when it exceeds `maxResumeAttempts` the * run settles into a terminal `failed`/`needs-human` state with an actionable * reason instead of silently looping. Absent on old runs (treated as 0). */ resumeAttempts?: number; /** The cap this run was started/resumed under. Absent -> runtime default. */ maxResumeAttempts?: number; /** ISO timestamp of the last resume, so an operator can see how long a run has * been cycling on a usage limit. */ lastResumeAt?: string; /** * Bounded control-action audit log (issue #136 §1). Older persisted runs omit * this; loaders must not reject runs that lack it. Kept small (last N events) * so it never dominates the persisted record. */ controlActions?: ControlActionEvent[]; /** * Terminal exhaustion marker set when usage-limit resume attempts exceeded the * cap. The run is `failed` with `pauseReason: "usage_exhausted"` and a * `needs-human` semantic status, so the navigator shows an actionable terminal * state instead of a silent infinite-retry loop. */ usageExhausted?: boolean; /** Snapshot of the routing/context/tool-policy captured at run start, so resume * keeps the original snapshot unless an explicit change invalidates the right * suffix (issue #136 §2). The harness selection already carries routing; this * is the steering override applied after start. */ steeringSnapshot?: { contextMode?: string; harnessType?: string; harnessConfig?: 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; workspaceId?: 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; /** Package-authorized host callbacks captured when the saved command started. */ hostCapabilities?: FoundationHostCapability[]; /** Stable package execution-policy identity used by resume hashes. */ hostCapabilityPolicyKey?: string; /** Initial isolated-worktree HEAD used to validate committed and uncommitted paths. */ hostEditScopeBaseRef?: 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; /** * Delete completed runs exceeding the retention policy. Only runs whose status * is exactly "completed" are ever considered; active/paused/failed/aborted * runs are never removed, and artifact files still referenced by a surviving * run are never deleted. Returns the run IDs that were removed. */ pruneCompletedRuns(config?: RunStateRetentionConfig): string[]; } export interface RunLease { runId: string; token: string; } /** * On-disk reference to a payload that was spilled out of the run-state JSON to * keep the persisted file within the documented size bound. Only the save path * ever writes these markers; {@link rehydrateState} resolves them back into the * original value on load so every consumer of `load()`/`list()` sees the real * payload. The marker uses a namespaced discriminator (`__spilledArtifact`) and * an exact key set so it cannot be mistaken for a legitimate workflow result. */ export interface SpilledArtifactRef { readonly __spilledArtifact: true; /** Path relative to the run's runsDir, e.g. `/artifacts/result-3.json`. */ readonly path: string; /** Original byte size of the pretty-JSON payload, for auditing/regression. */ readonly bytes: number; /** Payload class: "history" | "result". */ readonly kind: ArtifactKind; } export type ArtifactKind = "history" | "result"; /** * Retention/cleanup policy for persisted run state. Only COMPLETED runs are ever * eligible for automatic cleanup; active/paused/failed/aborted runs and any * artifact still referenced by a surviving run are never removed. */ export interface RunStateRetentionConfig { /** * Per-payload byte bound (pretty-JSON, the on-disk form). A single * agent-history, journal-result, or top-level result payload larger than this * is spilled to an artifact file and replaced inline by a {@link SpilledArtifactRef}. * Identity fields (journal hash/index/label/usage/model/timestamps) are never * spilled, so deterministic resume longest-prefix semantics are unaffected. * Default {@link DEFAULT_RUN_STATE_PAYLOAD_BOUND_BYTES}. */ payloadBoundBytes?: number; /** Remove completed runs whose `updatedAt` is older than this (ms). 0 = no age limit. */ completedMaxAgeMs?: number; /** Keep at most this many most-recent completed runs; older ones are pruned. 0 = no count limit. */ completedMaxCount?: number; } /** Default per-payload spill bound: 32 KiB (pretty-JSON bytes). */ export declare const DEFAULT_RUN_STATE_PAYLOAD_BOUND_BYTES: number; /** * Documented target upper bound for a completed run's persisted JSON once * history is de-duplicated and large payloads spill to artifacts. Spilled * artifact files are lazy-loaded and not counted toward this bound. */ export declare const TARGET_RUN_STATE_JSON_BOUND_BYTES: number; /** Resolve a payload-bound override to a positive integer, falling back to the default. */ export declare function normalizePayloadBound(value: unknown): number; /** * 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; rmSync?: typeof rmSync; }; /** 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; /** Pretty-JSON byte length of a value (the on-disk form used by save()). */ export declare function persistedByteLength(value: unknown): number; interface FsWriteOps { existsSync: typeof existsSync; mkdirSync: typeof mkdirSync; writeFileSync: typeof writeFileSync; renameSync: typeof renameSync; } /** * Relative path (from runsDir) of an artifact for a given run/payload. * Shape: `/artifacts/-.json`. */ export declare function artifactRelPath(runId: string, kind: ArtifactKind, index: number): string; /** * Copy `journal[].history` onto matching `agents[].history` when an agent lacks * history, mirroring {@link hydrateJournalHistory} in workflow-manager but * operating on the loaded state so on-disk consumers (telemetry, UI agent * detail) see history without the duplicate copy on disk. * * Pairing is by label + done status, in journal/agent order, so a resumed run * with replayed (non-live) agents still recovers history for display/telemetry. * Runs whose journal lacks history (pre-change shape) keep whatever agent * history they already have — fully backward compatible. */ export declare function hydrateAgentHistoryFromJournal(state: PersistedRunState): void; /** * Produce a size-bounded copy of `state` for persistence: drop the duplicated * `agents[].history` (the journal is the on-disk source of truth) and spill any * oversized journal-result / journal-history / top-level-result payloads to * artifact files under `//artifacts/`. Returns the compacted * state plus the set of artifact relative paths written. * * Identity fields (journal index/hash/label/usage/model/timestamps, agent * status/tokens/contextWindow, run status/phases/logs) are NEVER spilled, so * deterministic resume longest-prefix semantics and recovery links are intact. */ export declare function compactStateForSave(state: PersistedRunState, bound: number, runsDir: string, fs: FsWriteOps): { compact: PersistedRunState; artifacts: string[]; }; export declare function createRunPersistence(cwd: string, fsOverride?: Partial, retention?: RunStateRetentionConfig): 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; /** Maximum control-action events retained in the persisted audit log. The * log is a bounded ring of the most recent events so it never dominates the * persisted record while still giving an operator a usable history. */ export declare const MAX_CONTROL_ACTION_LOG = 16; /** * Generate a unique run ID. */ export declare function generateRunId(): string; export {};