@sema-agent/core
Version:
Stateless, task-oriented AI agent core
769 lines • 51.3 kB
TypeScript
import { type FileHistoryRetentionPolicy } from "./file-history-retention.js";
import type { ExecutionEnv } from "../internal/harness-types.js";
/** rel path of `child` under `root` in "/" form (win root) / verbatim form (posix root); null when not under.
* Fold "\" only under a WIN-family root ({@link isWinFormPath}); a POSIX tree may legally contain literal
* backslashes in FILE NAMES, and folding them would corrupt both the track key and the restore target. */
declare function relUnder(root: string, child: string): string | null;
/** Join root + a "/"-form relPath using the ROOT's native separator (keys stay family-consistent). */
declare function joinUnder(root: string, rel: string): string;
/** The parent directory of an addressed path, as a pure family-aware string op (no host node:path). */
declare function parentDirOf(absPath: string): string | undefined;
/** The re-join verdict of {@link resolveTrackKey}: an absolute path to operate on, or a NAMED
* refusal (the fail-closed arm — a recorded key that would escape `root` is never coerced into
* something writable). A-082 idx5: named so the fence can be part of the public surface. */
export type FileHistoryTrackKeyResolution = {
ok: true;
abs: string;
} | {
ok: false;
reason: string;
};
/** The path family a track key was MINTED under — the half a bare key string cannot carry. */
export type FileHistoryKeyFamily = "posix" | "win";
/**
* A track key's SELF-DESCRIPTION (design/381 §3 amendment, closing the A-082 r2 registered
* residual): a key is a bare string, so it carries the shape it was minted in but never the family
* it was minted under. Recorded beside every tracked path and carried by the session-sync envelope,
* so a graph that arrives from ANOTHER MACHINE can be judged at the door instead of silently
* re-joining under the local root as an oddly-named in-root file.
*/
export interface FileHistoryTrackKeyDescriptor {
/** `"root-relative"` = re-join against the CURRENT root; `"absolute"` = restore where captured. */
kind: "root-relative" | "absolute";
family: FileHistoryKeyFamily;
}
/** The family of a root — the one place a backend asks "which family am I operating in?". */
export declare function trackKeyFamilyOf(root: string): FileHistoryKeyFamily;
/**
* {@link trackKeyOf} plus the self-description a backend must record with it. Mint through THIS and
* a graph stays judgeable after it crosses machines; mint through the bare key alone and the
* provenance is gone for good (the residual this closes).
*/
export declare function describeTrackKey(root: string, absPath: string): {
key: string;
} & FileHistoryTrackKeyDescriptor;
/**
* Track-key coordinate (design/381 DV-6, the CC `wZu` relativization ported to the trusted task
* root): a path under `root` is recorded root-RELATIVE (worktree/moved-root safe — restore re-joins
* it against the CURRENT root), a path outside `root` is recorded verbatim-absolute.
*
* **Exported as part of the "MUST reuse, NOT re-implement" set** (A-082 idx5, the same §3 分工
* clause that governs the two env halves): every backend mints keys from the `(absPath, root)` pair
* `trackEdit`/`snapshot` hand it and re-joins them in `restore`, so this pair — not the env halves —
* is where a hand-rolled backend re-introduces path escape. The escape check lives INSIDE the pair
* (mint refuses to record an ambiguous rel form; {@link resolveTrackKey} refuses an unsafe recorded
* one), so a backend that calls them cannot re-open the hole, and one that re-derives them must
* re-derive the family-conditional bare-drive rule and the '..' fail-closed arm correctly on its own.
*/
export declare function trackKeyOf(root: string, absPath: string): string;
/**
* Re-join a recorded track key against the CURRENT root (identity for absolute keys) — the
* {@link trackKeyOf} mirror, exported under the same "MUST reuse" clause. A key that would escape
* the root is REFUSED by name, never coerced: `{ok:false}` is the fail-closed arm a backend must
* surface as a per-file `refused` row, not silently drop.
*
* CROSS-FAMILY PROVENANCE (A-082 codex r2, registered in slice 1 — CLOSED here in slice 2 exactly as
* that note prescribed). A key is a bare STRING: it carries the shape it was minted in, never the
* FAMILY it was minted under. Every key minted against this scope's own root resolves by the mirror
* above — but a scope carried in from another machine (the {@link FileHistoryExport} envelope,
* `adoptScope` across hosts) can hold a key whose family differs from the current root's, and under
* a POSIX root a win-absolute record (`D:\other\x`) used to re-join UNDER the root as an oddly-named
* in-root file (contained, never traversal — but not the file the record meant). The closure is the
* §3 amendment the note named: a SELF-DESCRIBING key ({@link FileHistoryTrackKeyDescriptor}: kind +
* source family) recorded beside every tracked path and carried by the versioned envelope, judged
* HERE through the optional `minted` argument. Omit it and this function is byte-identical to
* before; pass it and a foreign-family record is refused by name instead of coerced.
*/
export declare function resolveTrackKey(root: string, key: string, minted?: FileHistoryTrackKeyDescriptor): FileHistoryTrackKeyResolution;
export interface FileHistoryError {
/** `not_found` = no boundary for that (scope, entryId); `aborted` = the signal fired (or the
* fencing discarded a timed-out attempt); `conflict` = a non-destructive-target law refused
* (adopt/import onto a scope with its own history) — and ONLY that, because a caller decides
* whether to stay quiet on it: the engine's fork-carry seat treats `conflict` as "already carried,
* or grown its own" and says nothing, so an adoption that failed for any OTHER reason (a corrupt
* source graph, a blob it cannot reach, a consumed-id ledger it cannot carry — including the
* tombstones a publish owes for unreadable residue it is about to replace) must NOT wear this
* code or the failure disappears into that silence — those are `adopt_failed`, which the
* ADOPTION FAMILY shares: `importHistory` publishes under the same target law and answers the
* same code for the same non-conflict failures (its `unsupported_format` names a bad ENVELOPE or
* work its shape drove; a fault in the PUBLISH step itself — the disk refusing a good graph —
* answers `adopt_failed`); `unsupported_format`
* = a foreign-epoch or wrong-version envelope; the rest name the verb that failed. */
code: "track_failed" | "snapshot_failed" | "restore_failed" | "adopt_failed" | "not_found" | "aborted" | "unsupported_format" | "conflict";
message: string;
}
export type FileHistoryResult = {
ok: true;
} | {
ok: false;
error: FileHistoryError;
};
/**
* {@link FileHistoryStore.trackEdit}'s answer. `minted` is the ANNUL HANDLE in boolean form (#491):
* true = THIS call created the first-touch record, so the caller may {@link
* FileHistoryStore.annulTrack} it if the edit that record was minted for never lands; false = the
* path was already tracked (a cheap no-op), and annulling would destroy a record some EARLIER edit
* legitimately owns. A backend that cannot tell answers `false` — the safe side (a stale record is
* a cowork hazard, but discarding someone else's pristine baseline is worse).
*/
export type FileHistoryTrackResult = {
ok: true;
minted: boolean;
} | {
ok: false;
error: FileHistoryError;
};
/**
* Three-state discriminant + full per-file ledger on EVERY arm (design/381 DV-15). Refusals are
* never folded into benign skips; `base_unavailable` (DV-14) is a refusal reason.
*/
export type FileHistoryRestoreResult = {
/** `"complete"` = every target applied or identical; `"partial"` = ≥1 refused or failed, others
* applied; `"failed"` = the operation itself could not run (e.g. no such boundary). */
outcome: "complete" | "partial" | "failed";
/** Track keys written or deleted (the converged set). */
filesChanged: string[];
/** Track keys already equal to the target (untouched — content-hash proven, never mtime). */
identicalSkipped: string[];
/** Per-file security/consistency refusals (skipped, counted, never failing the whole restore). */
refused: Array<{
path: string;
reason: string;
}>;
/** Per-file operational failures (env write/remove errors, aborts after k writes). */
failed: Array<{
path: string;
reason: string;
}>;
/** Files whose recorded POSIX mode could not be re-applied (env without the optional mode face). */
modeNotPreserved?: number;
/** Present on outcome:"failed" (operation-level cause) and optionally on "partial". */
error?: FileHistoryError;
};
export interface FileHistoryDiffStats {
filesChanged: string[];
insertions: number;
deletions: number;
}
/** The line-diff bound (see {@link countRestoreLineDiff}): a pair whose edit distance exceeds this
* is counted as a whole-file replacement. Public so a preview UI can state what it is showing. */
export declare const FILE_HISTORY_DIFF_LINE_BUDGET = 2000;
/**
* The per-file line arithmetic behind {@link FileHistoryStore.diffStats} — ONE rule both bundled
* backends use, because "how much will this rewind change" is a claim, and two backends answering it
* differently would be two claims.
*
* `undefined` on either side means "does not exist" (a creation or a deletion), so every arm of the
* restore is expressible: bytes → bytes is a line diff, absent → bytes is a pure insertion, bytes →
* absent a pure deletion.
*
* TWO HONESTY BOUNDS, both stated rather than tuned in silence:
* · BINARY content (a NUL byte anywhere) contributes 0/0 while still being reported as CHANGED —
* "lines" is not a fact about a binary file, and printing a number derived from stray `\n` bytes
* would be inventing one.
* · Beyond {@link FILE_HISTORY_DIFF_LINE_BUDGET} the greedy diff STOPS and the pair is counted as a
* whole-file replacement (every current line deleted, every target line inserted). That is not a
* fallback to a worse estimate so much as the shape such a pair actually has — a diff that large
* IS a rewrite — and it keeps a preview from turning into an O(N·M) walk over two big files.
*/
export declare function countRestoreLineDiff(current: Uint8Array | undefined, target: Uint8Array | undefined): {
insertions: number;
deletions: number;
};
/**
* Versioned session-sync envelope; `formatVersion` is the refuse-not-coerce gate (design/381 §5.3).
* COMPLETE-state export: the tracked set and EVERY retained version record travel — including v1
* records no surviving boundary references (they are restore()'s pristine fallback and reap()
* retains them independently) and the parent-identity guards (root-relative under root) that
* restore's preflight needs. An import that received only boundary references could neither run the
* parent-chain check nor serve a tracked-later-than-target restore; round-trip-after-reap is a
* contract-kit test.
*/
export interface FileHistoryExport {
formatVersion: "file-history/1";
tracked: Array<{
/** Root-relative when captured under the task root, verbatim-absolute otherwise. */
relOrAbsPath: string;
/**
* The key's SELF-DESCRIPTION (§3 amendment, A-082 r2). A bare key string cannot say which
* family minted it, so a graph that crosses machines was previously re-joined against the local
* root on a guess. Both members travel with every path and the import door refuses an envelope
* whose values are outside the closed sets (refuse-not-coerce, same posture as `formatVersion`);
* {@link resolveTrackKey} then judges each foreign-family key at restore time.
*/
keyKind: FileHistoryTrackKeyDescriptor["kind"];
keyFamily: FileHistoryKeyFamily;
/**
* DV-14: false ⇒ the pristine base could not be captured (durable base-unavailable marker); the
* chain then starts above v1 and restore-below-base must keep refusing after import. Travels
* with the graph; never inferred.
*/
baseAvailable: boolean;
/** Full retained chain (v1 first iff baseAvailable). `blobHash:null` = existed-not marker. */
versions: Array<{
version: number;
blobHash: string | null;
mode?: number;
parentIdent?: string;
backupTime: string;
}>;
}>;
boundaries: Array<{
entryId: string;
files: Array<{
relOrAbsPath: string;
version: number;
}>;
}>;
}
/**
* The IDENTITY of an envelope — sha256 over its canonical (sorted) form, so the same graph digests
* the same however a backend ordered its rows. It is what makes `importHistory`'s idempotent
* re-publish decidable WITHOUT extra bookkeeping: a target already holding a graph that digests
* equal to the incoming one is the SAME import arriving twice (ok), while any other non-empty target
* is locally-grown history the import must never overwrite (typed `conflict`). Exported under the
* "MUST reuse" clause: two backends that computed identity differently would disagree about which
* retries are safe.
*/
export declare function fileHistoryExportDigest(data: FileHistoryExport): string;
/**
* The import DOOR's structural judgment, shared by every backend (a second implementation is a second
* opinion about what is safe to accept): the envelope must carry THIS format version and every row
* must be shaped and self-described from the closed sets — `unsupported_format` otherwise, never a
* coercion. Backends call this BEFORE fetching a single blob, so a foreign-epoch payload costs
* nothing and changes nothing.
*/
export declare function validateFileHistoryExport(data: FileHistoryExport): FileHistoryResult;
/**
* design/381 §3 — per-edited-file rewind history (CC FileHistory topology).
*
* Replaces the E19 whole-tree FileSnapshotStore. Three verbs mirror the reference module:
* `trackEdit` (backup-before-first-edit), `snapshot` (version the tracked set at a turn boundary),
* `restore` (converge ONLY the tracked set to a recorded boundary).
*
* Store discipline (contract-kit enforced):
* - NEVER throw: every failure is encoded in the returned result (FileSystem posture).
* - snapshot MUST NOT fail the caller's turn: a failed boundary capture degrades to "no boundary"
* with disclosure (onError phase:"rewind"). trackEdit is DIFFERENT (DV-14): its `{ok:false}`
* means "no durable first-touch state exists" and the CALLER's default policy is to REFUSE the
* edit (typed, loud) — silently proceeding would let a post-recovery retry mint already-modified
* bytes as pristine v1. Any proceed-unprotected policy is an explicit, adjudicated caller arm,
* never the store's own behavior.
* - Structurally out of scope: files the agent never edited. No store method may enumerate, read,
* or write ANY path outside the tracked set plus its recorded restore targets. (The cowork
* guarantee lives HERE, as a contract clause.)
* - BOUNDED by default: one boundary per turn, forever, is unbounded growth. Both bundled backends
* trim their own scope after each commit (see {@link FileHistoryStore.reap} for the rule, the
* default and the host override); an out-of-tree backend owes its own bound or an explicit
* statement that the host must schedule the GC.
*/
export interface FileHistoryStore {
/**
* Backup `absPath`'s CURRENT state before an agent edit (first touch per scope only; later calls
* are cheap no-ops). The first touch is ONE atomic durable record — pristine blob+record,
* existence=false marker (rewind of an agent-CREATED file deletes it), or base-unavailable marker
* (DV-14). Reads bytes via `env` (remote-safe); stores them host-side. `root` is the trusted task
* root: paths under it are recorded root-RELATIVE (worktree/moved-root safe), paths outside it
* verbatim-absolute; the parent-identity guard is recorded in the same frame.
* `{ok:false}` = NO durable first-touch state was persisted — see the discipline block: the
* caller's default is to REFUSE the edit (DV-14), because proceeding leaves the path
* indistinguishable from never-touched. `{ok:true, minted}` — see {@link FileHistoryTrackResult}:
* `minted:true` means this call created the record, which is what makes {@link annulTrack} legal
* for it (#491).
*/
trackEdit(scope: string, absPath: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryTrackResult>;
/**
* Discard the first-touch record `trackEdit` MINTED for `absPath` — the compensating verb for the
* one ordering the topology cannot avoid (#491): the pre-image capture must run BEFORE the write,
* so a write that then fails (a lost create race, ENOSPC, EACCES, a read-only mount) leaves a
* record for an edit that never happened. Left standing, an `existed-not` v1 makes an untouched
* path a rewind DELETE target: whoever writes those bytes next — the user, a peer process — gets
* them removed by a restore that reports `complete`, which is precisely the structural
* out-of-scope guarantee this contract opens with. So the record must be retractable.
*
* Discipline (contract-kit enforced): NEVER-throw like every other verb, and no env IO AT ALL
* unless the caller asks for the proof read below. Retraction is legal ONLY while the record is
* still exactly what `trackEdit` minted — a record referenced by ANY committed boundary, or one that has grown
* past its first version, is REFUSED (typed `conflict`) and left intact, because history that a
* boundary already froze is no longer the caller's to withdraw. An absent record is an idempotent
* `{ok:true}` (the retry path). Callers annul ONLY what their own `trackEdit` reported as
* `minted:true`: a path that was already tracked belongs to the edit that first touched it, and
* discarding that baseline would be the fake-pristine hole DV-14 exists to weld shut.
*
* `verify` is the EVIDENCE arm (adversarial round 2). A caller whose write failed with a code the
* FileSystem contract defines as "nothing was written" (`already_exists`, `precondition_failed`)
* has proof and passes nothing. Every other failure — a plain write is not contractually atomic,
* so ENOSPC, a dying transport or a thrown backend may have TRUNCATED the file before reporting —
* must hand an `env` here: the store then re-reads the path ONCE and retracts only if the world
* still matches what the mint recorded (bytes still absent for an existed-not mint, still the same
* hash for a captured one). A file that changed keeps its record, because the alternative is
* deleting the only baseline of a file the agent really did modify — damage rewind could no longer
* reach, which is strictly worse than a stale record.
*/
annulTrack(scope: string, absPath: string, root: string, verify?: {
env: ExecutionEnv;
signal?: AbortSignal;
}): Promise<FileHistoryResult>;
/**
* Record a history boundary keyed by `entryId` — the TURN'S OWN initial session entry, minted
* after that entry is appended and BEFORE any tool executes, so the boundary means "file state
* when `entryId`'s turn began" (≡ the reference's user-message-uuid snapshot; every turn
* incarnation appends a fresh entry, so keys never alias across re-branches from the same fork
* point). For each tracked file: re-stat via `env`; unchanged (metadata may prove inequality,
* content hash proves equality — DV-13: never mtime) ⇒ reference the prior version; changed ⇒
* store a new version; missing ⇒ record a deletion marker. Zero directory-tree traversal, zero
* size gate. Idempotent per (scope, entryId) over COMMITTED boundaries.
*
* Commit visibility + fencing + CAPTURE LEASE (contract-kit enforced): a boundary becomes visible
* to canRestore/restore ONLY after its complete version mapping is durable. Each attempt is
* FENCED (a timed-out/aborted attempt may never commit — {@link boundaryPublishVerdict}, no grace
* threshold), and the boundary's capture window is a LEASE that the caller closes before the
* first tool executes: after lease close, a fresh CAPTURE (any env read) for that entryId is
* refused — a retry may only PUBLISH a mapping whose bytes were fully captured before close.
* The close is NOT persisted and the store has no close verb (design/381 §10 S-6): close is the
* calling Runner's in-process discipline; after a crash the new process has no capture path for
* the old entryId (its own prepare mints only its own turn's boundary), and abandoned staging is
* judged by COMPLETENESS alone — complete ⇒ publishable, incomplete ⇒ abandoned (that turn has no
* boundary) — with ZERO env reads on the retry path. A turn whose pre-turn capture failed simply
* has no boundary (disclosed; the reference behaves the same) — there is NO mid-turn re-capture,
* because re-reading after edits would publish post-turn bytes under a pre-turn key. An entryId
* whose boundary was COMMITTED and later REAPED is closed by the same law and for the same reason
* (see {@link FileHistoryStore.reap}): it refuses typed with zero env reads rather than
* re-capturing a world that has moved on. Retention can therefore make a boundary unavailable,
* but never wrong.
*/
snapshot(scope: string, entryId: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryResult>;
/**
* Converge ONLY the tracked set to the boundary `entryId`: for each tracked file, target-version
* bytes ⇒ write via `env` (the guarded-write arm and the symlink/parent-chain/kind preflights are
* the design/381 slice-3 hardening — until then the write is plain and ADVISORY, stated, never
* overclaimed); deletion marker ⇒ remove; identical ⇒ untouched; tracked-later-than-target ⇒ fall
* back to its v1 (pristine) version, and a base-unavailable path (DV-14) is REFUSED with reason
* `base_unavailable` (left at its current state — never overwritten with a fake baseline).
* Per-file refusals SKIP that file and are counted, never failing the whole restore (CC form).
* Paths recorded relative to `root` re-join against the CURRENT root (worktree/moved-cwd safe).
*
* Partial outcome is FIRST-CLASS with a three-state discriminant and the per-file ledger always
* travels: applied / identicalSkipped / refused / failed are SEPARATE columns (a security refusal
* must never hide inside a benign skip count), so a caller can distinguish "nothing was mutated"
* from "the env died after k writes". Restore is per-file idempotent (identical files skip), so
* the documented recovery for a partial outcome is: re-run the same restore; it converges. There
* is NO all-or-nothing guarantee and none is claimed. Task-level policy on `"partial"` lives in
* the CALLER (prepare defaults to fail-loud; see DV-15).
*/
restore(scope: string, entryId: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryRestoreResult>;
/** Whether a boundary exists for (scope, entryId). */
canRestore(scope: string, entryId: string): Promise<boolean>;
/**
* Optional dry-run: per-file change stats for a prospective restore (CC diffStats; design/381
* slice 3). A-2 two-state law: `null` = preview UNAVAILABLE ≠ an empty stats object = a true
* zero-coverage answer — an absent preview must never be rendered as "nothing will change".
*
* It is an UPPER BOUND on what a restore will touch, and the reason is worth stating rather than
* discovering: the preview answers the CONTENT question (which tracked files differ from the
* boundary, and by how many lines), while the per-file security preflight — symlink/kind, parent
* chain — is decided by `restore` against the world as it stands THEN. A file listed here can
* therefore come back refused, and a preview that pre-ran those checks would be making promises
* about a world it does not own. Files whose target cannot be resolved at all (a missing version
* record or blob, an unsafe key) are left OUT: the restore reports them per file, and a preview
* cannot show the size of a change it cannot compute.
*/
diffStats?(scope: string, entryId: string, env: ExecutionEnv, root: string): Promise<FileHistoryDiffStats | null>;
/**
* GC: drop boundaries in `scope` not in `keepEntryIds`; then drop version records (and content
* blobs) referenced by NO surviving boundary — EXCEPT v1 records AND base-unavailable markers
* (DV-14), which are retained while the scope lives (the pristine fallback and its explicit
* refusal state are both part of the graph). Returns the number of boundaries dropped.
*
* WHO CALLS IT (design/381 §片2 — the bound is the backend's, not a chore left to the host): both
* bundled backends drive this verb THEMSELVES. Every committed boundary is followed by a
* retention pass that trims the scope to the newest
* {@link import("./file-history-retention.js").DEFAULT_FILE_HISTORY_BOUNDARY_KEEP} boundaries —
* an engine default a deployment overrides through the store's own `retention` option, including
* `keep: "unbounded"` to switch the self-trim off and take the schedule back. The pass is
* best-effort by construction (a GC failure never fails the turn that triggered it) and it only
* ever removes boundaries the caller could not name any more anyway.
*
* A backend outside this repo therefore owes ONE of two things, and silence is neither: its own
* bound (this verb, driven from its own commit path), or a documented statement that the host
* must schedule `reap`. An unbounded boundary graph is not a tuning choice — one boundary per
* turn, forever, is a disk that fills with no line anywhere admitting it would.
*
* WHAT THE SELF-TRIM DOES NOT COVER, said here rather than left to be discovered: it runs at a
* COMMIT, so a graph that arrives whole — `adoptScope`, `importHistory` — is published whole and
* comes under the bound at that scope's next commit, not before. That is deliberate rather than
* an oversight (round 2 raised it): both verbs promise all-or-nothing visibility of the graph the
* caller handed over, and trimming during publication would silently discard history the host
* explicitly asked to transfer. The overshoot is bounded by the envelope the host chose, which is
* a different thing from the per-turn source this bound exists to stop.
*
* A REAPED entryId IS SPENT (adversarial review — a law this verb acquired the day anything
* started calling it): dropping a boundary must not hand its key back. Until boundaries could be
* dropped, the committed mapping was itself the record that an entryId had been consumed, and
* `snapshot`'s idempotent short-circuit read it; a store that forgot a reaped id would let a
* later `snapshot` for that key take the CAPTURE path and publish post-turn bytes under a
* pre-turn key. Both bundled backends therefore tombstone what they reap and refuse a later
* capture of it, typed and with zero env reads. The ledger is IDS, not mappings, and it is
* unbounded — stated plainly because a bound's own residue growing forever would be a poor joke:
* one id string per boundary ever reaped, against the complete tracked-set mapping each of those
* boundaries used to carry. It is the cheap part of what the bound throws away, by two orders of
* magnitude, and it is what makes the bound safe rather than merely small.
*
* Concurrency is unchanged and stays the caller's to reason about: a pass that runs while another
* entryId's capture is parked at an env read may drop a version that capture staged, and that
* capture then refuses TYPED (its turn has no boundary) rather than publishing a dangling
* reference — the arm the commit paths already validate for.
*/
reap(scope: string, keepEntryIds: string[]): Promise<number>;
/**
* Cross-session carry (CC copyFileHistoryForResume): make `toScope` see `fromScope`'s boundaries
* + versions (content-addressed backends copy references, zero bytes). Same-scope call is a
* no-op. ATOMIC-VISIBILITY, same law as snapshot: the WHOLE adopted graph (tracked set, every
* version incl. base-availability state, every referenced blob) is staged and verified, then
* published in one step — `toScope` either sees the complete history or none of it. A
* failed/partial adopt publishes NOTHING; the resume still proceeds (same availability posture as
* the reference's tolerated partial copy, without the half-visible graph) and the failure is
* disclosed. EMPTY-TARGET ONLY: adoption is refused (loud, typed `conflict`) once `toScope` has
* ANY history of its own — adoption's seat is the fork/resume boundary BEFORE the new session's
* first turn; a retry after the target has begun writing would need a destructive merge
* (version-number collisions, overwritten target history) that this contract deliberately
* forbids. Retry while the target is still empty is idempotent and repairs abandoned staging.
*
* WHO CALLS IT — THE ENGINE DOES, since design/381 slice 3, and the seat is worth knowing because
* it decides WHEN this verb runs against your backend. `prepareTask` carries a forked session's
* history on the first turn it prepares for it, keyed on the LINEAGE FACT
* (`SessionMetadata.forkedFrom`) rather than on the code path that forked — so the Agent tool's
* fork route, `warmResume`, and a deployment that forks a session and then runs a task on it all
* reach the same seat. It runs BEFORE the restore seat, so the very first task on a fork may
* itself ask to restore files to a pre-fork boundary.
*
* The empty-target law is what makes that safe to attempt rather than schedule: a scope that
* already holds history answers the typed `conflict`, which at that seat MEANS "already carried,
* or grown its own" and is treated as a no-op. Only a non-conflict failure is disclosed
* (`onError` phase `"rewind"`), because that one is a carry the deployment expected and did not
* get. A failed carry never blocks the fork's run.
*
* A HOST MAY STILL CALL IT — the engine's seat covers sessions the engine prepares, and a
* deployment that copies history between scopes for its own reasons (a migration, a scope rename)
* is calling the same verb under the same law. What a MISSED carry gets is degraded, not broken,
* and it is worth naming precisely: the new scope starts EMPTY, so nothing the old one tracked is
* restorable from it. A rewind aimed at a pre-fork boundary answers "no boundary" (honest, typed)
* instead of converging those files, and the new lineage's own first touch of a path records THAT
* path's then-current bytes as its pristine v1 — a correct baseline for the new lineage, just not
* the old one's history. Nothing is silently mis-restored; the carry simply does not happen.
*/
adoptScope(fromScope: string, toScope: string): Promise<FileHistoryResult>;
/**
* Session-sync export/import (2c), versioned envelope — see {@link FileHistoryExport}. Optional.
* importHistory obeys the SAME atomic-visibility + staged-verify law as adoptScope: every
* referenced blob fetched and integrity-checked, then the scope's graph published in one step; a
* mid-import failure publishes nothing. NON-DESTRUCTIVE TARGET LAW (same family as adoptScope): a
* first import requires an EMPTY target graph; a retry is legal only while the target is still
* empty OR already holds a graph with the exact same import identity/digest (pure idempotent
* re-publish). A non-empty target with any other content ⇒ typed `conflict` refusal — atomic
* visibility prevents half-graphs, this law prevents publishing the WRONG complete graph over
* locally-grown history.
*/
/**
* NO `dispose` / lifecycle verb, and that is a decision rather than an omission (design/381 slice 3,
* the R-6 review candidate — recorded here because the next reader will otherwise wonder). The case
* for one: a directly-constructed file backend holds no store lock, so the single-writer premise is
* enforced only where a deployment assembles the backend (its data-root LOCK), and a `dispose`
* would let a store hold a lease and release it. The case against, which won:
* · every verb here is self-contained — a store holds no cross-call resource, so `dispose` would be
* an obligation on EVERY backend (a SQL twin included) in order to serve one backend's optional
* locking;
* · a lock released by a verb nobody is required to call fails WORSE than no lock: a crash leaves a
* stale lease that blocks a store which today simply works, and the recovery is manual;
* · the single-writer premise is the deployment's to make. The assembled backend is where it is
* enforced, and a directly-constructed store is the same "I know what I am doing" path every
* sibling file store in this family offers.
* A deployment that needs cross-process exclusion takes the assembled backend, or brings its own.
*/
exportHistory?(scope: string): Promise<FileHistoryExport | null>;
importHistory?(scope: string, data: FileHistoryExport, srcGetBlob: (hash: string) => Promise<Uint8Array | undefined>): Promise<FileHistoryResult>;
}
/**
* design/381 §3 + §10 T-5 — the FENCING judge for a boundary-capture attempt, exposed as a
* direct-callable function (the G6b grey-box seat) and used by every in-tree backend as the single
* commit gate: an attempt whose signal fired — timeout or abort, no matter how close to completion
* the capture got — may NEVER publish its mapping. ABSOLUTE, no grace threshold (§10 T-5: "无宽限
* 期" — the verdict has no time axis at all): a late-completing capture may have read bytes AFTER
* the lease closed (post-edit bytes), and publishing them under the pre-turn key is exactly the
* poisoning the fence exists to stop.
*/
export declare function boundaryPublishVerdict(attempt: {
aborted: boolean;
}): {
publish: true;
} | {
publish: false;
reason: "aborted";
};
/** One captured state of one file, as read through the env (the shared capture half). */
export type FileBackupCapture =
/** `mode` = the env's POSIX permission bits when it HAS that face (design/381 DV-8); absent when
* the env cannot express them — never fabricated, so a restore can tell "this mode was recorded
* and could not be applied" from "there was never a mode to keep". */
{
state: "file";
bytes: Uint8Array;
hash: string;
mode?: number;
parentIdent?: string;
} | {
state: "absent";
parentIdent?: string;
} | {
state: "aborted";
}
/** Non-ENOENT read failure: the file may exist but its bytes are not capturable right now. */
| {
state: "unreadable";
cause: string;
};
/**
* Capture ONE file's current state through the ExecutionEnv — **the env-operating half every
* backend MUST reuse** (design/381 §3 分工, the E19 captureManifest condition carried forward:
* re-implementing it per backend is how symlink-escape/fail-closed bugs come back). Used by
* `trackEdit` (first-touch pristine) and `snapshot` (per-file re-read). `not_found` is the sole
* absent signal (the read itself is the authoritative existence probe — a separate stat would only
* re-open the same race one window later). The parent-identity guard rides the same frame:
* `canonicalPath(parent)` — root-relativized when under `root` (DV-6: a legal whole-root move must
* keep verifying; an in-root parent-chain swap must keep refusing) — recorded best-effort (an env
* that cannot canonicalize omits it; the slice-3 preflight then has nothing to compare, which is
* the honest degraded form, never a fabricated identity).
*/
export declare function captureFileBackupViaEnv(env: ExecutionEnv, absPath: string, root: string, signal?: AbortSignal): Promise<FileBackupCapture>;
/**
* The retraction PROOF READ (design/381 #491 + adversarial round 2), shared by every backend so the
* evidence rule cannot drift: does the path still look exactly as the first touch recorded it? An
* existed-not mint requires the path to still be absent; a captured mint requires the same content
* hash. Anything else — including an unreadable path — leaves the record standing, because the only
* failure this arm exists to distinguish is "the write never landed" from "the write may have
* changed the file". A base-unavailable marker is REFUSED here rather than waved through
* (adversarial round 3): it carries no bytes to compare, but the marker itself is the durable
* refusal state that stops a later touch from minting already-modified bytes as pristine — erasing
* it on an ambiguous failure re-opens exactly the DV-14 hole it exists to close. It stays
* retractable through the `"proven"` path, which needs no evidence read at all.
*/
export declare function worldStillMatchesMint(env: ExecutionEnv, absPath: string, root: string, baseAvailable: boolean, mintedBlobHash: string | null, signal?: AbortSignal): Promise<{
unchanged: true;
} | {
unchanged: false;
reason: string;
}>;
/**
* DV-13's metadata rule applied to the MODE axis (design/381 slice 3), and it is a rule about
* EVIDENCE rather than about equality: metadata may prove two states DIFFERENT, never the same. Two
* known-and-unequal modes are a proven difference, so the file is versioned again even though its
* bytes are identical (the permission bit is part of the state a rewind restores). An UNKNOWN mode
* on either side proves nothing and must not manufacture a version — which is also what keeps a
* graph captured before this field existed from re-versioning its whole tracked set on the first
* boundary after an upgrade.
*/
export declare function modeProvenDifferent(recorded: number | undefined, current: number | undefined): boolean;
/** The target one file must converge to (bytes+hash, or "must not exist"). */
export type FileRestoreTarget = {
bytes: Uint8Array;
hash: string;
} | {
absent: true;
};
/**
* The recorded facts the slice-3 preflight judges the current world against, plus the abort signal
* (design/381 §6 slice 3). Every member is OPTIONAL on purpose: a record captured by an env without
* the mode face, or without canonicalization, carries neither — and a guard with nothing recorded to
* compare must degrade to "no opinion", never to a fabricated expectation.
*/
export interface FileRestoreApplyOptions {
/** The task root the recorded key was re-joined against — the coordinate `parentIdent` lives in. */
root: string;
/** The parent identity recorded WITH the version being restored ({@link parentIdentityViaEnv}). */
parentIdent?: string;
/** The POSIX mode recorded with the version being restored (design/381 DV-8). */
mode?: number;
signal?: AbortSignal;
}
export type FileRestoreApplyOutcome =
/** `modeNotPreserved` rides the two NON-refusal arms: the bytes converged but the recorded mode
* could not be re-applied (no env mode face, or the env refused/failed the change). A disclosure,
* never a failure — the file's CONTENT is what the restore promised. */
{
outcome: "applied";
modeNotPreserved?: true;
} | {
outcome: "identical";
modeNotPreserved?: true;
} | {
outcome: "refused";
reason: string;
} | {
outcome: "failed";
reason: string;
};
/**
* Converge ONE file to `target` through the ExecutionEnv — **the env-operating + security half
* every backend MUST reuse** (design/381 §3 分工). skip-if-identical is content-hash proven (DV-13:
* metadata may only ever prove INEQUALITY, never equality; mtime is never consulted), so an
* identical file is untouched (no write, no mtime churn). Deletion markers remove the current file
* (ENOENT tolerated = already converged).
*
* THE PREFLIGHT FAMILY (design/381 slice 3, DV-6 — the env-level approximation of the reference's
* fd-level `O_NOFOLLOW` + dev/ino re-check, ordered cheapest-refusal-first). Every arm SKIPS the
* one file and is counted; none of them fails the whole restore:
* · KIND — the addressed object (no-follow `fileInfo`) must be a regular file or absent. A symlink
* at the leaf is refused rather than followed: the recorded bytes belong to the file the agent
* edited, not to whatever a link now points at. A directory/socket/FIFO in its place is the same
* refusal for the same reason.
* · PARENT CHAIN — the current canonical parent identity must equal the one recorded with the
* version ({@link parentIdentityViaEnv}, root-relative under the root). A legal whole-root move
* keeps it equal (E4 passes); an in-root ancestor swapped for a link out of the tree does not
* (E5 refuses, and nothing outside the root is written). BOTH must hold at once — relaxing to
* "moves also refuse" or "swaps also pass" is a mis-fix, not a tuning choice.
* · The guard is EVIDENCE-BASED, not assumption-based: a record with no parent identity (an env
* that cannot canonicalize) and a parent that cannot be canonicalized NOW (the ordinary "the
* directory was deleted and this restore recreates it" case) both mean "nothing to compare", and
* inventing a refusal there would break the recreate arm rather than close a hole.
*
* THE WRITE uses `env.writeFileGuarded` when the env has it — one atomic verify-and-write whose
* expectation names the addressed path and forbids following a leaf link, so the window between
* this preflight and the write is closed at the backend. An env WITHOUT it falls back to plain
* `env.writeFile` and the convergence is then ADVISORY on the TOCTOU axis: the preflight above is a
* check-then-act, and a sufficiently fast racer can still swap the leaf in between. Stated here
* because the alternative — a two-step emulation behind an atomic-looking face — is exactly what
* that capability's own contract forbids.
*
* MODE (DV-8): a recorded mode is re-applied through the optional `setFileMode` face after the
* bytes land (and on an otherwise-identical file whose mode drifted, which IS a convergence). An env
* without the face — or one that refuses the change — yields `modeNotPreserved`, disclosed by the
* caller's ledger; it never turns a successful content restore into a failure.
*/
export declare function applyFileRestoreViaEnv(env: ExecutionEnv, absPath: string, target: FileRestoreTarget, opts: FileRestoreApplyOptions): Promise<FileRestoreApplyOutcome>;
/**
* ONE file's contribution to {@link FileHistoryStore.diffStats}, shared by both bundled backends:
* `undefined` = this file would NOT change (already at the target, or already absent for a deletion
* marker), otherwise the line arithmetic of the change. Content-hash proven, exactly like the
* restore's own identical-skip — a preview that used a cheaper test than the operation it previews
* would disagree with it.
*/
export declare function previewFileDelta(env: ExecutionEnv, absPath: string, targetHash: string | null, targetBytes: Uint8Array | undefined, targetMode?: number): Promise<{
insertions: number;
deletions: number;
} | undefined>;
interface FileHistoryVersionRecord {
version: number;
/** Content-addressed blob hash, or null = "did not exist at this point" (existed-not marker —
* serves both the pre-creation v1 arm and later deletion arms). */
blobHash: string | null;
mode?: number;
parentIdent?: string;
backupTime: string;
}
interface TrackedFileState {
/** DV-14: false ⇒ the pristine base could not be captured; a durable refusal state, not an
* absence — versions then start ABOVE v1 and v1 is never minted later. */
baseAvailable: boolean;
/** The key's self-description (§3 amendment): recorded at the mint so a graph that later crosses
* machines can be judged instead of guessed. */
keyDesc: FileHistoryTrackKeyDescriptor;
/** Set when a LATER trackEdit for this path was served (answered `minted:false`) — that lane is
* now relying on this record as its own pre-edit baseline, so the original minter may no longer
* retract it (adversarial round 1: two lanes racing one path, the first failing its write, would
* otherwise delete the baseline the SECOND lane's successful edit depends on). */
observedByLaterTouch?: boolean;
versions: FileHistoryVersionRecord[];
}
/** One staged per-file outcome of a capture attempt (commit assigns version numbers atomically). */
type StagedFileCapture = {
kind: "unchanged";
version: number;
} | {
kind: "changed";
blobHash: string | null;
mode?: number;
parentIdent?: string;
};
/**
* In-memory, content-addressed reference {@link FileHistoryStore}. Blobs are deduplicated by sha256
* ACROSS all scopes and versions (a file unchanged between boundaries stores zero new bytes); each
* tracked file keeps a version chain, each boundary a complete `trackKey → version` mapping over
* the tracked set as of its mint. Single-process (the default-deps reference; the durable
* file-backed impl is design/381 slice 2).
*
* Grey-box fault seams (design/381 §7 C7 / §10 T-4): `persistFirstTouch` and `publishBoundary` are
* `protected` COMMIT points — a test subclass overrides one to throw and the public verb answers
* the typed `{ok:false}` the contract promises (the store itself still never throws).
*
* RETENTION is built in (design/381 §片2): each committed boundary is followed by a trim back to
* {@link import("./file-history-retention.js").DEFAULT_FILE_HISTORY_BOUNDARY_KEEP} newest boundaries per scope, overridable through
* {@link InMemoryFileHistoryStoreOptions.retention}. Even a single-process reference store needs
* it — a long-lived process IS the unbounded case.
*/
export interface InMemoryFileHistoryStoreOptions {
/** design/381 §片2 — the built-in boundary bound and its override. Omitted ⇒ the engine default
* ({@link import("./file-history-retention.js").DEFAULT_FILE_HISTORY_BOUNDARY_KEEP}); a malformed policy is REFUSED here, at the wiring
* site, rather than silently becoming a different policy inside a GC pass nobody watches. */
retention?: FileHistoryRetentionPolicy;
}
export declare class InMemoryFileHistoryStore implements FileHistoryStore {
private readonly scopes;
/** Resolved boundary cap, or `null` for the explicit `"unbounded"` opt-out (host owns the GC). */
private readonly boundaryKeep;
private readonly blobs;
/** Blob hashes written by an in-flight capture BEFORE its commit — a racing reap must treat them
* as live (same MAJOR-1 posture as the retired whole-tree store). REFCOUNTED, not a set
* (adversarial round): two concurrent captures can lease the same new hash, and the first
* attempt's release must not strip the second attempt's liveness. */
private readonly inFlightBlobs;
constructor(opts?: InMemoryFileHistoryStoreOptions);
/**
* The RETENTION pass, run after a boundary commits (design/381 §片2 — see {@link FileHistoryStore.reap}
* for why the backend owns this rather than the host). Boundaries are an insertion-ordered map
* here, so commit order is exact and the selection needs no clock.
*
* Never throws and never fails the commit that triggered it: the boundary is already published and
* a GC that could not run is a graph that is merely larger than its bound — the strictly safer of
* the two failure directions, and the same posture the reference implementation's own eviction has.
*/
private applyBoundaryRetention;
private leaseBlob;
private releaseBlob;
private scope;
/** PROTECTED COMMIT SEAM (fault-injectable): durably record one path's first-touch state.
* Synchronous single assignment = the "ONE atomic durable record" of DV-14 for this backend. */
protected persistFirstTouch(scope: string, key: string, state: TrackedFileState): void;
/** PROTECTED COMMIT SEAM (fault-injectable): publish a captured boundary in ONE synchronous step
* — version records + mapping become visible together or not at all (atomic visibility). */
protected publishBoundary(scope: string, entryId: string, staged: Map<string, StagedFileCapture>): void;
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>;
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>;
adoptScope(fromScope: string, toScope: string): Promise<FileHistoryResult>;
exportHistory(scope: string): Promise<FileHistoryExport | null>;
/** The export projection as a SYNCHRONOUS function — the import's final claim needs to compare a
* target's identity without yielding (see the publish step). */
private exportOf;
importHistory(scope: string, data: FileHistoryExport, srcGetBlob: (hash: string) => Promise<Uint8Array | undefined>): Promise<FileHistoryResult>;
/** The import body inside the blob lease (see {@link importHistory}). */
private importStaged;
}
/** Test-only window (family-aware path-coordinate pins + the DV-6 track-key coordinate): pure
* helpers whose behavior carries a fence/restore invariant. Not re-exported from src/index.ts. */
export declare const __testables: {
joinUnder: typeof joinUnder;
relUnder: typeof relUnder;
stripTrailSep: (p: string) => string;
trackKeyOf: typeof trackKeyOf;
resolveTrackKey: typeof resolveTrackKey;
parentDirOf: typeof parentDirOf;
};
export {};