pi-onlyne 2.3.0 → 2.4.0

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/src/socket.mjs CHANGED
@@ -15,7 +15,8 @@
15
15
  // tree to fall back on: it has to find the client the way the plan says an
16
16
  // external runtime does (`docs/v2-PLAN.md` line 289), by reading the
17
17
  // registration files in the runtime directory — `<digest>.json`, one per live
18
- // endpoint, holding `kind`, `role`, `root`, `pid`, `version` and `runtime`.
18
+ // endpoint, holding `kind`, `role`, `cluster`, `root`, `pid`, `version` and
19
+ // `runtime`.
19
20
  //
20
21
  // So a hand-started pi reads those files and the order of its answers is:
21
22
  //
@@ -182,7 +183,7 @@ export function registrationPath(root, env, options = {}) {
182
183
  *
183
184
  * @param {Record<string, string | undefined>} env
184
185
  * @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null }} [options]
185
- * @returns {{ path: string, root: string, kind: string | null, runtime: string | null }[]}
186
+ * @returns {{ path: string, root: string, kind: string | null, role: string | null, runtime: string | null, cluster: string | null }[]}
186
187
  */
187
188
  export function listClientRegistrations(env, options = {}) {
188
189
  const readDir = options.readDir ?? ((path) => readdirSync(path));
@@ -212,6 +213,7 @@ export function listClientRegistrations(env, options = {}) {
212
213
  kind: parsed.kind,
213
214
  role: typeof parsed.role === "string" && parsed.role !== "" ? parsed.role : null,
214
215
  runtime: typeof parsed.runtime === "string" && parsed.runtime !== "" ? parsed.runtime : null,
216
+ cluster: typeof parsed.cluster === "string" && parsed.cluster !== "" ? parsed.cluster : null,
215
217
  });
216
218
  }
217
219
  return found;
@@ -227,10 +229,28 @@ export function listClientRegistrations(env, options = {}) {
227
229
  * the moment `onlyne-client` binds the socket. When a client registration for
228
230
  * `role` appears in `<runtime_dir>`, it prefers that registration's socket.
229
231
  *
232
+ * A role name is per cluster, not per machine: two clusters on one machine both
233
+ * run `lazuli`, and the runtime directory carries one registration per client.
234
+ * The declared cluster answers first: when `options.cluster` names one, the
235
+ * registration of that role in that cluster is the answer, whatever tree this
236
+ * session runs in. That is the sandwiched agent — started from one cluster's
237
+ * root, its role workspace living in another — which the tree below would
238
+ * otherwise answer with the wrong client, and whose frames that client drops
239
+ * as another workspace's. Where one cluster holds several clients of the role,
240
+ * that is a fault of the cluster and refused by name.
241
+ *
242
+ * Without a usable declared cluster, the tree this session runs in decides
243
+ * between the role's clients, in either direction — `workspaceRoot` above the
244
+ * registered root (a resident started in the cluster root, its role workspace
245
+ * below) or below it. Where that leaves several, the resolution is refused
246
+ * rather than answered with the deterministic path: with a client of that role
247
+ * already running elsewhere, nothing binds that path, and every frame written
248
+ * to it is a silently dropped message (field report 2026-10-21, issue 1).
249
+ *
230
250
  * @param {Record<string, string | undefined>} env
231
251
  * @param {string} workspaceRoot
232
252
  * @param {string} role
233
- * @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null, runtime?: string }} [options]
253
+ * @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null, runtime?: string, cluster?: string | null }} [options]
234
254
  * @returns {string}
235
255
  */
