bareloop 0.28.0 → 0.30.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 +115 -0
- package/bareloop.context.md +79 -0
- package/package.json +1 -1
- package/src/authorrun.js +9 -1
- package/src/behaviour.js +5 -2
- package/src/cli.js +72 -3
- package/src/panel/index.html +2156 -0
- package/src/panel/server.js +1583 -0
- package/src/replay.js +678 -13
- package/src/replayio.js +7 -1
- package/src/runlist.js +281 -0
- package/src/userrun.js +26 -0
- package/types/behaviour.d.ts +5 -3
- package/types/cli.d.ts +2 -1
- package/types/panel/server.d.ts +265 -0
- package/types/replay.d.ts +104 -6
- package/types/runlist.d.ts +112 -0
- package/types/userrun.d.ts +7 -0
package/src/replayio.js
CHANGED
|
@@ -110,7 +110,13 @@ export function replayOne(spinePath, opts = {}) {
|
|
|
110
110
|
const { runId, auditPath } = resolveSiblings(spinePath);
|
|
111
111
|
const spine = preParsedSpine ?? parseJsonl(spinePath);
|
|
112
112
|
const audit = (!skipAudit && auditPath) ? parseJsonl(auditPath) : { records: [], skipped: 0 };
|
|
113
|
-
|
|
113
|
+
// `auditAvailable`: true only when a sidecar was actually FOUND AND READ.
|
|
114
|
+
// `skipAudit` (a deliberate cheap directory listing) and "no sidecar on
|
|
115
|
+
// disk" both mean this call never learned the real tool-call count, so
|
|
116
|
+
// both report unknown, never a coincidental 0 (doctrine: unknown reported
|
|
117
|
+
// as unknown, never rendered as zero — see `replayRun`'s own doc).
|
|
118
|
+
const auditAvailable = !skipAudit && auditPath !== null;
|
|
119
|
+
const summary = replayRun(spine.records, audit.records, { runId, auditAvailable });
|
|
114
120
|
summary.skipped += spine.skipped + audit.skipped;
|
|
115
121
|
return summary;
|
|
116
122
|
}
|
package/src/runlist.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// PANEL-BUILD.md P1 (2026-09-24 rulings) — "one home for runs": a single LIST
|
|
2
|
+
// of every run bareloop has started, never a move of the runs themselves.
|
|
3
|
+
// Patient copies stay exactly where they already live (`bareloop-patients/…`
|
|
4
|
+
// for run-u, `<bundleDir>/runs/<runid>/` for `bareloop run`); jobs stay in
|
|
5
|
+
// `jobs/` (moving them into the home is deferred to P3, hamr picked "A").
|
|
6
|
+
// This module is the ONE place that list lives: `~/.config/bareloop/
|
|
7
|
+
// runs.jsonl` — the same directory the keys file (PRD §7d) lives in, kept
|
|
8
|
+
// separate from any repo tree (secrets/run metadata never enter the tree,
|
|
9
|
+
// the spine, or a repo's own configs).
|
|
10
|
+
//
|
|
11
|
+
// One JSON object per line: `{ at, runid, job, spine, patient, via }`.
|
|
12
|
+
// `spine`/`patient` are always ABSOLUTE paths (or `patient: null` when the
|
|
13
|
+
// caller doesn't know one) — a relative path here would resolve differently
|
|
14
|
+
// depending on who later reads the file. `via` says how the row was minted:
|
|
15
|
+
// `'run-u'` (src/userrun.js, the person-path run), `'bundle'`
|
|
16
|
+
// (src/cli.js:doRun, `bareloop run`), or `'backfill'` (`bareloop runs
|
|
17
|
+
// backfill`, reconstructed from an archived spine already on disk).
|
|
18
|
+
//
|
|
19
|
+
// SECRETS: a row carries only paths/ids/names — never a key, a prompt, or any
|
|
20
|
+
// value read out of the run's own tree. Nothing here reads a spine's payload
|
|
21
|
+
// beyond `job`/`ts` on its `job-start` record.
|
|
22
|
+
//
|
|
23
|
+
// No new env var: grepped the tree for an existing HOME-override convention
|
|
24
|
+
// before adding one (2026-09-24) — none exists (`src/tools.js`'s
|
|
25
|
+
// `expandHome` calls `os.homedir()` directly, same as every other caller).
|
|
26
|
+
// This module follows that precedent and adds an injectable `home` param for
|
|
27
|
+
// tests instead, the same test-seam shape `deps.provider` already is
|
|
28
|
+
// elsewhere in this codebase.
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
existsSync, mkdirSync, appendFileSync, chmodSync, readdirSync, statSync,
|
|
32
|
+
} from 'node:fs';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import {
|
|
35
|
+
join, dirname, basename, resolve,
|
|
36
|
+
} from 'node:path';
|
|
37
|
+
import { parseJsonl, isSidecarByName, looksLikeSpine, resolveSiblings } from './replayio.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `~/.config/bareloop` (or the injected override) — the one home directory
|
|
41
|
+
* for both the keys file (PRD §7d, not this module's concern) and the run
|
|
42
|
+
* list.
|
|
43
|
+
* @param {string} [override] test seam; a caller in production never passes it
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function runlistHome(override) {
|
|
47
|
+
return override ?? join(homedir(), '.config', 'bareloop');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `<home>/runs.jsonl`.
|
|
52
|
+
* @param {string} [home]
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function runlistPath(home) {
|
|
56
|
+
return join(runlistHome(home), 'runs.jsonl');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill' }} RunRow
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Append one row to the run list, mkdir -p'ing the home directory first.
|
|
65
|
+
* IDEMPOTENT BY RUNID: a row already carrying `row.runid` is never
|
|
66
|
+
* duplicated (a resume, or a re-run of `backfill`, must not double-list one
|
|
67
|
+
* run) — the file is read once first to check.
|
|
68
|
+
*
|
|
69
|
+
* Perms: the home directory is created `0700` and the file kept `0600`
|
|
70
|
+
* (chmod'd after every append, in case it pre-existed with wider perms) —
|
|
71
|
+
* the same "outside any repo, keys-file-adjacent" posture PRD §7d states for
|
|
72
|
+
* `.env` in this same directory, even though this file carries no secret
|
|
73
|
+
* itself.
|
|
74
|
+
*
|
|
75
|
+
* THROWS on any IO failure (permission denied, disk full, …) — the caller
|
|
76
|
+
* (a live run) is required to catch this and continue the run rather than
|
|
77
|
+
* let a list-append failure abort paid work (hamr's rule: a panel list must
|
|
78
|
+
* never block real work). This function itself does not swallow, so a
|
|
79
|
+
* direct caller (e.g. `backfillRuns`, a $0 path) sees the real error.
|
|
80
|
+
* @param {RunRow} row
|
|
81
|
+
* @param {{ home?: string }} [opts]
|
|
82
|
+
* @returns {{ appended: boolean, reason?: 'duplicate-runid' }}
|
|
83
|
+
*/
|
|
84
|
+
export function appendRun(row, opts = {}) {
|
|
85
|
+
const home = runlistHome(opts.home);
|
|
86
|
+
const path = runlistPath(opts.home);
|
|
87
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
88
|
+
if (existsSync(path)) {
|
|
89
|
+
const { records } = parseJsonl(path);
|
|
90
|
+
if (records.some((r) => r && r.runid === row.runid)) {
|
|
91
|
+
return { appended: false, reason: 'duplicate-runid' };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
appendFileSync(path, `${JSON.stringify(row)}\n`);
|
|
95
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort perms; the append above already landed */ }
|
|
96
|
+
return { appended: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Tolerant reader — reuses {@link parseJsonl} (never a second hand-rolled
|
|
101
|
+
* parser). An absent file reads as an empty list, not an error: a fresh
|
|
102
|
+
* install has never run `appendRun` yet.
|
|
103
|
+
* @param {{ home?: string }} [opts]
|
|
104
|
+
* @returns {{ rows: RunRow[], skipped: number }}
|
|
105
|
+
*/
|
|
106
|
+
export function readRunList(opts = {}) {
|
|
107
|
+
const path = runlistPath(opts.home);
|
|
108
|
+
if (!existsSync(path)) return { rows: [], skipped: 0 };
|
|
109
|
+
const { records, skipped } = parseJsonl(path);
|
|
110
|
+
return { rows: records, skipped };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The runid a spine file names, for BOTH layouts this scan understands:
|
|
115
|
+
* the bundle layout (`.../runs/<runid>/spine.jsonl` — the runid lives in the
|
|
116
|
+
* directory name, since every bundle run is literally named `spine.jsonl`)
|
|
117
|
+
* and every other convention ({@link resolveSiblings}'s filename-based
|
|
118
|
+
* read, e.g. `u-<id>.jsonl`).
|
|
119
|
+
* @param {string} spinePath
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
function runidForSpine(spinePath) {
|
|
123
|
+
const base = basename(spinePath);
|
|
124
|
+
const parent = dirname(spinePath);
|
|
125
|
+
if (base === 'spine.jsonl' && basename(dirname(parent)) === 'runs') {
|
|
126
|
+
return basename(parent);
|
|
127
|
+
}
|
|
128
|
+
return resolveSiblings(spinePath).runId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A backfill scan of a real patients directory can be arbitrarily deep
|
|
132
|
+
// (person-path runs archive several levels down: `<dir>/<proj>/out/
|
|
133
|
+
// source-<x>/<proj>-bareloop/u-<id>.jsonl`, 4 levels under the dir a person
|
|
134
|
+
// actually points `backfill` at) — a non-recursive/immediate-subdir-only
|
|
135
|
+
// scan silently misses every run nested past that first level (measured:
|
|
136
|
+
// the newest real run in a 2026-09-09-capped panel was 12 days stale while
|
|
137
|
+
// real runs existed through 2026-09-21). Tighten-only cap, named so a future
|
|
138
|
+
// change to it is a deliberate, visible edit, never a silent number bump.
|
|
139
|
+
export const MAX_BACKFILL_DEPTH = 6;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every `.jsonl` file under `dir`, recursively, that is not a sidecar by
|
|
143
|
+
* name — bounded to {@link MAX_BACKFILL_DEPTH} directory levels below `dir`
|
|
144
|
+
* itself (`dir` is depth 0). Never descends into `node_modules` or `.git`
|
|
145
|
+
* (a copied repo source or a bundle's own worktree can carry either, and
|
|
146
|
+
* neither ever holds a real archived spine worth scanning). Never follows a
|
|
147
|
+
* symlinked directory (a symlink is skipped outright — `Dirent.isDirectory`/
|
|
148
|
+
* `isFile` both read false for one, since `readdirSync` with `withFileTypes`
|
|
149
|
+
* types by `lstat`, not the link's target — so it silently falls out of
|
|
150
|
+
* both branches below rather than needing a separate check).
|
|
151
|
+
* @param {string} dir
|
|
152
|
+
* @param {number} [maxDepth]
|
|
153
|
+
* @returns {string[]}
|
|
154
|
+
*/
|
|
155
|
+
function jsonlFilesIn(dir, maxDepth = MAX_BACKFILL_DEPTH) {
|
|
156
|
+
/** @type {Set<string>} */
|
|
157
|
+
const found = new Set();
|
|
158
|
+
/** @param {string} d @param {number} depth */
|
|
159
|
+
function walk(d, depth) {
|
|
160
|
+
let entries;
|
|
161
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (entry.isDirectory()) {
|
|
164
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
165
|
+
if (depth < maxDepth) walk(join(d, entry.name), depth + 1);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl') && !isSidecarByName(entry.name)) {
|
|
169
|
+
found.add(join(d, entry.name));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (existsSync(dir)) walk(dir, 0);
|
|
174
|
+
return [...found].sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `bareloop runs backfill <dir>` — recursively scan `dir` (bounded to
|
|
179
|
+
* {@link MAX_BACKFILL_DEPTH} levels, never into `node_modules`/`.git`,
|
|
180
|
+
* never following a symlinked directory) for archived spine files — both
|
|
181
|
+
* the free-standing layout (e.g. `<dir>/<x>/u-<id>.jsonl`, at any depth) and
|
|
182
|
+
* the bundle layout (`<dir>/.../runs/<runid>/spine.jsonl`) — and add one row
|
|
183
|
+
* per spine whose RESOLVED ABSOLUTE PATH is not already in the list.
|
|
184
|
+
*
|
|
185
|
+
* F197: dedup is by spine path, never by `runidForSpine`'s own derived
|
|
186
|
+
* runid. A spine carrying no `runid` field falls back to a filename-derived
|
|
187
|
+
* id (e.g. `run` for a `run.jsonl` with no runid inside) — a SECOND such
|
|
188
|
+
* file elsewhere (same basename, different directory: not a rare shape,
|
|
189
|
+
* `bareloop-patients/quickstart-proof/run.jsonl` is a real example) is a
|
|
190
|
+
* DIFFERENT run, not a duplicate, and dedup-by-runid would have silently
|
|
191
|
+
* dropped it. When the derived runid collides with one already in the list
|
|
192
|
+
* (pre-existing or added earlier in this same pass), the row's OWN runid is
|
|
193
|
+
* disambiguated (`<base>~2`, `<base>~3`, …) so every row keeps a runid the
|
|
194
|
+
* panel's `/api/runs/:runid` lookup can resolve unambiguously — see
|
|
195
|
+
* {@link RUNID_RE} in `src/panel/server.js`, updated to allow `~`.
|
|
196
|
+
*
|
|
197
|
+
* IDEMPOTENT: running twice adds nothing the second time (the resolved-path
|
|
198
|
+
* set already contains every spine this pass would otherwise re-add). Never
|
|
199
|
+
* silent about what it found: every candidate `.jsonl` is bucketed into
|
|
200
|
+
* exactly one of added / already listed / skipped (not a spine, or
|
|
201
|
+
* unreadable).
|
|
202
|
+
* @param {string} dir
|
|
203
|
+
* @param {{ home?: string }} [opts]
|
|
204
|
+
* @returns {{ added: number, alreadyListed: number, skipped: number, addedRows: RunRow[] }}
|
|
205
|
+
*/
|
|
206
|
+
export function backfillRuns(dir, opts = {}) {
|
|
207
|
+
if (!existsSync(dir)) throw new Error(`backfillRuns: no such directory: ${dir}`);
|
|
208
|
+
|
|
209
|
+
const candidates = new Set(jsonlFilesIn(dir));
|
|
210
|
+
|
|
211
|
+
const { rows: existingRows } = readRunList(opts);
|
|
212
|
+
const knownPaths = new Set(existingRows.map((r) => (r && typeof r.spine === 'string' ? resolve(r.spine) : null)).filter(Boolean));
|
|
213
|
+
const knownRunids = new Set(existingRows.map((r) => r && r.runid).filter(Boolean));
|
|
214
|
+
|
|
215
|
+
let added = 0;
|
|
216
|
+
let alreadyListed = 0;
|
|
217
|
+
let skipped = 0;
|
|
218
|
+
/** @type {RunRow[]} */
|
|
219
|
+
const addedRows = [];
|
|
220
|
+
|
|
221
|
+
for (const spinePath of [...candidates].sort()) {
|
|
222
|
+
const resolvedPath = resolve(spinePath);
|
|
223
|
+
if (knownPaths.has(resolvedPath)) { alreadyListed += 1; continue; }
|
|
224
|
+
let parsed;
|
|
225
|
+
try {
|
|
226
|
+
parsed = parseJsonl(spinePath);
|
|
227
|
+
} catch {
|
|
228
|
+
skipped += 1;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!looksLikeSpine(parsed.records)) { skipped += 1; continue; }
|
|
232
|
+
const baseRunid = runidForSpine(spinePath);
|
|
233
|
+
let runid = baseRunid;
|
|
234
|
+
if (knownRunids.has(runid)) {
|
|
235
|
+
let n = 2;
|
|
236
|
+
while (knownRunids.has(`${baseRunid}~${n}`)) n += 1;
|
|
237
|
+
runid = `${baseRunid}~${n}`;
|
|
238
|
+
}
|
|
239
|
+
const jobStart = parsed.records.find((r) => r && r.type === 'job-start') ?? null;
|
|
240
|
+
const job = typeof jobStart?.job === 'string' ? jobStart.job : '(unknown job)';
|
|
241
|
+
// `at`: the job-start record's own `ts` (stamped by src/spine.js's
|
|
242
|
+
// `makeSpine`) when present; else the earliest timestamped record on the
|
|
243
|
+
// spine; else the file's own mtime, honestly — never a fabricated "now".
|
|
244
|
+
const earliestTs = parsed.records.find((r) => typeof r?.ts === 'string')?.ts ?? null;
|
|
245
|
+
const at = typeof jobStart?.ts === 'string' ? jobStart.ts
|
|
246
|
+
: earliestTs ?? statSync(spinePath).mtime.toISOString();
|
|
247
|
+
// `patient`: no spine record carries the run's workdir (verified against
|
|
248
|
+
// src/run.js's own `job-start` emit, 2026-09-24) — reported honestly as
|
|
249
|
+
// `null` rather than guessed from directory layout, which does not hold
|
|
250
|
+
// across the archive's several naming conventions (see src/replayio.js's
|
|
251
|
+
// own file-header comment).
|
|
252
|
+
const row = {
|
|
253
|
+
at, runid, job, spine: resolvedPath, patient: null, via: /** @type {const} */ ('backfill'),
|
|
254
|
+
};
|
|
255
|
+
const result = appendRun(row, opts);
|
|
256
|
+
if (result.appended) {
|
|
257
|
+
added += 1; addedRows.push(row); knownPaths.add(resolvedPath); knownRunids.add(runid);
|
|
258
|
+
} else {
|
|
259
|
+
// only reachable if two candidates in the SAME pass resolve to the
|
|
260
|
+
// same path under different spellings, or an unexpected runid clash
|
|
261
|
+
// appendRun itself caught — either way, honestly counted, never lost.
|
|
262
|
+
alreadyListed += 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { added, alreadyListed, skipped, addedRows };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* One printable line per row for `bareloop runs` (no subcommand): `job
|
|
271
|
+
* (runid) · date · spine path`, with `file missing` appended when the row's
|
|
272
|
+
* `spine` no longer exists on disk (an archive moved/pruned since the row
|
|
273
|
+
* was written — never silently listed as if it were still there).
|
|
274
|
+
* @param {RunRow} row
|
|
275
|
+
* @returns {string}
|
|
276
|
+
*/
|
|
277
|
+
export function formatRunRow(row) {
|
|
278
|
+
const date = typeof row.at === 'string' ? row.at.slice(0, 10) : '(unknown date)';
|
|
279
|
+
const missing = existsSync(row.spine) ? '' : ' — file missing';
|
|
280
|
+
return `${row.job} (${row.runid}) · ${date} · ${row.spine}${missing}`;
|
|
281
|
+
}
|
package/src/userrun.js
CHANGED
|
@@ -70,6 +70,8 @@ import { HITL_PAUSE } from './declaredclose.js';
|
|
|
70
70
|
import { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
|
|
71
71
|
// the cold reset, shared with the battery drivers so "cold" has one spelling
|
|
72
72
|
import { coldReset, moveStaleGateAudit } from './u-patient.js';
|
|
73
|
+
// PANEL-BUILD.md P1 — the one run list (`~/.config/bareloop/runs.jsonl`).
|
|
74
|
+
import { appendRun } from './runlist.js';
|
|
73
75
|
// the banner's wall arithmetic, extracted so it is reachable by a test (F83): the
|
|
74
76
|
// end-of-run readout sits past the approval gate, so nothing could ever drive it here
|
|
75
77
|
import { wallLine, doomedResume, deathAtOf, evidencePackage, doorLines, resumeAtLines, reviewDoorPackage, runDoorLines, tokensLine, doorTimingRedLines } from './u-readout.js';
|
|
@@ -222,6 +224,10 @@ class ExitSignal extends Error {
|
|
|
222
224
|
* check entirely, the same shape `src/cli.js`'s `main(argv, deps)` uses.
|
|
223
225
|
* @property {(tier: string) => any} [providerFor]
|
|
224
226
|
* @property {any} [judgeProvider]
|
|
227
|
+
* @property {string} [runlistHome] F196 — a test seam for `appendRun`'s
|
|
228
|
+
* (src/runlist.js) `home` override, the same shape `deps.provider` already
|
|
229
|
+
* is. Production never sets it; `appendRun` falls through to its own
|
|
230
|
+
* `os.homedir()`-based default.
|
|
225
231
|
*/
|
|
226
232
|
|
|
227
233
|
/**
|
|
@@ -1332,6 +1338,26 @@ async function execute(ctx) {
|
|
|
1332
1338
|
const runid = Date.now().toString(36);
|
|
1333
1339
|
const spineFile = join(spineDir, `u-${runid}.jsonl`);
|
|
1334
1340
|
|
|
1341
|
+
// PANEL-BUILD.md P1 — one row in the run list, BEFORE any paid call (this
|
|
1342
|
+
// leg's own `runJob` further down, and BEFORE the provider-key checks that
|
|
1343
|
+
// preceded this point already gated on). `runid` is fresh for THIS leg
|
|
1344
|
+
// (cold start or resumed leg both mint one here), so `appendRun`'s own
|
|
1345
|
+
// runid-dedup never fires on the ordinary path — it exists for a caller
|
|
1346
|
+
// that legitimately re-enters with the same runid (e.g. a retried
|
|
1347
|
+
// in-process test). A list-append failure must never block a real,
|
|
1348
|
+
// already-signed run: caught and named loudly, never rethrown.
|
|
1349
|
+
// `deps.runlistHome` — the same injectable test seam `ctx.deps` already
|
|
1350
|
+
// carries everything else through (env/out/err/provider/…): a caller that
|
|
1351
|
+
// injects it writes this leg's row under a temp home instead of the real
|
|
1352
|
+
// `~/.config/bareloop`; production never sets it.
|
|
1353
|
+
try {
|
|
1354
|
+
appendRun({
|
|
1355
|
+
at: new Date().toISOString(), runid, job: spec.job, spine: spineFile, patient: wd, via: 'run-u',
|
|
1356
|
+
}, { home: deps.runlistHome });
|
|
1357
|
+
} catch (/** @type {any} */ e) {
|
|
1358
|
+
err(`WARNING: could not add this run to ~/.config/bareloop/runs.jsonl (${e.message}) — the run continues; the panel's list will be missing this row.`);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1335
1361
|
const git = (/** @type {string[]} */ a) => execFileSync('git', ['-C', wd, ...a], { encoding: 'utf8' }).trim();
|
|
1336
1362
|
if (dead) {
|
|
1337
1363
|
// A RESUME is the OPPOSITE case from a cold launch: the halted run's edits are work
|
package/types/behaviour.d.ts
CHANGED
|
@@ -39,8 +39,10 @@ export function runBehaviour(events: Array<{
|
|
|
39
39
|
* Render a {@link runBehaviour} summary as the agreed printable block
|
|
40
40
|
* (2–4 short lines). A zero-call run formats plainly ("0 tool calls") rather
|
|
41
41
|
* than a NaN-laced percentage — an empty run is a complete, reportable
|
|
42
|
-
* answer, not a division error.
|
|
43
|
-
*
|
|
42
|
+
* answer, not a division error. `summary === null` means the caller never
|
|
43
|
+
* had a gate-audit sidecar to read at all (see `replayRun`'s `auditAvailable`
|
|
44
|
+
* option) — that reads as "unknown", never coerced to a 0-call summary.
|
|
45
|
+
* @param {ReturnType<typeof runBehaviour>|null} summary
|
|
44
46
|
* @returns {string}
|
|
45
47
|
*/
|
|
46
|
-
export function formatBehaviour(summary: ReturnType<typeof runBehaviour>): string;
|
|
48
|
+
export function formatBehaviour(summary: ReturnType<typeof runBehaviour> | null): string;
|
package/types/cli.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
|
|
4
4
|
* queued stdout on its own (F-something: `process.exit()` can discard it).
|
|
5
5
|
* @param {string[]} argv
|
|
6
|
-
* @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any }} deps
|
|
6
|
+
* @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string }} deps
|
|
7
7
|
* @returns {Promise<number>}
|
|
8
8
|
*/
|
|
9
9
|
export function main(argv: string[], deps?: {
|
|
@@ -17,4 +17,5 @@ export function main(argv: string[], deps?: {
|
|
|
17
17
|
judgeModel?: string | null;
|
|
18
18
|
now?: () => number;
|
|
19
19
|
stdin?: any;
|
|
20
|
+
runlistHome?: string;
|
|
20
21
|
}): Promise<number>;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `[✓]`/`[✗]`/`[▶]` — the ONLY vocabulary a result is ever rendered in
|
|
3
|
+
* (auto-memory `ui-verdict-words.md`: never the words green/red/soft-green
|
|
4
|
+
* anywhere in the page). `null` (no `job-end` reached — a killed-mid-run or
|
|
5
|
+
* still-running spine) reads `▶` — the same "in progress / unresolved" glyph
|
|
6
|
+
* P1's own four-glyph vocabulary reserves for that state; a genuinely still-
|
|
7
|
+
* running attempt and an archived spine that never reached its own end are
|
|
8
|
+
* the same fact from this read-only side: no verdict has been recorded yet.
|
|
9
|
+
* @param {string|null} outcome
|
|
10
|
+
* @returns {'✓'|'✗'|'▶'}
|
|
11
|
+
*/
|
|
12
|
+
export function glyphForOutcome(outcome: string | null): "\u2713" | "\u2717" | "\u25B6";
|
|
13
|
+
/**
|
|
14
|
+
* `deterministic` (green) / `rubric` (soft-green) / `unknown` (a class this
|
|
15
|
+
* reader doesn't recognize) — the settled UI wording (PANEL-BUILD.md §6),
|
|
16
|
+
* never the internal `green`/`soft-green` spelling. Item 3 (2026-09-25): a
|
|
17
|
+
* spine with NO `verdictType` (pre-F117) is genuinely ambiguous UNLESS its
|
|
18
|
+
* own run date (`atIso`, the job-start ts) predates {@link
|
|
19
|
+
* SOFTGREEN_ADMITTED_ISO} — in that one case, `deterministic` was the only
|
|
20
|
+
* check type that could have run, so it is reported as such (not fabricated:
|
|
21
|
+
* derived from the frozen historical fact that soft-green did not exist
|
|
22
|
+
* yet), paired with {@link checkTypeTitle}'s tooltip explaining the
|
|
23
|
+
* derivation rather than presenting it as if it had been directly recorded.
|
|
24
|
+
* `atIso` is optional so every existing single-arg caller/test keeps its
|
|
25
|
+
* prior behavior (no date -> 'unknown', same as before this item).
|
|
26
|
+
* @param {string|null} verdictType
|
|
27
|
+
* @param {string|null} [atIso] the run's own job-start timestamp
|
|
28
|
+
* @returns {'deterministic'|'rubric'|'unknown'}
|
|
29
|
+
*/
|
|
30
|
+
export function checkTypeLabel(verdictType: string | null, atIso?: string | null): "deterministic" | "rubric" | "unknown";
|
|
31
|
+
/**
|
|
32
|
+
* The tooltip/title text for {@link checkTypeLabel}'s pre-softgreen-cutoff
|
|
33
|
+
* branch — `null` for every other case (a real `verdictType` needs no
|
|
34
|
+
* explanation; a genuinely unknown one has none to give).
|
|
35
|
+
* @param {string|null} verdictType
|
|
36
|
+
* @param {string|null} [atIso]
|
|
37
|
+
* @returns {string|null}
|
|
38
|
+
*/
|
|
39
|
+
export function checkTypeTitle(verdictType: string | null, atIso?: string | null): string | null;
|
|
40
|
+
/**
|
|
41
|
+
* `YYYY-MM-DD HH:MM` (local time, 24h, zero-padded) — the ONE
|
|
42
|
+
* timestamp-WITH-TIME format this page ever shows (a plain date elsewhere
|
|
43
|
+
* stays `YYYY-MM-DD`, e.g. `row.at.slice(0,10)`, untouched — that's a
|
|
44
|
+
* different, shorter field, not this helper's concern). Used instead of a
|
|
45
|
+
* locale-dependent `toLocaleString()` spelling (F195: the died "why" line
|
|
46
|
+
* read `9/9/2026, 11:49:40 AM`, inconsistent with every other date on the
|
|
47
|
+
* page) so a future second timestamp-with-time spot routes through the same
|
|
48
|
+
* one function rather than growing its own `toLocale*` call.
|
|
49
|
+
* @param {string|number|Date} ts
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function formatTimestamp(ts: string | number | Date): string;
|
|
53
|
+
/**
|
|
54
|
+
* `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
|
|
55
|
+
* append order — a backfill scan appends rows in sorted-PATH order, which
|
|
56
|
+
* does not track chronological `at` order; a plain `.reverse()` here once
|
|
57
|
+
* silently mis-sorted any list `appendRun` didn't build strictly
|
|
58
|
+
* chronologically), each enriched with a cheap ($0, `skipAudit:true`)
|
|
59
|
+
* summary. A row with an unparseable/missing `at` sorts last (Unix epoch 0),
|
|
60
|
+
* never crashes the sort or floats to the top. Ties (identical `at`) keep
|
|
61
|
+
* their original file order among themselves (stable sort) rather than an
|
|
62
|
+
* arbitrary one. The Workflows view (item 2, 2026-09-26 merge) groups this
|
|
63
|
+
* SAME payload by job client-side (`groupRunsByJob` in `index.html`) rather
|
|
64
|
+
* than a second server-side endpoint, so the glyph/checkType mapping is
|
|
65
|
+
* still computed in exactly one place.
|
|
66
|
+
* @param {{ home?: string }} [opts]
|
|
67
|
+
* @returns {any[]}
|
|
68
|
+
*/
|
|
69
|
+
export function listRuns(opts?: {
|
|
70
|
+
home?: string;
|
|
71
|
+
}): any[];
|
|
72
|
+
/**
|
|
73
|
+
* `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
|
|
74
|
+
* iterations), counters, summary-box fields. Looks `runid` up in the run
|
|
75
|
+
* list FIRST and reads only the path stored there (path safety — see file
|
|
76
|
+
* header); `null` when the runid is not in the list at all (caller renders
|
|
77
|
+
* 404).
|
|
78
|
+
* @param {string} runid
|
|
79
|
+
* @param {{ home?: string }} [opts]
|
|
80
|
+
* @returns {any|null}
|
|
81
|
+
*/
|
|
82
|
+
export function getRunDetail(runid: string, opts?: {
|
|
83
|
+
home?: string;
|
|
84
|
+
}): any | null;
|
|
85
|
+
/**
|
|
86
|
+
* `GET /api/runs/:runid/audit` — the Audit tab's rows, off the run's own
|
|
87
|
+
* gate-audit sidecar (name-convention resolution, `src/replayio.js`'s
|
|
88
|
+
* `resolveSiblings`), scoped to this run's own ts window (see {@link
|
|
89
|
+
* runAuditWindow} — a sidecar can carry other runs' rows). A row's real
|
|
90
|
+
* fields (`ts`, `action.type`, `action.path`, `decision`) plus `partIndex`/
|
|
91
|
+
* `partLabel`/`attemptN` — see {@link makePartLookup} (build item C,
|
|
92
|
+
* 2026-09-26 rewrite): derived from the row's own ROUND's `seq` matched
|
|
93
|
+
* against `summary.parts`' own attempt windows, never phase-string guessing.
|
|
94
|
+
* `reason` names WHY `partIndex` is `null`: `'no-round'` (the row's `ts` falls
|
|
95
|
+
* before the very first model call) vs `'unassigned'` (a round exists but its
|
|
96
|
+
* seq matched no part window — an honest gap, never fabricated) vs `null`
|
|
97
|
+
* when `partIndex` is real.
|
|
98
|
+
* `round` — see
|
|
99
|
+
* {@link makeRoundLookup}. A `phase:'record'`/`action.type:'llm'` row (item
|
|
100
|
+
* 2: every MODEL CALL, `decision` always `null` on the real record) is
|
|
101
|
+
* marked `kind:'model-call'` with its own `result.{costUsd,tokens,
|
|
102
|
+
* durationMs}` surfaced directly (never re-derived); every other row is
|
|
103
|
+
* `kind:'tool-call'`, unchanged.
|
|
104
|
+
* `reason` distinguishes the two ways this can come back empty (item 1,
|
|
105
|
+
* 2026-09-25): `'no-sidecar'` — no gate-audit sidecar was ever written for
|
|
106
|
+
* this run (an older version, or a run-u/native path that never emitted one)
|
|
107
|
+
* — vs `'sidecar-empty'` — a sidecar file DOES exist but has zero rows FOR
|
|
108
|
+
* THIS RUN'S OWN WINDOW (the run made no tool/model calls the gate audited,
|
|
109
|
+
* or every row on disk belongs to a different run sharing the same
|
|
110
|
+
* filename). The client renders these as two different sentences; never a
|
|
111
|
+
* bare empty table for either.
|
|
112
|
+
* @param {string} runid
|
|
113
|
+
* @param {{ home?: string }} [opts]
|
|
114
|
+
* @returns {{runid: string, rows: any[], raw: string, empty: boolean, reason: 'no-sidecar'|'sidecar-empty'|null}|null}
|
|
115
|
+
*/
|
|
116
|
+
export function getRunAudit(runid: string, opts?: {
|
|
117
|
+
home?: string;
|
|
118
|
+
}): {
|
|
119
|
+
runid: string;
|
|
120
|
+
rows: any[];
|
|
121
|
+
raw: string;
|
|
122
|
+
empty: boolean;
|
|
123
|
+
reason: "no-sidecar" | "sidecar-empty" | null;
|
|
124
|
+
} | null;
|
|
125
|
+
/**
|
|
126
|
+
* `GET /api/runs/:runid/rounds?part=<index>&attempt=<n>&offset=&limit=`
|
|
127
|
+
* — build item C (2026-09-26 rewrite): the Audit tab's expandable attempt
|
|
128
|
+
* detail, built server-side in one endpoint and rendered lazily on expand.
|
|
129
|
+
* REPLACES the earlier step-id/occurrence/`FIX_LOOP_STEP_ID`-sentinel/
|
|
130
|
+
* `timelineKind` branching (three separate ways to resolve a window,
|
|
131
|
+
* deleted here) with ONE rule: `part` indexes directly into
|
|
132
|
+
* `summary.parts` (`src/replay.js`'s own ONE ordered part list — scout,
|
|
133
|
+
* plan, each step occurrence, replan, fix, judge, or a single `run` part on
|
|
134
|
+
* an old spine), and `attempt` indexes into THAT part's own `attempts` array
|
|
135
|
+
* — every part kind already carries at least one (a real per-attempt list
|
|
136
|
+
* for `step`/`fix`, or a single synthetic whole-part attempt for
|
|
137
|
+
* scout/plan/replan/judge/run, `src/replay.js`'s `wholePartAttempt`). No
|
|
138
|
+
* second windowing pass: each attempt's own `startSeq`/`endSeq` is read
|
|
139
|
+
* directly off `replayOne`'s already-computed summary.
|
|
140
|
+
* Tool calls per round reuse {@link getRunAudit}'s own already-scoped,
|
|
141
|
+
* already-round-tagged rows — filtered to this round's number, never
|
|
142
|
+
* re-derived. `toolLogSaved:false` (no gate-audit sidecar at all) still
|
|
143
|
+
* returns every round from the spine's own worker-round records, each with
|
|
144
|
+
* `toolCalls: null` (never a fabricated empty array — the client renders
|
|
145
|
+
* "tool calls: no log saved").
|
|
146
|
+
* Pagination: `offset`/`limit` slice the ATTEMPT's rounds list (never a
|
|
147
|
+
* round's own tool-call list) — `limit` defaults to {@link
|
|
148
|
+
* ROUNDS_PAGE_DEFAULT}, clamped to {@link ROUNDS_PAGE_MAX}; the response
|
|
149
|
+
* always carries `totalRounds` so the client can print "showing A–B of N",
|
|
150
|
+
* never a silently truncated list.
|
|
151
|
+
* `null` when the run isn't listed, the requested part/attempt index doesn't
|
|
152
|
+
* resolve to a real window, or the spine file is missing — the caller
|
|
153
|
+
* renders 404, same posture as {@link getRunAudit}/{@link getRunJob}.
|
|
154
|
+
* @param {string} runid
|
|
155
|
+
* @param {{ part?: number, attempt?: number, offset?: number, limit?: number }} query
|
|
156
|
+
* @param {{ home?: string }} [opts]
|
|
157
|
+
* @returns {any|null}
|
|
158
|
+
*/
|
|
159
|
+
export function getRunRounds(runid: string, query?: {
|
|
160
|
+
part?: number;
|
|
161
|
+
attempt?: number;
|
|
162
|
+
offset?: number;
|
|
163
|
+
limit?: number;
|
|
164
|
+
}, opts?: {
|
|
165
|
+
home?: string;
|
|
166
|
+
}): any | null;
|
|
167
|
+
/**
|
|
168
|
+
* `GET /api/runs/:runid/job` — the Job tab, filled from the first source
|
|
169
|
+
* that actually resolves (item 2, 2026-09-25), in order:
|
|
170
|
+
* (a) the bundle's own `spec.json` (a `bareloop run` bundle keeps one
|
|
171
|
+
* beside its `runs/` dir) — the pre-existing path, unchanged;
|
|
172
|
+
* (b) `jobs/<job>.json` in the repo's own jobs/ dir (resolved from the
|
|
173
|
+
* package root) — its `jobSpecHash` is compared against the run's own
|
|
174
|
+
* job-start `specHash`; a mismatch is shown, never hidden (the job may
|
|
175
|
+
* have been edited since this run signed it);
|
|
176
|
+
* (c) NEW (item 2, 2026-09-25): the run's own `resolved-spec.json`, found
|
|
177
|
+
* beside the spine via {@link sourceNearSpine} — a person-path (`--spec`
|
|
178
|
+
* or a source-seed-backed JOBS-table row) run's signed copy, never
|
|
179
|
+
* searched for, only read when the on-disk shape unambiguously names it;
|
|
180
|
+
* (d) the run's own job-start record (goal/model/budgetUsd/verdictType only
|
|
181
|
+
* — a narrower, unsigned record, `resolved:false`);
|
|
182
|
+
* else every spec-only field reads `'not recorded'`, never fabricated.
|
|
183
|
+
* `resolvedFrom` names which of these actually supplied the fields, shown in
|
|
184
|
+
* the page. `source`/`destination` are resolved SEPARATELY from the rest (any
|
|
185
|
+
* branch above may supply the goal/close/etc while carrying no source.json of
|
|
186
|
+
* its own) — from the source front door's manifest when `sourceNearSpine`
|
|
187
|
+
* finds one, else the run list's own recorded `patient` path for a `source`
|
|
188
|
+
* with no manifest, else `'not recorded'`. `success`/`guardrails`/`tools` come
|
|
189
|
+
* from the resolved SPEC only (a) (b) (c) — a job-start record and `none()`
|
|
190
|
+
* carry no close/writeScope/tools at all, so those stay `'not recorded'`.
|
|
191
|
+
* @param {string} runid
|
|
192
|
+
* @param {{ home?: string }} [opts]
|
|
193
|
+
* @returns {any|null}
|
|
194
|
+
*/
|
|
195
|
+
export function getRunJob(runid: string, opts?: {
|
|
196
|
+
home?: string;
|
|
197
|
+
}): any | null;
|
|
198
|
+
/**
|
|
199
|
+
* Handle one request against the read-only API + the page. Exported
|
|
200
|
+
* separately from {@link createPanelServer} so tests can drive it without a
|
|
201
|
+
* real listening socket where that is simpler (most path-safety/405 tests
|
|
202
|
+
* still go through a real socket, per the build spec).
|
|
203
|
+
* @param {import('node:http').IncomingMessage} req
|
|
204
|
+
* @param {import('node:http').ServerResponse} res
|
|
205
|
+
* @param {{ home?: string, port: number }} opts
|
|
206
|
+
*/
|
|
207
|
+
export function handleRequest(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, opts: {
|
|
208
|
+
home?: string;
|
|
209
|
+
port: number;
|
|
210
|
+
}): void;
|
|
211
|
+
/**
|
|
212
|
+
* Start the panel server. Binds `127.0.0.1` ONLY. A taken port is a LOUD,
|
|
213
|
+
* non-zero-exit failure — this never falls back to another port (see file
|
|
214
|
+
* header). Resolves once actually listening; rejects on a bind error
|
|
215
|
+
* (including `EADDRINUSE`) with a `.port` field on the error for the
|
|
216
|
+
* caller's message.
|
|
217
|
+
* @param {{ port?: number, home?: string }} [opts]
|
|
218
|
+
* @returns {Promise<{ server: import('node:http').Server, port: number, close: () => Promise<void> }>}
|
|
219
|
+
*/
|
|
220
|
+
export function createPanelServer(opts?: {
|
|
221
|
+
port?: number;
|
|
222
|
+
home?: string;
|
|
223
|
+
}): Promise<{
|
|
224
|
+
server: import("node:http").Server;
|
|
225
|
+
port: number;
|
|
226
|
+
close: () => Promise<void>;
|
|
227
|
+
}>;
|
|
228
|
+
/**
|
|
229
|
+
* `bareloop panel [--port N]` — the CLI entry (`src/cli.js` dispatch).
|
|
230
|
+
* Never picks a different port on collision (see file header): prints a
|
|
231
|
+
* loud, named error to stderr and returns 1.
|
|
232
|
+
* @param {string[]} argv
|
|
233
|
+
* @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string }} ctx
|
|
234
|
+
* @returns {Promise<number>}
|
|
235
|
+
*/
|
|
236
|
+
export function panelMain(argv: string[], ctx: {
|
|
237
|
+
out: (s: string) => void;
|
|
238
|
+
err: (s: string) => void;
|
|
239
|
+
runlistHome?: string;
|
|
240
|
+
}): Promise<number>;
|
|
241
|
+
/**
|
|
242
|
+
* A runid, as it appears in a URL path segment — never a raw filesystem
|
|
243
|
+
* path. `~` is included so a backfill-disambiguated runid (F197:
|
|
244
|
+
* `src/runlist.js`'s `backfillRuns`, e.g. `run~2` when two archived spines
|
|
245
|
+
* derive the same filename-based runid) is a legal, reachable id, never a
|
|
246
|
+
* 400.
|
|
247
|
+
*/
|
|
248
|
+
export const RUNID_RE: RegExp;
|
|
249
|
+
/** 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}). */
|
|
250
|
+
export const DEFAULT_PORT: 4700;
|
|
251
|
+
/**
|
|
252
|
+
* Soft-green was admitted at commit 30df0f9 (2026-08-18) — before that,
|
|
253
|
+
* `green` was the only admissible verdict type, so a run started before this
|
|
254
|
+
* moment that carries no `verdictType` at all (pre-F117 spine) really was
|
|
255
|
+
* deterministic; there was no other check type it could have been. Tighten-
|
|
256
|
+
* only, named so a future change is a deliberate, visible edit (item 3,
|
|
257
|
+
* 2026-09-25).
|
|
258
|
+
*/
|
|
259
|
+
export const SOFTGREEN_ADMITTED_ISO: "2026-08-18T00:00:00.000Z";
|
|
260
|
+
export const DIED_MTIME_MS: number;
|
|
261
|
+
/** The default/max page size for {@link getRunRounds} — tighten-only,
|
|
262
|
+
* named so a future change is a deliberate, visible edit (item 5,
|
|
263
|
+
* 2026-09-25). */
|
|
264
|
+
export const ROUNDS_PAGE_DEFAULT: 50;
|
|
265
|
+
export const ROUNDS_PAGE_MAX: 200;
|