/** * Graph Execution Engine v2 — Engine State Persistence * * Version: 2.0 * Date: 2026-07-25 * * The unified on-disk store for {@link EngineState}. Serializes the whole * engine container — including the `Map` fields — into a plain, versioned JSON * file that a later `recover()` can hydrate back into a live engine. * * Scope: * - `save(state)` — write-through, synchronous, atomic (`.tmp` + `renameSync`). * The durability path for **critical** transitions (node lifecycle, graph * phase, frontier, checkpoint records, approval state), invoked from the * advancement critical section's `finally` block. * - `scheduleSave(state)` — debounced (500ms) write path for **non-critical** * churn only: signal-ledger history updates and budget / per-node * tokensConsumed counters. Multiple rapid mutations coalesce into a single * atomic write. * - `flush()` — force-drain a pending debounced write. Runs when the engine * reaches a terminal phase (`complete`), so no debounced write is lost. * - `dispose()` — teardown for a runtime that is being replaced / discarded. * Cancels the debounce timer and DROPS the pending write (no flush): the * disposed runtime's state is stale relative to the successor runtime, so * flushing it would overwrite newer state (review 05-F1/F3, M14/ML1). * - `load(graphId)` — read + validate; returns `null` for a missing file (only * ENOENT), a schema-version mismatch, a file whose nodes fail the R2 * node-level field gate, or an out-of-vocabulary enum (clean start / * migration point), * mirroring `TaskStateStore.load()` (`src/dispatch/persistence/task-store.ts:125`). * Any other read failure is rethrown — an unreadable state file is an * explicit error, never a silent clean start (review 05-F6, L22). * * Two-tier durability policy (Q2 Option A): critical mutations write through * synchronously so a crash never loses node/phase/frontier progress; non-critical * churn (signal history, budget/token counters) is debounced to avoid a sync * write on every high-frequency update. A critical `save` always cancels any * pending debounced write (the sync write already contains the latest state), * so the two tiers stay consistent. * * Design reference: * - `.rolebox/design/engine-state-machine.md` §4 (persistence model, atomic * write pattern, versioned header). * - `.rolebox/design/implementation-roadmap.md` Q2 Option A (write-through for * critical, debounce for non-critical). * - Atomic pattern mirrored from `src/dispatch/persistence/task-store.ts:101-108` * and `persist-helpers.ts` (pattern reference only — those files are not * modified). */ import type { CheckpointRecord, EdgePayload, EngineState, LoopGroupRuntimeState, NodeRuntimeState, SignalLedgerEntry } from "../../types.engine-v2.ts"; /** Schema version of the persisted engine state file. */ export declare const ENGINE_PERSISTENCE_VERSION: 2; /** Debounce window for non-critical state writes (ms). See Q2 Option A. */ export declare const NON_CRITICAL_DEBOUNCE_MS: 500; /** * Mark the engine state as mutated. Every critical mutation site MUST call * this after mutating any persistent field (node lifecycle, phase, frontier, * budget, signal ledger, loop group state, checkpoints, etc.). The * advancement critical section's `finally` block only persists when the flag * is set, avoiding redundant writes on idle sections. * * This function is the official choke-point — callers never set * `state.isDirty` directly. The field is deliberately omitted from the * serialization DTO so a deserialized (recovered) state always starts clean. */ export declare function markDirty(state: EngineState): void; /** * Clear the dirty flag after a successful persist. Called in the advancement * critical section's `finally` block immediately after `persistState?.`. * The state is now durably on disk and the flag is reset so the next idle * section does not re-persist. */ export declare function clearDirty(state: EngineState): void; /** * Whether the engine state has unpersisted mutations. When `false`, the * advancement critical section's `finally` block skips the `persistState?.` * call — the section was idle (no mutations occurred). */ export declare function shouldPersist(state: EngineState): boolean; /** * Mark the engine state as carrying **non-critical** churn (signal-ledger * history updates, budget / per-node tokensConsumed counters). Unlike * {@link markDirty}, this does NOT require a synchronous write-through — the * advancement critical section's `finally` block routes a section whose only * mutations were non-critical through the debounced write path instead. * * The official choke-point for non-critical mutations — callers never set * `state.isNonCriticalDirty` directly. The field is omitted from the * serialization DTO so a deserialized (recovered) state always starts clean. */ export declare function markNonCriticalDirty(state: EngineState): void; /** * Clear the non-critical dirty flag after the mutation has been accounted for * (either coalesced into a synchronous write or handed to the debounced path). * Called in the advancement critical section's `finally` block alongside * {@link clearDirty}. */ export declare function clearNonCriticalDirty(state: EngineState): void; /** * Whether the engine state has unpersisted **non-critical** churn. When * `true` and the critical {@link shouldPersist} flag is `false`, the * advancement critical section's `finally` block schedules a debounced write * instead of a synchronous one. */ export declare function shouldPersistNonCritical(state: EngineState): boolean; /** * Runtime fields whose representation is NOT directly JSON-safe and therefore * needs an explicit projection in the DTO. Today the only one is * `upstreamResults` (`Map` → plain `Record`). */ type NodeRuntimeStateProjectedKeys = "upstreamResults"; /** * Flat, JSON-safe projection of {@link NodeRuntimeState}. * * R2 (trust boundary): the DTO is DERIVED from the runtime type (mapped type) * instead of hand-mirrored. A field added to / removed from * {@link NodeRuntimeState} now flows into the DTO automatically, so it cannot * silently drift out of the persistence contract: `serializeNodeDTO`'s * `satisfies` check and the key-coverage assertions below fail to compile * instead. `upstreamResults` is the only field whose runtime representation * (`Map`) needs flattening; every other field is already JSON-primitive and * passes through unchanged. */ export type NodeRuntimeStateDTO = Omit & { upstreamResults: Record; }; /** * `EngineState` keys that are NOT part of the JSON-safe container file: * runtime-only dirty flags, the two non-serializable event sinks, and the * three `Map` collections that are re-declared below in their flat JSON form. * * `advancingLock` / `pendingCompletions` are deliberately NOT in this list: * they remain in the file (crash diagnostics / legacy read-compat) but are * reset to their initial values on hydration — see `deserializeEngineState`. */ type EngineStateNonSerializedKeys = "nodes" | "loopGroups" | "signalLedger" | "isDirty" | "isNonCriticalDirty" | "phaseEventSink" | "budgetEventSink"; /** * Top-level on-disk schema (versioned). `Map` fields are plain `Record`s. * * R2: like the node DTO, this is DERIVED from {@link EngineState} — the * `Omit` removes only the runtime-only / re-shaped keys above, so a new * persisted field on `EngineState` cannot be forgotten here. */ export type EnginePersistenceFile = { version: typeof ENGINE_PERSISTENCE_VERSION; } & Omit & { nodes: Record; /** * LEGACY READ-COMPAT — the dead `EngineState.edges` map was removed (D3), * so new files never carry this key. It is retained here (optional) so * files authored before the removal — which DO carry a top-level `edges` * object — still pass the required-shape gate and hydrate cleanly. The key * is tolerated and ignored: it is never written and never hydrated back * onto a live state. */ edges?: Record; loopGroups: Record; signalLedger: Record; }; /** * Deep-enough clone of the append-only per-node checkpoint history map. * * Exported (H3) so `hydrate` / `adopt` paths outside this module can reuse the * same defensive-copy semantics instead of reimplementing per-record spread. */ export declare function cloneCheckpointHistory(c: Record | undefined): Record | undefined; /** * Project one live {@link NodeRuntimeState} into its JSON-safe DTO. * * R2: field-by-field instead of a `...rest` spread. The trailing `satisfies` * makes the DTO's `satisfies`-checked field set the authority: adding a field * to {@link NodeRuntimeState} and forgetting it here is a compile error rather * than a silent `JSON.stringify` drop / reshape. `budget` is carried * explicitly (it used to ride the untyped spread while being absent from the * hand-mirrored DTO declaration) so the per-node declared ceilings survive a * recovery round trip *by contract*, not by accident. */ export declare function serializeNodeDTO(n: NodeRuntimeState): NodeRuntimeStateDTO; /** Flatten a live {@link EngineState} into the versioned, JSON-safe DTO. */ export declare function serializeEngineState(state: EngineState): EnginePersistenceFile; /** Hydrate a live {@link EngineState} from a versioned, plain DTO. */ export declare function deserializeEngineState(file: EnginePersistenceFile): EngineState; /** * Build a safe on-disk slug from a `graphId`. Graph ids are generated as * `"{name}-{timestamp}-{seq}"`, but the leading `name` is user/declaration * controlled and may contain characters that are unsafe in a filename — so the * slug strips everything outside `[A-Za-z0-9._-]`. */ export declare function engineStateSlug(graphId: string): string; /** Absolute path to a graph's engine state file: `.rolebox/state/engine-{slug}.json`. */ export declare function engineStatePath(directory: string, graphId: string): string; /** * File-backed store for a single graph's engine state. * * Construct with a workspace directory (defaults to `process.cwd()`); the * state file lives under `.rolebox/state/`. The `directory` is injectable so * tests can point at a throwaway temp dir and never touch the real state tree. * * Writes are synchronous and atomic (`.tmp` + `renameSync`), the same crash-safe * pattern as `task-store.ts:101-108`. `save` never throws — a failed write is * logged as a warning and reported via the boolean return so the caller can * gate `clearDirty` on the outcome (M5); a write failure never silently drops * the pending state. Two-tier policy: critical transitions use the synchronous * {@link save}; non-critical churn uses the debounced {@link scheduleSave} and * is drained by {@link flush} on terminal phases — a replaced / discarded * runtime calls {@link dispose} instead (cancels the debounce, drops the * pending write, never flushes stale state). */ export declare class EnginePersistence { private readonly directory; private debounceTimer?; constructor(directory?: string); /** * Write-through save of the current engine state. Synchronous and atomic. * Intended for the advancement critical section's `finally` block so that * critical transitions (node lifecycle, phase, frontier) survive a crash. * * A critical `save` also cancels any pending debounced write — the sync write * already contains the latest state, so coalescing the non-critical churn into * it is safe (see the two-tier policy in the class header). * * Returns `true` when the state reached disk, `false` on a failed write * (never throws). Callers that gate `clearDirty` on the outcome use this to * keep the dirty flag set so a later section retries the persist. */ save(state: EngineState): boolean; /** * Debounced save (500ms) for **non-critical** updates — signal-ledger history * updates and budget / per-node tokensConsumed counters. Multiple rapid * mutations are coalesced into a single atomic write of the most recent * state. A final {@link save} / {@link flush} is still required to guarantee * durability before process exit (flush-on-terminate is wired into the * engine when a section reaches a terminal phase). A runtime that is * replaced / discarded must call {@link dispose} — which cancels the * debounce and drops the pending write rather than flushing stale state. * * If the debounce timer's write fails, the pending state is RETAINED so the * next {@link flush} / {@link save} retries it — a failed debounced write is * never silently dropped (M5). */ scheduleSave(state: EngineState): void; /** * Force-drain a pending debounced write synchronously. Companion to * {@link scheduleSave} — runs when the engine reaches a terminal phase * (`complete`) or the runtime is disposed / replaced so no debounced * non-critical write is lost. A no-op when no debounced write is pending. * * Returns `true` when there was nothing pending or the drain write reached * disk, `false` when the drain write failed — in which case the pending * state is RETAINED for a later retry (M5). */ flush(): boolean; /** * Teardown entry point (review 05-F1/F3, M14/ML1): cancel any pending * debounce timer and DROP the pending-to-flush state — the runtime owning * this store is being disposed / replaced, so its state is stale relative to * whatever writes the successor runtime has already performed. Unlike * {@link flush}, this deliberately does NOT write: flushing a stale snapshot * over the new runtime's state is the exact stale-write race the review * flagged (the "flush-on-replace" contract in the class header only applies * when the engine itself reaches a terminal phase — a dispose is not that * path). * * Idempotent — a second dispose is a no-op. After dispose, a late * {@link scheduleSave} would re-arm the timer, so callers must not keep * using a disposed store. */ dispose(): void; /** * Load a graph's persisted engine state. * * Returns `null` (clean start / caller should provision a fresh engine) when: * - the state file does not exist (ENOENT); * - the JSON is corrupt / not an object; * - the schema version does not match `ENGINE_PERSISTENCE_VERSION`; * - the file is structurally invalid / missing a required field (total * hydration — this method NEVER throws, so `recover()` can rely on `null` * meaning "no valid persisted state"); * - the file carries an out-of-vocabulary enum value — `node.status` / * `node.joinStrategy` / `file.phase` not in their runtime vocabularies * (R2: a corrupt-but-shape-valid file must not hydrate and crash later in * `canTransitionNode`); * - a node entry fails the R2 node-level field gate (`agent` / `prompt` / * `needsApproval` / `signalsObserved` / `upstreamResults` / * `tokensConsumed` with its three numeric counters) — a previously * "barely loadable" stub node now yields a clean start. * * A legacy bare `joinStrategy: "quorum"` (no count) is NOT corrupt: it is * normalized to `{ quorum: 1 }` with a `logWarn` (contract C1) — see * `normalizeJoinStrategy`. * * Non-ENOENT READ failures are NOT clean starts (review 05-F6 / L22): an * unreadable-but-present state file (EACCES, EISDIR, ...) is rethrown so the * caller surfaces the error explicitly instead of silently re-provisioning a * graph whose completed nodes would be re-executed. The engine's `recover()` * wraps this call in its own try/catch and logs the failure, matching the * failure accounting of `recoverInterruptedGraphs` (engine-startup.ts). */ load(graphId: string): EngineState | null; private _writeOnFlush?; private _cancelDebounce; /** * Serialize → mkdir → write `.tmp` → atomic rename-over the destination. * * The destination is replaced by a single `renameSync(tmp, filePath)` — * POSIX rename-over is atomic, so a concurrent reader (e.g. the TUI polling * engine-*.json) can never observe the path missing mid-write: the * destination always holds either the previous snapshot or the new one. * The former unlink-then-rename sequence opened an ENOENT read window * between the two syscalls that made the TUI drop the graph for a tick. * * Returns `true` on success, `false` on failure. Never throws — write-through * must not break the advancement critical section, so a failed write degrades * gracefully in memory, is surfaced through the boolean (no longer silently * swallowed, M5), and is left to the caller to retry. */ private _write; } /** * Parse a raw state-file string and return the hydrated {@link EngineState}, * or `null` when it is not a valid version-`2` engine state file. Shared by * {@link EnginePersistence.load} so the version/malformation gate is testable * without touching the filesystem. * * **Total hydration**: this function NEVER throws. A file that is corrupt JSON, * a schema-version mismatch, missing a required field, structurally invalid * at any deeper level, or carrying an out-of-vocabulary enum value (`status` / * `joinStrategy` / `phase`) returns `null` (the documented corrupt-to-null * contract in the class header — `load()` doc at `EnginePersistence.load`). A * parseable-but-field-incomplete file must never make recovery throw, because * that would leave the graph permanently unrecoverable (re-failing every * restart). Missing required fields are treated as CORRUPT, not as a * migration point — `ENGINE_PERSISTENCE_VERSION` stays `2`. */ export declare function loadEngineStateFromJson(raw: string, _sourceLabel?: string): EngineState | null; export {}; //# sourceMappingURL=engine-persistence.d.ts.map