236
256
  export function resolveResidentSocketPath(env, workspaceRoot, role, options = {}) {
@@ -240,17 +260,48 @@ export function resolveResidentSocketPath(env, workspaceRoot, role, options = {}
240
260
  const dir = runtimeDir(env, options);
241
261
  const here = canonicalRoot(workspaceRoot, options);
242
262
  const candidates = listClientRegistrations(env, options);
263
+ const socketOf = (entry) => join(dir, `${workspaceDigest(entry.root, options)}${SOCKET_SUFFIX}`);
264
+
265
+ // The cluster the session declares outranks the tree it happens to run in:
266
+ // a body in one cluster's workspace and a workspace in another's is exactly
267
+ // what tree containment gets wrong. An unknown cluster is not an error — it
268
+ // is a session whose file predates clustering, or names one whose client is
269
+ // not up yet — so it falls through to the tree rules below.
270
+ const cluster = typeof options.cluster === "string" && options.cluster.trim() !== "" ? options.cluster.trim() : null;
271
+ if (cluster) {
272
+ const byCluster = candidates.filter((entry) => entry.cluster === cluster && (!role || entry.role === role));
273
+ if (byCluster.length === 1) return socketOf(byCluster[0]);
274
+ if (byCluster.length > 1) {
275
+ throw new SocketResolutionError(
276
+ `onlyne: ${byCluster.length} clients are registered as role ${role ?? "?"} in cluster ${cluster} — ` +
277
+ `${describe(byCluster)} — so which one serves this session is ambiguous. ` +
278
+ "Start this workspace's client, or name the socket with ONLYNE_SOCKET",
279
+ byCluster,
280
+ );
281
+ }
282
+ }
243
283
 
244
284
  const coveringRole = candidates.filter((entry) => covers(entry.root, here) && (!role || !entry.role || entry.role === role));
245
285
  if (coveringRole.length > 0) {
246
286
  const nearest = coveringRole.reduce((best, entry) => (entry.root.length > best.root.length ? entry : best));
247
- return join(dir, `${workspaceDigest(nearest.root, options)}${SOCKET_SUFFIX}`);
287
+ return socketOf(nearest);
248
288
  }
249
289
 
250
290
  if (role) {
251
291
  const byRole = candidates.filter((entry) => entry.role === role);
252
292
  if (byRole.length === 1) {
253
- return join(dir, `${workspaceDigest(byRole[0].root, options)}${SOCKET_SUFFIX}`);
293
+ return socketOf(byRole[0]);
294
+ }
295
+ if (byRole.length > 1) {
296
+ const inTree = byRole.filter((entry) => covers(here, entry.root) || covers(entry.root, here));
297
+ if (inTree.length === 1) return socketOf(inTree[0]);
298
+ throw new SocketResolutionError(
299
+ `onlyne: ${byRole.length} clients are registered as role ${role} — ${describe(byRole)} — ` +
300
+ `and ${inTree.length === 0 ? "none of them is" : `${inTree.length} of them are`} ` +
301
+ `in this workspace's tree (${here}), so which one serves this session is ambiguous. ` +
302
+ "Start this workspace's client, or name the socket with ONLYNE_SOCKET",
303
+ inTree.length > 0 ? inTree : byRole,
304
+ );
254
305
  }
255
306
  }
256
307
 
@@ -322,9 +373,13 @@ function covers(root, here) {
322
373
  return root === here || here.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
323
374
  }
324
375
 
325
- /** `<path> (root <root>, runtime <runtime>)` per entry, for an error message. */
376
+ /** `<path> (root <root>, runtime <runtime>, cluster <cluster>)` per entry, for an error message. */
326
377
  function describe(entries) {
327
378
  return entries
328
- .map((entry) => `${entry.path} (root ${entry.root}, runtime ${entry.runtime ?? "-"})`)
379
+ .map(
380
+ (entry) =>
381
+ `${entry.path} (root ${entry.root}, runtime ${entry.runtime ?? "-"}` +
382
+ `${entry.cluster ? `, cluster ${entry.cluster}` : ""})`,
383
+ )
329
384
  .join(", ");
330
385
  }
@@ -51,9 +51,11 @@ function registrationName(root) {
51
51
  }
52
52
 
53
53
  /** The registration of one client whose sessions run under `runtime`. */
54
- function client(root, runtimeName = "pi") {
55
- // The writer's spelling is the canonical one (`RegistrationFile::new`).
56
- return { kind: "client", role: "planner", root: canonicalRoot(root), pid: 4242, version: "0.2.0", runtime: runtimeName };
54
+ function client(root, runtimeName = "pi", role = "planner", cluster = null) {
55
+ // The writer's spelling is the canonical one (`RegistrationFile::new`), and
56
+ // `cluster` is written only when the client's server names one.
57
+ const body = { kind: "client", role, root: canonicalRoot(root), pid: 4242, version: "0.2.0", runtime: runtimeName };
58
+ return cluster === null ? body : { ...body, cluster };
57
59
  }
58
60
 
59
61
  /** The env a client-spawned session carries, with the runtime directory pinned. */
@@ -262,3 +264,128 @@ test("resolveResidentSocketPath falls back to deterministic socket before client
262
264
  socketPath(roleWs, envFor(liveDir)),
263
265
  );
264
266
  });
267
+
268
+ test("a role name two clusters share resolves to the client in this tree", () => {
269
+ const chamber = scratch("pi-onlyne-chamber-");
270
+ const workshop = scratch("pi-onlyne-workshop-");
271
+ const chamberRole = join(chamber, "lazuli");
272
+ const workshopRole = join(workshop, "lazuli");
273
+ mkdirSync(chamberRole, { recursive: true });
274
+ mkdirSync(workshopRole, { recursive: true });
275
+ const dir = runtime([
276
+ { name: registrationName(workshopRole), body: client(workshopRole, "pi", "lazuli") },
277
+ { name: registrationName(chamberRole), body: client(chamberRole, "pi", "lazuli") },
278
+ ]);
279
+
280
+ // The field report's shape: a resident started in the cluster root, its own
281
+ // role workspace below it, and another cluster's client of the same role
282
+ // elsewhere on the machine. The tree decides; two role matches no longer
283
+ // mean no answer.
284
+ assert.equal(resolveResidentSocketPath(envFor(dir), chamber, "lazuli"), socketPath(chamberRole, envFor(dir)));
285
+ // From inside the role workspace the same client answers: there the
286
+ // registered root contains this one.
287
+ assert.equal(resolveResidentSocketPath(envFor(dir), chamberRole, "lazuli"), socketPath(chamberRole, envFor(dir)));
288
+ });
289
+
290
+ test("a role no client in this tree serves is refused, never dialed on the deterministic path", () => {
291
+ const first = scratch("pi-onlyne-cluster-");
292
+ const second = scratch("pi-onlyne-cluster-");
293
+ const unrelated = scratch("pi-onlyne-ws-");
294
+ const firstRole = join(first, "lazuli");
295
+ const secondRole = join(second, "lazuli");
296
+ mkdirSync(firstRole, { recursive: true });
297
+ mkdirSync(secondRole, { recursive: true });
298
+ const elsewhere = runtime([
299
+ { name: registrationName(firstRole), body: client(firstRole, "pi", "lazuli") },
300
+ { name: registrationName(secondRole), body: client(secondRole, "pi", "lazuli") },
301
+ ]);
302
+
303
+ // Neither candidate is in this tree's chain in either direction, so which
304
+ // lazuli serves this session has no answer — and the deterministic path
305
+ // belongs to no client, which is the socket the report wrote into.
306
+ const error = refusal(() => resolveResidentSocketPath(envFor(elsewhere), unrelated, "lazuli"));
307
+ assert.match(error.message, /2 clients are registered as role lazuli/);
308
+ assert.match(error.message, /ambiguous/);
309
+ assert.equal(error.candidates.length, 2);
310
+ assert.ok(
311
+ !error.message.includes(socketPath(unrelated, envFor(elsewhere))),
312
+ "the refusal names the candidates, not the socket nothing binds",
313
+ );
314
+
315
+ // Several same-named clients inside one tree are the same question: no
316
+ // nearest root names a single client, so it is refused as well.
317
+ const parent = scratch("pi-onlyne-parent-");
318
+ const leftRole = join(parent, "left", "lazuli");
319
+ const rightRole = join(parent, "right", "lazuli");
320
+ mkdirSync(leftRole, { recursive: true });
321
+ mkdirSync(rightRole, { recursive: true });
322
+ const nested = runtime([
323
+ { name: registrationName(leftRole), body: client(leftRole, "pi", "lazuli") },
324
+ { name: registrationName(rightRole), body: client(rightRole, "pi", "lazuli") },
325
+ ]);
326
+ const nestedError = refusal(() => resolveResidentSocketPath(envFor(nested), parent, "lazuli"));
327
+ assert.match(nestedError.message, /2 of them are in this workspace's tree/);
328
+ assert.equal(nestedError.candidates.length, 2);
329
+ });
330
+
331
+ test("the declared cluster answers before the tree a sandwiched agent runs in", () => {
332
+ // The sandwiched agent: its body runs inside `chamber`'s tree, and the role
333
+ // workspace it serves lives in `workshop`. Tree containment answers with
334
+ // chamber's client, whose frames the workshop's client never sees, so the
335
+ // declaration has to outrank the tree.
336
+ const chamber = scratch("pi-onlyne-chamber-");
337
+ const workshop = scratch("pi-onlyne-workshop-");
338
+ const chamberRole = join(chamber, "lazuli");
339
+ const workshopRole = join(workshop, "lazuli");
340
+ mkdirSync(chamberRole, { recursive: true });
341
+ mkdirSync(workshopRole, { recursive: true });
342
+ const dir = runtime([
343
+ { name: registrationName(chamberRole), body: client(chamberRole, "pi", "lazuli", "chamber") },
344
+ { name: registrationName(workshopRole), body: client(workshopRole, "pi", "lazuli", "workshop") },
345
+ ]);
346
+
347
+ assert.equal(
348
+ resolveResidentSocketPath(envFor(dir), chamber, "lazuli", { cluster: "workshop" }),
349
+ socketPath(workshopRole, envFor(dir)),
350
+ );
351
+ // Declaring the cluster the tree already sits in changes nothing.
352
+ assert.equal(
353
+ resolveResidentSocketPath(envFor(dir), chamber, "lazuli", { cluster: "chamber" }),
354
+ socketPath(chamberRole, envFor(dir)),
355
+ );
356
+
357
+ // A cluster no client of this role registered, a blank declaration, and no
358
+ // declaration at all: each falls back to tree containment.
359
+ for (const options of [{ cluster: "nowhere" }, { cluster: " " }, {}]) {
360
+ assert.equal(
361
+ resolveResidentSocketPath(envFor(dir), chamber, "lazuli", options),
362
+ socketPath(chamberRole, envFor(dir)),
363
+ );
364
+ }
365
+ });
366
+
367
+ test("two clients of the role in the declared cluster are refused by name", () => {
368
+ const cluster = scratch("pi-onlyne-cluster-");
369
+ const leftRole = join(cluster, "left", "lazuli");
370
+ const rightRole = join(cluster, "right", "lazuli");
371
+ const elsewhereRole = join(cluster, "elsewhere", "lazuli");
372
+ for (const role of [leftRole, rightRole, elsewhereRole]) mkdirSync(role, { recursive: true });
373
+ const dir = runtime([
374
+ { name: registrationName(leftRole), body: client(leftRole, "pi", "lazuli", "house") },
375
+ { name: registrationName(rightRole), body: client(rightRole, "pi", "lazuli", "house") },
376
+ { name: registrationName(elsewhereRole), body: client(elsewhereRole, "pi", "lazuli", "other") },
377
+ ]);
378
+
379
+ // The tree holds one of the two, which is not an answer: the declaration
380
+ // named a cluster serving this role twice, so the cluster is the fault.
381
+ const error = refusal(() =>
382
+ resolveResidentSocketPath(envFor(dir), cluster, "lazuli", { cluster: "house" }),
383
+ );
384
+ assert.match(error.message, /2 clients are registered as role lazuli in cluster house/);
385
+ assert.match(error.message, /ambiguous/);
386
+ assert.equal(error.candidates.length, 2);
387
+ assert.ok(
388
+ error.candidates.every((entry) => entry.cluster === "house"),
389
+ "the refusal names the cluster's own clients, not the other cluster's",
390
+ );
391
+ });