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/README.md +26 -7
- package/README.zh.md +19 -5
- package/package.json +1 -1
- package/src/activity.mjs +294 -0
- package/src/activity.test.mjs +69 -1
- package/src/agent.mjs +139 -14
- package/src/agent.test.mjs +83 -1
- package/src/config.mjs +179 -4
- package/src/config.test.mjs +59 -1
- package/src/index.ts +177 -20
- package/src/pi-surface.mjs +80 -10
- package/src/pi-surface.test.mjs +102 -4
- 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 +104 -4
- package/src/socket.test.mjs +157 -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));
|
|
@@ -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(
|
|
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
|
}
|
package/src/socket.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|