@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open review findings of a block (B64a `code-forge status`, B64b done file): the number of
|
|
3
|
+
* distinct (file, finding) pairs the block gate (`checkBlockReviews`) would refuse today, each
|
|
4
|
+
* reviewed file at the content hash of its last review row. Shared by `src/cli/status.mjs` and
|
|
5
|
+
* `src/cli/block.mjs`, so the done file and `status` count the same way.
|
|
6
|
+
*/
|
|
7
|
+
import { KEY_BYTES, loadKey } from '../state/signer.mjs';
|
|
8
|
+
import { checkBlockReviews, reviewedFiles } from './gate-check.mjs';
|
|
9
|
+
|
|
10
|
+
/** The refusal codes that name one open review finding. */
|
|
11
|
+
const FINDING_CODES = new Set(['not_approved', 'late_unruled', 'review_cap', 'autopilot_waiver_invalid']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The open review findings of one block: the gate's own check over the files the block's review
|
|
15
|
+
* rows name, each at the content hash it was last reviewed at.
|
|
16
|
+
* @param {{block: string, runId: string, mine: Array<Record<string, any>>, rows: Array<Record<string, any>>, key: Buffer}} opts
|
|
17
|
+
* `mine`: the block's rows of the run; `rows`: every row of the project (waivers, rulings).
|
|
18
|
+
* @returns {number}
|
|
19
|
+
*/
|
|
20
|
+
export function openFindingsOf({ block, runId, mine, rows, key }) {
|
|
21
|
+
/** @type {Map<string, string>} each reviewed file -> the content hash of its last review row */
|
|
22
|
+
const hashes = new Map();
|
|
23
|
+
for (const r of mine) {
|
|
24
|
+
if (typeof r.content_hash !== 'string') continue;
|
|
25
|
+
const [file] = reviewedFiles([r], block); // a review row naming a repo path, else none
|
|
26
|
+
if (file !== undefined) hashes.set(file, r.content_hash);
|
|
27
|
+
}
|
|
28
|
+
const files = reviewedFiles(mine, block).map((file) => ({ file, content_hash: hashes.get(file) ?? '' }));
|
|
29
|
+
if (files.length === 0 && !mine.some((r) => r.event === 'review.late_finding')) return 0;
|
|
30
|
+
const { refusals } = checkBlockReviews({ block, runId, files, rows, key, proofFiles: [], highPaths: [] });
|
|
31
|
+
const pairs = new Set(refusals.filter((r) => FINDING_CODES.has(r.code) && typeof r.finding === 'string').map((r) => `${r.file}\0${r.finding}`));
|
|
32
|
+
return pairs.size;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The run key for counting findings: read only; with no key a zero key stands in, so no signed
|
|
37
|
+
* waiver or approval verifies (fail closed).
|
|
38
|
+
* @param {Record<string, any>} record @returns {Promise<Buffer>}
|
|
39
|
+
*/
|
|
40
|
+
export async function findingsKey(record) {
|
|
41
|
+
try {
|
|
42
|
+
return await loadKey(record.run_id);
|
|
43
|
+
} catch {
|
|
44
|
+
return Buffer.alloc(KEY_BYTES);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* B64b: the open findings `status` shows for ONE block — the same count (`openFindingsOf`, the
|
|
50
|
+
* same key rule), for the block-end done file.
|
|
51
|
+
* @param {Record<string, any>} record @param {Array<Record<string, any>>} rows - the project's rows.
|
|
52
|
+
* @param {string} block
|
|
53
|
+
* @returns {Promise<number>}
|
|
54
|
+
*/
|
|
55
|
+
export async function blockOpenFindings(record, rows, block) {
|
|
56
|
+
const runId = String(record.run_id);
|
|
57
|
+
const mine = rows.filter((r) => r?.run === runId && r.block === block);
|
|
58
|
+
return openFindingsOf({ block, runId, mine, rows, key: await findingsKey(record) });
|
|
59
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B72 (issues #12, #15): `block reopen <id> --file <path> --run <r> --reason <text>` — the human
|
|
3
|
+
* restarts the review of a file the fix loop STOPPED (any reason: `review_cap`,
|
|
4
|
+
* `l3_patch_exhausted`, `split_required`, …) or whose state is LOST (B68). Only a stopped file
|
|
5
|
+
* needs content that differs from what its stop reviewed; a lost file restarts on its current
|
|
6
|
+
* content.
|
|
7
|
+
*
|
|
8
|
+
* Human-only like `block waive`: the verb is on `CODER_ONLY_FORBIDDEN` (`src/util/forbidden.mjs`)
|
|
9
|
+
* and its row is signed with the run key, `by: human` (`block waive` has no other in-verb guard,
|
|
10
|
+
* so neither has this). The verb (`reopenFile`) refuses, writing no row, with a stable code:
|
|
11
|
+
* - `usage` (exit 2) — no `--file`, no `--reason`, or a path that is not a normalised
|
|
12
|
+
* repo-root-relative path;
|
|
13
|
+
* - `unknown_block` — the run does not know the block (the code `block waive` uses);
|
|
14
|
+
* - `reopen-block-not-open` — the block is not open (stopped or closed);
|
|
15
|
+
* - `reopen-not-owned` — the block does not own the file;
|
|
16
|
+
* - `reopen-tampered` — the file's state does not verify (an integrity failure stays a security
|
|
17
|
+
* event; the owner's exit is `block close --no-require-reviews`);
|
|
18
|
+
* - `reopen-not-stopped` — no state, or a state that is open or complete;
|
|
19
|
+
* - `reopen-same-content` — a stopped file whose current content is the content its stop
|
|
20
|
+
* reviewed (no free re-roll: the human waives the findings instead), or a stop whose content
|
|
21
|
+
* is unknown;
|
|
22
|
+
* - `reopen-already-recorded` — a verified human row already names the file's latest anchor seq
|
|
23
|
+
* for other content (run `review-file`, or the content changed since that reopen);
|
|
24
|
+
* - `reopen-write-failed` — the signed row was not written.
|
|
25
|
+
* Accepted, it first drops the done marker of the current content's ticket (C11, `io.clearDone`),
|
|
26
|
+
* then writes ONE signed `review.reopened {block, file, from: stopped|lost, stop_reason, at_seq,
|
|
27
|
+
* from_hash, to_hash, reason, by: human}` row (`at_seq` = the file's latest anchor seq). There is
|
|
28
|
+
* at most one verified human row per `at_seq`: a retry on the same content finds it and writes
|
|
29
|
+
* none.
|
|
30
|
+
*
|
|
31
|
+
* The content a stop reviewed (`stopContentHash`) is the stopped state's `stop_content_hash`,
|
|
32
|
+
* which the worker sets on every stop (`src/worker/engine.mjs`) and which is signed through the
|
|
33
|
+
* anchor. A state from before B72 has none: then the latest verified `review.cap` row of THIS
|
|
34
|
+
* stop counts — written after the file's latest verified `review.reopened` row, with the state's
|
|
35
|
+
* round and stop reason — else the content is unknown and the reopen is refused.
|
|
36
|
+
*
|
|
37
|
+
* The worker (`fixLoopRound`) asks `reopenRowFor`: a verified human row of this run for the block
|
|
38
|
+
* and file whose `at_seq` is the file's latest anchor seq starts a new state (round 1, a FULL
|
|
39
|
+
* review, `reviewed_content` null, the level from the run record, `l3_rung_used` kept). The new
|
|
40
|
+
* state saves at `at_seq + 1`, so a row works once. For `stopped` the content must still differ
|
|
41
|
+
* from the stop's content; else the stop repeats.
|
|
42
|
+
*
|
|
43
|
+
* This file imports nothing from `src/worker/`: the fix-loop state is read through `io`.
|
|
44
|
+
*/
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import { StateError } from '../state/paths.mjs';
|
|
47
|
+
import { verifyRow } from '../state/signer.mjs';
|
|
48
|
+
|
|
49
|
+
export const REOPENED_EVENT = 'review.reopened';
|
|
50
|
+
|
|
51
|
+
/** A code a refusal may name: an errno (`EACCES`), a StateError code (`ledger-locked`), a name. */
|
|
52
|
+
const CAUSE_CODE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* B79: the code a failed write names — the error's `code`, else its `name`, when it has the form
|
|
56
|
+
* {@link CAUSE_CODE}, else `unknown` (a thrown `undefined` or `null` too). Called only for a
|
|
57
|
+
* write that threw; one that wrote nothing is named `no-write` by its caller. Never the message:
|
|
58
|
+
* it may hold a path or a value.
|
|
59
|
+
* @param {unknown} err @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function causeCode(err) {
|
|
62
|
+
if (err === null || err === undefined) return 'unknown';
|
|
63
|
+
const e = /** @type {{code?: unknown, name?: unknown}} */ (err);
|
|
64
|
+
if (typeof e.code === 'string' && CAUSE_CODE.test(e.code)) return e.code;
|
|
65
|
+
if (typeof e.name === 'string' && CAUSE_CODE.test(e.name)) return e.name;
|
|
66
|
+
return 'unknown';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether a row's MAC verifies with the run key (a throw is a no).
|
|
71
|
+
* @param {Record<string, any>} row @param {Buffer} key
|
|
72
|
+
*/
|
|
73
|
+
function verified(row, key) {
|
|
74
|
+
try {
|
|
75
|
+
return verifyRow(row, key).ok === true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The content hash the stop of a stopped state reviewed, or null when it is unknown: the state's
|
|
83
|
+
* `stop_content_hash`; for a state from before B72, the latest verified `review.cap` row of this
|
|
84
|
+
* run for the block and file that belongs to this stop (after the file's latest verified
|
|
85
|
+
* `review.reopened` row, with the state's round and stop reason).
|
|
86
|
+
* @param {Record<string, any> | null} state
|
|
87
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
88
|
+
* @param {{runId: string, block: string, file: string, key: Buffer}} at
|
|
89
|
+
* @returns {string | null}
|
|
90
|
+
*/
|
|
91
|
+
export function stopContentHash(state, rows, { runId, block, file, key }) {
|
|
92
|
+
if (state?.status !== 'stopped') return null;
|
|
93
|
+
if (typeof state.stop_content_hash === 'string' && state.stop_content_hash.length > 0) return state.stop_content_hash;
|
|
94
|
+
const mine = (/** @type {Record<string, any>} */ r) => r?.run === runId && r.block === block && r.file === file && verified(r, key);
|
|
95
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
96
|
+
const r = rows[i];
|
|
97
|
+
if (r?.event === REOPENED_EVENT && mine(r)) return null;
|
|
98
|
+
if (r?.event !== 'review.cap' || !mine(r)) continue;
|
|
99
|
+
if (typeof r.content_hash !== 'string' || r.round !== state.round || r.reason !== state.next?.reason) return null;
|
|
100
|
+
return r.content_hash;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The block checks every reopen runs first, before any config, key or ledger read: the run must
|
|
107
|
+
* know the block (`unknown_block`, the code `block waive` uses) and the block must be open.
|
|
108
|
+
* @param {Record<string, any>} record @param {string} runId @param {string} id
|
|
109
|
+
* @returns {Record<string, any>} the block's entry.
|
|
110
|
+
* @throws {StateError} `unknown_block` or `reopen-block-not-open`.
|
|
111
|
+
*/
|
|
112
|
+
export function openBlockEntry(record, runId, id) {
|
|
113
|
+
const entry = record.blocks?.[id];
|
|
114
|
+
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
115
|
+
if (entry.status !== 'open') throw new StateError('reopen-block-not-open', `reopen-block-not-open: block ${id} is not open in run ${runId}; nothing was recorded`);
|
|
116
|
+
return entry;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The latest verified human `review.reopened` row of this run for the block and file whose
|
|
121
|
+
* `at_seq` is `seq`, or null.
|
|
122
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
123
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, seq: number}} at
|
|
124
|
+
* @returns {Record<string, any> | null}
|
|
125
|
+
*/
|
|
126
|
+
function latestRowAt(rows, { runId, block, file, key, seq }) {
|
|
127
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
128
|
+
const r = rows[i];
|
|
129
|
+
if (r?.event !== REOPENED_EVENT || r.run !== runId || r.block !== block || r.file !== file) continue;
|
|
130
|
+
if (r.by !== 'human' || r.at_seq !== seq || !verified(r, key)) continue;
|
|
131
|
+
return r;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Worker side: the `review.reopened` row that restarts this file now, or null. Only a row whose MAC
|
|
138
|
+
* verifies with the run key, of this run, block and file, `by: human`, and whose `at_seq` is the
|
|
139
|
+
* file's latest anchor seq counts (the latest such row). A `lost` state needs `from: lost`; a
|
|
140
|
+
* stopped state needs `from: stopped` AND current content that is neither the row's `from_hash`
|
|
141
|
+
* nor the content the stop reviewed (`stopContentHash`; unknown ⇒ no restart).
|
|
142
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
143
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, status: string, state: Record<string, any> | null, seq: number, contentHash: string}} at
|
|
144
|
+
* @returns {Record<string, any> | null}
|
|
145
|
+
*/
|
|
146
|
+
export function reopenRowFor(rows, { runId, block, file, key, status, state, seq, contentHash }) {
|
|
147
|
+
const row = latestRowAt(rows, { runId, block, file, key, seq });
|
|
148
|
+
if (row === null) return null;
|
|
149
|
+
if (status === 'lost') return row.from === 'lost' ? row : null;
|
|
150
|
+
if ((status === 'ok' || status === 'restored') && state?.status === 'stopped' && row.from === 'stopped') {
|
|
151
|
+
const stopHash = stopContentHash(state, rows, { runId, block, file, key });
|
|
152
|
+
if (stopHash === null || contentHash === stopHash || contentHash === row.from_hash) return null;
|
|
153
|
+
return row;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The verb (`block reopen`): check, drop the done marker (`io.clearDone`), then write the signed
|
|
160
|
+
* row — or return the verified human row an earlier try wrote for the same `at_seq` and `to_hash`
|
|
161
|
+
* (at most one row per `at_seq`: one for other content is refused `reopen-already-recorded`).
|
|
162
|
+
* @param {{
|
|
163
|
+
* runId: string, id: string, file: unknown, reason: unknown,
|
|
164
|
+
* record: Record<string, any>, rows: ReadonlyArray<Record<string, any>>, key: Buffer,
|
|
165
|
+
* writeRow: (row: Record<string, any>) => Promise<unknown>,
|
|
166
|
+
* writeSigned: (runId: string, writeRow: (row: Record<string, any>) => Promise<unknown>, row: Record<string, any>) => Promise<unknown>,
|
|
167
|
+
* io: {
|
|
168
|
+
* owns: (owned: ReadonlyArray<string>, file: string, kinds?: Readonly<Record<string, string>>) => boolean,
|
|
169
|
+
* loadState: (where: Record<string, any>) => {status: string, state: Record<string, any> | null, seq: number},
|
|
170
|
+
* contentHash: (file: string) => string,
|
|
171
|
+
* clearDone: (file: string) => boolean,
|
|
172
|
+
* },
|
|
173
|
+
* }} opts
|
|
174
|
+
* @returns {Promise<{row: Record<string, any>, cleared: boolean, existing: boolean}>}
|
|
175
|
+
* @throws {StateError} `usage` or a refusal code (see the module doc).
|
|
176
|
+
*/
|
|
177
|
+
export async function reopenFile({ runId, id, file, reason, record, rows, key, writeRow, writeSigned, io }) {
|
|
178
|
+
if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block reopen needs --file <path>');
|
|
179
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block reopen needs --reason');
|
|
180
|
+
if (path.isAbsolute(file) || file.split('/').includes('..') || file.startsWith('./') || path.posix.normalize(file) !== file) {
|
|
181
|
+
throw new StateError('usage', '--file must be repo-root-relative');
|
|
182
|
+
}
|
|
183
|
+
const entry = openBlockEntry(record, runId, id);
|
|
184
|
+
const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
|
|
185
|
+
if (!io.owns(owned, file, entry.owned_kinds)) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|
|
186
|
+
|
|
187
|
+
const runRows = rows.filter((r) => r?.run === runId);
|
|
188
|
+
const loaded = io.loadState({ runId, block: id, file, key, rows: runRows });
|
|
189
|
+
if (loaded.status === 'tampered') {
|
|
190
|
+
throw new StateError('reopen-tampered', `reopen-tampered: the review state of ${file} does not verify (a security event, not a stop); the owner's way out is block close --no-require-reviews; nothing was recorded`);
|
|
191
|
+
}
|
|
192
|
+
const current = io.contentHash(file);
|
|
193
|
+
/** @type {'stopped' | 'lost'} */
|
|
194
|
+
let from;
|
|
195
|
+
/** @type {string | null} */
|
|
196
|
+
let stopReason = null;
|
|
197
|
+
/** @type {string | null} */
|
|
198
|
+
let fromHash = null;
|
|
199
|
+
if (loaded.status === 'lost') {
|
|
200
|
+
from = 'lost';
|
|
201
|
+
} else if ((loaded.status === 'ok' || loaded.status === 'restored') && loaded.state?.status === 'stopped') {
|
|
202
|
+
from = 'stopped';
|
|
203
|
+
stopReason = typeof loaded.state.next?.reason === 'string' ? loaded.state.next.reason : 'stopped';
|
|
204
|
+
fromHash = stopContentHash(loaded.state, runRows, { runId, block: id, file, key });
|
|
205
|
+
if (fromHash === null) {
|
|
206
|
+
throw new StateError('reopen-same-content', `reopen-same-content: the content the stop of ${file} reviewed is unknown, so a change cannot be shown; waive its findings instead (block waive); nothing was recorded`);
|
|
207
|
+
}
|
|
208
|
+
if (fromHash === current) {
|
|
209
|
+
throw new StateError('reopen-same-content', `reopen-same-content: ${file} still has the content its stop (${stopReason}) reviewed; change the file first, or waive its findings (block waive); nothing was recorded`);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
const what = loaded.status === 'none' || loaded.state === null ? 'has no review state' : `is ${loaded.state.status}`;
|
|
213
|
+
throw new StateError('reopen-not-stopped', `reopen-not-stopped: ${file} ${what}; only a stopped or lost file is reopened; nothing was recorded`);
|
|
214
|
+
}
|
|
215
|
+
if (!Number.isInteger(loaded.seq) || loaded.seq < 0) {
|
|
216
|
+
throw new StateError('reopen-tampered', `reopen-tampered: the review state of ${file} has no valid anchor seq; nothing was recorded`);
|
|
217
|
+
}
|
|
218
|
+
// one verified human row per at_seq: the same content reuses it, other content is refused
|
|
219
|
+
const earlier = latestRowAt(runRows, { runId, block: id, file, key, seq: loaded.seq });
|
|
220
|
+
if (earlier !== null && earlier.to_hash !== current) {
|
|
221
|
+
throw new StateError('reopen-already-recorded', `reopen-already-recorded: ${file} was already reopened at state seq ${loaded.seq} for other content; run review-file, or the content changed since the reopen; nothing was recorded`);
|
|
222
|
+
}
|
|
223
|
+
// C11: the cached result of the current content's ticket would repeat the stop — dropped FIRST,
|
|
224
|
+
// so a failed row write leaves at worst a re-run that returns the same stop, never a row with a
|
|
225
|
+
// stale marker
|
|
226
|
+
const cleared = io.clearDone(file);
|
|
227
|
+
if (earlier !== null) return { row: earlier, cleared, existing: true };
|
|
228
|
+
const row = { event: REOPENED_EVENT, block: id, file, from, stop_reason: stopReason, at_seq: loaded.seq, from_hash: fromHash, to_hash: current, reason: reason.trim(), by: 'human' };
|
|
229
|
+
/** @type {Record<string, any> | null} */
|
|
230
|
+
let written = null;
|
|
231
|
+
let signedOut = null;
|
|
232
|
+
/** @type {unknown} */
|
|
233
|
+
let failure = null;
|
|
234
|
+
// a thrown `undefined` (or null) is still a throw: `threw`, not the value, says the write threw
|
|
235
|
+
let threw = false;
|
|
236
|
+
try {
|
|
237
|
+
signedOut = await writeSigned(runId, async (signed) => {
|
|
238
|
+
const result = await writeRow(signed);
|
|
239
|
+
// the ledger's appendRow resolves with the row it wrote; nothing (or false) is no write
|
|
240
|
+
if (result === null || result === undefined || result === false) return result;
|
|
241
|
+
written = signed;
|
|
242
|
+
return result;
|
|
243
|
+
}, row);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
written = null;
|
|
246
|
+
failure = err;
|
|
247
|
+
threw = true;
|
|
248
|
+
}
|
|
249
|
+
if (written === null || signedOut === null || signedOut === undefined || signedOut === false) {
|
|
250
|
+
// B79: the cause is kept (`cause`) and named by its code only — never a path, key or config value
|
|
251
|
+
const refused = new StateError('reopen-write-failed', `reopen-write-failed: the signed review.reopened row for ${file} was not written (${threw ? causeCode(failure) : 'no-write'}); run block reopen again`);
|
|
252
|
+
// StateError takes (code, message) only (src/state/paths.mjs is not this block's): `cause` is
|
|
253
|
+
// set here, non-enumerable like a native one, so a serializer never emits the cause's path
|
|
254
|
+
if (threw) Object.defineProperty(refused, 'cause', { value: failure, enumerable: false, configurable: true, writable: true });
|
|
255
|
+
throw refused;
|
|
256
|
+
}
|
|
257
|
+
return { row: written, cleared, existing: false };
|
|
258
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B73 (issue #12): the ways out of a file the fix loop stopped (`review_cap`,
|
|
3
|
+
* `l3_patch_exhausted`, `split_required`), in this order — `block reopen` after a fix,
|
|
4
|
+
* `block waive` for each open finding, `review-file --answers` for a fix made in another owned
|
|
5
|
+
* file, or re-decompose the block. `review-file`'s `stopped` answer carries them as `exits`; the
|
|
6
|
+
* gate appends {@link stopExitsLine} to its `review_cap` and `unreviewed` detail for a stopped
|
|
7
|
+
* file. A `tampered` review state is not a stop (it is refused; the owner's way out is
|
|
8
|
+
* `block close --no-require-reviews`) and never gets them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @type {ReadonlyArray<string>} */
|
|
12
|
+
export const STOP_EXITS = Object.freeze(['block reopen', 'block waive', 'review-file --answers', 're-decompose']);
|
|
13
|
+
|
|
14
|
+
/** The one short line the gate appends to a stopped file's detail. */
|
|
15
|
+
export const stopExitsLine = `exits: ${STOP_EXITS.join(', ')}`;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B64b (issue #13): the block-end signal — a done file and the owner's `hooks.on_block_end` hook.
|
|
3
|
+
*
|
|
4
|
+
* A block ends where `src/state/block.mjs` sets its status to `closed` or `stopped`: `closeBlock`
|
|
5
|
+
* closing it, `closeBlock` stopping it (a pinned worker event or `ledger.tamper`), and `stopBlock`
|
|
6
|
+
* (`block stop`). After each end — its ledger rows written and the run lock released — the CLI
|
|
7
|
+
* (`src/cli/block.mjs`):
|
|
8
|
+
*
|
|
9
|
+
* 1. writes `<runs>/<run>/blocks/<id>.done.json` ({@link doneFilePath}) atomically (a temp file in
|
|
10
|
+
* the same directory, then a rename; a second end overwrites it) with {@link doneFileContent}:
|
|
11
|
+
* the block, the run, its status, the end's event and reason, the HEAD commit of the block's
|
|
12
|
+
* tree, the block's own spend and its open findings, and the time. Never transcript text, diff
|
|
13
|
+
* text or secrets. A failed write changes nothing but one stderr warning line.
|
|
14
|
+
* 2. runs `hooks.on_block_end` (`blockEndHook` in `config-snapshot.mjs`, from the run-start
|
|
15
|
+
* config snapshot only — the key is immutable) once, outside the run lock: argv only (no
|
|
16
|
+
* shell), cwd the repository top, stdin closed, stdout and stderr discarded, the parent env plus
|
|
17
|
+
* `CODE_FORGE_RUN`, `CODE_FORGE_BLOCK`, `CODE_FORGE_BLOCK_STATUS` and `CODE_FORGE_DONE_FILE`;
|
|
18
|
+
* its process group is killed at `timeout_s`. One signed `hook.ran` row records it (under the run lock). A hook
|
|
19
|
+
* that fails, is missing, times out or crashes changes nothing but one stderr warning line.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { runSpend } from '../ledger/spend.mjs';
|
|
26
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
27
|
+
import { exec } from '../util/exec.mjs';
|
|
28
|
+
import { assertRunId, runsDir } from './paths.mjs';
|
|
29
|
+
import { withRunLock, writeSigned } from './run.mjs';
|
|
30
|
+
|
|
31
|
+
// B64b: the hook's config reader lives next to `externalReviewer` (read by `state/block.mjs` too)
|
|
32
|
+
export { HOOK_TIMEOUT_DEFAULT_S, HOOK_TIMEOUT_MAX_S, HOOK_TIMEOUT_MIN_S, blockEndHook } from './config-snapshot.mjs';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The block's done file: `<runs>/<run>/blocks/<id>.done.json` (the run's own directory under the
|
|
36
|
+
* runs dir, next to the run record — never in the source tree).
|
|
37
|
+
* @param {string} runId @param {string} id
|
|
38
|
+
* @returns {string} an absolute path
|
|
39
|
+
*/
|
|
40
|
+
export function doneFilePath(runId, id) {
|
|
41
|
+
return path.resolve(runsDir(), assertRunId(runId), 'blocks', `${safeId(id)}.done.json`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {string} id @returns {string} the id, refused when it could leave the blocks dir. */
|
|
45
|
+
function safeId(id) {
|
|
46
|
+
if (typeof id !== 'string' || id.length === 0 || id.includes('/') || id.includes('\\') || id === '.' || id === '..' || id.includes('\0')) {
|
|
47
|
+
throw new TypeError(`block id ${JSON.stringify(id)} cannot name a done file`);
|
|
48
|
+
}
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The done file's content, keys in this order. Pure.
|
|
54
|
+
* @param {{
|
|
55
|
+
* block: string, runId: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
|
|
56
|
+
* commit: string | null, spend: {usd: number, unknown: number} | null, findings: number | null, at: Date,
|
|
57
|
+
* }} facts
|
|
58
|
+
* @returns {{block: string, run_id: string, status: string, event: string, reason: string | null, commit: string | null,
|
|
59
|
+
* spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string}}
|
|
60
|
+
*/
|
|
61
|
+
export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at }) {
|
|
62
|
+
return {
|
|
63
|
+
block,
|
|
64
|
+
run_id: runId,
|
|
65
|
+
status,
|
|
66
|
+
event,
|
|
67
|
+
reason: typeof reason === 'string' ? reason : null,
|
|
68
|
+
commit: typeof commit === 'string' && commit.length > 0 ? commit : null,
|
|
69
|
+
// a non-finite spend or a non-integer count is unknown (null) — the keys are never dropped
|
|
70
|
+
spent_usd: spend && Number.isFinite(spend.usd) ? spend.usd : null,
|
|
71
|
+
unknown_usd_sessions: spend && Number.isInteger(spend.unknown) ? spend.unknown : null,
|
|
72
|
+
findings: Number.isInteger(findings) ? findings : null,
|
|
73
|
+
at: at.toISOString(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {string} cwd @returns {Promise<string | null>} HEAD of `cwd`'s repository, or null. */
|
|
78
|
+
async function headCommit(cwd) {
|
|
79
|
+
try {
|
|
80
|
+
const env = {};
|
|
81
|
+
for (const [name, value] of Object.entries(process.env)) if (!name.startsWith('GIT_')) env[name] = value;
|
|
82
|
+
const res = await exec(['git', 'rev-parse', '--verify', 'HEAD'], {
|
|
83
|
+
cwd,
|
|
84
|
+
env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', GIT_TERMINAL_PROMPT: '0' },
|
|
85
|
+
timeoutMs: 10000,
|
|
86
|
+
});
|
|
87
|
+
const sha = res.stdout.trim();
|
|
88
|
+
return res.result === 'ok' && /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Write the block's done file after an end (its ledger rows are already written). Never throws: a
|
|
96
|
+
* failure prints `warning: done file not written: <reason>` on stderr and returns null.
|
|
97
|
+
* @param {{
|
|
98
|
+
* record: Record<string, any>, id: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
|
|
99
|
+
* openFindings: (rows: Record<string, any>[]) => Promise<number>, err: (s: string) => unknown, now?: Date,
|
|
100
|
+
* }} opts - `record` is the run record read before the end (its `workspace` and the block's
|
|
101
|
+
* `worktree` do not change at an end); `openFindings` counts the block's open findings exactly
|
|
102
|
+
* as `code-forge status` does.
|
|
103
|
+
* @returns {Promise<string | null>} the done file's absolute path, or null when it was not written.
|
|
104
|
+
*/
|
|
105
|
+
export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date() }) {
|
|
106
|
+
const runId = String(record?.run_id);
|
|
107
|
+
try {
|
|
108
|
+
const file = doneFilePath(runId, id);
|
|
109
|
+
/** @type {Record<string, any>[] | null} */
|
|
110
|
+
let rows = null;
|
|
111
|
+
try {
|
|
112
|
+
rows = await readAllRows(record.project);
|
|
113
|
+
} catch {
|
|
114
|
+
rows = null;
|
|
115
|
+
}
|
|
116
|
+
const spend = rows === null ? null : runSpend(rows.filter((r) => r?.block === id), runId);
|
|
117
|
+
// like the spend: unknown (null) when the rows cannot be read or the count fails — never a 0
|
|
118
|
+
/** @type {number | null} */
|
|
119
|
+
let findings = null;
|
|
120
|
+
if (rows !== null) {
|
|
121
|
+
try {
|
|
122
|
+
const n = await openFindings(rows);
|
|
123
|
+
findings = Number.isInteger(n) && n >= 0 ? n : null;
|
|
124
|
+
} catch {
|
|
125
|
+
findings = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const tree = record.blocks?.[id]?.worktree?.path;
|
|
129
|
+
const commit = await headCommit(typeof tree === 'string' && tree.length > 0 ? tree : record.workspace);
|
|
130
|
+
const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now });
|
|
131
|
+
const dir = path.dirname(file);
|
|
132
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
133
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
134
|
+
try {
|
|
135
|
+
await writeFile(tmp, `${JSON.stringify(content)}\n`, { mode: 0o600 });
|
|
136
|
+
await rename(tmp, file);
|
|
137
|
+
} catch (thrown) {
|
|
138
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
139
|
+
throw thrown;
|
|
140
|
+
}
|
|
141
|
+
return file;
|
|
142
|
+
} catch (thrown) {
|
|
143
|
+
err(`warning: done file not written: ${reasonOf(thrown)}\n`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {unknown} thrown @returns {string} a one-line reason (an error code or message). */
|
|
149
|
+
function reasonOf(thrown) {
|
|
150
|
+
const any = /** @type {any} */ (thrown);
|
|
151
|
+
const text = typeof any?.code === 'string' ? any.code : typeof any?.message === 'string' ? any.message : String(thrown);
|
|
152
|
+
return text.split('\n')[0];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @typedef {{exit: number | null, signal: string | null, timedOut: boolean, notFound: boolean, error: string | null}} SpawnResult
|
|
157
|
+
*/
|
|
158
|
+
|
|
159
|
+
/** @param {unknown} thrown @returns {string} the error's code (`EACCES`, …), else `UNKNOWN` — never its message. */
|
|
160
|
+
function errorCode(thrown) {
|
|
161
|
+
const code = /** @type {any} */ (thrown)?.code;
|
|
162
|
+
return typeof code === 'string' && /^[A-Z0-9_]{1,32}$/.test(code) ? code : 'UNKNOWN';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The warning's tail for a hook that did not exit 0 (ruling round 2 (b)), or null for a clean exit:
|
|
167
|
+
* `exit N`, `timed out`, `not found`, `signal <NAME>` or `failed to start (<code>)`.
|
|
168
|
+
* @param {SpawnResult} ran @returns {string | null}
|
|
169
|
+
*/
|
|
170
|
+
export function hookWarning(ran) {
|
|
171
|
+
if (ran.timedOut) return 'timed out';
|
|
172
|
+
if (ran.notFound) return 'not found';
|
|
173
|
+
if (ran.error !== null) return `failed to start (${ran.error})`;
|
|
174
|
+
if (typeof ran.exit === 'number') return ran.exit === 0 ? null : `exit ${ran.exit}`;
|
|
175
|
+
if (typeof ran.signal === 'string') return `signal ${ran.signal}`;
|
|
176
|
+
return 'failed to start (UNKNOWN)';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Spawn the hook: no shell, stdin closed, stdout and stderr discarded, its own process group,
|
|
181
|
+
* killed (the whole group) at the timeout.
|
|
182
|
+
* @param {string[]} argv @param {{cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number}} opts
|
|
183
|
+
* `notFound` is an ENOENT for `argv[0]` itself; every other spawn error (a missing cwd included —
|
|
184
|
+
* Node names `argv[0]` in that ENOENT too, so the cwd is checked first) is `error`, its code.
|
|
185
|
+
* @returns {Promise<SpawnResult>}
|
|
186
|
+
*/
|
|
187
|
+
async function spawnHook(argv, { cwd, env, timeoutMs }) {
|
|
188
|
+
try {
|
|
189
|
+
if (!(await stat(cwd)).isDirectory()) return { exit: null, signal: null, timedOut: false, notFound: false, error: 'ENOTDIR' };
|
|
190
|
+
} catch (thrown) {
|
|
191
|
+
return { exit: null, signal: null, timedOut: false, notFound: false, error: errorCode(thrown) };
|
|
192
|
+
}
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
let timedOut = false;
|
|
195
|
+
let settled = false;
|
|
196
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
197
|
+
let timer;
|
|
198
|
+
const done = (/** @type {SpawnResult} */ r) => {
|
|
199
|
+
if (settled) return;
|
|
200
|
+
settled = true;
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
resolve(r);
|
|
203
|
+
};
|
|
204
|
+
const failed = (/** @type {unknown} */ thrown) => {
|
|
205
|
+
const any = /** @type {any} */ (thrown);
|
|
206
|
+
const notFound = any?.code === 'ENOENT' && (any?.path === undefined || any.path === argv[0]);
|
|
207
|
+
done({ exit: null, signal: null, timedOut: false, notFound, error: notFound ? null : errorCode(thrown) });
|
|
208
|
+
};
|
|
209
|
+
let child;
|
|
210
|
+
try {
|
|
211
|
+
child = spawn(argv[0], argv.slice(1), { cwd, env, shell: false, detached: true, stdio: ['ignore', 'ignore', 'ignore'] });
|
|
212
|
+
} catch (thrown) {
|
|
213
|
+
failed(thrown);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const pid = child.pid;
|
|
217
|
+
child.on('error', failed);
|
|
218
|
+
child.on('close', (code, signal) => done({ exit: code, signal: signal ?? null, timedOut, notFound: false, error: null }));
|
|
219
|
+
timer = setTimeout(() => {
|
|
220
|
+
timedOut = true;
|
|
221
|
+
try {
|
|
222
|
+
if (typeof pid === 'number' && pid > 1) process.kill(-pid, 'SIGKILL');
|
|
223
|
+
else child.kill('SIGKILL');
|
|
224
|
+
} catch {
|
|
225
|
+
try {
|
|
226
|
+
child.kill('SIGKILL');
|
|
227
|
+
} catch {
|
|
228
|
+
// already gone
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}, timeoutMs);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Run `hooks.on_block_end` once for an end, outside the run lock, then write one signed `hook.ran`
|
|
237
|
+
* row under it (also when the hook failed, timed out or was not found). Never throws, never prints
|
|
238
|
+
* on stdout; a hook that did not exit 0 prints one `warning: on_block_end hook <tail>` line on
|
|
239
|
+
* stderr ({@link hookWarning}). `doneFile` is the empty string when the done file was not written.
|
|
240
|
+
* @param {{
|
|
241
|
+
* hook: {argv: string[], timeoutS: number}, runId: string, id: string, status: string, doneFile: string,
|
|
242
|
+
* repoTop: string, writeRow: (row: Record<string, any>) => Promise<unknown>, err: (s: string) => unknown,
|
|
243
|
+
* }} opts
|
|
244
|
+
* @returns {Promise<Record<string, any> | null>} the row written (unsigned fields), or null when none was.
|
|
245
|
+
*/
|
|
246
|
+
export async function runBlockEndHook({ hook, runId, id, status, doneFile, repoTop, writeRow, err }) {
|
|
247
|
+
const env = {
|
|
248
|
+
...process.env,
|
|
249
|
+
CODE_FORGE_RUN: runId,
|
|
250
|
+
CODE_FORGE_BLOCK: id,
|
|
251
|
+
CODE_FORGE_BLOCK_STATUS: status,
|
|
252
|
+
CODE_FORGE_DONE_FILE: doneFile,
|
|
253
|
+
};
|
|
254
|
+
const started = process.hrtime.bigint();
|
|
255
|
+
/** @type {SpawnResult} */
|
|
256
|
+
let ran;
|
|
257
|
+
// "failed to start" covers the spawn only; a later failure gets neutral wording below
|
|
258
|
+
try {
|
|
259
|
+
ran = await spawnHook(hook.argv, { cwd: repoTop, env, timeoutMs: hook.timeoutS * 1000 });
|
|
260
|
+
} catch (thrown) {
|
|
261
|
+
ran = { exit: null, signal: null, timedOut: false, notFound: false, error: errorCode(thrown) };
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const durationMs = Math.round(Number(process.hrtime.bigint() - started) / 1e6);
|
|
265
|
+
const warning = hookWarning(ran);
|
|
266
|
+
if (warning !== null) err(`warning: on_block_end hook ${warning}\n`);
|
|
267
|
+
const row = {
|
|
268
|
+
event: 'hook.ran',
|
|
269
|
+
block: id,
|
|
270
|
+
hook: 'on_block_end',
|
|
271
|
+
argv0: path.basename(hook.argv[0]),
|
|
272
|
+
exit: ran.exit,
|
|
273
|
+
signal: ran.signal,
|
|
274
|
+
timed_out: ran.timedOut,
|
|
275
|
+
duration_ms: durationMs,
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
// clause 2: the hook.ran row is appended UNDER the run lock (writeSigned alone takes no lock)
|
|
279
|
+
await withRunLock(runId, () => writeSigned(runId, writeRow, row));
|
|
280
|
+
} catch (thrown) {
|
|
281
|
+
// an error code only, never raw message text
|
|
282
|
+
err(`warning: on_block_end hook row not written (${errorCode(thrown)})\n`);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return row;
|
|
286
|
+
} catch (thrown) {
|
|
287
|
+
err(`warning: on_block_end hook not recorded (${errorCode(thrown)})\n`);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|