@codedology/code-forge 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
|
@@ -3,48 +3,149 @@
|
|
|
3
3
|
*
|
|
4
4
|
* B12b's `fixloop.mjs` is a state machine whose `FileState` (round, level, open findings,
|
|
5
5
|
* `reviewed_content`, …) must survive between two `review-file` calls on the same file: round 1
|
|
6
|
-
* is the first ticket, round 2 (the fix-hunk recheck) the next one, and so on.
|
|
7
|
-
* in the run's
|
|
6
|
+
* is the first ticket, round 2 (the fix-hunk recheck) the next one, and so on. B69: the worker
|
|
7
|
+
* keeps it in the run's own directory under the code-forge home, OUTSIDE the workspace and outside
|
|
8
|
+
* the temp root (a temp sweep or a TMPDIR cleanup no longer loses it):
|
|
8
9
|
*
|
|
9
|
-
* <
|
|
10
|
+
* <code-forge home>/runs/<run>/review-state/<block>/<sha256(file)[0:24]>.json ({@link statePath})
|
|
11
|
+
*
|
|
12
|
+
* The old place, `<run temp root>/review-state/<block>/<name>.json` ({@link legacyStatePath}), is
|
|
13
|
+
* read once: a file missing at the new path but present at the old one that passes the same
|
|
14
|
+
* checks is moved (new written atomically 0600, old removed); a wrong one there is `tampered` and
|
|
15
|
+
* left where it is.
|
|
10
16
|
*
|
|
11
17
|
* Every state file is SIGNED with the run key (`{run, block, file, seq, state}` + `mac`) AND
|
|
12
18
|
* anchored in the run's append-only ledger: each save first writes a signed row
|
|
13
19
|
* `{event: 'review.state', block, file, seq, state_sha, l3_rung_used}`. A load matches the file
|
|
14
|
-
* against the LATEST verified anchor (highest `seq`) for the file:
|
|
15
|
-
*
|
|
16
|
-
* ticket, never a fresh round 1 that could clear a `review_cap` stop or an open finding.
|
|
20
|
+
* against the LATEST verified anchor (highest `seq`) for the file: an older signed file
|
|
21
|
+
* (rollback), a different sha, or a file with no anchor is `tampered` — the hook refuses the
|
|
22
|
+
* ticket, never a fresh round 1 that could clear a `review_cap` stop or an open finding. B68: the
|
|
23
|
+
* anchor also carries `state_body`, the signed state with `reviewed_content` set to null and its
|
|
24
|
+
* sha carried as `reviewed_content_sha` (or `state_omitted: "too_large"` over `STATE_BODY_CAP`), so a MISSING file is `restored` from it
|
|
25
|
+
* (the engine saves it as a new seq and writes `review.state_restored`); an anchor with no body
|
|
26
|
+
* makes a missing file `lost` (refused, never a round 1). A
|
|
17
27
|
* `review.state` row of the run whose MAC does not verify is `tampered` for every block and file
|
|
18
28
|
* of it (`blockAnchors`). Whether the block's one L3 rung was used comes from the anchors, never
|
|
19
29
|
* from the state files.
|
|
30
|
+
*
|
|
31
|
+
* B78: the reviewed text itself is kept as a git blob in the content root's object store
|
|
32
|
+
* (`git hash-object -w --no-filters --stdin`), its id in the signed body as `reviewed_content_blob`.
|
|
33
|
+
* A restore reads it back (`git cat-file blob`) and uses it only when its sha256 equals
|
|
34
|
+
* `reviewed_content_sha`; a missing, pruned or mismatching blob falls back to the B68 rule. A blob
|
|
35
|
+
* write that fails saves `reviewed_content_blob: null` and never fails the round.
|
|
20
36
|
*/
|
|
21
37
|
|
|
38
|
+
import { spawnSync } from 'node:child_process';
|
|
22
39
|
import { createHash, randomBytes } from 'node:crypto';
|
|
23
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
41
|
import path from 'node:path';
|
|
42
|
+
import { exec } from '../util/exec.mjs';
|
|
43
|
+
import { assertRunId, runsDir } from '../state/paths.mjs';
|
|
25
44
|
import { signRow, verifyRow } from '../state/signer.mjs';
|
|
26
|
-
|
|
45
|
+
// B78: every blob `git` call runs with `gitChildEnv()`: inherited GIT_* removed, no system config,
|
|
46
|
+
// global config = /dev/null
|
|
47
|
+
import { assertBlockId, assertRowPath, gitChildEnv } from './ticket.mjs';
|
|
27
48
|
|
|
28
49
|
/** @typedef {import('../review/fixloop.mjs').FileState} FileState */
|
|
29
50
|
/** @typedef {{runRootDir: string, runId: string, block: string, file: string, key: Buffer, rows: Array<Record<string, any>>}} Where */
|
|
30
51
|
|
|
31
52
|
export const STATE_EVENT = 'review.state';
|
|
53
|
+
/** B68: the row the engine writes after rebuilding a lost state file from its anchor. */
|
|
54
|
+
export const RESTORED_EVENT = 'review.state_restored';
|
|
55
|
+
/** B68: an anchor's `state_body` over this many bytes (as JSON) is `state_omitted: "too_large"`. */
|
|
56
|
+
export const STATE_BODY_CAP = 64 * 1024;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* B69: the block's state directory, `<code-forge home>/runs/<run>/review-state/<block>` (the run's
|
|
60
|
+
* own directory, next to `runs/<run>/blocks/`).
|
|
61
|
+
* @param {string} runId @param {string} block @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export const blockStateDir = (runId, block) => path.join(runsDir(), assertRunId(runId), 'review-state', assertBlockId(block));
|
|
64
|
+
|
|
65
|
+
/** @param {string} file @returns {string} the state file's name. */
|
|
66
|
+
const stateName = (file) => `${createHash('sha256').update(assertRowPath(file)).digest('hex').slice(0, 24)}.json`;
|
|
32
67
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The ONE path every reader and writer of a file's review state uses (B69).
|
|
70
|
+
* @param {string} runId @param {string} block @param {string} file - repo-root-relative.
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function statePath(runId, block, file) {
|
|
74
|
+
return path.join(blockStateDir(runId, block), stateName(file));
|
|
75
|
+
}
|
|
35
76
|
|
|
36
77
|
/**
|
|
37
|
-
*
|
|
78
|
+
* B69: where the state file lived before (the run's temp root) — read once, to move it.
|
|
79
|
+
* @param {string} runRootDir @param {string} block @param {string} file
|
|
38
80
|
* @returns {string}
|
|
39
81
|
*/
|
|
40
|
-
export function
|
|
41
|
-
|
|
42
|
-
return path.join(blockStateDir(runRootDir, block), `${name}.json`);
|
|
82
|
+
export function legacyStatePath(runRootDir, block, file) {
|
|
83
|
+
return path.join(runRootDir, 'review-state', assertBlockId(block), stateName(file));
|
|
43
84
|
}
|
|
44
85
|
|
|
45
86
|
/** @param {string} text @returns {string} */
|
|
46
87
|
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
47
88
|
|
|
89
|
+
/**
|
|
90
|
+
* B78: the env of a blob `git` call in `contentRoot`: `gitChildEnv()` plus a discovery ceiling at
|
|
91
|
+
* the root's parent, so a content root that is not itself a repository never reaches (and writes
|
|
92
|
+
* into) an enclosing one.
|
|
93
|
+
* @param {string} contentRoot @returns {NodeJS.ProcessEnv}
|
|
94
|
+
*/
|
|
95
|
+
const blobEnv = (contentRoot) => ({ ...gitChildEnv(), GIT_CEILING_DIRECTORIES: path.dirname(path.resolve(contentRoot)) });
|
|
96
|
+
|
|
97
|
+
/** B78: a git object id (SHA-1 or SHA-256 repository), lowercase hex only. */
|
|
98
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
99
|
+
/** B78: how long one blob write or read may take before it counts as failed. */
|
|
100
|
+
const BLOB_TIMEOUT_MS = 10_000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* B78: write `text` (its UTF-8 bytes, no filters) as a blob into `contentRoot`'s object store.
|
|
104
|
+
* @param {string} contentRoot @param {string} text
|
|
105
|
+
* @returns {Promise<string | null>} the blob id, or null on any failure (never throws).
|
|
106
|
+
*/
|
|
107
|
+
async function writeBlob(contentRoot, text) {
|
|
108
|
+
try {
|
|
109
|
+
const res = await exec(['git', 'hash-object', '-w', '--no-filters', '--stdin'], {
|
|
110
|
+
cwd: contentRoot,
|
|
111
|
+
env: blobEnv(contentRoot),
|
|
112
|
+
input: Buffer.from(text, 'utf8'),
|
|
113
|
+
timeoutMs: BLOB_TIMEOUT_MS,
|
|
114
|
+
});
|
|
115
|
+
const id = res.stdout.trim();
|
|
116
|
+
return res.result === 'ok' && OBJECT_ID.test(id) ? id : null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* B78: the blob's text when it is in `contentRoot`'s object store and its sha256 is `wanted`.
|
|
124
|
+
* Synchronous (the load is), so it spawns with `spawnSync`: argv only, no shell, a timeout.
|
|
125
|
+
* @param {string} contentRoot @param {unknown} id @param {string} wanted
|
|
126
|
+
* @returns {string | null} null when the id is not an object id, the blob is unreadable or gone,
|
|
127
|
+
* or its text does not match `wanted` (a mismatching blob is never used).
|
|
128
|
+
*/
|
|
129
|
+
function readBlob(contentRoot, id, wanted) {
|
|
130
|
+
if (typeof id !== 'string' || !OBJECT_ID.test(id)) return null;
|
|
131
|
+
try {
|
|
132
|
+
const res = spawnSync('git', ['cat-file', 'blob', id], {
|
|
133
|
+
cwd: contentRoot,
|
|
134
|
+
env: blobEnv(contentRoot),
|
|
135
|
+
shell: false,
|
|
136
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
137
|
+
timeout: BLOB_TIMEOUT_MS,
|
|
138
|
+
killSignal: 'SIGKILL',
|
|
139
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
140
|
+
});
|
|
141
|
+
if (res.error || res.status !== 0 || !Buffer.isBuffer(res.stdout)) return null;
|
|
142
|
+
const text = res.stdout.toString('utf8');
|
|
143
|
+
return sha(text) === wanted ? text : null;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
48
149
|
/**
|
|
49
150
|
* The block's verified `review.state` anchors, or null when any anchor of the RUN fails: EVERY
|
|
50
151
|
* `review.state` row of the run is verified (MAC, integer `seq`, string `state_sha`) BEFORE the
|
|
@@ -73,64 +174,258 @@ function latestAnchor(anchors, file) {
|
|
|
73
174
|
}
|
|
74
175
|
|
|
75
176
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
177
|
+
* Whether a signed body (a state file's, or an anchor's `state_body`) is this file's state at the
|
|
178
|
+
* anchor's seq: its MAC verifies with the run key, its run/block/file/seq equal the anchor's, and
|
|
179
|
+
* `state.file === file`.
|
|
180
|
+
* @param {any} body @param {{runId: string, block: string, file: string, key: Buffer, seq: number}} at
|
|
181
|
+
* @returns {boolean}
|
|
78
182
|
*/
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
183
|
+
function bodyMatches(body, { runId, block, file, key, seq }) {
|
|
184
|
+
return Boolean(
|
|
185
|
+
body &&
|
|
186
|
+
typeof body === 'object' &&
|
|
187
|
+
verifyRow(body, key).ok &&
|
|
188
|
+
body.run === runId &&
|
|
189
|
+
body.block === block &&
|
|
190
|
+
body.file === file &&
|
|
191
|
+
body.seq === seq &&
|
|
192
|
+
body.state &&
|
|
193
|
+
typeof body.state === 'object' &&
|
|
194
|
+
body.state.file === file,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* B68: rebuild a lost state file from its latest anchor's signed `state_body`. `reviewed_content`
|
|
200
|
+
* is (B78) the body's `reviewed_content_blob` read from the content root's object store when its
|
|
201
|
+
* sha equals `reviewed_content_sha`; else the file's current working-tree content when its sha
|
|
202
|
+
* equals it; else null (the next round then reviews the full file).
|
|
203
|
+
* @param {Record<string, any>} anchor
|
|
204
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, contentRoot?: string}} at
|
|
205
|
+
* @returns {{status: 'restored' | 'lost' | 'tampered', state: FileState | null, seq: number}}
|
|
206
|
+
*/
|
|
207
|
+
function restoreFromAnchor(anchor, { runId, block, file, key, contentRoot }) {
|
|
208
|
+
if (!Object.hasOwn(anchor, 'state_body')) return { status: 'lost', state: null, seq: anchor.seq };
|
|
209
|
+
const body = anchor.state_body;
|
|
210
|
+
if (!bodyMatches(body, { runId, block, file, key, seq: anchor.seq })) return { status: 'tampered', state: null, seq: anchor.seq };
|
|
211
|
+
const wanted = body.reviewed_content_sha;
|
|
212
|
+
/** @type {string | null} */
|
|
213
|
+
let reviewed = null;
|
|
214
|
+
if (typeof wanted === 'string' && typeof contentRoot === 'string') reviewed = readBlob(contentRoot, body.reviewed_content_blob, wanted);
|
|
215
|
+
if (reviewed === null && typeof wanted === 'string' && typeof contentRoot === 'string') {
|
|
216
|
+
try {
|
|
217
|
+
const current = readFileSync(path.join(contentRoot, file), 'utf8');
|
|
218
|
+
if (sha(current) === wanted) reviewed = current;
|
|
219
|
+
} catch {
|
|
220
|
+
// unreadable now: reviewed stays null, the next round reviews the full file
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const state = /** @type {FileState} */ ({ ...body.state, reviewed_content: reviewed });
|
|
224
|
+
return { status: 'restored', state, seq: anchor.seq };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {string} file @returns {string | null} the file's text, null when it does not exist.
|
|
229
|
+
* @throws any read error but ENOENT.
|
|
230
|
+
*/
|
|
231
|
+
function readIfThere(file) {
|
|
84
232
|
try {
|
|
85
|
-
|
|
233
|
+
return readFileSync(file, 'utf8');
|
|
86
234
|
} catch (err) {
|
|
87
|
-
if (/** @type {NodeJS.ErrnoException} */ (err).code
|
|
235
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return null;
|
|
236
|
+
throw err;
|
|
88
237
|
}
|
|
89
|
-
|
|
90
|
-
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Replace `target` with `text` atomically (file mode 0600, a new dir 0700).
|
|
242
|
+
* @param {string} target @param {string} text
|
|
243
|
+
*/
|
|
244
|
+
function writeAtomic(target, text) {
|
|
245
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
246
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
247
|
+
try {
|
|
248
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
249
|
+
renameSync(tmp, target);
|
|
250
|
+
} finally {
|
|
251
|
+
try {
|
|
252
|
+
rmSync(tmp, { force: true });
|
|
253
|
+
} catch {
|
|
254
|
+
// a stray temp file never hides the write's own error
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A state file's text checked against the file's latest anchor.
|
|
261
|
+
* @param {string} text @param {Record<string, any>} anchor @param {{runId: string, block: string, file: string, key: Buffer}} at
|
|
262
|
+
* @returns {{status: 'ok' | 'tampered', state: FileState | null, seq: number}}
|
|
263
|
+
*/
|
|
264
|
+
function checkText(text, anchor, { runId, block, file, key }) {
|
|
265
|
+
if (sha(text) !== anchor.state_sha) return { status: 'tampered', state: null, seq: anchor.seq };
|
|
91
266
|
let body = null;
|
|
92
267
|
try {
|
|
93
268
|
body = JSON.parse(text);
|
|
94
269
|
} catch {
|
|
95
270
|
body = null;
|
|
96
271
|
}
|
|
97
|
-
|
|
98
|
-
body &&
|
|
99
|
-
typeof body === 'object' &&
|
|
100
|
-
verifyRow(body, key).ok &&
|
|
101
|
-
body.run === runId &&
|
|
102
|
-
body.block === block &&
|
|
103
|
-
body.file === file &&
|
|
104
|
-
body.seq === anchor.seq &&
|
|
105
|
-
body.state &&
|
|
106
|
-
typeof body.state === 'object' &&
|
|
107
|
-
body.state.file === file;
|
|
108
|
-
if (!ok) return { status: 'tampered', state: null, seq: anchor.seq };
|
|
272
|
+
if (!bodyMatches(body, { runId, block, file, key, seq: anchor.seq })) return { status: 'tampered', state: null, seq: anchor.seq };
|
|
109
273
|
return { status: 'ok', state: /** @type {FileState} */ (body.state), seq: anchor.seq };
|
|
110
274
|
}
|
|
111
275
|
|
|
112
276
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
277
|
+
* @param {Omit<Where, 'runRootDir'> & {runRootDir?: string, contentRoot?: string, migrate?: boolean, afterMiss?: () => void}} where -
|
|
278
|
+
* `contentRoot`: the tree the file's current content is read from when a lost state is restored
|
|
279
|
+
* (B68); without it the restored `reviewed_content` is null. `runRootDir`: the run's temp root,
|
|
280
|
+
* where an old state file may still be (B69). `migrate` (default true): move a valid old file to
|
|
281
|
+
* the new path; false only reads it (a sibling's check never moves another ticket's file).
|
|
282
|
+
* `afterMiss`: a test seam called when both paths were missing, before the new path is read
|
|
283
|
+
* once more.
|
|
284
|
+
* @returns {{status: 'none' | 'ok' | 'restored' | 'lost' | 'tampered', state: FileState | null, seq: number}}
|
|
117
285
|
*/
|
|
118
|
-
export
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
286
|
+
export function loadState({ runRootDir, runId, block, file, key, rows, contentRoot, migrate = true, afterMiss }) {
|
|
287
|
+
const anchors = blockAnchors({ runId, block, key, rows });
|
|
288
|
+
if (anchors === null) return { status: 'tampered', state: null, seq: 0 };
|
|
289
|
+
const anchor = latestAnchor(anchors, file);
|
|
290
|
+
/** @type {string} */
|
|
291
|
+
let target;
|
|
292
|
+
let text = null;
|
|
293
|
+
/** @type {string | null} */
|
|
294
|
+
let legacy = null;
|
|
125
295
|
try {
|
|
296
|
+
target = statePath(runId, block, file);
|
|
297
|
+
text = readIfThere(target);
|
|
298
|
+
if (text === null && typeof runRootDir === 'string') {
|
|
299
|
+
legacy = legacyStatePath(runRootDir, block, file);
|
|
300
|
+
text = readIfThere(legacy);
|
|
301
|
+
if (text === null) {
|
|
302
|
+
legacy = null;
|
|
303
|
+
// another ticket may have moved the old file to the new path between the two reads
|
|
304
|
+
afterMiss?.();
|
|
305
|
+
text = readIfThere(target);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
return { status: 'tampered', state: null, seq: 0 };
|
|
310
|
+
}
|
|
311
|
+
if (anchor === null) return text === null ? { status: 'none', state: null, seq: 0 } : { status: 'tampered', state: null, seq: 0 };
|
|
312
|
+
if (text === null) return restoreFromAnchor(anchor, { runId, block, file, key, ...(contentRoot !== undefined ? { contentRoot } : {}) });
|
|
313
|
+
const checked = checkText(text, anchor, { runId, block, file, key });
|
|
314
|
+
if (checked.status !== 'ok' || legacy === null || !migrate) return checked;
|
|
315
|
+
// B69: a valid file at the old temp path moves to the new one — never over a file already there
|
|
316
|
+
// (a ticket that saved meanwhile wins and is loaded as usual) — then the old one is removed. A
|
|
317
|
+
// move that fails leaves the old file where it is; the load is `ok` either way (no throw)
|
|
318
|
+
const moved = moveIn(target, text);
|
|
319
|
+
if (moved === 'exists') {
|
|
320
|
+
const now = loadState({ runId, block, file, key, rows, ...(contentRoot !== undefined ? { contentRoot } : {}) });
|
|
321
|
+
if (now.status !== 'ok') return now;
|
|
322
|
+
// the new path holds a valid state (another ticket's move or save): the old copy is done
|
|
323
|
+
try {
|
|
324
|
+
rmSync(legacy, { force: true });
|
|
325
|
+
} catch {
|
|
326
|
+
// the old copy stays; the new path is read first from now on
|
|
327
|
+
}
|
|
328
|
+
return now;
|
|
329
|
+
}
|
|
330
|
+
if (moved === 'moved') {
|
|
331
|
+
try {
|
|
332
|
+
rmSync(legacy, { force: true });
|
|
333
|
+
} catch {
|
|
334
|
+
// the old copy stays; the new path is read first from now on
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return checked;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* B69: write `text` to the new state path without overwriting a file there: a hard link of a
|
|
342
|
+
* 0600 temp file, or — where hard links fail (EPERM, ENOTSUP, …) — an exclusive create.
|
|
343
|
+
* @param {string} target @param {string} text
|
|
344
|
+
* @returns {'moved' | 'exists' | 'failed'}
|
|
345
|
+
*/
|
|
346
|
+
function moveIn(target, text) {
|
|
347
|
+
let tmp = null;
|
|
348
|
+
try {
|
|
349
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
350
|
+
tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
126
351
|
writeFileSync(tmp, text, { mode: 0o600 });
|
|
127
|
-
|
|
352
|
+
try {
|
|
353
|
+
linkSync(tmp, target);
|
|
354
|
+
return 'moved';
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EEXIST') return 'exists';
|
|
357
|
+
}
|
|
358
|
+
writeFileSync(target, text, { flag: 'wx', mode: 0o600 });
|
|
359
|
+
return 'moved';
|
|
128
360
|
} catch (err) {
|
|
129
|
-
|
|
130
|
-
|
|
361
|
+
return /** @type {NodeJS.ErrnoException} */ (err)?.code === 'EEXIST' ? 'exists' : 'failed';
|
|
362
|
+
} finally {
|
|
363
|
+
if (tmp !== null) {
|
|
364
|
+
try {
|
|
365
|
+
rmSync(tmp, { force: true });
|
|
366
|
+
} catch {
|
|
367
|
+
// a stray temp file never fails the load
|
|
368
|
+
}
|
|
369
|
+
}
|
|
131
370
|
}
|
|
132
371
|
}
|
|
133
372
|
|
|
373
|
+
/**
|
|
374
|
+
* B69 (doctor): the files of the run whose latest verified `review.state` anchor has a state file
|
|
375
|
+
* neither at the new path nor (when `runRootDir` is given) at the old one, each with whether that
|
|
376
|
+
* anchor carries a `state_body` (the ledger can rebuild it) or not (lost). The anchor is picked
|
|
377
|
+
* exactly as `loadState` picks it (`blockAnchors` per block, then `latestAnchor`).
|
|
378
|
+
* @param {{runId: string, key: Buffer, rows: Array<Record<string, any>>, runRootDir?: string}} opts
|
|
379
|
+
* @returns {Array<{block: string, file: string, rebuildable: boolean}> | null} null when an anchor
|
|
380
|
+
* of the run does not verify (every ticket of the run is then refused `review-state-tampered`).
|
|
381
|
+
*/
|
|
382
|
+
export function missingStateFiles({ runId, key, rows, runRootDir }) {
|
|
383
|
+
const blocks = [...new Set(rows.filter((r) => r?.event === STATE_EVENT && r.run === runId).map((r) => r.block))];
|
|
384
|
+
const out = [];
|
|
385
|
+
for (const block of blocks) {
|
|
386
|
+
const anchors = blockAnchors({ runId, block, key, rows });
|
|
387
|
+
if (anchors === null) return null;
|
|
388
|
+
for (const file of new Set(anchors.map((a) => a.file))) {
|
|
389
|
+
const anchor = latestAnchor(anchors, file);
|
|
390
|
+
if (anchor === null) continue;
|
|
391
|
+
let paths;
|
|
392
|
+
try {
|
|
393
|
+
paths = [statePath(runId, block, file), ...(typeof runRootDir === 'string' ? [legacyStatePath(runRootDir, block, file)] : [])];
|
|
394
|
+
} catch {
|
|
395
|
+
continue; // a block id or a path no state file can have
|
|
396
|
+
}
|
|
397
|
+
if (paths.some((p) => existsSync(p))) continue;
|
|
398
|
+
out.push({ block, file, rebuildable: Object.hasOwn(anchor, 'state_body') });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Anchor, then write: the signed `review.state` row goes to the ledger FIRST (a failed ledger write
|
|
406
|
+
* changes nothing and throws), then the state file is replaced atomically (mode 0600).
|
|
407
|
+
* @param {Where & {state: FileState, seq: number, writeRow: (row: Record<string, any>) => Promise<unknown>, contentRoot?: string}} opts
|
|
408
|
+
* `seq` - the loaded state's seq (0 for a new file); this save is `seq + 1`. `contentRoot` (B78):
|
|
409
|
+
* the tree the round read the file in; the reviewed text goes into its object store as a blob.
|
|
410
|
+
*/
|
|
411
|
+
export async function saveState({ runId, block, file, key, state, seq, writeRow, contentRoot }) {
|
|
412
|
+
const target = statePath(runId, block, file);
|
|
413
|
+
const next = seq + 1;
|
|
414
|
+
const text = `${JSON.stringify(signRow({ run: runId, block, file, seq: next, state }, key))}\n`;
|
|
415
|
+
// B68: the slim signed state rides on the anchor (its own MAC covers it), so a lost file can be
|
|
416
|
+
// rebuilt; the full reviewed text stays out of the ledger, only its sha (and, B78, the id of its
|
|
417
|
+
// git blob in the content root's object store) goes in
|
|
418
|
+
const reviewed = typeof state.reviewed_content === 'string' ? state.reviewed_content : null;
|
|
419
|
+
const blob = reviewed !== null && typeof contentRoot === 'string' ? await writeBlob(contentRoot, reviewed) : null;
|
|
420
|
+
const stateBody = signRow(
|
|
421
|
+
{ run: runId, block, file, seq: next, state: { ...state, reviewed_content: null }, reviewed_content_sha: reviewed === null ? null : sha(reviewed), reviewed_content_blob: blob },
|
|
422
|
+
key,
|
|
423
|
+
);
|
|
424
|
+
const slim = Buffer.byteLength(JSON.stringify(stateBody), 'utf8') > STATE_BODY_CAP ? { state_omitted: 'too_large' } : { state_body: stateBody };
|
|
425
|
+
await writeRow({ event: STATE_EVENT, block, file, seq: next, state_sha: sha(text), l3_rung_used: state.l3_rung_used === true, ...slim });
|
|
426
|
+
writeAtomic(target, text);
|
|
427
|
+
}
|
|
428
|
+
|
|
134
429
|
/**
|
|
135
430
|
* Whether the block's one L3 rung (§3.6 rules 5/6) was already used on ANY of its files — the
|
|
136
431
|
* rung is once per block. Derived from the ledger anchors only; fails closed: a block anchor that
|
package/src/worker/ticket.mjs
CHANGED
|
@@ -185,7 +185,15 @@ function realOrSelf(p) {
|
|
|
185
185
|
* @returns {string}
|
|
186
186
|
*/
|
|
187
187
|
export function contentHash(repoRoot, file) {
|
|
188
|
-
|
|
188
|
+
return hashBytes(readRegularFileNoFollow(repoRoot, file));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The one content-hash rule (B90: the worker hashes the bytes it also counts lines on): sha256 of
|
|
193
|
+
* the bytes, or `DELETED_HASH` for `null` (no file).
|
|
194
|
+
* @param {Buffer | null} bytes @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
export function hashBytes(bytes) {
|
|
189
197
|
if (bytes === null) return DELETED_HASH;
|
|
190
198
|
return createHash('sha256').update(bytes).digest('hex');
|
|
191
199
|
}
|