pi-onlyne 2.2.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));
@@ -210,12 +211,107 @@ export function listClientRegistrations(env, options = {}) {
210
211
  path: join(dir, name),
211
212
  root: canonicalRoot(parsed.root, options),
212
213
  kind: parsed.kind,
214
+ role: typeof parsed.role === "string" && parsed.role !== "" ? parsed.role : null,
213
215
  runtime: typeof parsed.runtime === "string" && parsed.runtime !== "" ? parsed.runtime : null,
216
+ cluster: typeof parsed.cluster === "string" && parsed.cluster !== "" ? parsed.cluster : null,
214
217
  });
215
218
  }
216
219
  return found;
217
220
  }
218
221
 
222
+ /**
223
+ * Resolve the socket path for a resident or pre-started pi session.
224
+ *
225
+ * Unlike [`resolveSocketPath`], when `onlyne-client` has not been started yet
226
+ * (so `<runtime_dir>/<digest>.json` does not exist), this falls back to the
227
+ * deterministic `<runtime_dir>/<workspaceDigest(workspaceRoot)>.sock` path so
228
+ * the agent can enter its background reconnect loop and handshake automatically
229
+ * the moment `onlyne-client` binds the socket. When a client registration for
230
+ * `role` appears in `<runtime_dir>`, it prefers that registration's socket.
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
+ *
250
+ * @param {Record<string, string | undefined>} env
251
+ * @param {string} workspaceRoot
252
+ * @param {string} role
253
+ * @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null, runtime?: string, cluster?: string | null }} [options]
254
+ * @returns {string}
255
+ */
256
+ export function resolveResidentSocketPath(env, workspaceRoot, role, options = {}) {
257
+ const injected = typeof env[SOCKET_ENV] === "string" ? env[SOCKET_ENV].trim() : "";
258
+ if (injected) return injected;
259
+
260
+ const dir = runtimeDir(env, options);
261
+ const here = canonicalRoot(workspaceRoot, options);
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
+ }
283
+
284
+ const coveringRole = candidates.filter((entry) => covers(entry.root, here) && (!role || !entry.role || entry.role === role));
285
+ if (coveringRole.length > 0) {
286
+ const nearest = coveringRole.reduce((best, entry) => (entry.root.length > best.root.length ? entry : best));
287
+ return socketOf(nearest);
288
+ }
289
+
290
+ if (role) {
291
+ const byRole = candidates.filter((entry) => entry.role === role);
292
+ if (byRole.length === 1) {
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
+ );
305
+ }
306
+ }
307
+
308
+ try {
309
+ return resolveSocketPath(env, workspaceRoot, options);
310
+ } catch {
311
+ return socketPath(workspaceRoot, env, options);
312
+ }
313
+ }
314
+
219
315
  /**
220
316
  * The socket path to dial for the pi working directory `cwd`.
221
317
  *
@@ -277,9 +373,13 @@ function covers(root, here) {
277
373
  return root === here || here.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
278
374
  }
279
375
 
280
- /** `<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. */
281
377
  function describe(entries) {
282
378
  return entries
283
- .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
+ )
284
384
  .join(", ");
285
385
  }
@@ -17,6 +17,7 @@ import {
17
17
  canonicalRoot,
18
18
  listClientRegistrations,
19
19
  registrationPath,
20
+ resolveResidentSocketPath,
20
21
  resolveSocketPath,
21
22
  runtimeDir,
22
23
  socketPath,
@@ -50,9 +51,11 @@ function registrationName(root) {
50
51
  }
51
52
 
52
53
  /** The registration of one client whose sessions run under `runtime`. */
53
- function client(root, runtimeName = "pi") {
54
- // The writer's spelling is the canonical one (`RegistrationFile::new`).
55
- 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 };
56
59
  }
57
60
 
58
61
  /** The env a client-spawned session carries, with the runtime directory pinned. */
@@ -235,3 +238,154 @@ test("a registration read through the injected filesystem names the same socket"
235
238
  // The env is what names the socket a client injected, and it is still first.
236
239
  assert.equal(resolveSocketPath({ ONLYNE_RUNTIME_DIR: dir, [SOCKET_ENV]: "/tmp/served" }, workspace, options), "/tmp/served");
237
240
  });
241
+
242
+ test("resolveResidentSocketPath falls back to deterministic socket before client starts, then adopts registered role socket", () => {
243
+ const clusterRoot = scratch("pi-onlyne-cluster-");
244
+ const roleWs = join(clusterRoot, "supervisor");
245
+ mkdirSync(roleWs, { recursive: true });
246
+ const emptyDir = runtime([]);
247
+
248
+ // Before onlyne-client starts, no registration exists; returns deterministic socket path.
249
+ assert.equal(
250
+ resolveResidentSocketPath(envFor(emptyDir), roleWs, "supervisor"),
251
+ socketPath(roleWs, envFor(emptyDir)),
252
+ );
253
+
254
+ // Once onlyne-client starts and registers the role in the runtime directory, even if
255
+ // omp was started in the parent clusterRoot, it resolves the registered role's socket.
256
+ const liveDir = runtime([
257
+ {
258
+ name: registrationName(roleWs),
259
+ body: { kind: "client", role: "supervisor", root: canonicalRoot(roleWs), pid: 9001, version: "2.3.0", runtime: "pi" },
260
+ },
261
+ ]);
262
+ assert.equal(
263
+ resolveResidentSocketPath(envFor(liveDir), clusterRoot, "supervisor"),
264
+ socketPath(roleWs, envFor(liveDir)),
265
+ );
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
+ });