@cleocode/runtime 2026.6.12 → 2026.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,83 @@ function defineSubsystem(definition) {
20
20
  });
21
21
  }
22
22
 
23
+ // src/daemon/lease-ipc-client.ts
24
+ import {
25
+ LEASE_IPC_PROTOCOL_VERSION,
26
+ LeaseIpcRequestEnvelopeSchema,
27
+ LeaseIpcResponseEnvelopeSchema
28
+ } from "@cleocode/contracts";
29
+ var MalformedLeaseIpcFrameError = class extends Error {
30
+ /** The raw NDJSON line that failed to decode. */
31
+ line;
32
+ /**
33
+ * @param line - The offending raw NDJSON line.
34
+ * @param cause - The underlying JSON or Zod error.
35
+ */
36
+ constructor(line, cause) {
37
+ super(`Malformed lease-ipc frame: ${cause instanceof Error ? cause.message : cause}`);
38
+ this.name = "MalformedLeaseIpcFrameError";
39
+ this.line = line;
40
+ this.cause = cause;
41
+ }
42
+ };
43
+ function nextCorrelationId(seq) {
44
+ return `l${seq}-${Math.random().toString(36).slice(2, 10)}`;
45
+ }
46
+ function createLeaseIpcClient() {
47
+ let seq = 0;
48
+ return {
49
+ encodeRequest(request) {
50
+ seq += 1;
51
+ const id = nextCorrelationId(seq);
52
+ const envelope = {
53
+ protocol_version: LEASE_IPC_PROTOCOL_VERSION,
54
+ id,
55
+ direction: "request",
56
+ request
57
+ };
58
+ const parsed = LeaseIpcRequestEnvelopeSchema.parse(envelope);
59
+ return { id, line: `${JSON.stringify(parsed)}
60
+ ` };
61
+ },
62
+ decodeResponseLine(line) {
63
+ let json;
64
+ try {
65
+ json = JSON.parse(line);
66
+ } catch (cause) {
67
+ throw new MalformedLeaseIpcFrameError(line, cause);
68
+ }
69
+ const result = LeaseIpcResponseEnvelopeSchema.safeParse(json);
70
+ if (!result.success) {
71
+ throw new MalformedLeaseIpcFrameError(line, result.error);
72
+ }
73
+ return result.data;
74
+ }
75
+ };
76
+ }
77
+
78
+ // src/daemon/heartbeat-sender.ts
79
+ function createHeartbeatSender(writeLine, client = createLeaseIpcClient()) {
80
+ const active = typeof writeLine === "function";
81
+ return {
82
+ isActive() {
83
+ return active;
84
+ },
85
+ async sendHeartbeat(childId, inFlightLlm) {
86
+ if (!active || writeLine == null) {
87
+ return null;
88
+ }
89
+ const { id, line } = client.encodeRequest({
90
+ kind: "worker_heartbeat",
91
+ child_id: childId,
92
+ in_flight_llm: inFlightLlm
93
+ });
94
+ await writeLine(line);
95
+ return id;
96
+ }
97
+ };
98
+ }
99
+
23
100
  // src/daemon/logger.ts
24
101
  import { getLogger } from "@cleocode/core";
25
102
  function createSubsystemLogger(name) {
@@ -170,7 +247,7 @@ var MalformedIpcFrameError = class extends Error {
170
247
  this.cause = cause;
171
248
  }
172
249
  };
