archgen-mcp 0.0.6 → 0.0.7

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.
@@ -15,7 +15,7 @@
15
15
  //
16
16
  // SAFETY: MCP configs are written only with approval (otherwise the plan is
17
17
  // reported); non-JSON configs are reported, never rewritten.
18
- import { spawnSync } from 'node:child_process';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
19
  import { existsSync, readFileSync, statSync } from 'node:fs';
20
20
  import { homedir } from 'node:os';
21
21
  import { join } from 'node:path';
@@ -26,20 +26,81 @@ export const CODEGRAPH_PACKAGE = '@colbymchenry/codegraph';
26
26
 
27
27
  /** Run a command; never throws. Returns {ok, status, stdout, stderr}. */
28
28
  export function run(cmd, args, opts = {}) {
29
- const { timeout = 120000, cwd, env } = opts;
29
+ const { timeout = 120000, cwd, env, shell } = opts;
30
30
  const r = spawnSync(cmd, args, {
31
31
  encoding: 'utf8',
32
32
  timeout,
33
33
  cwd,
34
34
  env: env ? { ...process.env, ...env } : process.env,
35
- shell: false,
36
- // On Windows the npm/codegraph shims are .cmd files, which need a shell.
37
- ...(process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd) ? { shell: true } : {}),
35
+ // The caller's `shell` wins; otherwise only a Windows .cmd/.bat shim needs
36
+ // one. The option used to be dropped, so a caller asking for a shell did
37
+ // not get one.
38
+ shell: shell === true || (process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd)),
38
39
  });
39
40
  if (r.error) return { ok: false, status: -1, stdout: '', stderr: String(r.error.message ?? r.error) };
40
41
  return { ok: r.status === 0, status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
41
42
  }
42
43
 
44
+ /**
45
+ * Run a command asynchronously; never throws. Returns {ok, status, stdout, stderr}.
46
+ *
47
+ * The SYNCHRONOUS form blocks the event loop for the child's whole lifetime,
48
+ * and the stdio MCP server cannot answer a single request while that
49
+ * happens — a global install or a full index can take minutes, so every
50
+ * other request simply queues behind it.
51
+ *
52
+ * 'close' (not 'exit') decides the outcome: 'exit' fires when the process is
53
+ * gone, while its output may still be in flight, and the last chunk of a
54
+ * long install log would be lost.
55
+ */
56
+ export function runAsync(cmd, args, opts = {}) {
57
+ const { timeout = 120000, cwd, env, shell } = opts;
58
+ return new Promise((resolve) => {
59
+ let child;
60
+ try {
61
+ child = spawn(cmd, args, {
62
+ cwd,
63
+ env: env ? { ...process.env, ...env } : process.env,
64
+ shell: shell === true,
65
+ });
66
+ } catch (e) {
67
+ resolve({ ok: false, status: -1, stdout: '', stderr: String(e?.message ?? e) });
68
+ return;
69
+ }
70
+ let stdout = '';
71
+ let stderr = '';
72
+ let settled = false;
73
+ const finish = (result) => {
74
+ if (settled) return;
75
+ settled = true;
76
+ if (timer) clearTimeout(timer);
77
+ resolve(result);
78
+ };
79
+ const timer =
80
+ timeout > 0
81
+ ? setTimeout(() => {
82
+ try {
83
+ child.kill('SIGKILL');
84
+ } catch {
85
+ /* already gone */
86
+ }
87
+ }, timeout)
88
+ : null;
89
+ child.stdout?.on('data', (d) => {
90
+ stdout += d.toString();
91
+ });
92
+ child.stderr?.on('data', (d) => {
93
+ stderr += d.toString();
94
+ });
95
+ child.on('error', (e) => {
96
+ finish({ ok: false, status: -1, stdout, stderr: String(e?.message ?? e) });
97
+ });
98
+ child.on('close', (code) => {
99
+ finish({ ok: code === 0, status: code ?? -1, stdout, stderr });
100
+ });
101
+ });
102
+ }
103
+
43
104
  /** Locate an executable on PATH (cross-platform, no shell). */
