severally-mcp 2.4.0 → 2.5.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.
- package/CONFIG.md +1 -0
- package/README.ja.md +44 -19
- package/README.md +44 -18
- package/config.example.json +1 -1
- package/docs/how-it-works.ja.md +2 -0
- package/docs/how-it-works.md +2 -0
- package/package.json +3 -1
- 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 +630 -271
- package/plugins/severally/skills/_template/SKILL.md.tmpl +2 -1
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/SKILL.md +2 -1
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/SKILL.md +2 -1
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/SKILL.md +2 -1
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/SKILL.md +2 -1
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +57 -54
- package/scripts/doctor.mjs +15 -4
- package/scripts/init-config.mjs +2 -1
- package/scripts/install.mjs +73 -9
- package/scripts/live-check.mjs +11 -5
- package/scripts/live-mcp-check.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +24 -6
- package/src/adapters/antigravity.mjs +80 -17
- package/src/adapters/claude-code.mjs +49 -8
- package/src/adapters/codex.mjs +56 -6
- package/src/adapters/opencode-sandbox.mjs +20 -2
- package/src/adapters/opencode.mjs +115 -19
- package/src/brief.mjs +84 -7
- package/src/consultants.mjs +193 -27
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +50 -6
- package/src/expose-paths.mjs +149 -7
- package/src/failures.mjs +8 -1
- package/src/jobs.mjs +632 -152
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +91 -14
- package/src/platform.mjs +52 -2
- package/src/policy.mjs +151 -28
- package/src/redact.mjs +22 -3
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +158 -12
- package/src/schema.mjs +84 -11
- package/src/server.mjs +49 -14
- package/src/status.mjs +16 -4
- package/src/store.mjs +287 -6
package/src/status.mjs
CHANGED
|
@@ -37,9 +37,18 @@ const CLIENTS = [
|
|
|
37
37
|
{ label: 'OpenCode', cmd: 'opencode', checks: [['mcp', 'list']] },
|
|
38
38
|
];
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* A field of whatever was thrown, or undefined when it carries none.
|
|
42
|
+
* @param {unknown} e
|
|
43
|
+
* @param {string} key
|
|
44
|
+
* @returns {unknown}
|
|
45
|
+
*/
|
|
46
|
+
const thrown = (e, key) => (typeof e === 'object' && e !== null ? Reflect.get(e, key) : undefined);
|
|
47
|
+
|
|
40
48
|
// "registered": the client's own listing names severally (it may still be
|
|
41
49
|
// turned off there). "not registered": the client answered, without it.
|
|
42
50
|
// No answer at all (a timeout, a failure to start): unknown.
|
|
51
|
+
/** @param {{ cmd: string, checks: string[][] }} client */
|
|
43
52
|
function registration({ cmd, checks }) {
|
|
44
53
|
if (!findCommand(cmd)) return 'not installed';
|
|
45
54
|
let ran = false;
|
|
@@ -51,7 +60,7 @@ function registration({ cmd, checks }) {
|
|
|
51
60
|
} catch (err) {
|
|
52
61
|
// It ran and said no (`mcp get` of a missing server exits non-zero); a
|
|
53
62
|
// timeout or a failure to start says nothing either way.
|
|
54
|
-
if (typeof err
|
|
63
|
+
if (typeof thrown(err, 'status') === 'number') ran = true;
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
return ran ? 'not registered' : `could not tell (${cmd} ${checks.map((a) => a.join(' ')).join(' / ')} failed)`;
|
|
@@ -60,6 +69,7 @@ function registration({ cmd, checks }) {
|
|
|
60
69
|
// Each CLI's own word on its login, read without sending a prompt. Only the
|
|
61
70
|
// verdict is kept: `claude auth status` also prints the account's email.
|
|
62
71
|
// Antigravity has no such command, so it is not checked.
|
|
72
|
+
/** @type {Record<string, { cmd: string, args: string[], read: (out: string, ok: boolean) => boolean | string }>} */
|
|
63
73
|
const LOGIN = {
|
|
64
74
|
'claude-code': { cmd: 'claude', args: ['auth', 'status', '--json'], read: (out) => JSON.parse(out).loggedIn === true },
|
|
65
75
|
// Codex prints its answer on stderr; the exit status carries the verdict.
|
|
@@ -76,17 +86,19 @@ const LOGIN = {
|
|
|
76
86
|
},
|
|
77
87
|
};
|
|
78
88
|
|
|
89
|
+
/** @param {string} cli */
|
|
79
90
|
function login(cli) {
|
|
80
91
|
const check = LOGIN[cli];
|
|
81
92
|
if (!check) return 'not checked (no login status command)';
|
|
82
93
|
if (!findCommand(check.cmd)) return 'not installed';
|
|
94
|
+
/** @type {string} */
|
|
83
95
|
let out;
|
|
84
96
|
let ok = true;
|
|
85
97
|
try {
|
|
86
98
|
out = execCommandSync(check.cmd, check.args, { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
87
|
-
} catch (
|
|
88
|
-
if (typeof
|
|
89
|
-
out = `${
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (typeof thrown(e, 'status') !== 'number') return `could not tell (${check.cmd} ${check.args.join(' ')} failed)`;
|
|
101
|
+
out = `${thrown(e, 'stdout') ?? ''}`;
|
|
90
102
|
ok = false;
|
|
91
103
|
}
|
|
92
104
|
try {
|
package/src/store.mjs
CHANGED
|
@@ -7,6 +7,204 @@ 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 });
|
|
@@ -20,6 +218,7 @@ export function ensureDirs() {
|
|
|
20
218
|
return home;
|
|
21
219
|
}
|
|
22
220
|
|
|
221
|
+
/** @param {string} jobId */
|
|
23
222
|
export function jobDir(jobId) {
|
|
24
223
|
return path.join(POLICY.home, 'jobs', jobId);
|
|
25
224
|
}
|
|
@@ -28,10 +227,12 @@ export function jobDir(jobId) {
|
|
|
28
227
|
// directories, under one root: a consultant's read rules can then refuse that
|
|
29
228
|
// one directory, including homes created after it started. Codex's permission
|
|
30
229
|
// profile cannot express a wildcard such as jobs/*/home.
|
|
230
|
+
/** @param {string} jobId */
|
|
31
231
|
export function sandboxHome(jobId) {
|
|
32
232
|
return path.join(POLICY.home, 'homes', jobId);
|
|
33
233
|
}
|
|
34
234
|
|
|
235
|
+
/** @param {string} jobId */
|
|
35
236
|
export function makeWorkdir(jobId) {
|
|
36
237
|
// Empty, dedicated, and outside any project: nothing for the consultant to
|
|
37
238
|
// pick up as ambient context (no AGENTS.md / CLAUDE.md / repo files).
|
|
@@ -40,6 +241,11 @@ export function makeWorkdir(jobId) {
|
|
|
40
241
|
return dir;
|
|
41
242
|
}
|
|
42
243
|
|
|
244
|
+
/**
|
|
245
|
+
* @param {string} jobId
|
|
246
|
+
* @param {string} name
|
|
247
|
+
* @param {string | NodeJS.ArrayBufferView} content
|
|
248
|
+
*/
|
|
43
249
|
export function writeJobArtifact(jobId, name, content) {
|
|
44
250
|
const dir = jobDir(jobId);
|
|
45
251
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -48,6 +254,7 @@ export function writeJobArtifact(jobId, name, content) {
|
|
|
48
254
|
return p;
|
|
49
255
|
}
|
|
50
256
|
|
|
257
|
+
/** @param {string} p */
|
|
51
258
|
export function readIfExists(p) {
|
|
52
259
|
try {
|
|
53
260
|
return fs.readFileSync(p, 'utf8');
|
|
@@ -60,6 +267,10 @@ export function readIfExists(p) {
|
|
|
60
267
|
// read back from its round file. Verdicts are written after the checking, and
|
|
61
268
|
// the checking can take days, so the record must not depend on the job still
|
|
62
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
|
+
*/
|
|
63
274
|
function indexRow(jobId) {
|
|
64
275
|
for (const line of readIfExists(path.join(POLICY.home, 'history', 'index.jsonl')).split('\n')) {
|
|
65
276
|
if (!line.trim()) continue;
|
|
@@ -73,7 +284,10 @@ function indexRow(jobId) {
|
|
|
73
284
|
return null;
|
|
74
285
|
}
|
|
75
286
|
|
|
76
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* Every row of the history index, in the order written; a damaged line is skipped.
|
|
289
|
+
* @returns {IndexRow[]}
|
|
290
|
+
*/
|
|
77
291
|
export function indexRows() {
|
|
78
292
|
return readIfExists(path.join(POLICY.home, 'history', 'index.jsonl'))
|
|
79
293
|
.split('\n')
|
|
@@ -87,7 +301,11 @@ export function indexRows() {
|
|
|
87
301
|
});
|
|
88
302
|
}
|
|
89
303
|
|
|
90
|
-
/**
|
|
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
|
+
*/
|
|
91
309
|
export function loadRoundAt(row) {
|
|
92
310
|
try {
|
|
93
311
|
return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8'));
|
|
@@ -96,10 +314,19 @@ export function loadRoundAt(row) {
|
|
|
96
314
|
}
|
|
97
315
|
}
|
|
98
316
|
|
|
317
|
+
/**
|
|
318
|
+
* @param {string} chainId
|
|
319
|
+
* @param {number} round
|
|
320
|
+
*/
|
|
99
321
|
export const roundPath = (chainId, round) =>
|
|
100
322
|
path.join(POLICY.home, 'history', chainId, `round-${String(round).padStart(2, '0')}.json`);
|
|
101
323
|
|
|
102
|
-
/**
|
|
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
|
+
*/
|
|
103
330
|
export function writeDiagnostics(chainId, round, text) {
|
|
104
331
|
fs.mkdirSync(path.join(POLICY.home, 'history', chainId), { recursive: true, mode: 0o700 });
|
|
105
332
|
const file = roundPath(chainId, round).replace(/\.json$/, '.diagnostics.txt');
|
|
@@ -107,6 +334,10 @@ export function writeDiagnostics(chainId, round, text) {
|
|
|
107
334
|
return file;
|
|
108
335
|
}
|
|
109
336
|
|
|
337
|
+
/**
|
|
338
|
+
* @param {string} jobId
|
|
339
|
+
* @returns {Round | null}
|
|
340
|
+
*/
|
|
110
341
|
export function loadRound(jobId) {
|
|
111
342
|
const row = indexRow(jobId);
|
|
112
343
|
if (!row) return null;
|
|
@@ -119,8 +350,13 @@ export function loadRound(jobId) {
|
|
|
119
350
|
|
|
120
351
|
// Every round file of one chain, oldest first: a follow-up to a consultation
|
|
121
352
|
// from an earlier server session needs the rounds before it, not just one.
|
|
353
|
+
/**
|
|
354
|
+
* @param {string} chainId
|
|
355
|
+
* @returns {Round[]}
|
|
356
|
+
*/
|
|
122
357
|
export function loadChain(chainId) {
|
|
123
358
|
const dir = path.join(POLICY.home, 'history', chainId);
|
|
359
|
+
/** @type {string[]} */
|
|
124
360
|
let names = [];
|
|
125
361
|
try {
|
|
126
362
|
names = fs
|
|
@@ -142,6 +378,10 @@ export function loadChain(chainId) {
|
|
|
142
378
|
// `appendIndex: false` rewrites a round that has already been recorded -- the
|
|
143
379
|
// lead adding a verdict to it later. index.jsonl stays one line per
|
|
144
380
|
// consultation, so counting rounds there never counts edits.
|
|
381
|
+
/**
|
|
382
|
+
* @param {Round} record
|
|
383
|
+
* @param {{ appendIndex?: boolean }} [opts]
|
|
384
|
+
*/
|
|
145
385
|
export function persistRound(record, { appendIndex = true } = {}) {
|
|
146
386
|
fs.mkdirSync(path.join(POLICY.home, 'history', record.chain_id), { recursive: true, mode: 0o700 });
|
|
147
387
|
// Written aside and renamed into place, so a write that fails part-way
|
|
@@ -189,8 +429,10 @@ export function persistRound(record, { appendIndex = true } = {}) {
|
|
|
189
429
|
const JOB_ID = /^job_[0-9a-f]{12}$/;
|
|
190
430
|
const GROUP_ID = /^group_[0-9a-f]{12}$/;
|
|
191
431
|
const runningDir = () => path.join(POLICY.home, 'history', 'running');
|
|
432
|
+
/** @param {string} jobId */
|
|
192
433
|
const runningFile = (jobId) => path.join(runningDir(), `${jobId}.json`);
|
|
193
434
|
|
|
435
|
+
/** @param {RunningRecord} record */
|
|
194
436
|
export function writeRunning(record) {
|
|
195
437
|
if (!JOB_ID.test(record.job_id)) return;
|
|
196
438
|
fs.mkdirSync(runningDir(), { recursive: true, mode: 0o700 });
|
|
@@ -200,6 +442,10 @@ export function writeRunning(record) {
|
|
|
200
442
|
fs.renameSync(tmp, runningFile(record.job_id));
|
|
201
443
|
}
|
|
202
444
|
|
|
445
|
+
/**
|
|
446
|
+
* @param {string} jobId
|
|
447
|
+
* @returns {RunningRecord | null}
|
|
448
|
+
*/
|
|
203
449
|
export function loadRunning(jobId) {
|
|
204
450
|
if (!JOB_ID.test(jobId)) return null;
|
|
205
451
|
try {
|
|
@@ -209,6 +455,7 @@ export function loadRunning(jobId) {
|
|
|
209
455
|
}
|
|
210
456
|
}
|
|
211
457
|
|
|
458
|
+
/** @returns {string[]} */
|
|
212
459
|
export function listRunning() {
|
|
213
460
|
try {
|
|
214
461
|
return fs
|
|
@@ -220,6 +467,7 @@ export function listRunning() {
|
|
|
220
467
|
}
|
|
221
468
|
}
|
|
222
469
|
|
|
470
|
+
/** @param {string} jobId */
|
|
223
471
|
export function removeRunning(jobId) {
|
|
224
472
|
if (!JOB_ID.test(jobId)) return;
|
|
225
473
|
try {
|
|
@@ -235,10 +483,16 @@ export function removeRunning(jobId) {
|
|
|
235
483
|
// admitting at once cannot both see the last free seat. `held(seat)` says
|
|
236
484
|
// whether a seat's owner is still running it; one that is not is removed.
|
|
237
485
|
const seatsDir = () => path.join(POLICY.home, 'slots');
|
|
486
|
+
/** @param {string} jobId */
|
|
238
487
|
const seatFile = (jobId) => path.join(seatsDir(), `${jobId}.json`);
|
|
239
488
|
const LOCK_WAIT_MS = 2_000;
|
|
240
489
|
const LOCK_STALE_MS = 10_000;
|
|
241
490
|
|
|
491
|
+
/**
|
|
492
|
+
* @template T
|
|
493
|
+
* @param {() => T} fn
|
|
494
|
+
* @returns {T}
|
|
495
|
+
*/
|
|
242
496
|
function withSeatLock(fn) {
|
|
243
497
|
const lock = path.join(seatsDir(), '.lock');
|
|
244
498
|
fs.mkdirSync(seatsDir(), { recursive: true, mode: 0o700 });
|
|
@@ -248,7 +502,7 @@ function withSeatLock(fn) {
|
|
|
248
502
|
fs.mkdirSync(lock);
|
|
249
503
|
break;
|
|
250
504
|
} catch (err) {
|
|
251
|
-
if (err.code
|
|
505
|
+
if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST')) throw err;
|
|
252
506
|
// Held for a few file operations at most: one this old was left by a
|
|
253
507
|
// process that died holding it.
|
|
254
508
|
try {
|
|
@@ -271,9 +525,23 @@ function withSeatLock(fn) {
|
|
|
271
525
|
}
|
|
272
526
|
}
|
|
273
527
|
|
|
274
|
-
/**
|
|
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
|
+
*/
|
|
275
542
|
export function takeSeats(seats, cap, held) {
|
|
276
543
|
return withSeatLock(() => {
|
|
544
|
+
/** @type {Seat[]} */
|
|
277
545
|
const live = [];
|
|
278
546
|
const names = fs.readdirSync(seatsDir());
|
|
279
547
|
// A cancel asked for after its consultation ended has no seat left to go with.
|
|
@@ -287,6 +555,7 @@ export function takeSeats(seats, cap, held) {
|
|
|
287
555
|
}
|
|
288
556
|
for (const name of names) {
|
|
289
557
|
if (!name.endsWith('.json')) continue;
|
|
558
|
+
/** @type {Seat | null} */
|
|
290
559
|
let seat = null;
|
|
291
560
|
try {
|
|
292
561
|
seat = JSON.parse(fs.readFileSync(path.join(seatsDir(), name), 'utf8'));
|
|
@@ -304,7 +573,8 @@ export function takeSeats(seats, cap, held) {
|
|
|
304
573
|
// A chain runs one round at a time, across processes: a live seat on the
|
|
305
574
|
// same chain, or a round file already at the number chosen, means another
|
|
306
575
|
// server continued it first.
|
|
307
|
-
for (const mine of seats.filter(
|
|
576
|
+
for (const mine of seats.filter(continuesChain)) {
|
|
577
|
+
/** @type {Seat | undefined} */
|
|
308
578
|
let other = live.find((x) => x.chain_id === mine.chain_id);
|
|
309
579
|
if (!other && fs.existsSync(roundPath(mine.chain_id, mine.round))) {
|
|
310
580
|
try {
|
|
@@ -326,6 +596,7 @@ export function takeSeats(seats, cap, held) {
|
|
|
326
596
|
});
|
|
327
597
|
}
|
|
328
598
|
|
|
599
|
+
/** @param {string} jobId */
|
|
329
600
|
export function releaseSeat(jobId) {
|
|
330
601
|
if (!JOB_ID.test(jobId)) return;
|
|
331
602
|
try {
|
|
@@ -338,7 +609,9 @@ export function releaseSeat(jobId) {
|
|
|
338
609
|
|
|
339
610
|
// A cancel asked for by a server that does not run the consultation: left
|
|
340
611
|
// beside its seat, for the server that does to find and act on.
|
|
612
|
+
/** @param {string} jobId */
|
|
341
613
|
const cancelFile = (jobId) => path.join(seatsDir(), `${jobId}.cancel`);
|
|
614
|
+
/** @param {string} jobId */
|
|
342
615
|
export function requestCancel(jobId) {
|
|
343
616
|
if (!JOB_ID.test(jobId)) return false;
|
|
344
617
|
try {
|
|
@@ -349,7 +622,9 @@ export function requestCancel(jobId) {
|
|
|
349
622
|
return false;
|
|
350
623
|
}
|
|
351
624
|
}
|
|
625
|
+
/** @param {string} jobId */
|
|
352
626
|
export const cancelRequested = (jobId) => JOB_ID.test(jobId) && fs.existsSync(cancelFile(jobId));
|
|
627
|
+
/** @param {string} jobId */
|
|
353
628
|
export function clearCancel(jobId) {
|
|
354
629
|
if (JOB_ID.test(jobId))
|
|
355
630
|
try {
|
|
@@ -359,6 +634,10 @@ export function clearCancel(jobId) {
|
|
|
359
634
|
}
|
|
360
635
|
}
|
|
361
636
|
|
|
637
|
+
/**
|
|
638
|
+
* @param {string} groupId
|
|
639
|
+
* @returns {Group | null}
|
|
640
|
+
*/
|
|
362
641
|
export function loadGroup(groupId) {
|
|
363
642
|
if (!GROUP_ID.test(groupId)) return null;
|
|
364
643
|
try {
|
|
@@ -371,12 +650,14 @@ export function loadGroup(groupId) {
|
|
|
371
650
|
// A fan-out is recorded as its own record so a later reader can tell which
|
|
372
651
|
// answers were asked the same question at the same time; each member job still
|
|
373
652
|
// writes its own round file under its own chain.
|
|
653
|
+
/** @param {Group} group */
|
|
374
654
|
export function persistGroup(group) {
|
|
375
655
|
const dir = path.join(POLICY.home, 'history', 'groups');
|
|
376
656
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
377
657
|
fs.writeFileSync(path.join(dir, `${group.group_id}.json`), JSON.stringify(group, null, 2), { mode: 0o600 });
|
|
378
658
|
}
|
|
379
659
|
|
|
660
|
+
/** @param {string} jobId */
|
|
380
661
|
export function cleanupJobDir(jobId) {
|
|
381
662
|
for (const dir of [jobDir(jobId), sandboxHome(jobId)]) {
|
|
382
663
|
try {
|