@codedology/code-forge 0.8.0 → 0.10.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/CHANGELOG.md +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B72 (issues #12, #15): `block reopen <id> --file <path> --run <r> --reason <text>` — the human
|
|
3
|
+
* restarts the review of a file the fix loop STOPPED (any reason: `review_cap`,
|
|
4
|
+
* `l3_patch_exhausted`, `split_required`, …) or whose state is LOST (B68). Only a stopped file
|
|
5
|
+
* needs content that differs from what its stop reviewed; a lost file restarts on its current
|
|
6
|
+
* content.
|
|
7
|
+
*
|
|
8
|
+
* Human-only like `block waive`: the verb is on `CODER_ONLY_FORBIDDEN` (`src/util/forbidden.mjs`)
|
|
9
|
+
* and its row is signed with the run key, `by: human` (`block waive` has no other in-verb guard,
|
|
10
|
+
* so neither has this). The verb (`reopenFile`) refuses, writing no row, with a stable code:
|
|
11
|
+
* - `usage` (exit 2) — no `--file`, no `--reason`, or a path with a control character or that is
|
|
12
|
+
* not a normalised repo-root-relative path (`checkFileForm`, shared with `block waive`);
|
|
13
|
+
* - `unknown_block` — the run does not know the block (the code `block waive` uses);
|
|
14
|
+
* - `reopen-block-not-open` — the block is not open (stopped or closed);
|
|
15
|
+
* - `reopen-not-owned` — the block does not own the file;
|
|
16
|
+
* - `reopen-tampered` — the file's state does not verify (an integrity failure stays a security
|
|
17
|
+
* event; the owner's exit is `block close --no-require-reviews`);
|
|
18
|
+
* - `reopen-not-stopped` — no state, or a state that is open or complete;
|
|
19
|
+
* - `reopen-same-content` — a stopped file whose current content is the content its stop
|
|
20
|
+
* reviewed (no free re-roll: the human waives the findings instead), or a stop whose content
|
|
21
|
+
* is unknown;
|
|
22
|
+
* - `reopen-already-recorded` — a verified human row already names the file's latest anchor seq
|
|
23
|
+
* for other content (run `review-file`, or the content changed since that reopen);
|
|
24
|
+
* - `reopen-write-failed` — the signed row was not written.
|
|
25
|
+
* Accepted, it first drops the done marker of the current content's ticket (C11, `io.clearDone`),
|
|
26
|
+
* then writes ONE signed `review.reopened {block, file, from: stopped|lost, stop_reason, at_seq,
|
|
27
|
+
* from_hash, to_hash, reason, by: human}` row (`at_seq` = the file's latest anchor seq). There is
|
|
28
|
+
* at most one verified human row per `at_seq`: a retry on the same content finds it and writes
|
|
29
|
+
* none.
|
|
30
|
+
*
|
|
31
|
+
* The content a stop reviewed (`stopContentHash`) is the stopped state's `stop_content_hash`,
|
|
32
|
+
* which the worker sets on every stop (`src/worker/engine.mjs`) and which is signed through the
|
|
33
|
+
* anchor. A state from before B72 has none: then the latest verified `review.cap` row of THIS
|
|
34
|
+
* stop counts — written after the file's latest verified `review.reopened` row, with the state's
|
|
35
|
+
* round and stop reason — else the content is unknown and the reopen is refused.
|
|
36
|
+
*
|
|
37
|
+
* The worker (`fixLoopRound`) asks `reopenRowFor`: a verified human row of this run for the block
|
|
38
|
+
* and file whose `at_seq` is the file's latest anchor seq starts a new state (round 1, a FULL
|
|
39
|
+
* review, `reviewed_content` null, the level from the run record, `l3_rung_used` kept). The new
|
|
40
|
+
* state saves at `at_seq + 1`, so a row works once. For `stopped` the content must still differ
|
|
41
|
+
* from the stop's content; else the stop repeats.
|
|
42
|
+
*
|
|
43
|
+
* This file imports nothing from `src/worker/`: the fix-loop state is read through `io`.
|
|
44
|
+
*/
|
|
45
|
+
import { StateError } from '../state/paths.mjs';
|
|
46
|
+
import { verifyRow } from '../state/signer.mjs';
|
|
47
|
+
import { checkFileForm } from './verb-forms.mjs';
|
|
48
|
+
|
|
49
|
+
export const REOPENED_EVENT = 'review.reopened';
|
|
50
|
+
|
|
51
|
+
/** A code a refusal may name: an errno (`EACCES`), a StateError code (`ledger-locked`), a name. */
|
|
52
|
+
const CAUSE_CODE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* B79: the code a failed write names — the error's `code`, else its `name`, when it has the form
|
|
56
|
+
* {@link CAUSE_CODE}, else `unknown` (a thrown `undefined` or `null` too). Called only for a
|
|
57
|
+
* write that threw; one that wrote nothing is named `no-write` by its caller. Never the message:
|
|
58
|
+
* it may hold a path or a value.
|
|
59
|
+
* @param {unknown} err @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function causeCode(err) {
|
|
62
|
+
if (err === null || err === undefined) return 'unknown';
|
|
63
|
+
const e = /** @type {{code?: unknown, name?: unknown}} */ (err);
|
|
64
|
+
if (typeof e.code === 'string' && CAUSE_CODE.test(e.code)) return e.code;
|
|
65
|
+
if (typeof e.name === 'string' && CAUSE_CODE.test(e.name)) return e.name;
|
|
66
|
+
return 'unknown';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether a row's MAC verifies with the run key (a throw is a no).
|
|
71
|
+
* @param {Record<string, any>} row @param {Buffer} key
|
|
72
|
+
*/
|
|
73
|
+
function verified(row, key) {
|
|
74
|
+
try {
|
|
75
|
+
return verifyRow(row, key).ok === true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The content hash the stop of a stopped state reviewed, or null when it is unknown: the state's
|
|
83
|
+
* `stop_content_hash`; for a state from before B72, the latest verified `review.cap` row of this
|
|
84
|
+
* run for the block and file that belongs to this stop (after the file's latest verified
|
|
85
|
+
* `review.reopened` row, with the state's round and stop reason).
|
|
86
|
+
* @param {Record<string, any> | null} state
|
|
87
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
88
|
+
* @param {{runId: string, block: string, file: string, key: Buffer}} at
|
|
89
|
+
* @returns {string | null}
|
|
90
|
+
*/
|
|
91
|
+
export function stopContentHash(state, rows, { runId, block, file, key }) {
|
|
92
|
+
if (state?.status !== 'stopped') return null;
|
|
93
|
+
if (typeof state.stop_content_hash === 'string' && state.stop_content_hash.length > 0) return state.stop_content_hash;
|
|
94
|
+
const mine = (/** @type {Record<string, any>} */ r) => r?.run === runId && r.block === block && r.file === file && verified(r, key);
|
|
95
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
96
|
+
const r = rows[i];
|
|
97
|
+
if (r?.event === REOPENED_EVENT && mine(r)) return null;
|
|
98
|
+
if (r?.event !== 'review.cap' || !mine(r)) continue;
|
|
99
|
+
if (typeof r.content_hash !== 'string' || r.round !== state.round || r.reason !== state.next?.reason) return null;
|
|
100
|
+
return r.content_hash;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The block checks every reopen runs first, before any config, key or ledger read: the run must
|
|
107
|
+
* know the block (`unknown_block`, the code `block waive` uses) and the block must be open.
|
|
108
|
+
* @param {Record<string, any>} record @param {string} runId @param {string} id
|
|
109
|
+
* @returns {Record<string, any>} the block's entry.
|
|
110
|
+
* @throws {StateError} `unknown_block` or `reopen-block-not-open`.
|
|
111
|
+
*/
|
|
112
|
+
export function openBlockEntry(record, runId, id) {
|
|
113
|
+
const entry = record.blocks?.[id];
|
|
114
|
+
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
115
|
+
if (entry.status !== 'open') throw new StateError('reopen-block-not-open', `reopen-block-not-open: block ${id} is not open in run ${runId}; nothing was recorded`);
|
|
116
|
+
return entry;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The latest verified human `review.reopened` row of this run for the block and file whose
|
|
121
|
+
* `at_seq` is `seq`, or null.
|
|
122
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
123
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, seq: number}} at
|
|
124
|
+
* @returns {Record<string, any> | null}
|
|
125
|
+
*/
|
|
126
|
+
function latestRowAt(rows, { runId, block, file, key, seq }) {
|
|
127
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
128
|
+
const r = rows[i];
|
|
129
|
+
if (r?.event !== REOPENED_EVENT || r.run !== runId || r.block !== block || r.file !== file) continue;
|
|
130
|
+
if (r.by !== 'human' || r.at_seq !== seq || !verified(r, key)) continue;
|
|
131
|
+
return r;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Worker side: the `review.reopened` row that restarts this file now, or null. Only a row whose MAC
|
|
138
|
+
* verifies with the run key, of this run, block and file, `by: human`, and whose `at_seq` is the
|
|
139
|
+
* file's latest anchor seq counts (the latest such row). A `lost` state needs `from: lost`; a
|
|
140
|
+
* stopped state needs `from: stopped` AND current content that is neither the row's `from_hash`
|
|
141
|
+
* nor the content the stop reviewed (`stopContentHash`; unknown ⇒ no restart).
|
|
142
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
143
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, status: string, state: Record<string, any> | null, seq: number, contentHash: string}} at
|
|
144
|
+
* @returns {Record<string, any> | null}
|
|
145
|
+
*/
|
|
146
|
+
export function reopenRowFor(rows, { runId, block, file, key, status, state, seq, contentHash }) {
|
|
147
|
+
const row = latestRowAt(rows, { runId, block, file, key, seq });
|
|
148
|
+
if (row === null) return null;
|
|
149
|
+
if (status === 'lost') return row.from === 'lost' ? row : null;
|
|
150
|
+
if ((status === 'ok' || status === 'restored') && state?.status === 'stopped' && row.from === 'stopped') {
|
|
151
|
+
const stopHash = stopContentHash(state, rows, { runId, block, file, key });
|
|
152
|
+
if (stopHash === null || contentHash === stopHash || contentHash === row.from_hash) return null;
|
|
153
|
+
return row;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The verb (`block reopen`): check, drop the done marker (`io.clearDone`), then write the signed
|
|
160
|
+
* row — or return the verified human row an earlier try wrote for the same `at_seq` and `to_hash`
|
|
161
|
+
* (at most one row per `at_seq`: one for other content is refused `reopen-already-recorded`).
|
|
162
|
+
* @param {{
|
|
163
|
+
* runId: string, id: string, file: unknown, reason: unknown,
|
|
164
|
+
* record: Record<string, any>, rows: ReadonlyArray<Record<string, any>>, key: Buffer,
|
|
165
|
+
* writeRow: (row: Record<string, any>) => Promise<unknown>,
|
|
166
|
+
* writeSigned: (runId: string, writeRow: (row: Record<string, any>) => Promise<unknown>, row: Record<string, any>) => Promise<unknown>,
|
|
167
|
+
* io: {
|
|
168
|
+
* owns: (owned: ReadonlyArray<string>, file: string, kinds?: Readonly<Record<string, string>>) => boolean,
|
|
169
|
+
* loadState: (where: Record<string, any>) => {status: string, state: Record<string, any> | null, seq: number},
|
|
170
|
+
* contentHash: (file: string) => string,
|
|
171
|
+
* clearDone: (file: string) => boolean,
|
|
172
|
+
* },
|
|
173
|
+
* }} opts
|
|
174
|
+
* @returns {Promise<{row: Record<string, any>, cleared: boolean, existing: boolean}>}
|
|
175
|
+
* @throws {StateError} `usage` or a refusal code (see the module doc).
|
|
176
|
+
*/
|
|
177
|
+
export async function reopenFile({ runId, id, file, reason, record, rows, key, writeRow, writeSigned, io }) {
|
|
178
|
+
if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block reopen needs --file <path>');
|
|
179
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block reopen needs --reason');
|
|
180
|
+
checkFileForm(file, { normalized: true });
|
|
181
|
+
const entry = openBlockEntry(record, runId, id);
|
|
182
|
+
const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
|
|
183
|
+
if (!io.owns(owned, file, entry.owned_kinds)) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|
|
184
|
+
|
|
185
|
+
const runRows = rows.filter((r) => r?.run === runId);
|
|
186
|
+
const loaded = io.loadState({ runId, block: id, file, key, rows: runRows });
|
|
187
|
+
if (loaded.status === 'tampered') {
|
|
188
|
+
throw new StateError('reopen-tampered', `reopen-tampered: the review state of ${file} does not verify (a security event, not a stop); the owner's way out is block close --no-require-reviews; nothing was recorded`);
|
|
189
|
+
}
|
|
190
|
+
const current = io.contentHash(file);
|
|
191
|
+
/** @type {'stopped' | 'lost'} */
|
|
192
|
+
let from;
|
|
193
|
+
/** @type {string | null} */
|
|
194
|
+
let stopReason = null;
|
|
195
|
+
/** @type {string | null} */
|
|
196
|
+
let fromHash = null;
|
|
197
|
+
if (loaded.status === 'lost') {
|
|
198
|
+
from = 'lost';
|
|
199
|
+
} else if ((loaded.status === 'ok' || loaded.status === 'restored') && loaded.state?.status === 'stopped') {
|
|
200
|
+
from = 'stopped';
|
|
201
|
+
stopReason = typeof loaded.state.next?.reason === 'string' ? loaded.state.next.reason : 'stopped';
|
|
202
|
+
fromHash = stopContentHash(loaded.state, runRows, { runId, block: id, file, key });
|
|
203
|
+
if (fromHash === null) {
|
|
204
|
+
throw new StateError('reopen-same-content', `reopen-same-content: the content the stop of ${file} reviewed is unknown, so a change cannot be shown; waive its findings instead (block waive); nothing was recorded`);
|
|
205
|
+
}
|
|
206
|
+
if (fromHash === current) {
|
|
207
|
+
throw new StateError('reopen-same-content', `reopen-same-content: ${file} still has the content its stop (${stopReason}) reviewed; change the file first, or waive its findings (block waive); nothing was recorded`);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
const what = loaded.status === 'none' || loaded.state === null ? 'has no review state' : `is ${loaded.state.status}`;
|
|
211
|
+
throw new StateError('reopen-not-stopped', `reopen-not-stopped: ${file} ${what}; only a stopped or lost file is reopened; nothing was recorded`);
|
|
212
|
+
}
|
|
213
|
+
if (!Number.isInteger(loaded.seq) || loaded.seq < 0) {
|
|
214
|
+
throw new StateError('reopen-tampered', `reopen-tampered: the review state of ${file} has no valid anchor seq; nothing was recorded`);
|
|
215
|
+
}
|
|
216
|
+
// one verified human row per at_seq: the same content reuses it, other content is refused
|
|
217
|
+
const earlier = latestRowAt(runRows, { runId, block: id, file, key, seq: loaded.seq });
|
|
218
|
+
if (earlier !== null && earlier.to_hash !== current) {
|
|
219
|
+
throw new StateError('reopen-already-recorded', `reopen-already-recorded: ${file} was already reopened at state seq ${loaded.seq} for other content; run review-file, or the content changed since the reopen; nothing was recorded`);
|
|
220
|
+
}
|
|
221
|
+
// C11: the cached result of the current content's ticket would repeat the stop — dropped FIRST,
|
|
222
|
+
// so a failed row write leaves at worst a re-run that returns the same stop, never a row with a
|
|
223
|
+
// stale marker
|
|
224
|
+
const cleared = io.clearDone(file);
|
|
225
|
+
if (earlier !== null) return { row: earlier, cleared, existing: true };
|
|
226
|
+
const row = { event: REOPENED_EVENT, block: id, file, from, stop_reason: stopReason, at_seq: loaded.seq, from_hash: fromHash, to_hash: current, reason: reason.trim(), by: 'human' };
|
|
227
|
+
/** @type {Record<string, any> | null} */
|
|
228
|
+
let written = null;
|
|
229
|
+
let signedOut = null;
|
|
230
|
+
/** @type {unknown} */
|
|
231
|
+
let failure = null;
|
|
232
|
+
// a thrown `undefined` (or null) is still a throw: `threw`, not the value, says the write threw
|
|
233
|
+
let threw = false;
|
|
234
|
+
try {
|
|
235
|
+
signedOut = await writeSigned(runId, async (signed) => {
|
|
236
|
+
const result = await writeRow(signed);
|
|
237
|
+
// the ledger's appendRow resolves with the row it wrote; nothing (or false) is no write
|
|
238
|
+
if (result === null || result === undefined || result === false) return result;
|
|
239
|
+
written = signed;
|
|
240
|
+
return result;
|
|
241
|
+
}, row);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
written = null;
|
|
244
|
+
failure = err;
|
|
245
|
+
threw = true;
|
|
246
|
+
}
|
|
247
|
+
if (written === null || signedOut === null || signedOut === undefined || signedOut === false) {
|
|
248
|
+
// B79: the cause is kept (`cause`) and named by its code only — never a path, key or config value
|
|
249
|
+
const refused = new StateError('reopen-write-failed', `reopen-write-failed: the signed review.reopened row for ${file} was not written (${threw ? causeCode(failure) : 'no-write'}); run block reopen again`);
|
|
250
|
+
// StateError takes (code, message) only (src/state/paths.mjs is not this block's): `cause` is
|
|
251
|
+
// set here, non-enumerable like a native one, so a serializer never emits the cause's path
|
|
252
|
+
if (threw) Object.defineProperty(refused, 'cause', { value: failure, enumerable: false, configurable: true, writable: true });
|
|
253
|
+
throw refused;
|
|
254
|
+
}
|
|
255
|
+
return { row: written, cleared, existing: false };
|
|
256
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size-based review routing (block B90): `review.routes` sends a big file to a named model with
|
|
3
|
+
* its own session timeout and its own second level, with no `.code-forge.yml` edit and no reload.
|
|
4
|
+
*
|
|
5
|
+
* A rule is `{min_lines, level?, model, provider?, effort?, fallback?, session_timeout_s?, second?}`.
|
|
6
|
+
* `level` is the level it replaces (`L2` default, or `L3`, the judge). The worker picks the route
|
|
7
|
+
* once per ticket, before any session, from the line count of the file's content in the reviewed
|
|
8
|
+
* tree (the bytes it hashed). A deleted file has no content and is never routed. Among the rules
|
|
9
|
+
* of one level, the one with the largest `min_lines` that is at most the count wins.
|
|
10
|
+
*
|
|
11
|
+
* The routed ticket runs with a derived config: `levels.<level>` = the rule's model entry
|
|
12
|
+
* (`model`, `provider`, `effort`, `fallback`; with no `provider` the top-level one, like any level
|
|
13
|
+
* entry), `review.second_levels.<level>` = `second` when given — except an L2 rule while
|
|
14
|
+
* `review.multimodel` is on: the consensus second reviewer is `review.second_levels.L2`, so it is
|
|
15
|
+
* kept. The timeout is ONE value for every session of the ticket: the largest `session_timeout_s`
|
|
16
|
+
* among the matched rules that set one, else the unrouted timeout (`review.session_timeout_s`).
|
|
17
|
+
*
|
|
18
|
+
* No `review.routes` (or no rule that matches) leaves the config object itself untouched. Pure:
|
|
19
|
+
* no I/O; the worker (`src/worker/loop.mjs`) reads the bytes, writes the signed `review.route` row
|
|
20
|
+
* and pins the timeout. This module never imports `src/worker`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { resolveLevel } from '../config/known-ids.mjs';
|
|
24
|
+
|
|
25
|
+
/** The levels a rule may replace: the reviewers (L2) and the judge (L3). */
|
|
26
|
+
export const ROUTE_LEVELS = Object.freeze(['L2', 'L3']);
|
|
27
|
+
|
|
28
|
+
/** The level a rule replaces when it names none. */
|
|
29
|
+
export const DEFAULT_ROUTE_LEVEL = 'L2';
|
|
30
|
+
|
|
31
|
+
/** The keys of a rule that form the routed level entry (`$defs/level`). */
|
|
32
|
+
const ENTRY_KEYS = Object.freeze(['model', 'provider', 'effort', 'fallback']);
|
|
33
|
+
|
|
34
|
+
/** A route list that cannot be used: the ticket is `unavailable` with this code, never reviewed on another model. */
|
|
35
|
+
export class RouteError extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super('review.routes is not a list of valid rules');
|
|
38
|
+
this.code = 'route-invalid';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The number of lines of a file's content: its newline bytes, plus one for a last line with no
|
|
44
|
+
* newline. Counted on the bytes, never on decoded text. `null` (a deleted file) and an empty file
|
|
45
|
+
* have 0 lines.
|
|
46
|
+
* @param {Buffer | null} bytes @returns {number}
|
|
47
|
+
*/
|
|
48
|
+
export function countLines(bytes) {
|
|
49
|
+
if (bytes === null || bytes.length === 0) return 0;
|
|
50
|
+
let n = 0;
|
|
51
|
+
for (let at = bytes.indexOf(0x0a); at !== -1; at = bytes.indexOf(0x0a, at + 1)) n += 1;
|
|
52
|
+
return bytes[bytes.length - 1] === 0x0a ? n : n + 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {unknown} v @returns {v is Record<string, any>} */
|
|
56
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
57
|
+
|
|
58
|
+
/** @param {unknown} v @returns {boolean} */
|
|
59
|
+
const isPositiveInt = (v) => Number.isInteger(v) && /** @type {number} */ (v) >= 1;
|
|
60
|
+
|
|
61
|
+
/** @param {unknown} v @returns {boolean} a non-empty string. */
|
|
62
|
+
const isText = (v) => typeof v === 'string' && v.length > 0;
|
|
63
|
+
|
|
64
|
+
/** @param {unknown} v @returns {boolean} absent, or a string (the schema's `effort`/`provider` types). */
|
|
65
|
+
const optString = (v) => v === undefined || typeof v === 'string';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A fallback item (`$defs/level` fallback): an object with a model and a provider, and an
|
|
69
|
+
* `effort` string when present.
|
|
70
|
+
* @param {unknown} v @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
const isFallbackItem = (v) => isObject(v) && isText(v.model) && isText(v.provider) && optString(v.effort);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A `$defs/level`-shaped entry: a model string, `provider` and `effort` strings when present, and
|
|
76
|
+
* `fallback` (when present) a list of fallback items. Used for a rule and for its `second`.
|
|
77
|
+
* @param {unknown} v @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
function isEntry(v) {
|
|
80
|
+
if (!isObject(v) || !isText(v.model) || !optString(v.provider) || !optString(v.effort)) return false;
|
|
81
|
+
return v.fallback === undefined || (Array.isArray(v.fallback) && v.fallback.every(isFallbackItem));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Is `rule` usable? The schema and config rule 18 refuse everything else at `run start` and
|
|
86
|
+
* `run reload`; this guards a snapshot that never went through them.
|
|
87
|
+
* @param {unknown} rule @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
function usable(rule) {
|
|
90
|
+
if (!isEntry(rule)) return false;
|
|
91
|
+
const r = /** @type {Record<string, any>} */ (rule);
|
|
92
|
+
if (!Number.isInteger(r.min_lines) || r.min_lines < 0) return false;
|
|
93
|
+
if (r.level !== undefined && !ROUTE_LEVELS.includes(r.level)) return false;
|
|
94
|
+
if (r.session_timeout_s !== undefined && !isPositiveInt(r.session_timeout_s)) return false;
|
|
95
|
+
if (r.second !== undefined && !isEntry(r.second)) return false;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @param {Record<string, any>} rule @returns {Record<string, any>} the level entry a rule routes to. */
|
|
100
|
+
function entryOf(rule) {
|
|
101
|
+
/** @type {Record<string, any>} */
|
|
102
|
+
const entry = {};
|
|
103
|
+
for (const key of ENTRY_KEYS) if (rule[key] !== undefined) entry[key] = rule[key];
|
|
104
|
+
return entry;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The provider a rule's routed level runs on — the one place it is decided, shared with config
|
|
109
|
+
* validation (rules 16 and 18) so the two cannot drift: the rule's entry REPLACES
|
|
110
|
+
* `levels.<level>`, so a rule with no `provider` inherits the top-level `provider` (never the
|
|
111
|
+
* replaced level's own `provider`), resolved exactly as the spawner resolves any level.
|
|
112
|
+
* @param {Record<string, any> | null | undefined} cfg @param {Record<string, any>} rule
|
|
113
|
+
* @returns {string | undefined} undefined when the routed level cannot be resolved.
|
|
114
|
+
*/
|
|
115
|
+
export function ruleProvider(cfg, rule) {
|
|
116
|
+
const level = rule?.level ?? DEFAULT_ROUTE_LEVEL;
|
|
117
|
+
const levels = { ...(isObject(cfg?.levels) ? cfg?.levels : {}), [level]: isObject(rule) ? entryOf(rule) : {} };
|
|
118
|
+
try {
|
|
119
|
+
return resolveLevel({ ...(cfg ?? {}), levels }, /** @type {any} */ (level)).provider;
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The rules of `cfg.review.routes`: `[]` when the key is absent or null.
|
|
127
|
+
* @param {Record<string, any> | null | undefined} cfg
|
|
128
|
+
* @returns {Array<Record<string, any>>}
|
|
129
|
+
* @throws {RouteError} the key is set but is not a list of usable rules.
|
|
130
|
+
*/
|
|
131
|
+
export function routeRules(cfg) {
|
|
132
|
+
const routes = cfg?.review?.routes;
|
|
133
|
+
if (routes === undefined || routes === null) return [];
|
|
134
|
+
if (!Array.isArray(routes) || !routes.every(usable)) throw new RouteError();
|
|
135
|
+
// config rule 18 refuses the same level and min_lines twice; a snapshot that skipped it fails
|
|
136
|
+
// closed here rather than letting list order pick the rule
|
|
137
|
+
const seen = new Set(routes.map((r) => `${r.level ?? DEFAULT_ROUTE_LEVEL}\0${r.min_lines}`));
|
|
138
|
+
if (seen.size !== routes.length) throw new RouteError();
|
|
139
|
+
return routes;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The winning rule per level for a file of `lines` lines: among the rules of a level, the largest
|
|
144
|
+
* `min_lines` that is at most `lines`. A deleted file (`deleted: true`) is never routed.
|
|
145
|
+
* @param {Record<string, any> | null | undefined} cfg @param {number} lines - a non-negative integer.
|
|
146
|
+
* @param {{deleted?: boolean}} [opts]
|
|
147
|
+
* @returns {Array<{level: string, rule: Record<string, any>}>} in `ROUTE_LEVELS` order; `[]` ⇒ no route.
|
|
148
|
+
* @throws {RouteError}
|
|
149
|
+
*/
|
|
150
|
+
export function pickRoutes(cfg, lines, { deleted = false } = {}) {
|
|
151
|
+
const rules = routeRules(cfg);
|
|
152
|
+
// a count that is not a line count never picks (or skips) a route: fail closed
|
|
153
|
+
if (!Number.isInteger(lines) || lines < 0) throw new RouteError();
|
|
154
|
+
if (deleted || rules.length === 0) return [];
|
|
155
|
+
/** @type {Array<{level: string, rule: Record<string, any>}>} */
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const level of ROUTE_LEVELS) {
|
|
158
|
+
let best = null;
|
|
159
|
+
for (const rule of rules) {
|
|
160
|
+
if ((rule.level ?? DEFAULT_ROUTE_LEVEL) !== level || rule.min_lines > lines) continue;
|
|
161
|
+
if (best === null || rule.min_lines > best.min_lines) best = rule;
|
|
162
|
+
}
|
|
163
|
+
if (best !== null) out.push({ level, rule: best });
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @typedef {object} RoutedTicket
|
|
170
|
+
* @property {Record<string, any>} cfg - the config every session of the ticket runs with; the SAME
|
|
171
|
+
* object as the input when nothing is routed.
|
|
172
|
+
* @property {number} timeoutS - the session timeout of every session of the ticket.
|
|
173
|
+
* @property {Array<Record<string, any>>} rows - one `review.route` row body per routed level
|
|
174
|
+
* (`{event, level, min_lines, lines, provider, model, session_timeout_s, second_model}`), unsigned.
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The ticket's routed config, timeout and `review.route` rows.
|
|
179
|
+
* @param {Record<string, any>} cfg - the ticket's config snapshot.
|
|
180
|
+
* @param {{lines: number, deleted?: boolean, timeoutS: number}} file - `timeoutS`: the unrouted timeout.
|
|
181
|
+
* @returns {RoutedTicket}
|
|
182
|
+
* @throws {RouteError}
|
|
183
|
+
*/
|
|
184
|
+
export function routeTicket(cfg, { lines, deleted = false, timeoutS }) {
|
|
185
|
+
const picked = pickRoutes(cfg, lines, { deleted });
|
|
186
|
+
if (picked.length === 0) return { cfg, timeoutS, rows: [] };
|
|
187
|
+
const given = picked.map(({ rule }) => rule.session_timeout_s).filter((t) => t !== undefined);
|
|
188
|
+
const routedTimeout = given.length > 0 ? Math.max(...given) : timeoutS;
|
|
189
|
+
const levels = { ...(isObject(cfg.levels) ? cfg.levels : {}) };
|
|
190
|
+
const review = { ...(isObject(cfg.review) ? cfg.review : {}) };
|
|
191
|
+
/** @type {Record<string, any>} */
|
|
192
|
+
const seconds = { ...(isObject(review.second_levels) ? review.second_levels : {}) };
|
|
193
|
+
let secondsChanged = false;
|
|
194
|
+
/** @type {Array<{level: string, rule: Record<string, any>, second: boolean}>} */
|
|
195
|
+
const applied = [];
|
|
196
|
+
for (const { level, rule } of picked) {
|
|
197
|
+
levels[level] = entryOf(rule);
|
|
198
|
+
// the consensus second reviewer is `second_levels.L2`: an L2 rule never replaces it then
|
|
199
|
+
const second = rule.second !== undefined && !(level === 'L2' && review.multimodel === true);
|
|
200
|
+
if (second) {
|
|
201
|
+
seconds[level] = rule.second;
|
|
202
|
+
secondsChanged = true;
|
|
203
|
+
}
|
|
204
|
+
applied.push({ level, rule, second });
|
|
205
|
+
}
|
|
206
|
+
if (secondsChanged) review.second_levels = seconds;
|
|
207
|
+
const routed = { ...cfg, levels, review };
|
|
208
|
+
const rows = applied.map(({ level, rule, second }) => ({
|
|
209
|
+
event: 'review.route',
|
|
210
|
+
level,
|
|
211
|
+
min_lines: rule.min_lines,
|
|
212
|
+
lines,
|
|
213
|
+
provider: providerOf(cfg, rule),
|
|
214
|
+
model: rule.model,
|
|
215
|
+
session_timeout_s: routedTimeout,
|
|
216
|
+
second_model: second ? rule.second.model : null,
|
|
217
|
+
}));
|
|
218
|
+
return { cfg: routed, timeoutS: routedTimeout, rows };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The provider the routed level resolves to — the one its sessions spawn on (`ruleProvider`).
|
|
223
|
+
* @param {Record<string, any>} cfg - the unrouted snapshot. @param {Record<string, any>} rule
|
|
224
|
+
* @returns {string}
|
|
225
|
+
* @throws {RouteError} the routed level cannot be resolved (e.g. no provider anywhere): the ticket
|
|
226
|
+
* is `route-invalid`, never a row with no provider.
|
|
227
|
+
*/
|
|
228
|
+
function providerOf(cfg, rule) {
|
|
229
|
+
const provider = ruleProvider(cfg, rule);
|
|
230
|
+
if (provider === undefined) throw new RouteError();
|
|
231
|
+
return provider;
|
|
232
|
+
}
|