/** * TransactionBuffer — Per-stage STAGING buffer for state mutations * * What it IS: a staging buffer with read-your-writes and net-change commits. * - Changes are staged here during stage execution and flushed to * SharedMemory in ONE batch per stage (`commit()`) — other stages and * parallel siblings never observe a stage's half-finished writes. * - Read-after-write consistency within a stage — a stage sees its own * staged writes immediately. * - `commit()` records the stage's NET change (see {@link commit}), plus an * operation trace for deterministic replay. * * What it is NOT: a rollback mechanism. Despite the name, there is no * abort/rollback path — when a stage THROWS, the engine still commits * everything staged so far before re-throwing (commit-on-error in * `FlowchartTraverser`). That is deliberate: the audit trail must record * what the failing stage changed. Do not rely on "stage failed → its * writes vanished". */ import type { CommitValuesMode, MemoryPatch, TraceEntry } from './types.js'; export declare class TransactionBuffer { private readonly baseSnapshot; private workingCopy; private overwritePatch; private updatePatch; private opTrace; private redactedPaths; /** Commit-value encoding policy (#13c-B). `'full'` = historical bytes. */ private readonly commitValues; constructor(base: any, commitValues?: CommitValuesMode); /** Hard overwrite at the specified path. */ set(path: (string | number)[], value: any, shouldRedact?: boolean): void; /** * Explicit key deletion at the specified path (#13c-B; absorbs backlog B8). * * Stages EXACTLY the same buffer mutations as `set(path, undefined)` — * `workingCopy`/`overwritePatch` get an own `undefined` at the path (the * historical flattening, preserving read behavior and the dedup diff base * across modes) — but records the op verb as `'delete'`. At commit: * `'full'` mode maps it back to a `'set'` trace entry (byte-identical to * today); `'delta'` mode emits a real `'delete'` entry whose replay * REMOVES the key instead of leaving `key: undefined` behind. */ delete(path: (string | number)[], shouldRedact?: boolean): void; /** Deep union merge at the specified path. */ merge(path: (string | number)[], value: any, shouldRedact?: boolean): void; /** Read current value at path (includes uncommitted changes). */ get(path: (string | number)[], defaultValue?: any): any; /** * Flush all staged mutations and return the commit bundle — recording the * stage's NET CHANGE, not its raw write log. * * ── WHY (the defect this fixes) ───────────────────────────────────────── * Previously every `set`/`merge` was recorded verbatim, so the commit bundle * was a log of *operations* rather than *changes*. Two operations produce no * net change yet were still committed as "mutations": * * 1. No-op write — writing a key the value it already holds (e.g. an * agent context slot re-emitting identical content every * turn). base K=1, stage writes K=1. * 2. Write-revert — changing then restoring a key within one stage. * base K=1, stage writes K=2 then K=1. * * Recording these as mutations (a) bloated causal slicing / backtracking with * spurious dependencies on intermediate values that never reach final state, * and (b) made downstream "what changed here?" consumers light up stages that * changed nothing — most visibly the lens highlight flagging every slot. * * ── HOW ───────────────────────────────────────────────────────────────── * At commit we hold BOTH `baseSnapshot` (state when the stage began) and * `workingCopy` (state after all its writes). For each path the stage touched * we keep it in the bundle ONLY if its final value differs from the base * value ({@link deepEqual}). No-op AND write-revert paths drop out, because * both compare equal to base. This is a single net-delta diff at commit time * — one deep compare per touched path, O(changed state), paid once per stage * (NOT per write). A naive per-write deep-equal skip would be more expensive * and would still miss write-revert (the intermediate write differs from the * value present at the moment of writing). * * ── TWO HONEST TIERS (by design — do not "unify" them) ────────────────── * • commit (here) = CHANGE-level — truthful net delta. Feeds the commit * log, causal chain, narrative, and the lens highlight. * • `onWrite` event = OP-level — fires on EVERY write attempt regardless of * net change. Feeds metrics / behavioural observability * (a debugger wants to see "wrote 2, then reverted"). * `onWrite` is unchanged by this method; only the COMMIT becomes change-only. * * ── EMPTY COMMITS ARE INTENTIONAL ─────────────────────────────────────── * A stage that nets no change commits an EMPTY patch — NOT nothing. * {@link StageContext.commit} still records the bundle unconditionally, so * every executed stage remains a time-travel cursor stop (its `runtimeStageId` * marker is preserved); only its PATCH is empty. This is what keeps the * commit-indexed slider stable while making the highlight truthful. * * ── KNOWN LIMITATIONS / FUTURE ────────────────────────────────────────── * • Explicit key DELETION under the default 'full' mode is still * flattened to set-of-`undefined` (a removed key cannot be expressed * in MemoryPatch alone). CLOSED under `commitValues: 'delta'` (#13c-B): * {@link delete} stages a distinct op and the bundle carries a real * `delete` trace verb whose replay removes the key. * • Array-merge dedup in {@link deepSmartMerge} still uses reference equality * (`new Set`), so deep-equal *objects* in a merged array are not deduped. * Orthogonal to this change; tracked separately. * * Resets the buffer to empty state after commit. */ commit(): { overwrite: MemoryPatch; updates: MemoryPatch; redactedPaths: Set; trace: TraceEntry[]; }; /** * Rebuild overwrite / updates / trace keeping ONLY paths whose final value * differs from the base value — i.e. the stage's net change. See * {@link TransactionBuffer.commit} for the rationale. * * Paths are compared at the exact granularity they were written (each trace * entry's path), against `workingCopy` (final) vs `baseSnapshot` (start). * Surviving `set` paths copy their final value from `overwritePatch`; * surviving `merge` paths copy their accumulated delta from `updatePatch` — * preserving the set-vs-merge verb so replay ({@link applySmartMerge}) is * byte-for-byte identical to recording only the real changes. * * This is the DEFAULT (`commitValues: 'full'`) payload — byte-identical to * the historical behavior, including flattening staged `delete` ops into * `set`-of-`undefined` trace entries. The delta encoding lives in * {@link toDeltaPayload}. */ private toChangeOnlyPayload; /** * Delta-encoded payload (`commitValues: 'delta'`, #13c-B) — same net-change * filter as {@link toChangeOnlyPayload}, two encoding differences: * * 1. **One trace entry per surviving path** (the §2.5 dedup rule — `append` * is NOT idempotent on replay, so duplicate entries would multiply * tails). The verb is resolved from the path's op mix + base→final * relationship; entries are ordered by each path's LAST touch, * preserving last-writer-wins for nested/overlapping paths. * 2. **Verb resolution per path**: * - last op `'delete'` AND final value gone → `delete` (the path stays * enumerated in `overwrite` with `undefined` for key-set consumers); * - ONLY `'merge'` ops → `merge` with the accumulated `updatePatch` * delta (replaying the accumulated delta once ≡ the full mode's * k sequential replays — `deepSmartMerge` is reference-idempotent * within one replay pass); * - otherwise (`set`/mixed): the committed value is computed by * replaying the path's op sequence EXACTLY the way `applySmartMerge` * replays the full-mode bundle ({@link replayPathVerbs}) — for * pure-set paths that is simply the last set value; for mixed * set+merge interleavings it reproduces the full mode's quirk of * applying the ACCUMULATED merge delta at every merge position * (which can differ from the buffer's read-your-writes view; parity * with the `'full'` mode's committed state is the contract). If base * and that value are arrays and base is a STRICT PREFIX → `append` * storing only the tail; else `set` storing the full value. * * Losslessness never depends on detection succeeding — every fallback is * today's full-value `set`. */ private toDeltaPayload; /** * Replay ONE path's op-verb sequence against its base value, exactly the * way `applySmartMerge` replays the corresponding full-mode bundle: every * `set`/`delete` position applies the LAST staged overwrite value (the * bag holds one value per path — last writer wins), every `merge` * position applies the ACCUMULATED `updatePatch` delta. This reproduces * the full mode's committed value for any interleaving — including the * mixed set+merge quirk where the accumulated delta re-applies pre-set * merge keys (full-mode replay semantics, kept for byte-parity across * modes; property-tested in delta-replay-equivalence). */ private replayPathVerbs; }