@aron98/load-lock 1.5.0 → 1.6.1

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.
Files changed (66) hide show
  1. package/README.md +62 -14
  2. package/dist/cli/copies.d.ts +3 -2
  3. package/dist/cli/copies.js +32 -3
  4. package/dist/cli/hook.js +48 -4
  5. package/dist/cli/install.d.ts +2 -0
  6. package/dist/cli/install.js +67 -13
  7. package/dist/cli/main.js +62 -5
  8. package/dist/cli/setup.d.ts +131 -0
  9. package/dist/cli/setup.js +942 -0
  10. package/dist/cli/update.d.ts +5 -1
  11. package/dist/cli/update.js +16 -3
  12. package/dist/core/daemon.d.ts +29 -3
  13. package/dist/core/daemon.js +111 -11
  14. package/dist/core/persist.d.ts +3 -0
  15. package/dist/core/persist.js +49 -10
  16. package/dist/core/state.d.ts +9 -0
  17. package/dist/core/state.js +41 -3
  18. package/dist/mcp/server.d.ts +4 -3
  19. package/dist/mcp/server.js +29 -6
  20. package/dist/policy/text.d.ts +13 -3
  21. package/dist/policy/text.js +14 -3
  22. package/dist/shared/agent-guide.d.ts +3 -0
  23. package/dist/shared/agent-guide.js +16 -0
  24. package/dist/shared/client.js +5 -3
  25. package/dist/shared/drift.d.ts +4 -2
  26. package/dist/shared/drift.js +24 -6
  27. package/dist/shared/identity.d.ts +51 -0
  28. package/dist/shared/identity.js +116 -0
  29. package/dist/shared/pkginfo.d.ts +9 -0
  30. package/dist/shared/pkginfo.js +16 -0
  31. package/dist/shared/protocol.d.ts +22 -0
  32. package/dist/shared/protocol.js +15 -0
  33. package/dist/shared/runtimes.d.ts +41 -0
  34. package/dist/shared/runtimes.js +81 -0
  35. package/dist/shared/version.d.ts +6 -0
  36. package/dist/shared/version.js +9 -1
  37. package/dist/test/ace-mcp.test.d.ts +1 -0
  38. package/dist/test/ace-mcp.test.js +206 -0
  39. package/dist/test/ace-opencode.test.d.ts +1 -0
  40. package/dist/test/ace-opencode.test.js +287 -0
  41. package/dist/test/check-pins.test.d.ts +1 -0
  42. package/dist/test/check-pins.test.js +108 -0
  43. package/dist/test/compat.test.d.ts +1 -0
  44. package/dist/test/compat.test.js +190 -0
  45. package/dist/test/hook.test.js +179 -1
  46. package/dist/test/identity.test.d.ts +1 -0
  47. package/dist/test/identity.test.js +155 -0
  48. package/dist/test/install.test.js +135 -1
  49. package/dist/test/launcher.test.d.ts +1 -0
  50. package/dist/test/launcher.test.js +222 -0
  51. package/dist/test/mcp.test.js +93 -7
  52. package/dist/test/plugin.test.d.ts +1 -0
  53. package/dist/test/plugin.test.js +144 -0
  54. package/dist/test/runtimes.test.d.ts +1 -0
  55. package/dist/test/runtimes.test.js +282 -0
  56. package/dist/test/setup.test.d.ts +1 -0
  57. package/dist/test/setup.test.js +354 -0
  58. package/dist/test/update-command.test.js +71 -0
  59. package/dist/test/update.test.js +33 -1
  60. package/docs/ADAPTERS.md +3 -2
  61. package/docs/AGENT_GUIDE.md +36 -2
  62. package/docs/ARCHITECTURE.md +122 -7
  63. package/docs/COMPATIBILITY.md +142 -13
  64. package/docs/OPERATIONS.md +292 -14
  65. package/package.json +3 -2
  66. package/skills/load-lock/SKILL.md +62 -0
@@ -8,13 +8,21 @@ import assert from "node:assert/strict";
8
8
  import { spawn } from "node:child_process";
