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.
- package/README.ja.md +52 -17
- package/README.md +52 -16
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +15 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +76 -68
- package/scripts/doctor.mjs +66 -20
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +327 -90
- package/scripts/live-check.mjs +62 -23
- package/scripts/live-mcp-check.mjs +52 -16
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +62 -28
- package/src/adapters/antigravity.mjs +111 -30
- package/src/adapters/claude-code.mjs +121 -28
- package/src/adapters/codex.mjs +125 -33
- package/src/adapters/opencode-sandbox.mjs +59 -22
- package/src/adapters/opencode.mjs +144 -30
- package/src/brief.mjs +148 -47
- package/src/consultants.mjs +255 -48
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +99 -18
- package/src/expose-paths.mjs +310 -50
- package/src/failures.mjs +31 -14
- package/src/jobs.mjs +863 -249
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +172 -40
- package/src/platform.mjs +56 -8
- package/src/policy.mjs +245 -52
- package/src/redact.mjs +59 -16
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +225 -30
- package/src/schema.mjs +132 -27
- package/src/server.mjs +178 -81
- package/src/status.mjs +94 -13
- 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 {
|
|
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 {
|
|
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 {
|
|
280
|
+
} catch {
|
|
281
|
+
/* a damaged line is skipped, not fatal */
|
|
282
|
+
}
|
|
62
283
|
}
|
|
63
284
|
return null;
|
|
64
285
|
}
|
|
65
286
|
|
|
66
|
-
/**
|
|
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'))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
/**
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
178
|
-
|
|
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 {
|
|
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 {
|
|
202
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
/**
|
|
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')))
|
|
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 {
|
|
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
|
|
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(
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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))
|
|
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 {
|
|
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 {
|
|
663
|
+
try {
|
|
664
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
665
|
+
} catch {
|
|
666
|
+
/* best effort */
|
|
667
|
+
}
|
|
285
668
|
}
|
|
286
669
|
}
|