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
package/src/replayio.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// PANEL-BUILD.md P0 — the spine/gate-audit READ side. Lifted out of
|
|
2
|
+
// `scripts/run-replay.mjs` (verbatim logic, moved so it is callable
|
|
3
|
+
// in-process rather than only as a script) plus one more reader
|
|
4
|
+
// (`readHistoryLog`) for `bareloop history`'s bundle-registry file. This
|
|
5
|
+
// module is the IO layer ONLY: it resolves paths, parses JSONL tolerantly
|
|
6
|
+
// (a malformed line is counted and skipped, never thrown on), and hands
|
|
7
|
+
// parsed records to `src/replay.js`'s pure `replayRun`/`formatReplay`/
|
|
8
|
+
// `summarizeForAllLine`/`formatAllLines` — it never recomputes anything
|
|
9
|
+
// those already derive (PANEL-BUILD.md P0: "feed replay.js, never
|
|
10
|
+
// duplicate it").
|
|
11
|
+
//
|
|
12
|
+
// NAME-AGNOSTIC spine detection (coordinator fix, 2026-08-25, carried over
|
|
13
|
+
// verbatim from the script this replaces): the patient corpus does not agree
|
|
14
|
+
// on a filename convention — `u-<id>.jsonl` (run-u), `battery-A1-<id>.jsonl`
|
|
15
|
+
// / `l2accept-L1-<id>.jsonl` (batteries), `layer-r-probe-P1-<id>.jsonl`,
|
|
16
|
+
// `types-screen-C-<id>.jsonl`, `reuse-<id>.jsonl` (first record
|
|
17
|
+
// `reuse-start`), `job2-<id>.jsonl` all measured across the real archive. A
|
|
18
|
+
// spine is therefore identified by CONTENT, never by a naming pattern: any
|
|
19
|
+
// `*.jsonl` that is not a gate-audit/lag sidecar BY NAME
|
|
20
|
+
// (`*-gate-audit.jsonl`, `*.lag.jsonl` — the one convention that IS
|
|
21
|
+
// universal, because every spine resolves its own audit sibling this same
|
|
22
|
+
// way) and whose records include at least one `job-start` or `run-start` is
|
|
23
|
+
// a spine. A `.jsonl` that matches neither test is reported as
|
|
24
|
+
// `<name> not-a-spine` — never silently dropped, never silently misread as
|
|
25
|
+
// an empty/unknown run.
|
|
26
|
+
|
|
27
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
28
|
+
import { basename, dirname, join } from 'node:path';
|
|
29
|
+
import { replayRun, summarizeForAllLine } from './replay.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse one JSONL file into records, tolerant of a malformed line (counted
|
|
33
|
+
* in `skipped`, never thrown on). Mirrors `scripts/behaviour-readout.mjs`'s
|
|
34
|
+
* own parse.
|
|
35
|
+
* @param {string} file
|
|
36
|
+
* @returns {{records: any[], skipped: number}}
|
|
37
|
+
*/
|
|
38
|
+
export function parseJsonl(file) {
|
|
39
|
+
const raw = readFileSync(file, 'utf8');
|
|
40
|
+
const lines = raw.split('\n').filter((l) => l.trim() !== '');
|
|
41
|
+
/** @type {any[]} */
|
|
42
|
+
const records = [];
|
|
43
|
+
let skipped = 0;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
try {
|
|
46
|
+
records.push(JSON.parse(line));
|
|
47
|
+
} catch {
|
|
48
|
+
skipped += 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { records, skipped };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `true` when `name` (a bare filename, not a path) is a gate-audit or lag
|
|
56
|
+
* sidecar by NAME convention — the one filename rule that holds across the
|
|
57
|
+
* whole archive.
|
|
58
|
+
* @param {string} name
|
|
59
|
+
*/
|
|
60
|
+
export function isSidecarByName(name) {
|
|
61
|
+
return name.endsWith('-gate-audit.jsonl') || name.endsWith('.lag.jsonl');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `true` when `records` carries at least one `job-start` (present on every
|
|
66
|
+
* real spine checked, first record or not — a reuse/battery run nests one or
|
|
67
|
+
* more ordinary `runJob` calls inside it) or `run-start` (ralph's own inner
|
|
68
|
+
* loop marker, present on most but not all real spines).
|
|
69
|
+
* @param {any[]} records
|
|
70
|
+
*/
|
|
71
|
+
export function looksLikeSpine(records) {
|
|
72
|
+
return records.some((r) => r && (r.type === 'job-start' || r.type === 'run-start'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `<name>.jsonl` → `<name>-gate-audit.jsonl` in the same directory, and the
|
|
77
|
+
* bare run id for display — the run id lives only in the filename (no spine
|
|
78
|
+
* record carries it; see `src/replay.js`'s header). A leading `u-` (the
|
|
79
|
+
* run-u convention) is stripped; every other prefix (`battery-A1-`,
|
|
80
|
+
* `reuse-`, …) is kept verbatim, since it is the only thing distinguishing
|
|
81
|
+
* that run from its siblings in the same directory.
|
|
82
|
+
* @param {string} spinePath
|
|
83
|
+
* @returns {{runId: string, auditPath: string|null}}
|
|
84
|
+
*/
|
|
85
|
+
export function resolveSiblings(spinePath) {
|
|
86
|
+
const dir = dirname(spinePath);
|
|
87
|
+
const base = basename(spinePath);
|
|
88
|
+
const stem = base.replace(/\.jsonl$/, '');
|
|
89
|
+
const runId = stem.startsWith('u-') ? stem.slice(2) : stem;
|
|
90
|
+
const auditPath = join(dir, `${stem}-gate-audit.jsonl`);
|
|
91
|
+
return { runId, auditPath: existsSync(auditPath) ? auditPath : null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read one run's spine (+ its gate-audit sidecar, when present) off disk and
|
|
96
|
+
* hand both to {@link replayRun}. Read-only, $0, mints no verdict.
|
|
97
|
+
* @param {string} spinePath absolute or cwd-relative path to a `.jsonl` spine
|
|
98
|
+
* @param {{preParsedSpine?: {records: any[], skipped: number}, skipAudit?: boolean}} [opts]
|
|
99
|
+
* `preParsedSpine`: avoids a second parse of the same file when the caller
|
|
100
|
+
* ({@link listSpines}) already read it once to run {@link looksLikeSpine}.
|
|
101
|
+
* `skipAudit` (PR #23 review item 5, 2026-08-26, carried over): a
|
|
102
|
+
* directory listing never opens the gate-audit sidecar at all —
|
|
103
|
+
* `summarizeForAllLine` reads none of the fields that sidecar feeds inside
|
|
104
|
+
* `replayRun`, so reading every run's sidecar in a directory listing was
|
|
105
|
+
* pure unused I/O, multiplied by every spine found.
|
|
106
|
+
* @returns {ReturnType<typeof replayRun>}
|
|
107
|
+
*/
|
|
108
|
+
export function replayOne(spinePath, opts = {}) {
|
|
109
|
+
const { preParsedSpine, skipAudit = false } = opts;
|
|
110
|
+
const { runId, auditPath } = resolveSiblings(spinePath);
|
|
111
|
+
const spine = preParsedSpine ?? parseJsonl(spinePath);
|
|
112
|
+
const audit = (!skipAudit && auditPath) ? parseJsonl(auditPath) : { records: [], skipped: 0 };
|
|
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 });
|
|
120
|
+
summary.skipped += spine.skipped + audit.skipped;
|
|
121
|
+
return summary;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* List every spine found directly in `dir` (not recursive — same as the
|
|
126
|
+
* script this replaces): one entry per `.jsonl` file that is not a sidecar
|
|
127
|
+
* by name, each either `{kind:'spine', row}` ({@link summarizeForAllLine}'s
|
|
128
|
+
* row) or `{kind:'not-a-spine', name}` when the file's content doesn't look
|
|
129
|
+
* like a spine. Feeds `formatAllLines` (`src/replay.js`) for the printed
|
|
130
|
+
* table — this function does no formatting of its own, only IO + detection.
|
|
131
|
+
* @param {string} dir
|
|
132
|
+
* @returns {Array<{kind: 'spine', row: ReturnType<typeof summarizeForAllLine>}|{kind: 'not-a-spine', name: string}>}
|
|
133
|
+
*/
|
|
134
|
+
export function listSpines(dir) {
|
|
135
|
+
const candidates = readdirSync(dir)
|
|
136
|
+
.filter((f) => f.endsWith('.jsonl') && !isSidecarByName(f))
|
|
137
|
+
.sort();
|
|
138
|
+
return candidates.map((f) => {
|
|
139
|
+
const path = join(dir, f);
|
|
140
|
+
const spine = parseJsonl(path);
|
|
141
|
+
if (!looksLikeSpine(spine.records)) return { kind: /** @type {const} */ ('not-a-spine'), name: f };
|
|
142
|
+
const summary = replayOne(path, { preParsedSpine: spine, skipAudit: true });
|
|
143
|
+
return { kind: /** @type {const} */ ('spine'), row: summarizeForAllLine(summary) };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read a bundle's `history.jsonl` (one `bareloop run` row per line, written
|
|
149
|
+
* by `appendHistory` in `src/bundle.js`) — the single reader for this file,
|
|
150
|
+
* replacing `src/cli.js`'s own hand-rolled `readFileSync`/split. Tolerant of
|
|
151
|
+
* a malformed line the same way {@link parseJsonl} is (counted in
|
|
152
|
+
* `skipped`, never thrown on).
|
|
153
|
+
* @param {string} file absolute path to `history.jsonl`
|
|
154
|
+
* @returns {{rows: any[], skipped: number}}
|
|
155
|
+
*/
|
|
156
|
+
export function readHistoryLog(file) {
|
|
157
|
+
const { records, skipped } = parseJsonl(file);
|
|
158
|
+
return { rows: records, skipped };
|
|
159
|
+
}
|
package/src/reuse.js
CHANGED
|
@@ -805,7 +805,7 @@ function readGradeSeed(seen) {
|
|
|
805
805
|
* abandoned attempt twice in a chain of resumes.
|
|
806
806
|
*
|
|
807
807
|
* @param {any[]} events the dead spine's events, parsed, in file order
|
|
808
|
-
* @param {{deathAt?: number|null, direct?: boolean, resumableOutcomes?: string[]}} [opts]
|
|
808
|
+
* @param {{deathAt?: number|null, direct?: boolean, resumableOutcomes?: readonly string[]}} [opts]
|
|
809
809
|
* `deathAt`: when the process is judged to have died (the watchdog's kill record,
|
|
810
810
|
* which is later and better evidence than the last event). Defaults to the last
|
|
811
811
|
* event's own timestamp — the last sign of life there is.
|
package/src/run.js
CHANGED
|
@@ -204,7 +204,7 @@ async function primitiveSmoke(workdir) {
|
|
|
204
204
|
* a manufactured-fixation probe). `true` is the ON/experimental arm; the
|
|
205
205
|
* default is the OFF arm. (Shell-owned seam, same doctrine as the provider
|
|
206
206
|
* binding.)
|
|
207
|
-
* @param {{decision: string, text?: string}|null} [opts.humanRuling] N4 — the SIGNER's
|
|
207
|
+
* @param {{decision: string, text?: string|null}|null} [opts.humanRuling] N4 — the SIGNER's
|
|
208
208
|
* answer at a hitl pause (accept | rerun <text> | pause), carried by the leg that
|
|
209
209
|
* RESUMES a paused run and forwarded verbatim to the plan flow. Absent on every
|
|
210
210
|
* ordinary run; never authored and never defaulted (a defaulted answer to "is this
|
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/u-patient.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// THE COLD RESET, in ONE place.
|
|
2
|
+
//
|
|
3
|
+
// `run-u.mjs` has always owned this: a cold run starts at the frozen seed with the
|
|
4
|
+
// `.litectx` store removed, because a run that inherits the previous run's edits — or
|
|
5
|
+
// the previous run's isolate-verb memory (stash/remember persist in the store) — is
|
|
6
|
+
// measuring the wrong thing. The reuse rung's OFF arm and every cold contrast depend
|
|
7
|
+
// on it.
|
|
8
|
+
//
|
|
9
|
+
// It moved here the day a BATTERY driver needed to rehearse it (the read-shim Phase 2
|
|
10
|
+
// battery's `--dry-run` has to prove the patient really does go cold between rows).
|
|
11
|
+
// The alternative was a second spelling of `git reset --hard` in the battery, and two
|
|
12
|
+
// spellings of "cold" is exactly how one driver's rows come to start from a different
|
|
13
|
+
// tree than the other's — the blind-instrument class, at the patient.
|
|
14
|
+
//
|
|
15
|
+
// Nothing here decides anything and nothing here is arbiter territory: it is the
|
|
16
|
+
// mechanical preparation of a tree, and both callers print what it did.
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import {
|
|
19
|
+
rmSync, existsSync, renameSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reset a patient to its frozen seed and drop its `.litectx` store.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} wd absolute path to the patient's tree (a git repo with >=1 commit)
|
|
27
|
+
* @param {string} seed the frozen seed commit
|
|
28
|
+
* @returns {{ head: string, storeRemoved: boolean }} `head` is the short sha the tree
|
|
29
|
+
* now stands at; `storeRemoved` says whether a store was actually there to remove
|
|
30
|
+
* (so a caller can print the truth rather than a claim).
|
|
31
|
+
*/
|
|
32
|
+
export function coldReset(wd, seed) {
|
|
33
|
+
const git = (/** @type {string[]} */ a) => execFileSync('git', ['-C', wd, ...a], { encoding: 'utf8' }).trim();
|
|
34
|
+
git(['reset', '--hard', seed]);
|
|
35
|
+
git(['clean', '-fd']);
|
|
36
|
+
const store = join(wd, '.litectx');
|
|
37
|
+
const storeRemoved = existsSync(store);
|
|
38
|
+
rmSync(store, { recursive: true, force: true });
|
|
39
|
+
return { head: git(['rev-parse', '--short', 'HEAD']), storeRemoved };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* F186 — the patient's own `.gitignore` denies `*.jsonl`, so `coldReset`'s
|
|
44
|
+
* `git clean -fd` (no `-x`) never removes a `gate-audit.jsonl` already
|
|
45
|
+
* sitting at the tree root — it can only be there from an EARLIER run
|
|
46
|
+
* against this same patient (an authoring scout, or a prior worker run)
|
|
47
|
+
* that never got archived out. Left in place, THIS run's own Gate (which
|
|
48
|
+
* writes to that same path) would append to it, and the end-of-run rename
|
|
49
|
+
* that claims the whole file as "this run's own audit" would silently
|
|
50
|
+
* carry every earlier run's rows too — a harness slicing a shared append-
|
|
51
|
+
* only log has to account for every writer inside its window.
|
|
52
|
+
*
|
|
53
|
+
* Call this AFTER `coldReset` and BEFORE this run's own Gate ever opens: at
|
|
54
|
+
* that point a `gate-audit.jsonl` sitting in the tree is provably not this
|
|
55
|
+
* run's, because coldReset just proved the tree is unable to have produced
|
|
56
|
+
* one since the seed reset. NOT for a resume — a halted run's own gate
|
|
57
|
+
* audit already in the tree it resumes into is that SAME run's prior leg,
|
|
58
|
+
* not a stranger's; callers on the resume path must not call this.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} wd absolute path to the patient's tree, just cold-reset
|
|
61
|
+
* @param {string} spineDir absolute path to this run's spine directory
|
|
62
|
+
* @param {string} runid this run's own id (the same one every other spine
|
|
63
|
+
* file for this run is named after)
|
|
64
|
+
* @returns {string|null} the path the stale file was moved to, or `null`
|
|
65
|
+
* when there was nothing to move
|
|
66
|
+
*/
|
|
67
|
+
export function moveStaleGateAudit(wd, spineDir, runid) {
|
|
68
|
+
const staleAudit = join(wd, 'gate-audit.jsonl');
|
|
69
|
+
if (!existsSync(staleAudit)) return null;
|
|
70
|
+
const preFile = join(spineDir, `pre-${runid}-gate-audit.jsonl`);
|
|
71
|
+
renameSync(staleAudit, preFile);
|
|
72
|
+
return preFile;
|
|
73
|
+
}
|