@henols/vice-mcp 0.1.4

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.
@@ -0,0 +1,899 @@
1
+ #!/usr/bin/env node
2
+ // Container-side half of the on-demand broker protocol. Through Phase 01.2
3
+ // this module wrote the request/lease files resources/vice-broker.sh read
4
+ // and read the grant/denial/broker files that script wrote, all on the SAME
5
+ // .vice-supervisor/ bind mount tools/vice-supervisor.sh's epoch.json already
6
+ // used. Plan 01.6.2-07 deletes that file protocol wholesale (D-12: six
7
+ // mechanisms retiring together -- startHeartbeat()/the mtime-as-heartbeat
8
+ // convention/touchLease()/pollGrant()/pollRecycleAck()/the request-grant-
9
+ // denial-lease-recycle-ack directory tree) now that vice-proxy.ts's
10
+ // acquisition, release AND recycle all run over the TCP control plane
11
+ // (openBrokerControl()/BrokerControlSession below) instead. What survives:
12
+ // the request-id primitives (the new client's own acquire()/recycle() still
13
+ // mint ids with newRequestId()), brokerRootDir()/brokerJsonPath() (the
14
+ // discovery record's own location), and readBrokerLiveness() (unchanged
15
+ // classification, still reading the SAME broker.json openBrokerControl()
16
+ // reads for its control_host/control_port/control_token).
17
+ //
18
+ // Every read of broker.json is still untrusted input: parse in try/catch, a
19
+ // malformed or half-written file is "not there yet" or "absent", never a
20
+ // thrown exception. See 01.2-PATTERNS.md's "Never-throw /
21
+ // never-cache-a-negative-result" section.
22
+ //
23
+ // MUST NOT import hostpath.ts: the host-path consumer set is closed to
24
+ // four production modules by vice-mcp-selector-docs.test.mjs's assertion 4,
25
+ // and host-path message text stays in vice-proxy.mjs, which is already on
26
+ // that list.
27
+ import { readFileSync } from "node:fs";
28
+ import { randomUUID } from "node:crypto";
29
+ import { join, resolve } from "node:path";
30
+ import { connect, type Socket } from "node:net";
31
+
32
+ import { supervisorDir } from "./repo-root.ts";
33
+ // The module tree's ONE definition of the container-visible host alias
34
+ // (vice.ts:49), carrying its own VICE_MCP_HOST override -- consumed below by
35
+ // resolveControlTarget() rather than a fourth `host.docker.internal` literal
36
+ // (vice.ts:35-48 names the three duplicate copies that predated that
37
+ // function; this file must not become a fourth). Deliberately NOT
38
+ // `containerpath.ts`'s `containerHost()`: that function rewrites URL
39
+ // *strings*, not bare hostnames; its own loopback matcher structurally
40
+ // EXCLUDES `0.0.0.0` (a wildcard bind is not loopback, so the very address
41
+ // at fault here would pass through it untouched); `containerpath.ts:32-37`
42
+ // states outright that it does not know the container-visible host alias;
43
+ // and importing it would pull `hostpath.ts` into this module, which this
44
+ // file's own header (lines 23-26) forbids and which the host-path
45
+ // consumer-set assertion polices.
46
+ import { mcpHost } from "./vice.ts";
47
+
48
+ // -------------------------------------------------------------- request ids
49
+ //
50
+ // Primary noun of this protocol (assumption-delta decision, 01.2-01-PLAN.md):
51
+ // a request/grant/lease is identified by this id, never by port -- ports are
52
+ // recycled across sessions under on-demand launch, so a port is an attribute
53
+ // OF a grant, not identity. Matched byte-for-byte against the same shape
54
+ // resources/vice-broker.sh's own request-id pattern validates (T-01.2-01);
55
+ // the request-id-pattern parity test in vice-broker.test.mjs drives one
56
+ // shared corpus through both validators so neither side can silently accept
57
+ // an id shape the other rejects.
58
+ //
59
+ // C7 (Phase 01.6.1): this is the criterion's whole container-side
60
+ // deliverable -- a real, typed, NAMED export whose VALUE is unchanged from
61
+ // the pre-conversion .mjs (verified live, this plan's SUMMARY quotes both).
62
+ // 01.6.2's in-process broker imports this exact binding rather than
63
+ // re-stating the pattern a third time; the bash copy
64
+ // (resources/vice-broker.sh) does not retire until that phase deletes it.
65
+ export const REQUEST_ID_PATTERN: RegExp = /^req-[0-9]+-[0-9]+-[0-9a-f]{8}$/;
66
+
67
+ export function newRequestId(): string {
68
+ return `req-${process.pid}-${Date.now()}-${randomUUID().slice(0, 8)}`;
69
+ }
70
+
71
+ export function isValidRequestId(id: unknown): id is string {
72
+ return typeof id === "string" && REQUEST_ID_PATTERN.test(id);
73
+ }
74
+
75
+ // -------------------------------------------------------------- directories
76
+ //
77
+ // Resolved from VICE_POOL_DIR when set, otherwise from repo-root.ts's
78
+ // supervisorDir() -- the SAME default `.vice-supervisor` directory every
79
+ // other host/container pairing in this module tree already agrees on, so
80
+ // container and host never derive two different roots for this protocol.
81
+ // The five sibling directory helpers this function used to anchor
82
+ // (requestsDir/grantsDir/denialsDir/brokerLeasesDir/recycleAcksDir) and the
83
+ // lease path helper (leasePathFor) are GONE, not merely unused -- their
84
+ // directories cease to exist under D-01/D-12; only brokerJsonPath() below
85
+ // survives, since broker.json itself is not part of the retiring protocol.
86
+ export function brokerRootDir(): string {
87
+ return process.env.VICE_POOL_DIR ? resolve(process.env.VICE_POOL_DIR) : supervisorDir();
88
+ }
89
+
90
+ export function brokerJsonPath(dir: string = brokerRootDir()): string {
91
+ return join(dir, "broker.json");
92
+ }
93
+
94
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
95
+ * an array. Shared by readJsonMaybe()'s parse step, matching
96
+ * vice-broker.mts's readBrokerRecordMaybe()'s own isPlainObject() predicate
97
+ * exactly (that file's own doc comment states it matches this module's
98
+ * posture). */
99
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === "object" && value !== null && !Array.isArray(value);
101
+ }
102
+
103
+ /** Read and JSON.parse `path`, treating any failure (missing file, partial
104
+ * write, malformed JSON, non-object shape) as "not there yet" rather than
105
+ * throwing -- matches the posture vice-pool.mjs's readRegistry() used
106
+ * before its 2026-08-02 deletion. Two nested try/catch layers, one for the
107
+ * read and one for the parse -- never collapsed into one, never replaced by
108
+ * a thrown error (T-01.6.1-01). */
109
+ function readJsonMaybe(path: string): Record<string, unknown> | null {
110
+ let raw: string;
111
+ try {
112
+ raw = readFileSync(path, "utf8");
113
+ } catch {
114
+ return null;
115
+ }
116
+ try {
117
+ const parsed: unknown = JSON.parse(raw);
118
+ return isPlainObject(parsed) ? parsed : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ // writeRequest/createLease/touchLease/releaseLease/pollGrant/pollRecycleAck
125
+ // and their record interfaces (RequestRecord, RecycleRequestRecord,
126
+ // LeaseRecord, PollOptions, PollGrantResult, PollRecycleAckResult) are GONE:
127
+ // the whole file-messaging protocol they implemented (D-01/D-12) is replaced
128
+ // wholesale by the TCP control plane below. GRANT_POLL_TIMEOUT_MS/
129
+ // GRANT_POLL_INTERVAL_MS/RECYCLE_ACK_TIMEOUT_MS/RECYCLE_ACK_POLL_INTERVAL_MS
130
+ // (the retiring polls' own timeout/interval constants) and sleepMs() (their
131
+ // shared poll-delay helper) are gone with them -- nothing here polls a
132
+ // filesystem for a deadline any more.
133
+
134
+ export interface BrokerLivenessResult {
135
+ state: "never_started" | "stale" | "alive";
136
+ pid: number | null;
137
+ heartbeatAt: string | null;
138
+ path: string;
139
+ }
140
+
141
+ // --------------------------------------------------------- readBrokerLiveness
142
+ //
143
+ // Classifies broker.json as never_started / stale / alive against
144
+ // BROKER_STALE_MS. Plan 04 consumes the three states for its diagnostics;
145
+ // this task only needs the classification to exist and be correct.
146
+ export const BROKER_STALE_MS: number = Number(process.env.VICE_BROKER_STALE_MS || 180000);
147
+
148
+ /** Pure classification over an ALREADY-PARSED record (or null for "no file
149
+ * read anything back") -- factored out of readBrokerLiveness() below so
150
+ * openBrokerControl() (plan 06) can classify liveness against the SAME
151
+ * broker.json read it already performed for control_host/control_port/
152
+ * control_token, rather than re-reading the file a second time via a second
153
+ * readBrokerLiveness() call. readBrokerLiveness()'s own exported behaviour is
154
+ * unchanged by this split -- it still takes a path and returns the same
155
+ * shape; this is purely an internal refactor. */
156
+ function classifyLivenessFromRecord(parsed: Record<string, unknown> | null, path: string): BrokerLivenessResult {
157
+ if (parsed === null) {
158
+ return { state: "never_started", pid: null, heartbeatAt: null, path };
159
+ }
160
+ const pid = typeof parsed.pid === "number" && Number.isFinite(parsed.pid) ? parsed.pid : null;
161
+ const heartbeatAt = typeof parsed.heartbeat_at === "string" ? parsed.heartbeat_at : null;
162
+ const heartbeatMs = heartbeatAt ? Date.parse(heartbeatAt) : NaN;
163
+ if (!Number.isFinite(heartbeatMs)) {
164
+ return { state: "never_started", pid, heartbeatAt, path };
165
+ }
166
+ const state: BrokerLivenessResult["state"] = Date.now() - heartbeatMs > BROKER_STALE_MS ? "stale" : "alive";
167
+ return { state, pid, heartbeatAt, path };
168
+ }
169
+
170
+ export function readBrokerLiveness(path: string = brokerJsonPath()): BrokerLivenessResult {
171
+ const parsed = readJsonMaybe(path);
172
+ return classifyLivenessFromRecord(parsed, path);
173
+ }
174
+
175
+ // StartHeartbeatOptions/HEARTBEAT_MS/startHeartbeat() are GONE -- the
176
+ // lease-heartbeat interval (one of D-12's six retiring mechanisms) has no
177
+ // successor. Nothing needs touching to prove a TCP connection is alive; it
178
+ // either is, or the broker's own "close" handler has already reclaimed the
179
+ // instance.
180
+
181
+ // -------------------------------------------------------- dial resolution
182
+ //
183
+ // `broker.json`'s `control_host` field is the broker's BIND address
184
+ // (vice-broker.mts:782 writes `listener.host` into it, which is
185
+ // deliberately `0.0.0.0` per broker-control.mts:16-20's own rule: "Bind:
186
+ // 0.0.0.0 explicitly, never 127.0.0.1 -- host.docker.internal is the bridge
187
+ // address, not loopback"). A bind address is not a dial address: `0.0.0.0`
188
+ // dialed from inside THIS container reaches this container's own network
189
+ // stack, where nothing listens. Both connect sites below (the tracer's own
190
+ // acquireOverControlPlane() and openBrokerControl() further down) resolve
191
+ // their target through resolveControlTarget() and never read `control_host`
192
+ // as anything but diagnostic text.
193
+ //
194
+ // `VICE_BROKER_CONTROL_DIAL_HOST` is a NEW variable, deliberately not a
195
+ // homonym of the EXISTING `VICE_BROKER_CONTROL_HOST` (the broker's own BIND
196
+ // host, set on the HOST side -- vice-broker.mts:671, broker-control.mts:507,
197
+ // driven in broker-control.test.ts:949). Collapsing the two into one
198
+ // variable would reproduce this exact defect in env-var form: one name
199
+ // cannot correctly answer both "what should I bind" and "what should I
200
+ // dial", for the same reason `control_host` itself cannot -- those are two
201
+ // different consumers wanting two different addresses.
202
+ export interface ResolvedControlTarget {
203
+ host: string;
204
+ port: number;
205
+ source: "dial_override" | "bridge_alias";
206
+ /** The record's OWN `control_host` value -- carried through for the
207
+ * diagnostic only. Never a candidate dial target. */
208
+ recorded: string;
209
+ }
210
+
211
+ export type ResolveControlTargetResult =
212
+ | { ok: true; target: ResolvedControlTarget }
213
+ | { ok: false; kind: "unreachable_control_plane"; message: string; target: string };
214
+
215
+ const IPV4_LOOPBACK_RE = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
216
+ // Fully-expanded IPv6 "::" (all eight groups zero) and "::1" (seven zero
217
+ // groups then 1) -- the WHATWG URL parser's own bracketed short forms are
218
+ // matched as literals below; this regex pair only needs to catch the
219
+ // long-hand spellings a bare hostname string might still carry.
220
+ const IPV6_ALL_ZEROS_RE = /^(0{1,4}:){7}0{1,4}$/;
221
+ const IPV6_LOOPBACK_FULL_RE = /^(0{1,4}:){7}0{0,3}1$/;
222
+
223
+ function stripBrackets(host: string): string {
224
+ return host.replace(/^\[/, "").replace(/\]$/, "");
225
+ }
226
+
227
+ function isWildcardBindHost(host: string): boolean {
228
+ const bare = stripBrackets(host);
229
+ return bare === "0.0.0.0" || bare === "::" || IPV6_ALL_ZEROS_RE.test(bare);
230
+ }
231
+
232
+ function isLoopbackConnectHost(host: string): boolean {
233
+ const bare = stripBrackets(host);
234
+ return bare === "localhost" || bare === "::1" || IPV4_LOOPBACK_RE.test(bare) || IPV6_LOOPBACK_FULL_RE.test(bare);
235
+ }
236
+
237
+ /** Classifies a bare hostname (never a full URL) the same way
238
+ * `containerpath.ts`'s `isLoopbackHostname()` classifies loopback --
239
+ * matched STRUCTURALLY, whole address classes rather than single literals,
240
+ * deliberately RE-STATED here rather than imported (see this section's own
241
+ * header comment for why `containerpath.ts` is off-limits to this module).
242
+ * `wildcard_bind` covers the IPv4/IPv6 "listen on everything" addresses in
243
+ * their bracketed, unbracketed and fully-expanded spellings; `loopback`
244
+ * covers the whole 127.0.0.0/8 block, `localhost`, and IPv6 loopback in the
245
+ * same three spellings; everything else is `routable`. */
246
+ export function classifyConnectHost(host: string): "wildcard_bind" | "loopback" | "routable" {
247
+ if (isWildcardBindHost(host)) return "wildcard_bind";
248
+ if (isLoopbackConnectHost(host)) return "loopback";
249
+ return "routable";
250
+ }
251
+
252
+ /** Resolves the address this process will actually DIAL for the control
253
+ * plane -- never the record's own `control_host`, which flows through only
254
+ * as `recorded`, never as a candidate target. Precedence:
255
+ * `VICE_BROKER_CONTROL_DIAL_HOST` when set and non-empty (`source:
256
+ * "dial_override"`), otherwise `mcpHost()` (`source: "bridge_alias"`) --
257
+ * the SAME default source `vice-proxy.test.ts` already configures via
258
+ * `VICE_MCP_HOST` at ~30 call sites, which is exactly why that source was
259
+ * chosen: every one of those fixtures stays green with zero edits.
260
+ *
261
+ * Refuses -- before any connect is attempted -- when the resolved host
262
+ * classifies as `wildcard_bind`: that class is an address to listen on,
263
+ * never one to dial. Does NOT refuse `loopback`: an explicitly configured
264
+ * loopback host is a statement that the listener lives inside THIS
265
+ * container, which is the only topology the project's hard rule (nothing
266
+ * may dial the real host directly) permits a test to exercise -- and a
267
+ * loopback value can now only ever arrive from explicit configuration,
268
+ * never from the record, since the record's own value is never treated as
269
+ * a candidate. */
270
+ export function resolveControlTarget(record: Record<string, unknown>, port: number): ResolveControlTargetResult {
271
+ const recorded = typeof record.control_host === "string" ? record.control_host : "";
272
+ const override = process.env.VICE_BROKER_CONTROL_DIAL_HOST;
273
+ const useOverride = typeof override === "string" && override.length > 0;
274
+ const host = useOverride ? override : mcpHost();
275
+ const source: "dial_override" | "bridge_alias" = useOverride ? "dial_override" : "bridge_alias";
276
+
277
+ if (classifyConnectHost(host) === "wildcard_bind") {
278
+ return {
279
+ ok: false,
280
+ kind: "unreachable_control_plane",
281
+ message:
282
+ `openBrokerControl: the resolved dial target ${host}:${port} is a wildcard-bind address -- ` +
283
+ `it is valid to listen on but structurally impossible to dial. Refusing to attempt a connection.`,
284
+ target: `${host}:${port}`,
285
+ };
286
+ }
287
+ return { ok: true, target: { host, port, source, recorded } };
288
+ }
289
+
290
+ // ---------------------------------------------------- TCP control plane
291
+ //
292
+ // The container-side half of the TCP control plane (broker-control.mts is
293
+ // the host-side half). Wire format confirmed at plan 01's blocking
294
+ // checkpoint:decision (2026-08-03, `as-specified`; see
295
+ // .planning/RE-FINDINGS.md for the full record): newline-delimited JSON,
296
+ // per-boot capability token, connection open = claim / close = release.
297
+ export interface AcquireGrant {
298
+ id: string;
299
+ port: number;
300
+ url: string;
301
+ epoch_file: string;
302
+ supervisor_dir: string;
303
+ }
304
+
305
+ export interface AcquireOverControlPlaneHandle {
306
+ grant: AcquireGrant;
307
+ /** Closes the connection -- the connection IS the lease, so this alone
308
+ * is the release; the broker's own "close" handler tears the instance
309
+ * down (broker-control.mts). */
310
+ release: () => void;
311
+ }
312
+
313
+ /** P-08 (01.6.2.1-04-PLAN.md): default raised from 25000 to 120000. The
314
+ * knob (VICE_BROKER_ACQUIRE_TIMEOUT_MS) is unchanged -- an explicitly
315
+ * configured value keeps working exactly as before.
316
+ *
317
+ * Counter-evidence, recorded here rather than only in the plan: at the
318
+ * measured sub-second cold-launch boot (spike-003), the OLD 25000 ms value
319
+ * already implied a cliff far past what the instance ceiling would ever
320
+ * force -- so this raise is robustness headroom for a slow or contended
321
+ * host, not an unblocking of any wave-width constraint. .mcp.json's own
322
+ * `timeout` field is raised to 150000 in the same commit (see the ordering-
323
+ * invariant test in vice-proxy.test.ts), keeping this deadline strictly
324
+ * less than the MCP client's own configured timeout -- so a waiting caller
325
+ * always sees this module's warming-and-retry diagnostic rather than the
326
+ * client's generic timeout. */
327
+ export const CONTROL_ACQUIRE_TIMEOUT_MS: number = Number(process.env.VICE_BROKER_ACQUIRE_TIMEOUT_MS || 120000);
328
+
329
+ /** Reads broker.json ONCE for control_host/control_port/control_token,
330
+ * opens ONE TCP connection, sends a single `acquire` request framed as one
331
+ * JSON line, and awaits the grant line against
332
+ * CONTROL_ACQUIRE_TIMEOUT_MS. Rejects (never throws synchronously) on any
333
+ * failure: broker.json absent/unreadable/missing the control fields, a
334
+ * connection error, an `error` response, or a timeout. */
335
+ export function acquireOverControlPlane(dir: string = brokerRootDir()): Promise<AcquireOverControlPlaneHandle> {
336
+ return new Promise((resolvePromise, reject) => {
337
+ const broker = readJsonMaybe(brokerJsonPath(dir));
338
+ if (broker === null) {
339
+ reject(new Error("acquireOverControlPlane: broker.json not present or unreadable"));
340
+ return;
341
+ }
342
+ const controlHost = typeof broker.control_host === "string" ? broker.control_host : null;
343
+ const port = typeof broker.control_port === "number" ? broker.control_port : null;
344
+ const token = typeof broker.control_token === "string" ? broker.control_token : null;
345
+ if (controlHost === null || port === null || token === null) {
346
+ reject(new Error("acquireOverControlPlane: broker.json missing control_host/control_port/control_token"));
347
+ return;
348
+ }
349
+
350
+ const targetResult = resolveControlTarget(broker, port);
351
+ if (!targetResult.ok) {
352
+ reject(new Error(targetResult.message));
353
+ return;
354
+ }
355
+ const { host } = targetResult.target;
356
+
357
+ const socket = connect({ host, port });
358
+ let buffer = "";
359
+ let settled = false;
360
+
361
+ const timer = setTimeout(() => {
362
+ if (settled) return;
363
+ settled = true;
364
+ socket.destroy();
365
+ reject(new Error(`acquireOverControlPlane: no grant within ${CONTROL_ACQUIRE_TIMEOUT_MS}ms`));
366
+ }, CONTROL_ACQUIRE_TIMEOUT_MS);
367
+ if (typeof timer.unref === "function") timer.unref();
368
+
369
+ socket.on("connect", () => {
370
+ const requestId = newRequestId();
371
+ socket.write(`${JSON.stringify({ op: "acquire", id: requestId, token })}\n`);
372
+ });
373
+
374
+ socket.on("data", (chunk: Buffer) => {
375
+ if (settled) return;
376
+ buffer += chunk.toString("utf8");
377
+ const newlineIdx = buffer.indexOf("\n");
378
+ if (newlineIdx === -1) return;
379
+ const line = buffer.slice(0, newlineIdx);
380
+
381
+ let parsed: unknown;
382
+ try {
383
+ parsed = JSON.parse(line);
384
+ } catch {
385
+ settled = true;
386
+ clearTimeout(timer);
387
+ socket.destroy();
388
+ reject(new Error("acquireOverControlPlane: malformed response line"));
389
+ return;
390
+ }
391
+ if (typeof parsed !== "object" || parsed === null) {
392
+ settled = true;
393
+ clearTimeout(timer);
394
+ socket.destroy();
395
+ reject(new Error("acquireOverControlPlane: response line is not a JSON object"));
396
+ return;
397
+ }
398
+ const resp = parsed as Record<string, unknown>;
399
+ if (resp.kind === "grant") {
400
+ settled = true;
401
+ clearTimeout(timer);
402
+ const grant: AcquireGrant = {
403
+ id: String(resp.id),
404
+ port: Number(resp.port),
405
+ url: String(resp.url),
406
+ epoch_file: String(resp.epoch_file),
407
+ supervisor_dir: String(resp.supervisor_dir),
408
+ };
409
+ resolvePromise({
410
+ grant,
411
+ release: () => {
412
+ socket.destroy();
413
+ },
414
+ });
415
+ } else if (resp.kind === "error") {
416
+ settled = true;
417
+ clearTimeout(timer);
418
+ socket.destroy();
419
+ reject(new Error(`acquireOverControlPlane: ${String(resp.code)}: ${String(resp.message)}`));
420
+ }
421
+ // any other kind: not a terminal response to THIS request -- ignored,
422
+ // matching pollGrant()'s own "keep waiting" posture above.
423
+ });
424
+
425
+ socket.on("error", (err) => {
426
+ if (settled) return;
427
+ settled = true;
428
+ clearTimeout(timer);
429
+ reject(err);
430
+ });
431
+ });
432
+ }
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // BROKER-CONTROL-CLIENT REGION START (plan 06, task 1)
436
+ //
437
+ // Completed by plan 07 (the file protocol beside it is now gone).
438
+ // openBrokerControl() is the container-side half of D-01: session shape,
439
+ // all five request kinds, one discovery-record read, real per-request
440
+ // deadlines, and a distinct broker-gone outcome. Lives alongside
441
+ // acquireOverControlPlane() above (plan 01's tracer, kept unchanged and
442
+ // still used by broker-e2e.test.ts/broker-kill.test.ts as their own one-shot
443
+ // acquire helper for exercising the SERVER side) -- the file protocol this
444
+ // region's own predecessor sat beside is gone (plan 07, D-12).
445
+ //
446
+ // Deliberately never REJECTS a promise: every failure -- deadline, a
447
+ // refused connection, a malformed line, the broker going away mid-request --
448
+ // resolves an `{ ok: false, kind, message }` value instead, matching this
449
+ // module's established never-throw posture toward untrusted input (the
450
+ // broker's response lines) and network conditions, and sidestepping any
451
+ // possibility of an unhandled rejection escaping this client.
452
+ //
453
+ // A structural test in vice-broker-client.test.ts extracts exactly the
454
+ // region between this marker and REGION END below (by these marker strings,
455
+ // not a whole-file scan) and asserts it contains no filesystem-write
456
+ // construct -- nothing in this region may reintroduce a second on-disk
457
+ // authority for "is this lease alive."
458
+ // ---------------------------------------------------------------------------
459
+
460
+ /** Same value as the tracer's own CONTROL_ACQUIRE_TIMEOUT_MS above --
461
+ * referenced directly (not re-computed from the env var a second time) so
462
+ * the two can never drift apart. This is "the relocated value of the
463
+ * retiring grant-poll timeout" per 01.6.2-01-PLAN.md's own environment
464
+ * variable table (VICE_BROKER_ACQUIRE_TIMEOUT_MS, default now 120000, raised
465
+ * from 25000 per P-08 / 01.6.2.1-04-PLAN.md, against the measured tool-call
466
+ * budget spike-003 established -- see the counter-evidence comment at
467
+ * CONTROL_ACQUIRE_TIMEOUT_MS's own declaration above). */
468
+ export const ACQUIRE_TIMEOUT_MS: number = CONTROL_ACQUIRE_TIMEOUT_MS;
469
+
470
+ /** The recycle bound. Plan 06 referenced the (now-deleted) retiring
471
+ * pollRecycleAck()'s own RECYCLE_ACK_TIMEOUT_MS directly, so the two could
472
+ * never drift apart while both existed; that predecessor is gone (plan 07,
473
+ * D-12), so this reads the SAME environment variable directly -- the value
474
+ * itself is unchanged (VICE_BROKER_RECYCLE_TIMEOUT_MS, default 30000, per
475
+ * 01.6.2-01-PLAN.md's own environment variable table). Final tuning is
476
+ * Phase 01.6.2.1's item. */
477
+ export const RECYCLE_TIMEOUT_MS: number = Number(process.env.VICE_BROKER_RECYCLE_TIMEOUT_MS || 30000);
478
+
479
+ /** Genuinely NEW: the file protocol never "connected" anywhere, so there is
480
+ * no retiring value to carry forward for this one. A conservative bound for
481
+ * a TCP connect over the docker bridge to a broker broker.json has already
482
+ * classified alive (never_started/stale are refused before a connection is
483
+ * ever attempted) -- deliberately not read from an environment variable,
484
+ * since 01.6.2-06-PLAN.md's own scope is "no new environment variables
485
+ * beyond the two deadline variables named in 01.6.2-01-PLAN.md" (the two
486
+ * above). Final tuning is Phase 01.6.2.1's item, same as the other two. */
487
+ export const CONTROL_CONNECT_TIMEOUT_MS = 5000;
488
+
489
+ /** Every way a session-level request can fail to produce its expected
490
+ * success line: the two pre-connect liveness refusals, a refused TCP
491
+ * connection, a per-request deadline, the broker dropping the connection
492
+ * mid-request, a malformed/non-object response line, and the broker's own
493
+ * ControlErrorCode vocabulary (broker-control.mts's own type, duplicated
494
+ * here as a plain string-literal union rather than imported -- this client
495
+ * and that host-side listener run in separate processes; the shared surface
496
+ * between them is the wire format, not a TypeScript type, exactly like
497
+ * AcquireGrant below already duplicates the wire's own field names rather
498
+ * than importing a shared interface). */
499
+ export type ControlFailureKind =
500
+ | "never_started"
501
+ | "stale"
502
+ | "unreachable_control_plane"
503
+ | "connect_refused"
504
+ | "deadline"
505
+ | "broker_gone"
506
+ | "protocol"
507
+ | "unauthorized"
508
+ | "bad_request"
509
+ | "denied"
510
+ | "no_free_port"
511
+ | "at_capacity"
512
+ | "internal";
513
+
514
+ export type ControlAcquireResult = { ok: true; grant: AcquireGrant } | { ok: false; kind: ControlFailureKind; message: string };
515
+
516
+ export type ControlReleaseResult = { ok: true };
517
+
518
+ interface ControlRecycleAck {
519
+ outcome: string;
520
+ kill_stage: string;
521
+ reason: string;
522
+ }
523
+
524
+ export type ControlRecycleResult = { ok: true; ack: ControlRecycleAck } | { ok: false; kind: ControlFailureKind; message: string };
525
+
526
+ interface ControlStatusInstanceEntry {
527
+ port: number;
528
+ url: string;
529
+ state: string;
530
+ reason: string;
531
+ epoch: number | null;
532
+ }
533
+
534
+ export type ControlStatusResult =
535
+ | { ok: true; instances: ControlStatusInstanceEntry[] }
536
+ | { ok: false; kind: ControlFailureKind; message: string };
537
+
538
+ interface ControlHostStateFields {
539
+ pid: number;
540
+ started_at: string;
541
+ node_version: string;
542
+ vice_bin: string;
543
+ warm_floor: number;
544
+ max_instances: number;
545
+ base_port: number;
546
+ }
547
+
548
+ export type ControlHostStateResult =
549
+ | { ok: true; hostState: ControlHostStateFields }
550
+ | { ok: false; kind: ControlFailureKind; message: string };
551
+
552
+ /** Per-call deadline override -- matches PollOptions's own established shape
553
+ * above (pollGrant()/pollRecycleAck() already take an optional `timeoutMs`
554
+ * this same way). The MODULE-LEVEL constant (ACQUIRE_TIMEOUT_MS etc.) is the
555
+ * real, unchanged-from-the-retiring-poll default; a caller (chiefly this
556
+ * file's own tests, injecting a short bound to prove the deadline actually
557
+ * elapses without waiting out the real one) may override it per call. */
558
+ export interface ControlDeadlineOptions {
559
+ timeoutMs?: number;
560
+ }
561
+
562
+ /** The session opened by openBrokerControl(): one TCP connection, held for
563
+ * the session's lifetime -- the connection IS the lease (the tolerance
564
+ * decision recorded in broker-control-plane-over-tcp.md). Each method sends
565
+ * exactly one request line and resolves against its own deadline; none of
566
+ * them ever reject. */
567
+ export interface BrokerControlSession {
568
+ acquire(opts?: ControlDeadlineOptions): Promise<ControlAcquireResult>;
569
+ release(): Promise<ControlReleaseResult>;
570
+ recycle(targetId: string, opts?: ControlDeadlineOptions): Promise<ControlRecycleResult>;
571
+ status(opts?: ControlDeadlineOptions): Promise<ControlStatusResult>;
572
+ hostState(opts?: ControlDeadlineOptions): Promise<ControlHostStateResult>;
573
+ }
574
+
575
+ export interface OpenBrokerControlOptions {
576
+ connectTimeoutMs?: number;
577
+ }
578
+
579
+ export type OpenBrokerControlOutcome =
580
+ | { ok: true; session: BrokerControlSession }
581
+ | { ok: false; kind: ControlFailureKind; message: string; target?: string };
582
+
583
+ /** One in-flight request's settlement callback -- pushed onto the session's
584
+ * FIFO pending queue in sendAndAwaitLine() below, and shifted off it by
585
+ * EXACTLY ONE of: a response line arriving (createSession()'s own "data"
586
+ * handler), the per-request deadline elapsing, or the broker closing/erroring
587
+ * the connection (which drains and settles every entry still in the queue).
588
+ * FIFO order is sound here because every session method awaits its own
589
+ * sendAndAwaitLine() call to settle before this client ever writes a second
590
+ * request line -- responses can therefore never arrive out of the order
591
+ * their requests were sent in, so matching purely by arrival order (rather
592
+ * than by echoing the request id back, which several response kinds do not
593
+ * even carry) is correct. */
594
+ interface PendingLineEntry {
595
+ handle(line: Record<string, unknown> | null, brokerGone: boolean): void;
596
+ }
597
+
598
+ type RawLineOutcome = { ok: true; line: Record<string, unknown> } | { ok: false; kind: ControlFailureKind; message: string };
599
+
600
+ /** Builds the session object wrapping an already-CONNECTED socket. Wires the
601
+ * newline framing (buffer, split on "\n", one entry-per-response FIFO
602
+ * dispatch -- structurally the same shape broker-control.mts's own
603
+ * attachControlProtocol() uses on the host side) and the broker-gone
604
+ * settlement on "close"/"error", then exposes the five typed request
605
+ * methods over it. */
606
+ function createSession(socket: Socket, token: string): BrokerControlSession {
607
+ let buffer = "";
608
+ let closed = false;
609
+ const pending: PendingLineEntry[] = [];
610
+
611
+ socket.on("data", (chunk: Buffer) => {
612
+ buffer += chunk.toString("utf8");
613
+ let newlineIdx: number;
614
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
615
+ const line = buffer.slice(0, newlineIdx);
616
+ buffer = buffer.slice(newlineIdx + 1);
617
+ if (line.trim() === "") continue;
618
+ const entry = pending.shift();
619
+ if (!entry) continue; // unsolicited line -- this protocol never pushes one; ignored defensively
620
+
621
+ let parsed: unknown;
622
+ try {
623
+ parsed = JSON.parse(line);
624
+ } catch {
625
+ entry.handle(null, false);
626
+ continue;
627
+ }
628
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
629
+ entry.handle(null, false);
630
+ continue;
631
+ }
632
+ entry.handle(parsed as Record<string, unknown>, false);
633
+ }
634
+ });
635
+
636
+ function settleAllBrokerGone(): void {
637
+ closed = true;
638
+ const all = pending.splice(0, pending.length);
639
+ for (const entry of all) entry.handle(null, true);
640
+ }
641
+ socket.on("close", settleAllBrokerGone);
642
+ socket.on("error", settleAllBrokerGone);
643
+
644
+ /** Sends one JSON line carrying `token` and awaits the matching response,
645
+ * settling a typed failure rather than throwing on every failure mode:
646
+ * deadline, broker-gone, a malformed line, or the broker's own `error`
647
+ * response (whose `code` is forwarded verbatim as this outcome's `kind`).
648
+ * A success line is handed back UNINTERPRETED as `line` -- each public
649
+ * method below checks its own expected `kind` and extracts its own
650
+ * fields, so this shared helper carries none of that per-request-kind
651
+ * knowledge. */
652
+ function sendAndAwaitLine(payload: Record<string, unknown>, timeoutMs: number): Promise<RawLineOutcome> {
653
+ return new Promise((resolvePromise) => {
654
+ if (closed) {
655
+ resolvePromise({ ok: false, kind: "broker_gone", message: "openBrokerControl: session already closed" });
656
+ return;
657
+ }
658
+ let settled = false;
659
+ const entry: PendingLineEntry = {
660
+ handle(line, brokerGone) {
661
+ if (settled) return;
662
+ settled = true;
663
+ clearTimeout(timer);
664
+ if (brokerGone) {
665
+ resolvePromise({
666
+ ok: false,
667
+ kind: "broker_gone",
668
+ message: "openBrokerControl: the broker closed the connection while this request was in flight",
669
+ });
670
+ return;
671
+ }
672
+ if (line === null) {
673
+ resolvePromise({ ok: false, kind: "protocol", message: "openBrokerControl: malformed or non-object response line" });
674
+ return;
675
+ }
676
+ if (line.kind === "error") {
677
+ const code = typeof line.code === "string" ? (line.code as ControlFailureKind) : "internal";
678
+ resolvePromise({
679
+ ok: false,
680
+ kind: code,
681
+ message: typeof line.message === "string" ? line.message : "openBrokerControl: broker reported an error",
682
+ });
683
+ return;
684
+ }
685
+ resolvePromise({ ok: true, line });
686
+ },
687
+ };
688
+ const timer = setTimeout(() => {
689
+ if (settled) return;
690
+ settled = true;
691
+ const idx = pending.indexOf(entry);
692
+ if (idx !== -1) pending.splice(idx, 1);
693
+ resolvePromise({ ok: false, kind: "deadline", message: `openBrokerControl: no response within ${timeoutMs}ms` });
694
+ }, timeoutMs);
695
+ if (typeof timer.unref === "function") timer.unref();
696
+
697
+ pending.push(entry);
698
+ socket.write(`${JSON.stringify(payload)}\n`);
699
+ });
700
+ }
701
+
702
+ async function acquire(opts: ControlDeadlineOptions = {}): Promise<ControlAcquireResult> {
703
+ const requestId = newRequestId();
704
+ const raw = await sendAndAwaitLine({ op: "acquire", id: requestId, token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
705
+ if (!raw.ok) return raw;
706
+ const line = raw.line;
707
+ if (line.kind !== "grant") {
708
+ return { ok: false, kind: "protocol", message: `openBrokerControl: acquire got unexpected response kind ${String(line.kind)}` };
709
+ }
710
+ const grant: AcquireGrant = {
711
+ id: String(line.id),
712
+ port: Number(line.port),
713
+ url: String(line.url),
714
+ epoch_file: String(line.epoch_file),
715
+ supervisor_dir: String(line.supervisor_dir),
716
+ };
717
+ return { ok: true, grant };
718
+ }
719
+
720
+ /** The connection IS the lease -- closing it is the ENTIRE release, no
721
+ * wire round trip needed (matches acquireOverControlPlane()'s own
722
+ * release() above). socket.destroy() is itself idempotent, so a second
723
+ * release() call is a silent no-op, matching the idempotent posture the
724
+ * retiring file-based releaseLease() already had. */
725
+ async function release(): Promise<ControlReleaseResult> {
726
+ if (!socket.destroyed) socket.destroy();
727
+ closed = true;
728
+ return { ok: true };
729
+ }
730
+
731
+ async function recycle(targetId: string, opts: ControlDeadlineOptions = {}): Promise<ControlRecycleResult> {
732
+ const requestId = newRequestId();
733
+ const raw = await sendAndAwaitLine({ op: "recycle", id: requestId, target_id: targetId, token }, opts.timeoutMs ?? RECYCLE_TIMEOUT_MS);
734
+ if (!raw.ok) return raw;
735
+ const line = raw.line;
736
+ if (line.kind !== "recycle_ack") {
737
+ return { ok: false, kind: "protocol", message: `openBrokerControl: recycle got unexpected response kind ${String(line.kind)}` };
738
+ }
739
+ // Exactly the key set vice-proxy.ts's recycleAckOutcomeMessage() (lines
740
+ // 584-611) plus its caller (lines 707-713) read from the ack: outcome,
741
+ // kill_stage, reason -- documented at the point of use here rather than
742
+ // only in the plan, since this IS the point of use.
743
+ return {
744
+ ok: true,
745
+ ack: {
746
+ outcome: typeof line.outcome === "string" ? line.outcome : "unknown",
747
+ kill_stage: typeof line.kill_stage === "string" ? line.kill_stage : "unknown",
748
+ reason: typeof line.reason === "string" ? line.reason : "",
749
+ },
750
+ };
751
+ }
752
+
753
+ async function status(opts: ControlDeadlineOptions = {}): Promise<ControlStatusResult> {
754
+ // Reuses ACQUIRE_TIMEOUT_MS as a shared bound -- status is a synchronous,
755
+ // in-memory read on the broker side (no launch, no kill involved), so it
756
+ // needs no timeout of its own scale; introducing a distinct constant (or
757
+ // environment variable) for it would be exactly the kind of new knob
758
+ // 01.6.2-06-PLAN.md's own scope excludes.
759
+ const raw = await sendAndAwaitLine({ op: "status", token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
760
+ if (!raw.ok) return raw;
761
+ const line = raw.line;
762
+ if (line.kind !== "status") {
763
+ return { ok: false, kind: "protocol", message: `openBrokerControl: status got unexpected response kind ${String(line.kind)}` };
764
+ }
765
+ const rawInstances = Array.isArray(line.instances) ? line.instances : [];
766
+ const instances: ControlStatusInstanceEntry[] = rawInstances.map((rawEntry) => {
767
+ const e = rawEntry && typeof rawEntry === "object" ? (rawEntry as Record<string, unknown>) : {};
768
+ return {
769
+ port: Number(e.port),
770
+ url: typeof e.url === "string" ? e.url : "",
771
+ state: typeof e.state === "string" ? e.state : "",
772
+ reason: typeof e.reason === "string" ? e.reason : "",
773
+ epoch: typeof e.epoch === "number" ? e.epoch : null,
774
+ };
775
+ });
776
+ return { ok: true, instances };
777
+ }
778
+
779
+ async function hostState(opts: ControlDeadlineOptions = {}): Promise<ControlHostStateResult> {
780
+ // Same shared-bound reasoning as status() above.
781
+ const raw = await sendAndAwaitLine({ op: "host_state", token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
782
+ if (!raw.ok) return raw;
783
+ const line = raw.line;
784
+ if (line.kind !== "host_state") {
785
+ return { ok: false, kind: "protocol", message: `openBrokerControl: host_state got unexpected response kind ${String(line.kind)}` };
786
+ }
787
+ return {
788
+ ok: true,
789
+ hostState: {
790
+ pid: Number(line.pid),
791
+ started_at: String(line.started_at),
792
+ node_version: String(line.node_version),
793
+ vice_bin: String(line.vice_bin),
794
+ warm_floor: Number(line.warm_floor),
795
+ max_instances: Number(line.max_instances),
796
+ base_port: Number(line.base_port),
797
+ },
798
+ };
799
+ }
800
+
801
+ return { acquire, release, recycle, status, hostState };
802
+ }
803
+
804
+ /** Opens ONE session against the control plane: reads broker.json ONCE for
805
+ * control_host/control_port/control_token (and, from that SAME read,
806
+ * classifies liveness -- never a second file read for the same record),
807
+ * refuses to even attempt a connection when that classification is
808
+ * never_started or stale, then opens ONE TCP connection and holds it for
809
+ * the caller. Every failure mode resolves a typed `{ ok: false, kind,
810
+ * message }` outcome rather than rejecting -- see this region's own header
811
+ * comment for why. */
812
+ export function openBrokerControl(dir: string = brokerRootDir(), opts: OpenBrokerControlOptions = {}): Promise<OpenBrokerControlOutcome> {
813
+ const connectTimeoutMs = opts.connectTimeoutMs ?? CONTROL_CONNECT_TIMEOUT_MS;
814
+ return new Promise((resolvePromise) => {
815
+ const path = brokerJsonPath(dir);
816
+ const parsed = readJsonMaybe(path); // the ONE read of the discovery record for this whole session
817
+ const liveness = classifyLivenessFromRecord(parsed, path);
818
+ if (liveness.state === "never_started" || liveness.state === "stale") {
819
+ resolvePromise({
820
+ ok: false,
821
+ kind: liveness.state,
822
+ message: `openBrokerControl: broker.json classifies ${liveness.state} (${path}) -- refusing to attempt a connection`,
823
+ });
824
+ return;
825
+ }
826
+ if (parsed === null) {
827
+ // Unreachable in practice -- classifyLivenessFromRecord() only ever
828
+ // answers "alive" when it was handed a non-null record -- but keeps
829
+ // the branch below soundly typed rather than asserting past the
830
+ // compiler.
831
+ resolvePromise({ ok: false, kind: "never_started", message: "openBrokerControl: broker.json unexpectedly absent" });
832
+ return;
833
+ }
834
+ const controlHost = typeof parsed.control_host === "string" ? parsed.control_host : null;
835
+ const port = typeof parsed.control_port === "number" ? parsed.control_port : null;
836
+ const token = typeof parsed.control_token === "string" ? parsed.control_token : null;
837
+ if (controlHost === null || port === null || token === null) {
838
+ resolvePromise({
839
+ ok: false,
840
+ kind: "protocol",
841
+ message: "openBrokerControl: broker.json missing control_host/control_port/control_token",
842
+ });
843
+ return;
844
+ }
845
+
846
+ const targetResult = resolveControlTarget(parsed, port);
847
+ if (!targetResult.ok) {
848
+ resolvePromise({ ok: false, kind: targetResult.kind, message: targetResult.message, target: targetResult.target });
849
+ return;
850
+ }
851
+ const { host } = targetResult.target;
852
+
853
+ let settled = false;
854
+ const socket = connect({ host, port });
855
+
856
+ const connectTimer = setTimeout(() => {
857
+ if (settled) return;
858
+ settled = true;
859
+ socket.removeListener("connect", onConnect);
860
+ socket.removeListener("error", onError);
861
+ socket.destroy();
862
+ resolvePromise({
863
+ ok: false,
864
+ kind: "connect_refused",
865
+ message: `openBrokerControl: no connection to ${host}:${port} within ${connectTimeoutMs}ms`,
866
+ target: `${host}:${port}`,
867
+ });
868
+ }, connectTimeoutMs);
869
+ if (typeof connectTimer.unref === "function") connectTimer.unref();
870
+
871
+ function onConnect(): void {
872
+ if (settled) return;
873
+ settled = true;
874
+ clearTimeout(connectTimer);
875
+ socket.removeListener("error", onError);
876
+ resolvePromise({ ok: true, session: createSession(socket, token as string) });
877
+ }
878
+
879
+ function onError(err: Error): void {
880
+ if (settled) return;
881
+ settled = true;
882
+ clearTimeout(connectTimer);
883
+ socket.removeListener("connect", onConnect);
884
+ resolvePromise({
885
+ ok: false,
886
+ kind: "connect_refused",
887
+ message: `openBrokerControl: connection to ${host}:${port} failed -- ${err.message}`,
888
+ target: `${host}:${port}`,
889
+ });
890
+ }
891
+
892
+ socket.once("connect", onConnect);
893
+ socket.once("error", onError);
894
+ });
895
+ }
896
+
897
+ // ---------------------------------------------------------------------------
898
+ // BROKER-CONTROL-CLIENT REGION END (plan 06, task 1)
899
+ // ---------------------------------------------------------------------------