severally-mcp 2.3.1 → 2.4.2

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 (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
package/src/store.mjs CHANGED
@@ -7,15 +7,218 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { POLICY } from './policy.mjs';
9
9
 
10
+ /**
11
+ * @typedef {object} JobFailure
12
+ * @property {string} kind
13
+ * @property {string} message
14
+ * @property {string | null} detail
15
+ * @property {boolean} retriable
16
+ * @property {boolean} delivered_advice
17
+ */
18
+
19
+ /**
20
+ * What the lead wrote about one point of an answer.
21
+ * @typedef {object} RecordEntry
22
+ * @property {string} id
23
+ * @property {string} verdict
24
+ * @property {string | null} effect
25
+ * @property {string | null} note
26
+ * @property {string} recorded_at
27
+ */
28
+
29
+ /**
30
+ * The lead's verdicts on a round. The counts are present in a view
31
+ * (recordSummary), not in what the history keeps.
32
+ * @typedef {object} JobRecord
33
+ * @property {string} updated_at
34
+ * @property {RecordEntry[]} entries
35
+ * @property {Record<string, number> | undefined} [verdicts]
36
+ * @property {{recordable: number, recorded: number} | undefined} [coverage]
37
+ * @property {string[] | undefined} [unrecorded]
38
+ */
39
+
40
+ /**
41
+ * What the answer added over what the lead expected, in the lead's words.
42
+ * @typedef {object} Reflection
43
+ * @property {string} delta
44
+ * @property {string[]} related_item_ids
45
+ * @property {string} recorded_at
46
+ */
47
+
48
+ /**
49
+ * Written before the consultant is launched and never sent to it.
50
+ * @typedef {object} Prediction
51
+ * @property {string} expected
52
+ * @property {string} worry
53
+ * @property {string} recorded_at
54
+ */
55
+
56
+ /** @typedef {import('./parse-result.mjs').ConsultResult} ConsultResult A consultant's normalized answer (see normalizeResult). */
57
+ /** @typedef {import('./parse-result.mjs').Finding | import('./parse-result.mjs').Unknown | import('./parse-result.mjs').NextCheck} ResultItem One finding, unknown or next check of an answer. */
58
+ /** @typedef {import('./schema.mjs').Mode} Mode */
59
+ /** @typedef {import('./run.mjs').Boundary} Boundary What the consultant could and could not do, as its launch set it. */
60
+ /** @typedef {import('./run.mjs').UsageRecord} UsageRecord */
61
+
62
+ /**
63
+ * What the server derived from a normalized answer and the checks around it.
64
+ * @typedef {object} Quality
65
+ * @property {string[]} missing_sections
66
+ * @property {number} findings_without_grounds
67
+ * @property {boolean} unsourced
68
+ * @property {Record<string, number> | undefined} [list_truncated]
69
+ * @property {import('./parse-result.mjs').EvidenceBasis | null | undefined} [evidence_basis]
70
+ * @property {string | null | undefined} [caveat]
71
+ */
72
+
73
+ /**
74
+ * The material the lead handed over, as the round keeps it (see briefRecord).
75
+ * @typedef {object} BriefRecord
76
+ * @property {string | null} objective
77
+ * @property {string[]} success_criteria
78
+ * @property {string[]} constraints
79
+ * @property {string[]} facts
80
+ * @property {string | null} proposal
81
+ * @property {string[]} counterpoints
82
+ * @property {Array<{name: string, kind: string, language: string | null, source: string | null, excerpt: string}>} artifacts
83
+ * @property {{entries: BriefExposeEntry[], totals: import('./expose-paths.mjs').ExposeTotals, skipped: import('./expose-paths.mjs').SkippedPath[]} | null} expose_paths
84
+ */
85
+
86
+ /**
87
+ * @typedef {object} BriefExposeEntry
88
+ * @property {number} index
89
+ * @property {string} source
90
+ * @property {string} exposed_as
91
+ * @property {string} kind
92
+ * @property {number} files
93
+ * @property {number} bytes
94
+ * @property {string | null} sha256
95
+ * @property {string | undefined} [base]
96
+ * @property {string[] | undefined} [changes]
97
+ * @property {boolean | undefined} [changed_since_parent]
98
+ */
99
+
100
+ /**
101
+ * The tail of what a timed-out consultant had written (raw, unparsed).
102
+ * @typedef {object} PartialOutput
103
+ * @property {true} partial
104
+ * @property {number} chars_total
105
+ * @property {boolean} capture_truncated
106
+ * @property {string} tail
107
+ * @property {string} stopped_at
108
+ * @property {string} note
109
+ */
110
+
111
+ /**
112
+ * One earlier attempt of a consultation that was run again.
113
+ * @typedef {object} RetryRecord
114
+ * @property {string} failure_kind
115
+ * @property {string} reason
116
+ * @property {string} at
117
+ * @property {UsageRecord | null} usage
118
+ */
119
+
120
+ /**
121
+ * One consultation as the history keeps it (see JobManager#record) and as the
122
+ * process running it describes it; written to history/<chain>/round-NN.json.
123
+ * @typedef {object} Round
124
+ * @property {string} job_id
125
+ * @property {string} chain_id
126
+ * @property {string | null | undefined} [group_id]
127
+ * @property {number} round
128
+ * @property {string} target
129
+ * @property {string} cli
130
+ * @property {string | null | undefined} [vendor]
131
+ * @property {string | null | undefined} [caller]
132
+ * @property {string | null | undefined} [caller_model]
133
+ * @property {string | null | undefined} [initiator]
134
+ * @property {'on' | 'off' | undefined} [network]
135
+ * @property {Boundary | null | undefined} [boundary]
136
+ * @property {RetryRecord[] | undefined} [retries]
137
+ * @property {PartialOutput | null | undefined} [partial_output]
138
+ * @property {Record<string, number> | null | undefined} [tool_calls]
139
+ * @property {string | null | undefined} [diagnostics]
140
+ * @property {Mode} mode
141
+ * @property {string} status
142
+ * @property {string} model
143
+ * @property {string} question
144
+ * @property {BriefRecord | null} brief
145
+ * @property {Prediction | null | undefined} [prediction]
146
+ * @property {Reflection | null | undefined} [reflection]
147
+ * @property {JobRecord | null | undefined} [record]
148
+ * @property {string} created_at
149
+ * @property {string | null} started_at
150
+ * @property {string | null} finished_at
151
+ * @property {number | null} duration_ms
152
+ * @property {ConsultResult | null} result
153
+ * @property {Quality | null} quality
154
+ * @property {UsageRecord | null} usage
155
+ * @property {JobFailure | null} failure
156
+ * @property {number | null | undefined} [references_count]
157
+ * @property {string | null} followup_to
158
+ * @property {number | undefined} [rounds_used]
159
+ * @property {number | undefined} [rounds_remaining]
160
+ */
161
+
162
+ /** @typedef {Round & { owner?: Seat['owner'] }} RunningRecord The in-progress marker: a round record so far, plus who runs it. */
163
+
164
+ /**
165
+ * One line of history/index.jsonl.
166
+ * @typedef {object} IndexRow
167
+ * @property {string} job_id
168
+ * @property {string} chain_id
169
+ * @property {number} round
170
+ * @property {string} target
171
+ * @property {string | null} cli
172
+ * @property {string | null} initiator
173
+ * @property {string} mode
174
+ * @property {string} status
175
+ * @property {string | null} failure_kind
176
+ * @property {string} model
177
+ * @property {string} created_at
178
+ * @property {number | null} duration_ms
179
+ */
180
+
181
+ /**
182
+ * A seat file: one running consultation, shared by every severally process on one home.
183
+ * @typedef {object} Seat
184
+ * @property {string} job_id
185
+ * @property {string | undefined} [chain_id] set on a follow-up: the chain it continues
186
+ * @property {number | undefined} [round] set on a follow-up: the round it opens
187
+ * @property {{ pid: number, deadline_at: string } | undefined} [owner]
188
+ */
189
+
190
+ /**
191
+ * What takeSeats says: `chainBusy` when the chain already has a round under way,
192
+ * otherwise `live` (the seats held) and, when the cap is hit, `elsewhere`.
193
+ * @typedef {{ ok: true, live: number, elsewhere?: undefined, chainBusy?: undefined }
194
+ * | { ok: false, live?: undefined, elsewhere?: undefined, chainBusy: { live: Seat, mine: Seat } }
195
+ * | { ok: false, live: number, elsewhere: number, chainBusy?: undefined }} SeatResult
196
+ */
197
+
198
+ /**
199
+ * A fan-out, recorded under history/groups/.
200
+ * @typedef {object} Group
201
+ * @property {string} group_id
202
+ * @property {string} created_at
203
+ * @property {Mode} mode
204
+ * @property {string} question
205
+ * @property {string[]} job_ids
206
+ */
207
+
10
208
  export function ensureDirs() {
11
209
  const home = POLICY.home;
12
210
  fs.mkdirSync(path.join(home, 'history'), { recursive: true, mode: 0o700 });
13
211
  fs.mkdirSync(path.join(home, 'jobs'), { recursive: true, mode: 0o700 });
14
212
  fs.mkdirSync(path.join(home, 'homes'), { recursive: true, mode: 0o700 });
15
- try { fs.chmodSync(home, 0o700); } catch { /* best effort */ }
213
+ try {
214
+ fs.chmodSync(home, 0o700);
215
+ } catch {
216
+ /* best effort */
217
+ }
16
218
  return home;
17
219
  }
18
220
 
221
+ /** @param {string} jobId */
19
222
  export function jobDir(jobId) {
20
223
  return path.join(POLICY.home, 'jobs', jobId);
21
224
  }
@@ -24,10 +227,12 @@ export function jobDir(jobId) {
24
227
  // directories, under one root: a consultant's read rules can then refuse that
25
228
  // one directory, including homes created after it started. Codex's permission
26
229
  // profile cannot express a wildcard such as jobs/*/home.
230
+ /** @param {string} jobId */
27
231
  export function sandboxHome(jobId) {
28
232
  return path.join(POLICY.home, 'homes', jobId);
29
233
  }
30
234
 
235
+ /** @param {string} jobId */
31
236
  export function makeWorkdir(jobId) {
32
237
  // Empty, dedicated, and outside any project: nothing for the consultant to
33
238
  // pick up as ambient context (no AGENTS.md / CLAUDE.md / repo files).
@@ -36,6 +241,11 @@ export function makeWorkdir(jobId) {
36
241
  return dir;
37
242
  }
38
243
 
244
+ /**
245
+ * @param {string} jobId
246
+ * @param {string} name
247
+ * @param {string | NodeJS.ArrayBufferView} content
248
+ */
39
249
  export function writeJobArtifact(jobId, name, content) {
40
250
  const dir = jobDir(jobId);
41
251
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -44,44 +254,79 @@ export function writeJobArtifact(jobId, name, content) {
44
254
  return p;
45
255
  }
46
256
 
257
+ /** @param {string} p */
47
258
  export function readIfExists(p) {
48
- try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
259
+ try {
260
+ return fs.readFileSync(p, 'utf8');
261
+ } catch {
262
+ return '';
263
+ }
49
264
  }
50
265
 
51
266
  // A consultation from an earlier server session, found through the index and
52
267
  // read back from its round file. Verdicts are written after the checking, and
53
268
  // the checking can take days, so the record must not depend on the job still
54
269
  // being in this process's memory. Returns null when the index has no such job.
270
+ /**
271
+ * @param {string} jobId
272
+ * @returns {IndexRow | null}
273
+ */
55
274
  function indexRow(jobId) {
56
275
  for (const line of readIfExists(path.join(POLICY.home, 'history', 'index.jsonl')).split('\n')) {
57
276
  if (!line.trim()) continue;
58
277
  try {
59
278
  const parsed = JSON.parse(line);
60
279
  if (parsed.job_id === jobId) return parsed;
61
- } catch { /* a damaged line is skipped, not fatal */ }
280
+ } catch {
281
+ /* a damaged line is skipped, not fatal */
282
+ }
62
283
  }
63
284
  return null;
64
285
  }
65
286
 
66
- /** Every row of the history index, in the order written; a damaged line is skipped. */
287
+ /**
288
+ * Every row of the history index, in the order written; a damaged line is skipped.
289
+ * @returns {IndexRow[]}
290
+ */
67
291
  export function indexRows() {
68
- return readIfExists(path.join(POLICY.home, 'history', 'index.jsonl')).split('\n').flatMap((line) => {
69
- try {
70
- const row = line.trim() ? JSON.parse(line) : null;
71
- return row && typeof row === 'object' && JOB_ID.test(row.job_id) ? [row] : [];
72
- } catch { return []; }
73
- });
292
+ return readIfExists(path.join(POLICY.home, 'history', 'index.jsonl'))
293
+ .split('\n')
294
+ .flatMap((line) => {
295
+ try {
296
+ const row = line.trim() ? JSON.parse(line) : null;
297
+ return row && typeof row === 'object' && JOB_ID.test(row.job_id) ? [row] : [];
298
+ } catch {
299
+ return [];
300
+ }
301
+ });
74
302
  }
75
303
 
76
- /** The round file an index row points at, or null. */
304
+ /**
305
+ * The round file an index row points at, or null.
306
+ * @param {{ chain_id: string, round: number }} row
307
+ * @returns {Round | null}
308
+ */
77
309
  export function loadRoundAt(row) {
78
- try { return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8')); } catch { return null; }
310
+ try {
311
+ return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8'));
312
+ } catch {
313
+ return null;
314
+ }
79
315
  }
80
316
 
317
+ /**
318
+ * @param {string} chainId
319
+ * @param {number} round
320
+ */
81
321
  export const roundPath = (chainId, round) =>
82
322
  path.join(POLICY.home, 'history', chainId, `round-${String(round).padStart(2, '0')}.json`);
83
323
 
84
- /** Kept beside the round when a consultant did not end on its own; `text` is already redacted. */
324
+ /**
325
+ * Kept beside the round when a consultant did not end on its own; `text` is already redacted.
326
+ * @param {string} chainId
327
+ * @param {number} round
328
+ * @param {string} text
329
+ */
85
330
  export function writeDiagnostics(chainId, round, text) {
86
331
  fs.mkdirSync(path.join(POLICY.home, 'history', chainId), { recursive: true, mode: 0o700 });
87
332
  const file = roundPath(chainId, round).replace(/\.json$/, '.diagnostics.txt');
@@ -89,26 +334,54 @@ export function writeDiagnostics(chainId, round, text) {
89
334
  return file;
90
335
  }
91
336
 
337
+ /**
338
+ * @param {string} jobId
339
+ * @returns {Round | null}
340
+ */
92
341
  export function loadRound(jobId) {
93
342
  const row = indexRow(jobId);
94
343
  if (!row) return null;
95
- try { return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8')); } catch { return null; }
344
+ try {
345
+ return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8'));
346
+ } catch {
347
+ return null;
348
+ }
96
349
  }
97
350
 
98
351
  // Every round file of one chain, oldest first: a follow-up to a consultation
99
352
  // from an earlier server session needs the rounds before it, not just one.
353
+ /**
354
+ * @param {string} chainId
355
+ * @returns {Round[]}
356
+ */
100
357
  export function loadChain(chainId) {
101
358
  const dir = path.join(POLICY.home, 'history', chainId);
359
+ /** @type {string[]} */
102
360
  let names = [];
103
- try { names = fs.readdirSync(dir).filter((n) => /^round-\d+\.json$/.test(n)).sort(); } catch { return []; }
361
+ try {
362
+ names = fs
363
+ .readdirSync(dir)
364
+ .filter((n) => /^round-\d+\.json$/.test(n))
365
+ .sort();
366
+ } catch {
367
+ return [];
368
+ }
104
369
  return names.flatMap((n) => {
105
- try { return [JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'))]; } catch { return []; }
370
+ try {
371
+ return [JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'))];
372
+ } catch {
373
+ return [];
374
+ }
106
375
  });
107
376
  }
108
377
 
109
378
  // `appendIndex: false` rewrites a round that has already been recorded -- the
110
379
  // lead adding a verdict to it later. index.jsonl stays one line per
111
380
  // consultation, so counting rounds there never counts edits.
381
+ /**
382
+ * @param {Round} record
383
+ * @param {{ appendIndex?: boolean }} [opts]
384
+ */
112
385
  export function persistRound(record, { appendIndex = true } = {}) {
113
386
  fs.mkdirSync(path.join(POLICY.home, 'history', record.chain_id), { recursive: true, mode: 0o700 });
114
387
  // Written aside and renamed into place, so a write that fails part-way
@@ -156,8 +429,10 @@ export function persistRound(record, { appendIndex = true } = {}) {
156
429
  const JOB_ID = /^job_[0-9a-f]{12}$/;
157
430
  const GROUP_ID = /^group_[0-9a-f]{12}$/;
158
431
  const runningDir = () => path.join(POLICY.home, 'history', 'running');
432
+ /** @param {string} jobId */
159
433
  const runningFile = (jobId) => path.join(runningDir(), `${jobId}.json`);
160
434
 
435
+ /** @param {RunningRecord} record */
161
436
  export function writeRunning(record) {
162
437
  if (!JOB_ID.test(record.job_id)) return;
163
438
  fs.mkdirSync(runningDir(), { recursive: true, mode: 0o700 });
@@ -167,20 +442,39 @@ export function writeRunning(record) {
167
442
  fs.renameSync(tmp, runningFile(record.job_id));
168
443
  }
169
444
 
445
+ /**
446
+ * @param {string} jobId
447
+ * @returns {RunningRecord | null}
448
+ */
170
449
  export function loadRunning(jobId) {
171
450
  if (!JOB_ID.test(jobId)) return null;
172
- try { return JSON.parse(fs.readFileSync(runningFile(jobId), 'utf8')); } catch { return null; }
451
+ try {
452
+ return JSON.parse(fs.readFileSync(runningFile(jobId), 'utf8'));
453
+ } catch {
454
+ return null;
455
+ }
173
456
  }
174
457
 
458
+ /** @returns {string[]} */
175
459
  export function listRunning() {
176
460
  try {
177
- return fs.readdirSync(runningDir()).map((n) => n.replace(/\.json$/, '')).filter((id) => JOB_ID.test(id));
178
- } catch { return []; }
461
+ return fs
462
+ .readdirSync(runningDir())
463
+ .map((n) => n.replace(/\.json$/, ''))
464
+ .filter((id) => JOB_ID.test(id));
465
+ } catch {
466
+ return [];
467
+ }
179
468
  }
180
469
 
470
+ /** @param {string} jobId */
181
471
  export function removeRunning(jobId) {
182
472
  if (!JOB_ID.test(jobId)) return;
183
- try { fs.rmSync(runningFile(jobId), { force: true }); } catch { /* best effort */ }
473
+ try {
474
+ fs.rmSync(runningFile(jobId), { force: true });
475
+ } catch {
476
+ /* best effort */
477
+ }
184
478
  }
185
479
 
186
480
  // Seats: the concurrency cap is shared by every severally process on one
@@ -189,50 +483,105 @@ export function removeRunning(jobId) {
189
483
  // admitting at once cannot both see the last free seat. `held(seat)` says
190
484
  // whether a seat's owner is still running it; one that is not is removed.
191
485
  const seatsDir = () => path.join(POLICY.home, 'slots');
486
+ /** @param {string} jobId */
192
487
  const seatFile = (jobId) => path.join(seatsDir(), `${jobId}.json`);
193
488
  const LOCK_WAIT_MS = 2_000;
194
489
  const LOCK_STALE_MS = 10_000;
195
490
 
491
+ /**
492
+ * @template T
493
+ * @param {() => T} fn
494
+ * @returns {T}
495
+ */
196
496
  function withSeatLock(fn) {
197
497
  const lock = path.join(seatsDir(), '.lock');
198
498
  fs.mkdirSync(seatsDir(), { recursive: true, mode: 0o700 });
199
499
  const until = Date.now() + LOCK_WAIT_MS;
200
500
  for (;;) {
201
- try { fs.mkdirSync(lock); break; } catch (err) {
202
- if (err.code !== 'EEXIST') throw err;
501
+ try {
502
+ fs.mkdirSync(lock);
503
+ break;
504
+ } catch (err) {
505
+ if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST')) throw err;
203
506
  // Held for a few file operations at most: one this old was left by a
204
507
  // process that died holding it.
205
- try { if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.rmdirSync(lock); } catch { /* gone already */ }
508
+ try {
509
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.rmdirSync(lock);
510
+ } catch {
511
+ /* gone already */
512
+ }
206
513
  if (Date.now() > until) throw new Error('the seat lock stayed taken; try again');
207
514
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
208
515
  }
209
516
  }
210
- try { return fn(); } finally { try { fs.rmdirSync(lock); } catch { /* best effort */ } }
517
+ try {
518
+ return fn();
519
+ } finally {
520
+ try {
521
+ fs.rmdirSync(lock);
522
+ } catch {
523
+ /* best effort */
524
+ }
525
+ }
211
526
  }
212
527
 
213
- /** Takes one seat per entry of `seats` ({ job_id, owner }), all or none. */
528
+ /**
529
+ * A seat that continues a chain names it and the round it opens together.
530
+ * @param {Seat} seat
531
+ * @returns {seat is Seat & { chain_id: string, round: number }}
532
+ */
533
+ const continuesChain = (seat) => Boolean(seat.chain_id);
534
+
535
+ /**
536
+ * Takes one seat per entry of `seats` ({ job_id, owner }), all or none.
537
+ * @param {Seat[]} seats
538
+ * @param {number} cap
539
+ * @param {(seat: Seat) => boolean} held
540
+ * @returns {SeatResult}
541
+ */
214
542
  export function takeSeats(seats, cap, held) {
215
543
  return withSeatLock(() => {
544
+ /** @type {Seat[]} */
216
545
  const live = [];
217
546
  const names = fs.readdirSync(seatsDir());
218
547
  // A cancel asked for after its consultation ended has no seat left to go with.
219
548
  for (const name of names.filter((n) => n.endsWith('.cancel'))) {
220
- if (!names.includes(name.replace(/\.cancel$/, '.json'))) try { fs.rmSync(path.join(seatsDir(), name), { force: true }); } catch { /* best effort */ }
549
+ if (!names.includes(name.replace(/\.cancel$/, '.json')))
550
+ try {
551
+ fs.rmSync(path.join(seatsDir(), name), { force: true });
552
+ } catch {
553
+ /* best effort */
554
+ }
221
555
  }
222
556
  for (const name of names) {
223
557
  if (!name.endsWith('.json')) continue;
558
+ /** @type {Seat | null} */
224
559
  let seat = null;
225
- try { seat = JSON.parse(fs.readFileSync(path.join(seatsDir(), name), 'utf8')); } catch { /* half-written or damaged: free */ }
560
+ try {
561
+ seat = JSON.parse(fs.readFileSync(path.join(seatsDir(), name), 'utf8'));
562
+ } catch {
563
+ /* half-written or damaged: free */
564
+ }
226
565
  if (seat && held(seat)) live.push(seat);
227
- else try { fs.rmSync(path.join(seatsDir(), name), { force: true }); } catch { /* best effort */ }
566
+ else
567
+ try {
568
+ fs.rmSync(path.join(seatsDir(), name), { force: true });
569
+ } catch {
570
+ /* best effort */
571
+ }
228
572
  }
229
573
  // A chain runs one round at a time, across processes: a live seat on the
230
574
  // same chain, or a round file already at the number chosen, means another
231
575
  // server continued it first.
232
- for (const mine of seats.filter((x) => x.chain_id)) {
576
+ for (const mine of seats.filter(continuesChain)) {
577
+ /** @type {Seat | undefined} */
233
578
  let other = live.find((x) => x.chain_id === mine.chain_id);
234
579
  if (!other && fs.existsSync(roundPath(mine.chain_id, mine.round))) {
235
- try { other = { job_id: JSON.parse(fs.readFileSync(roundPath(mine.chain_id, mine.round), 'utf8')).job_id, round: mine.round }; } catch { other = { job_id: '(unreadable)', round: mine.round }; }
580
+ try {
581
+ other = { job_id: JSON.parse(fs.readFileSync(roundPath(mine.chain_id, mine.round), 'utf8')).job_id, round: mine.round };
582
+ } catch {
583
+ other = { job_id: '(unreadable)', round: mine.round };
584
+ }
236
585
  }
237
586
  if (other) return { ok: false, chainBusy: { live: other, mine } };
238
587
  }
@@ -247,40 +596,74 @@ export function takeSeats(seats, cap, held) {
247
596
  });
248
597
  }
249
598
 
599
+ /** @param {string} jobId */
250
600
  export function releaseSeat(jobId) {
251
601
  if (!JOB_ID.test(jobId)) return;
252
- try { fs.rmSync(seatFile(jobId), { force: true }); } catch { /* best effort */ }
602
+ try {
603
+ fs.rmSync(seatFile(jobId), { force: true });
604
+ } catch {
605
+ /* best effort */
606
+ }
253
607
  clearCancel(jobId);
254
608
  }
255
609
 
256
610
  // A cancel asked for by a server that does not run the consultation: left
257
611
  // beside its seat, for the server that does to find and act on.
612
+ /** @param {string} jobId */
258
613
  const cancelFile = (jobId) => path.join(seatsDir(), `${jobId}.cancel`);
614
+ /** @param {string} jobId */
259
615
  export function requestCancel(jobId) {
260
616
  if (!JOB_ID.test(jobId)) return false;
261
- try { fs.mkdirSync(seatsDir(), { recursive: true, mode: 0o700 }); fs.writeFileSync(cancelFile(jobId), '', { mode: 0o600 }); return true; } catch { return false; }
617
+ try {
618
+ fs.mkdirSync(seatsDir(), { recursive: true, mode: 0o700 });
619
+ fs.writeFileSync(cancelFile(jobId), '', { mode: 0o600 });
620
+ return true;
621
+ } catch {
622
+ return false;
623
+ }
262
624
  }
625
+ /** @param {string} jobId */
263
626
  export const cancelRequested = (jobId) => JOB_ID.test(jobId) && fs.existsSync(cancelFile(jobId));
627
+ /** @param {string} jobId */
264
628
  export function clearCancel(jobId) {
265
- if (JOB_ID.test(jobId)) try { fs.rmSync(cancelFile(jobId), { force: true }); } catch { /* best effort */ }
629
+ if (JOB_ID.test(jobId))
630
+ try {
631
+ fs.rmSync(cancelFile(jobId), { force: true });
632
+ } catch {
633
+ /* best effort */
634
+ }
266
635
  }
267
636
 
637
+ /**
638
+ * @param {string} groupId
639
+ * @returns {Group | null}
640
+ */
268
641
  export function loadGroup(groupId) {
269
642
  if (!GROUP_ID.test(groupId)) return null;
270
- try { return JSON.parse(fs.readFileSync(path.join(POLICY.home, 'history', 'groups', `${groupId}.json`), 'utf8')); } catch { return null; }
643
+ try {
644
+ return JSON.parse(fs.readFileSync(path.join(POLICY.home, 'history', 'groups', `${groupId}.json`), 'utf8'));
645
+ } catch {
646
+ return null;
647
+ }
271
648
  }
272
649
 
273
650
  // A fan-out is recorded as its own record so a later reader can tell which
274
651
  // answers were asked the same question at the same time; each member job still
275
652
  // writes its own round file under its own chain.
653
+ /** @param {Group} group */
276
654
  export function persistGroup(group) {
277
655
  const dir = path.join(POLICY.home, 'history', 'groups');
278
656
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
279
657
  fs.writeFileSync(path.join(dir, `${group.group_id}.json`), JSON.stringify(group, null, 2), { mode: 0o600 });
280
658
  }
281
659
 
660
+ /** @param {string} jobId */
282
661
  export function cleanupJobDir(jobId) {
283
662
  for (const dir of [jobDir(jobId), sandboxHome(jobId)]) {
284
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
663
+ try {
664
+ fs.rmSync(dir, { recursive: true, force: true });
665
+ } catch {
666
+ /* best effort */
667
+ }
285
668
  }
286
669
  }