@sema-agent/core
Version:
Stateless, task-oriented AI agent core
94 lines (93 loc) • 6.42 kB
TypeScript
import type { Session, SessionCreateOptions, SessionForkOptions, SessionMetadata, SessionPlacementRecord, SessionRepo, SessionTreeEntry } from "../../internal/harness.js";
import type { PlacedSessionRow } from "../../core/session.js";
import { type CorruptReadInfo } from "./corrupt-read-sink.js";
export interface FileSessionRepoOptions {
/**
* Disclosure sink for a durable read this repo treats as ABSENT (ruled 2026-08-03). Same name and
* same `{path, reason}` shape as the sibling file stores' seat — "unreadable read as missing" and
* "corrupt read as missing" are one failure grammar, so they get one seat name.
*
* Fires for: an unreadable sessions DIRECTORY (the listing degrades to "no sessions"), a session log
* that could not be replayed during a listing (skipped from the result), and each corrupt interior JSONL
* record dropped on replay plus that replay's tally (turns missing from an otherwise healthy session).
*
* Never fires on plain ENOENT / `not_found` — that is honest absence, and the torn TAIL stays silent
* too (dying mid-write is the append log's documented crash-recovery shape, not corruption).
* Swallow-guarded: a throwing sink cannot re-introduce the fault the fail-open avoids.
*
* `sessionId` rides every disclosure about ONE session — its log's skipped records and tally, and its log
* skipped from a listing — naming the session the repo was reading (on a listing, the id its file name
* spells); a disclosure about the sessions directory itself carries none.
*/
onCorruptRead?: (info: CorruptReadInfo & {
sessionId?: string;
}) => void;
}
export declare class FileSessionRepo implements SessionRepo {
private readonly dir;
private readonly tmpDir;
private readonly onCorruptRead;
/** RB-437 — the shared authorities THIS repo has joined, weakly (canonical key → the joined storage).
* `dispose()` walks it and releases each holder share; the weak value is what makes the identity
* check on the way out possible without pinning a storage the table itself only holds weakly. */
private readonly joined;
constructor(root: string, opts?: FileSessionRepoOptions);
/** The one delivery point for {@link FileSessionRepoOptions.onCorruptRead}'s whole-file level; `sessionId` when the file is one session's log. */
private disclose;
private pathFor;
/**
* Read + torn-tail-recover a session file into (meta, entries). Three answers: no file → `not_found`; a
* file with no meta header → `corrupt_session`, even when every other record is intact (every write path
* here lays the header down first and the header alone carries `createdAt` / fork / placement, so the file
* is not a session this store wrote, or its header line is damaged); otherwise the session, each skipped
* record disclosed.
*/
private read;
private storage;
/**
* RB-437 — release every shared authority this repo joined (LAST holder ⇒ evict + close the append fd).
*
* This is the lifecycle-tied half of the eviction rule the four file-REPLACING operations already
* cover: after it, a NEW repo/backend over the same root replays each session from disk instead of
* serving this process's pre-dispose in-memory leaf, and a Session handle held across the teardown
* is refused with `code=log_closed` rather than writing onto a data root the process has released.
*
* Idempotent; safe to call on a repo that never opened anything. A repo whose authority was replaced
* meanwhile (delete/fork/import/create-over-absent) releases NOTHING for that key — the identity check
* means a stale holder can never revoke the rebuilt entry.
*/
dispose(): Promise<void>;
create(options?: SessionCreateOptions): Promise<Session>;
open(metadata: SessionMetadata): Promise<Session>;
list(): Promise<SessionMetadata[]>;
/**
* Subagent transcript persistence — the PLACED partition's own enumeration (the joint reap's
* partition-leg input; `TtlSessionStore` re-exposes it verbatim). Age (`olderThanMs`) is judged on
* the session FILE's mtime — the transcript's last append — falling back to `placedAt` when the
* stat fails (mtime is also the CC orphan-adoption window's basis, so the two age reads agree).
* Rows missing either `(scope, handle)` join half return the honest `tupleIncomplete` variant
* (consumers fail closed on it — never age-reaped, see the SessionStore contract).
*/
listPlaced(kind: "subagent", opts?: {
olderThanMs?: number;
scope?: string;
}): Promise<PlacedSessionRow[]>;
/** Cheap placement probe for one id (the TtlSessionStore's cold-cache release consults it so the
* placed real-deletion obligation holds after a restart). `undefined` = missing file OR unplaced. */
placementOf(sessionId: string): Promise<SessionPlacementRecord | undefined>;
delete(metadata: SessionMetadata): Promise<void>;
fork(sourceMetadata: SessionMetadata, options?: SessionForkOptions): Promise<Session>;
/** 2c session-sync (the FILE half): the FULL oldest-first log for export. The file backend
* has NO F3 floor — `read` returns EVERY persisted entry (`id`/`parentId` verbatim) — so this is the complete
* log, BYPASSing any bounded tail (matches `InMemorySessionRepo.exportEntries`). Missing file → not_found. */
exportEntries(sessionId: string): Promise<SessionTreeEntry[]>;
/** 2c session-sync: import a verbatim log into `sessionId`. The single fail-closed invariant gate runs
* FIRST ({@link validateEntriesForImport}); `owner` is a durable multi-tenant concept the local file backend
* has no field for (accepted, unused — same as `InMemorySessionRepo`). Writes the WHOLE file (meta + entries)
* atomically via tmp→fsync→rename — the rename is the commit point, so a crash leaves either nothing or an
* orphan temp, never a partially-imported session (mirrors `fork`). An existing session is REPLACED (the
* orchestrator's importSession owns the already-exists policy, same as InMemory's overwrite). */
importEntries(sessionId: string, owner: string | undefined, entries: SessionTreeEntry[], options?: {
preserveActorAssertions?: boolean;
}): Promise<void>;
}