@codedology/code-forge 0.8.0 → 0.9.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 +56 -0
- package/README.md +18 -0
- package/docs/reference/blocks.json +234 -1
- package/package.json +1 -1
- package/skill/references/code.md +15 -6
- package/skill/references/ledger.md +1 -1
- package/skill/references/review.md +21 -3
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +2 -2
- package/src/cli/block.mjs +137 -10
- package/src/cli/review-file.mjs +264 -13
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +89 -2
- package/src/ledger/write.mjs +7 -1
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +375 -78
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +3 -1
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
package/src/doctor/index.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge doctor` (plan §2.3; block B13b): every row, in print order. `--quick` = the three
|
|
3
3
|
* rows the skill's preflight needs (config, harness links, Jev key resolution); the full run adds
|
|
4
|
-
* the PATH/Solo/gate rows, the live Jev call, ledger, `tmp`,
|
|
5
|
-
* version, flags, one call per role builder, isolation, path deny, Codex
|
|
6
|
-
* start/stop, the signer key and the `signer: same-user boundary only` line.
|
|
4
|
+
* the PATH/Solo/gate rows, the live Jev call, ledger, `tmp`, `review state` (B69), the provider
|
|
5
|
+
* CLI probes (presence, version, flags, one call per role builder, isolation, path deny, Codex
|
|
6
|
+
* rules), the worker start/stop, the signer key and the `signer: same-user boundary only` line.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { randomBytes } from 'node:crypto';
|
|
@@ -13,7 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
14
14
|
import { newRunId } from '../state/paths.mjs';
|
|
15
15
|
import { runRoot } from '../util/tmp.mjs';
|
|
16
|
-
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkTmp, checkTools } from './local.mjs';
|
|
16
|
+
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
|
|
17
17
|
import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
|
|
18
18
|
import { row } from './rows.mjs';
|
|
19
19
|
import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
|
|
@@ -68,6 +68,7 @@ export async function runDoctor(opts = {}, deps = {}) {
|
|
|
68
68
|
rows.push(...(await checkTools(toolOpts)));
|
|
69
69
|
rows.push(...(await checkLedger()));
|
|
70
70
|
rows.push(...checkTmp(cfg));
|
|
71
|
+
rows.push(...(await checkReviewState(cfg)));
|
|
71
72
|
rows.push(checkReviewConcurrency(cfg));
|
|
72
73
|
if (!cfg) {
|
|
73
74
|
rows.push(row('probes', 'FAIL', 'probes', 'skipped: no valid config'));
|
package/src/doctor/local.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Doctor rows that need no provider CLI (plan §2.3; block B13b): config, harness links, Jev key
|
|
3
3
|
* resolution (the three `--quick` rows), `code-forge` on PATH, Solo, gate commands, the live Jev
|
|
4
|
-
* call, ledger writability and the `
|
|
4
|
+
* call, ledger writability, the `tmp` row and (B69) the `review state` row. Every check returns
|
|
5
|
+
* rows; none throws.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { constants, readdirSync } from 'node:fs';
|
|
@@ -17,10 +18,16 @@ import { missingTools } from '../install/tools.mjs';
|
|
|
17
18
|
import { OP_MESSAGES } from '../keys/onepassword.mjs';
|
|
18
19
|
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
19
20
|
import { ledgerDir } from '../ledger/paths.mjs';
|
|
21
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
20
22
|
import { DEFAULT_PROVIDER_CONCURRENCY, providerLimit } from '../session/spawn.mjs';
|
|
23
|
+
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
24
|
+
import { assertRunId, runsDir } from '../state/paths.mjs';
|
|
25
|
+
import { readRun } from '../state/run.mjs';
|
|
26
|
+
import { loadKey } from '../state/signer.mjs';
|
|
21
27
|
import { listEntries } from '../util/reaper.mjs';
|
|
22
|
-
import { ownerState, tmpBase } from '../util/tmp.mjs';
|
|
28
|
+
import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
|
|
23
29
|
import { parallelTickets } from '../worker/loop.mjs';
|
|
30
|
+
import { missingStateFiles } from '../worker/review-state.mjs';
|
|
24
31
|
import { row } from './rows.mjs';
|
|
25
32
|
|
|
26
33
|
/** @typedef {import('./rows.mjs').Row} Row */
|
|
@@ -192,6 +199,86 @@ export function checkTmp(cfg) {
|
|
|
192
199
|
return [row('tmp', roots + pids > 0 ? 'WARN' : 'OK', 'tmp', roots + pids > 0 ? `${detail} (the next code-forge run start removes them)` : detail)];
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
/** B69: the most missing state files the `review state` row names. */
|
|
203
|
+
export const LISTED_MAX = 10;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* B69: one WARN row per ACTIVE run (its record's `status` is `active`, as `run end` and the worker
|
|
207
|
+
* decide it) whose ledger has `review.state` anchors whose latest state file is missing — neither
|
|
208
|
+
* at `<code-forge home>/runs/<run>/review-state/` nor at the old temp-root path. The row counts the
|
|
209
|
+
* files the ledger can rebuild (the latest anchor carries `state_body`) and the lost ones (no body:
|
|
210
|
+
* the next ticket is refused `review-state-lost`; the owner needs `code-forge block reopen`), and
|
|
211
|
+
* names the first {@link LISTED_MAX} blocks and files (then `… and K more`). No row when nothing
|
|
212
|
+
* is missing. When the old temp location cannot be named (`tmp.root` not absolute), nothing is
|
|
213
|
+
* counted and the row says so; when an anchor of the run does not verify, the row says the review
|
|
214
|
+
* state was not checked. Each project's ledger is read once. Never throws: a run whose record, key
|
|
215
|
+
* or ledger cannot be read is skipped. No file content is read or printed.
|
|
216
|
+
* @param {Record<string, any> | null} cfg
|
|
217
|
+
* @returns {Promise<Row[]>}
|
|
218
|
+
*/
|
|
219
|
+
export async function checkReviewState(cfg) {
|
|
220
|
+
let names = [];
|
|
221
|
+
try {
|
|
222
|
+
names = readdirSync(runsDir()).filter((n) => n.endsWith('.json'));
|
|
223
|
+
} catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
/** @type {Row[]} */
|
|
227
|
+
const rows = [];
|
|
228
|
+
/** @type {Map<string, Array<Record<string, any>>>} the ledger rows per project, read once */
|
|
229
|
+
const ledgers = new Map();
|
|
230
|
+
for (const name of names.sort()) {
|
|
231
|
+
const runId = name.slice(0, -'.json'.length);
|
|
232
|
+
try {
|
|
233
|
+
assertRunId(runId);
|
|
234
|
+
const record = await readRun(runId);
|
|
235
|
+
if (record?.status !== 'active' || typeof record.project !== 'string') continue;
|
|
236
|
+
const key = await loadKey(runId);
|
|
237
|
+
let ledger = ledgers.get(record.project);
|
|
238
|
+
if (ledger === undefined) {
|
|
239
|
+
ledger = await readAllRows(record.project);
|
|
240
|
+
ledgers.set(record.project, ledger);
|
|
241
|
+
}
|
|
242
|
+
const atNew = missingStateFiles({ runId, key, rows: ledger });
|
|
243
|
+
if (atNew === null) {
|
|
244
|
+
rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: review.state anchors do not verify; review state not checked`));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
let root;
|
|
248
|
+
try {
|
|
249
|
+
root = snapshotFor(record, '')?.config?.tmp?.root;
|
|
250
|
+
} catch {
|
|
251
|
+
root = undefined;
|
|
252
|
+
}
|
|
253
|
+
if (typeof root !== 'string') root = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
|
|
254
|
+
let runRootDir;
|
|
255
|
+
try {
|
|
256
|
+
runRootDir = runRootPath(runId, root === undefined ? {} : { root });
|
|
257
|
+
} catch {
|
|
258
|
+
runRootDir = undefined;
|
|
259
|
+
}
|
|
260
|
+
if (runRootDir === undefined) {
|
|
261
|
+
// the old place cannot be named: nothing is counted as missing, the row says why
|
|
262
|
+
if (atNew.length > 0) {
|
|
263
|
+
rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: the old temp location could not be checked (tmp.root is not an absolute path); review state files are not counted`));
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const missing = missingStateFiles({ runId, key, rows: ledger, runRootDir });
|
|
268
|
+
if (missing === null || missing.length === 0) continue;
|
|
269
|
+
const rebuildable = missing.filter((m) => m.rebuildable).length;
|
|
270
|
+
const lost = missing.length - rebuildable;
|
|
271
|
+
const shown = missing.slice(0, LISTED_MAX).map((m) => `${m.block} ${m.file} (${m.rebuildable ? 'rebuildable' : 'lost'})`).join(', ');
|
|
272
|
+
const more = missing.length > LISTED_MAX ? ` … and ${missing.length - LISTED_MAX} more` : '';
|
|
273
|
+
rows.push(row('review-state', 'WARN', 'review state',
|
|
274
|
+
`run ${runId}: ${missing.length} state file(s) missing — ${rebuildable} rebuildable from the ledger, ${lost} lost (needs code-forge block reopen): ${shown}${more}`));
|
|
275
|
+
} catch {
|
|
276
|
+
// an unreadable record, key or ledger: not this row's business
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return rows;
|
|
280
|
+
}
|
|
281
|
+
|
|
195
282
|
/**
|
|
196
283
|
* B44: the `review concurrency` INFO row — how many review tickets run at once and each provider's
|
|
197
284
|
* session slots, from the effective config (defaults when a key is unset). Never throws.
|
package/src/ledger/write.mjs
CHANGED
|
@@ -53,9 +53,14 @@ function normalizeRow(row) {
|
|
|
53
53
|
/**
|
|
54
54
|
* Claim `<base>.<n>.jsonl` for `n = 1, 2, ...` atomically via `link()` (hardlink the still-full
|
|
55
55
|
* file under the rotated name, then unlink the original) instead of `rename()`.
|
|
56
|
+
*
|
|
57
|
+
* `hooks.afterLink` is a test seam only: when given, it is awaited with the claimed candidate path
|
|
58
|
+
* right after a successful `link()` and before this call's `unlink()` of the source, so a test can
|
|
59
|
+
* force another racer's unlink into exactly that window. Production callers pass nothing.
|
|
56
60
|
* @param {string} file @param {string} dir @param {string} base
|
|
61
|
+
* @param {{ afterLink?: (candidate: string) => Promise<void> | void }} [hooks]
|
|
57
62
|
*/
|
|
58
|
-
export async function claimRotatedName(file, dir, base) {
|
|
63
|
+
export async function claimRotatedName(file, dir, base, hooks = {}) {
|
|
59
64
|
let n = 1;
|
|
60
65
|
for (;;) {
|
|
61
66
|
const candidate = path.join(dir, `${base}.${n}.jsonl`);
|
|
@@ -70,6 +75,7 @@ export async function claimRotatedName(file, dir, base) {
|
|
|
70
75
|
if (err.code === 'ENOENT') return; // `file` vanished before we could link it — a racer already rotated it out
|
|
71
76
|
throw err;
|
|
72
77
|
}
|
|
78
|
+
if (hooks.afterLink) await hooks.afterLink(candidate);
|
|
73
79
|
try {
|
|
74
80
|
await unlink(file);
|
|
75
81
|
return;
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B71 (issue #9 b): an answers ticket — `review-file <answering file> --block <id> --answers <ids>
|
|
3
|
+
* --of <source>` — asks ONE fresh L2 reviewer session whether the answering file (`by_file`)
|
|
4
|
+
* resolves the open findings `<ids>` of the source file. The worker runs it through the engine
|
|
5
|
+
* seam (`reviewTicket` dispatches here on `kind: 'answers'`); `loop.mjs` has already checked the
|
|
6
|
+
* ticket's run, its tree and that the source still has the ticket's hash.
|
|
7
|
+
*
|
|
8
|
+
* Checks here (the ones that count; `review-file` only pre-checks the same rules, unverified):
|
|
9
|
+
* - both files are owned by the block (`answers-not-owned`) and they are two files
|
|
10
|
+
* (`answers-same-file`);
|
|
11
|
+
* - each file is read ONCE: the answering file's bytes must hash to the ticket's `by_hash` (else
|
|
12
|
+
* `stale`, `answering-file-changed`; absent or unreadable: `answers-file-missing`) and the
|
|
13
|
+
* source's to its `content_hash` (else `stale`, `source-changed`); the packet is built from
|
|
14
|
+
* those same bytes, so the row's `by_hash` is the hash of what the reviewer saw;
|
|
15
|
+
* - every id is open on the source's CURRENT hash by the signed rows and not waived or answered
|
|
16
|
+
* yet — `answerableFindings`, the close's own rules (`answers-not-open`).
|
|
17
|
+
* A refusal runs no session. The packet holds each finding (file, lines, severity, claim, evidence,
|
|
18
|
+
* fix — from the source's signed fix-loop state; only id and severity when the state has none),
|
|
19
|
+
* the source lines around it, and the answering file's diff against the block base (its full
|
|
20
|
+
* content when it is new). The reviewer answers per id `resolved` or `not_resolved` with a reason;
|
|
21
|
+
* a missing, repeated or broken verdict is `not_resolved`, and a session that fails (no result,
|
|
22
|
+
* not `ok`, a non-zero exit, a thrown spawn error: `session-error`) makes the ticket `unavailable`
|
|
23
|
+
* with no row. A packet that cannot be built (B79: a secret-like source or answering file too) is
|
|
24
|
+
* `packet-unreadable`; the whole packet stays within the `full_in` budget, and one whose fixed text
|
|
25
|
+
* alone is over it is refused `packet-too-large`. One that cannot be written to the work dir is
|
|
26
|
+
* `packet-write-failed`. All three are refusals (`unavailable`: never approval, no session, no row). For each resolved id the
|
|
27
|
+
* worker writes one signed `review.answered {block, file: source, content_hash: source hash,
|
|
28
|
+
* finding, by_file, by_hash, ticket}` row (`ctx.writeRow` stamps the block, the source and its
|
|
29
|
+
* hash). The ticket's result is `status: 'answers'`, never an approval: the gate never reads it
|
|
30
|
+
* as a verdict on the source (`neverReviewed`), the answering file still needs its own approval,
|
|
31
|
+
* and the source's fix-loop state is not touched.
|
|
32
|
+
*
|
|
33
|
+
* The worker's file and state readers come in through `io` (the worker passes its own
|
|
34
|
+
* `readRegularFileNoFollow`, `loadState` and `assertAnswers`), so this review-side module imports
|
|
35
|
+
* nothing from `src/worker/`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
39
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import { ownsFile } from '../state/registry.mjs';
|
|
42
|
+
import { exec } from '../util/exec.mjs';
|
|
43
|
+
import { redact } from '../util/redact.mjs';
|
|
44
|
+
import { ANSWERED_EVENT, ANSWERS_STATUS, answerableFindings, ticketAnswered } from './gate-check.mjs';
|
|
45
|
+
import { fixHunkDiff } from './fixloop.mjs';
|
|
46
|
+
import { assertPacketPath, budgetFor, isSecretLike, plainGitEnv, reviewGitEnv } from './packet.mjs';
|
|
47
|
+
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
48
|
+
|
|
49
|
+
/** The answer the reviewer gives: one verdict per finding id. */
|
|
50
|
+
export const ANSWERS_SCHEMA = Object.freeze({
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
verdicts: {
|
|
54
|
+
type: 'array',
|
|
55
|
+
items: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
id: { type: 'string' },
|
|
59
|
+
verdict: { type: 'string', enum: ['resolved', 'not_resolved'] },
|
|
60
|
+
reason: { type: 'string' },
|
|
61
|
+
},
|
|
62
|
+
required: ['id', 'verdict', 'reason'],
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
required: ['verdicts'],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** The system prompt of the answers session. */
|
|
72
|
+
export const ANSWERS_LENS =
|
|
73
|
+
'You are a code reviewer for code-forge, judging whether a change in one file resolves findings a review raised about another file. ' +
|
|
74
|
+
'You have no tools and no files; everything you may use is in the message. Answer once, with a JSON object that matches the schema.';
|
|
75
|
+
|
|
76
|
+
/** Lines of the source shown before and after each finding. */
|
|
77
|
+
const AROUND = 5;
|
|
78
|
+
|
|
79
|
+
/** A reason longer than this is cut (the reviewer's free text). */
|
|
80
|
+
const REASON_MAX = 500;
|
|
81
|
+
|
|
82
|
+
/** B79: what a part cut at the packet budget ends with. */
|
|
83
|
+
export const TRUNCATED_MARKER = '[... truncated at the packet budget]';
|
|
84
|
+
|
|
85
|
+
/** B79: what a cut part shows when its cap is too small for {@link TRUNCATED_MARKER}. */
|
|
86
|
+
export const SHORT_MARKER = '[cut]';
|
|
87
|
+
|
|
88
|
+
/** B79: the packet's fixed text (headings, labels, fences) alone is over the budget. */
|
|
89
|
+
class PacketTooLarge extends Error {}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {object} AnswersOutcome
|
|
93
|
+
* @property {string} status - `answers`, `stale` or `unavailable`.
|
|
94
|
+
* @property {boolean} approved - always false.
|
|
95
|
+
* @property {string} engine
|
|
96
|
+
* @property {string} [reason]
|
|
97
|
+
* @property {string} kind - `answers`.
|
|
98
|
+
* @property {Array<{id: string, severity: unknown, verdict: 'resolved' | 'not_resolved', reason: string}>} [findings]
|
|
99
|
+
* @property {Array<Record<string, any>>} sessions
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The ticket fields an answers ticket carries (`worker/queue.mjs` `enqueueAnswers`).
|
|
104
|
+
* @typedef {object} AnswersTicket
|
|
105
|
+
* @property {string} ticket
|
|
106
|
+
* @property {string} block
|
|
107
|
+
* @property {string} file - the source.
|
|
108
|
+
* @property {string} content_hash - the source's hash.
|
|
109
|
+
* @property {string} [by_file]
|
|
110
|
+
* @property {string} [by_hash]
|
|
111
|
+
* @property {string[]} [answers]
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What the worker's review context gives an answers ticket.
|
|
116
|
+
* @typedef {object} AnswersContext
|
|
117
|
+
* @property {string} repoRoot
|
|
118
|
+
* @property {string | null} [contentRoot] - the block's tree, when it has one.
|
|
119
|
+
* @property {string} runId
|
|
120
|
+
* @property {string} [runRootDir]
|
|
121
|
+
* @property {Buffer | null} [key]
|
|
122
|
+
* @property {Parameters<typeof budgetFor>[0]} cfg
|
|
123
|
+
* @property {Parameters<typeof spawnWithTimeoutRetry>[0]} spawn
|
|
124
|
+
* @property {() => Promise<Array<Record<string, any>>>} [readRows]
|
|
125
|
+
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The worker's readers (injected, see the module doc).
|
|
130
|
+
* @typedef {object} AnswersIo
|
|
131
|
+
* @property {(root: string, file: string) => Buffer | null} readFile - no symlinks; null when absent.
|
|
132
|
+
* @property {(opts: Record<string, any>) => {status: string, state?: Record<string, any> | null}} loadState - the signed fix-loop state, read only.
|
|
133
|
+
* @property {(answers: unknown) => string[]} assertAnswers - the queue's id-list rule (sorted, unique); throws when the list is bad.
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string} status @param {string} reason @param {Array<Record<string, any>>} [sessions]
|
|
138
|
+
* @returns {AnswersOutcome}
|
|
139
|
+
*/
|
|
140
|
+
const outcome = (status, reason, sessions = []) => ({ status, reason, approved: false, engine: 'adaptive', kind: 'answers', sessions });
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Run one answers ticket.
|
|
144
|
+
* @param {AnswersTicket} ticket - `kind: 'answers'`.
|
|
145
|
+
* @param {AnswersContext} ctx - `contentRoot` is the block's tree.
|
|
146
|
+
* @param {{entry: {base: string | null, owned: string[], ownedKinds?: Record<string, string>}, workDir: string, io: AnswersIo}} opts
|
|
147
|
+
* @returns {Promise<AnswersOutcome>}
|
|
148
|
+
*/
|
|
149
|
+
export async function reviewAnswers(ticket, ctx, { entry, workDir, io }) {
|
|
150
|
+
const root = ctx.contentRoot ?? ctx.repoRoot;
|
|
151
|
+
const source = ticket.file;
|
|
152
|
+
const byFile = /** @type {string} */ (ticket.by_file);
|
|
153
|
+
// the id list, checked here too by the queue's own rule: 1 to 10 well-formed ids, none twice
|
|
154
|
+
/** @type {string[]} */
|
|
155
|
+
let ids;
|
|
156
|
+
try {
|
|
157
|
+
ids = io.assertAnswers(ticket.answers);
|
|
158
|
+
} catch {
|
|
159
|
+
return outcome(ANSWERS_STATUS, 'usage');
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(ticket.answers) || ids.length !== ticket.answers.length) return outcome(ANSWERS_STATUS, 'usage');
|
|
162
|
+
if (!ctx.key || !ctx.readRows || !ctx.writeRow) return outcome('unavailable', 'no-ledger');
|
|
163
|
+
if (typeof byFile !== 'string' || path.posix.normalize(byFile) === path.posix.normalize(source)) return outcome(ANSWERS_STATUS, 'answers-same-file');
|
|
164
|
+
if (!ownsFile(entry.owned, source, entry.ownedKinds) || !ownsFile(entry.owned, byFile, entry.ownedKinds)) return outcome(ANSWERS_STATUS, 'answers-not-owned');
|
|
165
|
+
// each file is read ONCE; the hashes and the packet come from these bytes
|
|
166
|
+
/** @type {Buffer | null} */
|
|
167
|
+
let byBytes;
|
|
168
|
+
/** @type {Buffer | null} */
|
|
169
|
+
let srcBytes;
|
|
170
|
+
try {
|
|
171
|
+
byBytes = io.readFile(root, byFile);
|
|
172
|
+
} catch {
|
|
173
|
+
byBytes = null;
|
|
174
|
+
}
|
|
175
|
+
if (byBytes === null) return outcome(ANSWERS_STATUS, 'answers-file-missing');
|
|
176
|
+
const byHash = hashOf(byBytes);
|
|
177
|
+
if (byHash !== ticket.by_hash) return outcome('stale', 'answering-file-changed');
|
|
178
|
+
try {
|
|
179
|
+
srcBytes = io.readFile(root, source);
|
|
180
|
+
} catch {
|
|
181
|
+
srcBytes = null;
|
|
182
|
+
}
|
|
183
|
+
if (srcBytes === null || hashOf(srcBytes) !== ticket.content_hash) return outcome('stale', 'source-changed');
|
|
184
|
+
|
|
185
|
+
let rows;
|
|
186
|
+
try {
|
|
187
|
+
rows = (await ctx.readRows()).filter((r) => r?.run === ctx.runId);
|
|
188
|
+
} catch {
|
|
189
|
+
return outcome('unavailable', 'ledger-unreadable');
|
|
190
|
+
}
|
|
191
|
+
const current = new Map([
|
|
192
|
+
[source, ticket.content_hash],
|
|
193
|
+
[byFile, byHash],
|
|
194
|
+
]);
|
|
195
|
+
// this ticket's own rows do not close an id: a retry after a partial write finishes the rest
|
|
196
|
+
const open = answerableFindings({ block: ticket.block, runId: ctx.runId, rows, key: ctx.key, file: source, hash: ticket.content_hash, current, ticket: ticket.ticket });
|
|
197
|
+
if (!ids.every((id) => open.has(id))) return outcome(ANSWERS_STATUS, 'answers-not-open');
|
|
198
|
+
|
|
199
|
+
let text;
|
|
200
|
+
try {
|
|
201
|
+
text = await packetText({ ticket, ctx, rows, root, entry, open, io, ids, srcBytes, byBytes, workDir });
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// B79: a packet whose fixed text alone is over the budget is refused like an unreadable one:
|
|
204
|
+
// never approval, no session, no row
|
|
205
|
+
if (err instanceof PacketTooLarge) return outcome('unavailable', 'packet-too-large');
|
|
206
|
+
return outcome('unavailable', 'packet-unreadable');
|
|
207
|
+
}
|
|
208
|
+
const promptPath = path.join(workDir, `answers-${randomBytes(6).toString('hex')}.md`);
|
|
209
|
+
try {
|
|
210
|
+
mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
211
|
+
writeFileSync(promptPath, text, { mode: 0o600 });
|
|
212
|
+
} catch {
|
|
213
|
+
return outcome('unavailable', 'packet-write-failed');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const note = async (/** @type {Record<string, any>} */ row) => {
|
|
217
|
+
try {
|
|
218
|
+
await ctx.writeRow?.(row);
|
|
219
|
+
} catch {
|
|
220
|
+
// a note is best-effort
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
let res;
|
|
224
|
+
try {
|
|
225
|
+
({ res } = await spawnWithTimeoutRetry(ctx.spawn, { level: 'L2', role: 'reviewer', promptPath, schema: ANSWERS_SCHEMA, systemPromptText: ANSWERS_LENS, rowExtra: { lens: 'answers' } }, note));
|
|
226
|
+
} catch {
|
|
227
|
+
// a spawn or adapter error is no verdict: retry the ticket
|
|
228
|
+
return outcome('unavailable', 'session-error');
|
|
229
|
+
} finally {
|
|
230
|
+
rmSync(promptPath, { force: true });
|
|
231
|
+
}
|
|
232
|
+
const sessions = [{ lens: 'answers', role: 'reviewer', level: 'L2', provider: res?.provider ?? null, model: res?.model ?? null, tokens_in: res?.usage?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? null }];
|
|
233
|
+
// a spawn that threw (`spawnWithTimeoutRetry` gives null) is `session-error`, like a throw above
|
|
234
|
+
if (!res) return outcome('unavailable', 'session-error', sessions);
|
|
235
|
+
if (res.status !== 'ok' || (res.exit_code !== undefined && res.exit_code !== 0)) return outcome('unavailable', typeof res.status === 'string' && res.status !== 'ok' ? res.status : 'exit', sessions);
|
|
236
|
+
|
|
237
|
+
const verdicts = verdictsOf(res.answer, ids);
|
|
238
|
+
/** @type {NonNullable<AnswersOutcome['findings']>} */
|
|
239
|
+
const findings = ids.map((id) => {
|
|
240
|
+
const v = verdicts.get(id) ?? { verdict: /** @type {const} */ ('not_resolved'), reason: 'no valid verdict' };
|
|
241
|
+
return { id, severity: open.get(id) ?? null, verdict: v.verdict, reason: v.reason };
|
|
242
|
+
});
|
|
243
|
+
// every verdict is built before any row is written; an id this ticket already answered (an
|
|
244
|
+
// earlier attempt whose later write failed) gets no second row, so a retry ends with one per id
|
|
245
|
+
const done = ticketAnswered({ block: ticket.block, runId: ctx.runId, rows, key: ctx.key, file: source, current, ticket: ticket.ticket });
|
|
246
|
+
for (const f of findings) {
|
|
247
|
+
if (f.verdict !== 'resolved' || done.has(f.id)) continue;
|
|
248
|
+
try {
|
|
249
|
+
await ctx.writeRow({ event: ANSWERED_EVENT, finding: f.id, by_file: byFile, by_hash: byHash, ticket: ticket.ticket });
|
|
250
|
+
} catch {
|
|
251
|
+
return outcome('unavailable', 'ledger-write-failed', sessions);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { status: ANSWERS_STATUS, approved: false, engine: 'adaptive', kind: 'answers', findings, sessions };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The reviewer's verdict per requested id: only `{id, verdict: 'resolved' | 'not_resolved',
|
|
259
|
+
* reason: string}` entries count; an id given twice, an unknown id or a broken entry gives no
|
|
260
|
+
* verdict for that id (the caller reads it as `not_resolved`).
|
|
261
|
+
* @param {unknown} answer @param {string[]} ids
|
|
262
|
+
* @returns {Map<string, {verdict: 'resolved' | 'not_resolved', reason: string}>}
|
|
263
|
+
*/
|
|
264
|
+
export function verdictsOf(answer, ids) {
|
|
265
|
+
/** @type {Map<string, {verdict: 'resolved' | 'not_resolved', reason: string}>} */
|
|
266
|
+
const out = new Map();
|
|
267
|
+
let value = answer;
|
|
268
|
+
if (typeof value === 'string') {
|
|
269
|
+
try {
|
|
270
|
+
value = JSON.parse(value);
|
|
271
|
+
} catch {
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const list = value && typeof value === 'object' && Array.isArray(/** @type {any} */ (value).verdicts) ? /** @type {unknown[]} */ (/** @type {any} */ (value).verdicts) : [];
|
|
276
|
+
/** @type {Set<string>} */
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
/** @type {Set<string>} */
|
|
279
|
+
const repeated = new Set();
|
|
280
|
+
for (const v of list) {
|
|
281
|
+
const e = /** @type {Record<string, unknown>} */ (v);
|
|
282
|
+
if (!e || typeof e !== 'object' || typeof e.id !== 'string' || !ids.includes(e.id)) continue;
|
|
283
|
+
if (seen.has(e.id)) repeated.add(e.id);
|
|
284
|
+
seen.add(e.id);
|
|
285
|
+
if ((e.verdict === 'resolved' || e.verdict === 'not_resolved') && typeof e.reason === 'string') out.set(e.id, { verdict: e.verdict, reason: e.reason.slice(0, REASON_MAX) });
|
|
286
|
+
}
|
|
287
|
+
for (const id of repeated) out.delete(id);
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The worker's content hash of bytes it read (`worker/ticket.mjs` `contentHash`: sha256, hex). */
|
|
292
|
+
const hashOf = (/** @type {Buffer} */ bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The answering file's change, built from the bytes that were hashed (never a second read): its
|
|
296
|
+
* diff against its text at the block base (`entry.base`, else HEAD), or its full content when the
|
|
297
|
+
* base has no such file. A secret-like path or a git failure throws (`packet-unreadable`).
|
|
298
|
+
* @param {{root: string, file: string, base: string | null, bytes: Buffer, workDir: string}} opts
|
|
299
|
+
* @returns {Promise<{kind: 'new' | 'tracked', text: string}>}
|
|
300
|
+
*/
|
|
301
|
+
async function answeringChange({ root, file, base, bytes, workDir }) {
|
|
302
|
+
const rel = assertPacketPath(root, file);
|
|
303
|
+
if (isSecretLike(rel)) throw new Error('secret-like path');
|
|
304
|
+
const ref = base ?? 'HEAD';
|
|
305
|
+
if (typeof ref !== 'string' || ref.length === 0 || ref.startsWith('-')) throw new Error('the base must be a commit id');
|
|
306
|
+
const git = { cwd: root, timeoutMs: 30000 };
|
|
307
|
+
const listed = await exec(['git', 'ls-tree', '-z', ref, '--', rel], { ...git, env: reviewGitEnv() });
|
|
308
|
+
if (listed.result !== 'ok') throw new Error(`git ls-tree failed (exit ${listed.code})`);
|
|
309
|
+
const current = bytes.toString('utf8');
|
|
310
|
+
if (listed.stdout.length === 0) return { kind: 'new', text: current };
|
|
311
|
+
const shown = await exec(['git', 'show', `${ref}:${rel}`], { ...git, env: plainGitEnv() });
|
|
312
|
+
if (shown.result !== 'ok') throw new Error(`git show failed (exit ${shown.code})`);
|
|
313
|
+
const diff = await fixHunkDiff({ file: rel, previous: shown.stdout, current, workDir });
|
|
314
|
+
return { kind: 'tracked', text: diff.diffText };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* B79: `text` cut to at most `max` UTF-8 bytes, marker included — never more. The bytes are cut on
|
|
319
|
+
* the Buffer and the cut moves back to a character boundary (never half a character); a cut text
|
|
320
|
+
* ends with a newline and {@link TRUNCATED_MARKER}. When `max` is too small for that marker, the
|
|
321
|
+
* part's text is dropped for {@link SHORT_MARKER} (or for nothing, when even that does not fit).
|
|
322
|
+
* A text that fits is returned as it is.
|
|
323
|
+
* @param {string} text @param {number} max @returns {string}
|
|
324
|
+
*/
|
|
325
|
+
export function capBytes(text, max) {
|
|
326
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
327
|
+
if (bytes.length <= max) return text;
|
|
328
|
+
const tail = `\n${TRUNCATED_MARKER}`;
|
|
329
|
+
const tailBytes = Buffer.byteLength(tail);
|
|
330
|
+
if (max < tailBytes) return max >= Buffer.byteLength(SHORT_MARKER) ? SHORT_MARKER : '';
|
|
331
|
+
let end = max - tailBytes;
|
|
332
|
+
// a UTF-8 continuation byte (10xxxxxx) never starts a character: step back to the lead byte
|
|
333
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
|
334
|
+
return `${bytes.subarray(0, end).toString('utf8')}${tail}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The free-text fields of a finding the packet shows. */
|
|
338
|
+
const FIELDS = /** @type {const} */ (['claim', 'evidence', 'fix']);
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The packet: the findings, the source lines around each, and the answering file's change — all
|
|
342
|
+
* from the bytes `reviewAnswers` read and hashed. Neither file may be secret-like (both refused the
|
|
343
|
+
* same way: `packet-unreadable`). B79: the WHOLE packet stays within the budget (`full_in` × 4
|
|
344
|
+
* bytes). Its fixed text (headings, labels, fences) is counted first — over the budget alone it is
|
|
345
|
+
* {@link PacketTooLarge}. Of the room left, the findings get half — per finding, each of claim,
|
|
346
|
+
* evidence and fix a fifth of its share and the source excerpt the rest — and the answering file's
|
|
347
|
+
* change what the findings leave. Each part is redacted BEFORE it is cut (a secret is never cut
|
|
348
|
+
* into a prefix redaction misses), and cut by {@link capBytes}: one marker per cut part, never over
|
|
349
|
+
* its cap. The fixed text is redacted before it is measured, so the joined packet is never cut: it
|
|
350
|
+
* ends with the change's closing fence. A joined packet redaction would still change (a secret
|
|
351
|
+
* across a part boundary) is refused, never cut — `packet-unreadable`.
|
|
352
|
+
* @param {{ticket: AnswersTicket, ctx: AnswersContext, rows: Array<Record<string, any>>, root: string, entry: {base: string | null}, open: Map<string, unknown>, io: AnswersIo, ids: string[], srcBytes: Buffer, byBytes: Buffer, workDir: string}} opts
|
|
353
|
+
* @returns {Promise<string>}
|
|
354
|
+
*/
|
|
355
|
+
async function packetText({ ticket, ctx, rows, root, entry, open, io, ids, srcBytes, byBytes, workDir }) {
|
|
356
|
+
const source = ticket.file;
|
|
357
|
+
const byFile = /** @type {string} */ (ticket.by_file);
|
|
358
|
+
if (isSecretLike(assertPacketPath(root, source))) throw new Error('secret-like path');
|
|
359
|
+
const details = findingDetails({ ticket, ctx, rows, root, io });
|
|
360
|
+
const lines = srcBytes.toString('utf8').split('\n');
|
|
361
|
+
const diff = await answeringChange({ root, file: byFile, base: entry.base, bytes: byBytes, workDir });
|
|
362
|
+
const limit = budgetFor(ctx.cfg, 'full_in') * 4;
|
|
363
|
+
// each finding: its fixed lines, and its free text (null: not recorded) and source excerpt (null: none)
|
|
364
|
+
const findings = ids.map((id) => {
|
|
365
|
+
const f = details.get(id) ?? null;
|
|
366
|
+
const start = Number.isInteger(f?.line_start) ? /** @type {number} */ (f?.line_start) : null;
|
|
367
|
+
const end = Number.isInteger(f?.line_end) ? /** @type {number} */ (f?.line_end) : start;
|
|
368
|
+
// the fixed text is redacted before it is measured: the joined packet never grows past its count
|
|
369
|
+
const head = [`## Finding ${id}`, '', `- file: ${source}`, `- lines: ${start === null ? 'unknown' : `${start}-${end}`}`, `- severity: ${String(f?.severity ?? open.get(id) ?? 'unknown')}`].map(redact);
|
|
370
|
+
/** @type {Record<typeof FIELDS[number], string | null>} */
|
|
371
|
+
const text = { claim: null, evidence: null, fix: null };
|
|
372
|
+
for (const k of FIELDS) text[k] = typeof f?.[k] === 'string' ? redact(f[k]) : null;
|
|
373
|
+
/** @type {{label: string, text: string} | null} */
|
|
374
|
+
let excerpt = null;
|
|
375
|
+
// a range recorded backwards (start after end) shows no lines, whatever the lines around it
|
|
376
|
+
if (start !== null && end !== null && start <= end && lines.length > 0) {
|
|
377
|
+
const from = Math.max(1, start - AROUND);
|
|
378
|
+
const to = Math.min(lines.length, end + AROUND);
|
|
379
|
+
// a recorded range past the end of the source, or a backwards one, shows no lines
|
|
380
|
+
if (from <= to) excerpt = { label: redact(`Source lines ${from}-${to}:`), text: redact(lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join('\n')) };
|
|
381
|
+
}
|
|
382
|
+
return { head, text, excerpt };
|
|
383
|
+
});
|
|
384
|
+
const intro = [
|
|
385
|
+
'# Answered findings',
|
|
386
|
+
'',
|
|
387
|
+
`A review of \`${source}\` left the findings below open. The coder says the change in \`${byFile}\` resolves them.`,
|
|
388
|
+
'For EACH finding id, decide whether that change resolves it: `resolved` only when the change clearly removes the problem the finding names; otherwise `not_resolved`. Give a short reason for each.',
|
|
389
|
+
'Reply with ONE JSON object: {"verdicts": [{"id": string, "verdict": "resolved" | "not_resolved", "reason": string}]}, one entry per id below.',
|
|
390
|
+
'',
|
|
391
|
+
].map(redact);
|
|
392
|
+
const changeHead = [`## The change in ${byFile}`, '', diff.kind === 'new' ? '(a new file: its full content)' : `(its diff against the block base)`, '```'].map(redact);
|
|
393
|
+
/**
|
|
394
|
+
* The packet with these part texts (`parts[i]`: finding i's fields and excerpt) and change.
|
|
395
|
+
* @param {Array<{claim: string, evidence: string, fix: string, excerpt: string}>} parts @param {string} change
|
|
396
|
+
*/
|
|
397
|
+
const render = (parts, change) => {
|
|
398
|
+
const out = [...intro];
|
|
399
|
+
findings.forEach((f, i) => {
|
|
400
|
+
out.push(...f.head);
|
|
401
|
+
for (const k of FIELDS) out.push(`- ${k}: ${f.text[k] === null ? '(not recorded)' : parts[i][k]}`);
|
|
402
|
+
out.push('');
|
|
403
|
+
if (f.excerpt !== null) out.push(f.excerpt.label, '```', parts[i].excerpt, '```', '');
|
|
404
|
+
});
|
|
405
|
+
out.push(...changeHead, change, '```', '');
|
|
406
|
+
return out.join('\n');
|
|
407
|
+
};
|
|
408
|
+
const fixed = Buffer.byteLength(render(findings.map(() => ({ claim: '', evidence: '', fix: '', excerpt: '' })), ''));
|
|
409
|
+
if (fixed > limit) throw new PacketTooLarge('the packet fixed text is over the budget');
|
|
410
|
+
const share = Math.floor((limit - fixed) / 2 / Math.max(1, findings.length));
|
|
411
|
+
const textCap = Math.floor(share / 5);
|
|
412
|
+
const sourceCap = share - FIELDS.length * textCap;
|
|
413
|
+
const parts = findings.map((f) => ({
|
|
414
|
+
claim: capBytes(f.text.claim ?? '', textCap),
|
|
415
|
+
evidence: capBytes(f.text.evidence ?? '', textCap),
|
|
416
|
+
fix: capBytes(f.text.fix ?? '', textCap),
|
|
417
|
+
excerpt: capBytes(f.excerpt?.text ?? '', sourceCap),
|
|
418
|
+
}));
|
|
419
|
+
// the change gets what the fixed text and the findings leave
|
|
420
|
+
const used = Buffer.byteLength(render(parts, ''));
|
|
421
|
+
const packet = render(parts, capBytes(redact(diff.text), limit - used));
|
|
422
|
+
// every piece is redacted and counted: the packet is never cut here, only refused (fail closed)
|
|
423
|
+
if (Buffer.byteLength(packet) > limit) throw new PacketTooLarge('the packet is over the budget');
|
|
424
|
+
if (redact(packet) !== packet) throw new Error('a secret across a part boundary');
|
|
425
|
+
return packet;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The open findings' details from the source's signed fix-loop state (read only, never moved or
|
|
430
|
+
* restored here). A state that does not load gives none: the packet then names id and severity.
|
|
431
|
+
* @param {{ticket: AnswersTicket, ctx: AnswersContext, rows: Array<Record<string, any>>, root: string, io: AnswersIo}} opts
|
|
432
|
+
* @returns {Map<string, Record<string, any>>}
|
|
433
|
+
*/
|
|
434
|
+
function findingDetails({ ticket, ctx, rows, root, io }) {
|
|
435
|
+
/** @type {Map<string, Record<string, any>>} */
|
|
436
|
+
const out = new Map();
|
|
437
|
+
try {
|
|
438
|
+
const loaded = io.loadState({ runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key: /** @type {Buffer} */ (ctx.key), rows, contentRoot: root, migrate: false });
|
|
439
|
+
if (loaded.status !== 'ok' && loaded.status !== 'restored') return out;
|
|
440
|
+
for (const f of [...(loaded.state?.open ?? []), ...(loaded.state?.late ?? [])]) if (typeof f?.id === 'string' && !out.has(f.id)) out.set(f.id, f);
|
|
441
|
+
} catch {
|
|
442
|
+
// no details: id and severity only
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|