bareloop 0.33.0 → 0.35.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 +47 -0
- package/bareloop.context.md +263 -27
- package/package.json +1 -1
- package/src/bundlerun.js +13 -4
- package/src/draftspend.js +82 -0
- package/src/exits.js +10 -1
- package/src/interviewrun.js +10 -9
- package/src/job.js +39 -2
- package/src/judged.js +25 -5
- package/src/kinds.js +2 -0
- package/src/ledger.js +104 -9
- package/src/legs.js +171 -0
- package/src/monthly.js +133 -87
- package/src/panel/authorroutes.js +95 -15
- package/src/panel/authorsession.js +408 -113
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1071 -184
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +855 -81
- package/src/panel/status.js +51 -0
- package/src/planrun.js +63 -31
- package/src/providerrows.js +21 -2
- package/src/providers.js +4 -4
- package/src/ralph.js +3 -0
- package/src/replay.js +139 -19
- package/src/replayio.js +11 -4
- package/src/reuse.js +20 -3
- package/src/run.js +6 -3
- package/src/runlist.js +90 -7
- package/src/text.js +20 -0
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +166 -65
- package/types/draftspend.d.ts +53 -0
- package/types/job.d.ts +25 -0
- package/types/judged.d.ts +5 -2
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +39 -22
- package/types/panel/authorroutes.d.ts +38 -1
- package/types/panel/authorsession.d.ts +109 -2
- package/types/panel/importroutes.d.ts +129 -0
- package/types/panel/importrun.d.ts +77 -0
- package/types/panel/runroutes.d.ts +32 -0
- package/types/panel/server.d.ts +255 -2
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +14 -1
- package/types/providers.d.ts +1 -2
- package/types/replay.d.ts +25 -1
- package/types/replayio.d.ts +2 -2
- package/types/reuse.d.ts +5 -0
- package/types/run.d.ts +6 -2
- package/types/runlist.d.ts +66 -2
- package/types/text.d.ts +19 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// PANEL-BUILD.md P5 item 4 — IMPORT, READ ONLY. A person points the panel at a job folder (an exported bundle) to
|
|
2
|
+
// LOOK at it: its goal, checks, guardrails, caps, model, exported history and whether it is approved on this
|
|
3
|
+
// machine. Nothing here runs a bundle, signs anything or writes into the folder; the only write is one line in
|
|
4
|
+
// `<home>/imports.jsonl` (`{at, dir, job, bundleHash}`, the list of what was imported).
|
|
5
|
+
//
|
|
6
|
+
// Two routes carry NEW DISK EXPOSURE, so both sit behind `checkHumanGuard` (token + own address), the strict
|
|
7
|
+
// guard `/api/author/*` has — not just the Host guard the read-only GETs use:
|
|
8
|
+
// GET /api/fs/list?path= the job bundles (a manifest.json file sits there) found up to 3 folders below path
|
|
9
|
+
// POST /api/imports {path} import one folder (readBundle must be clean)
|
|
10
|
+
// plus the guarded reads `GET /api/imports` (the list, every bundle re-read) and `GET /api/imports/:id` (one view).
|
|
11
|
+
//
|
|
12
|
+
// PATH SAFETY (the listing is a window onto the person's disk, so it is narrow by construction):
|
|
13
|
+
// * the requested path is expanded (`~` / `~/…` only), refused if it carries a NUL byte or is not absolute,
|
|
14
|
+
// then `resolve`d (`.`/`..` collapsed) and `realpath`ed — the REAL folder is what is listed and what is
|
|
15
|
+
// recorded, and the response says which it chose (`path` beside `requested`);
|
|
16
|
+
// * a path that is not a directory is refused; names are listed, file CONTENTS are never read here;
|
|
17
|
+
// * a SYMLINK inside the search is never listed and never followed (Dirent.isDirectory() is false for a link,
|
|
18
|
+
// which is what `readdir` withFileTypes reports — it types by lstat); `node_modules`, `.git` and dot-folders are
|
|
19
|
+
// skipped, a bundle is never descended into, and the search is capped (folders visited, bundles returned);
|
|
20
|
+
// * the `bundle` tag is a regular FILE named manifest.json (lstat), never a link to one.
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import {
|
|
25
|
+
writeFileSync, renameSync, mkdirSync, readFileSync, existsSync, readdirSync, statSync, lstatSync, realpathSync,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { dirname, join, isAbsolute, resolve } from 'node:path';
|
|
28
|
+
import { checkHumanGuard } from './authorroutes.js';
|
|
29
|
+
import { readBundle, verifyBlessing } from '../bundle.js';
|
|
30
|
+
import { runlistHome } from '../runlist.js';
|
|
31
|
+
import { statusFor, GOAL_MET_LINE } from './status.js';
|
|
32
|
+
import { bundleRuns, latestGreenRun, latestBridgeGreen, bridgeRunDetail, importedRunId } from './importrun.js';
|
|
33
|
+
|
|
34
|
+
/** the longest path the routes will look at (bytes) */
|
|
35
|
+
export const MAX_PATH_LEN = 4096;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Expand, normalise and `realpath` a requested folder. Never throws.
|
|
39
|
+
* @param {unknown} input the raw `path` (absent/blank = the person's home folder)
|
|
40
|
+
* @param {string} userHome the home folder (`~`)
|
|
41
|
+
* @returns {{ok: true, requested: string, path: string}|{ok: false, error: string}}
|
|
42
|
+
*/
|
|
43
|
+
export function resolveFsPath(input, userHome) {
|
|
44
|
+
const raw = input === undefined || input === null ? '' : input;
|
|
45
|
+
if (typeof raw !== 'string') return { ok: false, error: 'the path must be text' };
|
|
46
|
+
if (raw.length > MAX_PATH_LEN) return { ok: false, error: 'that path is too long' };
|
|
47
|
+
if (raw.includes('\0')) return { ok: false, error: 'that path has a character no folder name can have' };
|
|
48
|
+
let want = raw.trim();
|
|
49
|
+
if (want === '') want = userHome;
|
|
50
|
+
else if (want === '~') want = userHome;
|
|
51
|
+
else if (want.startsWith('~/')) want = join(userHome, want.slice(2));
|
|
52
|
+
else if (want.startsWith('~')) return { ok: false, error: 'only ~ and ~/… are understood, not another user\'s home' };
|
|
53
|
+
if (!isAbsolute(want)) return { ok: false, error: 'use an absolute path (starting with / or ~)' };
|
|
54
|
+
const requested = resolve(want);
|
|
55
|
+
/** @type {string} */
|
|
56
|
+
let real;
|
|
57
|
+
try { real = realpathSync(requested); } catch (/** @type {any} */ e) {
|
|
58
|
+
return { ok: false, error: e?.code === 'ENOENT' ? `no such folder: ${requested}` : `could not open ${requested} (${e?.code ?? 'error'})` };
|
|
59
|
+
}
|
|
60
|
+
let isDir = false;
|
|
61
|
+
try { isDir = statSync(real).isDirectory(); } catch { isDir = false; }
|
|
62
|
+
if (!isDir) return { ok: false, error: `not a folder: ${real}` };
|
|
63
|
+
return { ok: true, requested, path: real };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** how many levels below the current folder the bundle search looks */
|
|
67
|
+
export const SEARCH_DEPTH = 3;
|
|
68
|
+
/** the most folders one search visits; past it `stopped: true` */
|
|
69
|
+
export const MAX_VISITED = 5000;
|
|
70
|
+
/** the most bundles one search returns; past it `truncated: true` */
|
|
71
|
+
export const MAX_RESULTS = 200;
|
|
72
|
+
|
|
73
|
+
/** a bundle is a folder holding a regular FILE named manifest.json (lstat — never a link to one) */
|
|
74
|
+
function isBundleDir(/** @type {string} */ dir) {
|
|
75
|
+
try { return lstatSync(join(dir, 'manifest.json')).isFile(); } catch { return false; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The exported job bundles below `dir` (a `realpath` already), searched DOWN at most SEARCH_DEPTH levels. Symlinks are
|
|
80
|
+
* never followed, `node_modules`, `.git` and every dot-folder are skipped, a bundle is never descended into, an
|
|
81
|
+
* unreadable folder is skipped silently, and the search is capped (folders visited, results). Only names are read.
|
|
82
|
+
* @param {string} dir
|
|
83
|
+
* @returns {{entries: {name: string, path: string, bundle: true}[], visited: number, stopped: boolean, truncated: boolean}}
|
|
84
|
+
*/
|
|
85
|
+
export function findBundles(dir) {
|
|
86
|
+
/** @type {{name: string, path: string, bundle: true}[]} */
|
|
87
|
+
const entries = [];
|
|
88
|
+
let visited = 0;
|
|
89
|
+
let stopped = false;
|
|
90
|
+
let truncated = false;
|
|
91
|
+
/** @param {string} cur @param {string} rel @param {number} depth */
|
|
92
|
+
const walk = (cur, rel, depth) => {
|
|
93
|
+
if (depth > SEARCH_DEPTH || stopped || truncated) return;
|
|
94
|
+
/** @type {string[]} */
|
|
95
|
+
let names;
|
|
96
|
+
try { names = readdirSync(cur, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return; }
|
|
97
|
+
names.sort((a, b) => a.localeCompare(b));
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
if (name === 'node_modules' || name.startsWith('.')) continue;
|
|
100
|
+
if (visited >= MAX_VISITED) { stopped = true; return; }
|
|
101
|
+
visited += 1;
|
|
102
|
+
const path = join(cur, name);
|
|
103
|
+
const r = rel ? `${rel}/${name}` : name;
|
|
104
|
+
if (isBundleDir(path)) {
|
|
105
|
+
if (entries.length >= MAX_RESULTS) { truncated = true; return; }
|
|
106
|
+
entries.push({ name: r, path, bundle: true });
|
|
107
|
+
} else walk(path, r, depth + 1);
|
|
108
|
+
if (stopped || truncated) return;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(dir, '', 1);
|
|
112
|
+
return { entries, visited, stopped, truncated };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {string} [home] @returns {string} `<home>/imports.jsonl` */
|
|
116
|
+
export function importsPath(home) { return join(runlistHome(home), 'imports.jsonl'); }
|
|
117
|
+
|
|
118
|
+
/** the stable id of an imported folder (its real path, hashed — never the path itself in a URL) */
|
|
119
|
+
export function importId(/** @type {string} */ dir) { return createHash('sha256').update(dir).digest('hex').slice(0, 12); }
|
|
120
|
+
|
|
121
|
+
/** a folder's realpath, or the text itself when it cannot be resolved (a moved/deleted folder still lists) */
|
|
122
|
+
function realDir(/** @type {string} */ dir) { try { return realpathSync(dir); } catch { return dir; } }
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Record one import: ONE row per real folder. Any earlier row for the same folder is dropped and the new one is
|
|
126
|
+
* written, so re-importing updates the entry instead of adding a second. The rewrite goes through a temp file + rename.
|
|
127
|
+
* @param {string|undefined} home
|
|
128
|
+
* @param {{at: string, dir: string, job: string, bundleHash: string}} row
|
|
129
|
+
*/
|
|
130
|
+
export function recordImport(home, row) {
|
|
131
|
+
const file = importsPath(home);
|
|
132
|
+
const key = realDir(row.dir);
|
|
133
|
+
/** @type {string[]} */
|
|
134
|
+
const keep = [];
|
|
135
|
+
let text = '';
|
|
136
|
+
try { text = readFileSync(file, 'utf8'); } catch { text = ''; }
|
|
137
|
+
for (const line of text.split('\n')) {
|
|
138
|
+
if (!line.trim()) continue;
|
|
139
|
+
let o;
|
|
140
|
+
try { o = JSON.parse(line); } catch { keep.push(line); continue; } // never drop what we cannot read
|
|
141
|
+
if (o && typeof o.dir === 'string' && realDir(o.dir) === key) continue;
|
|
142
|
+
keep.push(line);
|
|
143
|
+
}
|
|
144
|
+
keep.push(JSON.stringify(row));
|
|
145
|
+
mkdirSync(runlistHome(home), { recursive: true, mode: 0o700 });
|
|
146
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
147
|
+
writeFileSync(tmp, `${keep.join('\n')}\n`, { mode: 0o600 });
|
|
148
|
+
renameSync(tmp, file);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The imported list: the LATEST row per folder (a re-import supersedes), newest first. A malformed line is skipped.
|
|
153
|
+
* @param {string} [home]
|
|
154
|
+
* @returns {{at: string, dir: string, job: string, bundleHash: string, id: string}[]}
|
|
155
|
+
*/
|
|
156
|
+
export function readImports(home) {
|
|
157
|
+
let text = '';
|
|
158
|
+
try { text = readFileSync(importsPath(home), 'utf8'); } catch { return []; }
|
|
159
|
+
/** @type {Map<string, any>} */
|
|
160
|
+
const byDir = new Map();
|
|
161
|
+
for (const line of text.split('\n')) {
|
|
162
|
+
if (!line.trim()) continue;
|
|
163
|
+
let r;
|
|
164
|
+
try { r = JSON.parse(line); } catch { continue; }
|
|
165
|
+
if (r && typeof r.dir === 'string' && typeof r.job === 'string' && typeof r.bundleHash === 'string') {
|
|
166
|
+
// one row per REAL folder: a later line (or a symlinked spelling of the same folder) replaces the earlier one
|
|
167
|
+
const key = realDir(r.dir);
|
|
168
|
+
const prev = byDir.get(key);
|
|
169
|
+
if (!prev || String(r.at) >= String(prev.at)) byDir.set(key, { ...r, id: importId(r.dir) });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return [...byDir.values()].sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The ONE spelling of the plain-words line for a changed import (Run tab, reuse refusal). The list tag stays the short statusText. */
|
|
176
|
+
export const IMPORT_CHANGED_LINE = 'files changed since you imported it \u2014 Reuse is off. Import it again if the change was yours.';
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Re-read one imported folder NOW and say how it stands against the import: `ok`, `changed` (the bundle's own
|
|
180
|
+
* hash, a tampered file or an unreadable part differ from what was imported — shown red as "changed since
|
|
181
|
+
* import"), or `missing` (the folder is gone). Never throws.
|
|
182
|
+
* @param {{dir: string, bundleHash: string}} row
|
|
183
|
+
* @returns {{status: 'ok'|'changed'|'missing', statusText: string|null, reds: string[], bundle: ReturnType<typeof readBundle>}}
|
|
184
|
+
*/
|
|
185
|
+
export function bundleStatus(row) {
|
|
186
|
+
const bundle = readBundle(row.dir);
|
|
187
|
+
const reds = bundle.reds.map((r) => r.code);
|
|
188
|
+
if (!existsSync(row.dir)) return { status: 'missing', statusText: 'folder not found — it was moved or deleted since import', reds, bundle };
|
|
189
|
+
const hash = bundle.manifest && typeof bundle.manifest.bundleHash === 'string' ? bundle.manifest.bundleHash : null;
|
|
190
|
+
if (bundle.ok && hash === row.bundleHash) return { status: 'ok', statusText: null, reds, bundle };
|
|
191
|
+
return { status: 'changed', statusText: 'changed since import', reds, bundle };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The `bundle` half of an import view: the exported history (the registry rows the bundle shipped), the approval
|
|
196
|
+
* on THIS machine (`blessing.json`, written by the first green run here), nothing about the folder's other files.
|
|
197
|
+
* @param {ReturnType<typeof readBundle>} bundle
|
|
198
|
+
*/
|
|
199
|
+
export function bundleHistoryAndApproval(bundle) {
|
|
200
|
+
/** @type {any[]} */
|
|
201
|
+
const rows = [];
|
|
202
|
+
for (const b of bundle.bridges) if (b && Array.isArray(b.history)) rows.push(...b.history);
|
|
203
|
+
rows.sort((a, b) => String(b?.at ?? '').localeCompare(String(a?.at ?? '')));
|
|
204
|
+
const greens = rows.filter((r) => r && r.outcome === 'green').length;
|
|
205
|
+
const approval = verifyBlessing(bundle);
|
|
206
|
+
const blessedAt = bundle.blessing && typeof bundle.blessing.blessedAt === 'string' ? bundle.blessing.blessedAt.slice(0, 10) : null;
|
|
207
|
+
return {
|
|
208
|
+
history: {
|
|
209
|
+
greens, reds: rows.length - greens, total: rows.length,
|
|
210
|
+
recent: rows.slice(0, 10).map((r) => ({
|
|
211
|
+
at: typeof r?.at === 'string' ? r.at : null, runid: typeof r?.runid === 'string' ? r.runid : null,
|
|
212
|
+
outcome: typeof r?.outcome === 'string' ? r.outcome : null, costUsd: typeof r?.costUsd === 'number' ? r.costUsd : null,
|
|
213
|
+
})),
|
|
214
|
+
},
|
|
215
|
+
approved: approval.ok,
|
|
216
|
+
approvedText: approval.ok ? `approved on this machine${blessedAt ? ` (first green run ${blessedAt})` : ''}`
|
|
217
|
+
: (approval.unblessed ? 'not approved on this machine yet — it has never run green here'
|
|
218
|
+
: 'approval is stale — the bundle changed since it was approved'),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The import routes' handler. `describeSpec(spec)` is the server's own spec reader (the SAME fact-readers the Job
|
|
224
|
+
* tab uses, so an imported job and a run read alike): `{checkType, goal, success, guardrails, model, budgetUsd,
|
|
225
|
+
* maxWallMs}`.
|
|
226
|
+
* @param {{ port: number, token: string, home?: string, userHome?: string,
|
|
227
|
+
* describeSpec: (spec: any) => any }} opts
|
|
228
|
+
*/
|
|
229
|
+
export function createImportRoutes(opts) {
|
|
230
|
+
const userHome = opts.userHome ?? homedir();
|
|
231
|
+
|
|
232
|
+
/** the view of one imported row, re-read now */
|
|
233
|
+
const viewOf = (/** @type {{id: string, at: string, dir: string, job: string, bundleHash: string}} */ row) => {
|
|
234
|
+
const st = bundleStatus(row);
|
|
235
|
+
const spec = st.bundle.spec;
|
|
236
|
+
const iv = importedRunView(row, st.bundle);
|
|
237
|
+
return {
|
|
238
|
+
id: row.id, job: row.job, dir: row.dir, importedAt: row.at, bundleHash: row.bundleHash,
|
|
239
|
+
status: st.status, statusText: st.statusText, changedLine: st.status === 'changed' ? IMPORT_CHANGED_LINE : null, reds: st.reds,
|
|
240
|
+
...(spec && typeof spec === 'object' ? opts.describeSpec(spec) : {}),
|
|
241
|
+
...bundleHistoryAndApproval(st.bundle),
|
|
242
|
+
...iv,
|
|
243
|
+
runStatus: iv.run.kind !== 'none' ? statusFor({ outcome: 'green' }) : null, runReason: iv.run.kind !== 'none' ? GOAL_MET_LINE : null,
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* What the Run tab shows below the IMPORTED facts (see importrun.js): `runsHere` (run folders with a readable spine),
|
|
249
|
+
* and `run` — `{kind:'spine', id, runid, at}` (the newest green run in the bundle's `runs/`, read through
|
|
250
|
+
* `/api/runs/<id>`), `{kind:'bridge', runid, at, detail}` (the bridge's green version as a run detail) or
|
|
251
|
+
* `{kind:'none'}`. Never throws: a bundle that cannot be read is `none`.
|
|
252
|
+
* @param {{id: string, dir: string, job: string}} row
|
|
253
|
+
* @param {ReturnType<typeof readBundle>} bundle
|
|
254
|
+
*/
|
|
255
|
+
const importedRunView = (row, bundle) => {
|
|
256
|
+
try {
|
|
257
|
+
const runsHere = bundleRuns(row.dir).length;
|
|
258
|
+
const g = latestGreenRun(row.dir);
|
|
259
|
+
if (g) return { runsHere, run: { kind: 'spine', id: importedRunId(row.id, g.runid), runid: g.runid, at: g.at } };
|
|
260
|
+
const b = latestBridgeGreen(bundle.bridges);
|
|
261
|
+
if (b) {
|
|
262
|
+
const spec = bundle.spec && typeof bundle.spec === 'object' ? bundle.spec : {};
|
|
263
|
+
const facts = opts.describeSpec(spec);
|
|
264
|
+
const detail = bridgeRunDetail(b, {
|
|
265
|
+
job: row.job, checkType: facts.checkType, checkTypeTitle: null,
|
|
266
|
+
model: typeof spec.model === 'string' && spec.model.length > 0 ? spec.model : null,
|
|
267
|
+
budgetUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
|
|
268
|
+
});
|
|
269
|
+
return { runsHere, run: { kind: 'bridge', runid: detail.runid, at: b.version.greenAt, detail } };
|
|
270
|
+
}
|
|
271
|
+
return { runsHere, run: { kind: 'none' } };
|
|
272
|
+
} catch {
|
|
273
|
+
return { runsHere: 0, run: { kind: 'none' } };
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @param {import('node:http').IncomingMessage} req
|
|
279
|
+
* @param {import('node:http').ServerResponse} res
|
|
280
|
+
* @param {string} pathname
|
|
281
|
+
* @param {any} body
|
|
282
|
+
* @returns {boolean}
|
|
283
|
+
*/
|
|
284
|
+
function handle(req, res, pathname, body) {
|
|
285
|
+
const viewM = /^\/api\/imports\/([0-9a-f]{12})$/.exec(pathname);
|
|
286
|
+
if (pathname !== '/api/fs/list' && pathname !== '/api/imports' && !viewM) return false;
|
|
287
|
+
const send = (/** @type {number} */ code, /** @type {any} */ obj) => {
|
|
288
|
+
const text = JSON.stringify(obj);
|
|
289
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
|
|
290
|
+
res.end(text);
|
|
291
|
+
};
|
|
292
|
+
const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
|
|
293
|
+
if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
|
|
294
|
+
|
|
295
|
+
if (pathname === '/api/fs/list') {
|
|
296
|
+
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
297
|
+
const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams.get('path') ?? '';
|
|
298
|
+
const r = resolveFsPath(q, userHome);
|
|
299
|
+
if (!r.ok) { send(400, r); return true; }
|
|
300
|
+
try {
|
|
301
|
+
const l = findBundles(r.path);
|
|
302
|
+
const parent = dirname(r.path);
|
|
303
|
+
send(200, {
|
|
304
|
+
ok: true, requested: r.requested, path: r.path, parent: parent === r.path ? null : parent,
|
|
305
|
+
bundle: (() => { try { return lstatSync(join(r.path, 'manifest.json')).isFile(); } catch { return false; } })(),
|
|
306
|
+
entries: l.entries, truncated: l.truncated, stopped: l.stopped, visited: l.visited, depth: SEARCH_DEPTH,
|
|
307
|
+
});
|
|
308
|
+
} catch (/** @type {any} */ e) {
|
|
309
|
+
send(400, { ok: false, error: `could not read ${r.path} (${e?.code ?? 'error'})` });
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (viewM) {
|
|
315
|
+
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
316
|
+
const row = readImports(opts.home).find((x) => x.id === viewM[1]);
|
|
317
|
+
if (!row) { send(404, { ok: false, error: 'no such imported job' }); return true; }
|
|
318
|
+
send(200, { ok: true, ...viewOf(row) });
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// /api/imports
|
|
323
|
+
if (req.method === 'GET') {
|
|
324
|
+
send(200, {
|
|
325
|
+
ok: true,
|
|
326
|
+
imports: readImports(opts.home).map((r) => {
|
|
327
|
+
const st = bundleStatus(r);
|
|
328
|
+
// the sign + word of the green run the view will SHOW (null when the bundle holds none — nothing to wear a word)
|
|
329
|
+
const shown = importedRunView(r, st.bundle).run.kind !== 'none';
|
|
330
|
+
return {
|
|
331
|
+
id: r.id, job: r.job, dir: r.dir, importedAt: r.at, bundleHash: r.bundleHash, status: st.status, statusText: st.statusText,
|
|
332
|
+
runStatus: shown ? statusFor({ outcome: 'green' }) : null, runReason: shown ? GOAL_MET_LINE : null,
|
|
333
|
+
};
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
if (req.method !== 'POST') { send(405, { ok: false, error: 'GET or POST only' }); return true; }
|
|
339
|
+
const r = resolveFsPath(body?.path, userHome);
|
|
340
|
+
if (!r.ok) { send(400, r); return true; }
|
|
341
|
+
const bundle = readBundle(r.path);
|
|
342
|
+
if (!bundle.ok || !bundle.manifest || typeof bundle.manifest.job !== 'string' || typeof bundle.manifest.bundleHash !== 'string') {
|
|
343
|
+
const why = bundle.reds.length > 0 ? bundle.reds.map((x) => `${x.code} (${x.path})`).join('; ') : 'no manifest';
|
|
344
|
+
send(400, { ok: false, error: `${r.path} is not a clean exported job folder — ${why}` });
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
const row = { at: new Date().toISOString(), dir: r.path, job: bundle.manifest.job, bundleHash: bundle.manifest.bundleHash };
|
|
348
|
+
try {
|
|
349
|
+
recordImport(opts.home, row);
|
|
350
|
+
} catch (/** @type {any} */ e) {
|
|
351
|
+
send(500, { ok: false, error: `could not record the import: ${e?.message ?? e}` });
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
send(200, { ok: true, id: importId(r.path), job: row.job, dir: r.path, requested: r.requested });
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { handle, viewOf };
|
|
359
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// An IMPORTED job's Run tab reads like a normal run page: the latest GREEN run in the bundle, rendered by the same
|
|
2
|
+
// summary / map / audit code a run uses. Where that run comes from, in order:
|
|
3
|
+
// 1. `<bundle>/runs/<runid>/spine.jsonl` (what `bareloop run <bundle>` writes) — the newest one that ended green.
|
|
4
|
+
// The panel reads it through the ordinary `/api/runs/<id>` routes (the run's id there is `<importId>~<runid>`).
|
|
5
|
+
// 2. else the bundle's bridge (`bridges/*.json`): the plan's steps and the numbers the bridge carries, as a run
|
|
6
|
+
// detail with every field it does not carry left null (the page says "not recorded", never 0).
|
|
7
|
+
// 3. else nothing: "no green run in this bundle".
|
|
8
|
+
// READ ONLY, and the bundle is untrusted input: nothing is ever written into it; every path is built from a checked
|
|
9
|
+
// name and `lstat`ed (a link is never followed), reads are size-bounded, a parse failure reads as "not recorded".
|
|
10
|
+
|
|
11
|
+
import { lstatSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { replayOne, parseJsonl } from '../replayio.js';
|
|
14
|
+
import { statusFor } from './status.js';
|
|
15
|
+
|
|
16
|
+
/** the most run folders one bundle is searched through (newest first) */
|
|
17
|
+
export const MAX_RUN_DIRS = 40;
|
|
18
|
+
/** the biggest spine / tool-log file that is read (bytes) */
|
|
19
|
+
export const MAX_RUN_FILE_BYTES = 32 * 1024 * 1024;
|
|
20
|
+
const RUN_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
21
|
+
const IMPORTED_RUN_RE = /^([0-9a-f]{12})~([A-Za-z0-9_-]{1,64})$/;
|
|
22
|
+
|
|
23
|
+
/** @param {string} p @returns {import('node:fs').Stats|null} */
|
|
24
|
+
function lst(p) { try { return lstatSync(p); } catch { return null; } }
|
|
25
|
+
|
|
26
|
+
/** the id an imported run has in `/api/runs/<id>` @param {string} importId @param {string} runid */
|
|
27
|
+
export function importedRunId(importId, runid) { return `${importId}~${runid}`; }
|
|
28
|
+
|
|
29
|
+
/** @param {string} id @returns {{importId: string, runid: string}|null} */
|
|
30
|
+
export function parseImportedRunId(id) {
|
|
31
|
+
const m = IMPORTED_RUN_RE.exec(id);
|
|
32
|
+
return m ? { importId: m[1], runid: m[2] } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The spine path of `<dir>/runs/<runid>`, or null when it is not a plain, bounded file in plain folders: `runs/` and
|
|
37
|
+
* the run folder must be real directories (a link is refused), the spine a regular file under the size bound, and a
|
|
38
|
+
* tool log beside it (either spelling the replay reader looks for) must not be a link or oversize either.
|
|
39
|
+
* @param {string} dir the bundle folder
|
|
40
|
+
* @param {string} runid
|
|
41
|
+
* @returns {string|null}
|
|
42
|
+
*/
|
|
43
|
+
export function safeSpinePath(dir, runid) {
|
|
44
|
+
if (typeof runid !== 'string' || !RUN_NAME_RE.test(runid)) return null;
|
|
45
|
+
const runDir = join(dir, 'runs', runid);
|
|
46
|
+
for (const p of [join(dir, 'runs'), runDir]) {
|
|
47
|
+
const s = lst(p);
|
|
48
|
+
if (!s || !s.isDirectory()) return null;
|
|
49
|
+
}
|
|
50
|
+
const spine = join(runDir, 'spine.jsonl');
|
|
51
|
+
const ss = lst(spine);
|
|
52
|
+
if (!ss || !ss.isFile() || ss.size > MAX_RUN_FILE_BYTES) return null;
|
|
53
|
+
for (const name of ['gate-audit.jsonl', 'spine-gate-audit.jsonl']) {
|
|
54
|
+
const s = lst(join(runDir, name));
|
|
55
|
+
if (s && (!s.isFile() || s.size > MAX_RUN_FILE_BYTES)) return null;
|
|
56
|
+
}
|
|
57
|
+
return spine;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The run folders of a bundle that hold a readable spine, newest spine first (bounded).
|
|
62
|
+
* @param {string} dir
|
|
63
|
+
* @returns {{runid: string, spine: string, mtimeMs: number}[]}
|
|
64
|
+
*/
|
|
65
|
+
export function bundleRuns(dir) {
|
|
66
|
+
let names;
|
|
67
|
+
try { names = readdirSync(join(dir, 'runs'), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return []; }
|
|
68
|
+
/** @type {{runid: string, spine: string, mtimeMs: number}[]} */
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const runid of names) {
|
|
71
|
+
const spine = safeSpinePath(dir, runid);
|
|
72
|
+
const s = spine ? lst(spine) : null;
|
|
73
|
+
if (spine && s) out.push({ runid, spine, mtimeMs: s.mtimeMs });
|
|
74
|
+
}
|
|
75
|
+
out.sort((a, b) => (b.mtimeMs - a.mtimeMs) || a.runid.localeCompare(b.runid));
|
|
76
|
+
return out.slice(0, MAX_RUN_DIRS);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* When the run started: its `job-start` record's `ts`, else the spine file's mtime (an ISO string). Never throws.
|
|
81
|
+
* @param {string} spine
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function spineStartedAt(spine) {
|
|
85
|
+
try {
|
|
86
|
+
const first = parseJsonl(spine).records.find((r) => r && r.type === 'job-start' && typeof r.ts === 'string' && Number.isFinite(Date.parse(r.ts)));
|
|
87
|
+
if (first) return first.ts;
|
|
88
|
+
} catch { /* falls back to the file's own time */ }
|
|
89
|
+
const s = lst(spine);
|
|
90
|
+
return new Date(s ? s.mtimeMs : 0).toISOString();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The newest run in the bundle whose spine ended green (`green` / `already-green`, the same two the run page's Ended
|
|
95
|
+
* block calls green). A spine that cannot be read is skipped, never fatal.
|
|
96
|
+
* @param {string} dir
|
|
97
|
+
* @returns {{runid: string, spine: string, at: string}|null}
|
|
98
|
+
*/
|
|
99
|
+
export function latestGreenRun(dir) {
|
|
100
|
+
for (const r of bundleRuns(dir)) {
|
|
101
|
+
let outcome = null;
|
|
102
|
+
try { outcome = replayOne(r.spine, { skipAudit: true }).outcome; } catch { continue; }
|
|
103
|
+
if (outcome === 'green' || outcome === 'already-green') return { runid: r.runid, spine: r.spine, at: spineStartedAt(r.spine) };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The newest green version across a bundle's bridges (by `greenAt`), with the history row of the same run (it
|
|
110
|
+
* carries whether the spend figure is complete). A malformed bridge or version is skipped.
|
|
111
|
+
* @param {any[]} bridges
|
|
112
|
+
* @returns {{bridge: any, version: any, historyRow: any|null}|null}
|
|
113
|
+
*/
|
|
114
|
+
export function latestBridgeGreen(bridges) {
|
|
115
|
+
/** @type {{bridge: any, version: any, at: number}|null} */
|
|
116
|
+
let best = null;
|
|
117
|
+
for (const b of Array.isArray(bridges) ? bridges : []) {
|
|
118
|
+
if (!b || typeof b !== 'object' || !Array.isArray(b.versions)) continue;
|
|
119
|
+
for (const v of b.versions) {
|
|
120
|
+
if (!v || typeof v !== 'object' || typeof v.greenAt !== 'string') continue;
|
|
121
|
+
const at = Date.parse(v.greenAt);
|
|
122
|
+
if (!Number.isFinite(at)) continue;
|
|
123
|
+
if (!best || at > best.at) best = { bridge: b, version: v, at };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!best) return null;
|
|
127
|
+
const hist = Array.isArray(best.bridge.history) ? best.bridge.history : [];
|
|
128
|
+
const historyRow = typeof best.version.runid === 'string'
|
|
129
|
+
? (hist.find((/** @type {any} */ h) => h && h.runid === best.version.runid && h.outcome === 'green') ?? null) : null;
|
|
130
|
+
return { bridge: best.bridge, version: best.version, historyRow };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @param {unknown} v @returns {number|null} */
|
|
134
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null);
|
|
135
|
+
/** @param {unknown} v @returns {string[]} */
|
|
136
|
+
const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.length > 0).slice(0, 100) : []);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The run detail of the bridge's green version, in the SAME shape `getRunDetail` returns (so the page's one run
|
|
140
|
+
* renderer paints it), plus `fromBridge: true`. Only what the bridge carries is filled; every other field is null
|
|
141
|
+
* ("not recorded" on the page), never 0: no tool counts, no cache, no per-step figures, no close stages' numbers.
|
|
142
|
+
* @param {{bridge: any, version: any, historyRow: any|null}} g
|
|
143
|
+
* @param {{job: string, checkType: string, checkTypeTitle: string|null, model: string|null, budgetUsd: number|null}} facts
|
|
144
|
+
* @returns {any}
|
|
145
|
+
*/
|
|
146
|
+
export function bridgeRunDetail(g, facts) {
|
|
147
|
+
const v = g.version;
|
|
148
|
+
const steps = Array.isArray(v.plan?.steps) ? v.plan.steps.filter((/** @type {any} */ s) => s && typeof s.id === 'string').slice(0, 200) : [];
|
|
149
|
+
const parts = steps.map((/** @type {any} */ s) => ({
|
|
150
|
+
kind: 'step', label: s.id, occurrence: 1, tryNumber: 1, continued: false, resumedNext: false, outcome: null, stopReason: null,
|
|
151
|
+
attempts: [], blocked: 0, rounds: null, toolCalls: null, wallMs: null, spentUsd: null, unpricedRounds: 0, byTool: null,
|
|
152
|
+
}));
|
|
153
|
+
const stageNames = strs(g.bridge.closeStageNames);
|
|
154
|
+
return {
|
|
155
|
+
fromBridge: true,
|
|
156
|
+
runid: typeof v.runid === 'string' ? v.runid : null,
|
|
157
|
+
job: facts.job,
|
|
158
|
+
goal: null,
|
|
159
|
+
checkType: facts.checkType,
|
|
160
|
+
checkTypeTitle: facts.checkTypeTitle,
|
|
161
|
+
model: facts.model,
|
|
162
|
+
provider: null,
|
|
163
|
+
judgeModel: null,
|
|
164
|
+
budgetUsd: facts.budgetUsd,
|
|
165
|
+
glyph: '✓',
|
|
166
|
+
status: statusFor({ outcome: 'green' }),
|
|
167
|
+
outcome: 'green',
|
|
168
|
+
died: false,
|
|
169
|
+
stopReason: 'not recorded',
|
|
170
|
+
ended: null,
|
|
171
|
+
live: false,
|
|
172
|
+
stopping: false,
|
|
173
|
+
legs: [],
|
|
174
|
+
legDividers: [],
|
|
175
|
+
resumedCount: 0,
|
|
176
|
+
resume: null,
|
|
177
|
+
spentUsd: num(v.costUsd),
|
|
178
|
+
spendComplete: g.historyRow ? g.historyRow.spendComplete === true : null,
|
|
179
|
+
draftSpentUsd: null,
|
|
180
|
+
draftSpendComplete: null,
|
|
181
|
+
spendFloorUsd: null,
|
|
182
|
+
wallFloorMs: null,
|
|
183
|
+
wallMs: num(v.wallMs),
|
|
184
|
+
rounds: num(v.rounds),
|
|
185
|
+
timelineKind: 'plan',
|
|
186
|
+
steps: parts.map((/** @type {any} */ p) => ({
|
|
187
|
+
id: p.label, occurrence: 1, outcome: null, state: 'done', rounds: null, toolCalls: null, wallMs: null, spentUsd: null,
|
|
188
|
+
unpricedRounds: 0, checks: null, treeChanged: null, tripped: null, attempts: [],
|
|
189
|
+
})),
|
|
190
|
+
scoutPlan: null,
|
|
191
|
+
fixLoop: null,
|
|
192
|
+
parts,
|
|
193
|
+
replans: null,
|
|
194
|
+
close: stageNames.length > 0 ? { verdict: `satisfied — ${stageNames.join(' · ')}` } : null,
|
|
195
|
+
branch: null,
|
|
196
|
+
date: typeof v.greenAt === 'string' ? v.greenAt.slice(0, 10) : null,
|
|
197
|
+
at: v.greenAt,
|
|
198
|
+
via: 'import',
|
|
199
|
+
behaviour: null,
|
|
200
|
+
memoryCache: null,
|
|
201
|
+
toolsUsed: strs(g.bridge.toolsUsed),
|
|
202
|
+
};
|
|
203
|
+
}
|