@codedology/code-forge 0.7.7 → 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 +41 -0
- package/README.md +50 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +157 -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/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 +292 -33
- 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 +61 -6
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -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 +17 -5
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- 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 +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 +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
|
@@ -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
|
+
}
|
package/src/state/block.mjs
CHANGED
|
@@ -18,11 +18,11 @@ import path from 'node:path';
|
|
|
18
18
|
import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
|
|
19
19
|
import { exec } from '../util/exec.mjs';
|
|
20
20
|
import { DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
|
|
21
|
-
import { externalReviewer } from './config-snapshot.mjs';
|
|
21
|
+
import { blockEndHook, externalReviewer } from './config-snapshot.mjs';
|
|
22
22
|
import { ownedKinds, pathKind } from './owned-kinds.mjs';
|
|
23
23
|
import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
24
24
|
import { StateError } from './paths.mjs';
|
|
25
|
-
import { assertOwned,
|
|
25
|
+
import { assertOwned, blocksOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
|
|
26
26
|
import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
|
|
27
27
|
import { verifyRow, loadKey } from './signer.mjs';
|
|
28
28
|
|
|
@@ -261,34 +261,42 @@ function activeBlocks(record) {
|
|
|
261
261
|
* is the owner's switch past the local per-file review, so no block may own it. These are the
|
|
262
262
|
* spellings an owned entry may match it by: relative to the workspace and, for a workspace in a
|
|
263
263
|
* subdirectory, relative to the repository top level (the close gate matches both, `ownsAt`).
|
|
264
|
-
* Empty when the key is not set.
|
|
265
|
-
*
|
|
264
|
+
* Empty when the key is not set. B64b: `hooks.on_block_end` in the snapshot guards it the same way
|
|
265
|
+
* — the file names a command the orchestrator runs at every block end — and `why` names the key
|
|
266
|
+
* that guards it (`review.external.required` first, so its message is unchanged).
|
|
267
|
+
* @param {Record<string, any>} record @returns {Promise<{paths: string[], why: string}>}
|
|
266
268
|
* @throws {StateError} `config-snapshot`, `git-failed`
|
|
267
269
|
*/
|
|
268
270
|
async function guardedConfigPaths(record) {
|
|
269
|
-
|
|
271
|
+
const why = externalReviewer(record) !== null ? 'review.external.required is true' : blockEndHook(record) !== null ? 'hooks.on_block_end is set' : null;
|
|
272
|
+
if (why === null) return { paths: [], why: '' };
|
|
270
273
|
const top = await repoTop(record.workspace);
|
|
271
274
|
// the repo-top spelling `<prefix>/.code-forge.yml` may also match a different file of the same
|
|
272
275
|
// name under the workspace (`<ws>/<prefix>/.code-forge.yml`): that over-match only refuses more,
|
|
273
276
|
// never less, and is the one `ownsAt` gives the close gate
|
|
274
277
|
const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
|
|
275
|
-
return prefix === '' ? [DEFAULT_CONFIG_FILENAME] : [DEFAULT_CONFIG_FILENAME, `${prefix}/${DEFAULT_CONFIG_FILENAME}`];
|
|
278
|
+
return { paths: prefix === '' ? [DEFAULT_CONFIG_FILENAME] : [DEFAULT_CONFIG_FILENAME, `${prefix}/${DEFAULT_CONFIG_FILENAME}`], why };
|
|
276
279
|
}
|
|
277
280
|
|
|
278
281
|
/**
|
|
279
282
|
* B58: refuse an owned entry that owns one of `guarded` (B57's rule, `ownsFile`: the exact path,
|
|
280
283
|
* a directory above it, or a glob that matches it).
|
|
281
|
-
* @param {ReadonlyArray<string>} owned @param {import('./registry.mjs').OwnedKinds} kinds @param {ReadonlyArray<string
|
|
284
|
+
* @param {ReadonlyArray<string>} owned @param {import('./registry.mjs').OwnedKinds} kinds @param {{paths: ReadonlyArray<string>, why: string}} guarded
|
|
282
285
|
* @throws {StateError} `config-owned`
|
|
283
286
|
*/
|
|
284
287
|
function refuseConfigOwner(owned, kinds, guarded) {
|
|
285
288
|
for (const entry of owned) {
|
|
286
|
-
if (guarded.some((file) => ownsFile([entry], file, kinds))) {
|
|
287
|
-
throw new StateError('config-owned', `owned entry ${JSON.stringify(entry)} covers the project's ${DEFAULT_CONFIG_FILENAME}; no block may own it while
|
|
289
|
+
if (guarded.paths.some((file) => ownsFile([entry], file, kinds))) {
|
|
290
|
+
throw new StateError('config-owned', `owned entry ${JSON.stringify(entry)} covers the project's ${DEFAULT_CONFIG_FILENAME}; no block may own it while ${guarded.why}`);
|
|
288
291
|
}
|
|
289
292
|
}
|
|
290
293
|
}
|
|
291
294
|
|
|
295
|
+
/**
|
|
296
|
+
* @typedef {{path: string, branch: string, base_sha: string}} TreeRecord - a block entry's `worktree` (B62a).
|
|
297
|
+
* @typedef {{worktree: TreeRecord, created: boolean, rollback: (() => Promise<string | null>) | null}} OpenTree - `rollback` resolves to a sentence naming leftovers, or null.
|
|
298
|
+
*/
|
|
299
|
+
|
|
292
300
|
/**
|
|
293
301
|
* `block open` — refuses a second open of the same id and any owned set that overlaps another
|
|
294
302
|
* ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast and the
|
|
@@ -304,13 +312,29 @@ function refuseConfigOwner(owned, kinds, guarded) {
|
|
|
304
312
|
* `dispatch` row — under the same lock, so a refused open writes neither. Its `answer` is the
|
|
305
313
|
* requested lane and must equal `level` (else `bad-decision`, nothing written); the docs floor may
|
|
306
314
|
* still raise the dispatched level, and the dispatch row then names the lane as `requested_level`.
|
|
315
|
+
* B60: `tests` (`block open --tests <paths…>`, the same path rules as owned entries, else
|
|
316
|
+
* `bad-tests`) is recorded as the entry's `test_files` and on the dispatch row; a gate argv item
|
|
317
|
+
* `{test_files}` expands to them (`gates/expand.mjs`). Left out, neither carries the key.
|
|
318
|
+
* B62a: `worktree` (given by `block open` under `proof.isolation: worktree`, `proof/worktree.mjs`;
|
|
319
|
+
* this module never imports it) plans the block's own worktree, or the reuse of the one an earlier
|
|
320
|
+
* entry of the same id recorded (`previous`); its `make()` makes or reuses it. Both are called
|
|
321
|
+
* under the lock before anything is recorded: the plan before the overlap check (which lets this
|
|
322
|
+
* block share owned paths with another open block on a different tree, `blocksOverlap`), `make()`
|
|
323
|
+
* after every other check. A refusal there writes nothing; if the rows or the save fail after
|
|
324
|
+
* `make()` created a tree, its `rollback()` removes it again, and the original error is thrown
|
|
325
|
+
* with a sentence added naming anything the rollback could not remove. The entry stores the tree's
|
|
326
|
+
* `{path, branch, base_sha}` as `worktree`; a tree it made is a signed `worktree.created
|
|
327
|
+
* {block, path, branch, base}` row just before `dispatch`. On a re-open the block's base is the
|
|
328
|
+
* tree's `base_sha`, and a `base` that resolves to another commit is refused (`worktree-base`).
|
|
307
329
|
* @param {{
|
|
308
330
|
* runId: string, id: string, level: string, owned: string[], acceptance: unknown,
|
|
309
331
|
* attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
|
|
310
|
-
* kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date,
|
|
332
|
+
* kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[],
|
|
311
333
|
* decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
|
|
334
|
+
* worktree?: (ctx: {baseSha: string, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
|
|
312
335
|
* }} opts
|
|
313
|
-
* @returns {Promise<{block: Record<string, any>, pointer: string | null}>}
|
|
336
|
+
* @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean}>} `worktreeReused`:
|
|
337
|
+
* the block took an earlier attempt's worktree as it was.
|
|
314
338
|
*/
|
|
315
339
|
export async function openBlock(opts) {
|
|
316
340
|
const { runId, id, level, attempt = 1, lines = null, writeRow, now = new Date() } = opts;
|
|
@@ -322,6 +346,7 @@ export async function openBlock(opts) {
|
|
|
322
346
|
if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
|
|
323
347
|
const owned = assertOwned(opts.owned);
|
|
324
348
|
const acceptance = assertAcceptance(opts.acceptance);
|
|
349
|
+
const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
|
|
325
350
|
const kind = blockKind({ owned, declared: opts.kind });
|
|
326
351
|
const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
|
|
327
352
|
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
@@ -330,35 +355,70 @@ export async function openBlock(opts) {
|
|
|
330
355
|
// not wait on it); under the lock the base is resolved again and must be the one stamped against
|
|
331
356
|
const before = await readRun(runId);
|
|
332
357
|
const { workspace } = before;
|
|
333
|
-
|
|
358
|
+
// B62a: a re-open of a block with a worktree keeps the tree's base — the block's base is the
|
|
359
|
+
// commit its tree started from, and a different --base is refused before anything is touched
|
|
360
|
+
/** @param {Record<string, any>} record @returns {{path: string, branch: string, base_sha: string} | null} */
|
|
361
|
+
const previousTree = (record) => (opts.worktree ? record.blocks[id]?.worktree ?? null : null);
|
|
362
|
+
const previous = previousTree(before);
|
|
363
|
+
const baseRef = previous ? previous.base_sha : opts.base ?? 'HEAD';
|
|
364
|
+
const stampedBase = await resolveCommit(baseRef, workspace);
|
|
365
|
+
if (previous && opts.base !== undefined && (await resolveCommit(opts.base, workspace)) !== previous.base_sha) {
|
|
366
|
+
throw new StateError('worktree-base', `block ${id}: --base ${opts.base} is not ${previous.base_sha.slice(0, 12)}, the base of the block's worktree ${previous.path}; a re-open keeps the worktree and its base — drop --base; nothing was recorded`);
|
|
367
|
+
}
|
|
334
368
|
const treeOpen = await treeAtOpen(workspace, stampedBase);
|
|
335
369
|
// B58: `review.external` cannot change mid-run, so the record read here decides
|
|
336
370
|
const guarded = await guardedConfigPaths(before);
|
|
337
371
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
372
|
+
/** @type {OpenTree | null} */
|
|
373
|
+
let made = null;
|
|
374
|
+
try {
|
|
375
|
+
const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
|
|
376
|
+
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
377
|
+
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
378
|
+
// file owns only itself, here and in every later check
|
|
379
|
+
const kinds = ownedKinds(record.workspace, owned);
|
|
380
|
+
refuseConfigOwner(owned, kinds, guarded);
|
|
381
|
+
const prior = previousTree(record);
|
|
382
|
+
const baseSha = await resolveCommit(prior ? prior.base_sha : opts.base ?? 'HEAD', record.workspace);
|
|
383
|
+
if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
|
|
384
|
+
// B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
|
|
385
|
+
const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
|
|
386
|
+
const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
|
|
387
|
+
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
388
|
+
const pair = blocksOverlap(self, other);
|
|
389
|
+
if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
390
|
+
}
|
|
391
|
+
// B62a: the worktree is made (or an earlier attempt's reused) last, once every other check
|
|
392
|
+
// passed; a tree made here is rolled back below if the rows or the save then fail
|
|
393
|
+
made = planned ? await planned.make() : null;
|
|
394
|
+
const tree = made;
|
|
395
|
+
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
|
|
396
|
+
record.blocks[id] = entry;
|
|
397
|
+
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
398
|
+
const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
|
|
399
|
+
return {
|
|
400
|
+
result: { block: entry, reused: tree !== null && !tree.created },
|
|
401
|
+
rows: [
|
|
402
|
+
...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
|
|
403
|
+
...created,
|
|
404
|
+
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
|
|
405
|
+
],
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
return { block, pointer, worktreeReused: reused };
|
|
409
|
+
} catch (err) {
|
|
410
|
+
// B62a: nothing was recorded, so a tree this open made must not outlive it
|
|
411
|
+
if (made?.created && made.rollback) {
|
|
412
|
+
// the original error is thrown either way; what could not be removed is named in it (the
|
|
413
|
+
// rollback checks each git result and resolves, it does not throw)
|
|
414
|
+
const left = await made.rollback();
|
|
415
|
+
if (left !== null) {
|
|
416
|
+
if (!(err instanceof Error)) throw new Error(`${String(err).replace(/\.?$/, '.')} ${left}`, { cause: err });
|
|
417
|
+
err.message = `${err.message.replace(/\.?$/, '.')} ${left}`;
|
|
418
|
+
}
|
|
347
419
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
|
|
351
|
-
record.blocks[id] = entry;
|
|
352
|
-
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
353
|
-
return {
|
|
354
|
-
result: entry,
|
|
355
|
-
rows: [
|
|
356
|
-
...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
|
|
357
|
-
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines },
|
|
358
|
-
],
|
|
359
|
-
};
|
|
360
|
-
});
|
|
361
|
-
return { block, pointer };
|
|
420
|
+
throw err;
|
|
421
|
+
}
|
|
362
422
|
}
|
|
363
423
|
|
|
364
424
|
/** @param {{runId: string, id: string, writeRow: WriteRow}} opts */
|
|
@@ -374,12 +434,15 @@ export async function attemptBlock({ runId, id, writeRow }) {
|
|
|
374
434
|
* in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
|
|
375
435
|
* (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
|
|
376
436
|
* is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
|
|
377
|
-
* below it).
|
|
437
|
+
* below it). B62a: refused for a block with its own worktree (`worktree-rebase`).
|
|
378
438
|
* @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
|
|
379
439
|
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
380
440
|
*/
|
|
381
441
|
export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
382
442
|
return mutateRun(runId, id, writeRow, async (record, block) => {
|
|
443
|
+
// B62a: a worktree block's base is the commit its tree started from; moving it would leave the
|
|
444
|
+
// tree on its old base, so the rebase is refused
|
|
445
|
+
if (block.worktree) throw new StateError('worktree-rebase', `block ${id} has its own worktree ${block.worktree.path} on branch ${block.worktree.branch}; its base is the base of that tree, so block rebase does not apply — work on the branch in the tree instead`);
|
|
383
446
|
const cwd = record.workspace;
|
|
384
447
|
const newBase = await resolveCommit(head, cwd);
|
|
385
448
|
const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
|
|
@@ -396,10 +459,11 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
396
459
|
/**
|
|
397
460
|
* `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
|
|
398
461
|
* glob, and when another open block owns it — or anything it would own: the claimed path becomes an
|
|
399
|
-
* owned entry, so it is checked with `
|
|
462
|
+
* owned entry, so it is checked with `blocksOverlap` (B57: an entry owns the paths below it too,
|
|
400
463
|
* unless it is a regular file now — its kind is recorded in `owned_kinds` as at `block open`).
|
|
401
464
|
* B58: with `review.external.required` set, a path that owns the project's config is refused
|
|
402
|
-
* (`config-owned`), as at `block open`.
|
|
465
|
+
* (`config-owned`), as at `block open`. B62a: two blocks that each have their own worktree may
|
|
466
|
+
* own the same path (`blocksOverlap`).
|
|
403
467
|
* @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
|
|
404
468
|
*/
|
|
405
469
|
export async function claimPath({ runId, id, file, writeRow }) {
|
|
@@ -410,7 +474,7 @@ export async function claimPath({ runId, id, file, writeRow }) {
|
|
|
410
474
|
const kinds = { [file]: pathKind(record.workspace, file) };
|
|
411
475
|
refuseConfigOwner([file], kinds, guarded);
|
|
412
476
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
413
|
-
const pair = otherId === id ? null :
|
|
477
|
+
const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: block.worktree ?? null }, other);
|
|
414
478
|
if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
415
479
|
}
|
|
416
480
|
if (!block.owned_files.includes(file)) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B64a (issue #13 item 6): the short status of one run — the data `code-forge status` prints, as
|
|
3
|
+
* text or as one JSON line. A pure function: it reads the run record and the ledger rows it is
|
|
4
|
+
* handed and nothing else (no file, no lock, no row written); the CLI (`src/cli/status.mjs`) reads
|
|
5
|
+
* them and renders the result. The object it returns has exactly the keys of the `--json` run
|
|
6
|
+
* object, in order: run_id, status, mode, spent_usd, unknown_usd_sessions, budget_usd, blocks,
|
|
7
|
+
* more, closed, stopped.
|
|
8
|
+
*
|
|
9
|
+
* Per run: its status (null when the record has none — `run status` then prints no status either;
|
|
10
|
+
* the text shows `-`), its mode (`brief` for a `run start --mode brief` run, else `plan`), the
|
|
11
|
+
* spend (`runSpend`, the same numbers `run status` prints as spent_usd and unknown_usd_sessions;
|
|
12
|
+
* both null when the caller could not read the ledger, `rows: null`) and the budget the caller
|
|
13
|
+
* read, then one entry per block that is neither closed nor stopped — closed and stopped blocks are
|
|
14
|
+
* only counted. A block entry holds:
|
|
15
|
+
* - `state`: `open` on its first attempt, `attempt-<n>` after `block attempt` raised it to n; a
|
|
16
|
+
* block whose record status is anything other than open shows that status as its state;
|
|
17
|
+
* - `level`: the level it was dispatched at (the run record);
|
|
18
|
+
* - `model`: `<provider>/<model>` (or the model alone) of the block's latest coder `session` /
|
|
19
|
+
* `session.background` row of the run that names a model — the rows `src/session/spawn.mjs`
|
|
20
|
+
* writes carry `role`, `provider`, `model`; a `ledger add coder` row (`manual`, no model) is
|
|
21
|
+
* skipped — null when the ledger names none;
|
|
22
|
+
* - `gate`: the block's LAST gate run — `gate.done` says pass (`all_ok: true`) or fail, a
|
|
23
|
+
* `gate.red` with no `gate.done` after it is a fail; the name is the failing gate (the last
|
|
24
|
+
* `gate.red` of that run), null on a pass; `{result: 'none', name: null}` when no gate ran;
|
|
25
|
+
* - `findings`: the open review findings of the block, counted by the caller's `findingsOf`
|
|
26
|
+
* (the CLI counts what the block close gate would refuse, `src/review/gate-check.mjs`).
|
|
27
|
+
* Blocks are in block id order (numeric-aware: B2 before B10).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { runSpend } from '../ledger/spend.mjs';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {{id: string, state: string, level: string | null, model: string | null, gate: {result: 'pass' | 'fail' | 'none', name: string | null}, findings: number}} BlockStatus
|
|
34
|
+
* @typedef {{run_id: string, status: string | null, mode: 'plan' | 'brief', spent_usd: number | null, unknown_usd_sessions: number | null, budget_usd: number | null, blocks: BlockStatus[], more: number, closed: number, stopped: number}} RunStatus
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** @param {string} a @param {string} b @returns {number} */
|
|
38
|
+
const byBlockId = (a, b) => a.localeCompare(b, 'en', { numeric: true }) || (a < b ? -1 : a > b ? 1 : 0);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The block's last gate run (see the module doc).
|
|
42
|
+
* @param {Array<Record<string, any>>} mine - the block's rows of the run, in ledger order.
|
|
43
|
+
* @returns {{result: 'pass' | 'fail' | 'none', name: string | null}}
|
|
44
|
+
*/
|
|
45
|
+
export function lastGate(mine) {
|
|
46
|
+
/** @type {string | null} */
|
|
47
|
+
let red = null;
|
|
48
|
+
/** @type {{result: 'pass' | 'fail' | 'none', name: string | null}} */
|
|
49
|
+
let last = { result: 'none', name: null };
|
|
50
|
+
for (const r of mine) {
|
|
51
|
+
if (r.event === 'gate.red') {
|
|
52
|
+
red = typeof r.gate === 'string' ? r.gate : null;
|
|
53
|
+
last = { result: 'fail', name: red };
|
|
54
|
+
} else if (r.event === 'gate.done') {
|
|
55
|
+
last = r.all_ok === true ? { result: 'pass', name: null } : { result: 'fail', name: red };
|
|
56
|
+
red = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return last;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The engine/model of the block's latest coder session row, or null.
|
|
64
|
+
* @param {Array<Record<string, any>>} mine @returns {string | null}
|
|
65
|
+
*/
|
|
66
|
+
export function coderModel(mine) {
|
|
67
|
+
for (let i = mine.length - 1; i >= 0; i -= 1) {
|
|
68
|
+
const r = mine[i];
|
|
69
|
+
if ((r.event === 'session' || r.event === 'session.background') && r.role === 'coder' && typeof r.model === 'string' && r.model.length > 0) {
|
|
70
|
+
return typeof r.provider === 'string' && r.provider.length > 0 ? `${r.provider}/${r.model}` : r.model;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {{
|
|
78
|
+
* record: Record<string, any>, rows: Array<Record<string, any>> | null, budget: number | null,
|
|
79
|
+
* findingsOf: (blockId: string) => number,
|
|
80
|
+
* }} opts - `rows`: the project's ledger rows (other runs are filtered out here), null when the
|
|
81
|
+
* ledger could not be read; `budget`: the workspace's `budget.usd` (null when unset), as
|
|
82
|
+
* `run status` reads it.
|
|
83
|
+
* @returns {RunStatus}
|
|
84
|
+
*/
|
|
85
|
+
export function briefStatus({ record, rows, budget, findingsOf }) {
|
|
86
|
+
const runId = String(record.run_id);
|
|
87
|
+
/** @type {Map<string, Array<Record<string, any>>>} the run's rows, grouped by block once */
|
|
88
|
+
const byBlock = new Map();
|
|
89
|
+
for (const r of rows ?? []) {
|
|
90
|
+
if (r?.run !== runId || typeof r.block !== 'string') continue;
|
|
91
|
+
const list = byBlock.get(r.block);
|
|
92
|
+
if (list) list.push(r);
|
|
93
|
+
else byBlock.set(r.block, [r]);
|
|
94
|
+
}
|
|
95
|
+
const spend = rows == null ? null : runSpend(rows, runId);
|
|
96
|
+
const entries = record.blocks && typeof record.blocks === 'object' ? record.blocks : {};
|
|
97
|
+
const ids = Object.keys(entries).sort(byBlockId);
|
|
98
|
+
/** @type {BlockStatus[]} */
|
|
99
|
+
const blocks = [];
|
|
100
|
+
let closed = 0;
|
|
101
|
+
let stopped = 0;
|
|
102
|
+
for (const id of ids) {
|
|
103
|
+
const entry = entries[id];
|
|
104
|
+
const status = typeof entry?.status === 'string' ? entry.status : 'open';
|
|
105
|
+
if (status === 'closed') closed += 1;
|
|
106
|
+
else if (status === 'stopped') stopped += 1;
|
|
107
|
+
if (status === 'closed' || status === 'stopped') continue;
|
|
108
|
+
const mine = byBlock.get(id) ?? [];
|
|
109
|
+
const attempt = Number.isInteger(entry?.attempt) ? entry.attempt : 1;
|
|
110
|
+
blocks.push({
|
|
111
|
+
id,
|
|
112
|
+
state: status !== 'open' ? status : attempt > 1 ? `attempt-${attempt}` : 'open',
|
|
113
|
+
level: typeof entry?.level === 'string' ? entry.level : null,
|
|
114
|
+
model: coderModel(mine),
|
|
115
|
+
gate: lastGate(mine),
|
|
116
|
+
findings: findingsOf(id),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
run_id: runId,
|
|
121
|
+
status: typeof record.status === 'string' ? record.status : null,
|
|
122
|
+
mode: record.mode === 'brief' ? 'brief' : 'plan',
|
|
123
|
+
spent_usd: spend === null ? null : spend.usd,
|
|
124
|
+
unknown_usd_sessions: spend === null ? null : spend.unknown,
|
|
125
|
+
budget_usd: budget,
|
|
126
|
+
blocks,
|
|
127
|
+
more: 0,
|
|
128
|
+
closed,
|
|
129
|
+
stopped,
|
|
130
|
+
};
|
|
131
|
+
}
|