@sema-agent/core
Version:
Stateless, task-oriented AI agent core
243 lines (242 loc) • 14.9 kB
TypeScript
/** The row schema version this engine writes and fully understands. A row carrying a HIGHER version
* is a newer engine's row: it is skipped as `schema_newer` (capability negotiation — the row is not
* garbage, this engine simply cannot judge it), never treated as a malformed value. */
export declare const PEER_SESSION_RECORD_SCHEMA_VERSION = 1;
/** CC `Fa(s, 262144)` — the read cap of one registration file. */
export declare const PEER_SESSION_RECORD_MAX_BYTES = 262144;
export type PeerSessionLiveness = "live" | "dead" | "deleted";
export type PeerSessionTempo = "active" | "idle" | "blocked";
/** A session's SELF-REPORTED presence: what it says it is (`agent`) and what it says it is doing
* (`intent`). Labels a peer controls — rendered bounded and prefixed `self:`, never read by any
* permission, routing or wake judgment. The same object rides `TaskSpec.peerPresence`, the wiring
* manifest and the directory row a host copies it into. */
export interface PeerPresence {
agent?: string;
intent?: string;
}
/** The read cap of one presence label; a longer value refuses the whole row (one rule, `field_invalid`). */
export declare const PEER_PRESENCE_MAX_CHARS = 120;
/** THE rule for one presence label, read by the row reader and by prepare's door on `TaskSpec.peerPresence` (a
* spec the directory would refuse is refused where it is written, not when the host files the row). */
export declare function peerPresenceLabelValid(v: unknown): v is string | undefined;
/** Prepare's door: `TaskSpec.peerPresence` is a plain object whose `agent` / `intent` each pass {@link peerPresenceLabelValid}
* — else `config.peer_presence_invalid`, naming the field. */
export declare function assertPeerPresenceValue(v: unknown): void;
/** The registration row. Field set = CC's load-bearing subset + sema's `scope` axis + the sema-only
* liveness/tombstone members. A host writes it; every terminal reads it through {@link readPeerSessionRecord}. */
export interface PeerSessionRecord {
schemaVersion: number;
/** The session's durable identity: its box address is `session.<sessionId>` ({@link peerSessionBoxHandle}). */
sessionId: string;
/** sema axis — the principal scope the row belongs to. A reader filters by the ACCESSOR's scope
* before anything else (the mechanical seat of the cross-principal wall). */
scope: string;
/** Display name (raw). Matching is on {@link normalizePeerName} of it, never on the raw string. */
name: string;
nameSource?: "auto" | "flag" | "rename";
formerNames?: readonly string[];
pid?: number;
/** Process start time (ms) — paired with `pid` against PID reuse. */
procStartMs?: number;
cwd?: string;
startedAt: number;
/** When the row last changed (the host's `lastActivityAt` on a server row; `markDead` writes it too). */
updatedAt: number;
/**
* @contract peer_directory.dead_is_the_hosts_word — `dead` is the HOST's word: a host marks a row dead
* when the session has no leg in flight AND `updatedAt` is older than the host's retention period, and
* stamps `diedAt` with the marking time; that retention period is at least the engine's listing
* freshness window (`listingFreshWindowMs`), so a row the engine would list as fresh is never dead.
* The engine reads `liveness` and `updatedAt`; it never rewrites `liveness` from a row's age.
*/
liveness: PeerSessionLiveness;
/** Present iff `liveness !== "live"`: when the row was marked dead (or the tombstone was written). */
diedAt?: number;
/** The host's activity word. A stale `tempo` is the host's to clear: a row that says `active` is
* listed as fresh regardless of its age. */
tempo?: PeerSessionTempo;
/** Self-reported presence (see {@link PeerPresence}); a host copies it off the session's spec. */
agent?: string;
intent?: string;
entrypoint?: string;
peerProtocol?: number;
/** CC capability bits (`/^[a-z0-9_]{1,32}$/`, ≤16); `sema_mailbox_v1` marks a store-lane peer. */
peerFeatures?: readonly string[];
sockPath?: string;
/** CC `cross_session_inbound: available|unavailable` — the session's SELF-REPORTED inbound posture.
* `"unavailable"` lets a sender refuse synchronously (degraded, best-effort); absent/stale means the
* drain point decides. */
inboundPosture?: "available" | "unavailable";
}
/** The box-address prefix of a session box (design/385 §2.2 — the dot form: `:` is outside the file
* backend's path charset, `.` is inside it and outside the a* handle grammar). */
export declare const SESSION_BOX_PREFIX = "session.";
/** The grammar, spelled for a refusal message. */
export declare const PEER_SESSION_ID_GRAMMAR = "^[A-Za-z0-9_-]{1,80}$";
/** Is this session id addressable on the peer lane (spellable as a `session.<id>` box handle)? The
* lane mount asks this BEFORE binding a drain: a host-minted id outside the grammar refuses the lane
* by name instead of throwing out of {@link peerSessionBoxHandle} mid-prepare. */
export declare function isPeerSessionId(sessionId: string): boolean;
/** The mailbox handle of a session's own box. Folded to lower case: both bundled backends key boxes
* case-insensitively, and the address form is spelled lower-case. */
export declare function peerSessionBoxHandle(sessionId: string): string;
/** Is `text` spelled as an explicit session address? Returns the (lower-cased) session id, or
* `undefined` when the text is not an address at all. An address is NEVER a name: the resolution
* ladder ranks this arm beside the precise a* id, ahead of every name rung. */
export declare function parsePeerSessionAddress(text: string): string | undefined;
export type PeerSessionRecordRefusal = "not_an_object" | "schema_missing" | "schema_newer" | "session_id_invalid" | "scope_invalid" | "name_invalid" | "liveness_invalid" | "field_invalid";
export type PeerSessionRecordRead = {
ok: true;
record: PeerSessionRecord;
} | {
ok: false;
reason: PeerSessionRecordRefusal;
field?: string;
};
/**
* Validate one raw row (parsed JSON, a store row, a wire object) into a typed record — a DETACHED copy
* (only the declared members are carried; unknown keys are dropped, a newer schema is refused as
* `schema_newer`, a malformed member names its field). A reader SKIPS a refused row loudly (the host
* logs the reason) and never adopts a partial one — a half-typed row would let the resolver judge on
* fabricated liveness.
*/
export declare function readPeerSessionRecord(raw: unknown): PeerSessionRecordRead;
/** What a liveness judgment needs from the OS — injectable so a test never has to spawn a process. */
export interface PeerLivenessProbe {
/** Does a process with this pid exist (signal 0 semantics)? */
processExists(pid: number): boolean;
/** The process's start time in ms, or `undefined` when the platform cannot say. */
processStartMs?(pid: number): number | undefined;
}
/** The bundled probe: `process.kill(pid, 0)` for existence (EPERM counts as existing — a process this
* uid may not signal is still a process), no start-time source (the host adapter supplies a platform
* one; without it the start-time arm is skipped, which is the honest weaker judgment). */
export declare const defaultPeerLivenessProbe: PeerLivenessProbe;
/**
* Is the row's process alive? `false` when the pid is gone, OR when the probe knows a start time and it
* disagrees with the row's (PID reuse — a different process wears the number). A row with no pid has
* no process to probe and is judged by its declared liveness alone.
*/
export declare function isPeerSessionProcessAlive(record: Pick<PeerSessionRecord, "pid" | "procStartMs" | "liveness">, probe?: PeerLivenessProbe): boolean;
export interface PeerDirectoryAccess {
/** The accessor's scope — rows of any other scope are never returned (§10 wall). */
scope: string;
}
/**
* The discovery contract (RunnerDeps `peerDirectory`; no seat = the cross-session lane is absent and
* every face of it stays byte-identical to a pre-385 build). ONE read face: every row the accessor may
* see — live, dead AND deleted — because the resolution set is the FULL set (a ref minted over the
* visible-only list would not be unique against a same-named dead row). The engine resolves over the
* returned rows with {@link resolvePeerSessions}; a host that can resolve server-side may still hand
* back the full list (the engine's ref minting needs it). Rows a host cannot vouch for (foreign owner,
* group/world-writable file, newer schema) are SKIPPED by the host's reader, never returned partially.
*/
export interface PeerDirectory {
listPeerSessions(access: PeerDirectoryAccess): readonly PeerSessionRecord[] | Promise<readonly PeerSessionRecord[]>;
/**
* design/385 §2.1 (the sweep race, send-side backstop) — restore a row ONLY IF ABSENT (CAS-on-absent:
* a live row a concurrent registration wrote is never overwritten). The sweep predicate (box empty ⇒
* remove the dead row) is not transactional with `append`: a sender can resolve a dead row, the sweep
* can remove it, the append then lands in a box no row addresses. The message is never lost (the
* recipient drains its box by its OWN session id, and re-registers its row on its next start); what
* breaks is NAME addressing until then — and this call closes that window: SendMessage re-reads the
* directory after every append and, when the target row is gone, restores the row it resolved.
* Optional: a directory that cannot write (a server session table the engine only reads) omits it,
* and the residual window is disclosed in the receipt instead.
*/
restorePeerSession?(record: PeerSessionRecord): boolean | Promise<boolean>;
}
export interface PeerSessionCandidate {
record: PeerSessionRecord;
/** The listing-minted ref (6..12 hex) of this row — stable across the live/dead filter because it is
* minted over the FULL set. */
ref: string;
}
export type PeerSessionResolution = {
status: "found";
candidate: PeerSessionCandidate;
rung: "address" | "name" | "ref";
} | {
status: "deleted";
candidate: PeerSessionCandidate;
} | {
status: "self";
} | {
status: "ambiguous";
candidates: readonly PeerSessionCandidate[];
message: string;
}
/** A ref was supplied but matches no row of the current set (peer set moved on) — re-list. */
| {
status: "stale_ref";
ref: string;
} | {
status: "not_found";
};
/** Mint refs for every row of the directory's full set (self participates in disambiguation). */
export declare function mintPeerSessionCandidates(records: readonly PeerSessionRecord[], self?: {
sessionId: string;
}): PeerSessionCandidate[];
/**
* Resolve `to` against the directory's full set. Order (design/385 §2.2): an explicit `session.<id>`
* ADDRESS resolves directly (only to a KNOWN row — the phantom-box defense: an address nobody
* registered is `not_found`, never an append target); otherwise `name [ref]` — a ref must match its
* name's row exactly (a ref that resolves nowhere is `stale_ref`: "re-run ListAgents"); a bare name
* that matches ONE row (live or dead) resolves, several ⇒ `ambiguous` with every candidate's ref (the
* dead ones marked offline — CC's second ref source, the disambiguation error). A `deleted` row is
* returned as `deleted` on every rung so the caller refuses with `invalid_target`; the caller's own
* session is `self`.
*/
export declare function resolvePeerSessions(records: readonly PeerSessionRecord[], to: string, self?: {
sessionId: string;
}): PeerSessionResolution;
export interface InMemoryPeerDirectory extends PeerDirectory {
/** Write or replace a row (keyed by scope + sessionId). A name that spells an address is refused by
* the caller's own `reservedPeerNameReason` check — this store trusts its writer. */
upsert(record: PeerSessionRecord): void;
/** §2.1 dead-row posture: mark, keep (the name stays resolvable for offline delivery). */
markDead(scope: string, sessionId: string, diedAt?: number): boolean;
/** §1.2⑤ tombstone: the deletion cascade flips the row BEFORE dropping the box. */
markDeleted(scope: string, sessionId: string, at?: number): boolean;
/** §2.1 sweep predicate: a non-live row is removable ONLY while its box is EMPTY (the box-empty
* predicate is the caller's — it reads the mailbox). Returns the swept session ids. */
sweep(scope: string, boxEmpty: (sessionId: string) => boolean | Promise<boolean>): Promise<string[]>;
restorePeerSession(record: PeerSessionRecord): boolean;
}
export declare function createInMemoryPeerDirectory(): InMemoryPeerDirectory;
/** The closed refusal set of the registry-directory ancestor-chain vetting. Each ancestor from the
* directory up to the filesystem root must be a real directory (no symlink on the chain — a shared
* /tmp lets another uid pre-plant one), owned by this uid, and not group/world-writable. */
export type PeerRegistryDirectoryRefusal = "not_absolute" | "missing" | "symlink" | "not_directory" | "foreign_owner" | "group_or_world_writable";
export type PeerRegistryDirectoryVerdict = {
ok: true;
} | {
ok: false;
code: PeerRegistryDirectoryRefusal;
path: string;
};
/** Vet a registry directory and every ancestor. `uid` defaults to the process uid; on a platform with
* no uid (win32) the owner arm is skipped. Refuses on the FIRST failing ancestor (closest first).
* `platform` (default: this process's) decides whether the permission bits are judged at all
* ({@link posixModeBitsMeaningful}): where they are not meaningful the group/world-writable arm is skipped,
* and nothing in this verdict says so — the caller that reads it owns any disclosure. */
export declare function vetPeerRegistryDirectory(dir: string, opts?: {
uid?: number;
stopAt?: string;
platform?: NodeJS.Platform;
}): Promise<PeerRegistryDirectoryVerdict>;
/** §2.1 ③ — the read-side judgment on ONE registration file's stat facts (pure; the reader supplies
* the stat): owner must be this uid, mode must carry no group/world write bit, and it must be a
* regular file under the read cap. A refused file is skipped LOUDLY by the reader. The mode arm is judged
* only where `platform` (default: this process's) has permission bits to judge ({@link posixModeBitsMeaningful}). */
export declare function judgePeerRecordFile(stat: {
uid?: number;
mode: number;
isFile: boolean;
size: number;
}, selfUid: number | undefined, platform?: NodeJS.Platform): {
ok: true;
} | {
ok: false;
code: "not_regular" | "foreign_owner" | "group_or_world_writable" | "too_large";
};