@sema-agent/core
Version:
Stateless, task-oriented AI agent core
107 lines (106 loc) • 7.67 kB
TypeScript
/**
* design/381 §片2 — the engine's OWN bound on file-history growth, and the door its one knob passes
* through.
*
* The hole this closes: `FileHistoryStore.reap` is a complete, contract-tested GC verb that nothing
* in the engine ever called. Every turn of a wired session commits one more boundary, each boundary
* is a complete mapping over the tracked set, and no seat anywhere dropped the old ones — so a long
* session's history grew without bound, silently, which is precisely the shape the bad-value
* loudness rule exists to forbid (a limit nobody states is still a limit; it is just the disk's).
* The bundled backends therefore carry the bound THEMSELVES: each one commits a boundary and then
* trims its scope back to the retention bound, and a host that wants the GC schedule for itself
* says so (`keep: "unbounded"`) rather than discovering the default by running out of disk.
*
* PROVENANCE OF THE DEFAULT (not a taste call, and not a JSDoc-only constraint): the reference
* implementation caps its own file-history at the newest 100 snapshots and evicts the rest — CC
* 2.1.250 holds the cap as a module constant (`R1e = 100`) and applies it in the snapshot reducer
* (`if (E.length > R1e) R = E.slice(-R1e), <evict>(E.slice(0, E.length - R1e), R, …).catch(…)`),
* i.e. keep the newest N by commit order, hand the evicted ones to a best-effort eviction pass, and
* never let that pass fail the turn that triggered it. Its eviction unlinks a backup only when no
* surviving snapshot references it AND its version is not 1 — the same v1-permanence law this
* store's `reap` already implements. design/381 §片2 carries the number over as "边界上限(默认
* 100,store 旋钮)", which is the shape below: engine default, store-level, host-overridable.
*
* @see {@link import("./retention-policy.js").assertRetentionPolicy} — the sibling row stores' door.
* This one is separate because its admissible range is different (see {@link refuseRetentionPolicy}).
*/
export declare const DEFAULT_FILE_HISTORY_BOUNDARY_KEEP = 100;
/**
* The file-history retention knob, as a deployment writes it. ONE knob on purpose — the graph has
* exactly one unbounded dimension (boundaries per scope); version records and blobs are already
* bounded by it, because `reap` drops every version no surviving boundary references (v1 and the
* base-unavailable markers excepted, which are per tracked file, not per turn).
*
* Absent ⇒ {@link DEFAULT_FILE_HISTORY_BOUNDARY_KEEP}. Every other reading of a badly-formed value
* is a REFUSAL at construction, never a quiet fallback — see {@link resolveFileHistoryRetention}.
*/
export interface FileHistoryRetentionPolicy {
/**
* How many of a scope's NEWEST boundaries survive each retention pass. A whole number `>= 1`, or
* the literal `"unbounded"` to switch the store's self-trim OFF and take the GC schedule over
* (the host then owns calling {@link import("./file-history-store.js").FileHistoryStore.reap};
* nothing else collects boundaries).
*
* `0` is REFUSED rather than accepted as "keep nothing": a boundary is minted by the very commit
* that would then reap it, so a store configured that way would answer `ok` to every `snapshot`
* and `false` to every `canRestore` — a rewind seat that is on, reports success, and can never
* restore anything. `"unbounded"` is the ONE spelling of "do not trim"; omitting the knob is NOT
* that spelling — it selects the engine default and therefore trims (cross-review round 3 caught
* this said the opposite here and in the refusal text, which would have sent an operator who
* wanted everything kept down the path that deletes).
*/
keep?: number | "unbounded";
}
/**
* The retention door: resolve a deployment's policy to a boundary cap, or REFUSE loudly.
*
* Returns the cap (a whole number `>= 1`), or `null` for the explicit `"unbounded"` opt-out. Called
* at CONSTRUCTION by every bundled backend, so a malformed policy fails where the operator wired it
* rather than mid-session inside a GC pass they cannot see.
*
* The three ways a value can be bad each get their own named exit — none of them falls back to the
* default, because a retention knob that quietly became a different number is the failure this whole
* seam exists to remove:
* 1. GARBAGE — the policy is not a knob bag, a knob is misspelled, or `keep` is not a whole number
* (NaN from a `Number(cfg.keep)` on a missing field, `Infinity`, `2.5`, `"100"`, `null`).
* 2. OUT OF RANGE — a negative `keep`. (Reached by the same route as NaN in the sibling stores'
* history: `slice(Math.max(0, keep))` turns both into `slice(0)` = delete everything.)
* 3. CONTRADICTORY — `keep: 0`, well-formed and in the range its siblings accept, but self-cancelling
* here; refused with the ONE spelling of "do not trim" named, and with what omission really does
* said out loud so the remediation cannot be followed into the opposite outcome.
*
* @param label the seam the operator configured, so the refusal names it.
*/
export declare function resolveFileHistoryRetention(label: string, policy: FileHistoryRetentionPolicy | undefined): number | null;
/**
* Which boundaries survive the pass that `justCommitted`'s commit triggered — the SELECTION half,
* shared so the two bundled backends (and an out-of-tree twin) cannot drift on which boundary dies.
*
* Returns `null` when the scope is still within its bound, which is the answer the caller wants
* cheaply: no `reap` call at all, so a session under the cap pays one directory listing per turn and
* nothing else. Otherwise returns the keep set for
* {@link import("./file-history-store.js").FileHistoryStore.reap} — the newest `keep` ids, with
* `justCommitted` PINNED into it unconditionally.
*
* The pin is not belt-and-braces. `orderedOldestFirst` is whatever commit order its backend can
* PROVE, and the two bundled ones prove different amounts: the in-memory reference reads an
* insertion-ordered map (exact), while the durable one reads each boundary's own publication stamp,
* falling back to file mtime for records written before that field or rebuilt from an import, and
* to the entry id for ties. A clock stepped backwards, or a host minting non-monotonic ids of its
* own, can still invert two neighbours there.
* An ordering that is off by one place costs a different-but-equally-legal retention choice; an
* ordering that put the JUST-COMMITTED boundary at the bottom would cost this turn its rewind seat.
* Pinning makes the second outcome unreachable by construction rather than by trusting the clock.
*
* One more place the order is only as good as its source, said plainly rather than assumed away: an
* envelope round trip (`exportHistory` → `importHistory`) writes boundaries in CANONICAL — i.e.
* lexicographic — entry-id order, so a scope rebuilt from one has entry-id order where it used to
* have commit order. For engine-minted uuidv7 ids those are the same order; for a foreign graph's
* ids they need not be, and such a scope's oldest-first reading is by id. The consequence is again
* only WHICH legal boundary is retained, and the just-committed pin still holds.
*
* @param orderedOldestFirst every committed boundary id in the scope, oldest first.
* @param justCommitted the boundary whose commit triggered the pass.
* @param keep the resolved cap (`>= 1`).
*/
export declare function fileHistoryBoundariesToKeep(orderedOldestFirst: readonly string[], justCommitted: string, keep: number): string[] | null;