44
105
  export function which(bin, env = process.env) {
45
106
  const exts = process.platform === 'win32' ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';') : [''];
@@ -102,7 +163,6 @@ export function telemetryState(bin) {
102
163
  * approved?: boolean,
103
164
  * dryRun?: boolean,
104
165
  * telemetry?: 'keep'|'off'|'on',
105
- * noUpgrade?: boolean,
106
166
  * noInstall?: boolean,
107
167
  * home?: string,
108
168
  * env?: Record<string, string|undefined>,
@@ -119,7 +179,6 @@ export async function manageCodegraph(opts) {
119
179
  approved = false,
120
180
  dryRun = false,
121
181
  telemetry = 'keep',
122
- noUpgrade = false,
123
182
  noInstall = false,
124
183
  home = homedir(),
125
184
  env = process.env,
@@ -140,11 +199,26 @@ export async function manageCodegraph(opts) {
140
199
  ok: true,
141
200
  };
142
201
 
143
- /** Install CodeGraph when missing; upgrade to the latest published version. */
144
- const ensureCli = () => {
202
+ /**
203
+ * Resolve the CodeGraph CLI.
204
+ *
205
+ * THE RULE: install it when it is MISSING, and leave it alone when it is
206
+ * ALREADY THERE.
207
+ *
208
+ * An installed CodeGraph is the user's to manage. Upgrading it behind
209
+ * their back changes a program they may have pinned, and the upgrade ran
210
+ * on every bootstrap — so a plain `npx archgen-skill init` could rewrite a
211
+ * working installation. The version that is already on the machine is the
212
+ * version the MCP config points at, and it works.
213
+ *
214
+ * `install` lets a caller ask for the RESOLUTION only — the binary's path
215
+ * without touching the machine. That is what `wire` needs: wiring is a
216
+ * configuration change; installing a program globally is not.
217
+ */
218
+ const ensureCli = async ({ install = true } = {}) => {
145
219
  let found = findCodegraph(env);
146
220
  if (!found) {
147
- if (noInstall) {
221
+ if (noInstall || !install) {
148
222
  // Caller manages the CLI — fall back to the npx form in the MCP config.
149
223
  report.cli.action = 'absent';
150
224
  report.cli.detail = `not installed and installation was skipped — the MCP config will use "npx -y ${CODEGRAPH_PACKAGE} serve --mcp"`;
@@ -156,7 +230,7 @@ export async function manageCodegraph(opts) {
156
230
  }
157
231
  // Official install path; always the latest published release.
158
232
  const inv = npmInvocation(['install', '-g', CODEGRAPH_PACKAGE], env);
159
- const r = run(inv.command, inv.args, { timeout: 600000, shell: inv.shell, env });
233
+ const r = await runAsync(inv.command, inv.args, { timeout: 600000, shell: inv.shell, env });
160
234
  found = findCodegraph(env);
161
235
  if (!found) {
162
236
  report.cli.action = 'failed';
@@ -166,37 +240,17 @@ export async function manageCodegraph(opts) {
166
240
  }
167
241
  report.cli.action = 'installed';
168
242
  } else {
243
+ // Already installed: report it and stop. No upgrade, no version check.
169
244
  report.cli.action = 'present';
170
245
  }
171
246
  report.cli.present = true;
172
247
  report.cli.bin = found.bin;
173
248
  report.cli.version = found.version;
174
-
175
- if (noUpgrade) return found;
176
-
177
- // Freshness via the official updater (no-op when already current).
178
- const chk = run(found.bin, ['upgrade', '--check'], { timeout: 120000, env });
179
- const m = /latest\s+v?([0-9][^\s]*)/i.exec((chk.stdout ?? '') + (chk.stderr ?? ''));
180
- report.cli.latest = m ? m[1] : null; // informational only
181
- if (dryRun) {
182
- report.cli.action += '+upgrade';
183
- return found;
184
- }
185
- const up = run(found.bin, ['upgrade'], { timeout: 600000, env });
186
- const after = findCodegraph(env);
187
- if (after) {
188
- report.cli.version = after.version;
189
- report.cli.bin = after.bin;
190
- report.cli.action += /already up to date/i.test(up.stdout ?? '') ? '+current' : '+upgraded';
191
- } else {
192
- report.cli.action += '+upgrade-failed';
193
- report.ok = false;
194
- }
195
249
  return found;
196
250
  };
197
251
 
198
252
  /** Wire the MCP server into every detected harness (native + fallback). */
199
- const wireHarnesses = (cli) => {
253
+ const wireHarnesses = async (cli) => {
200
254
  const detected = detectHarnesses(registry, projectRoot, home);
201
255
  const wanted = targets ?? detected.map((d) => d.id);
202
256
  const selected = detected.filter((d) => wanted.includes(d.id));
@@ -222,7 +276,7 @@ export async function manageCodegraph(opts) {
222
276
  report.wiring.native.push({ targets: uniqueNative.join(','), location: loc, status: 'planned' });
223
277
  continue;
224
278
  }
225
- const r = run(
279
+ const r = await runAsync(
226
280
  cli.bin ?? 'codegraph',
227
281
  ['install', `--target=${uniqueNative.join(',')}`, `--location=${loc}`, '--yes'],
228
282
  {
@@ -258,6 +312,7 @@ export async function manageCodegraph(opts) {
258
312
  binPath: cli.bin,
259
313
  fallbackCommand: 'codegraph',
260
314
  skipHarnesses: selected.filter((d) => d.spec.codegraphTarget).map((d) => d.id),
315
+ targets: selected.map((d) => d.id),
261
316
  });
262
317
  for (const w of manual.wired) {
263
318
  report.wiring.manual.push({ harness: w.harness, path: w.path, key: w.key, ...w });
@@ -290,7 +345,7 @@ export async function manageCodegraph(opts) {
290
345
  };
291
346
 
292
347
  /** Index the project: `codegraph init` builds `.codegraph/` + the first graph. */
293
- const initIndex = (cli) => {
348
+ const initIndex = async (cli) => {
294
349
  const codegraphDir = join(projectRoot, '.codegraph');
295
350
  if (existsSync(codegraphDir)) {
296
351
  report.index.action = 'already-indexed';
@@ -311,7 +366,7 @@ export async function manageCodegraph(opts) {
311
366
  return;
312
367
  }
313
368
  // Official indexing command: creates .codegraph/ AND builds the graph.
314
- const r = run(cli.bin ?? 'codegraph', ['init', projectRoot], { timeout: 900000, env });
369
+ const r = await runAsync(cli.bin ?? 'codegraph', ['init', projectRoot], { timeout: 900000, env });
315
370
  if (!r.ok) {
316
371
  report.index.action = 'failed';
317
372
  report.index.detail = (r.stderr || r.stdout || 'codegraph init failed').trim().split('\n')[0];
@@ -332,7 +387,7 @@ export async function manageCodegraph(opts) {
332
387
  }
333
388
  };
334
389
 
335
- const applyTelemetry = (cli) => {
390
+ const applyTelemetry = async (cli) => {
336
391
  const state = telemetryState(cli.bin);
337
392
  report.telemetry.state = state;
338
393
  if (telemetry === 'keep' || !cli.bin) return;
@@ -356,7 +411,7 @@ export async function manageCodegraph(opts) {
356
411
  report.cli.version = cli?.version ?? null;
357
412
  // Official freshness check — reports what "latest" means today, no pin.
358
413
  if (cli) {
359
- const chk = run(cli.bin, ['upgrade', '--check'], { timeout: 120000, env });
414
+ const chk = await runAsync(cli.bin, ['upgrade', '--check'], { timeout: 120000, env });
360
415
  const m = /latest\s+v?([0-9][^\s]*)/i.exec((chk.stdout ?? '') + (chk.stderr ?? ''));
361
416
  report.cli.latest = m ? m[1] : null;
362
417
  }
@@ -382,23 +437,32 @@ export async function manageCodegraph(opts) {
382
437
  }
383
438
  report.index.action = existsSync(join(projectRoot, '.codegraph')) ? 'indexed' : 'not-indexed';
384
439
  } else if (mode === 'init') {
385
- const cli = ensureCli();
386
- applyTelemetry(cli);
387
- initIndex(cli);
440
+ const cli = await ensureCli();
441
+ await applyTelemetry(cli);
442
+ await initIndex(cli);
443
+ } else if (mode === 'wire') {
444
+ // WIRE ONLY. It resolves the binary (so the config can point at it) but
445
+ // never installs, never upgrades and never writes a global setting: a
446
+ // wiring request is a request to change the MCP CONFIGS.
447
+ const cli = await ensureCli({ install: false });
448
+ const detected = await wireHarnesses(cli);
449
+ report.detected = detected.map((d) => d.id);
388
450
  } else {
389
- // ensure | wire
390
- const cli = ensureCli();
391
- applyTelemetry(cli);
392
- const detected = wireHarnesses(cli);
451
+ // ensure — install the CLI if it is MISSING (an installed one is left
452
+ // exactly as it is), wire the configs, and index the project.
453
+ const cli = await ensureCli();
454
+ await applyTelemetry(cli);
455
+ const detected = await wireHarnesses(cli);
393
456
  if (mode === 'ensure') {
394
457
  if (!approved && !dryRun) {
395
- // Without approval: print the plan, write no MCP config. The CLI itself
396
- // is still installed/upgraded (a program, not a user config).
458
+ // Without approval: print the plan, write no MCP config. The CLI
459
+ // itself is still installed if it was missing (a program, not a user
460
+ // config); an existing one is never touched.
397
461
  report.ok = true;
398
462
  report.planned = true;
399
463
  report.index.action = 'would-index';
400
464
  } else {
401
- initIndex(cli);
465
+ await initIndex(cli);
402
466
  }
403
467
  }
404
468
  report.detected = detected.map((d) => d.id);
@@ -10,7 +10,7 @@
10
10
  // skill/SKILL.md frontmatter
11
11
  // metadata:
12
12
  // requires:
13
- // archgen_mcp: ">=0.0.6"
13
+ // archgen_mcp: ">=x.y.z"
14
14
  //
15
15
  // `doctor` reports the mismatch; `bootstrap` warns before wiring; CI refuses
16
16
  // to ship a skill whose declared minimum exceeds the server's version.
@@ -29,11 +29,21 @@ export function parseVersion(v) {
29
29
  return m ? { major: +m[1], minor: +m[2], patch: +m[3] } : null;
30
30
  }
31
31
 
32
- /** Compare two versions: -1 / 0 / 1. Unparseable versions compare as equal. */
32
+ /**
33
+ * Compare two versions: -1 / 0 / 1.
34
+ *
35
+ * An unparseable version THROWS. The question "which of these is newer?"
36
+ * has no answer when one of them is not a version, and answering "0" — the
37
+ * old behaviour — said "they are the same", which is false and quietly
38
+ * wrong. `satisfies` answers a different question ("does this version meet
39
+ * this requirement?") and handles an unknown version as "meets nothing".
40
+ */
33
41
  export function compareVersions(a, b) {
34
42
  const x = parseVersion(a);
35
43
  const y = parseVersion(b);
36
- if (!x || !y) return 0;
44
+ if (!x || !y) {
45
+ throw invalidInput(`cannot compare versions: '${a}' and '${b}' are not both versions`, { a, b });
46
+ }
37
47
  for (const k of ['major', 'minor', 'patch']) {
38
48
  if (x[k] !== y[k]) return x[k] < y[k] ? -1 : 1;
39
49
  }
@@ -65,10 +65,10 @@ const FR_ID_SOURCE = '(?<![A-Za-z0-9_])FR-[A-Z0-9]+-\\d+(?![A-Za-z0-9_])';
65
65
  * @param {string} input
66
66
  * @returns {{slugDir: string, repoRoot: string, slug: string}}
67
67
  */
68
- export function resolveDocScope(input) {
68
+ export function resolveDocScope(input, projectRoot = null) {
69
69
  let scoped;
70
70
  try {
71
- scoped = resolveSlugInput(input);
71
+ scoped = resolveSlugInput(input, projectRoot);
72
72
  } catch (e) {
73
73
  if (e instanceof ScopeError) throw invalidInput(`doc-index: ${e.message}`, { input });
74
74
  throw invalidInput(`cannot read ${input} (missing or not a directory)`, { input });
@@ -121,13 +121,14 @@ function walkFiles(slugDir, filter) {
121
121
  const rel = stack.pop();
122
122
  for (const e of readdirSync(rel ? join(slugDir, rel) : slugDir, { withFileTypes: true })) {
123
123
  if (e.name.startsWith('.')) continue;
124
- const r = rel ? `${rel}/${e.name}` : e.name;
124
+ const r = rel ? `${rel}/${e.name}` : rel === '' ? e.name : `${rel}/${e.name}`;
125
125
  if (e.isDirectory()) stack.push(r);
126
126
  else if (filter.test(e.name)) candidates.push(r);
127
127
  }
128
128
  }
129
129
  const chosen = new Map(); // real key -> { rel, isLink }
130
130
  let duplicatesSkipped = 0;
131
+ let escapesSkipped = 0;
131
132
  for (const r of candidates.sort()) {
132
133
  const abs = join(slugDir, r);
133
134
  let key;
@@ -139,6 +140,14 @@ function walkFiles(slugDir, filter) {
139
140
  key = resolve(abs); // dangling symlink: keep, keyed by spelled path
140
141
  isLink = false;
141
142
  }
143
+ // A file whose REAL path is outside the artifact space is a symlink out
144
+ // of it. Reading it would return whatever it points at — a private key,
145
+ // a file from elsewhere on the machine — inside a tool that promises to
146
+ // read nothing but archgen's own artifacts. Such a file is skipped.
147
+ if (key !== slugDir && !key.startsWith(slugDir + sep)) {
148
+ escapesSkipped++;
149
+ continue;
150
+ }
142
151
  const prev = chosen.get(key);
143
152
  if (!prev) {
144
153
  chosen.set(key, { rel: r, isLink });
@@ -147,7 +156,11 @@ function walkFiles(slugDir, filter) {
147
156
  duplicatesSkipped++;
148
157
  if (prev.isLink && !isLink) chosen.set(key, { rel: r, isLink }); // real spelling wins over alias
149
158
  }
150
- return { files: [...chosen.values()].map((v) => v.rel).sort(), duplicatesSkipped };
159
+ return {
160
+ files: [...chosen.values()].map((v) => v.rel).sort(),
161
+ duplicatesSkipped,
162
+ escapesSkipped,
163
+ };
151
164
  }
152
165
 
153
166
  const wordCount = (lines) => lines.join(' ').split(/\s+/).filter(Boolean).length;
@@ -165,7 +178,7 @@ export function docIndex(input, opts = {}) {
165
178
  const sectionRef = opts.section ?? null;
166
179
  const fileScopeArg = opts.file ?? null;
167
180
 
168
- const { slugDir, repoRoot, slug } = resolveDocScope(input);
181
+ const { slugDir, repoRoot, slug } = resolveDocScope(input, opts.projectRoot ?? null);
169
182
  const relFromRoot = (abs) => relative(repoRoot, abs).split(sep).join('/');
170
183
  const relFromSlug = (abs) => relative(slugDir, abs).split(sep).join('/');
171
184
  const parsedDocAt = makeDocCache();
@@ -324,12 +337,18 @@ export function docIndex(input, opts = {}) {
324
337
  const hash = link.target.indexOf('#');
325
338
  const target = hash === -1 ? link.target : link.target.slice(0, hash);
326
339
  const anchor = hash === -1 ? null : link.target.slice(hash + 1);
327
- if (target && !existsSync(resolve(dirname(doc.abs), target))) {
340
+ // A link target is resolved against the document's directory, so a
341
+ // link like `../../../secrets.md` would otherwise point outside the
342
+ // artifact space. The link is checked, never READ from outside: a
343
+ // target outside the slug directory is reported as broken rather
344
+ // than followed.
345
+ const targetAbs = target ? resolve(dirname(doc.abs), target) : '';
346
+ const inside = targetAbs && (targetAbs === slugDir || targetAbs.startsWith(slugDir + sep));
347
+ if (target && (!inside || !existsSync(targetAbs))) {
328
348
  broken.push({ kind: 'link', ref: link.target, file: relFromRoot(doc.abs), line: link.line });
329
349
  continue;
330
350
  }
331
351
  if (anchor && target && /\.md$/i.test(target)) {
332
- const targetAbs = resolve(dirname(doc.abs), target);
333
352
  const targetDoc = parsedDocAt(targetAbs);
334
353
  if (targetDoc && !anchorExists(targetDoc, anchor)) {
335
354
  broken.push({ kind: 'anchor', ref: link.target, file: relFromRoot(doc.abs), line: link.line });
@@ -17,9 +17,9 @@
17
17
  // These semantics are inherited verbatim from the CLI's store.js and from
18
18
  // skill/scripts/set-status.mjs — the MCP server must not be weaker than the
19
19
  // scripts it replaces.
20
- import { lstatSync, mkdirSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
20
+ import { lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
21
21
  import { dirname, resolve, sep } from 'node:path';
22
- import { permissionDenied } from './errors.mjs';
22
+ import { conflict, permissionDenied } from './errors.mjs';
23
23
 
24
24
  /** Canonical refusal message — identical wording to the CLI's. */
25
25
  export function refuseSymlinkMessage(absPath) {
@@ -42,21 +42,40 @@ export function assertNotSymlink(absPath) {
42
42
  }
43
43
 
44
44
  /**
45
- * Realpath-escape guard: resolve `absPath` (climbing to the nearest existing
46
- * ancestor when it does not exist yet, so a symlinked PARENT dir is caught
47
- * before anything is created beneath it) and refuse when the real location
48
- * lands outside `rootAbs`.
45
+ * Realpath-escape guard: refuse anything that lands outside `rootAbs`.
46
+ *
47
+ * TWO CHECKS, IN ORDER.
48
+ *
49
+ * 1. LEXICAL. After normalization, the path must be inside the root. This
50
+ * always applies — it is the primary guard, and it cannot be skipped
51
+ * because a path does not exist yet.
52
+ * 2. REAL. The nearest existing ancestor is resolved and must also be
53
+ * inside the root's real path. This is what catches a symlinked
54
+ * PARENT directory that would redirect the write elsewhere.
55
+ *
56
+ * The guard does NOT fail open: if the root cannot be resolved, only the
57
+ * lexical check stands, and the lexical check is always applied.
58
+ *
49
59
  * @param {string} rootAbs
50
60
  * @param {string} absPath
51
61
  */
52
62
  export function assertWithinRoot(rootAbs, absPath) {
63
+ const root = resolve(rootAbs);
64
+ const abs = resolve(absPath);
65
+ if (abs !== root && !abs.startsWith(root + sep)) {
66
+ throw permissionDenied(
67
+ `refusing a path outside the project: ${absPath} is not inside ${rootAbs}`,
68
+ { path: absPath, root: rootAbs },
69
+ );
70
+ }
71
+
53
72
  let rootReal;
54
73
  try {
55
- rootReal = realpathSync(rootAbs);
74
+ rootReal = realpathSync(root);
56
75
  } catch {
57
- return; // the root itself does not exist yet — nothing can escape it
76
+ return; // the root itself does not exist yet — the lexical check stands
58
77
  }
59
- let probe = absPath;
78
+ let probe = abs;
60
79
  for (;;) {
61
80
  let exists = true;
62
81
  try {
@@ -105,10 +124,13 @@ export function containPath(rootAbs, candidate) {
105
124
  *
106
125
  * @param {string} file absolute target path
107
126
  * @param {string|Buffer} content
108
- * @param {{eol?: string}} [opts]
127
+ * @param {{eol?: string, root?: string}} [opts] `root` adds a containment
128
+ * check for the whole parent chain, so a symlinked PARENT cannot redirect
129
+ * the write outside the tree.
109
130
  */
110
131
  export function writeFileAtomic(file, content, opts = {}) {
111
132
  mkdirSync(dirname(file), { recursive: true });
133
+ if (opts.root) assertWithinRoot(opts.root, file);
112
134
  assertNotSymlink(file);
113
135
  let tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
114
136
  for (let attempt = 0; ; attempt++) {
@@ -136,5 +158,71 @@ export function writeFileAtomic(file, content, opts = {}) {
136
158
  return file;
137
159
  }
138
160
 
161
+ /**
162
+ * Run `fn` while holding an exclusive lock on `file`.
163
+ *
164
+ * WHY. An atomic write stops a READER seeing a half-written file. It does
165
+ * not stop two WRITERS losing each other's work: worker A reads tasks.yaml,
166
+ * worker B reads the same bytes, A writes its status change, B writes its
167
+ * own from the stale snapshot — and A's change is gone. The parallel
168
+ * workers are separate processes by design, so the guard has to live on
169
+ * disk, not in memory.
170
+ *
171
+ * The lock is a sibling `<file>.lock` created with O_EXCL. A lock older than
172
+ * `staleMs` is a crashed holder's leftover and is broken. The lock is always
173
+ * released, including when `fn` throws.
174
+ *
175
+ * @param {string} file the file being mutated
176
+ * @param {() => any} fn
177
+ * @param {{timeoutMs?: number, staleMs?: number, pollMs?: number}} [opts]
178
+ * @returns {any} whatever `fn` returns
179
+ */
180
+ export function withFileLock(file, fn, opts = {}) {
181
+ const timeoutMs = opts.timeoutMs ?? 10_000;
182
+ const staleMs = opts.staleMs ?? 30_000;
183
+ const pollMs = opts.pollMs ?? 50;
184
+ const lockPath = `${file}.lock`;
185
+ const deadline = Date.now() + timeoutMs;
186
+
187
+ mkdirSync(dirname(lockPath), { recursive: true });
188
+ for (;;) {
189
+ try {
190
+ writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
191
+ break;
192
+ } catch (e) {
193
+ if (e?.code !== 'EEXIST') throw e;
194
+ // A lock older than staleMs belongs to a holder that died.
195
+ try {
196
+ const age = Date.now() - statSync(lockPath).mtimeMs;
197
+ if (age > staleMs) {
198
+ unlinkSync(lockPath);
199
+ continue;
200
+ }
201
+ } catch {
202
+ continue; // the lock vanished between the EEXIST and the stat
203
+ }
204
+ if (Date.now() > deadline) {
205
+ throw conflict(`timed out after ${timeoutMs}ms waiting for the lock on ${file}`, { file, lockPath });
206
+ }
207
+ sleepSync(pollMs);
208
+ }
209
+ }
210
+
211
+ try {
212
+ return fn();
213
+ } finally {
214
+ try {
215
+ unlinkSync(lockPath);
216
+ } catch {
217
+ /* the lock is already gone */
218
+ }
219
+ }
220
+ }
221
+
222
+ /** Block the current thread for `ms` milliseconds. */
223
+ function sleepSync(ms) {
224
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
225
+ }
226
+
139
227
 
140
228
 
@@ -78,9 +78,14 @@ export function findCycle(prerequisites) {
78
78
  * @param {Map<string,any>} byId
79
79
  * @param {Map<string,string[]>} prerequisites
80
80
  * @returns {{waves: Array<Array<any>>, blockedByFailure: string[]}}
81
- * Wave membership: every prerequisite is done. Tasks whose TRANSITIVE
82
- * prerequisites include any failed task are excluded entirely and reported in
83
- * blockedByFailure (no silent cascades — orchestrator re-plans around them). */
81
+ * Wave membership: every prerequisite is DONE OR ALREADY QUEUED in an earlier
82
+ * wave. That is standard topological layering — a task in wave 3 may depend
83
+ * on a task in wave 2, which is not done yet when wave 3 starts; it is
84
+ * merely no longer blocked.
85
+ *
86
+ * Tasks whose TRANSITIVE prerequisites include any failed task are excluded
87
+ * entirely and reported in blockedByFailure (no silent cascades — the
88
+ * orchestrator re-plans around them). */
84
89
  export function computeWaves(byId, prerequisites) {
85
90
  const done = new Set();
86
91
  const failed = new Set();