/** * Subject builders for the v0.4 clean subject scheme. * * Canonical shape: * * agents.... * * Every verb is session-scoped: a single 5-token subject keyed by * machine/project/session. Identity (role/class/agent) lives in NATS micro * service metadata, never in the subject. * * MUST stay byte-identical with sesh `internal/subject/` Go package. * Verify with the golden-file test (see test/subjects.test.ts) and reconcile * with the Go side at every PR touching this file. * * Convention: lowercase tokens, dot-separated, no trailing dot, no wildcards. * Callers MUST pre-sanitize input tokens — these builders do NOT escape. * Use validateToken() if validation is desired (rejects '.', '*', '>', space). */ import { sanitizeSubjectToken } from "./sanitize.ts"; export interface Coord { machine: string; project: string; session: string; } const RESERVED = /[.\s*>]/; export class InvalidSubjectTokenError extends Error { constructor(public token: string, public reason: string) { super(`invalid subject token ${JSON.stringify(token)}: ${reason}`); this.name = "InvalidSubjectTokenError"; } } /** Throws InvalidSubjectTokenError if token contains '.', whitespace, '*', '>', or is empty. */ export function validateToken(token: string): void { if (!token) throw new InvalidSubjectTokenError(token, "empty"); if (RESERVED.test(token)) { throw new InvalidSubjectTokenError(token, "contains reserved character (. whitespace * >)"); } } /** * Validates the (machine, project, session) base of a coord. */ function validateBase(c: Coord): void { validateToken(c.machine); validateToken(c.project); validateToken(c.session); } /** A 40-character lowercase-hex `SESH_PROJECT_ID` pin, e.g. a git-style SHA-1. */ const PROJECT_ID_PIN = /^[0-9a-f]{40}$/; /** * Thrown when `SESH_PROJECT_ID` is set but is NOT a 40-char lowercase-hex pin. * * A malformed pin is fatal on purpose: silently falling back to the slug on a * present-but-wrong pin is the exact misdelivery hazard this resolver exists to * kill (issue #65). Two same-basename projects with a corrupted pin would * collapse onto the same 5-token subject and split a queue group across * unrelated work. Fail loud at boot instead. */ export class InvalidProjectIdError extends Error { constructor(public value: string) { super( `SESH_PROJECT_ID is set to ${JSON.stringify(value)} but must be 40 ` + `lowercase hex characters ([0-9a-f]{40}) — refusing to fall back to ` + `the SESH_PROJECT slug on a malformed pin`, ); this.name = "InvalidProjectIdError"; } } /** * Resolve the canonical `` subject token (issue #65 / Train 4). * * Single source of truth for the project segment of every 5-token subject * (`agents....`) across all TS adapters. * * Precedence: * 1. `pin` (`SESH_PROJECT_ID`) — when present and non-empty, it is the * canonical token. The Go side (sesh ≥ v0.6.0) already pins this; the * slug (`SESH_PROJECT`) is demoted to display metadata. A present pin is * validated as 40-char lowercase hex and used VERBATIM (already a valid * subject token — no sanitization needed). A present-but-malformed pin * throws `InvalidProjectIdError` (loud, never a silent slug fallback). * 2. `slug` (`SESH_PROJECT`, or an adapter-specific fallback source such as * `basename(cwd)`) — sanitized via the canonical subject-token sanitizer * when the pin is absent. Adapters spawned outside `sesh up` keep working. * 3. `fallback` — last-resort literal when the slug sanitizes to empty * (e.g. `"unknown-project"`, `"default"`). Adapter-chosen. * * `sesh up` injects both `SESH_PROJECT_ID` and `SESH_PROJECT`, so any * sesh-spawned agent always takes branch (1) and lands on the pin. */ export function resolveProjectToken(opts: { pin?: string; slug?: string; fallback: string; }): string { const pin = (opts.pin ?? "").trim(); if (pin.length > 0) { if (!PROJECT_ID_PIN.test(pin)) throw new InvalidProjectIdError(pin); return pin; } return sanitizeSubjectToken(opts.slug ?? "") || opts.fallback; } /** * agents.prompt... * * Prompt — always 5-token (session-scoped), same shape as hb/status/card/cardx. * * Adapters subscribe with the PROMPT_QUEUE_GROUP so the shim's SendMessage to a * session fronted by N adapter replicas is load-balanced across them. */ export function prompt(c: Coord): string { validateBase(c); return `agents.prompt.${c.machine}.${c.project}.${c.session}`; } /** * agents.hb... * * Heartbeat — always 5-token (session-orch addressable). */ export function heartbeat(c: Coord): string { validateBase(c); return `agents.hb.${c.machine}.${c.project}.${c.session}`; } /** * agents.status... * * Status — always 5-token (session-orch addressable). */ export function status(c: Coord): string { validateBase(c); return `agents.status.${c.machine}.${c.project}.${c.session}`; } /** * agents.card... * * Public AgentCard fetch — always 5-token. * * Single-segment verb `card` (not `card.get`) so the session-scoped addressing * stays consistent with `prompt`/`hb`/`status`. Subject-based authz * separates this from the auth-gated `cardx` at the account level. */ export function card(c: Coord): string { validateBase(c); return `agents.card.${c.machine}.${c.project}.${c.session}`; } /** * agents.cardx... * * Auth-gated AgentCard fetch (extended). Always 5-token. * * Distinct single-segment verb `cardx` (not `card.extended`) so NATS * subject-based authz can enforce the auth boundary at the account level * rather than inside each adapter. */ export function cardx(c: Coord): string { validateBase(c); return `agents.cardx.${c.machine}.${c.project}.${c.session}`; } /** * agents.task.stream... * * Per-task stream subject. Carries A2A StreamResponse union events (task, * message, statusUpdate, artifactUpdate). UNCHANGED by the clean-subject cutover. */ export function stream(args: { scopeKind: string; scopeId: string; taskId: string }): string { validateToken(args.scopeKind); validateToken(args.scopeId); validateToken(args.taskId); return `agents.task.stream.${args.scopeKind}.${args.scopeId}.${args.taskId}`; } /** * NATS micro queue group for the single 5-token prompt subject * (`agents.prompt...`). Adapter replicas fronting the * same session subscribe under this queue group so the shim's SendMessage is * load-balanced across them instead of fanning out to every replica. * MUST match the value used on the publisher side (sesh shim). * * The literal value remains `agents-prompt-v2` for wire compatibility while * the Go side completes the parallel rename — DO NOT change without coordination. */ export const PROMPT_QUEUE_GROUP = "agents-prompt-v2" as const;