9
9
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
10
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
11
- import { startDaemon, delay, mcpMainPath } from "./testutil.js";
12
- async function connectMcp(d) {
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { startDaemon, delay, mcpMainPath, cliMainPath } from "./testutil.js";
15
+ async function connectMcp(d, extraEnv = {}, args = [mcpMainPath]) {
13
16
  const client = new Client({ name: "load-lock-test", version: "0.1.0" }, { capabilities: {} });
17
+ // The test runner itself may be inside a harness session: drop inherited
18
+ // session identity so each test controls it (issue #50).
19
+ const env = { ...d.env };
20
+ for (const k of ["LOAD_LOCK_AGENT", "CLAUDE_CODE_SESSION_ID", "OPENCODE_SESSION_ID"])
21
+ delete env[k];
14
22
  const transport = new StdioClientTransport({
15
23
  command: process.execPath,
16
- args: [mcpMainPath],
17
- env: d.env,
24
+ args,
25
+ env: { ...env, ...extraEnv },
18
26
  });
19
27
  await client.connect(transport);
20
28
  return client;
@@ -24,15 +32,53 @@ async function callJson(c, name, args) {
24
32
  assert.equal(res.isError, undefined, `tool ${name} errored: ${JSON.stringify(res.content)}`);
25
33
  return JSON.parse(res.content[0].text);
26
34
  }
35
+ test("mcp: `load-lock mcp` serves the same MCP server as load-lock-mcp (#65)", async () => {
36
+ const d = await startDaemon();
37
+ const viaBin = await connectMcp(d);
38
+ const viaCli = await connectMcp(d, {}, [cliMainPath, "mcp"]);
39
+ try {
40
+ const names = async (c) => (await c.listTools()).tools.map((t) => t.name).sort();
41
+ assert.deepEqual(await names(viaCli), await names(viaBin), "identical tool set");
42
+ assert.equal(viaCli.getServerVersion()?.name, viaBin.getServerVersion()?.name);
43
+ const acq = await callJson(viaCli, "acquire", { reason: "build", op_key: "cli-mcp", agent_id: "cliMcpAgent" });
44
+ assert.equal(acq.state, "granted");
45
+ await callJson(viaCli, "release", { permit_token: acq.permit_token });
46
+ }
47
+ finally {
48
+ await viaCli.close();
49
+ await viaBin.close();
50
+ await d.stop();
51
+ }
52
+ });
53
+ test("mcp: initialize reports this package's version from package.json (#97)", async () => {
54
+ const d = await startDaemon();
55
+ const viaBin = await connectMcp(d); // load-lock-mcp entry point
56
+ const viaCli = await connectMcp(d, {}, [cliMainPath, "mcp"]); // `load-lock mcp`
57
+ try {
58
+ const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8")).version;
59
+ assert.ok(pkgVersion, "package.json has a version");
60
+ assert.equal(viaBin.getServerVersion()?.version, pkgVersion, "load-lock-mcp serverInfo.version matches package.json");
61
+ assert.equal(viaCli.getServerVersion()?.version, pkgVersion, "`load-lock mcp` serverInfo.version matches package.json");
62
+ assert.notEqual(pkgVersion, "0.1.0", "guards against the old hard-coded constant");
63
+ }
64
+ finally {
65
+ await viaCli.close();
66
+ await viaBin.close();
67
+ await d.stop();
68
+ }
69
+ });
27
70
  test("mcp: tool listing + acquire/release/configure round trip", async () => {
28
71
  const d = await startDaemon();
29
72
  const c = await connectMcp(d);
30
73
  try {
31
74
  const tools = await c.listTools();
32
75
  const names = tools.tools.map((t) => t.name);
33
- for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "wait_for_turn", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started", "wait_for_completion"]) {
76
+ for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "wait_for_turn", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started", "wait_for_completion", "agent_guide"]) {
34
77
  assert.ok(names.includes(expected), `tool ${expected} registered`);
35
78
  }
79
+ // agent_guide (issue #61): the same text `load-lock agent-guide` prints.
80
+ const guide = await c.callTool({ name: "agent_guide", arguments: {} });
81
+ assert.equal(guide.content[0].text, readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "docs", "AGENT_GUIDE.md"), "utf8"));
36
82
  const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA", label_prefix: "omac" });
37
83
  assert.equal(acq.state, "granted");
38
84
  assert.ok(acq.permit_token, "acquire result carries permit_token");
@@ -265,8 +311,8 @@ test("mcp: wait_for_completion on a daemon-tracked request_id (attach_pid) and j
265
311
  });
266
312
  test("mcp: multiple server processes share one machine-wide budget", async () => {
267
313
  const d = await startDaemon();
268
- const c1 = await connectMcp(d);
269
- const c2 = await connectMcp(d); // separate process = separate harness session
314
+ const c1 = await connectMcp(d, { CLAUDE_CODE_SESSION_ID: "budget-session-1" });
315
+ const c2 = await connectMcp(d, { CLAUDE_CODE_SESSION_ID: "budget-session-2" }); // separate harness session
270
316
  try {
271
317
  const a1 = await callJson(c1, "acquire", { reason: "agent1 build", op_key: "a1" });
272
318
  assert.equal(a1.state, "granted");
@@ -287,3 +333,43 @@ test("mcp: multiple server processes share one machine-wide budget", async () =>
287
333
  await d.stop();
288
334
  }
289
335
  });
336
+ test("mcp: two server processes from one harness session share an identity (issue #50)", async () => {
337
+ const d = await startDaemon();
338
+ // Same parent process, no session env: both resolve to the same harness
339
+ // ancestor, like a plugin-bundled + a standalone registration.
340
+ const c1 = await connectMcp(d);
341
+ const c2 = await connectMcp(d);
342
+ const other = await connectMcp(d, { CLAUDE_CODE_SESSION_ID: "other-session" });
343
+ try {
344
+ const filler = await callJson(other, "acquire", { reason: "other build", op_key: "fill" });
345
+ assert.equal(filler.state, "granted");
346
+ const a = await callJson(c1, "acquire", { reason: "session build", op_key: "shared-op" });
347
+ assert.equal(a.state, "queued");
348
+ if (process.platform === "linux")
349
+ assert.match(a.entry.agentId, /^proc:\d+:\d+$/);
350
+ // acquire through one server shows up in pending through the other
351
+ const viaOther = await callJson(c2, "pending", {});
352
+ assert.deepEqual(viaOther.queue.map((e) => e.requestId), [a.request_id]);
353
+ // and the op_key dedupes across both registrations
354
+ const again = await callJson(c2, "acquire", { reason: "session build", op_key: "shared-op" });
355
+ assert.equal(again.request_id, a.request_id);
356
+ // a server from a different session gets a distinct id and sees none of it
357
+ const otherPending = await callJson(other, "pending", {});
358
+ assert.equal(otherPending.queue.length, 0);
359
+ assert.equal(otherPending.held[0].agentId, "claude-code:other-session");
360
+ assert.notEqual(otherPending.held[0].agentId, a.entry.agentId);
361
+ // the release hands the offer to the shared identity: visible via either server
362
+ await callJson(other, "release", { permit_token: filler.permit_token });
363
+ const p1 = await callJson(c1, "pending", {});
364
+ assert.equal(p1.offers.length, 1);
365
+ const claimed = await callJson(c2, "claim", { offer_token: p1.offers[0].offerToken });
366
+ assert.equal(claimed.state, "held");
367
+ await callJson(c1, "release", { permit_token: claimed.permit_token });
368
+ }
369
+ finally {
370
+ await c1.close();
371
+ await c2.close();
372
+ await other.close();
373
+ await d.stop();
374
+ }
375
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Claude Code plugin contract tests (issue #61): the manifest, hooks file
3
+ * and root marketplace.json under plugins/claude-code, checked against the
4
+ * documented plugin format and ACE's import rules (#64), plus the hook and
5
+ * MCP commands run exactly the way the plugin runs them (through the
6
+ * launcher, against this checkout's dist/ on an isolated daemon).
7
+ */
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { spawnSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join, relative, resolve, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
17
+ import { cliMainPath, startDaemon } from "./testutil.js";
18
+ import { POLICY_BRIEF } from "../policy/text.js";
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const REPO_ROOT = resolve(HERE, "..", "..");
21
+ const PLUGIN_ROOT = join(REPO_ROOT, "plugins", "claude-code");
22
+ const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
23
+ const manifest = readJson(join(PLUGIN_ROOT, ".claude-plugin", "plugin.json"));
24
+ const hooksFile = readJson(join(PLUGIN_ROOT, "hooks", "hooks.json"));
25
+ const marketplace = readJson(join(REPO_ROOT, ".claude-plugin", "marketplace.json"));
26
+ const PKG_VERSION = readJson(join(REPO_ROOT, "package.json")).version;
27
+ const ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}";
28
+ const LAUNCHER_ARG = `${ROOT_VAR}/scripts/load-lock.mjs`;
29
+ /** A hook's shell-form command, capturing the hook event. */
30
+ const HOOK_COMMAND = /^node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/load-lock\.mjs" hook claude-code ([a-z-]+)$/;
31
+ /** ACE plugin_hooks_command_safe (#64): no shell metacharacters. */
32
+ const ACE_UNSAFE = /[;&|`]|\$\(/;
33
+ function filesUnder(dir) {
34
+ return readdirSync(dir).flatMap((name) => {
35
+ const p = join(dir, name);
36
+ return statSync(p).isDirectory() ? filesUnder(p) : [relative(PLUGIN_ROOT, p).split(sep).join("/")];
37
+ });
38
+ }
39
+ /** Every hook handler as [event, handler]. */
40
+ function handlers() {
41
+ return Object.entries(hooksFile.hooks).flatMap(([ev, groups]) => groups.flatMap((g) => g.hooks.map((h) => [ev, h])));
42
+ }
43
+ test("plugin.json: name, version pin, MCP server through the launcher, no secrets", () => {
44
+ assert.equal(manifest.name, "load-lock-plugin", "must differ from the skill's name (ACE install-dir clash, #64)");
45
+ assert.match(manifest.name, /^[a-z0-9]+(-[a-z0-9]+)*$/);
46
+ assert.equal(manifest.version, PKG_VERSION);
47
+ assert.equal(manifest.userConfig, undefined);
48
+ assert.equal(manifest.skills, undefined, "no skill in the plugin (#61)");
49
+ assert.deepEqual(Object.keys(manifest.mcpServers), ["load-lock"]);
50
+ const mcp = manifest.mcpServers["load-lock"];
51
+ assert.equal(mcp.command, "node");
52
+ assert.deepEqual(mcp.args, [LAUNCHER_ARG, "--bin", "load-lock-mcp"]);
53
+ assert.equal(mcp.env, undefined);
54
+ assert.equal(mcp.headers, undefined);
55
+ });
56
+ test("hooks.json: SessionStart policy + async await-offer watcher, Stop wakeup, shell form via the launcher", () => {
57
+ const got = handlers().map(([ev, h]) => [ev, HOOK_COMMAND.exec(h.command)?.[1], Boolean(h.async), Boolean(h.asyncRewake)]);
58
+ assert.deepEqual(got, [
59
+ ["SessionStart", "session-start", false, false],
60
+ ["SessionStart", "await-offer", true, true],
61
+ ["Stop", "stop", false, false],
62
+ ]);
63
+ for (const [, h] of handlers()) {
64
+ assert.equal(h.type, "command");
65
+ // Shell form (#63): one command string, no exec-form `args`; the root is quoted for paths with spaces.
66
+ assert.equal(h.args, undefined);
67
+ assert.ok(h.command.includes(`"${LAUNCHER_ARG}"`), h.command);
68
+ assert.doesNotMatch(h.command, ACE_UNSAFE);
69
+ assert.ok(Number.isInteger(h.timeout) && h.timeout > 0);
70
+ }
71
+ // asyncRewake hooks keep their timeout: leave room for the watcher's own 10 min bound.
72
+ const watcher = handlers().find(([, h]) => h.asyncRewake)[1];
73
+ assert.ok(watcher.timeout * 1000 > 10 * 60_000);
74
+ });
75
+ test("plugin folder: paths resolve inside it, ACE layout only, no SKILL.md or package.json", () => {
76
+ const launcher = LAUNCHER_ARG.replace(ROOT_VAR, PLUGIN_ROOT);
77
+ assert.ok(existsSync(launcher));
78
+ assert.ok(resolve(launcher).startsWith(PLUGIN_ROOT + sep));
79
+ const allowedDirs = ["skills", "commands", "agents", "hooks", "monitors", "themes", "output-styles", "bin", "scripts", ".claude-plugin"];
80
+ const allowedRoot = [".mcp.json", ".lsp.json", "settings.json", "README.md", "LICENSE", "CHANGELOG.md"];
81
+ for (const f of filesUnder(PLUGIN_ROOT)) {
82
+ assert.ok(allowedRoot.includes(f) || allowedDirs.includes(f.split("/")[0]), `unexpected file for ACE: ${f}`);
83
+ assert.notEqual(f.split("/").pop(), "SKILL.md");
84
+ assert.notEqual(f.split("/").pop(), "package.json");
85
+ }
86
+ // bin/ would land on the Bash tool's PATH and keeps a plugin off claude.ai; the launcher is not a CLI.
87
+ assert.ok(!existsSync(join(PLUGIN_ROOT, "bin")));
88
+ });
89
+ test("marketplace.json: the root marketplace lists the plugin subfolder", () => {
90
+ assert.equal(marketplace.name, "load-lock");
91
+ assert.ok(marketplace.owner?.name);
92
+ assert.equal(marketplace.plugins.length, 1);
93
+ const entry = marketplace.plugins[0];
94
+ assert.equal(entry.name, manifest.name);
95
+ assert.equal(entry.source, "./plugins/claude-code");
96
+ assert.equal(resolve(REPO_ROOT, entry.source), PLUGIN_ROOT);
97
+ assert.equal(entry.version, undefined, "plugin.json is the version authority");
98
+ for (const k of ["commands", "agents", "skills", "hooks", "mcpServers", "strict"])
99
+ assert.equal(entry[k], undefined, k);
100
+ });
101
+ test("plugin commands run as the plugin runs them (launcher -> PATH copy of this checkout)", async () => {
102
+ const d = await startDaemon();
103
+ const tmp = mkdtempSync(join(process.env.LL_TEST_TMP ?? tmpdir(), "ll-plug-"));
104
+ try {
105
+ const bin = join(tmp, "bin");
106
+ mkdirSync(bin);
107
+ symlinkSync(cliMainPath, join(bin, "load-lock"));
108
+ const env = { ...d.env, PATH: bin, LOAD_LOCK_LAUNCHER_DEBUG: "1" };
109
+ for (const k of ["LOAD_LOCK_AGENT", "CLAUDE_CODE_SESSION_ID", "OPENCODE_SESSION_ID"])
110
+ delete env[k];
111
+ // Hooks run as the harness runs a shell-form command: through sh, with
112
+ // CLAUDE_PLUGIN_ROOT in the environment and `node` resolved from PATH.
113
+ symlinkSync(process.execPath, join(bin, "node"));
114
+ const sub = (args) => args.map((a) => a.replace(ROOT_VAR, PLUGIN_ROOT));
115
+ const runHook = (event, input) => {
116
+ const h = handlers().find(([, x]) => HOOK_COMMAND.exec(x.command)?.[1] === event)[1];
117
+ return spawnSync(h.command, { shell: "/bin/sh", env: { ...env, CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }, input: JSON.stringify(input), encoding: "utf8" });
118
+ };
119
+ const s = runHook("session-start", { session_id: "plug-1", cwd: tmp, source: "startup", hook_event_name: "SessionStart" });
120
+ assert.equal(s.status, 0, s.stderr);
121
+ assert.ok(s.stdout.startsWith(POLICY_BRIEF), s.stdout);
122
+ assert.match(s.stdout, /load-lock now: pool "heavy"/);
123
+ assert.match(s.stderr, /launcher: path /);
124
+ const t = runHook("stop", { session_id: "plug-1", cwd: tmp, hook_event_name: "Stop" });
125
+ assert.equal(t.status, 0, t.stderr);
126
+ assert.equal(t.stdout, "", "no offer, no continuation");
127
+ const mcp = manifest.mcpServers["load-lock"];
128
+ const transport = new StdioClientTransport({ command: process.execPath, args: sub(mcp.args), env, stderr: "ignore" });
129
+ const client = new Client({ name: "plugin-test", version: "0.0.0" }, { capabilities: {} });
130
+ await client.connect(transport);
131
+ try {
132
+ const names = (await client.listTools()).tools.map((x) => x.name);
133
+ for (const n of ["acquire", "claim", "pending", "agent_guide"])
134
+ assert.ok(names.includes(n), n);
135
+ }
136
+ finally {
137
+ await client.close();
138
+ }
139
+ }
140
+ finally {
141
+ rmSync(tmp, { recursive: true, force: true });
142
+ await d.stop();
143
+ }
144
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Runtime-copy bookkeeping (issue #52):
3
+ * - daemon.json records the daemon's package root + version
4
+ * - the client envelope carries clientRoot; the daemon keeps a known-copies
5
+ * list (runtimes.json) deduped by real path, ignoring bogus hints and
6
+ * dropping copies whose path is gone
7
+ * - `doctor` lists the copies
8
+ * - self-restart onto the newer client's copy (issue #53), with bogus or
9
+ * mismatched hints falling back to the same-argv respawn, no loops
10
+ *
11
+ * Real daemons over real sockets in temp state dirs (never the user's).
12
+ */
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { spawn } from "node:child_process";
16
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, realpathSync, symlinkSync, cpSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { connect, createServer } from "node:net";
20
+ import { LLClient } from "../shared/client.js";
21
+ import { packageVersion, packageRoot } from "../shared/version.js";
22
+ import { loadRuntimes } from "../shared/runtimes.js";
23
+ import { restartEntry } from "../core/daemon.js";
24
+ import { startDaemon, cliMainPath, probe, delay, until } from "./testutil.js";
25
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
26
+ mkdirSync(TMP_BASE, { recursive: true });
27
+ /** Send one raw envelope line; resolve the first response (or null). */
28
+ function rawRequest(sockPath, envelope, timeoutMs = 3000) {
29
+ return new Promise((resolve) => {
30
+ let buf = "";
31
+ const s = connect(sockPath, () => s.write(JSON.stringify(envelope) + "\n"));
32
+ const done = (v) => {
33
+ s.destroy();
34
+ resolve(v);
35
+ };
36
+ s.on("data", (chunk) => {
37
+ buf += chunk.toString("utf8");
38
+ const idx = buf.indexOf("\n");
39
+ if (idx !== -1)
40
+ done(JSON.parse(buf.slice(0, idx)));
41
+ });
42
+ s.on("error", () => done(null));
43
+ setTimeout(() => done(null), timeoutMs).unref?.();
44
+ });
45
+ }
46
+ /** Plant a fake package root with the given package.json name/version. */
47
+ function plantCopy(base, name, pkgName, version) {
48
+ const dir = join(base, name);
49
+ mkdirSync(dir, { recursive: true });
50
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: pkgName, version }));
51
+ return dir;
52
+ }
53
+ test("daemon.json records the daemon's package root and version", async () => {
54
+ const d = await startDaemon();
55
+ try {
56
+ const info = JSON.parse(readFileSync(join(d.dir, "daemon.json"), "utf8"));
57
+ assert.equal(info.pid, d.child.pid);
58
+ assert.equal(info.version, packageVersion());
59
+ assert.equal(info.packageRoot, realpathSync(packageRoot()));
60
+ // The daemon's own copy is the first known runtime.
61
+ assert.ok(loadRuntimes(d.dir).some((c) => c.path === info.packageRoot && c.version === packageVersion()));
62
+ }
63
+ finally {
64
+ await d.stop();
65
+ }
66
+ });
67
+ test("client envelope carries clientRoot", async () => {
68
+ const capDir = mkdtempSync(join(TMP_BASE, "ll-capture-"));
69
+ const capSock = join(capDir, "cap.sock");
70
+ let captured = "";
71
+ const server = createServer((s) => {
72
+ s.on("data", (chunk) => {
73
+ captured += chunk.toString("utf8");
74
+ const idx = captured.indexOf("\n");
75
+ if (idx !== -1)
76
+ s.write(JSON.stringify({ id: JSON.parse(captured.slice(0, idx)).id, ok: true, result: {} }) + "\n");
77
+ });
78
+ });
79
+ await new Promise((r) => server.listen(capSock, r));
80
+ try {
81
+ const c = new LLClient({ socket: capSock, stateDir: capDir, spawnDaemon: false });
82
+ await c.connect();
83
+ await c.request("hello", {});
84
+ c.close();
85
+ const envelope = JSON.parse(captured.trim());
86
+ assert.equal(envelope.clientRoot, packageRoot());
87
+ }
88
+ finally {
89
+ await new Promise((r) => server.close(() => r()));
90
+ rmSync(capDir, { recursive: true, force: true });
91
+ }
92
+ });
93
+ test("known copies: recorded from handshakes, deduped by real path, bogus hints ignored, gone paths dropped", async () => {
94
+ const d = await startDaemon();
95
+ const base = mkdtempSync(join(TMP_BASE, "ll-copies-"));
96
+ try {
97
+ const a = plantCopy(base, "a", "@aron98/load-lock", "0.0.1");
98
+ const b = plantCopy(base, "b", "@aron98/load-lock", "0.0.2");
99
+ const foreign = plantCopy(base, "foreign", "some-other-pkg", "9.9.9");
100
+ symlinkSync(a, join(base, "a-link"));
101
+ // No clientVersion: these hints never trigger a self-restart.
102
+ for (const root of [a, join(base, "a-link"), foreign, join(base, "missing"), b]) {
103
+ const r = await rawRequest(d.sock, { id: 1, method: "hello", params: {}, clientRoot: root });
104
+ assert.ok(r?.ok, `hello with clientRoot ${root}`);
105
+ }
106
+ let copies = loadRuntimes(d.dir);
107
+ const paths = copies.map((c) => c.path);
108
+ assert.equal(paths.filter((p) => p === realpathSync(a)).length, 1, "symlinked hint deduped by real path");
109
+ assert.ok(copies.some((c) => c.path === realpathSync(b) && c.version === "0.0.2"), "version read from disk");
110
+ assert.ok(!paths.some((p) => p.includes("foreign") || p.includes("missing")), `bogus hints ignored: ${paths}`);
111
+ // Copy a disappears (e.g. npx cache cleaned); the next new sighting
112
+ // prunes it, and a reader never reports it either.
113
+ rmSync(a, { recursive: true, force: true });
114
+ const c = plantCopy(base, "c", "@aron98/load-lock", "0.0.3");
115
+ await rawRequest(d.sock, { id: 1, method: "hello", params: {}, clientRoot: c });
116
+ const raw = JSON.parse(readFileSync(join(d.dir, "runtimes.json"), "utf8"));
117
+ assert.ok(!raw.copies.some((x) => x.path === join(realpathSync(base), "a")), "gone copy pruned on disk");
118
+ copies = loadRuntimes(d.dir);
119
+ assert.ok(copies.some((x) => x.version === "0.0.3"));
120
+ }
121
+ finally {
122
+ await d.stop();
123
+ rmSync(base, { recursive: true, force: true });
124
+ }
125
+ });
126
+ test("doctor lists the daemon's runtime and known copies", async () => {
127
+ const d = await startDaemon();
128
+ const base = mkdtempSync(join(TMP_BASE, "ll-copies-"));
129
+ try {
130
+ const a = plantCopy(base, "a", "@aron98/load-lock", "0.0.1");
131
+ await rawRequest(d.sock, { id: 1, method: "hello", params: {}, clientRoot: a });
132
+ const r = await new Promise((resolve) => {
133
+ const child = spawn(process.execPath, [cliMainPath, "doctor", "--json"], { env: d.env });
134
+ let stdout = "";
135
+ child.stdout.on("data", (b) => (stdout += b));
136
+ child.stdin.end();
137
+ child.once("exit", (code) => resolve({ code, stdout }));
138
+ });
139
+ assert.equal(r.code, 0);
140
+ const report = JSON.parse(r.stdout);
141
+ assert.equal(report.daemonRuntime.path, realpathSync(packageRoot()));
142
+ assert.equal(report.daemonRuntime.version, packageVersion());
143
+ const listed = report.knownRuntimes.map((c) => `${c.version}@${c.path}`);
144
+ assert.ok(listed.includes(`0.0.1@${realpathSync(a)}`), `planted copy listed: ${listed}`);
145
+ assert.ok(listed.includes(`${packageVersion()}@${realpathSync(packageRoot())}`), `own copy listed: ${listed}`);
146
+ }
147
+ finally {
148
+ await d.stop();
149
+ rmSync(base, { recursive: true, force: true });
150
+ }
151
+ });
152
+ // ------------------------------------------------------------- issue #53
153
+ /**
154
+ * Plant a runnable load-lock copy: the built dist/ (minus tests), the real
155
+ * package.json with a different version, node_modules symlinked to ours.
156
+ */
157
+ function plantRuntime(base, name, version) {
158
+ const root = realpathSync(packageRoot());
159
+ const dir = join(base, name);
160
+ mkdirSync(dir, { recursive: true });
161
+ cpSync(join(root, "dist"), join(dir, "dist"), { recursive: true, filter: (src) => !src.startsWith(join(root, "dist", "test")) });
162
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
163
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ ...pkg, version }));
164
+ symlinkSync(join(root, "node_modules"), join(dir, "node_modules"));
165
+ return realpathSync(dir);
166
+ }
167
+ /** Start a daemon from a planted copy; only raw (version-less) envelopes talk to it. */
168
+ async function startCopyDaemon(copy, dir) {
169
+ const sock = join(dir, "daemon.sock");
170
+ const child = spawn(process.execPath, [join(copy, "dist", "core", "daemon-main.js"), "--state-dir", dir, "--socket", sock], {
171
+ stdio: "ignore",
172
+ detached: true,
173
+ });
174
+ child.unref();
175
+ await until("planted daemon up", () => probe(sock), 10_000, 100);
176
+ return { sock, pid: child.pid };
177
+ }
178
+ function killFromJson(dir) {
179
+ try {
180
+ process.kill(JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8")).pid, "SIGKILL");
181
+ }
182
+ catch {
183
+ /* gone */
184
+ }
185
+ }
186
+ async function helloRaw(sock, extra = {}) {
187
+ const r = await rawRequest(sock, { id: 1, method: "hello", params: {}, ...extra }, 2000);
188
+ return r?.ok ? r.result : null;
189
+ }
190
+ async function waitNewPid(sock, oldPid) {
191
+ let hello = null;
192
+ await until("daemon respawned", async () => {
193
+ if (!(await probe(sock)))
194
+ return false;
195
+ hello = await helloRaw(sock);
196
+ return hello !== null && hello.pid !== oldPid;
197
+ });
198
+ return hello;
199
+ }
200
+ test("restartEntry: only a matching load-lock copy with a daemon entry qualifies", () => {
201
+ const base = mkdtempSync(join(TMP_BASE, "ll-entry-"));
202
+ try {
203
+ const good = plantCopy(base, "good", "@aron98/load-lock", "0.2.0");
204
+ mkdirSync(join(good, "dist", "core"), { recursive: true });
205
+ writeFileSync(join(good, "dist", "core", "daemon-main.js"), "");
206
+ const noEntry = plantCopy(base, "noentry", "@aron98/load-lock", "0.2.0");
207
+ const foreign = plantCopy(base, "foreign", "not-load-lock", "0.2.0");
208
+ mkdirSync(join(foreign, "dist", "core"), { recursive: true });
209
+ writeFileSync(join(foreign, "dist", "core", "daemon-main.js"), "");
210
+ assert.equal(restartEntry(good, "0.2.0"), join(realpathSync(good), "dist", "core", "daemon-main.js"));
211
+ assert.equal(restartEntry(good, "0.3.0"), null, "claimed version must match the copy on disk");
212
+ assert.equal(restartEntry(noEntry, "0.2.0"), null, "copy without a daemon entry");
213
+ assert.equal(restartEntry(foreign, "0.2.0"), null, "foreign package");
214
+ assert.equal(restartEntry(join(base, "missing"), "0.2.0"), null, "nonexistent path");
215
+ assert.equal(restartEntry(undefined, "0.2.0"), null, "no hint");
216
+ }
217
+ finally {
218
+ rmSync(base, { recursive: true, force: true });
219
+ }
220
+ });
221
+ test("self-restart moves the daemon onto the newer client's copy; state and journal survive", async () => {
222
+ const base = mkdtempSync(join(TMP_BASE, "ll-2c-"));
223
+ const dir = join(base, "s");
224
+ mkdirSync(dir);
225
+ const sleeper = spawn("sleep", ["60"], { stdio: "ignore" });
226
+ try {
227
+ const oldCopy = plantRuntime(base, "old", "0.1.0");
228
+ const newCopy = plantRuntime(base, "new", "0.2.0");
229
+ const { sock, pid } = await startCopyDaemon(oldCopy, dir);
230
+ assert.equal((await helloRaw(sock)).daemonVersion, "0.1.0");
231
+ // A tracked permit held on the old daemon.
232
+ const g = await rawRequest(sock, { id: 2, method: "acquire", params: { agentId: "A", opKey: "op1", reason: "two copies" } });
233
+ const permitToken = g.result.entry.permitToken;
234
+ await rawRequest(sock, { id: 3, method: "attach_pid", params: { permitToken, pid: sleeper.pid } });
235
+ // A newer client from a different copy shows up.
236
+ assert.ok(await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: newCopy }));
237
+ const h = await waitNewPid(sock, pid);
238
+ assert.equal(h.daemonVersion, "0.2.0", "daemon now runs the newer copy");
239
+ const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
240
+ assert.equal(info.packageRoot, newCopy);
241
+ assert.equal(info.version, "0.2.0");
242
+ const st = await rawRequest(sock, { id: 4, method: "status", params: {} });
243
+ assert.equal(st.result.pools[0].holders.length, 1, "held permit survives the move");
244
+ assert.equal(st.result.pools[0].holders[0].pid, sleeper.pid);
245
+ const rel = await rawRequest(sock, { id: 5, method: "release", params: { permitToken } });
246
+ assert.equal(rel.result.releasedNow, true, "same permit token still releases");
247
+ // Stable afterwards: the same newer client does not restart it again.
248
+ await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: newCopy });
249
+ await delay(600);
250
+ assert.equal((await helloRaw(sock)).pid, h.pid);
251
+ }
252
+ finally {
253
+ sleeper.kill("SIGKILL");
254
+ killFromJson(dir);
255
+ rmSync(base, { recursive: true, force: true });
256
+ }
257
+ });
258
+ test("bogus or mismatched copy hint: no respawn onto it, same-argv fallback, no restart loop", async () => {
259
+ const base = mkdtempSync(join(TMP_BASE, "ll-2c-"));
260
+ const dir = join(base, "s");
261
+ mkdirSync(dir);
262
+ try {
263
+ const oldCopy = plantRuntime(base, "old", "0.1.0");
264
+ // Claims 0.2.0 but the copy on disk is 0.3.0.
265
+ const mismatched = plantRuntime(base, "mismatch", "0.3.0");
266
+ const { sock, pid } = await startCopyDaemon(oldCopy, dir);
267
+ await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: mismatched });
268
+ const h = await waitNewPid(sock, pid);
269
+ assert.equal(h.daemonVersion, "0.1.0", "respawned on its own copy, not the mismatched hint");
270
+ assert.equal(JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8")).packageRoot, oldCopy);
271
+ // Further bogus hints for the same or an older version: no loop.
272
+ await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: mismatched });
273
+ await helloRaw(sock, { clientVersion: "0.2.0", clientRoot: join(base, "nowhere") });
274
+ await helloRaw(sock, { clientVersion: "0.1.5", clientRoot: "/" });
275
+ await delay(800);
276
+ assert.equal((await helloRaw(sock)).pid, h.pid, "no restart loop");
277
+ }
278
+ finally {
279
+ killFromJson(dir);
280
+ rmSync(base, { recursive: true, force: true });
281
+ }
282
+ });
@@ -0,0 +1 @@
1
+ export {};