@codedology/code-forge 0.7.6 → 0.8.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 +51 -0
- package/README.md +57 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +171 -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 +33 -4
- package/skill/references/ledger.md +8 -4
- package/skill/references/plan.md +12 -0
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +4 -2
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +18 -1
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +337 -37
- 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 +21 -4
- package/src/cli/review-file.mjs +61 -6
- package/src/cli/run.mjs +16 -6
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/decide/lane-facts.mjs +41 -0
- package/src/doctor/local.mjs +2 -1
- 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 +32 -6
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/engine.mjs +3 -2
- package/src/review/gate-check.mjs +9 -5
- package/src/review/open-findings.mjs +59 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +111 -37
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +68 -8
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +21 -2
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +36 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/worker/engine.mjs +157 -17
- package/src/worker/loop.mjs +26 -7
- package/src/worker/queue.mjs +36 -10
- package/src/worker/ticket.mjs +46 -6
package/src/worker/engine.mjs
CHANGED
|
@@ -40,29 +40,59 @@
|
|
|
40
40
|
* The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
|
|
41
41
|
* A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
|
|
42
42
|
* `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
|
|
43
|
+
* B62b: for a block the run record gives a worktree (`proof.isolation: worktree`) every content
|
|
44
|
+
* read — the file, its diff against the block base, the peers' diffs, the fix hunks and the
|
|
45
|
+
* budget's file set — happens in that tree, against the tree's `base_sha` (`contentRootFor`; the
|
|
46
|
+
* tree comes from the run record, never from the coder-writable ticket), and the packet's facts
|
|
47
|
+
* slot names the tree and its branch. A file changed only in the main tree is never seen. The
|
|
48
|
+
* base, the tree and the facts come from ONE read of the run record. A ticket is `unavailable`
|
|
49
|
+
* with one of these reasons:
|
|
50
|
+
* - `worktree-missing`: the recorded tree is gone;
|
|
51
|
+
* - `worktree-not-dir`: its path is not a directory;
|
|
52
|
+
* - `worktree-malformed`: no absolute path, a branch that is empty or has control characters, or
|
|
53
|
+
* a base_sha that is not a full hex id;
|
|
54
|
+
* - `worktree-branch-mismatch`: the tree is not on its recorded branch;
|
|
55
|
+
* - `worktree-foreign`: the tree belongs to another repository;
|
|
56
|
+
* - `worktree-workspace-missing`: the workspace's subdirectory is not in the tree;
|
|
57
|
+
* - `worktree-mismatch`: the tree the worker hashed the ticket in (`ctx.contentRoot`, from the
|
|
58
|
+
* ticket's `tree` or its absence) is not the record's tree;
|
|
59
|
+
* - `git-failed`: git could not be run on the tree;
|
|
60
|
+
* - `run-record-unreadable`: the record cannot be parsed, or names no workspace.
|
|
61
|
+
* A block without a worktree, or one the run record does not know, reads the main tree as before.
|
|
62
|
+
* B63a: a ticket is also `unavailable` with `context-unreadable` when the config's `context` key
|
|
63
|
+
* is bad or one of its rules or lessons files is missing, outside the repository or unreadable at
|
|
64
|
+
* review time; no session runs and the packet dir is removed. The worker does not retry it (the
|
|
65
|
+
* ticket is done); `review-file` on the same content is a fresh attempt every time (`retry: true`).
|
|
66
|
+
* The tree is passed as `contentRoot`: `repoRoot` stays the main tree, and the fix-loop state
|
|
67
|
+
* stays under the run root.
|
|
43
68
|
*/
|
|
44
69
|
|
|
45
|
-
import { rmSync } from 'node:fs';
|
|
70
|
+
import { realpathSync, rmSync } from 'node:fs';
|
|
46
71
|
import path from 'node:path';
|
|
47
72
|
import { blockKind } from '../decide/escalation.mjs';
|
|
48
73
|
import { askJev } from '../decide/jev-client.mjs';
|
|
49
74
|
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
50
75
|
import { budgetFor } from '../review/packet.mjs';
|
|
76
|
+
import { ContextError, reviewDigest } from '../context/pack.mjs';
|
|
51
77
|
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
52
78
|
import { peerDiffReader } from '../review/moved.mjs';
|
|
53
79
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
54
80
|
import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
|
|
55
81
|
import { readRun } from '../state/run.mjs';
|
|
82
|
+
import { StateError } from '../state/paths.mjs';
|
|
83
|
+
import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
|
|
56
84
|
import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
|
|
57
85
|
import { verifyRow } from '../state/signer.mjs';
|
|
58
86
|
import { redact } from '../util/redact.mjs';
|
|
59
87
|
import { keyedLock } from '../util/locks.mjs';
|
|
60
88
|
import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
|
|
61
|
-
import { assertTicketId } from './ticket.mjs';
|
|
89
|
+
import { assertTicketId, WorkerError } from './ticket.mjs';
|
|
62
90
|
|
|
63
91
|
/**
|
|
64
92
|
* @typedef {object} ReviewContext
|
|
65
|
-
* @property {string} repoRoot
|
|
93
|
+
* @property {string} repoRoot - the main tree (the queue, the worker).
|
|
94
|
+
* @property {string} [contentRoot] - B62b: the tree the ticket's content is read in (the block's
|
|
95
|
+
* worktree); `repoRoot` when absent.
|
|
66
96
|
* @property {string} runId
|
|
67
97
|
* @property {string} runRootDir - the run's temp root (packets and session dirs live under it).
|
|
68
98
|
* @property {Record<string, any>} cfg - the project config.
|
|
@@ -110,14 +140,17 @@ import { assertTicketId } from './ticket.mjs';
|
|
|
110
140
|
* of its owned files. `ownedKinds` is the recorded `owned_kinds` (B57: which exact entries were
|
|
111
141
|
* regular files at `block open`/`claim`), `{}` when there is none (every exact entry directory-like).
|
|
112
142
|
* With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
|
|
113
|
-
*
|
|
143
|
+
* `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
|
|
144
|
+
* none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
|
|
145
|
+
* (path, branch, hex base_sha) throws `worktree-malformed`.
|
|
146
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
|
|
114
147
|
*/
|
|
115
148
|
export async function blockEntryFor(runId, block) {
|
|
116
149
|
let record;
|
|
117
150
|
try {
|
|
118
151
|
record = await readRun(runId);
|
|
119
152
|
} catch (err) {
|
|
120
|
-
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null };
|
|
153
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null, tree: null, workspace: null };
|
|
121
154
|
throw new Error('run-record-unreadable');
|
|
122
155
|
}
|
|
123
156
|
if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
|
|
@@ -128,7 +161,85 @@ export async function blockEntryFor(runId, block) {
|
|
|
128
161
|
const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
|
|
129
162
|
const kinds = entry.owned_kinds;
|
|
130
163
|
const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
|
|
131
|
-
|
|
164
|
+
/** @type {WorktreeEntry | null} */
|
|
165
|
+
let tree = null;
|
|
166
|
+
if (entry.worktree !== undefined && entry.worktree !== null) {
|
|
167
|
+
try {
|
|
168
|
+
tree = worktreeRecord(entry.worktree);
|
|
169
|
+
} catch {
|
|
170
|
+
throw new Error('worktree-malformed');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
|
|
177
|
+
|
|
178
|
+
/** The refusals of {@link blockTree} a ticket answers with, as its `reason`. */
|
|
179
|
+
const TREE_CODES = new Set(['worktree-malformed', 'worktree-missing', 'worktree-not-dir', 'worktree-branch-mismatch', 'worktree-workspace-missing', 'worktree-foreign', 'git-failed']);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* B62b: where the content of a ticket of `block` is read — the top of the block's worktree from
|
|
183
|
+
* the run record (`blockTree`: present, a directory, on its recorded branch, of this repository),
|
|
184
|
+
* else `repoRoot`. `repoRoot` exactly as before B62b when there is no run record, the block is not
|
|
185
|
+
* in it, or it has no `worktree`: only a block WITH a worktree record can be refused here.
|
|
186
|
+
* @param {string} runId @param {string} block @param {string} repoRoot
|
|
187
|
+
* @returns {Promise<string>}
|
|
188
|
+
* @throws {WorkerError} a code of {@link TREE_CODES}, or `run-record-unreadable` for a record that
|
|
189
|
+
* cannot be parsed or is not a record; any other failure (an I/O error) is rethrown.
|
|
190
|
+
*/
|
|
191
|
+
export async function contentRootFor(runId, block, repoRoot) {
|
|
192
|
+
let record;
|
|
193
|
+
try {
|
|
194
|
+
record = await readRun(runId);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return repoRoot;
|
|
197
|
+
if (err instanceof SyntaxError) throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
if (record === null || typeof record !== 'object') throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
|
|
201
|
+
const blocks = record.blocks;
|
|
202
|
+
const entry = blocks && typeof blocks === 'object' && typeof block === 'string' && Object.hasOwn(blocks, block) ? blocks[block] : null;
|
|
203
|
+
if (entry?.worktree === undefined || entry.worktree === null) return repoRoot;
|
|
204
|
+
return treeTopOf(record.workspace, block, entry.worktree);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The realpath'd top of the recorded worktree `worktree` of `block` (`blockTree`).
|
|
209
|
+
* @param {string} workspace @param {string} block @param {unknown} worktree
|
|
210
|
+
* @returns {Promise<string>}
|
|
211
|
+
* @throws {WorkerError} a code of {@link TREE_CODES}; any other failure is rethrown.
|
|
212
|
+
*/
|
|
213
|
+
async function treeTopOf(workspace, block, worktree) {
|
|
214
|
+
if (typeof workspace !== 'string' || !path.isAbsolute(workspace)) throw new WorkerError('run-record-unreadable', `block ${block}: the run record names no workspace`);
|
|
215
|
+
try {
|
|
216
|
+
return /** @type {import('../proof/worktree.mjs').BlockTree} */ (await blockTree(workspace, { worktree })).top;
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const code = /** @type {any} */ (err)?.code;
|
|
219
|
+
if (err instanceof StateError && TREE_CODES.has(code)) throw new WorkerError(code, `block ${block}: ${err.message}`);
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {string} p @returns {string} `p` realpath'd, or `p` itself when it cannot be resolved. */
|
|
225
|
+
export function canonicalPath(p) {
|
|
226
|
+
try {
|
|
227
|
+
return realpathSync(p);
|
|
228
|
+
} catch {
|
|
229
|
+
return p;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The packet's facts slot: the acceptance clauses, and for a block with a worktree (B62b) a line
|
|
235
|
+
* naming the tree and its branch the file is reviewed in.
|
|
236
|
+
* @param {{acceptance?: unknown, tree: WorktreeEntry | null}} entry @param {Record<string, any> | undefined} cfg
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
function factsFor(entry, cfg) {
|
|
240
|
+
const excerpt = acceptanceExcerpt(entry.acceptance, cfg);
|
|
241
|
+
if (!entry.tree) return excerpt;
|
|
242
|
+
return `${excerpt}\nWorktree: the file is reviewed as it is in the block's worktree ${redact(entry.tree.path)} (branch ${redact(entry.tree.branch)}), against the block base.`;
|
|
132
243
|
}
|
|
133
244
|
|
|
134
245
|
/** The acceptance line written when the block's clauses cannot be read. */
|
|
@@ -213,15 +324,43 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
213
324
|
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
214
325
|
}
|
|
215
326
|
if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
|
|
216
|
-
|
|
327
|
+
// B62b: every content read of this ticket happens in the block's tree (the main tree without
|
|
328
|
+
// one), taken from the SAME entry as the base and the facts. The worker passes the tree it
|
|
329
|
+
// hashed the ticket in as `ctx.contentRoot`; a record that changed since names another tree.
|
|
330
|
+
let contentRoot;
|
|
217
331
|
try {
|
|
332
|
+
contentRoot = entry.tree ? await treeTopOf(entry.workspace, ticket.block, entry.tree) : ctx.repoRoot;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
return { status: 'unavailable', reason: err instanceof WorkerError ? err.code : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
335
|
+
}
|
|
336
|
+
if (ctx.contentRoot !== undefined && canonicalPath(ctx.contentRoot) !== canonicalPath(contentRoot)) {
|
|
337
|
+
return { status: 'unavailable', reason: 'worktree-mismatch', approved: false, engine: 'adaptive', sessions: [] };
|
|
338
|
+
}
|
|
339
|
+
/** @type {ReviewContext} */
|
|
340
|
+
const tctx = { ...ctx, contentRoot };
|
|
341
|
+
const factsExcerpt = factsFor(entry, ctx.cfg);
|
|
342
|
+
const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
|
|
343
|
+
try {
|
|
344
|
+
// B63a: the `context` rules and lessons, read from the MAIN tree (`ctx.repoRoot`, never the
|
|
345
|
+
// block's worktree) with the config in force for this ticket; '' without a `context` key, so
|
|
346
|
+
// the packet stays as it was. A bad `context` value or a rules/lessons file that is missing,
|
|
347
|
+
// outside the repository or unreadable makes the ticket `unavailable` (`context-unreadable`,
|
|
348
|
+
// re-queued by `review-file` on the same content, as often as asked): no review runs without the rules it was promised.
|
|
349
|
+
let rulesDigest;
|
|
350
|
+
try {
|
|
351
|
+
rulesDigest = reviewDigest({ repoRoot: ctx.repoRoot, cfg: ctx.cfg });
|
|
352
|
+
} catch (err) {
|
|
353
|
+
if (!(err instanceof ContextError)) throw err;
|
|
354
|
+
return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
355
|
+
}
|
|
356
|
+
const digest = rulesDigest === '' ? {} : { rulesDigest };
|
|
218
357
|
if (!ctx.key) {
|
|
219
358
|
return await reviewFile(
|
|
220
|
-
{ repoRoot:
|
|
359
|
+
{ repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs },
|
|
221
360
|
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
222
361
|
);
|
|
223
362
|
}
|
|
224
|
-
return await fixLoopRound(ticket,
|
|
363
|
+
return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs });
|
|
225
364
|
} finally {
|
|
226
365
|
rmSync(workDir, { recursive: true, force: true });
|
|
227
366
|
}
|
|
@@ -329,10 +468,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
|
|
|
329
468
|
* first full round, or any round once the rung is gone) runs outside the block lock, in parallel
|
|
330
469
|
* with the block's other files.
|
|
331
470
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
332
|
-
* @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
471
|
+
* @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
333
472
|
* @returns {Promise<ReviewOutcome>}
|
|
334
473
|
*/
|
|
335
|
-
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
|
|
474
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs }) {
|
|
336
475
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
337
476
|
const readRows = ctx.readRows;
|
|
338
477
|
/** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
|
|
@@ -372,7 +511,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
372
511
|
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
373
512
|
}
|
|
374
513
|
const fixed = state;
|
|
375
|
-
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds, peerDiffs });
|
|
514
|
+
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
|
|
376
515
|
// a round that could take the rung keeps the block lock until its state is saved
|
|
377
516
|
if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
|
|
378
517
|
return { round };
|
|
@@ -383,10 +522,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
383
522
|
/**
|
|
384
523
|
* Run the prepared round and save the file's state: the ticket's outcome.
|
|
385
524
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
386
|
-
* @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
525
|
+
* @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
387
526
|
* @returns {Promise<ReviewOutcome>}
|
|
388
527
|
*/
|
|
389
|
-
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0, peerDiffs }) {
|
|
528
|
+
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs }) {
|
|
390
529
|
/** @type {Record<string, any> | null} */
|
|
391
530
|
let engineOutcome = null;
|
|
392
531
|
let approvalWritten = false;
|
|
@@ -429,18 +568,19 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
429
568
|
await runRound(
|
|
430
569
|
state,
|
|
431
570
|
{
|
|
432
|
-
repoRoot: ctx.repoRoot,
|
|
571
|
+
repoRoot: ctx.contentRoot ?? ctx.repoRoot,
|
|
433
572
|
cfg: ctx.cfg,
|
|
434
573
|
workDir,
|
|
435
574
|
base,
|
|
436
575
|
writeRow,
|
|
437
576
|
factsExcerpt,
|
|
577
|
+
...(rulesDigest !== '' ? { rulesDigest } : {}),
|
|
438
578
|
extraRounds,
|
|
439
579
|
...(peerDiffs ? { peerDiffs } : {}),
|
|
440
580
|
...(jev ? { jev } : {}),
|
|
441
581
|
...(schemaHistory ? { schemaHistory } : {}),
|
|
442
582
|
review: async () => {
|
|
443
|
-
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
|
|
583
|
+
engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
|
|
444
584
|
return engineOutcome;
|
|
445
585
|
},
|
|
446
586
|
spawn: async (opts) => {
|
|
@@ -500,7 +640,7 @@ async function recordBlockBudget(ticket, ctx, { base, owned, ownedKinds }) {
|
|
|
500
640
|
let changed = [];
|
|
501
641
|
if (base !== null && owned.length > 0) {
|
|
502
642
|
try {
|
|
503
|
-
changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
|
|
643
|
+
changed = (await computeFileSet({ cwd: ctx.contentRoot ?? ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
|
|
504
644
|
} catch {
|
|
505
645
|
// no row on a git failure: a ticket-only forecast would be permanent (the row is written
|
|
506
646
|
// once per block); the block's next keyed ticket retries
|
package/src/worker/loop.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { keyedLock } from '../util/locks.mjs';
|
|
|
50
50
|
import { writeSafe } from '../util/redact.mjs';
|
|
51
51
|
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
52
52
|
import { openFindings } from '../review/gate-check.mjs';
|
|
53
|
-
import { reviewTicket } from './engine.mjs';
|
|
53
|
+
import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
|
|
54
54
|
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
55
55
|
import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
|
|
56
56
|
|
|
@@ -285,13 +285,15 @@ export async function createWorker(opts, deps = {}) {
|
|
|
285
285
|
* One ticket through the engine hook with `tcfg` (the ticket's config snapshot): the unsigned result.
|
|
286
286
|
* @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
|
|
287
287
|
* @param {Record<string, any>} tcfg
|
|
288
|
+
* @param {string} contentRoot - B62b: the tree the content was hashed in, and is reviewed in.
|
|
288
289
|
* @returns {Promise<Record<string, any>>}
|
|
289
290
|
*/
|
|
290
|
-
async function reviewWith(ticket, base, tcfg) {
|
|
291
|
+
async function reviewWith(ticket, base, tcfg, contentRoot) {
|
|
291
292
|
const timeoutS = Number.isInteger(tcfg?.review?.session_timeout_s) && tcfg.review.session_timeout_s > 0 ? tcfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
|
|
292
293
|
/** @type {import('./engine.mjs').ReviewContext} */
|
|
293
294
|
const ctx = {
|
|
294
295
|
repoRoot,
|
|
296
|
+
contentRoot,
|
|
295
297
|
runId,
|
|
296
298
|
runRootDir,
|
|
297
299
|
cfg: tcfg,
|
|
@@ -360,15 +362,32 @@ export async function createWorker(opts, deps = {}) {
|
|
|
360
362
|
*/
|
|
361
363
|
async function reviewOne(id, ticket) {
|
|
362
364
|
const base = { event: 'review.result', block: ticket.block, file: ticket.file, content_hash: ticket.content_hash };
|
|
363
|
-
|
|
365
|
+
if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
|
|
366
|
+
// B62b: the tree the content is hashed AND reviewed in — a block's worktree (from the run
|
|
367
|
+
// record, as review-file did), else the main tree. A tree that cannot be used (gone, malformed)
|
|
368
|
+
// or a block entry that cannot be read is `unavailable` with its code: never approval, and a
|
|
369
|
+
// later ticket for the same content runs again once it is fixed.
|
|
370
|
+
let contentRoot;
|
|
371
|
+
try {
|
|
372
|
+
contentRoot = await contentRootFor(runId, ticket.block, repoRoot);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
// only the codes contentRootFor answers with; an I/O failure goes to runOne (`worker-error`)
|
|
375
|
+
if (!(err instanceof WorkerError)) throw err;
|
|
376
|
+
return { ...base, status: 'unavailable', reason: err.code, approved: false };
|
|
377
|
+
}
|
|
378
|
+
// a ticket was hashed in the tree it names (the main tree when it names none): never review it
|
|
379
|
+
// in another one — also a ticket with no tree for a block the record gives a worktree
|
|
380
|
+
const hashedIn = typeof ticket.tree === 'string' ? ticket.tree : repoRoot;
|
|
381
|
+
if (canonicalPath(hashedIn) !== canonicalPath(contentRoot)) return { ...base, status: 'unavailable', reason: 'worktree-mismatch', approved: false };
|
|
382
|
+
/** @type {string} */
|
|
364
383
|
let current;
|
|
365
384
|
try {
|
|
366
|
-
current =
|
|
385
|
+
current = contentHash(contentRoot, ticket.file);
|
|
367
386
|
} catch (err) {
|
|
368
387
|
// the path no longer passes the on-disk check, or the file cannot be read
|
|
369
|
-
|
|
388
|
+
const reason = err instanceof WorkerError ? err.code : 'unreadable';
|
|
389
|
+
return { ...base, status: 'refused', reason, approved: false };
|
|
370
390
|
}
|
|
371
|
-
if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
|
|
372
391
|
if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
|
|
373
392
|
if (current !== ticket.content_hash) return { ...base, status: 'stale', approved: false };
|
|
374
393
|
let ticketCfg;
|
|
@@ -377,7 +396,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
377
396
|
} catch {
|
|
378
397
|
return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
|
|
379
398
|
}
|
|
380
|
-
return reviewWith(ticket, base, ticketCfg);
|
|
399
|
+
return reviewWith(ticket, base, ticketCfg, contentRoot);
|
|
381
400
|
}
|
|
382
401
|
|
|
383
402
|
/**
|
package/src/worker/queue.mjs
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
|
|
11
11
|
* the MAC makes an edit visible anyway (T1/T2). Writes are atomic (temp file + rename).
|
|
12
|
+
*
|
|
13
|
+
* B62b: `<repo>` is always the MAIN tree's top level, where the worker runs — a block with its own
|
|
14
|
+
* worktree still queues there; only the reviewed content is read in the block's tree.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import { randomBytes } from 'node:crypto';
|
|
15
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
19
|
import path from 'node:path';
|
|
17
20
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
18
21
|
import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
@@ -54,26 +57,48 @@ function readJSON(file) {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/**
|
|
57
|
-
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string}} Ticket
|
|
60
|
+
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string}} Ticket
|
|
61
|
+
* `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
|
|
62
|
+
* and the worker only checks it against the tree it takes from the run record (a ticket whose
|
|
63
|
+
* tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
|
|
58
64
|
*/
|
|
59
65
|
|
|
60
66
|
/**
|
|
61
67
|
* Enqueue `file` (already repo-root-relative) for review. Idempotent by content hash: the same
|
|
62
|
-
* content returns the same ticket and never rewrites it.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
68
|
+
* content returns the same ticket and never rewrites it. The queue is under `repoRoot`; the
|
|
69
|
+
* content is hashed under `tree` when given (B62b: the block's worktree, realpath'd here), else
|
|
70
|
+
* under `repoRoot`. The tree is part of the ticket id, so a main-tree ticket and a tree ticket for
|
|
71
|
+
* the same bytes differ, and the answer names the tree given here, never a stored value.
|
|
72
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, now?: Date}} opts
|
|
73
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
|
|
74
|
+
* @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
|
|
65
75
|
*/
|
|
66
|
-
export function enqueue({ repoRoot, run, block, file, now = new Date() }) {
|
|
76
|
+
export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
|
|
67
77
|
const rel = assertRowPath(file);
|
|
68
|
-
const
|
|
69
|
-
const
|
|
78
|
+
const root = tree === null ? null : realTree(tree);
|
|
79
|
+
const content_hash = contentHash(root ?? repoRoot, rel);
|
|
80
|
+
const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
|
|
70
81
|
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
82
|
+
const named = root === null ? {} : { tree: root };
|
|
71
83
|
if (!existsSync(target)) {
|
|
72
84
|
/** @type {Ticket} */
|
|
73
|
-
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString() };
|
|
85
|
+
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
|
|
74
86
|
writeAtomic(target, `${JSON.stringify(body)}\n`);
|
|
75
87
|
}
|
|
76
|
-
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash };
|
|
88
|
+
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} tree @returns {string} `tree` realpath'd.
|
|
93
|
+
* @throws {WorkerError} `bad-path` when it is relative or cannot be resolved.
|
|
94
|
+
*/
|
|
95
|
+
function realTree(tree) {
|
|
96
|
+
if (!path.isAbsolute(tree)) throw new WorkerError('bad-path', `the content tree ${tree} is not an absolute path`);
|
|
97
|
+
try {
|
|
98
|
+
return realpathSync(tree);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new WorkerError('bad-path', `the content tree ${tree} cannot be resolved`);
|
|
101
|
+
}
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
/** @param {string} repoRoot @param {string} ticket */
|
|
@@ -113,6 +138,7 @@ export function readTicket(repoRoot, id) {
|
|
|
113
138
|
const t = readJSON(path.join(queueDir(repoRoot), `${assertTicketId(id)}.json`));
|
|
114
139
|
if (!t || typeof t !== 'object') throw new WorkerError('bad-ticket', `ticket ${id} is not readable JSON`);
|
|
115
140
|
assertRowPath(t.file);
|
|
141
|
+
if (t.tree !== undefined && (typeof t.tree !== 'string' || !path.isAbsolute(t.tree))) throw new WorkerError('bad-ticket', `ticket ${id} names a tree that is not an absolute path`);
|
|
116
142
|
if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string' || ticketId(t) !== id) {
|
|
117
143
|
throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
118
144
|
}
|
package/src/worker/ticket.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Review tickets (plan §4.8, V4; block B11): repo-root-relative paths, content hashes and ticket
|
|
3
|
-
* ids. Pure functions plus the
|
|
3
|
+
* ids. Pure functions plus the `git rev-parse` calls that find the repository root (and, B62b,
|
|
4
|
+
* the main working tree of a linked worktree: `mainRootOf`).
|
|
4
5
|
*
|
|
5
6
|
* Path rule (V4): a ticket's `file` — and every `review.*` row the worker writes — is relative to
|
|
6
7
|
* the REPOSITORY ROOT (realpath'd `git rev-parse --show-toplevel`), whatever directory the coder
|
|
@@ -8,8 +9,9 @@
|
|
|
8
9
|
* `bad-path` before anything is written; so is a result outside the root or under `.git/` or
|
|
9
10
|
* `.code-forge/`. A stored value is re-checked by `assertRowPath` (tickets are coder-writable).
|
|
10
11
|
*
|
|
11
|
-
* Ticket id = the first 24 hex chars of `sha256(run \0 block \0 file \0 content_hash)
|
|
12
|
-
* content
|
|
12
|
+
* Ticket id = the first 24 hex chars of `sha256(run \0 block \0 file \0 content_hash)`, plus
|
|
13
|
+
* `\0 tree` for content hashed in a block's worktree (B62b): the same content enqueued twice is
|
|
14
|
+
* the same ticket (idempotent), an edited file is a new one.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import { createHash } from 'node:crypto';
|
|
@@ -73,6 +75,39 @@ export async function repoRootOf(cwd) {
|
|
|
73
75
|
return realpathSync(top);
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
/**
|
|
79
|
+
* B62b: the top level of the MAIN working tree of the repository containing `cwd`, realpath'd —
|
|
80
|
+
* `cwd`'s own top level ({@link repoRootOf}) unless `cwd` is inside a linked worktree (`git
|
|
81
|
+
* worktree add`), whose main tree is the first record `git worktree list` prints. The review queue
|
|
82
|
+
* and the worker live there, whichever tree the coder works in. A repository whose main entry is
|
|
83
|
+
* bare has no main working tree to hold them, and is refused.
|
|
84
|
+
* @param {string} cwd
|
|
85
|
+
* @returns {Promise<string>}
|
|
86
|
+
* @throws {WorkerError} `no-repo` (also for git older than 2.36), `bare-main`, `no-main-tree`
|
|
87
|
+
*/
|
|
88
|
+
export async function mainRootOf(cwd) {
|
|
89
|
+
const top = await repoRootOf(cwd);
|
|
90
|
+
const opts = { cwd, env: gitChildEnv(), timeoutMs: 10000 };
|
|
91
|
+
const dirs = await exec(['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], opts);
|
|
92
|
+
if (dirs.result !== 'ok') throw new WorkerError('no-repo', `not inside a git repository, or git is older than 2.36 (code-forge needs git >= 2.36): ${dirs.stderr.trim()}`);
|
|
93
|
+
const [gitDir, commonDir] = dirs.stdout.split('\n');
|
|
94
|
+
if (!gitDir || !commonDir) throw new WorkerError('no-repo', 'git rev-parse printed no git directory');
|
|
95
|
+
if (realOrSelf(gitDir) === realOrSelf(commonDir)) return top;
|
|
96
|
+
const list = await exec(['git', 'worktree', 'list', '--porcelain', '-z'], opts);
|
|
97
|
+
if (list.result !== 'ok') throw new WorkerError('no-repo', `the main working tree of the repository cannot be listed (code-forge needs git >= 2.36): ${list.stderr.trim()}`);
|
|
98
|
+
// records end with an empty field; the first record is the main tree's
|
|
99
|
+
const first = list.stdout.split('\0\0')[0].split('\0');
|
|
100
|
+
const where = first.find((line) => line.startsWith('worktree '));
|
|
101
|
+
if (!where) throw new WorkerError('no-repo', 'git worktree list printed no main working tree');
|
|
102
|
+
if (first.includes('bare')) throw new WorkerError('bare-main', `the repository's main entry ${where.slice('worktree '.length)} is bare: it has no main working tree for the review queue`);
|
|
103
|
+
const main = where.slice('worktree '.length);
|
|
104
|
+
try {
|
|
105
|
+
return realpathSync(main);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new WorkerError('no-main-tree', `the repository's main working tree ${main} does not exist (moved or removed): the review queue lives there`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
/**
|
|
77
112
|
* Refuse a stored row path that is not a plain repo-root-relative POSIX path (V4).
|
|
78
113
|
* @param {unknown} value
|
|
@@ -187,9 +222,14 @@ export function readRegularFileNoFollow(repoRoot, file) {
|
|
|
187
222
|
}
|
|
188
223
|
|
|
189
224
|
/**
|
|
190
|
-
*
|
|
225
|
+
* B62b: a ticket that names the tree its content was hashed in (`tree`, a block's worktree) has
|
|
226
|
+
* the tree in its identity too — `sha256(run \0 block \0 file \0 content_hash \0 tree)` — so a
|
|
227
|
+
* main-tree ticket and a tree ticket for the same bytes are two tickets. Without a tree the id is
|
|
228
|
+
* the one from before B62b.
|
|
229
|
+
* @param {{run: string, block: string, file: string, content_hash: string, tree?: string | null}} t
|
|
191
230
|
* @returns {string} the ticket id.
|
|
192
231
|
*/
|
|
193
|
-
export function ticketId({ run, block, file, content_hash }) {
|
|
194
|
-
|
|
232
|
+
export function ticketId({ run, block, file, content_hash, tree = null }) {
|
|
233
|
+
const parts = tree === null ? [run, block, file, content_hash] : [run, block, file, content_hash, tree];
|
|
234
|
+
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 24);
|
|
195
235
|
}
|