@sema-agent/core
Version:
Stateless, task-oriented AI agent core
366 lines (365 loc) • 27.2 kB
TypeScript
import type { ExecutionEnv } from "../../internal/harness-types.js";
import { type FileHistoryDiffStats, type FileHistoryExport, type FileHistoryResult, type FileHistoryRestoreResult, type FileHistoryStore, type FileHistoryTrackResult } from "../../core/file-history-store.js";
import { type FileHistoryRetentionPolicy } from "../../core/file-history-retention.js";
import { type CorruptReadInfo } from "./corrupt-read-sink.js";
/**
* design/381 slice 2 — the DURABLE file-backed {@link FileHistoryStore}: the per-edited-file rewind
* history that survives a process restart (slice 1 shipped the contract + the in-memory reference,
* and the file backend carried no rewind store in between — this closes that hole).
*
* It REUSES core's exported halves rather than re-deriving them, which is the §3 分工 clause, not a
* style preference: `captureFileBackupViaEnv`/`applyFileRestoreViaEnv` own every env-touching and
* security-relevant decision (content-hash equality, deletion markers, the parent-identity guard),
* and `trackKeyOf`/`resolveTrackKey` own the path coordinate (root-relative under the task root,
* verbatim-absolute outside it, family-conditional, '..' fail-closed). A backend that hand-rolls
* either is how symlink-escape and moved-root bugs come back.
*
* ON-DISK LAYOUT under `<root>/file-history/`:
* ```
* blobs/<sha256> write-ONCE content-addressed bytes, shared by
* every scope and version (EEXIST = dedup no-op)
* scopes/<sanitizeScope(scope)>/
* tracked.json the tracked set + version chains (pair array)
* boundaries/<entryId>.json one COMMITTED boundary = one file (its existence
* IS canRestore; written LAST, so a boundary is
* visible only once everything it references is)
* staging/<attempt>.jsonl the crash-residue journal of an in-flight capture
* ```
*
* ATOMIC VISIBILITY (§3, the F5/G11a family): blobs are published first (atomic write-then-link),
* the version chains second (one atomic whole-file replace), the boundary file LAST. A crash in the
* middle therefore leaves at worst orphan blobs and orphan version records — both collectable — and
* NEVER a boundary whose bytes are missing. `canRestore` only ever answers true for a boundary file
* that exists and parses.
*
* CRASH RESIDUE IS REAPER-JUDGEABLE (the slice-1 adversarial round's registered leak, closed here):
* an in-memory backend that dies mid-capture leaks its staged blobs with nothing left to judge them.
* Here every capture opens a staging journal naming the blobs it published; `reap` unions the
* journals of THIS process's live attempts into the live set (so a concurrent reap cannot collect a
* capture's bytes) and treats every OTHER journal as residue of a dead attempt — it removes the
* journal, and the blobs it protected fall out of the live set in the same pass.
*
* WRITER COORDINATES (stated rather than assumed; the two clauses answer different questions):
* · WITHIN one process, the durable face is the FILE, not the instance — several store instances
* over one data root are a supported configuration (this suite pins it): committed history is
* mutually visible, verb bodies re-read disk after every await and commit synchronously (one
* event loop = no torn interleave), and `reap` judges another instance's in-flight staging by
* proof-of-death, never by "not in my map". There is deliberately no shared in-process table —
* cross-instance coordination all rides the on-disk artifacts.
* · ACROSS OS processes the premise stays SINGLE-WRITER per data directory (the
* {@link import("./index.js").FileStorageBackend} boot lock in the assembled deployment; a
* directly-constructed store takes no fence of its own — the contract has no dispose verb a
* lease could ride): `tracked.json` is a whole-file replace with no cross-process merge, so two
* WRITER processes can lose each other's rows. Only the GC path is hardened beyond the premise
* (a foreign journal is protected by owner-pid liveness + age), because deleting bytes is the
* one mistake that cannot be walked back. The capture-lease sets
* (`spentAttempts`/`activeCaptures`) are deliberately in-process and NOT persisted (§10 S-6:
* the lease close is the calling Runner's discipline, and after a crash no new process has a
* capture path for the old entryId), so cross-replica fencing is a multi-writer backend's own
* job (the SQL twin's DB-level claim), not something this backend pretends to provide.
*
* RETENTION IS BUILT IN (design/381 §片2): a scope's boundary directory grows by one file per turn,
* so this backend trims it after every commit — the newest
* {@link import("../../core/file-history-retention.js").DEFAULT_FILE_HISTORY_BOUNDARY_KEEP} survive
* by default, and {@link FileFileHistoryStoreOptions.retention} is how a deployment says otherwise
* (including handing the schedule back with `keep: "unbounded"`). Of the two bundled backends this
* is the one where the absence of a bound was a disk that fills, which is why the bound is the
* store's own and not a chore delegated to whoever wires it.
*/
export interface FileFileHistoryStoreOptions {
/**
* Disclosure sink for a durable read this store treats as ABSENT because the bytes were corrupt
* or unreadable — same name and `{path, reason}` shape as every sibling file store, because
* "unreadable read as missing" is one failure grammar. Fires for: a corrupt/unparseable
* `tracked.json` or boundary file collapsed to "no history" (the never-throw seam's documented
* fail-open), and a blob-GC pass that ABORTED because its live set could not be completed (blobs
* are then kept — leaked, never lost). Never fires on plain ENOENT: a scope with no history yet
* really is empty. Swallow-guarded.
*/
onCorruptRead?: (info: CorruptReadInfo) => void;
/**
* design/381 §片2 — the built-in boundary bound and its override. Omitted ⇒ the engine default
* ({@link import("../../core/file-history-retention.js").DEFAULT_FILE_HISTORY_BOUNDARY_KEEP}
* newest boundaries per scope, trimmed after each commit); `{ keep: "unbounded" }` switches the
* self-trim off and hands the GC schedule back to the deployment. A malformed policy is REFUSED
* at construction — this store writes to a disk that a silently-substituted retention policy
* would fill.
*/
retention?: FileHistoryRetentionPolicy;
}
export declare class FileFileHistoryStore implements FileHistoryStore {
private readonly base;
private readonly blobsDir;
private readonly scopesDir;
private readonly onCorruptRead;
/**
* scope → entryIds whose capture attempt failed: the lease is spent, so a retry refuses with ZERO
* env reads (§10 S-6 — in-process, deliberately not persisted; see {@link reapedCache} for the
* neighbouring ledger that IS durable and why the two differ).
*
* NESTED, not the joined `scope\0entryId` key this used to be (slice 3) — and the honest reason is
* defence in depth rather than a live bug. A joined key is only unambiguous while NEITHER component
* can contain the separator, and the entry id cannot: `historyPathComponent` runs FIRST in
* `snapshot` and rejects anything outside `[A-Za-z0-9_.-]`, so with NUL-free ids the last separator
* splits the string uniquely and no two (scope, entryId) pairs can share a key. The problem with
* that argument is where it lives: the ledger's correctness depended on a whitelist in a different
* function, and a future id spelling that relaxes it (or a second caller that reaches these ledgers
* without the path guard) would silently make one scope's failed capture close another scope's
* lease — refusing a good boundary AND explaining it with a "prior attempt" that scope never made.
* The nested form does not need the argument. It is the shape the reaped ledger beside it already
* uses, for the reason stated there.
*/
private readonly spentAttempts;
/** scope → entryId → staging journal path, for the CAPTURES in flight right now: a concurrent
* same-entryId capture is refused, and `reap` treats their journals' blobs as live. */
private readonly activeCaptures;
/** scope → the staging journals of the IMPORTS in flight right now. Separate from the captures
* above rather than sharing one key space with them: an import is keyed by its own journal path
* and a capture by an entry id, and one map holding both would be a key space where two unrelated
* vocabularies have to promise never to spell the same string. */
private readonly activeImports;
/**
* The CONSUMED-ID ledger's in-process view — a CACHE over `scopes/<scope>/reaped.log`, not the
* record itself (design/381 slice 3, closing the residual slice 2 registered here by name).
*
* Why it had to become durable. Reaping a boundary deletes the one artifact that proved its
* entryId had been consumed, and `snapshot`'s create-once check read exactly that artifact; without
* a tombstone a later capture for a reaped id takes the CAPTURE path and publishes post-turn bytes
* under a pre-turn key. Slice 2 kept the tombstones in this object, which holds for one store
* instance and for nothing else: a PEER store over the same data directory (supported in-process)
* and every restart see neither the deleted boundary file nor another instance's Set, so an old
* entryId re-presented THERE reached the capture path — the hole, one door over. The ledger is now
* an append-only log per scope, so every reader of that directory judges the same ids.
*
* The cache is keyed by the log's SIZE, which is sound precisely because the log is append-only:
* a peer's reap can only make it longer, so an unchanged size is an unchanged set. Steady state is
* therefore one `stat` on the per-turn path, which is what makes the durability affordable — and
* that sentence is load-bearing rather than decorative: past its bound a scope reaps on EVERY
* commit, so an entry dropped at each append would make the steady state a full re-read of a log
* that only grows. {@link markReaped} therefore CARRIES this entry across its own append.
*
* Growth is stated rather than hidden: one line (~40 bytes) per boundary ever reaped in the scope,
* forever — the cheap residue of a bound that throws away complete tracked-set mappings, by two
* orders of magnitude. It is what makes the bound SAFE rather than merely small.
*/
private readonly reapedCache;
/** Resolved boundary cap, or `null` for the explicit `"unbounded"` opt-out (host owns the GC). */
private readonly boundaryKeep;
/** Highest {@link BoundaryJson.committedAt} this instance has minted — see {@link nextCommittedAt}. */
private lastCommittedAt;
/** Scopes whose on-disk maximum has already been folded into {@link lastCommittedAt} — see there. */
private readonly seededScopes;
/**
* The publication stamp, STRICTLY INCREASING — and increasing against what is already ON DISK, not
* merely against what this object has minted (cross-review rounds 4 and 5).
*
* `Date.now()` has millisecond resolution and turns are faster than that, so several boundaries
* could legitimately share a stamp — and a tied group falls through to the entry-id tie-break,
* which is only commit order for MONOTONIC ids. A host minting arbitrary entry ids could therefore
* have the retention pass drop the genuinely newer of two same-millisecond boundaries and keep the
* older. Ties are not broken better here; they are made not to happen, which is the difference
* between a heuristic and an order. (The same technique the engine's own uuidv7 mint uses.)
*
* The instance counter alone was not enough, and the reason is a configuration this backend
* explicitly supports rather than an exotic one: a SECOND store object over the same directory — or
* the same process restarted, which is the everyday case — starts its counter at zero, so after a
* clock rollback (or simply within the same millisecond as the previous instance's last commit) it
* could stamp a NEW boundary at or below an existing one, and retention would then keep the older.
* So the first commit into a scope SEEDS the counter from that scope's highest resolved order.
* Cost: one directory read per (instance, scope), amortized to nothing across a session.
*
* RESIDUAL, named rather than implied: two live instances interleaving commits into one scope
* inside a single millisecond can still tie, because each advances its own counter after seeding.
* A tie is then decided by the entry id, which IS commit order for engine-minted uuidv7 ids.
*/
private nextCommittedAt;
constructor(root: string, opts?: FileFileHistoryStoreOptions);
/**
* The RETENTION pass, run after a boundary file is published (design/381 §片2 — see
* {@link FileHistoryStore.reap} for why the backend owns this rather than the host).
*
* COMMIT ORDER, in order of preference: the boundary's own {@link BoundaryJson.committedAt} stamp,
* else the file's mtime, else the entry id. The layering is the point — each fallback is weaker
* than the one above it and is named so nobody has to reverse-engineer which one is in force:
* · `committedAt` is written at publication and carried across `adoptScope`, so it survives the
* ordinary operations that destroy an mtime (a `cp` without `-p`, a restore from backup, the
* re-serialization adoption does). Absent only on records written before the field and on
* imported graphs, whose envelope carries no such value.
* · mtime is the fallback for exactly those, and is honest for a reason this family's standing
* law makes worth stating: DV-13 forbids metadata as a proof of CONTENT sameness, because
* deciding a tracked file "did not change" is a correctness and security judgment mtime cannot
* carry. Ordering records THIS store wrote once and never rewrites is a different question
* about a different object; being one place off costs a different-but-equally-legal retention
* choice and can never mis-restore a byte.
* · the entry id breaks ties, which for engine-minted uuidv7 ids IS commit order.
* A MIXED population (old records with no stamp beside new ones that have it) sorts coherently for
* a reason that is a fact rather than a hope, and is written down so nobody "fixes" one side into
* a different unit: both sources are milliseconds since the epoch — `Date.now()` and `Stats.mtimeMs`
* — so a stamped and an unstamped record are directly comparable.
* The residual, stated rather than papered over: a clock stepped BACKWARDS between commits, or a
* host minting non-monotonic entry ids of its own, can still make the pass drop a boundary that
* was not the oldest. It cannot make the pass drop THIS turn's boundary —
* {@link fileHistoryBoundariesToKeep} pins the just-committed id — and it cannot cost the pristine
* v1 fallback, which `reap` exempts permanently.
*
* WHAT IT REFUSES TO JUDGE — FAIL-CLOSED, WHOLE (cross-review rounds 1+2): if ANY `.json` in the
* scope fails to parse or fails its own digest, this pass does NOTHING for that scope. Not "skip
* that file", not "keep it and trim around it": nothing. The intermediate form is a trap, and it
* is worth writing down because it is the one this pass shipped with for a round. `reap` rebuilds
* its version-reference set from the READABLE boundaries, so retaining an unreadable boundary
* while trimming its neighbours still destroys the version records and blobs that only the
* unreadable one referenced — the file survives as "evidence" of history whose bytes the same
* pass deleted. And counting it toward the bound is the other half of the same mistake: one
* damaged record would push a healthy, still-restorable boundary out of the window. A scope with a
* record nobody can judge is a scope in a FAULT state, and an automatic, per-turn, irreversible
* deletion pass is exactly the wrong thing to keep running there. It grows past its bound instead,
* loudly (disclosed every pass), until an operator looks — the safe direction of a choice between
* "too much disk" and "history destroyed". The one caveat a deployment has to hear: that
* disclosure is only as loud as the sink it wired. A deployment that registers no
* {@link FileFileHistoryStoreOptions.onCorruptRead} gets the unbounded growth WITHOUT the notice —
* the same limitation every other corrupt-read fact in this store carries, and the reason the sink
* is not optional equipment in a durable deployment.
*
* COST, stated rather than left to be measured: the common path is ONE `readdir` — a scope under
* its bound parses nothing, stats nothing and calls no GC. A scope OVER the bound reads each
* boundary file (validation, then its stamp) and runs the whole `reap` pass, which ends in the
* cross-scope blob GC that walks every scope under this data root. Past the bound that is
* per-commit work proportional to the root, not to this scope — the price of the bound, and the
* reason the cheap path is kept genuinely cheap.
*
* Never throws and never fails the commit that triggered it (the boundary is already durable; a
* GC that could not run leaves a graph larger than its bound, the safer of the two directions).
*/
private applyBoundaryRetention;
/** The single delivery point for the disclosure sink. */
private disclose;
private scopeDir;
private trackedPath;
private boundaryDir;
/** A boundary file path, or null when `entryId` fails the strict path-component guard — the
* never-throw seam turns that into a typed result at the caller, never an exception. */
private tryBoundaryPath;
private blobPath;
/**
* Read a scope's tracked set, distinguishing the three states that matter (adversarial round 1):
* ABSENT (no history yet — the ordinary empty case), READ (the graph), and CORRUPT (bytes exist
* but cannot be understood). Collapsing CORRUPT into "empty" was a false-green machine: a readable
* boundary beside an unreadable tracked set made `restore` iterate zero files and report
* `complete` — a successful rewind that rewound nothing — and let the next `trackEdit` re-mint
* possibly-already-edited bytes as pristine. Callers must branch on `corrupt`.
*/
private readTrackedState;
/** The tracked set alone, for the read paths that have already handled the corrupt state. */
private readTracked;
/** Replace a scope's tracked set in ONE atomic step (the DV-14 "one atomic durable record" and
* the version-chain commit both ride this). */
private writeTracked;
private readBoundary;
/**
* A committed boundary's own publication stamp ({@link BoundaryJson.committedAt}), or `undefined`
* when the record predates the field, came from an import, or cannot be read. Callers treat
* `undefined` as "no stamp" and fall back — never as a value, and never as a reason to refuse: a
* boundary with no stamp is perfectly restorable, it just cannot state its own place in the order.
*/
private boundaryCommittedAt;
/**
* A boundary's place in commit order, resolved through the whole fallback chain in ONE place so
* the retention sort and the adoption copy cannot disagree about it (cross-review round 3): its
* own stamp, else the file's mtime, else `undefined` when neither is available. Both sources are
* milliseconds since the epoch, so a mixed population compares directly.
*/
/**
* UNION the source scope's consumed-id ledger into the target's — the adoption invariant, in ONE
* place because it has to hold on EVERY successful arm of `adoptScope` and the arm it was first
* missing from was the early one ("nothing to adopt", which is a statement about the GRAPH and not
* about the ledger). A fork is the source's history continued under a new name, so an id the
* source spent must not become mintable there.
*/
/**
* The boundary ids in `scope` that exist on disk but cannot be judged — `null` means the whole set
* is judgeable and safe to reason about. A directory that cannot be LISTED answers with an empty
* array rather than `null`, because "I cannot see them" and "I saw them and they are fine" must
* not be the same answer; ENOENT (no boundaries yet) is genuinely the second.
*/
private unjudgeableBoundaryIdsOf;
private carryConsumedIds;
/**
* The consumed-id ledger's path — a SIBLING of the scope directory, deliberately NOT a file inside
* it (cross-car review, adopted). `adoptScope` publishes by REPLACING the scope directory, and a
* ledger that lives inside it dies with every such replacement: the version that did had to read
* the ids out, carry them through the staging directory and write them back, which works right up
* until a crash lands between the removal and the rename — at which point the directory (and the
* only record of that scope's spent ids) is gone and an old entry id becomes mintable again.
* Outside, the ledger is simply never moved: the publish cannot lose it, and the carry-and-rewrite
* that used to be necessary is gone with it.
*
* A scope's sanitized directory name can never contain a `.` (non-alphanumerics collapse to `-`,
* then a hash is appended), so the `.reaped.log` suffix cannot collide with another scope's dir.
*/
private reapedLogPath;
/**
* The scope's consumed ids, cached by log size (see {@link reapedCache}). `null` = the log EXISTS
* but could not be read: a state where this store cannot tell a fresh entryId from a spent one, and
* the callers below take the fail-CLOSED arm rather than guess — refusing a boundary is a turn that
* cannot be rewound to, while guessing wrong is post-turn bytes published under a pre-turn key.
*/
private readReapedIds;
/** Append ids to the scope's consumed-id ledger. Append-only and idempotent by construction (a
* membership test cannot tell a duplicated line from a single one), which is what lets a peer
* process write it without coordination — the reason this is a log and not a rewritten set. */
private markReaped;
private boundaryOrderOf;
/** Publish a blob write-ONCE (EEXIST = the identical bytes are already stored — dedup no-op). */
private putBlob;
trackEdit(scope: string, absPath: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryTrackResult>;
annulTrack(scope: string, absPath: string, root: string, verify?: {
env: ExecutionEnv;
signal?: AbortSignal;
}): Promise<FileHistoryResult>;
/**
* Is this scope free of history RIGHT NOW? Synchronous on purpose (adversarial round 1): the
* empty-target law is only sound if the observation and the publish happen with no await between
* them — an emptiness result carried across an await is a claim about the past, and two concurrent
* imports that both believed it would each delete the other's freshly published graph.
*/
private scopeIsEmptySync;
/**
* The ONE-STEP publish shared by adoptScope and importHistory: verify emptiness and swap the
* staged directory into place with NOTHING awaited in between, so the whole claim-and-publish is
* atomic against any other in-flight publish in this process.
*
* THREE answers, not two (A-082 rescan): `"published"`, `"not_empty"` (the target stopped being
* empty — the caller's typed `conflict`), and `"ledger_failed"` (the tombstones for the residue
* this publish destroys could not be written). The second failure arrived with the tombstone write
* below and rode the same `false` as the first, so both callers dressed an IO fault as the one
* code the engine's fork-carry seat is contractually silent about — the exact silence the
* `conflict`/`adopt_failed` split was introduced to close, re-opened one door over (and its message
* asserted a fact that was not true: the target had gained nothing). They are separate answers now.
*/
private publishStagedScopeSync;
/** Committed boundary ids in a scope (a corrupt boundary is NOT listed — the listing agrees with
* canRestore/restore, so a half-readable directory never looks like more history than it serves). */
private listBoundaryIds;
snapshot(scope: string, entryId: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryResult>;
restore(scope: string, entryId: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryRestoreResult>;
canRestore(scope: string, entryId: string): Promise<boolean>;
diffStats(scope: string, entryId: string, env: ExecutionEnv, root: string): Promise<FileHistoryDiffStats | null>;
reap(scope: string, keepEntryIds: string[]): Promise<number>;
/**
* Blob GC across EVERY scope — FAIL-SAFE by construction: build the COMPLETE live set or keep
* every blob. An incomplete live set (a swallowed scan error) would delete still-live bytes, so
* any unexpected read aborts the pass and discloses (a leaked blob is harmless; a deleted live one
* is data loss). Synchronous end to end, so it cannot interleave with a capture parked at an
* await; the live set additionally unions the staging journals of THIS process's in-flight
* attempts (bytes published but not yet chained), while every OTHER journal is dead-attempt
* residue: it is removed here, and the blobs it was protecting become collectable in this pass.
*/
private collectBlobs;
adoptScope(fromScope: string, toScope: string): Promise<FileHistoryResult>;
exportHistory(scope: string): Promise<FileHistoryExport | null>;
importHistory(scope: string, data: FileHistoryExport, srcGetBlob: (hash: string) => Promise<Uint8Array | undefined>): Promise<FileHistoryResult>;
/** The import's body, so EVERY exit (typed refusal, thrown fault, success) passes one `finally`
* that releases the journal + lease — an early return used to leak both, and a leaked lease is
* permanent liveness for blobs nothing references (adversarial round 2). */
private runImport;
}