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.
- package/CHANGELOG.md +114 -0
- package/bareloop.context.md +245 -33
- package/package.json +1 -1
- package/src/authorreadout.js +323 -0
- package/src/authorrun.js +1345 -0
- package/src/behaviour.js +5 -2
- package/src/cli.js +190 -8
- package/src/index.js +13 -1
- package/src/interviewrun.js +842 -0
- package/src/panel/index.html +1910 -0
- package/src/panel/server.js +1527 -0
- package/src/planrun.js +1 -1
- package/src/replay.js +632 -13
- package/src/replayio.js +159 -0
- package/src/reuse.js +1 -1
- package/src/run.js +1 -1
- package/src/runlist.js +281 -0
- package/src/u-patient.js +73 -0
- package/src/u-readout.js +451 -0
- package/src/userrun.js +2299 -0
- package/types/authorreadout.d.ts +172 -0
- package/types/authorrun.d.ts +37 -0
- package/types/behaviour.d.ts +5 -3
- package/types/cli.d.ts +2 -1
- package/types/index.d.ts +2 -1
- package/types/interviewrun.d.ts +46 -0
- package/types/panel/server.d.ts +265 -0
- package/types/planrun.d.ts +2 -2
- package/types/replay.d.ts +104 -6
- package/types/replayio.d.ts +93 -0
- package/types/reuse.d.ts +2 -2
- package/types/run.d.ts +2 -2
- package/types/runlist.d.ts +112 -0
- package/types/u-patient.d.ts +39 -0
- package/types/u-readout.d.ts +247 -0
- package/types/userrun.d.ts +141 -0
|
@@ -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
|
+
}
|