@cleocode/runtime 2026.6.11 → 2026.6.13
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.
- package/dist/{chunk-WSQQ5DQL.js → chunk-GOWVGQ5Q.js} +60 -3
- package/dist/chunk-GOWVGQ5Q.js.map +1 -0
- package/dist/{chunk-5ZXRYEQ5.js → chunk-KQBVXGOL.js} +21 -4
- package/dist/chunk-KQBVXGOL.js.map +1 -0
- package/dist/daemon/index.d.ts +109 -2
- package/dist/daemon/index.js +5 -1
- package/dist/gateway/index.js +2 -2
- package/dist/gateway/rpc/index.d.ts +28 -1
- package/dist/gateway/rpc/index.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/dist/chunk-5ZXRYEQ5.js.map +0 -1
- package/dist/chunk-WSQQ5DQL.js.map +0 -1
|
@@ -20,6 +20,61 @@ 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
|
+
|
|
23
78
|
// src/daemon/logger.ts
|
|
24
79
|
import { getLogger } from "@cleocode/core";
|
|
25
80
|
function createSubsystemLogger(name) {
|
|
@@ -170,7 +225,7 @@ var MalformedIpcFrameError = class extends Error {
|
|
|
170
225
|
this.cause = cause;
|
|
171
226
|
}
|
|
172
227
|
};
|
|
173
|
-
function
|
|
228
|
+
function nextCorrelationId2(seq) {
|
|
174
229
|
return `c${seq}-${Math.random().toString(36).slice(2, 10)}`;
|
|
175
230
|
}
|
|
176
231
|
function createSupervisorIpcClient() {
|
|
@@ -178,7 +233,7 @@ function createSupervisorIpcClient() {
|
|
|
178
233
|
return {
|
|
179
234
|
encodeRequest(request) {
|
|
180
235
|
seq += 1;
|
|
181
|
-
const id =
|
|
236
|
+
const id = nextCorrelationId2(seq);
|
|
182
237
|
const envelope = {
|
|
183
238
|
protocol_version: SUPERVISOR_IPC_PROTOCOL_VERSION,
|
|
184
239
|
id,
|
|
@@ -207,9 +262,11 @@ function createSupervisorIpcClient() {
|
|
|
207
262
|
|
|
208
263
|
export {
|
|
209
264
|
defineSubsystem,
|
|
265
|
+
MalformedLeaseIpcFrameError,
|
|
266
|
+
createLeaseIpcClient,
|
|
210
267
|
createSubsystemLogger,
|
|
211
268
|
SubsystemRegistry,
|
|
212
269
|
MalformedIpcFrameError,
|
|
213
270
|
createSupervisorIpcClient
|
|
214
271
|
};
|
|
215
|
-
//# sourceMappingURL=chunk-
|
|
272
|
+
//# sourceMappingURL=chunk-GOWVGQ5Q.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/daemon/define-subsystem.ts","../src/daemon/lease-ipc-client.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 * 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;;;ACtHA,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
|
-
|
|
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
|
|
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-
|
|
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"]}
|
package/dist/daemon/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SubsystemDefinition, Subsystem, DaemonLifecycleHooks, HealthStatus, SupervisorIpcRequest, SupervisorIpcResponseEnvelope } from '@cleocode/contracts';
|
|
1
|
+
import { SubsystemDefinition, Subsystem, LeaseIpcRequest, LeaseIpcResponseEnvelope, DaemonLifecycleHooks, HealthStatus, SupervisorIpcRequest, SupervisorIpcResponseEnvelope } from '@cleocode/contracts';
|
|
2
2
|
export { DaemonLifecycleHooks, HealthStatus, Subsystem, SubsystemDefinition, SubsystemHealth, SubsystemLifecyclePhase, SubsystemState } from '@cleocode/contracts';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -45,6 +45,113 @@ export { DaemonLifecycleHooks, HealthStatus, Subsystem, SubsystemDefinition, Sub
|
|
|
45
45
|
*/
|
|
46
46
|
declare function defineSubsystem<TContext = void>(definition: SubsystemDefinition<TContext>): Subsystem<TContext>;
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Typed NDJSON client for the PARALLEL `lease-ipc` v1.1 contract (T11627 ST-5).
|
|
50
|
+
*
|
|
51
|
+
* Mirrors {@link ./supervisor-client.ts | supervisor-client.ts} conventions
|
|
52
|
+
* exactly — a transport-agnostic codec that **owns no socket**. It encodes
|
|
53
|
+
* outbound {@link LeaseIpcRequestEnvelope}s to newline-delimited JSON (NDJSON)
|
|
54
|
+
* lines and decodes inbound lines through the v1.1 Zod schemas. **Every inbound
|
|
55
|
+
* line is parsed** — a malformed or schema-violating frame is rejected with a
|
|
56
|
+
* typed {@link MalformedLeaseIpcFrameError} and never silently dropped.
|
|
57
|
+
*
|
|
58
|
+
* The wire format is byte-identical framing to the frozen `supervisor-ipc` v1.0
|
|
59
|
+
* envelope; only the version string ({@link LEASE_IPC_PROTOCOL_VERSION} =
|
|
60
|
+
* `1.1.0`) and the inner union differ. A single supervisor accept loop routes
|
|
61
|
+
* `1.0.0` frames to the v1.0 union and `1.1.0` frames to this lease union.
|
|
62
|
+
*
|
|
63
|
+
* The Rust `cleo-supervisor` is the arbiter (server); a CLEO process running in
|
|
64
|
+
* `CLEO_WRITER_LEASE_MODE=supervisor` is a client that sends lease requests and
|
|
65
|
+
* receives lease responses + unsolicited events (`lease_revoked`,
|
|
66
|
+
* `child_killed_unresponsive`), one NDJSON envelope per line.
|
|
67
|
+
*
|
|
68
|
+
* @packageDocumentation
|
|
69
|
+
* @module @cleocode/runtime/daemon
|
|
70
|
+
*
|
|
71
|
+
* @epic T11625
|
|
72
|
+
* @task T11894 — supervisor fast path: TS lease-ipc-client (ST-5)
|
|
73
|
+
* @saga T11243 SG-RUNTIME-UNIFICATION
|
|
74
|
+
* @see ./supervisor-client.ts — the v1.0 codec this mirrors
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A typed error raised when an inbound NDJSON line cannot be decoded into a
|
|
79
|
+
* valid {@link LeaseIpcResponseEnvelope}.
|
|
80
|
+
*
|
|
81
|
+
* Carries the offending raw `line` and the underlying `cause` (a JSON
|
|
82
|
+
* `SyntaxError` or a Zod validation error) so callers can log + correlate.
|
|
83
|
+
* Invalid frames are surfaced as this error — never silently dropped. This is
|
|
84
|
+
* the v1.1 sibling of {@link MalformedIpcFrameError}.
|
|
85
|
+
*
|
|
86
|
+
* @public
|
|
87
|
+
*/
|
|
88
|
+
declare class MalformedLeaseIpcFrameError extends Error {
|
|
89
|
+
/** The raw NDJSON line that failed to decode. */
|
|
90
|
+
readonly line: string;
|
|
91
|
+
/**
|
|
92
|
+
* @param line - The offending raw NDJSON line.
|
|
93
|
+
* @param cause - The underlying JSON or Zod error.
|
|
94
|
+
*/
|
|
95
|
+
constructor(line: string, cause: unknown);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A transport-agnostic codec for the PARALLEL `lease-ipc` v1.1 wire format.
|
|
99
|
+
*
|
|
100
|
+
* Construct via {@link createLeaseIpcClient}. The client is stateful only in its
|
|
101
|
+
* monotonic correlation-id counter; it owns no socket. Callers feed it raw lines
|
|
102
|
+
* from whatever transport (Unix socket, named pipe, test pair) they own.
|
|
103
|
+
*/
|
|
104
|
+
interface LeaseIpcClient {
|
|
105
|
+
/**
|
|
106
|
+
* Encode a request body into a complete, newline-terminated NDJSON envelope
|
|
107
|
+
* line ready to write to the transport.
|
|
108
|
+
*
|
|
109
|
+
* Stamps the parallel protocol version ({@link LEASE_IPC_PROTOCOL_VERSION}) and
|
|
110
|
+
* a fresh correlation `id`, validates the result against
|
|
111
|
+
* {@link LeaseIpcRequestEnvelopeSchema}, and returns the serialized line
|
|
112
|
+
* (including the trailing `\n`).
|
|
113
|
+
*
|
|
114
|
+
* @param request - The request body (validated by the envelope schema).
|
|
115
|
+
* @returns `{ id, line }` — the correlation id and the NDJSON line to send.
|
|
116
|
+
*/
|
|
117
|
+
encodeRequest: (request: LeaseIpcRequest) => {
|
|
118
|
+
id: string;
|
|
119
|
+
line: string;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Decode a single inbound NDJSON line into a typed response envelope.
|
|
123
|
+
*
|
|
124
|
+
* Parses the line as JSON, then validates it against
|
|
125
|
+
* {@link LeaseIpcResponseEnvelopeSchema}. A malformed or schema-violating line
|
|
126
|
+
* throws {@link MalformedLeaseIpcFrameError} — it is never silently dropped.
|
|
127
|
+
*
|
|
128
|
+
* @param line - A single NDJSON line (without the trailing newline).
|
|
129
|
+
* @returns The decoded {@link LeaseIpcResponseEnvelope}.
|
|
130
|
+
* @throws {MalformedLeaseIpcFrameError} When the line is not a valid response frame.
|
|
131
|
+
*/
|
|
132
|
+
decodeResponseLine: (line: string) => LeaseIpcResponseEnvelope;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Create a typed NDJSON codec for the PARALLEL `lease-ipc` v1.1 contract.
|
|
136
|
+
*
|
|
137
|
+
* @returns A {@link LeaseIpcClient}.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* const client = createLeaseIpcClient();
|
|
142
|
+
* const { id, line } = client.encodeRequest({
|
|
143
|
+
* kind: 'lease_acquire', scope: 'project', lane: 'tasks',
|
|
144
|
+
* holder_id: 'pid-42:tasks', priority: 0, ttl_ms: 30_000, reentrant: true,
|
|
145
|
+
* });
|
|
146
|
+
* socket.write(line);
|
|
147
|
+
* socket.on('line', (raw) => {
|
|
148
|
+
* const env = client.decodeResponseLine(raw); // throws on a bad frame
|
|
149
|
+
* if (env.response.kind === 'lease_granted') { /* ... *\/ }
|
|
150
|
+
* });
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
declare function createLeaseIpcClient(): LeaseIpcClient;
|
|
154
|
+
|
|
48
155
|
/**
|
|
49
156
|
* Daemon subsystem log-routing primitive.
|
|
50
157
|
*
|
|
@@ -303,4 +410,4 @@ interface SupervisorIpcClient {
|
|
|
303
410
|
*/
|
|
304
411
|
declare function createSupervisorIpcClient(): SupervisorIpcClient;
|
|
305
412
|
|
|
306
|
-
export { MalformedIpcFrameError, type SubsystemLogFields, type SubsystemLogger, SubsystemRegistry, type SupervisorIpcClient, createSubsystemLogger, createSupervisorIpcClient, defineSubsystem };
|
|
413
|
+
export { type LeaseIpcClient, MalformedIpcFrameError, MalformedLeaseIpcFrameError, type SubsystemLogFields, type SubsystemLogger, SubsystemRegistry, type SupervisorIpcClient, createLeaseIpcClient, createSubsystemLogger, createSupervisorIpcClient, defineSubsystem };
|
package/dist/daemon/index.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MalformedIpcFrameError,
|
|
3
|
+
MalformedLeaseIpcFrameError,
|
|
3
4
|
SubsystemRegistry,
|
|
5
|
+
createLeaseIpcClient,
|
|
4
6
|
createSubsystemLogger,
|
|
5
7
|
createSupervisorIpcClient,
|
|
6
8
|
defineSubsystem
|
|
7
|
-
} from "../chunk-
|
|
9
|
+
} from "../chunk-GOWVGQ5Q.js";
|
|
8
10
|
export {
|
|
9
11
|
MalformedIpcFrameError,
|
|
12
|
+
MalformedLeaseIpcFrameError,
|
|
10
13
|
SubsystemRegistry,
|
|
14
|
+
createLeaseIpcClient,
|
|
11
15
|
createSubsystemLogger,
|
|
12
16
|
createSupervisorIpcClient,
|
|
13
17
|
defineSubsystem
|
package/dist/gateway/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
defineSubsystem
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-GOWVGQ5Q.js";
|
|
4
4
|
import {
|
|
5
5
|
MCP_TOOL_PREFIX,
|
|
6
6
|
OPERATIONS,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
encodeFrame,
|
|
29
29
|
routeFrame,
|
|
30
30
|
startRpcServer
|
|
31
|
-
} from "../chunk-
|
|
31
|
+
} from "../chunk-KQBVXGOL.js";
|
|
32
32
|
import {
|
|
33
33
|
SSE_HEADERS,
|
|
34
34
|
createSseStream,
|
|
@@ -348,11 +348,38 @@ interface RpcServerHandle {
|
|
|
348
348
|
* handler errors are caught and rendered as a protocol-level `E_RPC_INTERNAL`
|
|
349
349
|
* error frame — never propagated as an unhandled rejection or `process.exit`.
|
|
350
350
|
*
|
|
351
|
+
* ## Connection-scoped session binding (T11640 · Epic T11638)
|
|
352
|
+
*
|
|
353
|
+
* The daemon serves many connections over one process, so it cannot rely on the
|
|
354
|
+
* single-process session-context singleton for identity. Instead, a frame on a
|
|
355
|
+
* connection that declares a `sessionId` binds it into the `{connId → sessionId}`
|
|
356
|
+
* registry, and the whole dispatch runs inside {@link runWithConnectionHandle}
|
|
357
|
+
* so `core`'s `resolveCurrentSession` resolves THIS connection's session (its
|
|
358
|
+
* highest-precedence tier) rather than "whoever wrote the DB last". When a frame
|
|
359
|
+
* carries no `sessionId`, no binding is performed and resolution transparently
|
|
360
|
+
* falls through to the env / active tiers — preserving existing behavior for
|
|
361
|
+
* anonymous frames.
|
|
362
|
+
*
|
|
363
|
+
* ### Intra-connection bleed safety
|
|
364
|
+
*
|
|
365
|
+
* Frames on one connection are dispatched CONCURRENTLY (each `routeFrame` is
|
|
366
|
+
* fired without awaiting the prior one), and registry binding is last-write-wins.
|
|
367
|
+
* To stop a still-in-flight Frame A from resolving a sibling Frame B's session,
|
|
368
|
+
* the frame's OWN declared `sessionId` is snapshotted into the per-frame
|
|
369
|
+
* connection handle ({@link runWithConnectionHandle}'s third arg) — that snapshot
|
|
370
|
+
* is authoritative for the dispatch, so a later re-bind of the same `connId`
|
|
371
|
+
* cannot retroactively change who Frame A is. The registry binding remains only
|
|
372
|
+
* for the late-binding path used by frames that declare no session of their own.
|
|
373
|
+
*
|
|
351
374
|
* @param handler - The injected transport-neutral gateway handler.
|
|
352
375
|
* @param frame - A validated, version-matched request frame.
|
|
376
|
+
* @param connId - The opaque per-connection id (bound at accept-time). Omitted
|
|
377
|
+
* only by direct/test callers that route a single frame outside a socket; a
|
|
378
|
+
* fresh ephemeral id is minted so the call still runs in an isolated
|
|
379
|
+
* connection-handle scope.
|
|
353
380
|
* @returns The response or error frame to write back on the same connection.
|
|
354
381
|
*/
|
|
355
|
-
declare function routeFrame(handler: GatewayHandler, frame: GatewayRpcRequestFrame): Promise<GatewayRpcResponseFrame | ReturnType<typeof buildErrorFrame>>;
|
|
382
|
+
declare function routeFrame(handler: GatewayHandler, frame: GatewayRpcRequestFrame, connId?: string): Promise<GatewayRpcResponseFrame | ReturnType<typeof buildErrorFrame>>;
|
|
356
383
|
/**
|
|
357
384
|
* Start the CLI-RPC unix-socket server over the gateway.
|
|
358
385
|
*
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/runtime",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.13",
|
|
4
4
|
"description": "Long-running process layer for CLEO — agent polling, SSE connections, heartbeat, key rotation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"node-cron": "^4.2.1",
|
|
36
|
-
"@cleocode/contracts": "2026.6.
|
|
37
|
-
"@cleocode/core": "2026.6.
|
|
36
|
+
"@cleocode/contracts": "2026.6.13",
|
|
37
|
+
"@cleocode/core": "2026.6.13"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"tsup": "^8.0.0",
|
|
@@ -1 +0,0 @@
|
|
|
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 { 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 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 * @param handler - The injected transport-neutral gateway handler.\n * @param frame - A validated, version-matched request frame.\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): 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 try {\n const response = await handler.handle(request);\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 * @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\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)\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\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"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/daemon/define-subsystem.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 * 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":"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","names":[]}
|