@aron98/load-lock 1.4.0 → 1.6.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.
Files changed (80) hide show
  1. package/README.md +61 -14
  2. package/dist/adapters/opencode/plugin.js +15 -1
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +3 -10
  5. package/dist/cli/copies.js +32 -44
  6. package/dist/cli/hook.js +48 -4
  7. package/dist/cli/install.d.ts +2 -0
  8. package/dist/cli/install.js +68 -14
  9. package/dist/cli/main.js +108 -211
  10. package/dist/cli/service.d.ts +56 -0
  11. package/dist/cli/service.js +261 -0
  12. package/dist/cli/setup.d.ts +131 -0
  13. package/dist/cli/setup.js +942 -0
  14. package/dist/cli/update.d.ts +57 -0
  15. package/dist/cli/update.js +287 -0
  16. package/dist/core/daemon.d.ts +29 -3
  17. package/dist/core/daemon.js +111 -11
  18. package/dist/core/persist.d.ts +3 -0
  19. package/dist/core/persist.js +49 -10
  20. package/dist/core/state.d.ts +9 -0
  21. package/dist/core/state.js +41 -3
  22. package/dist/dashboard/feed.d.ts +23 -0
  23. package/dist/dashboard/feed.js +64 -2
  24. package/dist/dashboard/frame.js +2 -0
  25. package/dist/dashboard/view.d.ts +11 -0
  26. package/dist/dashboard/view.js +13 -0
  27. package/dist/mcp/server.d.ts +11 -3
  28. package/dist/mcp/server.js +38 -6
  29. package/dist/policy/text.d.ts +13 -3
  30. package/dist/policy/text.js +14 -3
  31. package/dist/shared/agent-guide.d.ts +3 -0
  32. package/dist/shared/agent-guide.js +16 -0
  33. package/dist/shared/client.js +16 -4
  34. package/dist/shared/drift.d.ts +23 -0
  35. package/dist/shared/drift.js +95 -0
  36. package/dist/shared/identity.d.ts +51 -0
  37. package/dist/shared/identity.js +116 -0
  38. package/dist/shared/installs.d.ts +39 -0
  39. package/dist/shared/installs.js +101 -0
  40. package/dist/shared/pkginfo.d.ts +25 -0
  41. package/dist/shared/pkginfo.js +70 -0
  42. package/dist/shared/protocol.d.ts +22 -0
  43. package/dist/shared/protocol.js +15 -0
  44. package/dist/shared/runtimes.d.ts +41 -0
  45. package/dist/shared/runtimes.js +81 -0
  46. package/dist/shared/version.d.ts +6 -0
  47. package/dist/shared/version.js +9 -1
  48. package/dist/test/ace-mcp.test.d.ts +1 -0
  49. package/dist/test/ace-mcp.test.js +157 -0
  50. package/dist/test/ace-opencode.test.d.ts +1 -0
  51. package/dist/test/ace-opencode.test.js +287 -0
  52. package/dist/test/check-pins.test.d.ts +1 -0
  53. package/dist/test/check-pins.test.js +108 -0
  54. package/dist/test/compat.test.d.ts +1 -0
  55. package/dist/test/compat.test.js +190 -0
  56. package/dist/test/hook.test.js +179 -1
  57. package/dist/test/identity.test.d.ts +1 -0
  58. package/dist/test/identity.test.js +155 -0
  59. package/dist/test/install.test.js +135 -1
  60. package/dist/test/launcher.test.d.ts +1 -0
  61. package/dist/test/launcher.test.js +222 -0
  62. package/dist/test/mcp.test.js +76 -7
  63. package/dist/test/plugin.test.d.ts +1 -0
  64. package/dist/test/plugin.test.js +144 -0
  65. package/dist/test/reconnect.test.d.ts +1 -0
  66. package/dist/test/reconnect.test.js +389 -0
  67. package/dist/test/runtimes.test.d.ts +1 -0
  68. package/dist/test/runtimes.test.js +282 -0
  69. package/dist/test/setup.test.d.ts +1 -0
  70. package/dist/test/setup.test.js +354 -0
  71. package/dist/test/update-command.test.d.ts +1 -0
  72. package/dist/test/update-command.test.js +679 -0
  73. package/dist/test/update.test.js +33 -1
  74. package/docs/ADAPTERS.md +3 -2
  75. package/docs/AGENT_GUIDE.md +48 -2
  76. package/docs/ARCHITECTURE.md +122 -7
  77. package/docs/COMPATIBILITY.md +156 -13
  78. package/docs/OPERATIONS.md +407 -21
  79. package/package.json +3 -2
  80. package/skills/load-lock/SKILL.md +62 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * ACE detection of the `ace/opencode/` pointer folder (issue #79).
3
+ *
4
+ * Reimplements ACE's repo-sync discovery as of TNG/ace@b703410
5
+ * (https://github.com/TNG/ace/tree/b703410218833071800cc918cb95718e0404640a):
6
+ * - backend/app/features/repo_ingestion/service/repo_ingest/
7
+ * repo_ingestion_archive_discovery_service.py: `_discover`,
8
+ * `_opencode_local_candidates`, `_npm_plugin_candidates`
9
+ * - backend/app/domain/kinds/__init__.py: `detect_kind`,
10
+ * `entry_point_filenames` (SPEC order skill, subagent, mcp, policy, plugin)
11
+ * - backend/app/domain/kinds/plugin_kind.py: `ENTRY_POINT_PATH`,
12
+ * `OPENCODE_LOCAL_ENTRY_POINT`, `OPENCODE_PLUGIN_DIRS`,
13
+ * `opencode_local_plugin_dir`, `_resolve_package_main`,
14
+ * `resolve_npm_entry_point`, `is_opencode_npm_candidate`,
15
+ * `derive_npm_plugin_name`
16
+ * and runs it over this repo's git tree (what a GitHub zipball contains).
17
+ * Light: files only, no daemon.
18
+ */
19
+ import test from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { spawnSync } from "node:child_process";
22
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join, dirname, resolve } from "node:path";
25
+ import { fileURLToPath, pathToFileURL } from "node:url";
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+ const REPO_ROOT = resolve(HERE, "..", "..");
28
+ // plugin_kind.py
29
+ const ENTRY_POINT_PATH = ".claude-plugin/plugin.json";
30
+ const OPENCODE_LOCAL_ENTRY_POINT = ".opencode/plugins/plugin.ts";
31
+ const OPENCODE_PLUGIN_DIRS = [".opencode/plugins", ".opencode/plugin"];
32
+ const JS_ENTRY_EXTENSIONS = [".ts", ".js", ".mjs", ".cjs"];
33
+ // kinds/__init__.py: each SPEC's entry_point_filename, in SPEC order.
34
+ const KIND_ENTRIES = [
35
+ ["skill", ["SKILL.md"]],
36
+ ["subagent", ["AGENT.md"]],
37
+ ["mcp", ["mcp.json"]],
38
+ ["policy", ["policy.md"]],
39
+ ["plugin", [ENTRY_POINT_PATH, OPENCODE_LOCAL_ENTRY_POINT]],
40
+ ];
41
+ function detectKind(names) {
42
+ const matched = KIND_ENTRIES.filter(([, entries]) => entries.some((e) => names.has(e))).map(([k]) => k);
43
+ return matched.length === 1 ? matched[0] : null;
44
+ }
45
+ function loadPackageJson(files) {
46
+ const raw = files.get("package.json");
47
+ if (raw === undefined)
48
+ return null;
49
+ try {
50
+ const m = JSON.parse(raw);
51
+ return m && typeof m === "object" && !Array.isArray(m) ? m : null;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ const normaliseRelpath = (p) => {
58
+ p = p.replace(/\\/g, "/");
59
+ return p.startsWith("./") ? p.slice(2) : p;
60
+ };
61
+ function resolvePackageMain(m) {
62
+ const main = m.main;
63
+ if (typeof main === "string" && main.trim())
64
+ return normaliseRelpath(main);
65
+ const exp = m.exports;
66
+ let candidate = null;
67
+ if (typeof exp === "string")
68
+ candidate = exp;
69
+ else if (exp && typeof exp === "object") {
70
+ const root = exp["."];
71
+ if (typeof root === "string")
72
+ candidate = root;
73
+ else if (root && typeof root === "object") {
74
+ const r = root;
75
+ candidate = r.import || r.require || r.default; // Python `or` chain
76
+ }
77
+ }
78
+ return typeof candidate === "string" ? normaliseRelpath(candidate) : null;
79
+ }
80
+ function opencodeLocalPluginDir(path) {
81
+ const p = path.replace(/\\/g, "/");
82
+ if (!JS_ENTRY_EXTENSIONS.some((e) => p.endsWith(e)))
83
+ return null;
84
+ for (const pdir of OPENCODE_PLUGIN_DIRS) {
85
+ const seg = pdir + "/";
86
+ if (p.startsWith(seg)) {
87
+ if (!p.slice(seg.length).includes("/"))
88
+ return "";
89
+ }
90
+ else {
91
+ const marker = "/" + seg;
92
+ const i = p.indexOf(marker);
93
+ if (i !== -1 && !p.slice(i + marker.length).includes("/"))
94
+ return p.slice(0, i);
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ function resolveNpmEntryPoint(files) {
100
+ const m = loadPackageJson(files);
101
+ if (m === null)
102
+ return null;
103
+ const entry = resolvePackageMain(m);
104
+ return entry !== null && files.has(entry) ? entry : null;
105
+ }
106
+ function isOpencodeNpmCandidate(files, entry) {
107
+ if (!JS_ENTRY_EXTENSIONS.some((e) => entry.endsWith(e)))
108
+ return false;
109
+ const m = loadPackageJson(files);
110
+ if (m === null)
111
+ return false;
112
+ let haystack = typeof m.name === "string" ? m.name : "";
113
+ if (Array.isArray(m.keywords))
114
+ haystack += " " + m.keywords.filter((k) => typeof k === "string").join(" ");
115
+ return haystack.toLowerCase().includes("opencode");
116
+ }
117
+ function deriveNpmPluginName(files) {
118
+ const name = loadPackageJson(files)?.name;
119
+ if (typeof name !== "string" || !name.trim())
120
+ return null;
121
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || null;
122
+ }
123
+ function npmPluginCandidates(files, claimed) {
124
+ const dirs = new Set();
125
+ for (const p of files.keys()) {
126
+ if (p === "package.json")
127
+ dirs.add("");
128
+ else if (p.endsWith("/package.json"))
129
+ dirs.add(p.slice(0, -"/package.json".length));
130
+ }
131
+ const out = new Map();
132
+ for (const d of dirs) {
133
+ if (claimed.has(d))
134
+ continue;
135
+ const prefix = d === "" ? "" : d + "/";
136
+ const local = new Map();
137
+ for (const [p, data] of files)
138
+ if (p === d || p.startsWith(prefix))
139
+ local.set(p.slice(prefix.length), data);
140
+ const entry = resolveNpmEntryPoint(local);
141
+ if (entry !== null && isOpencodeNpmCandidate(local, entry))
142
+ out.set(d, entry);
143
+ }
144
+ return out;
145
+ }
146
+ function discover(files) {
147
+ const entries = KIND_ENTRIES.flatMap(([, e]) => e);
148
+ const roots = new Map();
149
+ const add = (d, e) => (roots.get(d) ?? roots.set(d, new Set()).get(d)).add(e);
150
+ for (const p of files.keys()) {
151
+ for (const entry of entries) {
152
+ if (p === entry)
153
+ add("", entry);
154
+ else if (p.endsWith("/" + entry))
155
+ add(p.slice(0, -(entry.length + 1)), entry);
156
+ }
157
+ }
158
+ const discovered = [];
159
+ const skips = [];
160
+ for (const [d, present] of roots) {
161
+ const kind = detectKind(present);
162
+ if (kind === null) {
163
+ skips.push(`${d || "<root>"}: ambiguous entry-point files ${[...present].sort().join(",")}`);
164
+ continue;
165
+ }
166
+ let format = null;
167
+ if (kind === "plugin") {
168
+ const claude = present.has(ENTRY_POINT_PATH);
169
+ if (claude && present.has(OPENCODE_LOCAL_ENTRY_POINT))
170
+ skips.push(`${d || "<root>"}: both Claude Code and OpenCode plugin entry points present`);
171
+ format = claude ? "claude" : "opencode";
172
+ }
173
+ discovered.push({ subpath: d, kind, format, npmEntryPoint: null });
174
+ }
175
+ let claimed = new Set(roots.keys());
176
+ const claudeDirs = new Set(discovered.filter((x) => x.kind === "plugin" && x.format === "claude").map((x) => x.subpath));
177
+ const localDirs = new Set();
178
+ for (const p of files.keys()) {
179
+ const d = opencodeLocalPluginDir(p);
180
+ if (d !== null)
181
+ localDirs.add(d);
182
+ }
183
+ for (const d of [...localDirs].filter((x) => !claimed.has(x)).sort()) {
184
+ discovered.push({ subpath: d, kind: "plugin", format: "opencode", npmEntryPoint: null });
185
+ skips.push(`${d || "<root>"}: detected as a local OpenCode plugin (.opencode/plugins/) — verify before registering`);
186
+ }
187
+ for (const d of [...localDirs].filter((x) => claudeDirs.has(x)).sort()) {
188
+ skips.push(`${d || "<root>"}: both Claude Code and OpenCode plugin entry points present`);
189
+ }
190
+ claimed = new Set([...claimed, ...localDirs]);
191
+ for (const [d, entry] of npmPluginCandidates(files, claimed)) {
192
+ discovered.push({ subpath: d, kind: "plugin", format: "opencode", npmEntryPoint: entry });
193
+ skips.push(`${d || "<root>"}: detected as an npm-distributed OpenCode plugin via package.json heuristic — verify before registering`);
194
+ }
195
+ return { discovered, skips };
196
+ }
197
+ // ------------------------------------------------------------ fixtures
198
+ /** The repo's git tree (tracked + untracked-not-ignored), as a GitHub zipball would carry it. */
199
+ function repoTree() {
200
+ const r = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: REPO_ROOT, encoding: "utf8" });
201
+ if (r.status !== 0)
202
+ return null;
203
+ const files = new Map();
204
+ for (const p of r.stdout.split("\0")) {
205
+ if (!p || !existsSync(join(REPO_ROOT, p)))
206
+ continue; // deleted-but-staged
207
+ files.set(p, p.endsWith(".json") ? readFileSync(join(REPO_ROOT, p), "utf8") : "");
208
+ }
209
+ return files;
210
+ }
211
+ const opencodePlugins = (d) => d.filter((x) => x.kind === "plugin" && x.format === "opencode");
212
+ // ------------------------------------------------------------ tests
213
+ test("ace-opencode: mirror matches ACE on known shapes", () => {
214
+ // Root package.json whose entry is gitignored build output: not detected (#64 finding).
215
+ const root = discover(new Map([["package.json", JSON.stringify({ name: "@aron98/load-lock", keywords: ["opencode"], exports: { ".": { default: "./dist/x.js" } } })]]));
216
+ assert.deepEqual(opencodePlugins(root.discovered), []);
217
+ // Resolvable entry but no "opencode" naming signal: not detected.
218
+ const noSignal = discover(new Map([["p/package.json", JSON.stringify({ name: "x", main: "i.js" })], ["p/i.js", ""]]));
219
+ assert.deepEqual(opencodePlugins(noSignal.discovered), []);
220
+ // A folder claimed by a Claude plugin manifest is never an npm candidate.
221
+ const claimed = discover(new Map([
222
+ ["p/package.json", JSON.stringify({ name: "x-opencode", main: "i.js" })],
223
+ ["p/i.js", ""],
224
+ ["p/.claude-plugin/plugin.json", "{}"],
225
+ ]));
226
+ assert.deepEqual(opencodePlugins(claimed.discovered), []);
227
+ // exports "." import condition wins over require/default.
228
+ const exp = discover(new Map([
229
+ ["p/package.json", JSON.stringify({ name: "y", keywords: ["OpenCode"], exports: { ".": { import: "./a.mjs", default: "./b.js" } } })],
230
+ ["p/a.mjs", ""],
231
+ ]));
232
+ assert.deepEqual(opencodePlugins(exp.discovered).map((x) => [x.subpath, x.npmEntryPoint]), [["p", "a.mjs"]]);
233
+ });
234
+ test("ace-opencode: this repo yields exactly one OpenCode plugin, at ace/opencode", (t) => {
235
+ const files = repoTree();
236
+ if (files === null)
237
+ return t.skip("not a git checkout");
238
+ assert.ok(files.has("ace/opencode/package.json"), "ace/opencode/package.json must be tracked");
239
+ const { discovered, skips } = discover(files);
240
+ const oc = opencodePlugins(discovered);
241
+ assert.deepEqual(oc.map((x) => [x.subpath, x.npmEntryPoint]), [["ace/opencode", "index.js"]], JSON.stringify(discovered));
242
+ // Detected only via the npm heuristic, never via a flat entry point or a local .opencode/plugins/ file.
243
+ assert.ok(!discovered.some((x) => x.subpath === "ace/opencode" && x.npmEntryPoint === null));
244
+ // No ambiguity / dual-format skip for the folder; ACE's only note is the informational "verify before registering".
245
+ const ours = skips.filter((s) => s.startsWith("ace/opencode:"));
246
+ assert.equal(ours.length, 1, ours.join("\n"));
247
+ assert.match(ours[0], /npm-distributed OpenCode plugin .* verify before registering/);
248
+ assert.ok(!skips.some((s) => s.includes("ambiguous") || s.includes("both Claude Code and OpenCode")), skips.join("\n"));
249
+ // Artifact name ACE derives from package.json.
250
+ const bundle = new Map();
251
+ for (const [p, d] of files)
252
+ if (p.startsWith("ace/opencode/"))
253
+ bundle.set(p.slice("ace/opencode/".length), d);
254
+ assert.equal(deriveNpmPluginName(bundle), "load-lock-opencode");
255
+ });
256
+ test("ace-opencode: folder is a thin pointer to the published package, pinned to package.json", () => {
257
+ const dir = join(REPO_ROOT, "ace", "opencode");
258
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
259
+ const version = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")).version;
260
+ assert.equal(pkg.private, true, "never publishable by accident");
261
+ assert.equal(pkg.dependencies["@aron98/load-lock"], version);
262
+ const code = readFileSync(join(dir, pkg.main), "utf8")
263
+ .split("\n")
264
+ .filter((l) => l.trim() && !l.trim().startsWith("//"));
265
+ assert.deepEqual(code, [`export { default } from "@aron98/load-lock";`]);
266
+ });
267
+ test("ace-opencode: pin is declared in check-pins and --write rewrites it", async () => {
268
+ const script = join(REPO_ROOT, "scripts", "check-pins.mjs");
269
+ const pins = await import(pathToFileURL(script).href);
270
+ const entry = pins.PINNED.find((e) => e.path === "ace/opencode/package.json");
271
+ assert.deepEqual(entry, { path: "ace/opencode/package.json", kind: "json", key: "dependencies.@aron98/load-lock" });
272
+ const root = mkdtempSync(join(tmpdir(), "ll-aceoc-"));
273
+ try {
274
+ mkdirSync(join(root, "ace", "opencode"), { recursive: true });
275
+ const src = readFileSync(join(REPO_ROOT, "ace", "opencode", "package.json"), "utf8");
276
+ writeFileSync(join(root, "ace", "opencode", "package.json"), src.replace(/"@aron98\/load-lock": "[^"]+"/, `"@aron98/load-lock": "0.0.1"`));
277
+ assert.equal(pins.checkPins(root, "9.9.9", [entry]).length, 1);
278
+ assert.deepEqual(pins.writePins(root, "9.9.9", [entry]), ["ace/opencode/package.json"]);
279
+ assert.deepEqual(pins.checkPins(root, "9.9.9", [entry]), []);
280
+ const after = JSON.parse(readFileSync(join(root, "ace", "opencode", "package.json"), "utf8"));
281
+ assert.equal(after.dependencies["@aron98/load-lock"], "9.9.9");
282
+ assert.equal(after.name, "load-lock-opencode");
283
+ }
284
+ finally {
285
+ rmSync(root, { recursive: true, force: true });
286
+ }
287
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for the version-pin sync check (issue #48): scripts/check-pins.mjs
3
+ * over planted files in a temp root, plus the real script on this repo.
4
+ * Light: no daemon, files only.
5
+ */
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { spawnSync } from "node:child_process";
9
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join, dirname, resolve } from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ const REPO_ROOT = resolve(HERE, "..", "..");
15
+ const SCRIPT = join(REPO_ROOT, "scripts", "check-pins.mjs");
16
+ const pins = (await import(pathToFileURL(SCRIPT).href));
17
+ function plant(files) {
18
+ const root = mkdtempSync(join(tmpdir(), "ll-pins-"));
19
+ for (const [rel, body] of Object.entries(files)) {
20
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
21
+ writeFileSync(join(root, rel), body);
22
+ }
23
+ return root;
24
+ }
25
+ const DECLARED = [
26
+ { path: "skills/load-lock/SKILL.md", kind: "spec" },
27
+ { path: "ace/mcp.json", kind: "spec" },
28
+ { path: "plugin/.claude-plugin/plugin.json", kind: "json", key: "version" },
29
+ ];
30
+ function plantedRepo(skillPin, mcpPin, pluginVersion) {
31
+ return plant({
32
+ "skills/load-lock/SKILL.md": `Else run \`npx -y @aron98/load-lock@${skillPin} status\`.\n` +
33
+ `Upgrade with \`npm i -g @aron98/load-lock@latest\` (unpinned, ignored).\n`,
34
+ "ace/mcp.json": JSON.stringify({ mcpServers: { "load-lock": { command: "npx", args: ["-y", `@aron98/load-lock@${mcpPin}`] } } }, null, 2) + "\n",
35
+ "plugin/.claude-plugin/plugin.json": JSON.stringify({ name: "load-lock", version: pluginVersion, description: "x" }, null, 2) + "\n",
36
+ });
37
+ }
38
+ test("check-pins: in-sync planted files pass", () => {
39
+ const root = plantedRepo("1.6.0", "1.6.0", "1.6.0");
40
+ try {
41
+ assert.deepEqual(pins.checkPins(root, "1.6.0", DECLARED), []);
42
+ }
43
+ finally {
44
+ rmSync(root, { recursive: true, force: true });
45
+ }
46
+ });
47
+ test("check-pins: mismatches are reported per pin, then --write fixes them all", () => {
48
+ const root = plantedRepo("1.5.0", "1.6.0-rc.1", "1.4.2");
49
+ try {
50
+ const problems = pins.checkPins(root, "1.6.0", DECLARED);
51
+ assert.equal(problems.length, 3, problems.join("\n"));
52
+ assert.ok(problems.some((p) => p.startsWith("skills/load-lock/SKILL.md:1: pins 1.5.0")), problems.join("\n"));
53
+ assert.ok(problems.some((p) => p.includes("ace/mcp.json") && p.includes("1.6.0-rc.1")), problems.join("\n"));
54
+ assert.ok(problems.some((p) => p.includes("plugin.json (version): pins 1.4.2")), problems.join("\n"));
55
+ const changed = pins.writePins(root, "1.6.0", DECLARED);
56
+ assert.deepEqual(changed.sort(), DECLARED.map((d) => d.path).sort());
57
+ assert.deepEqual(pins.checkPins(root, "1.6.0", DECLARED), []);
58
+ const skill = readFileSync(join(root, "skills/load-lock/SKILL.md"), "utf8");
59
+ assert.match(skill, /@aron98\/load-lock@1\.6\.0 status/);
60
+ assert.match(skill, /@aron98\/load-lock@latest/, "non-version tags are left alone");
61
+ const plugin = JSON.parse(readFileSync(join(root, "plugin/.claude-plugin/plugin.json"), "utf8"));
62
+ assert.deepEqual(plugin, { name: "load-lock", version: "1.6.0", description: "x" });
63
+ // idempotent
64
+ assert.deepEqual(pins.writePins(root, "1.6.0", DECLARED), []);
65
+ }
66
+ finally {
67
+ rmSync(root, { recursive: true, force: true });
68
+ }
69
+ });
70
+ test("check-pins: --write keeps a JSON file's own formatting", () => {
71
+ const body = `{\n "name": "load-lock-plugin",\n "version": "1.5.0",\n "keywords": ["a", "b"],\n` +
72
+ ` "deps": { "@aron98/load-lock": "1.5.0" }\n}\n`;
73
+ const root = plant({ "p.json": body });
74
+ const declared = [
75
+ { path: "p.json", kind: "json", key: "version" },
76
+ { path: "p.json", kind: "json", key: "deps.@aron98/load-lock" },
77
+ ];
78
+ try {
79
+ pins.writePins(root, "1.6.0", declared);
80
+ assert.equal(readFileSync(join(root, "p.json"), "utf8"), body.replaceAll("1.5.0", "1.6.0"));
81
+ }
82
+ finally {
83
+ rmSync(root, { recursive: true, force: true });
84
+ }
85
+ });
86
+ test("check-pins: a declared file that is missing or lost its pin fails", () => {
87
+ const root = plant({ "skills/load-lock/SKILL.md": "use `npx -y @aron98/load-lock@latest`\n" });
88
+ try {
89
+ const problems = pins.checkPins(root, "1.6.0", [
90
+ { path: "skills/load-lock/SKILL.md", kind: "spec" },
91
+ { path: "ace/mcp.json", kind: "spec" },
92
+ ]);
93
+ assert.equal(problems.length, 2, problems.join("\n"));
94
+ assert.match(problems[0], /SKILL\.md: no @aron98\/load-lock@<version> pin found/);
95
+ assert.match(problems[1], /ace\/mcp\.json: declared pinned file is missing/);
96
+ }
97
+ finally {
98
+ rmSync(root, { recursive: true, force: true });
99
+ }
100
+ });
101
+ test("check-pins: the script passes on this repo", () => {
102
+ const r = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
103
+ assert.equal(r.status, 0, r.stdout + r.stderr);
104
+ assert.match(r.stdout, /pins ok/);
105
+ // every declared path is relative and inside the repo
106
+ for (const e of pins.PINNED)
107
+ assert.ok(!e.path.startsWith("/") && !e.path.includes(".."), e.path);
108
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Persisted state format compatibility (issue #51): a state dir written by a
3
+ * NEWER format (higher formatVersion, extra fields, unknown journal entry
4
+ * kinds) must load in the current code, and unknown fields must survive a
5
+ * rewrite. Also: what we write stays readable by older daemons (the journal
6
+ * stays a bare array; config.json keeps `config` + `history`).
7
+ */
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { spawn } from "node:child_process";
11
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { LLClient } from "../shared/client.js";
15
+ import { Machine } from "../core/state.js";
16
+ import { loadState, saveState, loadJournal, saveJournal, STATE_FORMAT_VERSION } from "../core/persist.js";
17
+ import { daemonMainPath, probe, until } from "./testutil.js";
18
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
19
+ mkdirSync(TMP_BASE, { recursive: true });
20
+ const FUTURE = STATE_FORMAT_VERSION + 7;
21
+ function newerConfigFile() {
22
+ return {
23
+ formatVersion: FUTURE,
24
+ futureTopLevel: { keep: "me" },
25
+ config: {
26
+ version: 4,
27
+ pools: { heavy: { capacity: 2, futurePoolField: "pool-x" }, gpu: { capacity: 1 } },
28
+ offerTimeoutMs: 30_000,
29
+ maxWaitMs: 5_000,
30
+ maxTurnWaitMs: 600_000,
31
+ startTimeoutMs: 120_000,
32
+ untrackedLeaseMs: 15 * 60_000,
33
+ guidance: { notes: "n", futureGuidance: 42 },
34
+ futureConfigField: ["a", "b"],
35
+ },
36
+ history: [{ ts: 1, author: "newer", reason: "r", changes: {}, resultingVersion: 4, futureHistoryField: true }],
37
+ };
38
+ }
39
+ function newerJournal(now) {
40
+ return [
41
+ // a held permit with an extra field + newer per-entry version
42
+ {
43
+ formatVersion: FUTURE,
44
+ requestId: "req_held",
45
+ pool: "heavy",
46
+ agentId: "A",
47
+ opKey: "op1",
48
+ reason: "build",
49
+ weight: 1,
50
+ state: "held",
51
+ permitToken: "pmt_held",
52
+ phase: "running_untracked",
53
+ claimedAt: now,
54
+ futureEntryField: { x: 1 },
55
+ },
56
+ // an entry kind this version doesn't know
57
+ { formatVersion: FUTURE, requestId: "req_future", pool: "heavy", agentId: "B", opKey: "op", reason: "r", weight: 1, state: "suspended", claimedAt: now },
58
+ // a held permit in a phase this version doesn't know
59
+ { requestId: "req_phase", pool: "gpu", agentId: "C", opKey: "op", reason: "r", weight: 1, state: "held", permitToken: "pmt_phase", phase: "paused_by_future", claimedAt: now },
60
+ // malformed garbage
61
+ "not an entry",
62
+ { state: "held" },
63
+ // a queued request with an extra field
64
+ { requestId: "req_q", pool: "heavy", agentId: "D", opKey: "op", reason: "r", weight: 1, state: "queued", enqueuedAt: now, claimedAt: now, futureQueued: "q" },
65
+ ];
66
+ }
67
+ function tmpDir() {
68
+ return mkdtempSync(join(TMP_BASE, "ll-compat-"));
69
+ }
70
+ test("compat: newer config.json loads and unknown fields survive a config change", () => {
71
+ const dir = tmpDir();
72
+ try {
73
+ writeFileSync(join(dir, "config.json"), JSON.stringify(newerConfigFile()));
74
+ const st = loadState(dir);
75
+ assert.equal(st.formatVersion, FUTURE);
76
+ assert.equal(st.config.pools.heavy.capacity, 2);
77
+ const m = new Machine(Date.now, st.config);
78
+ m.history.push(...st.history);
79
+ m.configure({ expectedVersion: 4, changes: { pools: { heavy: { capacity: 3 } }, guidance: { notes: "n2" } }, reason: "t", author: "me" });
80
+ saveState(dir, m.config, m.history);
81
+ const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
82
+ assert.equal(raw.formatVersion, STATE_FORMAT_VERSION);
83
+ assert.deepEqual(raw.futureTopLevel, { keep: "me" });
84
+ assert.deepEqual(raw.config.futureConfigField, ["a", "b"]);
85
+ assert.equal(raw.config.pools.heavy.capacity, 3);
86
+ assert.equal(raw.config.pools.heavy.futurePoolField, "pool-x");
87
+ assert.equal(raw.config.guidance.notes, "n2");
88
+ assert.equal(raw.config.guidance.futureGuidance, 42);
89
+ assert.equal(raw.history[0].futureHistoryField, true);
90
+ assert.equal(raw.history.length, 2);
91
+ }
92
+ finally {
93
+ rmSync(dir, { recursive: true, force: true });
94
+ }
95
+ });
96
+ test("compat: pre-versioning config.json reads as format 0; our writes keep the old shape", () => {
97
+ const dir = tmpDir();
98
+ try {
99
+ const old = newerConfigFile();
100
+ writeFileSync(join(dir, "config.json"), JSON.stringify({ config: old.config, history: [] }));
101
+ const st = loadState(dir);
102
+ assert.equal(st.formatVersion, 0);
103
+ saveState(dir, st.config, st.history);
104
+ const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
105
+ // older daemons read exactly `config` + `history`
106
+ assert.equal(typeof raw.config, "object");
107
+ assert.ok(Array.isArray(raw.history));
108
+ // journal stays a bare array (what every released daemon reads)
109
+ saveJournal(dir, [{ requestId: "r", pool: "heavy", agentId: "A", opKey: "o", reason: "r", weight: 1, claimedAt: 1, state: "queued" }]);
110
+ const j = JSON.parse(readFileSync(join(dir, "journal.json"), "utf8"));
111
+ assert.ok(Array.isArray(j));
112
+ assert.equal(j[0].formatVersion, STATE_FORMAT_VERSION);
113
+ }
114
+ finally {
115
+ rmSync(dir, { recursive: true, force: true });
116
+ }
117
+ });
118
+ test("compat: unknown journal entry kinds don't break restore; unknown fields round-trip", () => {
119
+ const dir = tmpDir();
120
+ try {
121
+ const now = Date.now();
122
+ writeFileSync(join(dir, "config.json"), JSON.stringify(newerConfigFile()));
123
+ writeFileSync(join(dir, "journal.json"), JSON.stringify(newerJournal(now)));
124
+ const st = loadState(dir);
125
+ const m = new Machine(Date.now, st.config);
126
+ const res = m.restore(loadJournal(dir), () => null);
127
+ assert.equal(res.kept, 2, "held + unknown-phase held");
128
+ assert.equal(res.queued, 1);
129
+ assert.equal(res.skipped, 2, "unknown kind + malformed entry");
130
+ const ev = m.eventsSince(0).find((e) => e.type === "daemon_recovered");
131
+ assert.match(String(ev?.detail), /skipped 2 unrecognized/, "skips are reported in the recovery event");
132
+ // unknown phase: kept, conservatively as untracked with a lease
133
+ const out = m.journalEntries();
134
+ const phase = out.find((e) => e.requestId === "req_phase");
135
+ assert.equal(phase.phase, "running_untracked");
136
+ assert.ok(phase.leaseDeadline && phase.leaseDeadline > now);
137
+ // unknown per-entry fields are written back while the entry lives
138
+ saveJournal(dir, out);
139
+ const back = loadJournal(dir);
140
+ assert.deepEqual(back.find((e) => e.requestId === "req_held").futureEntryField, { x: 1 });
141
+ assert.equal(back.find((e) => e.requestId === "req_q").futureQueued, "q");
142
+ assert.ok(back.every((e) => e.formatVersion === STATE_FORMAT_VERSION));
143
+ }
144
+ finally {
145
+ rmSync(dir, { recursive: true, force: true });
146
+ }
147
+ });
148
+ test("compat: a daemon started on a newer-format state dir serves it and preserves unknown fields", async () => {
149
+ const dir = tmpDir();
150
+ const sock = join(dir, "daemon.sock");
151
+ writeFileSync(join(dir, "config.json"), JSON.stringify(newerConfigFile()));
152
+ writeFileSync(join(dir, "journal.json"), JSON.stringify(newerJournal(Date.now())));
153
+ const child = spawn(process.execPath, [daemonMainPath, "--state-dir", dir, "--socket", sock], {
154
+ stdio: ["ignore", "pipe", "pipe"],
155
+ env: { ...process.env, LOAD_LOCK_STATE_DIR: dir, LOAD_LOCK_SOCKET: sock },
156
+ });
157
+ try {
158
+ await until("daemon listens", async () => await probe(sock));
159
+ const c = new LLClient({ socket: sock, stateDir: dir, spawnDaemon: false });
160
+ await c.connect();
161
+ const st = (await c.request("status", {}));
162
+ const heavy = st.pools.find((p) => p.pool === "heavy");
163
+ assert.ok(heavy, "heavy pool present");
164
+ assert.equal(heavy.holders.length, 1, "known held permit restored");
165
+ const cfg = (await c.request("get_config", {}));
166
+ await c.request("configure", {
167
+ expectedVersion: cfg.config.version,
168
+ changes: { pools: { heavy: { capacity: 5 } } },
169
+ reason: "compat test",
170
+ author: "test",
171
+ });
172
+ // the restored token still releases
173
+ const rel = (await c.request("release", { permitToken: "pmt_held" }));
174
+ assert.equal(rel.releasedNow, true);
175
+ c.close();
176
+ const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
177
+ assert.equal(raw.config.pools.heavy.capacity, 5);
178
+ assert.equal(raw.config.pools.heavy.futurePoolField, "pool-x");
179
+ assert.deepEqual(raw.config.futureConfigField, ["a", "b"]);
180
+ assert.deepEqual(raw.futureTopLevel, { keep: "me" });
181
+ const j = JSON.parse(readFileSync(join(dir, "journal.json"), "utf8"));
182
+ assert.ok(Array.isArray(j));
183
+ assert.equal(j.find((e) => e.requestId === "req_q")?.futureQueued, "q");
184
+ }
185
+ finally {
186
+ child.kill("SIGTERM");
187
+ await new Promise((r) => (child.exitCode !== null ? r() : child.once("exit", () => r())));
188
+ rmSync(dir, { recursive: true, force: true });
189
+ }
190
+ });