bareloop 0.27.0 → 0.29.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.
@@ -0,0 +1,1527 @@
1
+ // PANEL-BUILD.md P1 — the read-only panel server. `node:http` ONLY (no new
2
+ // dependency — the one-production-dependency bar already spends its one slot
3
+ // on bare-agent, LIBRARY_CONVENTIONS.md). Layering law (PANEL-BUILD.md §2):
4
+ // this file is one more CALLER of the same `src/` library functions
5
+ // `src/cli.js` calls — `src/replayio.js`'s read side and `src/runlist.js`'s
6
+ // run list — never a re-implementation of either.
7
+ //
8
+ // READ-ONLY, BY CONSTRUCTION: GET/HEAD only (anything else -> 405); no
9
+ // endpoint runs a job, spends money, signs, or reads a key/.env. Nothing
10
+ // here imports `src/providers.js` or touches `process.env` for a secret —
11
+ // grepped before writing this file, and the same discipline is kept here.
12
+ //
13
+ // PATH SAFETY: a URL may name a runid ONLY (validated against
14
+ // `RUNID_RE` below); the server looks that runid up in the run list and
15
+ // reads ONLY the path stored there for that row. A URL segment is NEVER
16
+ // joined into a filesystem path directly — the one exception is the fixed,
17
+ // whitelisted `/` route, which always serves this same directory's own
18
+ // `index.html`, never a URL-derived filename.
19
+ //
20
+ // Bound to 127.0.0.1 ONLY (PANEL-BUILD.md P1's own port-4700 note). A taken
21
+ // port fails LOUDLY (prints the port, exits non-zero) — this module never
22
+ // silently tries another port (hamr's rule, restated across this codebase
23
+ // for every cap/threshold: a shell never widens what it was asked to do).
24
+
25
+ import { createServer } from 'node:http';
26
+ import { readFileSync, existsSync, statSync } from 'node:fs';
27
+ import {
28
+ dirname, join, basename, relative, isAbsolute, sep,
29
+ } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { readRunList } from '../runlist.js';
32
+ import {
33
+ replayOne, parseJsonl, resolveSiblings, isSidecarByName,
34
+ } from '../replayio.js';
35
+ import { summarizeForAllLine, auditWindow } from '../replay.js';
36
+ import { runBehaviour } from '../behaviour.js';
37
+ import { SPEND_RECORD_TYPES } from '../ledger.js';
38
+ import { jobSpecHash } from '../job.js';
39
+ import { confirmProtections } from '../authorflow.js';
40
+
41
+ const HERE = dirname(fileURLToPath(import.meta.url));
42
+
43
+ /**
44
+ * A runid, as it appears in a URL path segment — never a raw filesystem
45
+ * path. `~` is included so a backfill-disambiguated runid (F197:
46
+ * `src/runlist.js`'s `backfillRuns`, e.g. `run~2` when two archived spines
47
+ * derive the same filename-based runid) is a legal, reachable id, never a
48
+ * 400.
49
+ */
50
+ export const RUNID_RE = /^[A-Za-z0-9._~-]+$/;
51
+
52
+ /** Default bind port (PANEL-BUILD.md P1: checked free on the build machine, not guaranteed elsewhere — the server still fails loudly on collision, see {@link createPanelServer}). */
53
+ export const DEFAULT_PORT = 4700;
54
+
55
+ /**
56
+ * `[✓]`/`[✗]`/`[▶]` — the ONLY vocabulary a result is ever rendered in
57
+ * (auto-memory `ui-verdict-words.md`: never the words green/red/soft-green
58
+ * anywhere in the page). `null` (no `job-end` reached — a killed-mid-run or
59
+ * still-running spine) reads `▶` — the same "in progress / unresolved" glyph
60
+ * P1's own four-glyph vocabulary reserves for that state; a genuinely still-
61
+ * running attempt and an archived spine that never reached its own end are
62
+ * the same fact from this read-only side: no verdict has been recorded yet.
63
+ * @param {string|null} outcome
64
+ * @returns {'✓'|'✗'|'▶'}
65
+ */
66
+ export function glyphForOutcome(outcome) {
67
+ if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') return '✓';
68
+ if (outcome === null || outcome === undefined) return '▶';
69
+ return '✗';
70
+ }
71
+
72
+ /**
73
+ * Soft-green was admitted at commit 30df0f9 (2026-08-18) — before that,
74
+ * `green` was the only admissible verdict type, so a run started before this
75
+ * moment that carries no `verdictType` at all (pre-F117 spine) really was
76
+ * deterministic; there was no other check type it could have been. Tighten-
77
+ * only, named so a future change is a deliberate, visible edit (item 3,
78
+ * 2026-09-25).
79
+ */
80
+ export const SOFTGREEN_ADMITTED_ISO = '2026-08-18T00:00:00.000Z';
81
+
82
+ /**
83
+ * `true` when `atIso` parses to a moment strictly before {@link
84
+ * SOFTGREEN_ADMITTED_ISO}. An unparseable/missing `atIso` reads `false`
85
+ * (never assumed old) — an unknown date is a genuinely unknown check type,
86
+ * not a free pass to guess "old".
87
+ * @param {string|null|undefined} atIso
88
+ * @returns {boolean}
89
+ */
90
+ function isPreSoftgreen(atIso) {
91
+ const t = typeof atIso === 'string' ? Date.parse(atIso) : NaN;
92
+ return Number.isFinite(t) && t < Date.parse(SOFTGREEN_ADMITTED_ISO);
93
+ }
94
+
95
+ /**
96
+ * `deterministic` (green) / `rubric` (soft-green) / `unknown` (a class this
97
+ * reader doesn't recognize) — the settled UI wording (PANEL-BUILD.md §6),
98
+ * never the internal `green`/`soft-green` spelling. Item 3 (2026-09-25): a
99
+ * spine with NO `verdictType` (pre-F117) is genuinely ambiguous UNLESS its
100
+ * own run date (`atIso`, the job-start ts) predates {@link
101
+ * SOFTGREEN_ADMITTED_ISO} — in that one case, `deterministic` was the only
102
+ * check type that could have run, so it is reported as such (not fabricated:
103
+ * derived from the frozen historical fact that soft-green did not exist
104
+ * yet), paired with {@link checkTypeTitle}'s tooltip explaining the
105
+ * derivation rather than presenting it as if it had been directly recorded.
106
+ * `atIso` is optional so every existing single-arg caller/test keeps its
107
+ * prior behavior (no date -> 'unknown', same as before this item).
108
+ * @param {string|null} verdictType
109
+ * @param {string|null} [atIso] the run's own job-start timestamp
110
+ * @returns {'deterministic'|'rubric'|'unknown'}
111
+ */
112
+ export function checkTypeLabel(verdictType, atIso) {
113
+ if (verdictType === 'green') return 'deterministic';
114
+ if (verdictType === 'soft-green') return 'rubric';
115
+ if (isPreSoftgreen(atIso)) return 'deterministic';
116
+ return 'unknown';
117
+ }
118
+
119
+ /**
120
+ * The tooltip/title text for {@link checkTypeLabel}'s pre-softgreen-cutoff
121
+ * branch — `null` for every other case (a real `verdictType` needs no
122
+ * explanation; a genuinely unknown one has none to give).
123
+ * @param {string|null} verdictType
124
+ * @param {string|null} [atIso]
125
+ * @returns {string|null}
126
+ */
127
+ export function checkTypeTitle(verdictType, atIso) {
128
+ if (verdictType === 'green' || verdictType === 'soft-green') return null;
129
+ if (isPreSoftgreen(atIso)) return 'not recorded — deterministic was the only check type before 2026-08-18';
130
+ return null;
131
+ }
132
+
133
+ // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` never shares
134
+ // the failed glyph — [✗] stays reserved for a run whose close/arbiter
135
+ // actually rendered a "no" (a real result). A spine that just stops, with
136
+ // no ending ever recorded, is a DIFFERENT fact (killed, crashed, or the
137
+ // machine slept) and gets its own [?] glyph. The distinguishing signal is
138
+ // the spine FILE's own mtime, never wall-clock "now minus job-start" (a
139
+ // resumed/paused run can legitimately sit quiet for a long time without
140
+ // having died): still fresh (written to within this window) reads as the
141
+ // existing `running` [▶] state; older than this reads as died. Tighten-only,
142
+ // named so a future change is a deliberate, visible edit.
143
+ export const DIED_MTIME_MS = 10 * 60 * 1000;
144
+
145
+ /**
146
+ * Plain-words description of ONE spine record, for the died "why" sentence's
147
+ * "Last thing it did: …" clause. Built from the REAL record shapes this
148
+ * codebase's own spines carry (grepped, and read directly off a real dead
149
+ * archive — poc-sjisejl8 — while building this), never a guess: an
150
+ * unrecognized `type` still gets an honest, literal fallback rather than a
151
+ * silently wrong label.
152
+ * @param {any} r
153
+ * @returns {string}
154
+ */
155
+ function describeLastRecord(r) {
156
+ if (!r || typeof r !== 'object') return 'nothing (no record at all)';
157
+ const type = typeof r.type === 'string' ? r.type : 'unknown';
158
+ if (type === 'worker-round' || type === 'worker-turn' || type === 'judge-round') {
159
+ if (typeof r.phase === 'string' && r.phase.startsWith('step:')) {
160
+ const stepId = r.phase.slice('step:'.length);
161
+ return typeof r.iteration === 'number' ? `step ${stepId} iteration ${r.iteration}` : `step ${stepId}`;
162
+ }
163
+ if (typeof r.phase === 'string' && r.phase.length > 0) return `a ${r.phase} model call`;
164
+ return type === 'judge-round' ? 'a judge model call' : 'a model call';
165
+ }
166
+ /** @type {Record<string, (r: any) => string>} */
167
+ const labels = {
168
+ 'job-start': () => 'starting the job',
169
+ 'scout-start': () => 'starting the scout',
170
+ 'scout-result': () => 'the scout finishing',
171
+ 'check-run': () => 'a check run',
172
+ 'check-preflight': () => 'a preflight check',
173
+ 'check-menu': () => 'building the check menu',
174
+ 'step-start': (rec) => `starting step ${rec.step ?? '?'}`,
175
+ 'step-end': (rec) => `finishing step ${rec.step ?? '?'}`,
176
+ 'iteration-start': (rec) => `starting iteration ${rec.iteration ?? '?'}`,
177
+ 'exit-eval': (rec) => `an exit check${typeof rec.step === 'string' ? ` for step ${rec.step}` : ''}`,
178
+ materials: () => 'gathering materials',
179
+ 'plan-validate': () => 'validating the plan',
180
+ 'plan-accepted': () => 'accepting the plan',
181
+ 'plan-executed': () => 'finishing the plan',
182
+ 'work-branch': () => 'preparing the work branch',
183
+ 'scope-menu': () => 'building the scope menu',
184
+ 'close-verdict': () => 'a close verdict',
185
+ 'close-precheck': () => 'a close precheck',
186
+ 'close-timing': () => 'timing the close',
187
+ 'primitive-smoke': () => 'a primitive smoke check',
188
+ engagement: () => 'starting the engagement',
189
+ 'wall-clock': () => 'reading the wall clock',
190
+ ladder: () => 'a strike-ladder check',
191
+ 'fix-loop': () => 'a fix-loop iteration',
192
+ 'transport-retry': () => 'a transport retry',
193
+ 'memory-cache': () => 'reading the memory cache',
194
+ 'run-start': () => 'starting the run',
195
+ 'run-end': () => 'ending the run',
196
+ 'outer-close': () => 'the outer close',
197
+ 'attempt-bounded': () => 'bounding the attempt',
198
+ 'middle-done': () => 'finishing a middle step',
199
+ 'resume-seed': () => 'seeding a resume',
200
+ escalation: (rec) => `an escalation${typeof rec.category === 'string' ? ` (${rec.category})` : ''}`,
201
+ };
202
+ const label = labels[type];
203
+ return label ? label(r) : `a ${type} record`;
204
+ }
205
+
206
+ /**
207
+ * `YYYY-MM-DD HH:MM` (local time, 24h, zero-padded) — the ONE
208
+ * timestamp-WITH-TIME format this page ever shows (a plain date elsewhere
209
+ * stays `YYYY-MM-DD`, e.g. `row.at.slice(0,10)`, untouched — that's a
210
+ * different, shorter field, not this helper's concern). Used instead of a
211
+ * locale-dependent `toLocaleString()` spelling (F195: the died "why" line
212
+ * read `9/9/2026, 11:49:40 AM`, inconsistent with every other date on the
213
+ * page) so a future second timestamp-with-time spot routes through the same
214
+ * one function rather than growing its own `toLocale*` call.
215
+ * @param {string|number|Date} ts
216
+ * @returns {string}
217
+ */
218
+ export function formatTimestamp(ts) {
219
+ const d = new Date(ts);
220
+ if (Number.isNaN(d.getTime())) return 'an unknown time';
221
+ const pad = (n) => String(n).padStart(2, '0');
222
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
223
+ }
224
+
225
+ /**
226
+ * Died-run derivation: `died` (the mtime rule above), the plain-words
227
+ * `why` sentence, the priced-rounds spend floor, and the first→last
228
+ * record wall floor — all `null`/`false` when this run is NOT died (a real
229
+ * `job-end` was reached, or the file is still fresh). Reads the spine's raw
230
+ * records directly (never re-derives from `replayRun`'s own windowed
231
+ * fields, which assume a `job-end` exists) — the ONE owner of this
232
+ * derivation, used by every caller below (`/api/runs`, `/api/runs/:id`) so
233
+ * the two never drift apart.
234
+ * @param {string} spinePath
235
+ * @param {any[]} records raw parsed spine records (already read once by the caller)
236
+ * @param {string|null} outcome `replayRun`'s own `summary.outcome`
237
+ * @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
238
+ */
239
+ function deriveDeath(spinePath, records, outcome) {
240
+ const notDied = {
241
+ died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
242
+ };
243
+ if (outcome !== null && outcome !== undefined) return notDied; // a real job-end was reached
244
+ let mtimeMs;
245
+ try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return notDied; }
246
+ if (Date.now() - mtimeMs <= DIED_MTIME_MS) return notDied; // still fresh — genuinely `running`, not died
247
+
248
+ const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
249
+ const last = withTs.length ? withTs[withTs.length - 1] : null;
250
+ const when = last ? formatTimestamp(last.ts) : 'an unknown time';
251
+ const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${describeLastRecord(last)} at ${when}.`;
252
+
253
+ let spendSum = 0;
254
+ let pricedCount = 0;
255
+ for (const r of records) {
256
+ if (!r || typeof r !== 'object' || !SPEND_RECORD_TYPES.includes(r.type)) continue; // worker-result echoes excluded by construction — not a spend type
257
+ if (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd)) { spendSum += r.costUsd; pricedCount += 1; }
258
+ }
259
+ const spendFloorUsd = pricedCount > 0 ? spendSum : null;
260
+
261
+ const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
262
+ const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
263
+ const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
264
+
265
+ return {
266
+ died: true, why, spendFloorUsd, wallFloorMs,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * `duration()`'s own local twin (replay.js keeps its copy private) — used
272
+ * ONLY for the died-row "at least …" wall figure, so this formatting can
273
+ * never silently drift from the row's own normal wall column shape (`6m08s`).
274
+ * @param {number} ms
275
+ * @returns {string}
276
+ */
277
+ function formatDurationMs(ms) {
278
+ const s = ms / 1000;
279
+ if (s < 60) return `${s.toFixed(1)}s`;
280
+ let m = Math.floor(s / 60);
281
+ let rem = Math.round(s - m * 60);
282
+ if (rem === 60) { m += 1; rem = 0; }
283
+ return `${m}m${String(rem).padStart(2, '0')}s`;
284
+ }
285
+
286
+ /**
287
+ * One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
288
+ * spine no longer exists on disk — never silently listed as if it were
289
+ * still there (the same rule {@link import('../runlist.js').formatRunRow}
290
+ * already applies to the CLI's own listing).
291
+ * @param {import('../runlist.js').RunRow} row
292
+ * @returns {any}
293
+ */
294
+ function summarizeRow(row) {
295
+ if (!existsSync(row.spine)) {
296
+ return {
297
+ runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
298
+ };
299
+ }
300
+ let summary;
301
+ let rawRecords;
302
+ try {
303
+ summary = replayOne(row.spine, { skipAudit: true });
304
+ rawRecords = parseJsonl(row.spine).records;
305
+ } catch (e) {
306
+ return {
307
+ runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: false, readError: String(/** @type {Error} */ (e).message),
308
+ };
309
+ }
310
+ const line = summarizeForAllLine(summary);
311
+ const death = deriveDeath(row.spine, rawRecords, summary.outcome);
312
+ return {
313
+ runid: row.runid,
314
+ job: row.job,
315
+ at: row.at,
316
+ via: row.via,
317
+ fileMissing: false,
318
+ died: death.died,
319
+ glyph: death.died ? '?' : glyphForOutcome(summary.outcome),
320
+ checkType: checkTypeLabel(summary.verdictType, row.at),
321
+ checkTypeTitle: checkTypeTitle(summary.verdictType, row.at),
322
+ model: summary.model,
323
+ // died: never "unknown" when a priced round exists — glyph [?] already
324
+ // carries the word "died", so the row meta text itself never repeats it.
325
+ spend: death.died ? (death.spendFloorUsd !== null ? `at least $${death.spendFloorUsd.toFixed(4)}` : 'unknown') : line.spend,
326
+ wall: death.died ? (death.wallFloorMs !== null ? `at least ${formatDurationMs(death.wallFloorMs)}` : 'unknown') : line.wall,
327
+ date: typeof row.at === 'string' ? row.at.slice(0, 10) : null,
328
+ };
329
+ }
330
+
331
+ /**
332
+ * `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
333
+ * append order — a backfill scan appends rows in sorted-PATH order, which
334
+ * does not track chronological `at` order; a plain `.reverse()` here once
335
+ * silently mis-sorted any list `appendRun` didn't build strictly
336
+ * chronologically), each enriched with a cheap ($0, `skipAudit:true`)
337
+ * summary. A row with an unparseable/missing `at` sorts last (Unix epoch 0),
338
+ * never crashes the sort or floats to the top. Ties (identical `at`) keep
339
+ * their original file order among themselves (stable sort) rather than an
340
+ * arbitrary one. The Workflows view (item 2, 2026-09-26 merge) groups this
341
+ * SAME payload by job client-side (`groupRunsByJob` in `index.html`) rather
342
+ * than a second server-side endpoint, so the glyph/checkType mapping is
343
+ * still computed in exactly one place.
344
+ * @param {{ home?: string }} [opts]
345
+ * @returns {any[]}
346
+ */
347
+ export function listRuns(opts = {}) {
348
+ const { rows } = readRunList(opts);
349
+ const atMs = (r) => {
350
+ const t = typeof r?.at === 'string' ? Date.parse(r.at) : NaN;
351
+ return Number.isFinite(t) ? t : 0;
352
+ };
353
+ return rows
354
+ .map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
355
+ .sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
356
+ .map(({ row }) => summarizeRow(row));
357
+ }
358
+
359
+ /**
360
+ * `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
361
+ * iterations), counters, summary-box fields. Looks `runid` up in the run
362
+ * list FIRST and reads only the path stored there (path safety — see file
363
+ * header); `null` when the runid is not in the list at all (caller renders
364
+ * 404).
365
+ * @param {string} runid
366
+ * @param {{ home?: string }} [opts]
367
+ * @returns {any|null}
368
+ */
369
+ export function getRunDetail(runid, opts = {}) {
370
+ const { rows } = readRunList(opts);
371
+ const row = rows.find((r) => r && r.runid === runid);
372
+ if (!row) return null;
373
+ if (!existsSync(row.spine)) {
374
+ return {
375
+ runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
376
+ };
377
+ }
378
+ const summary = replayOne(row.spine);
379
+ const timelineKind = summary.timelineKind;
380
+ const rawSpineRecords = parseJsonl(row.spine).records;
381
+ const death = deriveDeath(row.spine, rawSpineRecords, summary.outcome);
382
+ // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
383
+ // `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
384
+ // close's own paid judge seam, distinct from the worker `model` above.
385
+ // `null` whenever no judge-round was ever recorded (the common case —
386
+ // src/replay.js's own note: no real archived run measured against this
387
+ // build carries one yet), never guessed from the worker model.
388
+ const judgeRoundWithModel = rawSpineRecords.find(
389
+ (r) => r && typeof r === 'object' && r.type === 'judge-round' && typeof r.model === 'string' && r.model.length > 0,
390
+ );
391
+ const judgeModel = judgeRoundWithModel ? judgeRoundWithModel.model : null;
392
+
393
+ /** @param {string|null} outcome @param {boolean} isLast */
394
+ const stateFor = (outcome, isLast) => {
395
+ const isGreen = outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied';
396
+ if (outcome !== null && outcome !== undefined) return isGreen ? 'done' : 'stopped';
397
+ if (isLast && death.died) return 'died';
398
+ if (isLast && summary.outcome === null) return 'running';
399
+ return 'waiting';
400
+ };
401
+
402
+ // item 4 (2026-09-25): a PLAN run keeps one box per step, each carrying its
403
+ // own `attempts` (src/replay.js's new per-step field — one entry per
404
+ // exit-eval that actually ran, reusing that SAME windowing, never a second
405
+ // one). A LOOP-shape run (no step-start at all — an older/non-plan run,
406
+ // `timelineKind:'iterations'`) used to render ONE box PER ITERATION
407
+ // (labelled "1 iteration 1", "2 iteration 2", …, each counted as if it
408
+ // were its own step); it now renders as ONE implicit step whose `attempts`
409
+ // are the run's own iterations, since a loop-shape run's iterations ARE
410
+ // its attempts (there is no separate step layer above them to attach to).
411
+ /** @type {any[]} */
412
+ let steps;
413
+ if (timelineKind === 'iterations') {
414
+ const units = summary.iterations;
415
+ const attempts = units.map((u, idx) => ({
416
+ n: idx + 1,
417
+ iteration: u.iteration,
418
+ outcome: (u.verdict === 'green' || u.verdict === 'already-green' || u.verdict === 'satisfied') ? 'green' : 'red',
419
+ }));
420
+ const rounds = units.reduce((acc, u) => acc + u.rounds, 0);
421
+ const allToolsKnown = units.every((u) => typeof u.toolCalls === 'number');
422
+ const toolCalls = allToolsKnown ? units.reduce((acc, u) => acc + (u.toolCalls ?? 0), 0) : null;
423
+ const allWallKnown = units.every((u) => typeof u.wallMs === 'number');
424
+ const wallMs = allWallKnown ? units.reduce((acc, u) => acc + (u.wallMs ?? 0), 0) : null;
425
+ const unpricedRounds = units.reduce((acc, u) => acc + u.unpricedRounds, 0);
426
+ const spentUsd = unpricedRounds > 0 ? null : units.reduce((acc, u) => acc + (u.spentUsd ?? 0), 0);
427
+ steps = units.length === 0 ? [] : [{
428
+ id: summary.job ?? row.job,
429
+ occurrence: null,
430
+ outcome: summary.outcome,
431
+ state: stateFor(summary.outcome, true),
432
+ rounds,
433
+ toolCalls,
434
+ wallMs,
435
+ spentUsd,
436
+ unpricedRounds,
437
+ checks: null,
438
+ treeChanged: null,
439
+ tripped: units.length ? units[units.length - 1].tripped : null,
440
+ attempts,
441
+ }];
442
+ } else {
443
+ steps = summary.steps.map((u, idx) => {
444
+ const isLast = idx === summary.steps.length - 1;
445
+ return {
446
+ id: u.id,
447
+ occurrence: u.occurrence,
448
+ outcome: u.outcome ?? null,
449
+ state: stateFor(u.outcome, isLast),
450
+ rounds: u.rounds,
451
+ toolCalls: u.toolCalls,
452
+ wallMs: u.wallMs,
453
+ spentUsd: u.spentUsd,
454
+ unpricedRounds: u.unpricedRounds,
455
+ checks: u.checks,
456
+ treeChanged: u.treeChanged,
457
+ tripped: u.tripped,
458
+ attempts: u.attempts,
459
+ };
460
+ });
461
+ }
462
+ // died before any step/iteration ever started (steps empty — the run was
463
+ // still in scout/planning) — one placeholder box, never an empty map. Its
464
+ // "id" IS the map's own display text (the map renders `String(s.id)`
465
+ // verbatim), so no separate client-side special case is needed for the
466
+ // map. `synthetic: true` marks it as a LABEL, not a real step — F196: it
467
+ // was being counted into "steps: 0 of 1 done" (should read "0 of 0"), and
468
+ // the map box carried a "1" number as if it were a real step 1. The
469
+ // client (index.html) excludes any `synthetic` step from both the summary
470
+ // count and the numbered step-card list; the map keeps the one box but
471
+ // renders it without a leading step number.
472
+ if (death.died && steps.length === 0) {
473
+ steps.push({
474
+ id: 'died during planning',
475
+ occurrence: null,
476
+ outcome: null,
477
+ state: 'died',
478
+ rounds: null,
479
+ toolCalls: null,
480
+ wallMs: null,
481
+ spentUsd: null,
482
+ unpricedRounds: 0,
483
+ checks: null,
484
+ treeChanged: null,
485
+ tripped: null,
486
+ attempts: [],
487
+ synthetic: true,
488
+ });
489
+ }
490
+ return {
491
+ // build item (2026-09-26): the LISTED runid (this function's own
492
+ // `runid` parameter, from `runlist`'s row) is the identity — never
493
+ // `summary.runId`, which `resolveSiblings` derives from the spine
494
+ // FILENAME STEM and silently drops a `~2`-style collision suffix
495
+ // runlist assigns when two spines share a basename in different dirs
496
+ // (getRunAudit/getRunRounds/getRunJob already return the parameter
497
+ // directly; this was the one holdout).
498
+ runid,
499
+ job: summary.job ?? row.job,
500
+ goal: summary.goal,
501
+ checkType: checkTypeLabel(summary.verdictType, row.at),
502
+ checkTypeTitle: checkTypeTitle(summary.verdictType, row.at),
503
+ model: summary.model,
504
+ provider: summary.provider,
505
+ judgeModel,
506
+ budgetUsd: summary.budgetUsd,
507
+ glyph: death.died ? '?' : glyphForOutcome(summary.outcome),
508
+ outcome: summary.outcome,
509
+ died: death.died,
510
+ stopReason: death.died ? death.why : summary.stopReason,
511
+ spentUsd: summary.spentUsd,
512
+ // died: a spend floor summed from real priced rounds present in the
513
+ // file — never null/unknown when at least one priced round exists.
514
+ // `null` on a non-died run (the normal `spentUsd`/`wallMs` fields above
515
+ // are already the real, complete figures there).
516
+ spendFloorUsd: death.died ? death.spendFloorUsd : null,
517
+ wallFloorMs: death.died ? death.wallFloorMs : null,
518
+ spendComplete: summary.spendComplete,
519
+ wallMs: summary.wallMs,
520
+ timelineKind,
521
+ steps,
522
+ // scoutPlan/fixLoop (panel build item 1, 2026-09-26): reused VERBATIM off
523
+ // `replayOne`'s own already-computed fields — never a second windowing
524
+ // pass here. Both `null` on the common case (no scout/plan rounds, or the
525
+ // close was satisfied on its first outer-close precheck with no fix loop
526
+ // ever starting) — see src/replay.js's own construction comments.
527
+ scoutPlan: summary.scoutPlan,
528
+ fixLoop: summary.fixLoop,
529
+ // parts (panel build item B/C, 2026-09-26): the ONE ordered part list
530
+ // (`src/replay.js`'s own field, computed once server-side) — drives the
531
+ // Run tab's map+cards and the Audit tab's grouped rows. Passed through
532
+ // VERBATIM (never re-derived client-side) so the two tabs can never
533
+ // disagree about run order, counts, or blocked-call figures.
534
+ parts: summary.parts,
535
+ replans: summary.replans,
536
+ close: summary.close,
537
+ branch: summary.branch,
538
+ date: typeof row.at === 'string' ? row.at.slice(0, 10) : null,
539
+ at: row.at,
540
+ via: row.via,
541
+ skipped: summary.skipped,
542
+ // item 7 (2026-09-25), corrected item 2 (2026-09-25): the Run summary's
543
+ // tools/cache rows. `behaviour` is {@link scopedBehaviour} — the SAME
544
+ // gate-audit rows the Audit tab now shows (this run's own job-start..
545
+ // job-end ts window, item 2's fix for a real measured defect: a
546
+ // gate-audit sidecar CAN carry other runs' rows when several spines
547
+ // share one filename — see {@link runAuditWindow}'s doc) — never
548
+ // `replayRun`'s own top-level `summary.behaviour`, which reads the
549
+ // WHOLE sidecar file unscoped and is contaminated on a real archived run
550
+ // (measured: pulselog-person-live-2's mu2p83go reads 142 unscoped tool
551
+ // calls vs 82 once scoped to this run's own window; `bareloop replay`'s
552
+ // CLI output, src/replay.js, still reports the old unscoped figure —
553
+ // untouched here, flagged separately, out of this panel-only fix's
554
+ // scope). `null` when no gate-audit sidecar was ever found (never a fake
555
+ // all-zero object). `memoryCache` is still reused verbatim from
556
+ // `replayRun` (a single spine record, never audit-sidecar-sourced, so it
557
+ // carries no contamination risk); `null` when the spine carries no
558
+ // `memory-cache` record at all (never armed on this run).
559
+ behaviour: scopedBehaviour(row.spine, rawSpineRecords),
560
+ memoryCache: summary.memoryCache,
561
+ };
562
+ }
563
+
564
+ /**
565
+ * The `[startTs, endTs]` window (both epoch ms, `endTs` possibly
566
+ * `Infinity`) that actually belongs to ONE run inside a gate-audit sidecar
567
+ * that can be SHARED across several runs' spines pointing at the same
568
+ * filename (measured directly on a real archived patient — pulselog-person-
569
+ * live-2, run mu2p83go: its sidecar carries rows from 7 distinct `run_id`s
570
+ * spanning 06:56Z to 13:25Z on one day, but the run's own `job-start`..
571
+ * `job-end` window is 13:19:40Z..13:25:48Z; every row before that window
572
+ * belongs to an EARLIER, unrelated run that happened to reuse the same
573
+ * sidecar path). F195: this is a thin wrapper over `src/replay.js`'s own
574
+ * {@link auditWindow} — the ONE owner of this rule (`replayRun` itself now
575
+ * applies it too, so `bareloop replay` and the panel can never disagree
576
+ * about which rows belong to a run) — kept here only to find `job-start`/
577
+ * `job-end` off the raw `spineRecords` array the panel already has in hand.
578
+ * @param {any[]} spineRecords
579
+ * @returns {{startTs: number, endTs: number}}
580
+ */
581
+ function runAuditWindow(spineRecords) {
582
+ const jobStart = spineRecords.find((r) => r && r.type === 'job-start') ?? null;
583
+ const jobEnd = [...spineRecords].reverse().find((r) => r && r.type === 'job-end') ?? null;
584
+ return auditWindow(jobStart, jobEnd);
585
+ }
586
+
587
+ /**
588
+ * The `round` column (item 2, 2026-09-25): which model-call round was
589
+ * running when an audit row happened, derived from ts ordering against this
590
+ * spine's own round-spending records (`SPEND_RECORD_TYPES` —
591
+ * `worker-round`/`judge-round`/`worker-turn`, the same canonical set
592
+ * `src/replay.js` itself imports rather than re-spelling). Round N's window
593
+ * opens at that Nth record's own `ts` (measured on mu2p83go: a round record's
594
+ * `ts` lands within ~10ms of its own gate-audit `llm` row, i.e. essentially
595
+ * simultaneous — the round record fires right as the model call is logged,
596
+ * before its resulting tool calls run) and stays open until the NEXT round
597
+ * record's `ts`, so every tool call the round's own model turn asked for
598
+ * lands inside it. A row whose `ts` falls BEFORE the first round record
599
+ * (scout/materials/pre-round activity) gets `null` — never a fabricated
600
+ * round 0 or round 1 — same honesty rule as an unparseable ts.
601
+ * @param {any[]} spineRecords
602
+ * @returns {(rowTs: string|null) => number|null}
603
+ */
604
+ /**
605
+ * Every `SPEND_RECORD_TYPES` round record on this spine with a parseable
606
+ * `ts`, sorted ascending — the ONE shared derivation {@link makeRoundLookup}
607
+ * (the `round` number column) and {@link makePartLookup} (the Audit
608
+ * tab's `step` column, item 2, 2026-09-26) both build on, so the two can
609
+ * never disagree about which record is "round N".
610
+ * @param {any[]} spineRecords
611
+ * @returns {any[]}
612
+ */
613
+ function sortedRoundRecords(spineRecords) {
614
+ return spineRecords
615
+ .filter((r) => r && SPEND_RECORD_TYPES.includes(r.type) && typeof r.ts === 'string' && Number.isFinite(Date.parse(r.ts)))
616
+ .sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
617
+ }
618
+
619
+ function makeRoundLookup(spineRecords) {
620
+ const roundTsMs = sortedRoundRecords(spineRecords).map((r) => Date.parse(r.ts));
621
+ return (rowTs) => {
622
+ if (typeof rowTs !== 'string') return null;
623
+ const ms = Date.parse(rowTs);
624
+ if (!Number.isFinite(ms)) return null;
625
+ let lo = 0;
626
+ let hi = roundTsMs.length - 1;
627
+ let found = -1;
628
+ while (lo <= hi) {
629
+ const mid = (lo + hi) >> 1;
630
+ if (roundTsMs[mid] <= ms) { found = mid; lo = mid + 1; } else hi = mid - 1;
631
+ }
632
+ return found === -1 ? null : found + 1; // 1-indexed round number
633
+ };
634
+ }
635
+
636
+ /**
637
+ * `runBehaviour`, fed only the gate-audit rows inside this run's own ts
638
+ * window (see {@link runAuditWindow}) — the panel-side fix for the same
639
+ * contamination `getRunAudit` fixes for the Audit tab, kept as ONE shared
640
+ * scoping function so the Audit tab and the Run tab's tools/cache summary
641
+ * can never disagree with each other about which rows belong to this run.
642
+ * `null` when no gate-audit sidecar exists at all (never a fake all-zero
643
+ * object — same rule `replayRun`'s own `auditAvailable` already follows).
644
+ * @param {string} spinePath
645
+ * @param {any[]} spineRecords
646
+ * @returns {ReturnType<typeof runBehaviour>|null}
647
+ */
648
+ function scopedBehaviour(spinePath, spineRecords) {
649
+ const { auditPath } = resolveSiblings(spinePath);
650
+ if (!auditPath || !existsSync(auditPath)) return null;
651
+ const { records } = parseJsonl(auditPath);
652
+ const { startTs, endTs } = runAuditWindow(spineRecords);
653
+ const windowed = records.filter((r) => {
654
+ if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
655
+ const ms = Date.parse(r.ts);
656
+ return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
657
+ });
658
+ return runBehaviour(windowed);
659
+ }
660
+
661
+ /**
662
+ * The Audit tab's part/attempt columns (build item C, 2026-09-26) — REPLACES
663
+ * `makeRoundPhaseLookup`'s phase-string heuristic (deleted here): that
664
+ * function mislabelled replan rounds as `plan` and needed a whole per-kind
665
+ * switch statement to recognize scout/plan/fix/step phases. `summary.parts`
666
+ * (`src/replay.js`'s own ONE ordered part list) already carries every part's
667
+ * own attempt seq windows (`startSeq`/`endSeq`, exclusive/inclusive same as
668
+ * every other windowing rule in this codebase) — a round's OWN `seq` matched
669
+ * against those windows tells you which part+attempt it belongs to directly,
670
+ * with no phase-string special-casing and no risk of a replan window being
671
+ * mislabelled as its neighbouring plan/step window (each part owns a
672
+ * disjoint, contiguous seq range by construction — see `src/replay.js`'s
673
+ * `parts` builder).
674
+ *
675
+ * `partIndex`/`attemptN` are `null` (never a guessed 0) when the round's own
676
+ * seq falls before this run's very first part-attempt window (should not
677
+ * happen on a spine with at least a `job-start`, since the first part's first
678
+ * attempt always opens at `-Infinity`, but a malformed/partial spine is
679
+ * handled honestly rather than assumed).
680
+ * @param {ReturnType<typeof import('../replay.js').replayRun>} summary
681
+ * @returns {(roundRecord: any|null) => {partIndex: number|null, partLabel: string|null, attemptN: number|null, reason: 'no-round'|'unassigned'|null}}
682
+ */
683
+ function makePartLookup(summary) {
684
+ const parts = Array.isArray(summary.parts) ? summary.parts : [];
685
+ return (roundRecord) => {
686
+ if (!roundRecord) return {
687
+ partIndex: null, partLabel: null, attemptN: null, reason: 'no-round',
688
+ };
689
+ const seq = typeof roundRecord.seq === 'number' ? roundRecord.seq : null;
690
+ if (seq === null) return {
691
+ partIndex: null, partLabel: null, attemptN: null, reason: 'unassigned',
692
+ };
693
+ for (let pi = 0; pi < parts.length; pi += 1) {
694
+ const attempts = Array.isArray(parts[pi].attempts) ? parts[pi].attempts : [];
695
+ for (const a of attempts) {
696
+ if (typeof a.startSeq === 'number' && typeof a.endSeq === 'number' && seq > a.startSeq && seq <= a.endSeq) {
697
+ return {
698
+ partIndex: pi, partLabel: partDisplayLabel(parts[pi]), attemptN: a.n, reason: null,
699
+ };
700
+ }
701
+ }
702
+ }
703
+ return {
704
+ partIndex: null, partLabel: null, attemptN: null, reason: 'unassigned',
705
+ };
706
+ };
707
+ }
708
+
709
+ /**
710
+ * The part's own display label — the step id (plus a "(try N)" suffix on a
711
+ * replanned occurrence) for a `kind:'step'` part, else the part's plain
712
+ * `label` (scout/plan/replan/fix/judge/run). ONE owner shared by the Audit
713
+ * tab's grouped rows and this file's flat-row lookup above.
714
+ * @param {any} part
715
+ * @returns {string}
716
+ */
717
+ function partDisplayLabel(part) {
718
+ if (part.kind === 'step' && part.occurrence > 1) return `${part.label} (try ${part.occurrence})`;
719
+ return part.label;
720
+ }
721
+
722
+ /**
723
+ * The run's tree root (the patient's own working directory) — used to
724
+ * shorten every path the Audit tab shows (item 2, 2026-09-26 build spec).
725
+ * Resolution order, honest at every step, never a guessed root:
726
+ * (a) `row.patient` — the run list's own recorded workdir (`run-u`/`bundle`
727
+ * rows carry it directly — see `src/runlist.js`'s `RunRow` shape,
728
+ * `src/userrun.js:1355`/`src/cli.js:418`); the real thing, trusted as-is;
729
+ * (b) the source-seed layout's own `tree/` dir — the same `into` derivation
730
+ * {@link sourceNearSpine} uses (`dirname(dirname(spine))`, only when its
731
+ * basename starts with `source-`) plus `tree` (verified against
732
+ * `src/userrun.js`'s own `specWorkdir = join(into, 'tree')`, and against
733
+ * a real archived run, pulselog-person-live-2's mu2p83go, whose row
734
+ * carries `patient: null` since it was `runs backfill`-added — a
735
+ * backfilled person-path run's tree root is ONLY ever recovered this
736
+ * way); only trusted when that directory actually exists on disk;
737
+ * else `null` — every path stays absolute, never shortened against a guess.
738
+ * @param {{ spine: string, patient: string|null }} row
739
+ * @returns {string|null}
740
+ */
741
+ function treeRootForRun(row) {
742
+ if (typeof row.patient === 'string' && row.patient.length > 0) return row.patient;
743
+ const into = dirname(dirname(row.spine));
744
+ if (!basename(into).startsWith('source-')) return null;
745
+ const tree = join(into, 'tree');
746
+ return existsSync(tree) ? tree : null;
747
+ }
748
+
749
+ /**
750
+ * Shortens an absolute `path` to be relative to `root` when it genuinely
751
+ * lives under `root` (the tree root itself shortens to `'.'`) — a path
752
+ * outside `root`, or a `null`/missing `root`, is returned exactly as given,
753
+ * never truncated to something that only LOOKS relative (item 2).
754
+ * @param {string|null} path
755
+ * @param {string|null} root
756
+ * @returns {string|null}
757
+ */
758
+ function shortenPath(path, root) {
759
+ if (typeof path !== 'string' || path.length === 0 || !root) return path;
760
+ const rel = relative(root, path);
761
+ if (rel === '') return '.';
762
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return path;
763
+ return rel;
764
+ }
765
+
766
+ /**
767
+ * `GET /api/runs/:runid/audit` — the Audit tab's rows, off the run's own
768
+ * gate-audit sidecar (name-convention resolution, `src/replayio.js`'s
769
+ * `resolveSiblings`), scoped to this run's own ts window (see {@link
770
+ * runAuditWindow} — a sidecar can carry other runs' rows). A row's real
771
+ * fields (`ts`, `action.type`, `action.path`, `decision`) plus `partIndex`/
772
+ * `partLabel`/`attemptN` — see {@link makePartLookup} (build item C,
773
+ * 2026-09-26 rewrite): derived from the row's own ROUND's `seq` matched
774
+ * against `summary.parts`' own attempt windows, never phase-string guessing.
775
+ * `reason` names WHY `partIndex` is `null`: `'no-round'` (the row's `ts` falls
776
+ * before the very first model call) vs `'unassigned'` (a round exists but its
777
+ * seq matched no part window — an honest gap, never fabricated) vs `null`
778
+ * when `partIndex` is real.
779
+ * `round` — see
780
+ * {@link makeRoundLookup}. A `phase:'record'`/`action.type:'llm'` row (item
781
+ * 2: every MODEL CALL, `decision` always `null` on the real record) is
782
+ * marked `kind:'model-call'` with its own `result.{costUsd,tokens,
783
+ * durationMs}` surfaced directly (never re-derived); every other row is
784
+ * `kind:'tool-call'`, unchanged.
785
+ * `reason` distinguishes the two ways this can come back empty (item 1,
786
+ * 2026-09-25): `'no-sidecar'` — no gate-audit sidecar was ever written for
787
+ * this run (an older version, or a run-u/native path that never emitted one)
788
+ * — vs `'sidecar-empty'` — a sidecar file DOES exist but has zero rows FOR
789
+ * THIS RUN'S OWN WINDOW (the run made no tool/model calls the gate audited,
790
+ * or every row on disk belongs to a different run sharing the same
791
+ * filename). The client renders these as two different sentences; never a
792
+ * bare empty table for either.
793
+ * @param {string} runid
794
+ * @param {{ home?: string }} [opts]
795
+ * @returns {{runid: string, rows: any[], raw: string, empty: boolean, reason: 'no-sidecar'|'sidecar-empty'|null}|null}
796
+ */
797
+ export function getRunAudit(runid, opts = {}) {
798
+ const { rows } = readRunList(opts);
799
+ const row = rows.find((r) => r && r.runid === runid);
800
+ if (!row) return null;
801
+ if (!existsSync(row.spine)) {
802
+ return {
803
+ runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
804
+ };
805
+ }
806
+ const { auditPath } = resolveSiblings(row.spine);
807
+ if (!auditPath || !existsSync(auditPath)) {
808
+ return {
809
+ runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
810
+ };
811
+ }
812
+ const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
813
+ const { startTs, endTs } = runAuditWindow(spineRecords);
814
+ const roundOf = makeRoundLookup(spineRecords);
815
+ const roundRecords = sortedRoundRecords(spineRecords);
816
+ // `preParsedSpine` avoids a second parse of the same spine file just read
817
+ // above; `skipAudit:true` since this call only needs `summary.parts`' own
818
+ // attempt seq windows (for {@link makePartLookup}), never `replayOne`'s own
819
+ // audit-derived `behaviour` field.
820
+ const summary = replayOne(row.spine, {
821
+ preParsedSpine: { records: spineRecords, skipped: spineSkipped },
822
+ skipAudit: true,
823
+ });
824
+ const partOf = makePartLookup(summary);
825
+
826
+ const { records } = parseJsonl(auditPath);
827
+ const windowed = records.filter((r) => {
828
+ if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
829
+ const ms = Date.parse(r.ts);
830
+ return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
831
+ });
832
+ // item 1 (build item, 2026-09-26): "Raw log" must show only THIS run's own
833
+ // window, same rule as `windowed` above (a sidecar can carry other runs'
834
+ // rows — see {@link runAuditWindow}'s doc). Filtered at the LINE level
835
+ // (never the parsed-record level) so a matched line stays byte-for-byte —
836
+ // "raw" means raw, not a re-serialized JSON.stringify of the parsed
837
+ // object. A line whose `ts` can't be parsed (malformed JSON, or a
838
+ // well-formed row missing/mistyping `ts`) is dropped rather than guessed
839
+ // into the window, matching the same honesty rule `windowed` already
840
+ // follows for its own rows.
841
+ const rawText = readFileSync(auditPath, 'utf8')
842
+ .split('\n')
843
+ .filter((line) => line.trim() !== '')
844
+ .filter((line) => {
845
+ let r;
846
+ try { r = JSON.parse(line); } catch { return false; }
847
+ if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
848
+ const ms = Date.parse(r.ts);
849
+ return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
850
+ })
851
+ .join('\n');
852
+ // item 2 (2026-09-26 build spec): every row's path shortened relative to
853
+ // the run's own tree root — computed ONCE here, never per-row, since the
854
+ // root never changes within one run's rows.
855
+ const treeRoot = treeRootForRun(row);
856
+ const auditRows = windowed.map((r) => {
857
+ const isModelCall = r.action && r.action.type === 'llm';
858
+ const resultObj = isModelCall && r.result && typeof r.result === 'object' ? r.result : null;
859
+ const n = roundOf(r.ts);
860
+ const {
861
+ partIndex, partLabel, attemptN, reason,
862
+ } = partOf(n === null ? null : roundRecords[n - 1]);
863
+ const path = r.action && typeof r.action.path === 'string' ? r.action.path : null;
864
+ return {
865
+ time: typeof r.ts === 'string' ? r.ts : null,
866
+ action: r.action && typeof r.action.type === 'string' ? r.action.type : null,
867
+ path,
868
+ pathShort: shortenPath(path, treeRoot),
869
+ decision: typeof r.decision === 'string' ? r.decision : null,
870
+ partIndex,
871
+ partLabel,
872
+ attemptN,
873
+ reason,
874
+ round: n,
875
+ kind: isModelCall ? 'model-call' : 'tool-call',
876
+ costUsd: resultObj && typeof resultObj.costUsd === 'number' && Number.isFinite(resultObj.costUsd) ? resultObj.costUsd : null,
877
+ tokens: resultObj && typeof resultObj.tokens === 'number' && Number.isFinite(resultObj.tokens) ? resultObj.tokens : null,
878
+ durationMs: resultObj && typeof resultObj.durationMs === 'number' && Number.isFinite(resultObj.durationMs) ? resultObj.durationMs : null,
879
+ };
880
+ });
881
+ const empty = auditRows.length === 0;
882
+ return {
883
+ runid, rows: auditRows, raw: rawText, empty, reason: empty ? 'sidecar-empty' : null,
884
+ };
885
+ }
886
+
887
+ /** The default/max page size for {@link getRunRounds} — tighten-only,
888
+ * named so a future change is a deliberate, visible edit (item 5,
889
+ * 2026-09-25). */
890
+ export const ROUNDS_PAGE_DEFAULT = 50;
891
+ export const ROUNDS_PAGE_MAX = 200;
892
+
893
+ /**
894
+ * `GET /api/runs/:runid/rounds?part=<index>&attempt=<n>&offset=&limit=`
895
+ * — build item C (2026-09-26 rewrite): the Audit tab's expandable attempt
896
+ * detail, built server-side in one endpoint and rendered lazily on expand.
897
+ * REPLACES the earlier step-id/occurrence/`FIX_LOOP_STEP_ID`-sentinel/
898
+ * `timelineKind` branching (three separate ways to resolve a window,
899
+ * deleted here) with ONE rule: `part` indexes directly into
900
+ * `summary.parts` (`src/replay.js`'s own ONE ordered part list — scout,
901
+ * plan, each step occurrence, replan, fix, judge, or a single `run` part on
902
+ * an old spine), and `attempt` indexes into THAT part's own `attempts` array
903
+ * — every part kind already carries at least one (a real per-attempt list
904
+ * for `step`/`fix`, or a single synthetic whole-part attempt for
905
+ * scout/plan/replan/judge/run, `src/replay.js`'s `wholePartAttempt`). No
906
+ * second windowing pass: each attempt's own `startSeq`/`endSeq` is read
907
+ * directly off `replayOne`'s already-computed summary.
908
+ * Tool calls per round reuse {@link getRunAudit}'s own already-scoped,
909
+ * already-round-tagged rows — filtered to this round's number, never
910
+ * re-derived. `toolLogSaved:false` (no gate-audit sidecar at all) still
911
+ * returns every round from the spine's own worker-round records, each with
912
+ * `toolCalls: null` (never a fabricated empty array — the client renders
913
+ * "tool calls: no log saved").
914
+ * Pagination: `offset`/`limit` slice the ATTEMPT's rounds list (never a
915
+ * round's own tool-call list) — `limit` defaults to {@link
916
+ * ROUNDS_PAGE_DEFAULT}, clamped to {@link ROUNDS_PAGE_MAX}; the response
917
+ * always carries `totalRounds` so the client can print "showing A–B of N",
918
+ * never a silently truncated list.
919
+ * `null` when the run isn't listed, the requested part/attempt index doesn't
920
+ * resolve to a real window, or the spine file is missing — the caller
921
+ * renders 404, same posture as {@link getRunAudit}/{@link getRunJob}.
922
+ * @param {string} runid
923
+ * @param {{ part?: number, attempt?: number, offset?: number, limit?: number }} query
924
+ * @param {{ home?: string }} [opts]
925
+ * @returns {any|null}
926
+ */
927
+ export function getRunRounds(runid, query = {}, opts = {}) {
928
+ const { rows } = readRunList(opts);
929
+ const row = rows.find((r) => r && r.runid === runid);
930
+ if (!row) return null;
931
+ if (!existsSync(row.spine)) return null;
932
+
933
+ const partIndex = typeof query.part === 'number' && Number.isInteger(query.part) && query.part >= 0 ? query.part : 0;
934
+ const attempt = typeof query.attempt === 'number' && Number.isInteger(query.attempt) && query.attempt > 0 ? query.attempt : 1;
935
+ const offset = typeof query.offset === 'number' && Number.isInteger(query.offset) && query.offset >= 0 ? query.offset : 0;
936
+ const limit = typeof query.limit === 'number' && Number.isInteger(query.limit) && query.limit > 0
937
+ ? Math.min(query.limit, ROUNDS_PAGE_MAX) : ROUNDS_PAGE_DEFAULT;
938
+
939
+ const summary = replayOne(row.spine);
940
+ const part = Array.isArray(summary.parts) ? summary.parts[partIndex] : null;
941
+ if (!part) return null;
942
+ const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
943
+ if (!a) return null;
944
+ /** @type {{startSeq: number, endSeq: number, outcome: string|null, detail: string|null}} */
945
+ const window = {
946
+ startSeq: a.startSeq,
947
+ endSeq: a.endSeq,
948
+ outcome: a.outcome,
949
+ detail: a.detail ?? (a.verdict || a.stages ? closeStageDetail({ verdict: a.verdict, stages: a.stages }) : null),
950
+ };
951
+ const stepLabel = partDisplayLabel(part);
952
+
953
+ const { records: spineRecords } = parseJsonl(row.spine);
954
+ const roundOf = makeRoundLookup(spineRecords);
955
+ const roundRecordsInWindow = spineRecords
956
+ .filter((r) => r && SPEND_RECORD_TYPES.includes(r.type) && typeof r.seq === 'number' && r.seq > window.startSeq && r.seq <= window.endSeq)
957
+ .sort((a, b) => a.seq - b.seq);
958
+
959
+ const auditResult = getRunAudit(runid, opts);
960
+ const toolLogSaved = !!auditResult && auditResult.reason !== 'no-sidecar';
961
+ /** @type {Map<number, any[]>} */
962
+ const toolsByRound = new Map();
963
+ if (toolLogSaved) {
964
+ for (const r of auditResult.rows) {
965
+ if (r.kind !== 'tool-call' || typeof r.round !== 'number') continue;
966
+ const bucket = toolsByRound.get(r.round) ?? [];
967
+ bucket.push(r);
968
+ toolsByRound.set(r.round, bucket);
969
+ }
970
+ }
971
+
972
+ const totalRounds = roundRecordsInWindow.length;
973
+ const page = roundRecordsInWindow.slice(offset, offset + limit);
974
+ const rounds = page.map((r) => {
975
+ const n = roundOf(r.ts);
976
+ const modelCallRow = toolLogSaved ? auditResult.rows.find((a) => a.kind === 'model-call' && a.round === n) : null;
977
+ return {
978
+ n,
979
+ ts: typeof r.ts === 'string' ? r.ts : null,
980
+ costUsd: typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? r.costUsd : null,
981
+ tokens: typeof r.tokens === 'number' && Number.isFinite(r.tokens) ? r.tokens : null,
982
+ durationMs: modelCallRow ? modelCallRow.durationMs : null,
983
+ toolCalls: toolLogSaved ? (n === null ? [] : (toolsByRound.get(n) ?? [])) : null,
984
+ };
985
+ });
986
+
987
+ return {
988
+ runid,
989
+ part: partIndex,
990
+ partKind: part.kind,
991
+ step: stepLabel,
992
+ occurrence: part.kind === 'step' ? part.occurrence : null,
993
+ attempt,
994
+ rounds,
995
+ totalRounds,
996
+ offset,
997
+ limit,
998
+ check: { outcome: window.outcome, detail: window.detail },
999
+ toolLogSaved,
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * The `↳ close:`-style text for one iteration's OWN close-verdict — same
1005
+ * shape as `src/replay.js`'s private `closeStageLine`, but that helper is
1006
+ * not exported (a formatting concern of the CLI's printable page); the
1007
+ * `/rounds` endpoint needs the same "failing stage names" text as plain
1008
+ * data, not a pre-formatted line, so it is derived here instead of importing
1009
+ * a print-only helper.
1010
+ * @param {{verdict: string|null, stages: any[]|null}|null} closeStage
1011
+ * @returns {string|null}
1012
+ */
1013
+ function closeStageDetail(closeStage) {
1014
+ if (!closeStage || !closeStage.verdict) return null;
1015
+ const failing = Array.isArray(closeStage.stages) ? closeStage.stages.filter((s) => s && s.verdict !== 'satisfied').map((s) => s.name) : [];
1016
+ return failing.length ? `failing: ${failing.join(', ')}` : null;
1017
+ }
1018
+
1019
+ /**
1020
+ * `<x>/runs/<runid>/spine.jsonl` -> `<x>` (the bundle directory carrying
1021
+ * `spec.json` — see `src/bundle.js`'s own export layout). `null` for every
1022
+ * other spine layout (run-u's free-standing spines have no bundle dir at
1023
+ * all — resolving one would mean guessing, which this function refuses to
1024
+ * do).
1025
+ * @param {string} spinePath
1026
+ * @returns {string|null}
1027
+ */
1028
+ function bundleDirForSpine(spinePath) {
1029
+ if (basename(spinePath) !== 'spine.jsonl') return null; // only the bundle layout uses this bare filename
1030
+ const runsDir = dirname(dirname(spinePath)); // <x>/runs/<runid> -> <x>/runs
1031
+ if (basename(runsDir) !== 'runs') return null; // not actually the bundle layout
1032
+ return dirname(runsDir); // <x>/runs -> <x>
1033
+ }
1034
+
1035
+ /**
1036
+ * `<out>/source-<seed>/<job>-bareloop/u-<runid>.jsonl` -> the two files an
1037
+ * authoring session leaves BESIDE that layout — `<out>/resolved-spec.json`
1038
+ * (the signed spec `--spec`/a JOBS-table row ran) and
1039
+ * `<out>/source-<seed>/source.json` (the source front door's manifest) —
1040
+ * verified against `src/userrun.js`'s own construction (`specWorkdir = join(into,
1041
+ * 'tree')`, `spineDir = join(wd, '..', target.spine)`, both `--spec` and the
1042
+ * `pulselog-person-strict` JOBS-table row read `into` the same way) and a real
1043
+ * archived run (pulselog-person-live-2, run mu2p83go): `dirname(spine)` is
1044
+ * `<into>/<job>-bareloop`, `dirname(dirname(spine))` is `<into>` itself
1045
+ * (`source-<seed>`, carrying `source.json`), and `dirname(dirname(dirname(spine)))`
1046
+ * is `<out>` (carrying `resolved-spec.json`).
1047
+ *
1048
+ * Only resolves when `<into>`'s own name starts with `source-` — the one
1049
+ * shape marker this convention always carries — and each file is checked to
1050
+ * actually exist before being handed back; any other spine layout (the bundle
1051
+ * `spine.jsonl`, a JOBS-table row whose workdir has no source-seed copy, e.g.
1052
+ * `litectx-u`) returns both fields `null`, never a guessed path (build spec
1053
+ * item 2, 2026-09-25: "don't search the disk broadly — fall through").
1054
+ * @param {string} spinePath
1055
+ * @returns {{ specPath: string|null, sourceJsonPath: string|null }}
1056
+ */
1057
+ function sourceNearSpine(spinePath) {
1058
+ const into = dirname(dirname(spinePath)); // <into>/<job>-bareloop/u-x.jsonl -> <into>
1059
+ if (!basename(into).startsWith('source-')) return { specPath: null, sourceJsonPath: null };
1060
+ const sourceJsonPath = join(into, 'source.json');
1061
+ const specPath = join(dirname(into), 'resolved-spec.json'); // <into> -> <out>
1062
+ return {
1063
+ specPath: existsSync(specPath) ? specPath : null,
1064
+ sourceJsonPath: existsSync(sourceJsonPath) ? sourceJsonPath : null,
1065
+ };
1066
+ }
1067
+
1068
+ /**
1069
+ * The close's stage names, in declared order — `close[].name` (a hand-authored
1070
+ * command close) or `closeDecl.stages[].name` (an authored declaration); the
1071
+ * two are mutually exclusive (`validateJob`'s own `close-duplicated` red).
1072
+ * `null` when neither carries a named stage (an old/malformed spec) — never a
1073
+ * fabricated placeholder.
1074
+ * @param {any} spec
1075
+ * @returns {string|null}
1076
+ */
1077
+ function successFromSpec(spec) {
1078
+ if (!spec || typeof spec !== 'object') return null;
1079
+ const stages = Array.isArray(spec.close) ? spec.close
1080
+ : (spec.closeDecl && Array.isArray(spec.closeDecl.stages) ? spec.closeDecl.stages : null);
1081
+ if (!stages) return null;
1082
+ const names = stages.filter((s) => s && typeof s.name === 'string' && s.name.length > 0).map((s) => s.name);
1083
+ return names.length > 0 ? names.join(' · ') : null;
1084
+ }
1085
+
1086
+ /**
1087
+ * The write fence plus the guard checks a close actually carries — reusing
1088
+ * {@link confirmProtections} (`src/authorflow.js`), the SAME function the
1089
+ * authoring CLI's confirm turn shows a person, never a second hand-typed list
1090
+ * (build spec item 2). Guard names only resolve for a `closeDecl` spec
1091
+ * (`classGuards` needs its `lang`+the spec's `verdictType`); an old-shape
1092
+ * `close`-array spec falls back to the write fence alone — its guard stages
1093
+ * (e.g. `no-suppressions`) already show up in {@link successFromSpec}'s own
1094
+ * stage list, and inventing a second detector to relabel them as "guards" here
1095
+ * would be exactly the per-age special-casing the build spec rules out.
1096
+ * `confirmProtections` itself can throw for an unrecognized verdictType/lang
1097
+ * (a spec this panel was never validated against) — caught here so a stale or
1098
+ * hand-edited spec never 500s the Job tab, only shows less than it could.
1099
+ * @param {any} spec
1100
+ * @returns {string|null}
1101
+ */
1102
+ function guardrailsFromSpec(spec) {
1103
+ if (!spec || typeof spec !== 'object') return null;
1104
+ const writeScope = Array.isArray(spec.writeScope) && spec.writeScope.every((s) => typeof s === 'string')
1105
+ ? spec.writeScope : null;
1106
+ const fenceLine = writeScope && writeScope.length > 0
1107
+ ? `write fence — the run may only change files matching: ${writeScope.join(', ')}` : null;
1108
+ if (spec.closeDecl && typeof spec.closeDecl.lang === 'string' && typeof spec.verdictType === 'string') {
1109
+ try {
1110
+ const lines = confirmProtections({ verdictType: spec.verdictType, lang: spec.closeDecl.lang, writeScope });
1111
+ if (lines.length > 0) return lines.join(' · ');
1112
+ } catch { /* falls through to the write-fence-only line below */ }
1113
+ }
1114
+ return fenceLine;
1115
+ }
1116
+
1117
+ /**
1118
+ * The granted tool list, RAW (as declared, in order) — `null` when the spec
1119
+ * carries none (a job may legally declare no `tools` at all). The one owner
1120
+ * of this array; {@link toolsFromSpec}'s joined display string is derived
1121
+ * from it, and item 3 (2026-09-25)'s "offered, never used" line on the Run
1122
+ * tab reads this raw array directly (`toolsList` on the Job response) rather
1123
+ * than reverse-parsing the joined string.
1124
+ * @param {any} spec
1125
+ * @returns {string[]|null}
1126
+ */
1127
+ function toolsListFromSpec(spec) {
1128
+ if (!spec || typeof spec !== 'object') return null;
1129
+ if (!Array.isArray(spec.tools) || spec.tools.length === 0) return null;
1130
+ const names = spec.tools.filter((t) => typeof t === 'string' && t.length > 0);
1131
+ return names.length > 0 ? names : null;
1132
+ }
1133
+
1134
+ /**
1135
+ * The granted tool list, verbatim, in declared order — `null` when the spec
1136
+ * carries none (a job may legally declare no `tools` at all).
1137
+ * @param {any} spec
1138
+ * @returns {string|null}
1139
+ */
1140
+ function toolsFromSpec(spec) {
1141
+ const names = toolsListFromSpec(spec);
1142
+ return names ? names.join(' · ') : null;
1143
+ }
1144
+
1145
+ /**
1146
+ * `<src/panel>/../../jobs` — the repo's `jobs/` directory of signed specs,
1147
+ * resolved from the bareloop PACKAGE ROOT (this module's own on-disk
1148
+ * location), never from `process.cwd()` — a panel launched from any working
1149
+ * directory must resolve the same jobs/ dir (item 2, 2026-09-25).
1150
+ * @returns {string}
1151
+ */
1152
+ function jobsDir() {
1153
+ return join(HERE, '..', '..', 'jobs');
1154
+ }
1155
+
1156
+ /**
1157
+ * `GET /api/runs/:runid/job` — the Job tab, filled from the first source
1158
+ * that actually resolves (item 2, 2026-09-25), in order:
1159
+ * (a) the bundle's own `spec.json` (a `bareloop run` bundle keeps one
1160
+ * beside its `runs/` dir) — the pre-existing path, unchanged;
1161
+ * (b) `jobs/<job>.json` in the repo's own jobs/ dir (resolved from the
1162
+ * package root) — its `jobSpecHash` is compared against the run's own
1163
+ * job-start `specHash`; a mismatch is shown, never hidden (the job may
1164
+ * have been edited since this run signed it);
1165
+ * (c) NEW (item 2, 2026-09-25): the run's own `resolved-spec.json`, found
1166
+ * beside the spine via {@link sourceNearSpine} — a person-path (`--spec`
1167
+ * or a source-seed-backed JOBS-table row) run's signed copy, never
1168
+ * searched for, only read when the on-disk shape unambiguously names it;
1169
+ * (d) the run's own job-start record (goal/model/budgetUsd/verdictType only
1170
+ * — a narrower, unsigned record, `resolved:false`);
1171
+ * else every spec-only field reads `'not recorded'`, never fabricated.
1172
+ * `resolvedFrom` names which of these actually supplied the fields, shown in
1173
+ * the page. `source`/`destination` are resolved SEPARATELY from the rest (any
1174
+ * branch above may supply the goal/close/etc while carrying no source.json of
1175
+ * its own) — from the source front door's manifest when `sourceNearSpine`
1176
+ * finds one, else the run list's own recorded `patient` path for a `source`
1177
+ * with no manifest, else `'not recorded'`. `success`/`guardrails`/`tools` come
1178
+ * from the resolved SPEC only (a) (b) (c) — a job-start record and `none()`
1179
+ * carry no close/writeScope/tools at all, so those stay `'not recorded'`.
1180
+ * @param {string} runid
1181
+ * @param {{ home?: string }} [opts]
1182
+ * @returns {any|null}
1183
+ */
1184
+ export function getRunJob(runid, opts = {}) {
1185
+ const { rows } = readRunList(opts);
1186
+ const row = rows.find((r) => r && r.runid === runid);
1187
+ if (!row) return null;
1188
+
1189
+ const near = existsSync(row.spine) ? sourceNearSpine(row.spine) : { specPath: null, sourceJsonPath: null };
1190
+ let manifest = null;
1191
+ if (near.sourceJsonPath) {
1192
+ try { manifest = JSON.parse(readFileSync(near.sourceJsonPath, 'utf8')); } catch { manifest = null; }
1193
+ }
1194
+
1195
+ /** @param {string|null} v @returns {string} */
1196
+ const notRecorded = (v) => (typeof v === 'string' && v.length > 0 ? v : 'not recorded');
1197
+ const sourceDisplay = () => {
1198
+ if (manifest && typeof manifest.source === 'string' && manifest.source.length > 0) return manifest.source;
1199
+ if (typeof row.patient === 'string' && row.patient.length > 0) return row.patient;
1200
+ return 'not recorded';
1201
+ };
1202
+ const destDisplay = () => {
1203
+ if (manifest && typeof manifest.destination === 'string' && manifest.destination.length > 0) return manifest.destination;
1204
+ return 'not recorded';
1205
+ };
1206
+
1207
+ // Read this run's own job-start record once — feeds the model fallback
1208
+ // below, (b)'s hash comparison, and (d)'s own fallback.
1209
+ let jobStart = null;
1210
+ if (existsSync(row.spine)) {
1211
+ try {
1212
+ const { records } = parseJsonl(row.spine);
1213
+ jobStart = records.find((r) => r && typeof r === 'object' && r.type === 'job-start') ?? null;
1214
+ } catch { jobStart = null; }
1215
+ }
1216
+ // A spec is a repeatable SHAPE and may legally omit `model` (resolved at
1217
+ // run time — `resolveWorkerModel`, e.g. `resolved-spec.json` for run
1218
+ // mu2p83go carries no `model` key at all, `deepseek-flash` was resolved and
1219
+ // only the job-start record says so): the job-start record's own `model` is
1220
+ // the real fact for what actually ran, shown whenever the spec itself is
1221
+ // silent on it — never a second age-conditioned code path, just the more
1222
+ // specific of two real fields.
1223
+ const modelDisplay = (/** @type {any} */ spec) => notRecorded(
1224
+ typeof spec.model === 'string' && spec.model.length > 0 ? spec.model
1225
+ : (typeof jobStart?.model === 'string' ? jobStart.model : null),
1226
+ );
1227
+
1228
+ /** @param {any} spec @param {boolean} resolved @param {string} resolvedFrom @param {string|null} note */
1229
+ const fromSpec = (spec, resolved, resolvedFrom, note) => ({
1230
+ runid,
1231
+ job: typeof spec.job === 'string' ? spec.job : row.job,
1232
+ resolved,
1233
+ resolvedFrom,
1234
+ checkType: checkTypeLabel(typeof spec.verdictType === 'string' ? spec.verdictType : null, row.at),
1235
+ checkTypeTitle: checkTypeTitle(typeof spec.verdictType === 'string' ? spec.verdictType : null, row.at),
1236
+ model: modelDisplay(spec),
1237
+ description: typeof spec.description === 'string' && spec.description.length > 0 ? spec.description : null,
1238
+ goal: notRecorded(typeof spec.goal === 'string' ? spec.goal : null),
1239
+ budgetUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
1240
+ maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : null,
1241
+ source: sourceDisplay(),
1242
+ destination: destDisplay(),
1243
+ success: notRecorded(successFromSpec(spec)),
1244
+ guardrails: notRecorded(guardrailsFromSpec(spec)),
1245
+ tools: notRecorded(toolsFromSpec(spec)),
1246
+ toolsList: toolsListFromSpec(spec),
1247
+ note,
1248
+ });
1249
+ const none = () => ({
1250
+ runid,
1251
+ job: row.job,
1252
+ resolved: false,
1253
+ resolvedFrom: 'none',
1254
+ checkType: 'unknown',
1255
+ checkTypeTitle: null,
1256
+ model: 'not recorded',
1257
+ description: null,
1258
+ goal: 'not recorded',
1259
+ budgetUsd: null,
1260
+ maxWallMs: null,
1261
+ source: sourceDisplay(),
1262
+ destination: destDisplay(),
1263
+ success: 'not recorded',
1264
+ guardrails: 'not recorded',
1265
+ tools: 'not recorded',
1266
+ toolsList: null,
1267
+ note: 'no resolvable spec for this run (only bareloop-run bundle-layout runs carry one; a run-u run has none on disk)',
1268
+ });
1269
+
1270
+ // (a) bundle spec.json — unchanged path, still tried first.
1271
+ if (existsSync(row.spine)) {
1272
+ const bundleDir = bundleDirForSpine(row.spine);
1273
+ const specPath = bundleDir ? join(bundleDir, 'spec.json') : null;
1274
+ if (specPath && existsSync(specPath)) {
1275
+ let spec = null;
1276
+ try { spec = JSON.parse(readFileSync(specPath, 'utf8')); } catch { spec = null; }
1277
+ if (spec && typeof spec === 'object') {
1278
+ return fromSpec(spec, true, 'bundle spec.json', null);
1279
+ }
1280
+ }
1281
+ }
1282
+
1283
+ // (b) jobs/<job>.json in the repo's own jobs/ dir.
1284
+ const jobsSpecPath = join(jobsDir(), `${row.job}.json`);
1285
+ if (existsSync(jobsSpecPath)) {
1286
+ let spec = null;
1287
+ try { spec = JSON.parse(readFileSync(jobsSpecPath, 'utf8')); } catch { spec = null; }
1288
+ if (spec && typeof spec === 'object') {
1289
+ const specHash = typeof jobStart?.specHash === 'string' ? jobStart.specHash : null;
1290
+ let mismatch = false;
1291
+ if (specHash) {
1292
+ try { mismatch = jobSpecHash(spec) !== specHash; } catch { mismatch = false; }
1293
+ }
1294
+ const note = mismatch ? 'this job was edited after this run (spec hash differs)' : null;
1295
+ return fromSpec(spec, true, `jobs/${row.job}.json`, note);
1296
+ }
1297
+ }
1298
+
1299
+ // (c) NEW — the run's own resolved-spec.json, found beside the spine.
1300
+ if (near.specPath) {
1301
+ let spec = null;
1302
+ try { spec = JSON.parse(readFileSync(near.specPath, 'utf8')); } catch { spec = null; }
1303
+ if (spec && typeof spec === 'object') {
1304
+ return fromSpec(spec, true, "the run's own resolved-spec.json", null);
1305
+ }
1306
+ }
1307
+
1308
+ // (d) the run's own job-start record — narrower, unsigned.
1309
+ if (jobStart) {
1310
+ return {
1311
+ runid,
1312
+ job: typeof jobStart.job === 'string' ? jobStart.job : row.job,
1313
+ resolved: false,
1314
+ resolvedFrom: "the run's own start record",
1315
+ checkType: checkTypeLabel(typeof jobStart.verdictType === 'string' ? jobStart.verdictType : null, row.at),
1316
+ checkTypeTitle: checkTypeTitle(typeof jobStart.verdictType === 'string' ? jobStart.verdictType : null, row.at),
1317
+ model: notRecorded(typeof jobStart.model === 'string' ? jobStart.model : null),
1318
+ description: null,
1319
+ goal: notRecorded(typeof jobStart.goal === 'string' ? jobStart.goal : null),
1320
+ budgetUsd: typeof jobStart.budgetUsd === 'number' ? jobStart.budgetUsd : null,
1321
+ maxWallMs: null,
1322
+ source: sourceDisplay(),
1323
+ destination: destDisplay(),
1324
+ success: 'not recorded',
1325
+ guardrails: 'not recorded',
1326
+ tools: 'not recorded',
1327
+ toolsList: null,
1328
+ note: "from the run's own start record",
1329
+ };
1330
+ }
1331
+
1332
+ return none();
1333
+ }
1334
+
1335
+ /** @param {any} res @param {number} code @param {any} body */
1336
+ function sendJson(res, code, body) {
1337
+ const text = JSON.stringify(body);
1338
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
1339
+ res.end(text);
1340
+ }
1341
+
1342
+ /** @param {any} res @param {number} code @param {string} text */
1343
+ function sendText(res, code, text) {
1344
+ res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8', 'content-length': Buffer.byteLength(text) });
1345
+ res.end(text);
1346
+ }
1347
+
1348
+ /**
1349
+ * Handle one request against the read-only API + the page. Exported
1350
+ * separately from {@link createPanelServer} so tests can drive it without a
1351
+ * real listening socket where that is simpler (most path-safety/405 tests
1352
+ * still go through a real socket, per the build spec).
1353
+ * @param {import('node:http').IncomingMessage} req
1354
+ * @param {import('node:http').ServerResponse} res
1355
+ * @param {{ home?: string, port: number }} opts
1356
+ */
1357
+ export function handleRequest(req, res, opts) {
1358
+ const method = req.method ?? 'GET';
1359
+ if (method !== 'GET' && method !== 'HEAD') {
1360
+ sendText(res, 405, 'method not allowed — this panel is read-only (GET/HEAD only)');
1361
+ return;
1362
+ }
1363
+
1364
+ let url;
1365
+ try {
1366
+ url = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1');
1367
+ } catch {
1368
+ sendText(res, 400, 'bad request');
1369
+ return;
1370
+ }
1371
+ const { pathname } = url;
1372
+
1373
+ const send = (code, body) => {
1374
+ if (method === 'HEAD') {
1375
+ const text = JSON.stringify(body);
1376
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
1377
+ res.end();
1378
+ return;
1379
+ }
1380
+ sendJson(res, code, body);
1381
+ };
1382
+
1383
+ if (pathname === '/' || pathname === '/index.html') {
1384
+ const indexPath = join(HERE, 'index.html');
1385
+ let html;
1386
+ try {
1387
+ html = readFileSync(indexPath, 'utf8');
1388
+ } catch {
1389
+ sendText(res, 500, 'panel page missing on disk');
1390
+ return;
1391
+ }
1392
+ html = html.replace(/__BARELOOP_PANEL_PORT__/g, String(opts.port));
1393
+ if (method === 'HEAD') {
1394
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(html) });
1395
+ res.end();
1396
+ return;
1397
+ }
1398
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(html) });
1399
+ res.end(html);
1400
+ return;
1401
+ }
1402
+
1403
+ if (pathname === '/api/runs') { send(200, { runs: listRuns({ home: opts.home }) }); return; }
1404
+
1405
+ const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
1406
+ if (runMatch) {
1407
+ const rawRunid = runMatch[1];
1408
+ const sub = runMatch[3] ?? null;
1409
+ // PATH SAFETY: a runid must match RUNID_RE before it is used for
1410
+ // ANYTHING — including the lookup itself. A runid carrying a slash
1411
+ // (encoded or not — `decodeURIComponent` runs first via `URL`'s own
1412
+ // pathname decoding) never reaches the run-list lookup at all.
1413
+ const runid = rawRunid;
1414
+ if (!RUNID_RE.test(runid)) { sendText(res, 400, 'bad runid'); return; }
1415
+ if (sub === 'audit') {
1416
+ const result = getRunAudit(runid, { home: opts.home });
1417
+ if (!result) { sendText(res, 404, 'no such run'); return; }
1418
+ send(200, result);
1419
+ return;
1420
+ }
1421
+ if (sub === 'job') {
1422
+ const result = getRunJob(runid, { home: opts.home });
1423
+ if (!result) { sendText(res, 404, 'no such run'); return; }
1424
+ send(200, result);
1425
+ return;
1426
+ }
1427
+ if (sub === 'rounds') {
1428
+ /** @param {string|null} v @returns {number|undefined} */
1429
+ const num = (v) => (v !== null && /^\d+$/.test(v) ? Number(v) : undefined);
1430
+ const query = {
1431
+ part: num(url.searchParams.get('part')),
1432
+ attempt: num(url.searchParams.get('attempt')),
1433
+ offset: num(url.searchParams.get('offset')),
1434
+ limit: num(url.searchParams.get('limit')),
1435
+ };
1436
+ const result = getRunRounds(runid, query, { home: opts.home });
1437
+ if (!result) { sendText(res, 404, 'no such run/part/attempt'); return; }
1438
+ send(200, result);
1439
+ return;
1440
+ }
1441
+ const result = getRunDetail(runid, { home: opts.home });
1442
+ if (!result) { sendText(res, 404, 'no such run'); return; }
1443
+ send(200, result);
1444
+ return;
1445
+ }
1446
+
1447
+ sendText(res, 404, 'not found');
1448
+ }
1449
+
1450
+ /**
1451
+ * Start the panel server. Binds `127.0.0.1` ONLY. A taken port is a LOUD,
1452
+ * non-zero-exit failure — this never falls back to another port (see file
1453
+ * header). Resolves once actually listening; rejects on a bind error
1454
+ * (including `EADDRINUSE`) with a `.port` field on the error for the
1455
+ * caller's message.
1456
+ * @param {{ port?: number, home?: string }} [opts]
1457
+ * @returns {Promise<{ server: import('node:http').Server, port: number, close: () => Promise<void> }>}
1458
+ */
1459
+ export function createPanelServer(opts = {}) {
1460
+ const requestedPort = opts.port ?? DEFAULT_PORT;
1461
+ const home = opts.home;
1462
+ return new Promise((resolve, reject) => {
1463
+ // Bound port is resolved from the live socket (`server.address().port`)
1464
+ // once listening starts, not the requested value — this is what makes
1465
+ // `port: 0` (OS-assigned ephemeral port) work for callers such as the
1466
+ // test suite, while `--port N` / DEFAULT_PORT callers still get back
1467
+ // exactly the port they asked for.
1468
+ let boundPort = requestedPort;
1469
+ const server = createServer((req, res) => {
1470
+ try {
1471
+ handleRequest(req, res, { home, port: boundPort });
1472
+ } catch (e) {
1473
+ sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
1474
+ }
1475
+ });
1476
+ server.once('error', (e) => {
1477
+ const err = /** @type {any} */ (e);
1478
+ err.port = requestedPort;
1479
+ reject(err);
1480
+ });
1481
+ server.listen(requestedPort, '127.0.0.1', () => {
1482
+ const addr = server.address();
1483
+ boundPort = typeof addr === 'object' && addr !== null ? addr.port : requestedPort;
1484
+ resolve({
1485
+ server,
1486
+ port: boundPort,
1487
+ close: () => new Promise((res2) => { server.close(() => res2(undefined)); }),
1488
+ });
1489
+ });
1490
+ });
1491
+ }
1492
+
1493
+ /**
1494
+ * `bareloop panel [--port N]` — the CLI entry (`src/cli.js` dispatch).
1495
+ * Never picks a different port on collision (see file header): prints a
1496
+ * loud, named error to stderr and returns 1.
1497
+ * @param {string[]} argv
1498
+ * @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string }} ctx
1499
+ * @returns {Promise<number>}
1500
+ */
1501
+ export async function panelMain(argv, ctx) {
1502
+ let port = DEFAULT_PORT;
1503
+ for (let i = 0; i < argv.length; i += 1) {
1504
+ if (argv[i] === '--port') {
1505
+ const v = Number(argv[i + 1]);
1506
+ if (!Number.isInteger(v) || v <= 0 || v > 65535) { ctx.err(`--port must be a valid TCP port, got ${JSON.stringify(argv[i + 1])}`); return 1; }
1507
+ port = v;
1508
+ i += 1;
1509
+ }
1510
+ }
1511
+ try {
1512
+ const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome });
1513
+ ctx.out(`bareloop panel — read-only, http://127.0.0.1:${boundPort} (Ctrl-C to stop)`);
1514
+ // never resolves on its own — the process stays up until killed, same
1515
+ // shape any other long-running dev server takes.
1516
+ await new Promise(() => {});
1517
+ return 0;
1518
+ } catch (e) {
1519
+ const err = /** @type {any} */ (e);
1520
+ if (err && err.code === 'EADDRINUSE') {
1521
+ ctx.err(`bareloop panel: port ${port} is already in use — pass --port to use a different one (never picked automatically)`);
1522
+ return 1;
1523
+ }
1524
+ ctx.err(`bareloop panel: failed to start — ${err && err.message ? err.message : String(err)}`);
1525
+ return 1;
1526
+ }
1527
+ }