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/README.md +10 -0
- package/README.zh.md +7 -0
- package/package.json +1 -1
- package/src/agent.mjs +37 -3
- package/src/agent.test.mjs +61 -0
- package/src/config.mjs +7 -2
- package/src/config.test.mjs +22 -0
- package/src/index.ts +105 -19
- package/src/pi-surface.mjs +11 -2
- package/src/pi-surface.test.mjs +24 -0
- package/src/resident-claim.test.mjs +461 -0
- package/src/session-scope.mjs +53 -0
- package/src/session-scope.test.mjs +34 -0
- package/src/socket.mjs +62 -7
- package/src/socket.test.mjs +130 -3
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
}
|
package/src/socket.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|