173
- function nextCorrelationId(seq) {
250
+ function nextCorrelationId2(seq) {
174
251
  return `c${seq}-${Math.random().toString(36).slice(2, 10)}`;
175
252
  }
176
253
  function createSupervisorIpcClient() {
@@ -178,7 +255,7 @@ function createSupervisorIpcClient() {
178
255
  return {
179
256
  encodeRequest(request) {
180
257
  seq += 1;
181
- const id = nextCorrelationId(seq);
258
+ const id = nextCorrelationId2(seq);
182
259
  const envelope = {
183
260
  protocol_version: SUPERVISOR_IPC_PROTOCOL_VERSION,
184
261
  id,
@@ -207,9 +284,12 @@ function createSupervisorIpcClient() {
207
284
 
208
285
  export {
209
286
  defineSubsystem,
287
+ MalformedLeaseIpcFrameError,
288
+ createLeaseIpcClient,
289
+ createHeartbeatSender,
210
290
  createSubsystemLogger,
211
291
  SubsystemRegistry,
212
292
  MalformedIpcFrameError,
213
293
  createSupervisorIpcClient
214
294
  };
215
- //# sourceMappingURL=chunk-WSQQ5DQL.js.map
295
+ //# sourceMappingURL=chunk-3K3LFSDF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/daemon/define-subsystem.ts","../src/daemon/lease-ipc-client.ts","../src/daemon/heartbeat-sender.ts","../src/daemon/logger.ts","../src/daemon/registry.ts","../src/daemon/supervisor-client.ts"],"sourcesContent":["/**\n * `defineSubsystem()` — declare a supervised daemon subsystem.\n *\n * The single factory R4-R7 adapters call to express a long-running concern\n * (Studio supervision, GC cron, web server, docs-viewer, runtime poller) as a\n * uniform {@link Subsystem}. The returned descriptor is frozen so the registry\n * can drive `start → healthProbe → shutdown` without the definition mutating\n * underfoot.\n *\n * @packageDocumentation\n * @module @cleocode/runtime/daemon\n *\n * @epic T11253 R2 — `@cleocode/runtime/daemon` submodule\n * @task T11367 — defineSubsystem + lifecycle/health registry + IPC client\n * @saga T11243 SG-RUNTIME-UNIFICATION\n */\n\nimport type { Subsystem, SubsystemDefinition } from '@cleocode/contracts';\n\n/**\n * Declare a daemon subsystem.\n *\n * Validates that the four required surfaces (`name`, `start`, `healthProbe`,\n * `shutdown`) are present and that `name` is a non-empty string, then returns a\n * frozen {@link Subsystem} ready to register with a `SubsystemRegistry`.\n *\n * @typeParam TContext - Opaque context threaded from `start` into `shutdown`\n * (e.g. a child handle or resolved config). Defaults to `void`.\n * @param definition - The subsystem descriptor.\n * @returns A frozen, registrable {@link Subsystem}.\n * @throws {TypeError} When `name` is empty or a required method is missing.\n *\n * @example\n * ```ts\n * const studio = defineSubsystem({\n * name: 'studio',\n * start: () => supervisor.start(),\n * healthProbe: () => ({ child_id: 'studio', pid: supervisor.pid ?? 0,\n * state: supervisor.status === 'running' ? 'running' : 'stopped',\n * restart_count: 0 }),\n * shutdown: () => supervisor.stop(),\n * });\n * ```\n */\nexport function defineSubsystem<TContext = void>(\n definition: SubsystemDefinition<TContext>,\n): Subsystem<TContext> {\n if (typeof definition.name !== 'string' || definition.name.length === 0) {\n throw new TypeError('defineSubsystem: `name` must be a non-empty string');\n }\n if (typeof definition.start !== 'function') {\n throw new TypeError(`defineSubsystem(${definition.name}): \\`start\\` must be a function`);\n }\n if (typeof definition.healthProbe !== 'function') {\n throw new TypeError(`defineSubsystem(${definition.name}): \\`healthProbe\\` must be a function`);\n }\n if (typeof definition.shutdown !== 'function') {\n throw new TypeError(`defineSubsystem(${definition.name}): \\`shutdown\\` must be a function`);\n }\n\n return Object.freeze({\n name: definition.name,\n start: definition.start,\n healthProbe: definition.healthProbe,\n shutdown: definition.shutdown,\n });\n}\n","/**\n * Typed NDJSON client for the PARALLEL `lease-ipc` v1.1 contract (T11627 ST-5).\n *\n * Mirrors {@link ./supervisor-client.ts | supervisor-client.ts} conventions\n * exactly — a transport-agnostic codec that **owns no socket**. It encodes\n * outbound {@link LeaseIpcRequestEnvelope}s to newline-delimited JSON (NDJSON)\n * lines and decodes inbound lines through the v1.1 Zod schemas. **Every inbound\n * line is parsed** — a malformed or schema-violating frame is rejected with a\n * typed {@link MalformedLeaseIpcFrameError} and never silently dropped.\n *\n * The wire format is byte-identical framing to the frozen `supervisor-ipc` v1.0\n * envelope; only the version string ({@link LEASE_IPC_PROTOCOL_VERSION} =\n * `1.1.0`) and the inner union differ. A single supervisor accept loop routes\n * `1.0.0` frames to the v1.0 union and `1.1.0` frames to this lease union.\n *\n * The Rust `cleo-supervisor` is the arbiter (server); a CLEO process running in\n * `CLEO_WRITER_LEASE_MODE=supervisor` is a client that sends lease requests and\n * receives lease responses + unsolicited events (`lease_revoked`,\n * `child_killed_unresponsive`), one NDJSON envelope per line.\n *\n * @packageDocumentation\n * @module @cleocode/runtime/daemon\n *\n * @epic T11625\n * @task T11894 — supervisor fast path: TS lease-ipc-client (ST-5)\n * @saga T11243 SG-RUNTIME-UNIFICATION\n * @see ./supervisor-client.ts — the v1.0 codec this mirrors\n */\n\nimport {\n LEASE_IPC_PROTOCOL_VERSION,\n type LeaseIpcRequest,\n type LeaseIpcRequestEnvelope,\n LeaseIpcRequestEnvelopeSchema,\n type LeaseIpcResponseEnvelope,\n LeaseIpcResponseEnvelopeSchema,\n} from '@cleocode/contracts';\n\n/**\n * A typed error raised when an inbound NDJSON line cannot be decoded into a\n * valid {@link LeaseIpcResponseEnvelope}.\n *\n * Carries the offending raw `line` and the underlying `cause` (a JSON\n * `SyntaxError` or a Zod validation error) so callers can log + correlate.\n * Invalid frames are surfaced as this error — never silently dropped. This is\n * the v1.1 sibling of {@link MalformedIpcFrameError}.\n *\n * @public\n */\nexport class MalformedLeaseIpcFrameError extends Error {\n /** The raw NDJSON line that failed to decode. */\n readonly line: string;\n\n /**\n * @param line - The offending raw NDJSON line.\n * @param cause - The underlying JSON or Zod error.\n */\n constructor(line: string, cause: unknown) {\n super(`Malformed lease-ipc frame: ${cause instanceof Error ? cause.message : cause}`);\n this.name = 'MalformedLeaseIpcFrameError';\n this.line = line;\n this.cause = cause;\n }\n}\n\n/**\n * A transport-agnostic codec for the PARALLEL `lease-ipc` v1.1 wire format.\n *\n * Construct via {@link createLeaseIpcClient}. The client is stateful only in its\n * monotonic correlation-id counter; it owns no socket. Callers feed it raw lines\n * from whatever transport (Unix socket, named pipe, test pair) they own.\n */\nexport interface LeaseIpcClient {\n /**\n * Encode a request body into a complete, newline-terminated NDJSON envelope\n * line ready to write to the transport.\n *\n * Stamps the parallel protocol version ({@link LEASE_IPC_PROTOCOL_VERSION}) and\n * a fresh correlation `id`, validates the result against\n * {@link LeaseIpcRequestEnvelopeSchema}, and returns the serialized line\n * (including the trailing `\\n`).\n *\n * @param request - The request body (validated by the envelope schema).\n * @returns `{ id, line }` — the correlation id and the NDJSON line to send.\n */\n encodeRequest: (request: LeaseIpcRequest) => { id: string; line: string };\n\n /**\n * Decode a single inbound NDJSON line into a typed response envelope.\n *\n * Parses the line as JSON, then validates it against\n * {@link LeaseIpcResponseEnvelopeSchema}. A malformed or schema-violating line\n * throws {@link MalformedLeaseIpcFrameError} — it is never silently dropped.\n *\n * @param line - A single NDJSON line (without the trailing newline).\n * @returns The decoded {@link LeaseIpcResponseEnvelope}.\n * @throws {MalformedLeaseIpcFrameError} When the line is not a valid response frame.\n */\n decodeResponseLine: (line: string) => LeaseIpcResponseEnvelope;\n}\n\n/**\n * Generate a process-unique correlation id for a lease IPC request envelope.\n *\n * @param seq - The client's monotonic sequence number.\n * @returns A correlation id of the form `l<seq>-<random>`.\n */\nfunction nextCorrelationId(seq: number): string {\n return `l${seq}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/**\n * Create a typed NDJSON codec for the PARALLEL `lease-ipc` v1.1 contract.\n *\n * @returns A {@link LeaseIpcClient}.\n *\n * @example\n * ```ts\n * const client = createLeaseIpcClient();\n * const { id, line } = client.encodeRequest({\n * kind: 'lease_acquire', scope: 'project', lane: 'tasks',\n * holder_id: 'pid-42:tasks', priority: 0, ttl_ms: 30_000, reentrant: true,\n * });\n * socket.write(line);\n * socket.on('line', (raw) => {\n * const env = client.decodeResponseLine(raw); // throws on a bad frame\n * if (env.response.kind === 'lease_granted') { /* ... *\\/ }\n * });\n * ```\n */\nexport function createLeaseIpcClient(): LeaseIpcClient {\n let seq = 0;\n\n return {\n encodeRequest(request: LeaseIpcRequest): { id: string; line: string } {\n seq += 1;\n const id = nextCorrelationId(seq);\n const envelope: LeaseIpcRequestEnvelope = {\n protocol_version: LEASE_IPC_PROTOCOL_VERSION,\n id,\n direction: 'request',\n request,\n };\n // Validate before serialization so we never emit an off-contract frame.\n const parsed = LeaseIpcRequestEnvelopeSchema.parse(envelope);\n return { id, line: `${JSON.stringify(parsed)}\\n` };\n },\n\n decodeResponseLine(line: string): LeaseIpcResponseEnvelope {\n let json: unknown;\n try {\n json = JSON.parse(line);\n } catch (cause) {\n throw new MalformedLeaseIpcFrameError(line, cause);\n }\n const result = LeaseIpcResponseEnvelopeSchema.safeParse(json);\n if (!result.success) {\n throw new MalformedLeaseIpcFrameError(line, result.error);\n }\n return result.data;\n },\n };\n}\n","/**\n * Worker-side heartbeat sender for the watchdog (T11628).\n *\n * A thin wrapper over {@link createLeaseIpcClient} that encodes a\n * `worker_heartbeat` frame and writes it to the supervisor over whatever\n * transport the caller owns. It is **degrade-by-default**: when no transport is\n * wired (no supervisor up / the daemon-off posture) {@link sendHeartbeat} is a\n * no-op, exactly mirroring how the LLM-queue admit path degrades when the\n * supervisor arbiter is absent. A managed worker calls {@link sendHeartbeat}\n * periodically (well inside the watchdog's NORMAL deadline) so the supervisor\n * knows it is alive; on a miss the watchdog SIGTERM→SIGKILLs it.\n *\n * The sender owns no socket: the caller supplies a `writeLine` sink (the\n * connected Unix-socket write half, a test double, …) — keeping this module free\n * of platform IO and trivially testable, consistent with the v1.0/v1.1 codecs.\n *\n * @packageDocumentation\n * @module @cleocode/runtime/daemon\n *\n * @epic T11625\n * @task T11628 — supervisor watchdog: worker heartbeat sender\n * @saga T11243 SG-RUNTIME-UNIFICATION\n * @see ./lease-ipc-client.ts — the codec this wraps\n */\n\nimport { createLeaseIpcClient, type LeaseIpcClient } from './lease-ipc-client.js';\n\n/**\n * A transport sink that writes one already-framed NDJSON line (including the\n * trailing `\\n`) to the supervisor. Returning a promise lets the caller await a\n * flush; the sender does not require it.\n */\nexport type WriteLine = (line: string) => void | Promise<void>;\n\n/**\n * A degrade-by-default worker heartbeat sender.\n *\n * Construct via {@link createHeartbeatSender}. When no transport is wired,\n * {@link HeartbeatSender.sendHeartbeat} is a no-op and {@link HeartbeatSender.isActive}\n * is `false`.\n */\nexport interface HeartbeatSender {\n /**\n * Send one `worker_heartbeat` for `childId`. A no-op (resolves immediately)\n * when no transport is wired — the worker degrades gracefully when no\n * supervisor is up.\n *\n * @param childId - The heartbeating child's logical id (registry key).\n * @param inFlightLlm - The worker's own view of whether it is currently\n * inside an LLM call. `true` lets the watchdog grant the\n * EXTENDED deadline so a slow-but-healthy long call is\n * never false-killed (AC2 · RISK-7).\n * @returns The correlation `id` of the sent frame, or `null` when no transport\n * is wired (the no-op path).\n */\n sendHeartbeat: (childId: string, inFlightLlm: boolean) => Promise<string | null>;\n\n /**\n * Whether a transport is wired. `false` means {@link sendHeartbeat} is a\n * no-op (no supervisor up).\n */\n isActive: () => boolean;\n}\n\n/**\n * Create a worker heartbeat sender.\n *\n * @param writeLine - The transport sink that writes one NDJSON line to the\n * supervisor, or `undefined`/`null` when no supervisor is up\n * (the no-op / degrade path).\n * @param client - An optional pre-built {@link LeaseIpcClient} codec (defaults\n * to a fresh one); injectable for tests.\n * @returns A {@link HeartbeatSender}.\n *\n * @example\n * ```ts\n * // With a connected supervisor socket:\n * const sender = createHeartbeatSender((line) => socket.write(line));\n * setInterval(() => sender.sendHeartbeat('worker-7', isInLlmCall()), 10_000);\n *\n * // No supervisor up — every sendHeartbeat is a silent no-op:\n * const offline = createHeartbeatSender(undefined);\n * await offline.sendHeartbeat('worker-7', false); // resolves to null\n * ```\n */\nexport function createHeartbeatSender(\n writeLine: WriteLine | undefined | null,\n client: LeaseIpcClient = createLeaseIpcClient(),\n): HeartbeatSender {\n const active = typeof writeLine === 'function';\n\n return {\n isActive(): boolean {\n return active;\n },\n\n async sendHeartbeat(childId: string, inFlightLlm: boolean): Promise<string | null> {\n // Degrade: no transport wired → no-op (mirrors the queue's degrade).\n if (!active || writeLine == null) {\n return null;\n }\n const { id, line } = client.encodeRequest({\n kind: 'worker_heartbeat',\n child_id: childId,\n in_flight_llm: inFlightLlm,\n });\n await writeLine(line);\n return id;\n },\n };\n}\n","/**\n * Daemon subsystem log-routing primitive.\n *\n * `createSubsystemLogger(name)` is the **single** logging surface the\n * `@cleocode/runtime/daemon` submodule exposes. It routes through the canonical\n * pino factory `getLogger` (`packages/core/src/logger.ts`) — NOT raw\n * `createWriteStream` and NOT `console.*` — so every daemon log line lands in\n * the rotating, retention-managed CLEO log files with a consistent structured\n * shape and a per-subsystem `subsystem` field.\n *\n * It supersedes the **5 ad-hoc logging conventions** the legacy daemons grew,\n * which R4-R7 will migrate onto this primitive:\n *\n * 1. `sentient/daemon.ts` — `createWriteStream(sentient.log)` /\n * `createWriteStream(sentient.err)` + `process.stderr.write` lines.\n * 2. `gc/daemon.ts` — `createWriteStream(gc.log)` / `createWriteStream(gc.err)`.\n * 3. `web.ts` — `console.*`.\n * 4. `docs-viewer.ts` — ad-hoc stdout/stderr writes.\n * 5. the `runtime` services — bespoke per-service logging.\n *\n * ## Migration guidance for R4-R7 adapters\n *\n * Each adapter (Studio, GC, web, docs-viewer) replaces its bespoke sink with:\n *\n * ```ts\n * import { createSubsystemLogger } from '@cleocode/runtime/daemon';\n * const log = createSubsystemLogger('studio');\n * log.info({ pid }, 'Studio started'); // not process.stderr.write(...)\n * log.error({ err }, 'Studio crashed'); // not createWriteStream('sentient.err')\n * ```\n *\n * Do not import pino directly and do not open file streams — the canonical\n * factory owns rotation, retention, level, and the `projectHash` correlation\n * field. Routing through it is what makes the daemon's logs observable via the\n * same `cleo` log surface as the rest of the system.\n *\n * @packageDocumentation\n * @module @cleocode/runtime/daemon\n *\n * @epic T11253 R2 — `@cleocode/runtime/daemon` submodule\n * @task T11368 — log routing primitive (pino getLogger canonical sink)\n * @saga T11243 SG-RUNTIME-UNIFICATION\n */\n\nimport { getLogger } from '@cleocode/core';\n\n/**\n * A structured log payload — a plain object of serializable fields merged into\n * the emitted log record (e.g. `{ pid, err }`).\n */\nexport type SubsystemLogFields = Record<string, unknown>;\n\n/**\n * The narrow logging surface a daemon subsystem uses.\n *\n * Each method accepts an optional structured `fields` object followed by the\n * message, mirroring pino's `(obj, msg)` call shape. This is intentionally\n * smaller than the full pino `Logger` so the daemon submodule does not leak the\n * pino type across its public boundary while still routing through the\n * canonical pino sink.\n */\nexport interface SubsystemLogger {\n /** Log at `debug` level. */\n debug: (fields: SubsystemLogFields, msg: string) => void;\n /** Log at `info` level. */\n info: (fields: SubsystemLogFields, msg: string) => void;\n /** Log at `warn` level. */\n warn: (fields: SubsystemLogFields, msg: string) => void;\n /** Log at `error` level. */\n error: (fields: SubsystemLogFields, msg: string) => void;\n}\n\n/**\n * Create the single per-subsystem logger for the daemon submodule.\n *\n * Routes through the canonical pino factory `getLogger('daemon')` and namespaces\n * the result with a per-subsystem child binding (`{ subsystem: name }`), so all\n * daemon log lines share the `daemon` subsystem root while remaining\n * individually attributable. Never opens a file stream and never writes to\n * `console`.\n *\n * @param name - The logical subsystem name (e.g. `'studio'`, `'gc'`, `'web'`).\n * @returns A {@link SubsystemLogger} bound to `name`.\n *\n * @example\n * ```ts\n * const log = createSubsystemLogger('studio');\n * log.info({ pid: 4242, port: 3456 }, 'Studio started');\n * ```\n */\nexport function createSubsystemLogger(name: string): SubsystemLogger {\n // Bind under the canonical 'daemon' root, then a per-subsystem child so each\n // subsystem's lines carry `{ subsystem: 'daemon', daemonSubsystem: name }`.\n const log = getLogger('daemon').child({ daemonSubsystem: name });\n return {\n debug: (fields, msg) => log.debug(fields, msg),\n info: (fields, msg) => log.info(fields, msg),\n warn: (fields, msg) => log.warn(fields, msg),\n error: (fields, msg) => log.error(fields, msg),\n };\n}\n","/**\n * `SubsystemRegistry` — drives the uniform daemon subsystem lifecycle.\n *\n * Aggregates {@link Subsystem}s declared via `defineSubsystem` and runs them\n * through a uniform `start → healthProbe → shutdown` lifecycle, firing the\n * typed {@link DaemonLifecycleHooks} in order. Health probes are aggregated\n * into a {@link HealthStatus} that projects onto the FROZEN `supervisor-ipc`\n * `MonitorResponse` the Rust supervisor consumes (AC4).\n *\n * Shutdown runs in reverse registration order (LIFO) so dependants stop before\n * their dependencies — the conventional supervised-process teardown ordering.\n *\n * @packageDocumentation\n * @module @cleocode/runtime/daemon\n *\n * @epic T11253 R2 — `@cleocode/runtime/daemon` submodule\n * @task T11367 — defineSubsystem + lifecycle/health registry + IPC client\n * @saga T11243 SG-RUNTIME-UNIFICATION\n */\n\nimport {\n type DaemonLifecycleHooks,\n type HealthStatus,\n type Subsystem,\n type SubsystemHealth,\n type SubsystemLifecyclePhase,\n summarizeHealth,\n} from '@cleocode/contracts';\n\n/**\n * A registered subsystem plus its runtime bookkeeping.\n *\n * The `context` is the value `start()` resolved to, threaded back into\n * `shutdown()`. It is type-erased to `unknown` inside the registry because the\n * registry holds subsystems of heterogeneous context types; each entry's\n * `subsystem.shutdown` is the only consumer and re-narrows it at the call site\n * via the subsystem's own typing — no `any`, and the erased value never escapes\n * the registry surface.\n */\ninterface RegisteredSubsystem {\n readonly subsystem: Subsystem<unknown>;\n started: boolean;\n context: unknown;\n}\n\n/** Normalize an unknown thrown value into an `Error`. */\nfunction toError(value: unknown): Error {\n return value instanceof Error ? value : new Error(String(value));\n}\n\n/**\n * The registry that owns and drives the daemon's subsystems.\n *\n * Construct with optional {@link DaemonLifecycleHooks}; register subsystems,\n * then `startAll()` / `aggregateHealth()` / `shutdownAll()`.\n */\nexport class SubsystemRegistry {\n readonly #entries: RegisteredSubsystem[] = [];\n readonly #hooks: DaemonLifecycleHooks;\n\n /**\n * @param hooks - Optional lifecycle hooks fired around start/shutdown and on\n * any lifecycle error.\n */\n constructor(hooks: DaemonLifecycleHooks = {}) {\n this.#hooks = hooks;\n }\n\n /**\n * Register a subsystem. Registration order defines start order; shutdown runs\n * in reverse.\n *\n * @typeParam TContext - The subsystem's start→shutdown context type.\n * @param subsystem - A subsystem produced by `defineSubsystem`.\n * @throws {Error} When a subsystem with the same `name` is already registered.\n */\n register<TContext>(subsystem: Subsystem<TContext>): void {\n if (this.#entries.some((entry) => entry.subsystem.name === subsystem.name)) {\n throw new Error(`SubsystemRegistry: duplicate subsystem name '${subsystem.name}'`);\n }\n // Widen the context type to `unknown` for heterogeneous storage. The\n // subsystem's own `start`/`shutdown` remain individually well-typed; only\n // the registry's internal handle is erased, and it never leaks out.\n this.#entries.push({\n subsystem: subsystem as Subsystem<unknown>,\n started: false,\n context: undefined,\n });\n }\n\n /** The names of all registered subsystems, in registration order. */\n get names(): readonly string[] {\n return this.#entries.map((entry) => entry.subsystem.name);\n }\n\n /**\n * Start every registered subsystem in registration order.\n *\n * For each subsystem: awaits `start()`, stores the resolved context, then\n * fires `onStart`. If `start()` throws, fires `onError` with phase `start`\n * and re-throws — a failed start is surfaced, never swallowed.\n *\n * @throws The first subsystem `start()` error (after firing `onError`).\n */\n async startAll(): Promise<void> {\n for (const entry of this.#entries) {\n try {\n entry.context = await entry.subsystem.start();\n entry.started = true;\n await this.#hooks.onStart?.(entry.subsystem.name);\n } catch (cause) {\n await this.#fireError(entry.subsystem.name, cause, 'start');\n throw toError(cause);\n }\n }\n }\n\n /**\n * Probe every registered subsystem and aggregate into a {@link HealthStatus}.\n *\n * A subsystem whose `healthProbe()` rejects does not abort the aggregate:\n * `onError` fires with phase `healthProbe` and the subsystem is reported as a\n * `stopped` row carrying the error in its `detail`, so the supervisor still\n * receives a complete snapshot.\n *\n * @returns The aggregate health snapshot (projects onto `MonitorResponse`).\n */\n async aggregateHealth(): Promise<HealthStatus> {\n const rows: SubsystemHealth[] = [];\n for (const entry of this.#entries) {\n try {\n rows.push(await entry.subsystem.healthProbe());\n } catch (cause) {\n await this.#fireError(entry.subsystem.name, cause, 'healthProbe');\n rows.push({\n child_id: entry.subsystem.name,\n pid: 0,\n state: 'stopped',\n restart_count: 0,\n detail: `healthProbe failed: ${toError(cause).message}`,\n });\n }\n }\n return summarizeHealth(rows);\n }\n\n /**\n * Shut down every started subsystem in reverse registration order (LIFO).\n *\n * Each subsystem's `shutdown(context)` is awaited and `onShutdown` fires. A\n * throwing `shutdown` fires `onError` with phase `shutdown` but does NOT\n * abort the teardown of the remaining subsystems — best-effort graceful\n * shutdown of the whole daemon.\n */\n async shutdownAll(): Promise<void> {\n for (let i = this.#entries.length - 1; i >= 0; i -= 1) {\n const entry = this.#entries[i];\n if (entry === undefined || !entry.started) continue;\n try {\n await entry.subsystem.shutdown(entry.context);\n entry.started = false;\n await this.#hooks.onShutdown?.(entry.subsystem.name);\n } catch (cause) {\n await this.#fireError(entry.subsystem.name, cause, 'shutdown');\n // Continue tearing down the rest — do not abort on one failure.\n }\n }\n }\n\n /** Fire the `onError` hook, swallowing any error the hook itself throws. */\n async #fireError(name: string, cause: unknown, phase: SubsystemLifecyclePhase): Promise<void> {\n try {\n await this.#hooks.onError?.(name, toError(cause), phase);\n } catch {\n // A throwing error-hook must not mask the original failure.\n }\n }\n}\n","/**\n * Typed NDJSON client for the FROZEN `supervisor-ipc` v1.0 contract.\n *\n * The Rust `cleo-supervisor` is the server; a CLEO daemon process is a client\n * that sends {@link SupervisorIpcRequestEnvelope}s and receives\n * {@link SupervisorIpcResponseEnvelope}s, one newline-delimited JSON\n * (NDJSON) envelope per line. This module is a transport-agnostic codec: it\n * encodes outbound envelopes to NDJSON lines and decodes inbound lines through\n * the frozen Zod schemas. **Every inbound line is parsed** — a malformed or\n * schema-violating frame is rejected with a typed {@link MalformedIpcFrameError}\n * and never silently dropped (T11367 AC2).\n *\n * The codec does not own a socket — callers feed it raw lines from whatever\n * transport (Unix socket, named pipe, test pair) they own, keeping the daemon\n * submodule free of platform IO and trivially testable.\n *\n * @packageDocumentation\n * @module @cleocode/runtime/daemon\n *\n * @epic T11253 R2 — `@cleocode/runtime/daemon` submodule\n * @task T11367 — defineSubsystem + lifecycle/health registry + IPC client\n * @saga T11243 SG-RUNTIME-UNIFICATION\n */\n\nimport {\n SUPERVISOR_IPC_PROTOCOL_VERSION,\n type SupervisorIpcRequest,\n type SupervisorIpcRequestEnvelope,\n SupervisorIpcRequestEnvelopeSchema,\n type SupervisorIpcResponseEnvelope,\n SupervisorIpcResponseEnvelopeSchema,\n} from '@cleocode/contracts';\n\n/**\n * A typed error raised when an inbound NDJSON line cannot be decoded into a\n * valid {@link SupervisorIpcResponseEnvelope}.\n *\n * Carries the offending raw `line` and the underlying `cause` (a JSON\n * `SyntaxError` or a Zod validation error) so callers can log + correlate.\n * Invalid frames are surfaced as this error — never silently dropped.\n */\nexport class MalformedIpcFrameError extends Error {\n /** The raw NDJSON line that failed to decode. */\n readonly line: string;\n\n /**\n * @param line - The offending raw NDJSON line.\n * @param cause - The underlying JSON or Zod error.\n */\n constructor(line: string, cause: unknown) {\n super(`Malformed supervisor-ipc frame: ${cause instanceof Error ? cause.message : cause}`);\n this.name = 'MalformedIpcFrameError';\n this.line = line;\n this.cause = cause;\n }\n}\n\n/**\n * A transport-agnostic codec for the FROZEN `supervisor-ipc` v1.0 wire format.\n *\n * Construct via {@link createSupervisorIpcClient}. The client is stateful only\n * in its monotonic correlation-id counter; it owns no socket.\n */\nexport interface SupervisorIpcClient {\n /**\n * Encode a request body into a complete, newline-terminated NDJSON envelope\n * line ready to write to the transport.\n *\n * Stamps the frozen protocol version and a fresh correlation `id`, validates\n * the result against {@link SupervisorIpcRequestEnvelopeSchema}, and returns\n * the serialized line (including the trailing `\\n`).\n *\n * @param request - The request body (validated by the envelope schema).\n * @returns `{ id, line }` — the correlation id and the NDJSON line to send.\n */\n encodeRequest: (request: SupervisorIpcRequest) => { id: string; line: string };\n\n /**\n * Decode a single inbound NDJSON line into a typed response envelope.\n *\n * Parses the line as JSON, then validates it against\n * {@link SupervisorIpcResponseEnvelopeSchema}. A malformed or\n * schema-violating line throws {@link MalformedIpcFrameError} — it is never\n * silently dropped.\n *\n * @param line - A single NDJSON line (without the trailing newline).\n * @returns The decoded {@link SupervisorIpcResponseEnvelope}.\n * @throws {MalformedIpcFrameError} When the line is not a valid response frame.\n */\n decodeResponseLine: (line: string) => SupervisorIpcResponseEnvelope;\n}\n\n/**\n * Generate a process-unique correlation id for an IPC request envelope.\n *\n * @param seq - The client's monotonic sequence number.\n * @returns A correlation id of the form `c<seq>-<random>`.\n */\nfunction nextCorrelationId(seq: number): string {\n return `c${seq}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/**\n * Create a typed NDJSON codec for the FROZEN `supervisor-ipc` v1.0 contract.\n *\n * @returns A {@link SupervisorIpcClient}.\n *\n * @example\n * ```ts\n * const client = createSupervisorIpcClient();\n * const { id, line } = client.encodeRequest({ kind: 'health' });\n * socket.write(line);\n * socket.on('line', (raw) => {\n * const env = client.decodeResponseLine(raw); // throws MalformedIpcFrameError on bad frame\n * if (env.response.kind === 'health') { /* ... *\\/ }\n * });\n * ```\n */\nexport function createSupervisorIpcClient(): SupervisorIpcClient {\n let seq = 0;\n\n return {\n encodeRequest(request: SupervisorIpcRequest): { id: string; line: string } {\n seq += 1;\n const id = nextCorrelationId(seq);\n const envelope: SupervisorIpcRequestEnvelope = {\n protocol_version: SUPERVISOR_IPC_PROTOCOL_VERSION,\n id,\n direction: 'request',\n request,\n };\n // Validate before serialization so we never emit an off-contract frame.\n const parsed = SupervisorIpcRequestEnvelopeSchema.parse(envelope);\n return { id, line: `${JSON.stringify(parsed)}\\n` };\n },\n\n decodeResponseLine(line: string): SupervisorIpcResponseEnvelope {\n let json: unknown;\n try {\n json = JSON.parse(line);\n } catch (cause) {\n throw new MalformedIpcFrameError(line, cause);\n }\n const result = SupervisorIpcResponseEnvelopeSchema.safeParse(json);\n if (!result.success) {\n throw new MalformedIpcFrameError(line, result.error);\n }\n return result.data;\n },\n };\n}\n"],"mappings":";AA4CO,SAAS,gBACd,YACqB;AACrB,MAAI,OAAO,WAAW,SAAS,YAAY,WAAW,KAAK,WAAW,GAAG;AACvE,UAAM,IAAI,UAAU,oDAAoD;AAAA,EAC1E;AACA,MAAI,OAAO,WAAW,UAAU,YAAY;AAC1C,UAAM,IAAI,UAAU,mBAAmB,WAAW,IAAI,iCAAiC;AAAA,EACzF;AACA,MAAI,OAAO,WAAW,gBAAgB,YAAY;AAChD,UAAM,IAAI,UAAU,mBAAmB,WAAW,IAAI,uCAAuC;AAAA,EAC/F;AACA,MAAI,OAAO,WAAW,aAAa,YAAY;AAC7C,UAAM,IAAI,UAAU,mBAAmB,WAAW,IAAI,oCAAoC;AAAA,EAC5F;AAEA,SAAO,OAAO,OAAO;AAAA,IACnB,MAAM,WAAW;AAAA,IACjB,OAAO,WAAW;AAAA,IAClB,aAAa,WAAW;AAAA,IACxB,UAAU,WAAW;AAAA,EACvB,CAAC;AACH;;;ACrCA;AAAA,EACE;AAAA,EAGA;AAAA,EAEA;AAAA,OACK;AAaA,IAAM,8BAAN,cAA0C,MAAM;AAAA;AAAA,EAE5C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMT,YAAY,MAAc,OAAgB;AACxC,UAAM,8BAA8B,iBAAiB,QAAQ,MAAM,UAAU,KAAK,EAAE;AACpF,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,QAAQ;AAAA,EACf;AACF;AA4CA,SAAS,kBAAkB,KAAqB;AAC9C,SAAO,IAAI,GAAG,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,EAAE,CAAC;AAC3D;AAqBO,SAAS,uBAAuC;AACrD,MAAI,MAAM;AAEV,SAAO;AAAA,IACL,cAAc,SAAwD;AACpE,aAAO;AACP,YAAM,KAAK,kBAAkB,GAAG;AAChC,YAAM,WAAoC;AAAA,QACxC,kBAAkB;AAAA,QAClB;AAAA,QACA,WAAW;AAAA,QACX;AAAA,MACF;AAEA,YAAM,SAAS,8BAA8B,MAAM,QAAQ;AAC3D,aAAO,EAAE,IAAI,MAAM,GAAG,KAAK,UAAU,MAAM,CAAC;AAAA,EAAK;AAAA,IACnD;AAAA,IAEA,mBAAmB,MAAwC;AACzD,UAAI;AACJ,UAAI;AACF,eAAO,KAAK,MAAM,IAAI;AAAA,MACxB,SAAS,OAAO;AACd,cAAM,IAAI,4BAA4B,MAAM,KAAK;AAAA,MACnD;AACA,YAAM,SAAS,+BAA+B,UAAU,IAAI;AAC5D,UAAI,CAAC,OAAO,SAAS;AACnB,cAAM,IAAI,4BAA4B,MAAM,OAAO,KAAK;AAAA,MAC1D;AACA,aAAO,OAAO;AAAA,IAChB;AAAA,EACF;AACF;;;AC7EO,SAAS,sBACd,WACA,SAAyB,qBAAqB,GAC7B;AACjB,QAAM,SAAS,OAAO,cAAc;AAEpC,SAAO;AAAA,IACL,WAAoB;AAClB,aAAO;AAAA,IACT;AAAA,IAEA,MAAM,cAAc,SAAiB,aAA8C;AAEjF,UAAI,CAAC,UAAU,aAAa,MAAM;AAChC,eAAO;AAAA,MACT;AACA,YAAM,EAAE,IAAI,KAAK,IAAI,OAAO,cAAc;AAAA,QACxC,MAAM;AAAA,QACN,UAAU;AAAA,QACV,eAAe;AAAA,MACjB,CAAC;AACD,YAAM,UAAU,IAAI;AACpB,aAAO;AAAA,IACT;AAAA,EACF;AACF;;;AClEA,SAAS,iBAAiB;AA8CnB,SAAS,sBAAsB,MAA+B;AAGnE,QAAM,MAAM,UAAU,QAAQ,EAAE,MAAM,EAAE,iBAAiB,KAAK,CAAC;AAC/D,SAAO;AAAA,IACL,OAAO,CAAC,QAAQ,QAAQ,IAAI,MAAM,QAAQ,GAAG;AAAA,IAC7C,MAAM,CAAC,QAAQ,QAAQ,IAAI,KAAK,QAAQ,GAAG;AAAA,IAC3C,MAAM,CAAC,QAAQ,QAAQ,IAAI,KAAK,QAAQ,GAAG;AAAA,IAC3C,OAAO,CAAC,QAAQ,QAAQ,IAAI,MAAM,QAAQ,GAAG;AAAA,EAC/C;AACF;;;AChFA;AAAA,EAME;AAAA,OACK;AAmBP,SAAS,QAAQ,OAAuB;AACtC,SAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;AACjE;AAQO,IAAM,oBAAN,MAAwB;AAAA,EACpB,WAAkC,CAAC;AAAA,EACnC;AAAA;AAAA;AAAA;AAAA;AAAA,EAMT,YAAY,QAA8B,CAAC,GAAG;AAC5C,SAAK,SAAS;AAAA,EAChB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,SAAmB,WAAsC;AACvD,QAAI,KAAK,SAAS,KAAK,CAAC,UAAU,MAAM,UAAU,SAAS,UAAU,IAAI,GAAG;AAC1E,YAAM,IAAI,MAAM,gDAAgD,UAAU,IAAI,GAAG;AAAA,IACnF;AAIA,SAAK,SAAS,KAAK;AAAA,MACjB;AAAA,MACA,SAAS;AAAA,MACT,SAAS;AAAA,IACX,CAAC;AAAA,EACH;AAAA;AAAA,EAGA,IAAI,QAA2B;AAC7B,WAAO,KAAK,SAAS,IAAI,CAAC,UAAU,MAAM,UAAU,IAAI;AAAA,EAC1D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,WAA0B;AAC9B,eAAW,SAAS,KAAK,UAAU;AACjC,UAAI;AACF,cAAM,UAAU,MAAM,MAAM,UAAU,MAAM;AAC5C,cAAM,UAAU;AAChB,cAAM,KAAK,OAAO,UAAU,MAAM,UAAU,IAAI;AAAA,MAClD,SAAS,OAAO;AACd,cAAM,KAAK,WAAW,MAAM,UAAU,MAAM,OAAO,OAAO;AAC1D,cAAM,QAAQ,KAAK;AAAA,MACrB;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYA,MAAM,kBAAyC;AAC7C,UAAM,OAA0B,CAAC;AACjC,eAAW,SAAS,KAAK,UAAU;AACjC,UAAI;AACF,aAAK,KAAK,MAAM,MAAM,UAAU,YAAY,CAAC;AAAA,MAC/C,SAAS,OAAO;AACd,cAAM,KAAK,WAAW,MAAM,UAAU,MAAM,OAAO,aAAa;AAChE,aAAK,KAAK;AAAA,UACR,UAAU,MAAM,UAAU;AAAA,UAC1B,KAAK;AAAA,UACL,OAAO;AAAA,UACP,eAAe;AAAA,UACf,QAAQ,uBAAuB,QAAQ,KAAK,EAAE,OAAO;AAAA,QACvD,CAAC;AAAA,MACH;AAAA,IACF;AACA,WAAO,gBAAgB,IAAI;AAAA,EAC7B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,cAA6B;AACjC,aAAS,IAAI,KAAK,SAAS,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG;AACrD,YAAM,QAAQ,KAAK,SAAS,CAAC;AAC7B,UAAI,UAAU,UAAa,CAAC,MAAM,QAAS;AAC3C,UAAI;AACF,cAAM,MAAM,UAAU,SAAS,MAAM,OAAO;AAC5C,cAAM,UAAU;AAChB,cAAM,KAAK,OAAO,aAAa,MAAM,UAAU,IAAI;AAAA,MACrD,SAAS,OAAO;AACd,cAAM,KAAK,WAAW,MAAM,UAAU,MAAM,OAAO,UAAU;AAAA,MAE/D;AAAA,IACF;AAAA,EACF;AAAA;AAAA,EAGA,MAAM,WAAW,MAAc,OAAgB,OAA+C;AAC5F,QAAI;AACF,YAAM,KAAK,OAAO,UAAU,MAAM,QAAQ,KAAK,GAAG,KAAK;AAAA,IACzD,QAAQ;AAAA,IAER;AAAA,EACF;AACF;;;ACzJA;AAAA,EACE;AAAA,EAGA;AAAA,EAEA;AAAA,OACK;AAUA,IAAM,yBAAN,cAAqC,MAAM;AAAA;AAAA,EAEvC;AAAA;AAAA;AAAA;AAAA;AAAA,EAMT,YAAY,MAAc,OAAgB;AACxC,UAAM,mCAAmC,iBAAiB,QAAQ,MAAM,UAAU,KAAK,EAAE;AACzF,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,QAAQ;AAAA,EACf;AACF;AA2CA,SAASA,mBAAkB,KAAqB;AAC9C,SAAO,IAAI,GAAG,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,EAAE,CAAC;AAC3D;AAkBO,SAAS,4BAAiD;AAC/D,MAAI,MAAM;AAEV,SAAO;AAAA,IACL,cAAc,SAA6D;AACzE,aAAO;AACP,YAAM,KAAKA,mBAAkB,GAAG;AAChC,YAAM,WAAyC;AAAA,QAC7C,kBAAkB;AAAA,QAClB;AAAA,QACA,WAAW;AAAA,QACX;AAAA,MACF;AAEA,YAAM,SAAS,mCAAmC,MAAM,QAAQ;AAChE,aAAO,EAAE,IAAI,MAAM,GAAG,KAAK,UAAU,MAAM,CAAC;AAAA,EAAK;AAAA,IACnD;AAAA,IAEA,mBAAmB,MAA6C;AAC9D,UAAI;AACJ,UAAI;AACF,eAAO,KAAK,MAAM,IAAI;AAAA,MACxB,SAAS,OAAO;AACd,cAAM,IAAI,uBAAuB,MAAM,KAAK;AAAA,MAC9C;AACA,YAAM,SAAS,oCAAoC,UAAU,IAAI;AACjE,UAAI,CAAC,OAAO,SAAS;AACnB,cAAM,IAAI,uBAAuB,MAAM,OAAO,KAAK;AAAA,MACrD;AACA,aAAO,OAAO;AAAA,IAChB;AAAA,EACF;AACF;","names":["nextCorrelationId"]}
@@ -86,19 +86,32 @@ var LineBuffer = class {
86
86
  };
87
87
 
88
88
  // src/gateway/rpc/server.ts
89
+ import { randomUUID } from "crypto";
89
90
  import { existsSync, unlinkSync } from "fs";
90
91
  import { createServer } from "net";
91
92
  import { GATEWAY_RPC_PROTOCOL_VERSION as GATEWAY_RPC_PROTOCOL_VERSION2 } from "@cleocode/contracts/gateway/rpc";
92
93
  import { getLogger } from "@cleocode/core";
93
- async function routeFrame(handler, frame) {
94
+ import {
95
+ bindConnectionSession,
96
+ runWithConnectionHandle,
97
+ unbindConnectionSession
98
+ } from "@cleocode/core/internal";
99
+ async function routeFrame(handler, frame, connId = randomUUID()) {
94
100
  const request = {
95
101
  ...frame.request,
96
102
  // Force the transport of origin — a client cannot impersonate cli/mcp/http.
97
103
  source: "rpc",
98
104
  requestId: frame.request.requestId
99
105
  };
106
+ if (request.sessionId) {
107
+ bindConnectionSession(connId, request.sessionId);
108
+ }
100
109
  try {
101
- const response = await handler.handle(request);
110
+ const response = await runWithConnectionHandle(
111
+ connId,
112
+ () => handler.handle(request),
113
+ request.sessionId
114
+ );
102
115
  return {
103
116
  protocol_version: GATEWAY_RPC_PROTOCOL_VERSION2,
104
117
  id: frame.id,
@@ -113,6 +126,7 @@ async function routeFrame(handler, frame) {
113
126
  function handleConnection(handler, socket, log) {
114
127
  socket.setEncoding("utf8");
115
128
  const lines = new LineBuffer();
129
+ const connId = randomUUID();
116
130
  socket.on("data", (chunk) => {
117
131
  for (const line of lines.push(chunk)) {
118
132
  const decoded = decodeLine(line);
@@ -120,7 +134,7 @@ function handleConnection(handler, socket, log) {
120
134
  socket.write(encodeFrame(decoded.frame));
121
135
  continue;
122
136
  }
123
- routeFrame(handler, decoded.frame).then((out) => {
137
+ routeFrame(handler, decoded.frame, connId).then((out) => {
124
138
  if (!socket.destroyed) socket.write(encodeFrame(out));
125
139
  }).catch((err) => {
126
140
  const message = err instanceof Error ? err.message : String(err);
@@ -134,6 +148,9 @@ function handleConnection(handler, socket, log) {
134
148
  socket.on("error", (err) => {
135
149
  log.warn({ err }, "rpc connection error");
136
150
  });
151
+ socket.on("close", () => {
152
+ unbindConnectionSession(connId);
153
+ });
137
154
  }
138
155
  function startRpcServer(handler, opts) {
139
156
  const log = getLogger("gateway-rpc");
@@ -187,4 +204,4 @@ export {
187
204
  routeFrame,
188
205
  startRpcServer
189
206
  };
190
- //# sourceMappingURL=chunk-5ZXRYEQ5.js.map
207
+ //# sourceMappingURL=chunk-KQBVXGOL.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/gateway/rpc/codec.ts","../src/gateway/rpc/server.ts"],"sourcesContent":["/**\n * NDJSON codec for the CLI-RPC transport.\n *\n * Implements the SAME framing discipline as the FROZEN `supervisor-ipc` v1.0\n * contract — one zod-validated JSON envelope per newline-delimited line, with\n * an explicit `protocol_version` negotiation step — but for the gateway-RPC\n * frame set (`@cleocode/contracts/gateway/rpc`). The supervisor-ipc module is\n * frozen and is NOT edited; this codec re-applies its pattern.\n *\n * The codec is intentionally socket-agnostic: it owns line buffering, JSON\n * parse, zod decode, and version negotiation, returning typed results the\n * server maps onto `node:net` reads/writes. This keeps the parse/validate logic\n * unit-testable without binding a real socket.\n *\n * @task T11449\n * @epic T11254\n * @saga T11243\n */\n\nimport {\n GATEWAY_RPC_PROTOCOL_VERSION,\n type GatewayRpcErrorCode,\n type GatewayRpcErrorFrame,\n type GatewayRpcFrame,\n GatewayRpcFrameSchema,\n type GatewayRpcRequestFrame,\n type GatewayRpcResponseFrame,\n isFrozenGatewayRpcVersion,\n} from '@cleocode/contracts/gateway/rpc';\n\n/** Newline delimiter for the NDJSON wire (mirrors supervisor-ipc). */\nconst NEWLINE = '\\n';\n\n/**\n * Encode any RPC frame to a single NDJSON line (JSON + trailing `\\n`).\n *\n * @param frame - The response or error frame to serialize.\n * @returns A newline-terminated JSON string ready to write to the socket.\n */\nexport function encodeFrame(frame: GatewayRpcResponseFrame | GatewayRpcErrorFrame): string {\n return `${JSON.stringify(frame)}${NEWLINE}`;\n}\n\n/**\n * Build a protocol-level error frame.\n *\n * @param id - Correlation id (`'0'` when the originating frame's id is unknown).\n * @param code - One of the frozen {@link GatewayRpcErrorCode} values.\n * @param message - Human-readable detail.\n * @returns A fully-formed {@link GatewayRpcErrorFrame}.\n */\nexport function buildErrorFrame(\n id: string,\n code: GatewayRpcErrorCode,\n message: string,\n): GatewayRpcErrorFrame {\n return {\n protocol_version: GATEWAY_RPC_PROTOCOL_VERSION,\n id,\n direction: 'error',\n error: { code, message },\n };\n}\n\n/**\n * The discriminated outcome of decoding a single inbound NDJSON line.\n *\n * - `request` — a valid, version-matched request frame ready to route.\n * - `error` — a protocol-level error frame the server should write straight\n * back (parse failure, version mismatch, or malformed frame). The\n * server never routes these to the gateway.\n */\nexport type DecodeResult =\n | { kind: 'request'; frame: GatewayRpcRequestFrame }\n | { kind: 'error'; frame: GatewayRpcErrorFrame };\n\n/**\n * Decode + validate a single inbound NDJSON line into a routable request or a\n * protocol-level error frame.\n *\n * Steps (the supervisor-ipc pattern, applied to gateway frames):\n * 1. JSON parse — failure → `E_RPC_PARSE`.\n * 2. zod decode against {@link GatewayRpcFrameSchema} — failure → `E_RPC_BAD_FRAME`.\n * 3. version negotiation via {@link isFrozenGatewayRpcVersion} —\n * mismatch → `E_RPC_BAD_VERSION`.\n * 4. direction must be `request` (a server never receives response/error\n * frames) — otherwise → `E_RPC_BAD_FRAME`.\n *\n * The best-effort correlation `id` is recovered from the raw JSON when possible\n * so the client can match the error to its request even on a decode failure.\n *\n * @param line - One trimmed NDJSON line (no trailing newline).\n * @returns A {@link DecodeResult}.\n */\nexport function decodeLine(line: string): DecodeResult {\n let raw: unknown;\n try {\n raw = JSON.parse(line);\n } catch {\n return { kind: 'error', frame: buildErrorFrame('0', 'E_RPC_PARSE', 'invalid JSON line') };\n }\n\n // Best-effort id recovery for error correlation before full validation.\n const recoveredId = recoverId(raw);\n\n const parsed = GatewayRpcFrameSchema.safeParse(raw);\n if (!parsed.success) {\n return {\n kind: 'error',\n frame: buildErrorFrame(recoveredId, 'E_RPC_BAD_FRAME', 'frame failed schema validation'),\n };\n }\n\n const frame: GatewayRpcFrame = parsed.data;\n\n if (!isFrozenGatewayRpcVersion(frame.protocol_version)) {\n return {\n kind: 'error',\n frame: buildErrorFrame(\n frame.id,\n 'E_RPC_BAD_VERSION',\n `unsupported protocol version ${frame.protocol_version}; server speaks ${GATEWAY_RPC_PROTOCOL_VERSION}`,\n ),\n };\n }\n\n if (frame.direction !== 'request') {\n return {\n kind: 'error',\n frame: buildErrorFrame(\n frame.id,\n 'E_RPC_BAD_FRAME',\n `server cannot accept a ${frame.direction} frame`,\n ),\n };\n }\n\n return { kind: 'request', frame };\n}\n\n/**\n * Recover a best-effort correlation id from raw decoded JSON, used so a\n * validation failure can still be correlated to the client's request.\n *\n * @param raw - The result of `JSON.parse` on the inbound line.\n * @returns The string `id` if present and non-empty, else `'0'`.\n */\nfunction recoverId(raw: unknown): string {\n if (raw && typeof raw === 'object' && 'id' in raw) {\n const id = (raw as { id: unknown }).id;\n if (typeof id === 'string' && id.length > 0) return id;\n }\n return '0';\n}\n\n/**\n * A streaming line splitter for the NDJSON wire.\n *\n * `node:net` sockets deliver arbitrary chunk boundaries; a single `data` event\n * may carry a partial line or several lines. This buffer accumulates bytes and\n * yields only complete lines (split on `\\n`), matching the supervisor-ipc\n * client's `readline`-style framing without depending on `readline` (so it can\n * be driven directly off socket chunks).\n */\nexport class LineBuffer {\n /** Pending bytes that have not yet completed a line. */\n private buffer = '';\n\n /**\n * Push a decoded UTF-8 chunk; return every complete line it completes.\n *\n * @param chunk - A UTF-8 string chunk from the socket.\n * @returns Zero or more complete lines (trailing newline stripped, blanks\n * skipped).\n */\n push(chunk: string): string[] {\n this.buffer += chunk;\n const lines: string[] = [];\n let idx = this.buffer.indexOf(NEWLINE);\n while (idx !== -1) {\n const line = this.buffer.slice(0, idx).trim();\n this.buffer = this.buffer.slice(idx + 1);\n if (line.length > 0) lines.push(line);\n idx = this.buffer.indexOf(NEWLINE);\n }\n return lines;\n }\n}\n","/**\n * CLI-RPC unix-socket transport adapter — routes NDJSON frames through the gateway.\n *\n * Serves the gateway over a long-lived unix-domain socket using newline-delimited\n * JSON (NDJSON): one zod-validated {@link GatewayRpcFrame} per line. Any local\n * client (the `cleo` CLI talking to a warm daemon, Studio's server process, a\n * test harness) can open the socket, write `request` frames, and read back\n * `response`/`error` frames — without paying the per-invocation cold-start of\n * spawning a fresh `cleo` process.\n *\n * This is the THIRD transport adapter, mirroring the just-merged MCP stdio\n * adapter (`packages/runtime/src/gateway/mcp/`):\n *\n * RPC request frame { direction:'request', request:{…} }\n * → decode + validate (`@cleocode/contracts/gateway/rpc`)\n * → DispatchRequest { source: 'rpc', … } (re-validated at the boundary)\n * → injected {@link GatewayHandler}.handle()\n * → DispatchResponse (LAFS envelope) → RPC response frame\n *\n * The adapter owns ONLY wire concerns: socket lifecycle, line framing, version\n * negotiation, error rendering, and per-connection cleanup. All domain logic\n * (resolution, validation, middleware, handler execution) lives behind the\n * {@link GatewayHandler} — exactly mirroring the CLI and MCP adapters. There is\n * NO `process.exit` and NO error-render inside the handlers (R3 contract); the\n * adapter never calls `process.exit` at all (lifecycle is the caller's).\n *\n * The request `source` is forced to `'rpc'` regardless of what the client sent,\n * so a client cannot impersonate another transport.\n *\n * @task T11449\n * @epic T11254\n * @saga T11243\n */\n\nimport { randomUUID } from 'node:crypto';\nimport { existsSync, unlinkSync } from 'node:fs';\nimport { createServer, type Socket } from 'node:net';\nimport type { DispatchRequest } from '@cleocode/contracts/gateway';\nimport type {\n GatewayRpcRequestFrame,\n GatewayRpcResponseFrame,\n} from '@cleocode/contracts/gateway/rpc';\nimport { GATEWAY_RPC_PROTOCOL_VERSION } from '@cleocode/contracts/gateway/rpc';\nimport { getLogger } from '@cleocode/core';\nimport {\n bindConnectionSession,\n runWithConnectionHandle,\n unbindConnectionSession,\n} from '@cleocode/core/internal';\nimport type { GatewayHandler } from '../index.js';\nimport { buildErrorFrame, decodeLine, encodeFrame, LineBuffer } from './codec.js';\nimport type { RpcServerHandle, RpcServerOptions } from './types.js';\n\n/**\n * Route a single decoded request frame through the gateway handler and build\n * the correlated response frame.\n *\n * The request's `source` is forced to `'rpc'`; the frame `id` is reused as the\n * response correlation id (and, when the client omitted a `requestId` shaped\n * for tracing, the frame id flows through as the dispatch `requestId`). Thrown\n * handler errors are caught and rendered as a protocol-level `E_RPC_INTERNAL`\n * error frame — never propagated as an unhandled rejection or `process.exit`.\n *\n * ## Connection-scoped session binding (T11640 · Epic T11638)\n *\n * The daemon serves many connections over one process, so it cannot rely on the\n * single-process session-context singleton for identity. Instead, a frame on a\n * connection that declares a `sessionId` binds it into the `{connId → sessionId}`\n * registry, and the whole dispatch runs inside {@link runWithConnectionHandle}\n * so `core`'s `resolveCurrentSession` resolves THIS connection's session (its\n * highest-precedence tier) rather than \"whoever wrote the DB last\". When a frame\n * carries no `sessionId`, no binding is performed and resolution transparently\n * falls through to the env / active tiers — preserving existing behavior for\n * anonymous frames.\n *\n * ### Intra-connection bleed safety\n *\n * Frames on one connection are dispatched CONCURRENTLY (each `routeFrame` is\n * fired without awaiting the prior one), and registry binding is last-write-wins.\n * To stop a still-in-flight Frame A from resolving a sibling Frame B's session,\n * the frame's OWN declared `sessionId` is snapshotted into the per-frame\n * connection handle ({@link runWithConnectionHandle}'s third arg) — that snapshot\n * is authoritative for the dispatch, so a later re-bind of the same `connId`\n * cannot retroactively change who Frame A is. The registry binding remains only\n * for the late-binding path used by frames that declare no session of their own.\n *\n * @param handler - The injected transport-neutral gateway handler.\n * @param frame - A validated, version-matched request frame.\n * @param connId - The opaque per-connection id (bound at accept-time). Omitted\n * only by direct/test callers that route a single frame outside a socket; a\n * fresh ephemeral id is minted so the call still runs in an isolated\n * connection-handle scope.\n * @returns The response or error frame to write back on the same connection.\n */\nexport async function routeFrame(\n handler: GatewayHandler,\n frame: GatewayRpcRequestFrame,\n connId: string = randomUUID(),\n): Promise<GatewayRpcResponseFrame | ReturnType<typeof buildErrorFrame>> {\n const request: DispatchRequest = {\n ...frame.request,\n // Force the transport of origin — a client cannot impersonate cli/mcp/http.\n source: 'rpc',\n requestId: frame.request.requestId,\n };\n\n // Bind this connection to the session the frame declared (empty/absent ids are\n // ignored by the registry). This feeds the late-binding path for any sibling\n // frame that declares no session of its own. The per-frame snapshot below — not\n // this mutable registry — is what THIS dispatch resolves against, so a\n // concurrent sibling frame's re-bind cannot bleed into it.\n if (request.sessionId) {\n bindConnectionSession(connId, request.sessionId);\n }\n\n try {\n // Snapshot the frame's own session into the per-frame handle so this dispatch\n // is immune to concurrent last-write-wins re-binds of the same connId.\n const response = await runWithConnectionHandle(\n connId,\n () => handler.handle(request),\n request.sessionId,\n );\n return {\n protocol_version: GATEWAY_RPC_PROTOCOL_VERSION,\n id: frame.id,\n direction: 'response',\n response,\n };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return buildErrorFrame(frame.id, 'E_RPC_INTERNAL', message);\n }\n}\n\n/**\n * Handle a single client connection: buffer NDJSON lines, decode each, route\n * request frames through the gateway, and write back response/error frames.\n *\n * Each connection owns its own {@link LineBuffer}, so partial-line chunk\n * boundaries are reassembled per socket. Frames are processed concurrently;\n * each correlated response carries the originating frame `id`, so the client\n * can demultiplex out-of-order completions.\n *\n * ## Accept-time session binding (T11640)\n *\n * A fresh opaque `connId` is minted per accepted socket and threaded into every\n * {@link routeFrame} call so the connection's declared session is bound into the\n * `{connId → sessionId}` registry and made the authoritative identity for that\n * dispatch (see {@link routeFrame}). On socket `close` the binding is removed so\n * the registry never grows unbounded across a long-lived daemon process.\n *\n * @param handler - The injected gateway handler.\n * @param socket - The accepted `node:net` connection.\n * @param log - The adapter's pino logger.\n */\nfunction handleConnection(\n handler: GatewayHandler,\n socket: Socket,\n log: ReturnType<typeof getLogger>,\n): void {\n socket.setEncoding('utf8');\n const lines = new LineBuffer();\n const connId = randomUUID();\n\n socket.on('data', (chunk: string) => {\n for (const line of lines.push(chunk)) {\n const decoded = decodeLine(line);\n if (decoded.kind === 'error') {\n socket.write(encodeFrame(decoded.frame));\n continue;\n }\n routeFrame(handler, decoded.frame, connId)\n .then((out) => {\n if (!socket.destroyed) socket.write(encodeFrame(out));\n })\n .catch((err: unknown) => {\n // routeFrame already traps handler errors; this guards the framing path.\n const message = err instanceof Error ? err.message : String(err);\n log.warn({ err }, 'rpc frame routing failed');\n if (!socket.destroyed) {\n socket.write(encodeFrame(buildErrorFrame(decoded.frame.id, 'E_RPC_INTERNAL', message)));\n }\n });\n }\n });\n\n socket.on('error', (err) => {\n log.warn({ err }, 'rpc connection error');\n });\n\n // Release the connection→session binding when the socket goes away so the\n // in-memory registry stays bounded across the daemon's lifetime (T11640).\n socket.on('close', () => {\n unbindConnectionSession(connId);\n });\n}\n\n/**\n * Start the CLI-RPC unix-socket server over the gateway.\n *\n * Binds {@link RpcServerOptions.socketPath}, accepts connections, and routes\n * each inbound NDJSON `request` frame through the injected {@link GatewayHandler}.\n * The caller assembles the handler (via `createGatewayHandler` with its domain\n * handlers + middleware) and injects it here — this adapter never builds\n * handlers itself, keeping the runtime free of any `@cleocode/cleo` dependency.\n *\n * Returns once the socket is listening. The returned {@link RpcServerHandle}\n * exposes an idempotent `close()` for deterministic teardown (daemon shutdown,\n * tests). The adapter never calls `process.exit`.\n *\n * @param handler - The transport-neutral gateway handler to route through.\n * @param opts - Socket path + lifecycle options.\n * @returns A promise resolving to the live {@link RpcServerHandle}.\n */\nexport function startRpcServer(\n handler: GatewayHandler,\n opts: RpcServerOptions,\n): Promise<RpcServerHandle> {\n const log = getLogger('gateway-rpc');\n const removeStale = opts.removeStaleSocket ?? true;\n\n if (removeStale && existsSync(opts.socketPath)) {\n try {\n unlinkSync(opts.socketPath);\n } catch (err) {\n log.warn({ err, socketPath: opts.socketPath }, 'failed to unlink stale rpc socket');\n }\n }\n\n const server = createServer((socket) => handleConnection(handler, socket, log));\n\n return new Promise<RpcServerHandle>((resolve, reject) => {\n server.once('error', reject);\n server.listen(opts.socketPath, () => {\n server.removeListener('error', reject);\n log.info(\n { socketPath: opts.socketPath, version: GATEWAY_RPC_PROTOCOL_VERSION },\n 'rpc server listening',\n );\n\n let closed = false;\n const handle: RpcServerHandle = {\n server,\n socketPath: opts.socketPath,\n close(): Promise<void> {\n if (closed) return Promise.resolve();\n closed = true;\n return new Promise<void>((res) => {\n server.close(() => {\n if (existsSync(opts.socketPath)) {\n try {\n unlinkSync(opts.socketPath);\n } catch {\n // best-effort cleanup; the OS reclaims the node on exit anyway.\n }\n }\n res();\n });\n });\n },\n };\n resolve(handle);\n });\n });\n}\n"],"mappings":"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","names":["GATEWAY_RPC_PROTOCOL_VERSION","GATEWAY_RPC_PROTOCOL_VERSION"]}
@@ -0,0 +1,91 @@
1
+ // src/gateway/registry.ts
2
+ import { OPERATIONS as _OPERATIONS } from "@cleocode/contracts";
3
+ var OPERATIONS = _OPERATIONS;
4
+ function deriveGatewayMatrix(gateway) {
5
+ const matrix = {};
6
+ for (const op of OPERATIONS) {
7
+ if (op.gateway !== gateway) continue;
8
+ if (!matrix[op.domain]) matrix[op.domain] = [];
9
+ matrix[op.domain].push(op.operation);
10
+ }
11
+ return matrix;
12
+ }
13
+ function getGatewayDomains(gateway) {
14
+ return Object.keys(deriveGatewayMatrix(gateway));
15
+ }
16
+ function resolve(gateway, domain, operation) {
17
+ const def = OPERATIONS.find(
18
+ (o) => o.gateway === gateway && o.domain === domain && o.operation === operation
19
+ );
20
+ if (!def) return void 0;
21
+ return { domain: def.domain, operation: def.operation, def };
22
+ }
23
+ function inferGateway(domain, operation) {
24
+ const def = OPERATIONS.find((o) => o.domain === domain && o.operation === operation);
25
+ return def?.gateway;
26
+ }
27
+ function resolveStreamRoute(domain, operation) {
28
+ const def = OPERATIONS.find(
29
+ (o) => o.domain === domain && o.operation === operation && o.streaming === true
30
+ );
31
+ if (def === void 0) return void 0;
32
+ return { gateway: def.gateway, def };
33
+ }
34
+ function validateRequiredParams(def, params) {
35
+ if (!def.requiredParams || def.requiredParams.length === 0) return [];
36
+ const provided = params || {};
37
+ const allowEmptyKeys = /* @__PURE__ */ new Set();
38
+ if (def.params) {
39
+ for (const p of def.params) {
40
+ if (p.allowEmpty === true) allowEmptyKeys.add(p.name);
41
+ }
42
+ }
43
+ return def.requiredParams.filter((key) => {
44
+ const v = provided[key];
45
+ if (v === void 0 || v === null) return true;
46
+ if (v === "" && !allowEmptyKeys.has(key)) return true;
47
+ return false;
48
+ });
49
+ }
50
+ function getByDomain(domain) {
51
+ return OPERATIONS.filter((o) => o.domain === domain);
52
+ }
53
+ function getByGateway(gateway) {
54
+ return OPERATIONS.filter((o) => o.gateway === gateway);
55
+ }
56
+ function getByTier(tier) {
57
+ return OPERATIONS.filter((o) => o.tier <= tier);
58
+ }
59
+ function getActiveDomains() {
60
+ const active = new Set(OPERATIONS.map((o) => o.domain));
61
+ return Array.from(active);
62
+ }
63
+ function getCounts() {
64
+ return {
65
+ query: OPERATIONS.filter((o) => o.gateway === "query").length,
66
+ mutate: OPERATIONS.filter((o) => o.gateway === "mutate").length,
67
+ total: OPERATIONS.length
68
+ };
69
+ }
70
+ var counts = getCounts();
71
+ if (counts.total !== OPERATIONS.length) {
72
+ console.warn(
73
+ `[Registry] Operation count mismatch: total=${counts.total}, registry=${OPERATIONS.length}`
74
+ );
75
+ }
76
+
77
+ export {
78
+ OPERATIONS,
79
+ deriveGatewayMatrix,
80
+ getGatewayDomains,
81
+ resolve,
82
+ inferGateway,
83
+ resolveStreamRoute,
84
+ validateRequiredParams,
85
+ getByDomain,
86
+ getByGateway,
87
+ getByTier,
88
+ getActiveDomains,
89
+ getCounts
90
+ };
91
+ //# sourceMappingURL=chunk-L5BLVQDA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/gateway/registry.ts"],"sourcesContent":["/**\n * Unified CQRS Dispatch Layer -- Operation Registry (shim)\n *\n * The `OPERATIONS` data array was relocated to\n * `@cleocode/contracts/dispatch/operations-registry` in T10061 / T9833b\n * (E-CLI-BOUNDARY · Saga T9831 SG-ARCH-SOLID). This file is now a thin\n * re-export shim that:\n *\n * 1. Re-exports `OPERATIONS` from contracts so every downstream consumer\n * that already imports `{ OPERATIONS }` from this path continues to\n * compile unchanged.\n * 2. Keeps the pure utility / derivation functions (`resolve`,\n * `validateRequiredParams`, `getByDomain`, etc.) that operate on\n * the data and are rightly colocated with the CLI package.\n *\n * @epic T4820\n * @task T4814, T5241, T5615, T10061\n */\n\n// OperationDef + Resolution live in @cleocode/contracts (SSoT — promoted in\n// T9954 / Phase 0b of SG-ARCH-SOLID Saga T9831 · E-CONTRACTS-FOUNDATION\n// T9832). Imported below for internal use AND re-exported so that every\n// downstream consumer that already imports from './registry.js' continues\n// to compile unchanged.\nimport type { OperationDef, Resolution } from '@cleocode/contracts';\nimport { OPERATIONS as _OPERATIONS } from '@cleocode/contracts';\nimport type { CanonicalDomain, Gateway, Tier } from '@cleocode/contracts/gateway';\n\nexport type { OperationDef, Resolution };\n\n/** The single source of truth for all operations in CLEO (data lives in @cleocode/contracts). */\nexport const OPERATIONS: OperationDef[] = _OPERATIONS;\n\n// ---------------------------------------------------------------------------\n// Gateway Matrix Derivation\n// ---------------------------------------------------------------------------\n\n/**\n * Derive a gateway operation matrix from the registry.\n *\n * Returns `Record<string, string[]>` containing:\n * - All canonical domains with their operations\n *\n * This is the SINGLE derivation point — gateways use this instead of\n * maintaining independent operation lists.\n */\nexport function deriveGatewayMatrix(gateway: Gateway): Record<string, string[]> {\n const matrix: Record<string, string[]> = {};\n\n for (const op of OPERATIONS) {\n if (op.gateway !== gateway) continue;\n if (!matrix[op.domain]) matrix[op.domain] = [];\n matrix[op.domain].push(op.operation);\n }\n\n return matrix;\n}\n\n/**\n * Get all accepted domain names for a gateway (canonical only).\n */\nexport function getGatewayDomains(gateway: Gateway): string[] {\n return Object.keys(deriveGatewayMatrix(gateway));\n}\n\n// ---------------------------------------------------------------------------\n// Lookup & Validation\n// ---------------------------------------------------------------------------\n\n/**\n * Resolves a domain + operation to its registered definition.\n */\nexport function resolve(\n gateway: Gateway,\n domain: string,\n operation: string,\n): Resolution | undefined {\n const def = OPERATIONS.find(\n (o) => o.gateway === gateway && o.domain === domain && o.operation === operation,\n );\n\n if (!def) return undefined;\n\n return { domain: def.domain, operation: def.operation, def };\n}\n\n/**\n * Infer the CQRS {@link Gateway} for a `(domain, operation)` pair from the\n * registry, without the caller naming `query`/`mutate` up front.\n *\n * This backs the versioned `/v1/<domain>/<operation>` REST facade (T11919),\n * where — unlike the legacy `/<gateway>/<domain>/<operation>` form — the client\n * does not spell the gateway segment: the registry is the single source of truth\n * for whether an operation is a read (`query`) or a write (`mutate`). Each\n * `(domain, operation)` pair maps to exactly one gateway in {@link OPERATIONS},\n * so the inference is unambiguous.\n *\n * @param domain - The canonical domain segment (e.g. `'tasks'`).\n * @param operation - The operation segment (e.g. `'show'`).\n * @returns The owning gateway, or `undefined` when no registered operation\n * matches the pair (the wire edge then renders a `404`).\n */\nexport function inferGateway(domain: string, operation: string): Gateway | undefined {\n const def = OPERATIONS.find((o) => o.domain === domain && o.operation === operation);\n return def?.gateway;\n}\n\n/**\n * The result of resolving a streaming route: the owning gateway plus the matched\n * {@link OperationDef} (so the caller has the op's tier/params on hand without a\n * second lookup).\n */\nexport interface StreamResolution {\n /** The CQRS gateway the streaming op is registered under. */\n gateway: Gateway;\n /** The matched streaming operation definition. */\n def: OperationDef;\n}\n\n/**\n * Resolve a `(domain, operation)` pair to a registered **streaming** operation.\n *\n * Backs the `GET /v1/<domain>/<operation>` SSE route (T11921): the listener only\n * opens a `text/event-stream` for a `(domain, operation)` pair that is BOTH\n * registered AND explicitly flagged `streaming: true` in {@link OPERATIONS}. A\n * unary-only op (or an unknown pair) resolves to `undefined`, so the listener\n * never streams an operation that was not declared streamable — a GET to a\n * unary op stays a `405` (method-not-allowed) rather than silently opening a\n * never-ending stream.\n *\n * @param domain - The canonical domain segment (e.g. `'orchestrate'`).\n * @param operation - The operation segment (e.g. `'events'`).\n * @returns The streaming resolution, or `undefined` when no registered op\n * matches the pair OR the matched op is not flagged `streaming: true`.\n */\nexport function resolveStreamRoute(\n domain: string,\n operation: string,\n): StreamResolution | undefined {\n const def = OPERATIONS.find(\n (o) => o.domain === domain && o.operation === operation && o.streaming === true,\n );\n if (def === undefined) return undefined;\n return { gateway: def.gateway, def };\n}\n\n/**\n * Validates that all required parameters are present in the request.\n * Returns an array of missing parameter keys.\n */\nexport function validateRequiredParams(\n def: OperationDef,\n params?: Record<string, unknown>,\n): string[] {\n if (!def.requiredParams || def.requiredParams.length === 0) return [];\n const provided = params || {};\n // Build a quick lookup so we can honor per-param `allowEmpty` declarations.\n // When a ParamDef sets `allowEmpty: true`, the empty string `\"\"` is a\n // valid value rather than \"missing\" — e.g. `provenance.backfill --since \"\"`\n // walks every reachable tag from the beginning of history.\n const allowEmptyKeys = new Set<string>();\n if (def.params) {\n for (const p of def.params) {\n if (p.allowEmpty === true) allowEmptyKeys.add(p.name);\n }\n }\n return def.requiredParams.filter((key) => {\n const v = provided[key];\n if (v === undefined || v === null) return true;\n if (v === '' && !allowEmptyKeys.has(key)) return true;\n return false;\n });\n}\n\n/** Get all operations for a specific canonical domain. */\nexport function getByDomain(domain: CanonicalDomain): OperationDef[] {\n return OPERATIONS.filter((o) => o.domain === domain);\n}\n\n/** Get all operations for a specific gateway. */\nexport function getByGateway(gateway: Gateway): OperationDef[] {\n return OPERATIONS.filter((o) => o.gateway === gateway);\n}\n\n/** Get all operations available at or below a specific tier. */\nexport function getByTier(tier: Tier): OperationDef[] {\n return OPERATIONS.filter((o) => o.tier <= tier);\n}\n\n/** Get a list of canonical domains that actually have operations registered. */\nexport function getActiveDomains(): CanonicalDomain[] {\n const active = new Set(OPERATIONS.map((o) => o.domain));\n return Array.from(active);\n}\n\n/**\n * Returns summary counts of operations for module validation.\n */\nexport function getCounts(): { query: number; mutate: number; total: number } {\n return {\n query: OPERATIONS.filter((o) => o.gateway === 'query').length,\n mutate: OPERATIONS.filter((o) => o.gateway === 'mutate').length,\n total: OPERATIONS.length,\n };\n}\n\n// Module load validation (dynamic, no hardcoded operation totals)\nconst counts = getCounts();\nif (counts.total !== OPERATIONS.length) {\n console.warn(\n `[Registry] Operation count mismatch: total=${counts.total}, registry=${OPERATIONS.length}`,\n );\n}\n"],"mappings":";AAyBA,SAAS,cAAc,mBAAmB;AAMnC,IAAM,aAA6B;AAenC,SAAS,oBAAoB,SAA4C;AAC9E,QAAM,SAAmC,CAAC;AAE1C,aAAW,MAAM,YAAY;AAC3B,QAAI,GAAG,YAAY,QAAS;AAC5B,QAAI,CAAC,OAAO,GAAG,MAAM,EAAG,QAAO,GAAG,MAAM,IAAI,CAAC;AAC7C,WAAO,GAAG,MAAM,EAAE,KAAK,GAAG,SAAS;AAAA,EACrC;AAEA,SAAO;AACT;AAKO,SAAS,kBAAkB,SAA4B;AAC5D,SAAO,OAAO,KAAK,oBAAoB,OAAO,CAAC;AACjD;AASO,SAAS,QACd,SACA,QACA,WACwB;AACxB,QAAM,MAAM,WAAW;AAAA,IACrB,CAAC,MAAM,EAAE,YAAY,WAAW,EAAE,WAAW,UAAU,EAAE,cAAc;AAAA,EACzE;AAEA,MAAI,CAAC,IAAK,QAAO;AAEjB,SAAO,EAAE,QAAQ,IAAI,QAAQ,WAAW,IAAI,WAAW,IAAI;AAC7D;AAkBO,SAAS,aAAa,QAAgB,WAAwC;AACnF,QAAM,MAAM,WAAW,KAAK,CAAC,MAAM,EAAE,WAAW,UAAU,EAAE,cAAc,SAAS;AACnF,SAAO,KAAK;AACd;AA8BO,SAAS,mBACd,QACA,WAC8B;AAC9B,QAAM,MAAM,WAAW;AAAA,IACrB,CAAC,MAAM,EAAE,WAAW,UAAU,EAAE,cAAc,aAAa,EAAE,cAAc;AAAA,EAC7E;AACA,MAAI,QAAQ,OAAW,QAAO;AAC9B,SAAO,EAAE,SAAS,IAAI,SAAS,IAAI;AACrC;AAMO,SAAS,uBACd,KACA,QACU;AACV,MAAI,CAAC,IAAI,kBAAkB,IAAI,eAAe,WAAW,EAAG,QAAO,CAAC;AACpE,QAAM,WAAW,UAAU,CAAC;AAK5B,QAAM,iBAAiB,oBAAI,IAAY;AACvC,MAAI,IAAI,QAAQ;AACd,eAAW,KAAK,IAAI,QAAQ;AAC1B,UAAI,EAAE,eAAe,KAAM,gBAAe,IAAI,EAAE,IAAI;AAAA,IACtD;AAAA,EACF;AACA,SAAO,IAAI,eAAe,OAAO,CAAC,QAAQ;AACxC,UAAM,IAAI,SAAS,GAAG;AACtB,QAAI,MAAM,UAAa,MAAM,KAAM,QAAO;AAC1C,QAAI,MAAM,MAAM,CAAC,eAAe,IAAI,GAAG,EAAG,QAAO;AACjD,WAAO;AAAA,EACT,CAAC;AACH;AAGO,SAAS,YAAY,QAAyC;AACnE,SAAO,WAAW,OAAO,CAAC,MAAM,EAAE,WAAW,MAAM;AACrD;AAGO,SAAS,aAAa,SAAkC;AAC7D,SAAO,WAAW,OAAO,CAAC,MAAM,EAAE,YAAY,OAAO;AACvD;AAGO,SAAS,UAAU,MAA4B;AACpD,SAAO,WAAW,OAAO,CAAC,MAAM,EAAE,QAAQ,IAAI;AAChD;AAGO,SAAS,mBAAsC;AACpD,QAAM,SAAS,IAAI,IAAI,WAAW,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC;AACtD,SAAO,MAAM,KAAK,MAAM;AAC1B;AAKO,SAAS,YAA8D;AAC5E,SAAO;AAAA,IACL,OAAO,WAAW,OAAO,CAAC,MAAM,EAAE,YAAY,OAAO,EAAE;AAAA,IACvD,QAAQ,WAAW,OAAO,CAAC,MAAM,EAAE,YAAY,QAAQ,EAAE;AAAA,IACzD,OAAO,WAAW;AAAA,EACpB;AACF;AAGA,IAAM,SAAS,UAAU;AACzB,IAAI,OAAO,UAAU,WAAW,QAAQ;AACtC,UAAQ;AAAA,IACN,8CAA8C,OAAO,KAAK,cAAc,WAAW,MAAM;AAAA,EAC3F;AACF;","names":[]}