@sema-agent/core
Version:
Stateless, task-oriented AI agent core
109 lines (108 loc) • 8.08 kB
TypeScript
import type { ToolResultDeletionReport, ToolResultProvenance, ToolResultSlice, ToolResultStore } from "../../core/tool-result-store.js";
import { type CorruptReadInfo } from "./corrupt-read-sink.js";
/**
* design/80 §2.5 — file {@link ToolResultStore}: write-once-idempotent, ONE file per ref.
*
* `ref = tr_<sessionId>~<toolCallId>` (engine-minted, already globally unique; the separator is the
* backlog #119 injective form). `put` is an `O_CREAT|O_EXCL` create → on `EEXIST` it no-ops
* (write-once-idempotent; never re-writes — a re-write risks a non-identical preview and breaks the
* prompt cache, §2.5 / the in-memory store's `map.has(ref)` check). Content is fsync'd before return.
* `get` slices the file by `{offset, limit}` over CHARS to match `ToolResultSlice.totalChars` semantics;
* an unknown ref → `undefined` (the model is told it's gone — degrades, never crashes).
* `withToolResultOffload` joins text with `\n` and stores the joined string verbatim, so the char-offset
* slice here is exactly the in-memory store's slice.
*
* Provenance (#119) lives in a SIDECAR file next to the content one (`<encoded>.owner.json`), not in a
* header inside it: the content file's bytes are the stored result verbatim, which is what makes the
* char slice identical to every other backend's and what keeps files written by older versions readable
* with no format migration.
*
* That is TWO published objects, so the window is stated rather than hidden (falsification review): the
* content link is the write-once election and the sidecar follows it, so a crash in between leaves an
* entry content-complete and momentarily UNOWNED. Publishing the sidecar FIRST would close that window
* and open a worse one — an ownerless writer's row could then be claimed by whoever raced it — so the
* order stands and the entry stays unowned: fail-closed, refused by a host read face, never
* mis-attributed, and never adopted by a later write (adoption is an adjudicated contract rule, not a
* local choice — see the `put` body). What a `put` no longer does is walk over DAMAGE: a sidecar that
* is present but unreadable stops the write loudly, where treating it as "no owner" would report
* success and leave the entry unclaimable forever.
*/
export interface FileToolResultStoreOptions {
/** Disclosure seat for an owner sidecar that is PRESENT but unreadable: {@link
* FileToolResultStore.ownerOf} answers `undefined` — the same word it uses for an entry nobody owns,
* so a host that authorizes on it reads damage as "unowned". Swallow-guarded. */
onCorruptRead?: (info: CorruptReadInfo) => void;
}
export declare class FileToolResultStore implements ToolResultStore {
private readonly dir;
private readonly discloseCorrupt;
constructor(root: string, opts?: FileToolResultStoreOptions);
/** The one delivery point for {@link FileToolResultStoreOptions.onCorruptRead}. */
private disclose;
/** The directory a ref's files live in, and their shared stem. See {@link encodeRefFilename} for why
* a FOLDED name gets its own directory instead of sharing one with identity names. */
private locate;
private pathFor;
/** #119 — the provenance sidecar's path. Same directory and stem as {@link pathFor}, so the pair is
* found by one encoding step and a ref that cannot be encoded has no sidecar either. */
private ownerPathFor;
put(ref: string, content: string, provenance?: ToolResultProvenance): void;
/** #119 — the recorded owner, or `undefined` for an unknown ref AND for a stored-but-unowned one.
* Lenient by design on the READ face (see {@link readOwnerFile}). */
ownerOf(ref: string): ToolResultProvenance | undefined;
/**
* #119 — read the owner sidecar. `loud` splits the two faces on purpose, the same split this backend
* already applies to ref safety: a READ (`ownerOf`, an authorization question) degrades an
* unreadable or malformed sidecar to "unowned" so a corrupt file cannot crash a page-back, while a
* WRITE refuses to proceed over one it cannot read — silently treating damage as "no owner" is how a
* write reports success and leaves the entry unclaimable forever (falsification review round 4).
* A MISSING sidecar is not damage in either face: it is the honest "unowned".
*/
private readOwnerFile;
/** #119 — publish the owner sidecar (write-once, like the content file it accompanies). */
private writeOwnerFile;
get(ref: string, opts?: {
offset?: number;
limit?: number;
}): ToolResultSlice | undefined;
/**
* Erase every entry owned by `sessionId` (interface: {@link ToolResultStore.deleteBySession}). Without
* it a session deletion leaves this store's files on disk for the lifetime of the data root.
*
* Selection matches on the SIDECAR's `sessionId`, never on the filename. That is not a preference, it
* is the only thing available: the mint separates segments with `~`, which is outside this backend's
* filename charset, so every engine-minted ref folds to `<base>-<sha256 of the raw ref>` and the
* session segment is gone from the name (and the readable base is a lossy, many-to-one prefix, so
* matching on it would reach a neighbouring session's files). `taskId`, where an older write site
* stamped one, is ignored — it narrows the same session.
*
* Both namespaces are swept: identity-named files in the store dir (refs a caller minted inside the
* filename charset, and everything written before the injective mint) and {@link FOLDED_SUBDIR}.
*
* Removal order is CONTENT then SIDECAR, and it is load-bearing. Interrupted after the content unlink,
* the next sweep still finds the sidecar, matches the same session and converges; interrupted after a
* sidecar-first unlink, the content file would be left owner-less — readable, attributable to nobody,
* and beyond the reach of every future session sweep. Same reasoning as `put`'s publication order, in
* the opposite direction. The pair is then re-examined ONCE after both unlinks, which is what keeps
* a `put` racing the two from leaving bytes whose owner this sweep removed — and what keeps that
* re-examination from deleting a row a DIFFERENT session claimed in the same window (see the body).
*
* What the re-examination is NOT is atomic: it reads the owner and then acts, so a writer landing
* inside THAT gap is narrowed, not excluded. Stated rather than locked away, because the locking that
* would close it is ruled out one layer down — `fs-atomic.ts` §2.4 admits exactly one file lock, the
* boot lock, on the premise that a data root has a single writing process (`FileStorageBackend`
* acquires it before constructing this store; cross-process correctness is a database backend's job,
* as everywhere else in this family). Under that premise there is no gap at all: both `put` and this
* sweep are wholly synchronous, so nothing can interleave with either. The narrowing above is what
* remains worth doing for a root someone shares anyway, and it costs one `readFileSync`.
*
* Failure posture: an entry that vanished between `readdir` and the unlink is honest absence (ENOENT
* ok, the concurrent sweeper's row). Any OTHER unlink error throws — a deletion face that cannot
* delete has to be loud — and because the sweep is idempotent, the retry after the operator clears
* the cause resumes where it stopped. A sidecar that cannot be READ (EACCES, torn JSON, a shape that
* is not the whole record) is counted `unattributable` and its content is left alone: a file we could
* not read carries no evidence about whose it is, and this store's rows stay readable through `get`
* even with a damaged owner record, so removing one on a guess would destroy another session's output.
*/
deleteBySession(sessionId: string): ToolResultDeletionReport;
}