@codedology/code-forge 0.7.7 → 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 +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- 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 +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
package/src/cli/report.mjs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
|
|
3
|
-
* only, plus an optional `--export <dir>` that writes the same
|
|
3
|
+
* only, plus an optional `--export <dir>` that writes the same 14 sections as JSON files. The
|
|
4
4
|
* terminal output ends with the spend per run (B33) and the mode of each run (B59: brief or plan);
|
|
5
|
-
* both are text only, never in `--json` or `--export
|
|
5
|
+
* both are text only, never in `--json` or `--export`. The 14th section, `outside_review` (B66), is
|
|
6
|
+
* printed as text lines under its header (`outside review: none`, or one line per PR).
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { writeSafe } from '../util/redact.mjs';
|
|
9
10
|
import { buildReport, buildRunModes, buildSpend } from '../ledger/report.mjs';
|
|
10
11
|
import { exportReportSections } from '../ledger/export.mjs';
|
|
12
|
+
import { outsideReviewLines } from '../ledger/outside-review.mjs';
|
|
11
13
|
import { readAllRows } from '../ledger/write.mjs';
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -60,7 +62,8 @@ export async function runReport(args, deps = {}) {
|
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
for (const [name, data] of Object.entries(sections)) {
|
|
63
|
-
|
|
65
|
+
const body = name === 'outside_review' ? outsideReviewLines(data) : `${JSON.stringify(data, null, 2)}\n`;
|
|
66
|
+
out(`== ${name} ==\n${body}`);
|
|
64
67
|
}
|
|
65
68
|
out(spendLines(buildSpend(rows)));
|
|
66
69
|
out(modeLines(buildRunModes(rows)));
|
|
@@ -72,7 +75,7 @@ export async function runReport(args, deps = {}) {
|
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
/**
|
|
75
|
-
* B33: the running spend per run (open blocks included), after the
|
|
78
|
+
* B33: the running spend per run (open blocks included), after the 14 sections. Unknown-price
|
|
76
79
|
* sessions are named, never priced.
|
|
77
80
|
* @param {ReturnType<typeof buildSpend>} spend @returns {string}
|
|
78
81
|
*/
|
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,17 +27,60 @@
|
|
|
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.
|
|
40
|
+
*
|
|
41
|
+
* B62b: the queue and the worker are always the MAIN tree's (`mainRootOf`), also when the coder
|
|
42
|
+
* runs `review-file` inside a block's worktree. For a block the run record gives a worktree
|
|
43
|
+
* (`proof.isolation: worktree`), the content is hashed in that tree — never in the main tree — and
|
|
44
|
+
* the ticket and the answer carry `tree: <tree top level>`; the worker reviews the same tree. The
|
|
45
|
+
* path is still given relative to the current directory, inside the tree it is called from, and is
|
|
46
|
+
* re-checked in the tree the content is read from. A block the record gives no worktree is hashed
|
|
47
|
+
* in the main tree, wherever `review-file` is called. When the run record does not exist here or
|
|
48
|
+
* cannot be opened (a sandboxed coder), the content is hashed in the tree `review-file` is called
|
|
49
|
+
* from, which is the block's tree when the coder works there. A record naming a worktree that is
|
|
50
|
+
* gone or malformed is refused (exit 1, `reason: worktree-missing|worktree-malformed|…`), and
|
|
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.
|
|
26
65
|
*/
|
|
27
66
|
|
|
28
|
-
import { readFileSync, rmSync } from 'node:fs';
|
|
67
|
+
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
29
68
|
import path from 'node:path';
|
|
30
|
-
import { enqueue, isDone, liveWorker, queueDir } from '../worker/queue.mjs';
|
|
31
|
-
import { assertBlockId, assertTicketId, 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';
|
|
32
75
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
76
|
+
import { StateError } from '../state/paths.mjs';
|
|
77
|
+
import { readRun } from '../state/run.mjs';
|
|
78
|
+
import { blockTree } from '../proof/worktree.mjs';
|
|
33
79
|
import { writeSafe } from '../util/redact.mjs';
|
|
34
80
|
|
|
35
|
-
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';
|
|
36
84
|
|
|
37
85
|
export const DEFAULT_WAIT_MS = 90_000;
|
|
38
86
|
const POLL_MS = 200;
|
|
@@ -54,17 +102,223 @@ export function parseMax(raw) {
|
|
|
54
102
|
*/
|
|
55
103
|
function doneResult(repoRoot, ticket) {
|
|
56
104
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
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 : '';
|
|
59
119
|
if (!rel.startsWith(`.code-forge${path.sep}reviews${path.sep}`) || rel.split(path.sep).includes('..')) return null;
|
|
60
|
-
|
|
120
|
+
const result = JSON.parse(readFileSync(path.join(repoRoot, rel), 'utf8'));
|
|
121
|
+
return result !== null && typeof result === 'object' && !Array.isArray(result) ? result : null;
|
|
61
122
|
} catch {
|
|
62
123
|
return null;
|
|
63
124
|
}
|
|
64
125
|
}
|
|
65
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
|
+
|
|
66
160
|
/**
|
|
67
|
-
*
|
|
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
|
+
|
|
231
|
+
/**
|
|
232
|
+
* B62b: where the block `block` of run `runId` keeps its content, from the run record (read from
|
|
233
|
+
* the user's state dir, `~/.code-forge/runs`, whatever the current directory): `{top}` with the
|
|
234
|
+
* top level of its worktree, realpath'd; `{top: null}` when the record gives the block no worktree
|
|
235
|
+
* (then the main tree, never the tree `review-file` is called from); null when the record does
|
|
236
|
+
* not exist here or cannot be opened (a sandboxed coder: no such file, or no permission) — only
|
|
237
|
+
* then is the content hashed in the tree `review-file` is called from, and the worker refuses a
|
|
238
|
+
* ticket hashed in the wrong tree. A record that names a worktree which cannot be used (gone,
|
|
239
|
+
* moved, malformed, off its branch) is refused, and so is a record that cannot be parsed or is not
|
|
240
|
+
* a record (`run-record-unreadable`): nothing is queued. Any other failure is rethrown.
|
|
241
|
+
* @param {string} runId @param {string} block
|
|
242
|
+
* @returns {Promise<{top: string | null} | null>}
|
|
243
|
+
* @throws {WorkerError} a `worktree-*` code, or `run-record-unreadable`.
|
|
244
|
+
*/
|
|
245
|
+
async function blockTreeOf(runId, block) {
|
|
246
|
+
let record;
|
|
247
|
+
try {
|
|
248
|
+
record = await readRun(runId);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const code = /** @type {{code?: unknown}} */ (err)?.code;
|
|
251
|
+
if (code === 'no-run' || code === 'EACCES' || code === 'EPERM') return null;
|
|
252
|
+
if (!(err instanceof SyntaxError)) throw err;
|
|
253
|
+
record = null;
|
|
254
|
+
}
|
|
255
|
+
if (record === null || typeof record !== 'object') throw new WorkerError('run-record-unreadable', `the run record of ${runId} cannot be read (corrupt or not a record); nothing was queued`);
|
|
256
|
+
try {
|
|
257
|
+
return { top: (await blockTree(record.workspace, record.blocks?.[block]))?.top ?? null };
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err instanceof StateError) throw new WorkerError(err.code, `block ${block}: ${err.message}; nothing was queued`);
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
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.
|
|
68
322
|
* @param {Record<string, any> | null} result
|
|
69
323
|
* @returns {Record<string, any>}
|
|
70
324
|
*/
|
|
@@ -72,6 +326,10 @@ export function fixList(result) {
|
|
|
72
326
|
if (!result || result.approved === true) return {};
|
|
73
327
|
/** @type {Record<string, any>} */
|
|
74
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
|
+
}
|
|
75
333
|
if (Array.isArray(result.findings) && result.findings.length > 0) {
|
|
76
334
|
out.fix_list = result.findings.map((/** @type {Record<string, any>} */ f) => ({
|
|
77
335
|
id: f?.id ?? null,
|
|
@@ -81,8 +339,11 @@ export function fixList(result) {
|
|
|
81
339
|
fix: f?.fix ?? '',
|
|
82
340
|
}));
|
|
83
341
|
}
|
|
84
|
-
if (typeof result.stopped === 'string')
|
|
85
|
-
|
|
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';
|
|
86
347
|
// B54: a split_required stop (only) says how large the packet was, the budget, and the section
|
|
87
348
|
if (out.stop === 'split_required') {
|
|
88
349
|
const fin = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
@@ -93,7 +354,7 @@ export function fixList(result) {
|
|
|
93
354
|
|
|
94
355
|
/**
|
|
95
356
|
* @param {string[]} args
|
|
96
|
-
* @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.
|
|
97
358
|
* @returns {Promise<number>}
|
|
98
359
|
*/
|
|
99
360
|
export async function runReviewFile(args, deps = {}) {
|
|
@@ -102,11 +363,12 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
102
363
|
const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
|
|
103
364
|
const cwd = deps.cwd ?? process.cwd();
|
|
104
365
|
try {
|
|
105
|
-
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max'] });
|
|
106
|
-
|
|
366
|
+
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
367
|
+
// the queue and the worker: the main tree, also from inside a block's worktree
|
|
368
|
+
const repoRoot = await mainRootOf(cwd);
|
|
107
369
|
|
|
108
370
|
if (typeof flags.wait === 'string') {
|
|
109
|
-
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');
|
|
110
372
|
const ticket = assertTicketId(flags.wait);
|
|
111
373
|
const maxMs = flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max);
|
|
112
374
|
const deadline = Date.now() + maxMs;
|
|
@@ -132,18 +394,27 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
132
394
|
|
|
133
395
|
if (positionals.length !== 1 || typeof flags.block !== 'string' || flags.max !== undefined) throw new WorkerError('usage', 'give one path and --block');
|
|
134
396
|
const block = assertBlockId(flags.block);
|
|
135
|
-
|
|
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 });
|
|
399
|
+
const file = normalizeRequestPath(positionals[0], cwd, await repoRootOf(cwd));
|
|
136
400
|
const worker = liveWorker(repoRoot);
|
|
137
401
|
if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
|
|
138
402
|
say({ status: 'worker_down', file });
|
|
139
403
|
return 3;
|
|
140
404
|
}
|
|
141
|
-
|
|
405
|
+
// the content: the block's worktree when the record gives one, the main tree when it gives
|
|
406
|
+
// none; only a record that cannot be opened here falls back to the tree review-file is called from
|
|
407
|
+
const recorded = await blockTreeOf(worker.run, block);
|
|
408
|
+
const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
|
|
409
|
+
assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
|
|
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 } : {}) });
|
|
412
|
+
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
142
413
|
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
|
|
143
414
|
// `unavailable` is never approval AND never final: drop the done marker so the worker runs
|
|
144
415
|
// the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
|
|
145
416
|
rmSync(path.join(queueDir(repoRoot), `${queued.ticket}.done`), { force: true });
|
|
146
|
-
say({ ...enqueue({ repoRoot, run: worker.run, block, file }), retry: true });
|
|
417
|
+
say({ ...enqueue({ repoRoot, run: worker.run, block, file, ...where }), retry: true });
|
|
147
418
|
return 0;
|
|
148
419
|
}
|
|
149
420
|
say(queued);
|
|
@@ -159,6 +430,41 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
159
430
|
}
|
|
160
431
|
}
|
|
161
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
|
+
|
|
162
468
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
163
469
|
export default async function run(args) {
|
|
164
470
|
return runReviewFile(args);
|