@sema-agent/core
Version:
Stateless, task-oriented AI agent core
167 lines (166 loc) • 10.6 kB
TypeScript
/**
* design/183 (F-011) — `adoptLocalDataRoot`: the ROOT-level adoption state machine for the file
* backend. It generalizes the design/182 rule-bucket arc (exclusive lock + durable intent marker +
* monotone phase CAS + permanent terminal record + quarantine-not-delete) to the WHOLE data root:
*
* ① take the data-root locks (the engine's own boot lock + a dedicated ADOPTION-LOCK) — a live
* writer refuses the adoption loudly instead of interleaving with it
* ② land the durable root intent marker `adoption.json` → phase 2
* ③ identity-axis rebind legs (the ONLY stores whose data carries identity):
* - permission rules: the design/182 machine, NESTED as-is — never re-implemented. Its own
* sub-arc (including its first sync round) runs here; legs are independent, the root phase
* only orders "all legs of a stage complete before the stage advances"
* - session-policy rows: per-row ATOMIC REWRITE (the filename is a one-way hash of the
* composite key, so a rename cannot rebind; the `__principal` metadata must change too)
* → phase 3
* ④ resolution switch: publishing phase 4 is itself the switch (the marker is the truth host
* configuration follows, never the reverse), and the affected-deployment CONFIG ACCOUNT is
* landed — configs the machine cannot reach are honest `migrated: false` entries that stay
* outstanding until a consumer WITNESSES them → phase 4
* ⑤ carriage legs (data to the cloud): pluggable, idempotent, re-run whole on resume; rows the
* far side refuses land in the per-store QUARANTINE area — preserved and disclosed, never
* deleted, and never blocking the arc (a quarantined row is a COMPLETED disposition)
* → phase 5
* ⑥ the marker is atomically rewritten into its PERMANENT terminal record carrying the immutable
* report. Never deleted; a re-run short-circuits to it.
*
* FREEZE (design/183 §8 load-bearing premise): from the moment ② lands, the root is closed to new
* writers — and it is worth being exact about which mechanism covers what, because the two halves
* meet at a seam rather than overlapping (backlog #136⑧):
* · the LOCKS cover the boot-locked shape: this machine holds `LOCK` (the engine's own boot lock,
* taken by `FileStorageBackend`) and `ADOPTION-LOCK` for the whole arc, so a live backend refuses
* the adoption instead of interleaving with it, and a second adoption refuses too;
* · invariant I6 (`assertAdoptionBootGate`) covers CONSTRUCTION over a DATA-ROOT ANCHOR: while the
* marker is in flight, a store constructor handed the data root refuses it — including across a
* crash, when no lock survives.
* What NEITHER covers, enumerated rather than implied:
* · an ALREADY-constructed instance over this root through a path that does not hold `LOCK` (the
* independent store faces — `new FileMemoryStore(root)`, the mailbox family). It keeps its open
* handles, and I6 is a construction-time gate, not a write-time one;
* · a NEW construction whose anchor is not the data root. Two faces take a directory that need not
* BE the data root — `FileDurableRulePartitionProvider` (the rule bucket) and
* `createFileTaskListStore` (the document's own directory) — so each takes its root anchor as an
* explicit option, and a caller that omits it constructs unGATED during a freeze. That default is
* what this arc's own nested rule leg rides (it must reach the bucket while the marker is in
* flight); a deployment wanting the gate passes the anchor. Both arms are pinned.
* That is the same boundary the file family draws everywhere else (one live writer per data root is
* the deployment's contract, mechanically enforced only for the backend-factory shape), and it is why
* "stop the engine before adopting" remains an operational instruction and not merely a machine-
* checked one.
* Idempotent resume ("re-run completes the unfinished legs") is only sound because no per-store row
* moves underneath it; the freeze is the premise of idempotence, not an implementation detail.
*
* What this half deliberately does NOT do: bulk data carriage for the zero-identity stores (session,
* checkpoint, memory, …) — ownership of those rows rides with the carriage legs (server bulk-import
* endpoints / existing sync faces), which plug into ⑤ through {@link AdoptionCarriageLeg}. Core's
* file half ships the freeze face, the acceptance face and the rebind legs.
*/
import type { PermissionRuleSyncTransport } from "../../../core/permission-rule-sync.js";
import { type AdoptionReceipt, type AdoptionSource, type AffectedDeploymentConfig } from "./marker.js";
/** The per-leg context handed to a carriage leg: rows the far side REFUSES go here — preserved and
* disclosed, never deleted (design/183 §7.2). Quarantining never fails the leg. */
export interface AdoptionCarriageLegContext {
quarantine: (name: string, bytes: string) => void;
}
/**
* One pluggable carriage leg for stage ⑤ (design/183 §5). MUST be idempotent: a resume re-runs every
* carriage leg in full (a sync round is a join; an import is row-keyed) — only legs WITHOUT a
* self-attesting completion predicate record completion bits, and carriage legs all have one (the
* far side's row set). `run` resolves with the carried row count; a throw stalls the arc at the
* ④→⑤ boundary and the marker keeps the root frozen until a re-run completes the leg.
*/
export interface AdoptionCarriageLeg {
/** Store family name as it should appear in the report legs (e.g. "session", "memory"). */
store: string;
run: (ctx: AdoptionCarriageLegContext) => Promise<{
rows?: number;
} | void>;
}
export interface AdoptLocalDataRootOptions {
/** The data root (the directory `FileStorageBackend` and the independent file stores build over). */
root: string;
from: AdoptionSource;
toPrincipal: string;
/** The claim REFERENCE for the new identity's credentials (minted by the server; design/183 §4.3).
* A reference, never the credential bytes — the marker file is a plain-text sidecar. */
credentials: {
issuedBy: "server";
ref: string;
};
/** The permission-rule bucket leg (design/182, nested unchanged). Absent ⇒ the deployment has no
* file rule bucket and the leg is skipped. */
rules?: {
dir: string;
transport: PermissionRuleSyncTransport;
};
/** Stage-⑤ carriage legs (server bulk-import / existing sync faces). All must complete for the arc
* to reach its terminal record. */
carriage?: AdoptionCarriageLeg[];
/** Extra affected-deployment configs beyond the built-in minimum face (design/183 §6: the table may
* GROW, never shrink — an entry with the same (deployment, key) overrides the built-in values). */
additionalConfigs?: AffectedDeploymentConfig[];
now?: () => number;
onError?: (message: string) => void;
}
export type AdoptLocalDataRootResult = AdoptionReceipt
/** A leg failed. The marker holds the last COMPLETED phase and keeps the root frozen (I6); call
* again to resume from there. No bytes were lost — that is the state machine's whole contract. */
| {
status: "stalled";
phase: 2 | 3 | 4 | 5;
error: string;
};
/** A consumer's typed read-back receipt: proof it OBSERVED the required value at runtime, bound to
* this adoption. Only this clears an account entry — an operator ack is recorded, never clearing. */
export interface AdoptionConfigWitnessReceipt {
adoptionId: string;
deployment: string;
key: string;
observedValue: string;
atMs: number;
}
/** Record an operator acknowledgement on one account entry. Recorded, NEVER clearing: the account's
* discriminating power comes from consumer witness, not from a hand-written boolean (design/183 §3.3). */
export declare function ackAdoptionConfig(root: string, deployment: string, key: string, now?: () => number): void;
/**
* Clear one config-account entry with a consumer's bound runtime read-back receipt. The receipt must
* name THIS adoption and carry the entry's exact required value — anything else is refused (typed):
* a mis-bound witness would clear the account while the deployment is still locked out.
*/
export declare function witnessAdoptionConfig(root: string, receipt: AdoptionConfigWitnessReceipt): void;
/** Enumerate the quarantine area (introspection face — a quarantined row is DISCLOSED, not buried). */
export declare function listAdoptionQuarantine(root: string): Array<{
store: string;
name: string;
path: string;
}>;
/** One root's adoption posture, readable WITHOUT the adoption parameters (the standing
* introspection face design/183 §3.3 requires: while the config account is uncleared, the
* outstanding list stays loudly readable — a standing answer, not a one-shot log line). */
export type AdoptionStatus = {
state: "none";
} | {
state: "in-flight";
from: AdoptionSource;
toPrincipal: string;
phase: 2 | 3 | 4 | 5;
} | {
state: "adopted";
receipt: AdoptionReceipt;
};
/** Read the root's adoption posture: never throws on absence, throws (typed) on a corrupt marker. */
export declare function readAdoptionStatus(root: string): AdoptionStatus;
/**
* Run (or resume) the root-level adoption state machine over the data root (design/183 §3).
*
* Contract notes for the calling deployment:
* - stop the engine first: this machine takes the SAME boot lock (`root/LOCK`) as
* `FileStorageBackend`, so a live instance refuses the adoption loudly rather than interleaving —
* plus the dedicated `ADOPTION-LOCK` against a concurrent adoption of independently-constructed
* stores. Across crashes the in-flight marker + the I6 boot gate carry the same exclusion;
* - idempotent: a re-run with the same (from → toPrincipal) answers the recorded receipt without
* re-running any leg; a re-run toward a DIFFERENT principal is refused (typed);
* - quarantined rows never block the arc, and post-terminal repair rides each store's NORMAL
* channel (sync round / import generation — design/183 §7.2), never a re-opened adoption.
*/
export declare function adoptLocalDataRoot(opts: AdoptLocalDataRootOptions): Promise<AdoptLocalDataRootResult>;