UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

366 lines (365 loc) • 27.2 kB
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; }