@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
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
## The file loop
|
|
17
17
|
|
|
18
|
-
After you finish each file in `owned_files` (tests written and green) run `forge review-file <path> --block <id> --run <r>`; before the next file run `forge review-file --wait <ticket> [--max 90s]` and act on the packet. A `worker_down` answer ⇒ print `===BLOCK <id> FAILED: worker down===` (never approval).
|
|
18
|
+
After you finish each file in `owned_files` (tests written and green) run `forge review-file <path> --block <id> --run <r>`; before the next file run `forge review-file --wait <ticket> [--max 90s]` and act on the packet. A finding whose fix belongs in another owned file (a missing test, say) is fixed there and sent with `forge review-file <that file> --block <id> --run <r> --answers <ids> --of <source>`. A `worker_down` answer ⇒ print `===BLOCK <id> FAILED: worker down===` (never approval).
|
|
19
19
|
|
|
20
20
|
## Final report — FAILED unless it lists the ticket ids
|
|
21
21
|
|
|
@@ -42,7 +42,7 @@ Or `===BLOCK <id> FAILED: <reason>===`.
|
|
|
42
42
|
|
|
43
43
|
## Fix-round brief (round ≥ 2, or a respawn after a climb)
|
|
44
44
|
|
|
45
|
-
The open findings by id, the fix history, the files they sit in. Fix only those hunks, then run `forge review-file <path> --block <id> --run <r>` and `--wait` on **every file the round changed** — a fix the reviewer never saw is not a fix. The final report follows the same rule: one `reviewed <path> ticket <ticket-id>` line per file changed in the round, or the round counts as FAILED. Re-run the block's test command after the fixes and print a fresh `TESTS <command> exit=<n>` line just before the round's sentinel: a TESTS line from an earlier report does not count.
|
|
45
|
+
The open findings by id, the fix history, the files they sit in. Fix only those hunks, then run `forge review-file <path> --block <id> --run <r>` and `--wait` on **every file the round changed** — a fix the reviewer never saw is not a fix. A fix made in another owned file is sent with `--answers <ids> --of <source>` (The file loop). The final report follows the same rule: one `reviewed <path> ticket <ticket-id>` line per file changed in the round, or the round counts as FAILED. Re-run the block's test command after the fixes and print a fresh `TESTS <command> exit=<n>` line just before the round's sentinel: a TESTS line from an earlier report does not count.
|
|
46
46
|
|
|
47
47
|
## Context pack
|
|
48
48
|
|
package/src/cli/block.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge block open|attempt|rebase|claim|close|stop` (plan §4.9). The skill calls
|
|
2
|
+
* `code-forge block open|attempt|rebase|claim|close|stop|waive|reopen` (plan §4.9). The skill calls
|
|
3
3
|
* `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
|
|
4
4
|
*
|
|
5
5
|
* block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file>
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
|
|
12
12
|
* block stop <id> --run <r> --reason <text>
|
|
13
13
|
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9)
|
|
14
|
+
* block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72)
|
|
14
15
|
*
|
|
15
16
|
* B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
|
|
16
17
|
* text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
|
|
@@ -77,6 +78,19 @@
|
|
|
77
78
|
* else still refuses — `unowned_change`, `unproven`, `no_changes`, the transcript grep and
|
|
78
79
|
* `closeBlock`'s own rows. With `--no-require-reviews` the human path runs exactly as without the key.
|
|
79
80
|
*
|
|
81
|
+
* B70 (issue #9 a): a file with no approval for its current hash whose real review (a signed
|
|
82
|
+
* `review.round` at that hash) left findings that are all waived — `block waive`, or an autopilot
|
|
83
|
+
* waiver its grant covered for a non-critical — passes as `reviewed_by_waiver`, never approved
|
|
84
|
+
* (`checkBlockReviews`). When the block closes, one signed `gate.review_waived_all {file,
|
|
85
|
+
* content_hash, findings, by}` row per such file is written just before `block.close` (after any
|
|
86
|
+
* other close row); a refused close writes none. The close prints `block <id>: <file>
|
|
87
|
+
* reviewed_by_waiver (<ids>; by <who>)` before the closed line, and `--json` adds
|
|
88
|
+
* `reviewed_by_waiver: [{file, content_hash, status, findings, by}]` (only when there is one).
|
|
89
|
+
* B71: a finding a signed `review.answered` row clears (another owned file resolved it, judged by
|
|
90
|
+
* a reviewer) counts like a waived one, listed under `answered` next to `findings` in the close
|
|
91
|
+
* row and the `--json` entry; the line then reads `(<ids>; by <who>; answered <ids>)`, or
|
|
92
|
+
* `(answered <ids>)` when nothing was waived (`by` is then null).
|
|
93
|
+
*
|
|
80
94
|
* B63b: `context.require_coder_tests: true` in the run's config snapshot (immutable like
|
|
81
95
|
* `review.external`; the live config is never read for it) adds one check after the report check:
|
|
82
96
|
* `--report` must be given and readable, its last sentinel for the block must be COMPLETE, and a
|
|
@@ -128,6 +142,20 @@
|
|
|
128
142
|
* before anything is asked or written. The ledger, the signed result files and the high paths stay
|
|
129
143
|
* in the main tree. Nothing is checked out, pushed or merged.
|
|
130
144
|
*
|
|
145
|
+
* B72 (issues #12, #15): `block reopen <id> --run <r> --file <path> --reason <text>` is the
|
|
146
|
+
* human's restart of a file whose review STOPPED or whose state is LOST (`review/reopen.mjs`):
|
|
147
|
+
* coder-forbidden like `block waive`. The done marker of the current content's ticket is removed
|
|
148
|
+
* (the ticket of the block's worktree when it has one), then one signed `review.reopened {block,
|
|
149
|
+
* file, from, stop_reason, at_seq, from_hash, to_hash, reason, by: human}` row is written, so the
|
|
150
|
+
* next `review-file` runs a full round 1. Refused (exit 1, no row): `unknown_block` and
|
|
151
|
+
* `reopen-block-not-open` (checked before anything is read), a `worktree-*` code (the block's tree
|
|
152
|
+
* cannot be read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read),
|
|
153
|
+
* `reopen-not-owned`, `reopen-tampered`, `reopen-not-stopped`, `reopen-same-content`,
|
|
154
|
+
* `reopen-already-recorded`, `reopen-write-failed`; a missing `--file` or `--reason` is a usage
|
|
155
|
+
* error (exit 2). It prints `block <id> reopened <file> (from
|
|
156
|
+
* <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
|
|
157
|
+
* "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
|
|
158
|
+
*
|
|
131
159
|
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
|
|
132
160
|
* rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
|
|
133
161
|
* through `closeBlock`'s `extraChecks`.
|
|
@@ -154,19 +182,78 @@ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
|
154
182
|
import { validateConfig } from '../config/validate.mjs';
|
|
155
183
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
156
184
|
import { exec } from '../util/exec.mjs';
|
|
157
|
-
import { gitChildEnv } from '../worker/ticket.mjs';
|
|
185
|
+
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
186
|
+
import { clearDone } from '../worker/queue.mjs';
|
|
187
|
+
import { loadState } from '../worker/review-state.mjs';
|
|
188
|
+
import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
189
|
+
import { runRootPath } from '../util/tmp.mjs';
|
|
158
190
|
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
159
191
|
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
160
192
|
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
161
193
|
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
162
194
|
|
|
195
|
+
/**
|
|
196
|
+
* B72: `block reopen` — the file's content is read where the block works (its worktree, else the
|
|
197
|
+
* main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
|
|
198
|
+
* the checks, the done marker's removal and then the signed row are `reopenFile`'s.
|
|
199
|
+
* @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
|
|
200
|
+
* @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
|
|
201
|
+
*/
|
|
202
|
+
async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
|
|
203
|
+
// the stable block codes come first, before any tree, config, key or ledger read
|
|
204
|
+
const entry = openBlockEntry(record, runId, id);
|
|
205
|
+
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
206
|
+
const mainTop = await real(await repoTop(record.workspace));
|
|
207
|
+
const wt = await blockTree(record.workspace, entry).catch((thrown) => {
|
|
208
|
+
if (typeof thrown?.code === 'string' && thrown.code.startsWith('worktree-')) throw new StateError(thrown.code, `block ${id}: ${thrown.message}, so the file's content cannot be read; nothing was recorded`);
|
|
209
|
+
throw thrown;
|
|
210
|
+
});
|
|
211
|
+
const contentRoot = wt === null ? mainTop : wt.top;
|
|
212
|
+
const wsPrefix = path.relative(mainTop, await real(record.workspace)).split(path.sep).join('/');
|
|
213
|
+
// the state is read where the worker keeps it: an unreadable snapshot or tmp.root is refused,
|
|
214
|
+
// never guessed (a wrong root would read as "no state"); only an absent tmp.root (undefined or
|
|
215
|
+
// null) means the default root, any other non-string is refused (B79)
|
|
216
|
+
/** @type {string} */
|
|
217
|
+
let runRootDir;
|
|
218
|
+
try {
|
|
219
|
+
const root = configInForce(record)?.tmp?.root;
|
|
220
|
+
if (root !== undefined && root !== null && typeof root !== 'string') throw new TypeError('tmp.root is not a string');
|
|
221
|
+
runRootDir = runRootPath(runId, typeof root === 'string' ? { root } : {});
|
|
222
|
+
} catch {
|
|
223
|
+
throw new StateError('reopen-config', `reopen-config: the run's config snapshot or its tmp.root cannot be read, so the review state of ${file} cannot be found; nothing was recorded`);
|
|
224
|
+
}
|
|
225
|
+
const key = await loadKey(runId);
|
|
226
|
+
const rows = await readAllRows(record.project);
|
|
227
|
+
const tree = contentRoot === mainTop ? {} : { tree: contentRoot };
|
|
228
|
+
const { row, cleared } = await reopenFile({
|
|
229
|
+
runId,
|
|
230
|
+
id,
|
|
231
|
+
file,
|
|
232
|
+
reason,
|
|
233
|
+
record,
|
|
234
|
+
rows,
|
|
235
|
+
key,
|
|
236
|
+
writeRow,
|
|
237
|
+
writeSigned,
|
|
238
|
+
io: {
|
|
239
|
+
owns: ownsAt(wsPrefix),
|
|
240
|
+
loadState: (where) => loadState({ .../** @type {any} */ (where), runRootDir, contentRoot, migrate: false }),
|
|
241
|
+
contentHash: (f) => contentHash(contentRoot, f),
|
|
242
|
+
// C11 (synchronous): the cached result of the current content's ticket would repeat the stop
|
|
243
|
+
clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
return { row, cleared };
|
|
247
|
+
}
|
|
248
|
+
|
|
163
249
|
const USAGE =
|
|
164
250
|
'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract] [--tests <paths…>]\n' +
|
|
165
251
|
' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
|
|
166
252
|
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
|
|
253
|
+
' code-forge block reopen <id> --run <r> --file <path> --reason <text>\n' +
|
|
167
254
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
168
255
|
|
|
169
|
-
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
|
|
256
|
+
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
|
|
170
257
|
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
|
|
171
258
|
|
|
172
259
|
/**
|
|
@@ -214,8 +301,10 @@ export async function runBlock(args, deps = {}) {
|
|
|
214
301
|
}
|
|
215
302
|
if (sub !== 'close' && flags['remove-worktree'] === true) throw new StateError('usage', '--remove-worktree is a block close flag');
|
|
216
303
|
if (sub === 'claim' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block claim needs exactly one path');
|
|
217
|
-
if ((sub === 'stop' || sub === 'waive') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
|
|
304
|
+
if ((sub === 'stop' || sub === 'waive' || sub === 'reopen') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
|
|
218
305
|
if (sub === 'waive' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block waive needs exactly one finding id');
|
|
306
|
+
if (sub === 'reopen' && positionals.length !== 1) throw new StateError('usage', 'block reopen takes one block id (the file goes in --file)');
|
|
307
|
+
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
219
308
|
if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
|
|
220
309
|
const record = await readRun(runId);
|
|
221
310
|
const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
@@ -367,6 +456,14 @@ export async function runBlock(args, deps = {}) {
|
|
|
367
456
|
if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'human' });
|
|
368
457
|
return 0;
|
|
369
458
|
}
|
|
459
|
+
if (sub === 'reopen') {
|
|
460
|
+
const file = /** @type {string} */ (flags.file);
|
|
461
|
+
const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow });
|
|
462
|
+
const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
|
|
463
|
+
say(`block ${id} reopened ${file} (from ${fromText}; by: human)\n`);
|
|
464
|
+
if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: 'human' });
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
370
467
|
if (sub === 'close') {
|
|
371
468
|
const entry = record.blocks?.[id];
|
|
372
469
|
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
@@ -430,6 +527,22 @@ export async function runBlock(args, deps = {}) {
|
|
|
430
527
|
const fileSet = await blockFileSet({ repoRoot: top, base, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
|
|
431
528
|
return { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed };
|
|
432
529
|
})());
|
|
530
|
+
// B58: the config waiver row is written by `closeBlock` under the run lock, just before
|
|
531
|
+
// `block.close` and only when every other check passed — never by a refused close
|
|
532
|
+
/** @type {Record<string, any>[]} */
|
|
533
|
+
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
534
|
+
/** @type {import('../review/gate-check.mjs').WaivedAll[]} */
|
|
535
|
+
let waivedAll = [];
|
|
536
|
+
// B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
|
|
537
|
+
// `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
|
|
538
|
+
// when the block closes — a refused close writes none)
|
|
539
|
+
const onWaivedAll = (/** @type {import('../review/gate-check.mjs').WaivedAll[]} */ files) => {
|
|
540
|
+
// idempotent: the waived rows are rebuilt from the final list, never appended per call
|
|
541
|
+
// (closeBlock reads closeRows only after every extra check passed)
|
|
542
|
+
waivedAll = files;
|
|
543
|
+
const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
|
|
544
|
+
closeRows.splice(0, closeRows.length, ...others, ...files.map((f) => ({ event: 'gate.review_waived_all', block: id, file: f.file, content_hash: f.content_hash, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })));
|
|
545
|
+
};
|
|
433
546
|
const reviews = reviewGateCheck(async () => {
|
|
434
547
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
435
548
|
if (transcript === null) {
|
|
@@ -479,7 +592,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
479
592
|
proofFiles: fileSet,
|
|
480
593
|
highPaths,
|
|
481
594
|
};
|
|
482
|
-
});
|
|
595
|
+
}, { onWaivedAll });
|
|
483
596
|
// B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
|
|
484
597
|
const report = async () => {
|
|
485
598
|
if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
|
|
@@ -493,10 +606,6 @@ export async function runBlock(args, deps = {}) {
|
|
|
493
606
|
const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
|
|
494
607
|
return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
495
608
|
};
|
|
496
|
-
// B58: the config waiver row is written by `closeBlock` under the run lock, just before
|
|
497
|
-
// `block.close` and only when every other check passed — never by a refused close
|
|
498
|
-
/** @type {Record<string, any>[]} */
|
|
499
|
-
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
500
609
|
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
501
610
|
const extraChecks = [reviews, report];
|
|
502
611
|
// B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
|
|
@@ -530,10 +639,28 @@ export async function runBlock(args, deps = {}) {
|
|
|
530
639
|
// does — also when a later step (the output, `--remove-worktree`) throws; it never changes
|
|
531
640
|
// the exit code or stdout (`runHook` never throws)
|
|
532
641
|
try {
|
|
642
|
+
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
643
|
+
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
644
|
+
if (result.ok) {
|
|
645
|
+
for (const f of waivedAll) {
|
|
646
|
+
const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
|
|
647
|
+
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
648
|
+
say(`block ${id}: ${f.file} reviewed_by_waiver (${parts.join('; ')})\n`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
533
651
|
(result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
534
652
|
// B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
|
|
535
653
|
if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
|
|
536
|
-
const closedJson = {
|
|
654
|
+
const closedJson = {
|
|
655
|
+
block: id,
|
|
656
|
+
event: result.status,
|
|
657
|
+
status: result.status,
|
|
658
|
+
reason: result.reason ?? null,
|
|
659
|
+
close_event: result.event ?? null,
|
|
660
|
+
...(end?.json ?? {}),
|
|
661
|
+
// B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
|
|
662
|
+
...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
|
|
663
|
+
};
|
|
537
664
|
if (!result.ok || !removeTree) {
|
|
538
665
|
if (json && result.ok) emit(closedJson);
|
|
539
666
|
return result.ok ? 0 : 1;
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
* review-file --wait <ticket> [--max <90s>] poll; prints {ticket, status: done, result} or
|
|
6
6
|
* {ticket, status: pending, reason: wait_timeout}
|
|
7
7
|
* when --max elapses (the review is still running)
|
|
8
|
+
* review-file <answering file> --block <id> --answers <ids> --of <source> [--run <id>]
|
|
9
|
+
* B71: enqueue an answers ticket; prints {ticket,
|
|
10
|
+
* status, kind: answers, file: source, by_file, answers}
|
|
11
|
+
* review-file <path> --block <id> --fresh B75: re-run a cached result that never counted
|
|
8
12
|
*
|
|
9
13
|
* The same content enqueued again after an `unavailable` result is a fresh attempt (`retry: true`),
|
|
10
14
|
* not the old result.
|
|
11
15
|
* A done result that is not approved and carries open findings adds `fix_list` (what the coder
|
|
12
16
|
* fixes before re-running `review-file <path>`, which is then a round-2 recheck of the fix hunks,
|
|
13
|
-
* §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …)
|
|
17
|
+
* §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) and (B73)
|
|
18
|
+
* `exits: ["block reopen", "block waive", "review-file --answers", "re-decompose"]` — the coder
|
|
14
19
|
* stops and reports; only the orchestrator and the human act on it. A `split_required` result
|
|
15
20
|
* (the packet is over `review.budgets.full_in`) adds `stop: split_required` with `tokens_in` and
|
|
16
21
|
* `budget` (always; null when unknown) and a Markdown `section` — never for another result (B54); a Markdown file gets there only when one of its sections alone is too large.
|
|
@@ -22,6 +27,15 @@
|
|
|
22
27
|
* `===BLOCK <id> FAILED: worker down===`. While waiting, `worker_down` needs the worker to look
|
|
23
28
|
* down on two polls in a row, and a worker whose pid is alive with a fresh heartbeat is never down
|
|
24
29
|
* (`liveWorker`, B30). Output is one JSON line on stdout.
|
|
30
|
+
* B75: `--fresh` drops the cached done marker of the file's current content and queues the ticket
|
|
31
|
+
* again ONLY when that result never counted as a review: `stale`, `unavailable`, or `refused` with
|
|
32
|
+
* `other-run`, `context-unreadable` or a `worktree-*` reason (an allow-list, fail closed). Any
|
|
33
|
+
* other cached result — a round ran (findings, approved, stopped), `review-state-lost`, an answers
|
|
34
|
+
* result, an unknown status or reason, an unreadable marker — is refused `fresh-needs-reopen`
|
|
35
|
+
* (exit 1, `exits: [block reopen, block waive]`, the marker kept byte-for-byte); a
|
|
36
|
+
* `review-state-tampered` one is refused `fresh-tampered` (names only `block close
|
|
37
|
+
* --no-require-reviews`). No cached result: a normal enqueue. A coder may run it; it never
|
|
38
|
+
* re-rolls a counted round and writes no ledger row.
|
|
25
39
|
* Exit codes: 0 queued/done/pending, 1 bad-path or another refusal, 2 usage, 3 worker_down.
|
|
26
40
|
*
|
|
27
41
|
* B62b: the queue and the worker are always the MAIN tree's (`mainRootOf`), also when the coder
|
|
@@ -35,19 +49,38 @@
|
|
|
35
49
|
* from, which is the block's tree when the coder works there. A record naming a worktree that is
|
|
36
50
|
* gone or malformed is refused (exit 1, `reason: worktree-missing|worktree-malformed|…`), and
|
|
37
51
|
* nothing is queued.
|
|
52
|
+
*
|
|
53
|
+
* B71: `--answers <id,id,…> --of <source>` (1 to 10 finding ids, comma-separated) asks the worker
|
|
54
|
+
* whether the change in the positional file (the answering file) resolves those open findings of
|
|
55
|
+
* `<source>`. It is refused before anything is queued — exit 2 `usage` for a missing `--of`, no
|
|
56
|
+
* ids, more than 10 ids or a malformed id; exit 1 `answers-same-file` when both name one file,
|
|
57
|
+
* `answers-not-owned` when the run record says the block does not own both, `answers-not-open`
|
|
58
|
+
* when an id is not open on the source at its current hash (not named by the latest refusal or
|
|
59
|
+
* cap row there, or already waived or answered; these two pre-checks read what this process can read, unverified, and are skipped when the run
|
|
60
|
+
* record cannot be opened here). The worker checks all of it again with the signed rows; that check
|
|
61
|
+
* is the one that counts. Its done result has `status: answers` and the coder-facing `answers:
|
|
62
|
+
* [{id, verdict: resolved|not_resolved, reason}]` instead of a fix list; a resolved id is a signed
|
|
63
|
+
* `review.answered` row the close counts until either file changes. The answering file still
|
|
64
|
+
* needs its own review.
|
|
38
65
|
*/
|
|
39
66
|
|
|
40
67
|
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
41
68
|
import path from 'node:path';
|
|
42
|
-
import { enqueue, isDone, liveWorker, queueDir } from '../worker/queue.mjs';
|
|
43
|
-
import { assertBlockId, assertInsideRoot, assertTicketId, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
69
|
+
import { answersTicketId, assertAnswers, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
70
|
+
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
71
|
+
import { namedOnHash } from '../review/gate-check.mjs';
|
|
72
|
+
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
73
|
+
import { ownsFile } from '../state/registry.mjs';
|
|
74
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
44
75
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
45
76
|
import { StateError } from '../state/paths.mjs';
|
|
46
77
|
import { readRun } from '../state/run.mjs';
|
|
47
78
|
import { blockTree } from '../proof/worktree.mjs';
|
|
48
79
|
import { writeSafe } from '../util/redact.mjs';
|
|
49
80
|
|
|
50
|
-
const USAGE =
|
|
81
|
+
const USAGE =
|
|
82
|
+
'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
|
|
83
|
+
' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n';
|
|
51
84
|
|
|
52
85
|
export const DEFAULT_WAIT_MS = 90_000;
|
|
53
86
|
const POLL_MS = 200;
|
|
@@ -69,15 +102,132 @@ export function parseMax(raw) {
|
|
|
69
102
|
*/
|
|
70
103
|
function doneResult(repoRoot, ticket) {
|
|
71
104
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
105
|
+
return resultOfMarker(repoRoot, readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} repoRoot @param {string} text - a done marker's text
|
|
113
|
+
* @returns {Record<string, any> | null} the worker's result it names (unverified), or null.
|
|
114
|
+
*/
|
|
115
|
+
function resultOfMarker(repoRoot, text) {
|
|
116
|
+
try {
|
|
117
|
+
const marker = JSON.parse(text);
|
|
118
|
+
const rel = typeof marker?.result === 'string' ? marker.result : '';
|
|
74
119
|
if (!rel.startsWith(`.code-forge${path.sep}reviews${path.sep}`) || rel.split(path.sep).includes('..')) return null;
|
|
75
|
-
|
|
120
|
+
const result = JSON.parse(readFileSync(path.join(repoRoot, rel), 'utf8'));
|
|
121
|
+
return result !== null && typeof result === 'object' && !Array.isArray(result) ? result : null;
|
|
76
122
|
} catch {
|
|
77
123
|
return null;
|
|
78
124
|
}
|
|
79
125
|
}
|
|
80
126
|
|
|
127
|
+
/** B75: the refused reasons that are not a verdict on the content (`--fresh` re-runs them). */
|
|
128
|
+
const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* B75: may `--fresh` drop this cached result? An allow-list, fail closed: `rerun` only for a
|
|
132
|
+
* result that never counted as a review — `stale`, `unavailable`, or `refused` with a reason that
|
|
133
|
+
* is not a verdict (`other-run`, `context-unreadable`, `worktree-*`) — whose marker names the same
|
|
134
|
+
* status. `tampered` for `refused review-state-tampered`; `counted` for everything else (a round
|
|
135
|
+
* ran, `review-state-lost`, an answers result, an unknown status or reason, an unreadable marker or
|
|
136
|
+
* result).
|
|
137
|
+
* @param {string} text - the done marker's text @param {Record<string, any> | null} result
|
|
138
|
+
* @returns {'rerun' | 'tampered' | 'counted'}
|
|
139
|
+
*/
|
|
140
|
+
export function freshVerdict(text, result) {
|
|
141
|
+
let marker;
|
|
142
|
+
try {
|
|
143
|
+
marker = JSON.parse(text);
|
|
144
|
+
} catch {
|
|
145
|
+
return 'counted';
|
|
146
|
+
}
|
|
147
|
+
if (result === null || marker === null || typeof marker !== 'object' || marker.status !== result.status) return 'counted';
|
|
148
|
+
if (result.status === 'stale' || result.status === 'unavailable') return 'rerun';
|
|
149
|
+
if (result.status !== 'refused' || typeof result.reason !== 'string') return 'counted';
|
|
150
|
+
if (result.reason === 'review-state-tampered') return 'tampered';
|
|
151
|
+
return FRESH_REFUSED.has(result.reason) || result.reason.startsWith('worktree-') ? 'rerun' : 'counted';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** B75: how many times --fresh classifies a marker that changed under its claim. */
|
|
155
|
+
const FRESH_ATTEMPTS = 3;
|
|
156
|
+
|
|
157
|
+
/** B75: the ways out a counted result names (human-only; a coder stops and reports). */
|
|
158
|
+
export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* B75: `review-file <path> --block <id> --fresh`. No done marker: a normal enqueue. The marker is
|
|
162
|
+
* read and classified IN PLACE; only a cached result that never counted is then claimed by
|
|
163
|
+
* `dropDoneIf` (rename, then a byte compare with what was classified) and the ticket queued again.
|
|
164
|
+
* A marker that changed under the claim is restored (link only) and classified again, at most
|
|
165
|
+
* {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
|
|
166
|
+
* crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
|
|
167
|
+
* `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
|
|
168
|
+
* the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
|
|
169
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
|
|
170
|
+
* @returns {number} the exit code.
|
|
171
|
+
*/
|
|
172
|
+
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf }) {
|
|
173
|
+
const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
|
|
174
|
+
const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
|
|
175
|
+
/** @type {'rerun' | 'tampered' | 'counted'} */
|
|
176
|
+
let verdict = 'counted';
|
|
177
|
+
// a marker that changed under the claim is classified again; bounded, then refused (fail closed)
|
|
178
|
+
for (let attempt = 0; attempt < FRESH_ATTEMPTS; attempt += 1) {
|
|
179
|
+
/** @type {Buffer | null} */
|
|
180
|
+
let bytes;
|
|
181
|
+
try {
|
|
182
|
+
bytes = readFileSync(markerPath);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// no marker: a normal enqueue; a marker that exists but cannot be read is never dropped
|
|
185
|
+
bytes = /** @type {{code?: unknown}} */ (err)?.code === 'ENOENT' ? null : Buffer.alloc(0);
|
|
186
|
+
}
|
|
187
|
+
if (bytes === null) {
|
|
188
|
+
say(enqueue({ repoRoot, run, block, file, ...where }));
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
// classified IN PLACE: a refusal never moves, renames or touches the marker
|
|
192
|
+
const text = bytes.toString('utf8');
|
|
193
|
+
verdict = freshVerdict(text, resultOfMarker(repoRoot, text));
|
|
194
|
+
if (verdict !== 'rerun') break;
|
|
195
|
+
/** @type {unknown} */
|
|
196
|
+
let outcome;
|
|
197
|
+
try {
|
|
198
|
+
outcome = dropDone(repoRoot, ticket, bytes);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// a named refusal passes through; any other throw is named here, never echoed
|
|
201
|
+
if (err instanceof WorkerError) throw err;
|
|
202
|
+
throw new WorkerError('fresh-claim-failed', `the claim of the cached result of ${file} failed; nothing was queued`);
|
|
203
|
+
}
|
|
204
|
+
if (outcome === 'changed') {
|
|
205
|
+
verdict = 'counted';
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// an allow-list of outcomes: anything else is refused and nothing is queued
|
|
209
|
+
if (outcome !== 'dropped' && outcome !== 'gone') throw new WorkerError('fresh-claim-unknown', `the claim of the cached result of ${file} gave an unknown outcome; nothing was queued`);
|
|
210
|
+
// dropped: queue it again now (a crash right here leaves a pending ticket with no marker, which
|
|
211
|
+
// the next review-file queues anyway); gone: another caller took the marker, a normal enqueue
|
|
212
|
+
const queued = enqueue({ repoRoot, run, block, file, ...where });
|
|
213
|
+
say(outcome === 'dropped' ? { ...queued, fresh: true } : queued);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
if (verdict === 'tampered') {
|
|
217
|
+
say({ status: 'refused', reason: 'fresh-tampered', ticket, file, exits: ['block close --no-require-reviews'], message: `fresh-tampered: the cached result of ${file} is review-state-tampered (a security event, not a stop); only the owner's block close --no-require-reviews gets past it; the cached result was kept` });
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
say({
|
|
221
|
+
status: 'refused',
|
|
222
|
+
reason: 'fresh-needs-reopen',
|
|
223
|
+
ticket,
|
|
224
|
+
file,
|
|
225
|
+
exits: [...FRESH_EXITS],
|
|
226
|
+
message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
|
|
227
|
+
});
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
|
|
81
231
|
/**
|
|
82
232
|
* B62b: where the block `block` of run `runId` keeps its content, from the run record (read from
|
|
83
233
|
* the user's state dir, `~/.code-forge/runs`, whatever the current directory): `{top}` with the
|
|
@@ -112,7 +262,63 @@ async function blockTreeOf(runId, block) {
|
|
|
112
262
|
}
|
|
113
263
|
|
|
114
264
|
/**
|
|
115
|
-
*
|
|
265
|
+
* B71: the coder-side pre-checks of an answers ticket (fast refusal only): with a run record this
|
|
266
|
+
* process can open, the block must own both files and every id must be open on the source's
|
|
267
|
+
* current hash — named by the latest refusal or cap row there, not waived or answered yet — read by
|
|
268
|
+
* the close's rules over unverified rows ({@link namedOnHash}). A record or ledger that
|
|
269
|
+
* cannot be read here skips the check it needs: the worker re-checks everything with signed rows.
|
|
270
|
+
* The rows of the ticket this request gets do not close an id (the retry of an attempt whose
|
|
271
|
+
* ledger write failed part way). `treeName`: the ticket's tree (null in the main tree).
|
|
272
|
+
* @param {{runId: string, block: string, file: string, byFile: string, tree: string, answers: string[], treeName: string | null}} opts
|
|
273
|
+
* @throws {WorkerError} `answers-not-owned`, `answers-not-open`
|
|
274
|
+
*/
|
|
275
|
+
async function answersPrecheck({ runId, block, file, byFile, tree, answers, treeName }) {
|
|
276
|
+
let record;
|
|
277
|
+
try {
|
|
278
|
+
record = await readRun(runId);
|
|
279
|
+
} catch {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const entry = record?.blocks?.[block];
|
|
283
|
+
if (!entry || typeof entry !== 'object') return;
|
|
284
|
+
const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
|
|
285
|
+
if (!ownsFile(owned, file, entry.owned_kinds) || !ownsFile(owned, byFile, entry.owned_kinds)) {
|
|
286
|
+
throw new WorkerError('answers-not-owned', `block ${block} does not own both ${byFile} and ${file}; nothing was queued`);
|
|
287
|
+
}
|
|
288
|
+
let rows;
|
|
289
|
+
try {
|
|
290
|
+
rows = await readAllRows(record.project);
|
|
291
|
+
} catch {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const hash = contentHash(tree, file);
|
|
295
|
+
const current = new Map([
|
|
296
|
+
[file, hash],
|
|
297
|
+
[byFile, contentHash(tree, byFile)],
|
|
298
|
+
]);
|
|
299
|
+
const by_hash = /** @type {string} */ (current.get(byFile));
|
|
300
|
+
const ticket = answersTicketId({ run: runId, block, file, content_hash: hash, by_file: byFile, by_hash, answers, tree: treeName });
|
|
301
|
+
const named = namedOnHash({ block, runId, rows, file, hash, current, ticket });
|
|
302
|
+
const missing = answers.filter((id) => !named.has(id));
|
|
303
|
+
if (missing.length > 0) throw new WorkerError('answers-not-open', `${missing.join(', ')} is not an open finding of ${file} at its current content; nothing was queued`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* B71: the `--answers` list: comma-separated finding ids, 1 to {@link MAX_ANSWERS}.
|
|
308
|
+
* @param {unknown} raw @returns {string[]}
|
|
309
|
+
* @throws {WorkerError} `usage`
|
|
310
|
+
*/
|
|
311
|
+
function answersOf(raw) {
|
|
312
|
+
if (typeof raw !== 'string') throw new WorkerError('usage', '--answers needs finding ids');
|
|
313
|
+
const ids = raw.split(',');
|
|
314
|
+
if (ids.length > MAX_ANSWERS) throw new WorkerError('usage', `--answers takes at most ${MAX_ANSWERS} finding ids`);
|
|
315
|
+
if (new Set(ids).size !== ids.length) throw new WorkerError('usage', '--answers names a finding id twice');
|
|
316
|
+
return assertAnswers(ids);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The coder-facing summary of a done result: the fix list and the stop reason, when any. B71: an
|
|
321
|
+
* answers result gives `answers: [{id, verdict, reason}]` instead.
|
|
116
322
|
* @param {Record<string, any> | null} result
|
|
117
323
|
* @returns {Record<string, any>}
|
|
118
324
|
*/
|
|
@@ -120,6 +326,10 @@ export function fixList(result) {
|
|
|
120
326
|
if (!result || result.approved === true) return {};
|
|
121
327
|
/** @type {Record<string, any>} */
|
|
122
328
|
const out = {};
|
|
329
|
+
if (result.kind === 'answers' || result.status === 'answers') {
|
|
330
|
+
if (Array.isArray(result.findings)) out.answers = result.findings.map((/** @type {Record<string, any>} */ f) => ({ id: f?.id ?? null, verdict: f?.verdict ?? 'not_resolved', reason: f?.reason ?? '' }));
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
123
333
|
if (Array.isArray(result.findings) && result.findings.length > 0) {
|
|
124
334
|
out.fix_list = result.findings.map((/** @type {Record<string, any>} */ f) => ({
|
|
125
335
|
id: f?.id ?? null,
|
|
@@ -129,8 +339,11 @@ export function fixList(result) {
|
|
|
129
339
|
fix: f?.fix ?? '',
|
|
130
340
|
}));
|
|
131
341
|
}
|
|
132
|
-
if (typeof result.stopped === 'string')
|
|
133
|
-
|
|
342
|
+
if (typeof result.stopped === 'string') {
|
|
343
|
+
out.stop = result.stopped;
|
|
344
|
+
// B73: a stopped file names its ways out (never a refused or tampered state: no `stopped`)
|
|
345
|
+
out.exits = [...STOP_EXITS];
|
|
346
|
+
} else if (result.status === 'split_required') out.stop = 'split_required';
|
|
134
347
|
// B54: a split_required stop (only) says how large the packet was, the budget, and the section
|
|
135
348
|
if (out.stop === 'split_required') {
|
|
136
349
|
const fin = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
@@ -141,7 +354,7 @@ export function fixList(result) {
|
|
|
141
354
|
|
|
142
355
|
/**
|
|
143
356
|
* @param {string[]} args
|
|
144
|
-
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number}} [deps]
|
|
357
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
|
|
145
358
|
* @returns {Promise<number>}
|
|
146
359
|
*/
|
|
147
360
|
export async function runReviewFile(args, deps = {}) {
|
|
@@ -150,12 +363,12 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
150
363
|
const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
|
|
151
364
|
const cwd = deps.cwd ?? process.cwd();
|
|
152
365
|
try {
|
|
153
|
-
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max'] });
|
|
366
|
+
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
154
367
|
// the queue and the worker: the main tree, also from inside a block's worktree
|
|
155
368
|
const repoRoot = await mainRootOf(cwd);
|
|
156
369
|
|
|
157
370
|
if (typeof flags.wait === 'string') {
|
|
158
|
-
if (positionals.length > 0 || flags.block !== undefined) throw new WorkerError('usage', '--wait takes only a ticket and --max');
|
|
371
|
+
if (positionals.length > 0 || flags.block !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true) throw new WorkerError('usage', '--wait takes only a ticket and --max');
|
|
159
372
|
const ticket = assertTicketId(flags.wait);
|
|
160
373
|
const maxMs = flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max);
|
|
161
374
|
const deadline = Date.now() + maxMs;
|
|
@@ -181,6 +394,8 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
181
394
|
|
|
182
395
|
if (positionals.length !== 1 || typeof flags.block !== 'string' || flags.max !== undefined) throw new WorkerError('usage', 'give one path and --block');
|
|
183
396
|
const block = assertBlockId(flags.block);
|
|
397
|
+
if (flags.fresh === true && (flags.answers !== undefined || flags.of !== undefined)) throw new WorkerError('usage', '--fresh takes no --answers or --of');
|
|
398
|
+
if (flags.answers !== undefined || flags.of !== undefined) return await enqueueAnswersTicket({ flags, answering: positionals[0], block, cwd, repoRoot, say });
|
|
184
399
|
const file = normalizeRequestPath(positionals[0], cwd, await repoRootOf(cwd));
|
|
185
400
|
const worker = liveWorker(repoRoot);
|
|
186
401
|
if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
|
|
@@ -193,6 +408,7 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
193
408
|
const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
|
|
194
409
|
assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
|
|
195
410
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
411
|
+
if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
196
412
|
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
197
413
|
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
|
|
198
414
|
// `unavailable` is never approval AND never final: drop the done marker so the worker runs
|
|
@@ -214,6 +430,41 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
214
430
|
}
|
|
215
431
|
}
|
|
216
432
|
|
|
433
|
+
/**
|
|
434
|
+
* B71: `review-file <answering file> --block <id> --answers <ids> --of <source>`.
|
|
435
|
+
* @param {{flags: Record<string, any>, answering: string, block: string, cwd: string, repoRoot: string, say: (obj: Record<string, any>) => unknown}} opts
|
|
436
|
+
* @returns {Promise<number>}
|
|
437
|
+
* @throws {WorkerError}
|
|
438
|
+
*/
|
|
439
|
+
async function enqueueAnswersTicket({ flags, answering, block, cwd, repoRoot, say }) {
|
|
440
|
+
if (typeof flags.of !== 'string' || flags.of.length === 0) throw new WorkerError('usage', '--answers needs --of <source>');
|
|
441
|
+
const answers = answersOf(flags.answers);
|
|
442
|
+
const top = await repoRootOf(cwd);
|
|
443
|
+
const byFile = normalizeRequestPath(answering, cwd, top);
|
|
444
|
+
const file = normalizeRequestPath(flags.of, cwd, top);
|
|
445
|
+
if (byFile === file) throw new WorkerError('answers-same-file', `${file} cannot answer its own findings: give the file that resolves them`);
|
|
446
|
+
const worker = liveWorker(repoRoot);
|
|
447
|
+
if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
|
|
448
|
+
say({ status: 'worker_down', file });
|
|
449
|
+
return 3;
|
|
450
|
+
}
|
|
451
|
+
const recorded = await blockTreeOf(worker.run, block);
|
|
452
|
+
const tree = recorded === null ? top : (recorded.top ?? repoRoot);
|
|
453
|
+
assertInsideRoot(tree, file);
|
|
454
|
+
assertInsideRoot(tree, byFile);
|
|
455
|
+
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
456
|
+
await answersPrecheck({ runId: worker.run, block, file, byFile, tree, answers, treeName: where.tree === undefined ? null : realpathSync(tree) });
|
|
457
|
+
const opts = { repoRoot, run: worker.run, block, file, byFile, answers, ...where };
|
|
458
|
+
const queued = enqueueAnswers(opts);
|
|
459
|
+
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
|
|
460
|
+
rmSync(path.join(queueDir(repoRoot), `${queued.ticket}.done`), { force: true });
|
|
461
|
+
say({ ...enqueueAnswers(opts), kind: 'answers', retry: true });
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
|
464
|
+
say({ ...queued, kind: 'answers' });
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
|
|
217
468
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
218
469
|
export default async function run(args) {
|
|
219
470
|
return runReviewFile(args);
|