bareloop 0.34.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 +31 -0
- package/bareloop.context.md +241 -13
- 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 +7 -7
- package/src/job.js +37 -0
- package/src/judged.js +2 -2
- package/src/kinds.js +2 -1
- package/src/ledger.js +101 -5
- package/src/legs.js +171 -0
- package/src/monthly.js +111 -72
- package/src/panel/authorroutes.js +84 -14
- 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 +839 -70
- package/src/panel/status.js +51 -0
- package/src/planrun.js +60 -26
- package/src/providerrows.js +17 -0
- 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 +75 -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 +138 -57
- package/types/draftspend.d.ts +53 -0
- package/types/job.d.ts +25 -0
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +23 -18
- package/types/panel/authorroutes.d.ts +23 -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 +12 -0
- 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 +52 -2
- package/types/text.d.ts +19 -0
package/src/panel/server.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// for every cap/threshold: a shell never widens what it was asked to do).
|
|
26
26
|
|
|
27
27
|
import { createServer } from 'node:http';
|
|
28
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
28
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
29
29
|
import {
|
|
30
30
|
dirname, join, basename, relative, isAbsolute, sep,
|
|
31
31
|
} from 'node:path';
|
|
@@ -34,12 +34,25 @@ import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
|
|
|
34
34
|
import {
|
|
35
35
|
replayOne, parseJsonl, resolveSiblings, isSidecarByName,
|
|
36
36
|
} from '../replayio.js';
|
|
37
|
-
import { summarizeForAllLine,
|
|
37
|
+
import { summarizeForAllLine, auditWindowsOf } from '../replay.js';
|
|
38
|
+
import { legsOf, legsWallMs, stopFilePath } from '../legs.js';
|
|
39
|
+
import { chainSpend } from '../ledger.js';
|
|
38
40
|
import { runBehaviour } from '../behaviour.js';
|
|
39
41
|
import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
|
|
40
|
-
import { jobSpecHash } from '../job.js';
|
|
42
|
+
import { jobSpecHash, workflowKey } from '../job.js';
|
|
41
43
|
import { confirmProtections } from '../authorflow.js';
|
|
42
|
-
import { createAuthorRoutes, mintToken, checkHostGuard } from './authorroutes.js';
|
|
44
|
+
import { createAuthorRoutes, mintToken, checkHostGuard, panelMoney2 } from './authorroutes.js';
|
|
45
|
+
import { createRunRoutes } from './runroutes.js';
|
|
46
|
+
import { createImportRoutes, readImports, bundleStatus, IMPORT_CHANGED_LINE } from './importroutes.js';
|
|
47
|
+
import { parseImportedRunId, safeSpinePath, spineStartedAt } from './importrun.js';
|
|
48
|
+
import { checkBundleDeps, resolveBundleSpec } from '../bundle.js';
|
|
49
|
+
import { checkCloseByteSignature } from '../close-integrity.js';
|
|
50
|
+
import { keysHome } from '../keysfile.js';
|
|
51
|
+
import { rowsForHome, findRow, modelChoiceFor } from '../providerrows.js';
|
|
52
|
+
import { resolveProvider } from '../providers.js';
|
|
53
|
+
import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
|
|
54
|
+
import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
|
|
55
|
+
import { statusFor, GOAL_MET_LINE } from './status.js';
|
|
43
56
|
import { createSettingsRoutes } from './settingsroutes.js';
|
|
44
57
|
|
|
45
58
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -71,9 +84,7 @@ export const DEFAULT_PORT = 4700;
|
|
|
71
84
|
* @returns {'✓'|'✗'|'▶'}
|
|
72
85
|
*/
|
|
73
86
|
export function glyphForOutcome(outcome) {
|
|
74
|
-
|
|
75
|
-
if (outcome === null || outcome === undefined) return '▶';
|
|
76
|
-
return '✗';
|
|
87
|
+
return /** @type {'✓'|'✗'|'▶'} */ (statusFor({ outcome }).sign);
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
/**
|
|
@@ -234,11 +245,11 @@ export function formatTimestamp(ts) {
|
|
|
234
245
|
* @param {import('../runlist.js').RunRow} row the run's listed row (its pid, else its spine's mtime, says whether it is still running)
|
|
235
246
|
* @param {any[]} records raw parsed spine records (already read once by the caller)
|
|
236
247
|
* @param {string|null} outcome `replayRun`'s own `summary.outcome`
|
|
237
|
-
* @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
|
|
248
|
+
* @returns {{died: boolean, why: string|null, lastThing: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
|
|
238
249
|
*/
|
|
239
250
|
function deriveDeath(row, records, outcome) {
|
|
240
251
|
const notDied = {
|
|
241
|
-
died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
|
|
252
|
+
died: false, why: null, lastThing: null, spendFloorUsd: null, wallFloorMs: null,
|
|
242
253
|
};
|
|
243
254
|
if (outcome !== null && outcome !== undefined) return notDied; // a real job-end was reached
|
|
244
255
|
// no job-end yet — the floor derivation is identical whether this turns
|
|
@@ -250,10 +261,11 @@ function deriveDeath(row, records, outcome) {
|
|
|
250
261
|
const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
|
|
251
262
|
const last = withTs.length ? withTs[withTs.length - 1] : null;
|
|
252
263
|
const when = last ? formatTimestamp(last.ts) : 'an unknown time';
|
|
253
|
-
const
|
|
264
|
+
const lastThing = `${describeLastRecord(last)} at ${when}`;
|
|
265
|
+
const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${lastThing}.`;
|
|
254
266
|
|
|
255
267
|
return {
|
|
256
|
-
died: true, why, ...floors,
|
|
268
|
+
died: true, why, lastThing, ...floors,
|
|
257
269
|
};
|
|
258
270
|
}
|
|
259
271
|
|
|
@@ -273,6 +285,352 @@ function formatDurationMs(ms) {
|
|
|
273
285
|
return `${m}m${String(rem).padStart(2, '0')}s`;
|
|
274
286
|
}
|
|
275
287
|
|
|
288
|
+
/**
|
|
289
|
+
* P5 item 2 — may this run be resumed, and under which spec file? The ONE owner of
|
|
290
|
+
* "would the engine accept `--resume` for this run" on the panel side: it asks the SAME
|
|
291
|
+
* library readers the engine's own refusals come from (`readResume` with
|
|
292
|
+
* `CHECKPOINT_OUTCOMES`, `checkpointAgeGate`), plus the two facts the panel needs to
|
|
293
|
+
* spawn it (a signed spec file beside the run, whose hash is the one the run was signed
|
|
294
|
+
* under). `ok:false` means the panel declines to offer a button the engine would refuse
|
|
295
|
+
* by design; it carries a plain `why`.
|
|
296
|
+
*
|
|
297
|
+
* The spec file is the run's own `resolved-spec.json` or one of its
|
|
298
|
+
* `resolved-spec-r<k>.json` siblings (a resume under raised caps writes one), picked by
|
|
299
|
+
* HASH against the run's `job-start.specHash` — a run resumed under raised caps must be
|
|
300
|
+
* resumed again under ITS caps, never the original's. No match = no resume offered.
|
|
301
|
+
* @param {{ spine: string, job: string, pid?: number }} row
|
|
302
|
+
* @param {any[]} records the run's raw spine records
|
|
303
|
+
* @returns {{ok: true, specPath: string, spec: any, specHash: string, budgetUsd: number|null,
|
|
304
|
+
* maxWallMin: number|null, spentUsd: number|null, spendComplete: boolean,
|
|
305
|
+
* draftSpentUsd: number|null, draftSpendComplete: boolean|null, wallUsedMs: number|null}|{ok: false, why: string}}
|
|
306
|
+
*/
|
|
307
|
+
export function resumePlanFor(row, records) {
|
|
308
|
+
if (runIsAlive(row)) return { ok: false, why: 'it is still running' };
|
|
309
|
+
// P5-R: a resumed run is one file of several legs. What may be resumed again is the LATEST leg, under the spec
|
|
310
|
+
// THAT leg ran with (its `job-start.specHash` — a leg resumed under raised caps carries the re-signed hash), so
|
|
311
|
+
// resume #2 is offered under the latest signed caps, never the first leg's.
|
|
312
|
+
const legs = legsOf(records);
|
|
313
|
+
const jobStart = (legs.findLast((l) => l.jobStart !== null) ?? null)?.jobStart ?? null;
|
|
314
|
+
if (!jobStart) return { ok: false, why: 'its log has no start record' };
|
|
315
|
+
const dead = readResume(records, { direct: true, resumableOutcomes: CHECKPOINT_OUTCOMES });
|
|
316
|
+
if (!dead.started) return { ok: false, why: 'its log has no start record' };
|
|
317
|
+
if (dead.greened) return { ok: false, why: 'it already met its goal' };
|
|
318
|
+
if (dead.ended) return { ok: false, why: 'it ended with an answer, not a stop' };
|
|
319
|
+
if (!dead.restart) return { ok: false, why: 'it never opened an attempt to continue' };
|
|
320
|
+
const age = checkpointAgeGate(records);
|
|
321
|
+
if (!age.ok) return { ok: false, why: String(age.detail ?? 'its checkpoint has expired') };
|
|
322
|
+
const near = sourceNearSpine(row.spine);
|
|
323
|
+
if (!near.specPath) return { ok: false, why: 'the signed job file is not beside this run' };
|
|
324
|
+
const dir = dirname(near.specPath);
|
|
325
|
+
/** @type {string[]} */
|
|
326
|
+
let names = [];
|
|
327
|
+
try { names = readdirSync(dir).filter((n) => n === 'resolved-spec.json' || /^resolved-spec-r\d+\.json$/.test(n)); } catch { names = []; }
|
|
328
|
+
for (const n of names) {
|
|
329
|
+
let spec;
|
|
330
|
+
try { spec = JSON.parse(readFileSync(join(dir, n), 'utf8')); } catch { continue; }
|
|
331
|
+
if (!spec || typeof spec !== 'object') continue;
|
|
332
|
+
const specHash = jobSpecHash(spec);
|
|
333
|
+
if (specHash !== jobStart.specHash || spec.job !== dead.job) continue;
|
|
334
|
+
const draft = typeof jobStart.draftSpentUsd === 'number' && jobStart.draftSpentUsd > 0 ? jobStart.draftSpentUsd : null;
|
|
335
|
+
const spentKnown = typeof dead.spentUsd === 'number' && Number.isFinite(dead.spentUsd);
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
specPath: join(dir, n),
|
|
339
|
+
spec,
|
|
340
|
+
specHash,
|
|
341
|
+
budgetUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
|
|
342
|
+
maxWallMin: typeof spec.maxWallMs === 'number' ? spec.maxWallMs / 60000 : null,
|
|
343
|
+
spentUsd: spentKnown ? dead.spentUsd + (draft ?? 0) : null,
|
|
344
|
+
spendComplete: spentKnown && dead.spendComplete !== false && (draft === null || jobStart.draftSpendComplete !== false),
|
|
345
|
+
draftSpentUsd: draft,
|
|
346
|
+
draftSpendComplete: draft === null ? null : jobStart.draftSpendComplete !== false,
|
|
347
|
+
wallUsedMs: typeof dead.restart.priorWallMs === 'number' && Number.isFinite(dead.restart.priorWallMs) ? dead.restart.priorWallMs : null,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return { ok: false, why: 'no signed job file beside this run matches the hash it ran under' };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* P5-R — the Audit tab's LEG DIVIDERS (hamr 2026-10-02: "clear audit mention resume"). One per resume, CODE-OWNED
|
|
355
|
+
* fixed sentences (ui-verdict-words: never model text) built from how the previous leg ended (`after`) and what the
|
|
356
|
+
* run had spent when it stopped:
|
|
357
|
+
* `── stopped: money cap reached ($8.00) · resumed 2026-10-03 14:10 ──`.
|
|
358
|
+
* `beforePart` is the index of the first part of the leg that picked up (the parts list length when that leg has not
|
|
359
|
+
* started a part yet). Money is the chain's own spend at that leg's end on the ONE basis ({@link chainSpend}).
|
|
360
|
+
* @param {any[]} spineRecords the run's raw records
|
|
361
|
+
* @param {any[]} parts `summary.parts` (each carries `leg` on a resumed run)
|
|
362
|
+
* @returns {{leg: number, beforePart: number, after: string|null, at: string|null, text: string}[]}
|
|
363
|
+
*/
|
|
364
|
+
export function legDividersFor(spineRecords, parts) {
|
|
365
|
+
const legs = legsOf(spineRecords);
|
|
366
|
+
/** @type {{leg: number, beforePart: number, after: string|null, at: string|null, text: string}[]} */
|
|
367
|
+
const out = [];
|
|
368
|
+
for (let k = 1; k < legs.length; k += 1) {
|
|
369
|
+
const l = legs[k];
|
|
370
|
+
const first = parts.findIndex((p) => typeof p.leg === 'number' && p.leg >= l.leg);
|
|
371
|
+
const spent = chainSpend(legs.slice(0, k).flatMap((x) => x.records));
|
|
372
|
+
const money = `${spent.complete ? '' : 'at least '}${panelMoney2(spent.usd)}`;
|
|
373
|
+
const a = l.after;
|
|
374
|
+
let stopped;
|
|
375
|
+
if (a === 'cap-halt') stopped = `money cap reached (${money})`;
|
|
376
|
+
else if (a === 'wall-halt') stopped = 'time cap reached';
|
|
377
|
+
else if (a === 'provider-red') stopped = 'the model provider failed';
|
|
378
|
+
else if (a === 'step-stalled') stopped = 'a step stopped making progress';
|
|
379
|
+
else if (a === 'stopped') stopped = 'you stopped it';
|
|
380
|
+
else if (a === 'hitl-pause') stopped = 'paused for a decision';
|
|
381
|
+
else if (a === 'died' || a === null) stopped = 'no ending was recorded';
|
|
382
|
+
else stopped = String(a);
|
|
383
|
+
const at = typeof l.start?.at === 'string' ? l.start.at : (typeof l.start?.ts === 'string' ? l.start.ts : null);
|
|
384
|
+
out.push({
|
|
385
|
+
leg: l.leg,
|
|
386
|
+
beforePart: first === -1 ? parts.length : first,
|
|
387
|
+
after: a,
|
|
388
|
+
at,
|
|
389
|
+
text: `stopped: ${stopped} · resumed ${at ? formatTimestamp(at) : 'at an unknown time'}`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** outcomes whose ending is "the checks said no" (a graded answer from a working close) */
|
|
396
|
+
const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated']);
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* P5 item 1 — the ENDED block: why a run ended, what to do next, and which buttons the
|
|
400
|
+
* engine would accept. CODE-OWNED FIXED SENTENCES (ui-verdict-words: never model text);
|
|
401
|
+
* the only slot-fills are numbers and the engine's own recorded detail. ONE owner,
|
|
402
|
+
* feeding both `getRunDetail` (`ended`) and `summarizeRow` (`endedLine`). `null` while
|
|
403
|
+
* a run is live (no Ended block while running).
|
|
404
|
+
*
|
|
405
|
+
* The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
|
|
406
|
+
* Resume button appears only when `o.resume` says the engine would accept it; "Reuse workflow"
|
|
407
|
+
* (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
|
|
408
|
+
* @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
|
|
409
|
+
* @param {{died: boolean, lastThing: string|null}} death
|
|
410
|
+
* @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean}} [o]
|
|
411
|
+
* @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
|
|
412
|
+
*/
|
|
413
|
+
export function endedFor(summary, death, o = {}) {
|
|
414
|
+
const resumeOk = !!(o.resume && o.resume.ok);
|
|
415
|
+
/** @type {{id: string, label: string}[]} */
|
|
416
|
+
const RESUME = [{ id: 'resume', label: 'Resume' }];
|
|
417
|
+
/** Reuse workflow (replaces P5 item 3's Start from this, hamr 2026-10-03): the same signed job on a new source — a NEW
|
|
418
|
+
* run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: Clear the card and draft a new one" instead. */
|
|
419
|
+
const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
|
|
420
|
+
const CHANGE = 'Change the job: Clear the card and draft a new one.';
|
|
421
|
+
const detailOf = (/** @type {string|null} */ s) => {
|
|
422
|
+
if (typeof s !== 'string' || s.length === 0) return '';
|
|
423
|
+
return s.length > 120 ? `${s.slice(0, 120)}…` : s;
|
|
424
|
+
};
|
|
425
|
+
/** a resumable ending whose Resume the engine would refuse: say so, never offer the button */
|
|
426
|
+
const resumeOr = (/** @type {string} */ okNext) => (resumeOk ? okNext : `Resume is not available for this run${o.resume && o.resume.why ? ` (${o.resume.why})` : ''}.`);
|
|
427
|
+
|
|
428
|
+
if (death.died) {
|
|
429
|
+
return {
|
|
430
|
+
reason: `Stopped with no ending recorded${death.lastThing ? ` (last thing it did: ${death.lastThing})` : ''}.`,
|
|
431
|
+
next: resumeOr('Resume.'),
|
|
432
|
+
line: resumeOk ? 'no ending recorded — resume' : 'no ending recorded',
|
|
433
|
+
actions: resumeOk ? RESUME : [],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
const raw = summary.outcome;
|
|
437
|
+
if (raw === null || raw === undefined) return null;
|
|
438
|
+
const outcome = raw;
|
|
439
|
+
const esc = summary.lastEscalation;
|
|
440
|
+
const cat = typeof esc?.category === 'string' ? esc.category : null;
|
|
441
|
+
|
|
442
|
+
// `escalated` is a TERMINAL: its sentence may name the real governor, but it is never
|
|
443
|
+
// resumable. Two governors, never mixed (F198): the STRIKE ladder (the fix loop stopped
|
|
444
|
+
// improving) also files category `cap-halt` — it is the MONEY cap only when the spine
|
|
445
|
+
// says so (a `money-halt` record, or the job-end outcome `cap-halt` itself).
|
|
446
|
+
if (outcome === 'escalated') {
|
|
447
|
+
const strikes = esc?.spend?.strikes;
|
|
448
|
+
const limit = esc?.spend?.strikeLimit;
|
|
449
|
+
if (cat === 'cap-halt' && o.moneyHalt) {
|
|
450
|
+
const money = typeof summary.spentUsd === 'number' && typeof summary.budgetUsd === 'number'
|
|
451
|
+
? ` (${panelMoney2(summary.spentUsd)} of ${panelMoney2(summary.budgetUsd)})` : '';
|
|
452
|
+
return {
|
|
453
|
+
reason: `Money cap reached${money}.`, next: CHANGE, line: 'money cap', actions: [],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (cat === 'cap-halt' && typeof strikes === 'number' && typeof limit === 'number') {
|
|
457
|
+
return {
|
|
458
|
+
reason: `The fix loop stopped improving (${strikes} of ${limit} tries, no check got better).`,
|
|
459
|
+
next: CHANGE,
|
|
460
|
+
line: 'stopped improving',
|
|
461
|
+
actions: [],
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
if (cat === 'wall-halt') {
|
|
465
|
+
return { reason: 'Time cap reached.', next: CHANGE, line: 'time cap', actions: [] };
|
|
466
|
+
}
|
|
467
|
+
if (cat === 'provider-red') {
|
|
468
|
+
const d = detailOf(summary.stopReason);
|
|
469
|
+
return {
|
|
470
|
+
reason: `The model provider failed${d ? ` (${d})` : ''}.`, next: CHANGE, line: 'provider failed', actions: [],
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
|
|
476
|
+
if (o.destinationRefused) {
|
|
477
|
+
return {
|
|
478
|
+
reason: 'Goal met, but the output could not be delivered.',
|
|
479
|
+
next: 'Fix the destination, then Reuse workflow.',
|
|
480
|
+
line: `${GOAL_MET_LINE} — not delivered`,
|
|
481
|
+
actions: REUSE,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
|
|
485
|
+
}
|
|
486
|
+
if (outcome === 'cap-halt') {
|
|
487
|
+
const money = typeof summary.spentUsd === 'number' && typeof summary.budgetUsd === 'number'
|
|
488
|
+
? ` (${panelMoney2(summary.spentUsd)} of ${panelMoney2(summary.budgetUsd)})` : '';
|
|
489
|
+
return {
|
|
490
|
+
reason: `Money cap reached${money}.`,
|
|
491
|
+
next: resumeOr('Raise the cap, then Resume.'),
|
|
492
|
+
line: resumeOk ? 'money cap — resume' : 'money cap',
|
|
493
|
+
actions: resumeOk ? RESUME : [],
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
if (outcome === 'wall-halt') {
|
|
497
|
+
return {
|
|
498
|
+
reason: 'Time cap reached.',
|
|
499
|
+
next: resumeOr('Raise the time, then Resume.'),
|
|
500
|
+
line: resumeOk ? 'time cap — resume' : 'time cap',
|
|
501
|
+
actions: resumeOk ? RESUME : [],
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (outcome === 'stopped') {
|
|
505
|
+
// P5 item 5: the PERSON ended the leg (Stop). Nothing failed; the finished steps stand.
|
|
506
|
+
return {
|
|
507
|
+
reason: 'You stopped it.',
|
|
508
|
+
next: resumeOr('Resume.'),
|
|
509
|
+
line: resumeOk ? 'you pressed Stop — resume' : 'you pressed Stop',
|
|
510
|
+
actions: resumeOk ? RESUME : [],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (outcome === 'provider-red') {
|
|
514
|
+
const d = detailOf(summary.stopReason);
|
|
515
|
+
return {
|
|
516
|
+
reason: `The model provider failed${d ? ` (${d})` : ''}.`,
|
|
517
|
+
next: resumeOr('Resume.'),
|
|
518
|
+
line: resumeOk ? 'provider failed — resume' : 'provider failed',
|
|
519
|
+
actions: resumeOk ? RESUME : [],
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (outcome === 'step-stalled') {
|
|
523
|
+
return {
|
|
524
|
+
reason: 'A step stopped making progress.',
|
|
525
|
+
next: resumeOr('Resume, or change the job: Clear the card and draft a new one.'),
|
|
526
|
+
line: resumeOk ? 'step stalled — resume' : 'step stalled',
|
|
527
|
+
actions: resumeOk ? RESUME : [],
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
if (GOAL_NOT_MET.has(outcome)) {
|
|
531
|
+
const d = detailOf(summary.stopReason);
|
|
532
|
+
return {
|
|
533
|
+
reason: `Goal not met — the checks said no${d ? ` (${d})` : ''}.`,
|
|
534
|
+
next: CHANGE,
|
|
535
|
+
line: 'checks said no',
|
|
536
|
+
actions: [],
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
if (outcome === 'close-red') {
|
|
540
|
+
return {
|
|
541
|
+
reason: 'The check itself broke (instrument fault), not your goal.',
|
|
542
|
+
next: CHANGE,
|
|
543
|
+
line: 'check broke',
|
|
544
|
+
actions: [],
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
// the raw engine detail is never shown here (it can name retired surfaces); the code is enough
|
|
548
|
+
return {
|
|
549
|
+
reason: `Stopped before or outside the work (code: ${outcome}).`,
|
|
550
|
+
next: CHANGE,
|
|
551
|
+
line: 'stopped before the work',
|
|
552
|
+
actions: [],
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The Ended block for one listed run (P5 item 1): asks {@link resumePlanFor} only when
|
|
558
|
+
* the run's ending is one a resume could ever apply to, so a finished green never reads
|
|
559
|
+
* its spec files. Returns the block, the resume plan (for the confirm box's numbers) and
|
|
560
|
+
* nothing else — the sentences all live in {@link endedFor}.
|
|
561
|
+
* @param {{ spine: string, job: string, pid?: number }} row
|
|
562
|
+
* @param {any[]} records
|
|
563
|
+
* @param {any} summary `replayOne`'s summary
|
|
564
|
+
* @param {{died: boolean, lastThing: string|null}} death
|
|
565
|
+
*/
|
|
566
|
+
/** the timestamp of the spine's LAST `job-end` record, or null (live, died, or no timestamp) */
|
|
567
|
+
function lastJobEndTs(/** @type {any[]} */ records) {
|
|
568
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
569
|
+
const r = records[i];
|
|
570
|
+
if (r && r.type === 'job-end') return typeof r.ts === 'string' ? r.ts : null;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function endedForRow(row, records, summary, death) {
|
|
576
|
+
const out = summary.outcome;
|
|
577
|
+
const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
|
|
578
|
+
const resume = maybeResumable ? resumePlanFor(row, records) : null;
|
|
579
|
+
const refused = [...records].reverse().find((r) => r && r.type === 'destination-refused') ?? null;
|
|
580
|
+
const ended = endedFor(summary, death, {
|
|
581
|
+
resume,
|
|
582
|
+
moneyHalt: records.some((r) => r && r.type === 'money-halt'),
|
|
583
|
+
destinationRefused: refused ? String(refused.detail ?? refused.code ?? 'the destination refused it') : null,
|
|
584
|
+
});
|
|
585
|
+
const esc = summary.lastEscalation;
|
|
586
|
+
const status = statusFor({
|
|
587
|
+
outcome: out, died: death.died, category: typeof esc?.category === 'string' ? esc.category : null,
|
|
588
|
+
moneyHalt: records.some((r) => r && r.type === 'money-halt'),
|
|
589
|
+
});
|
|
590
|
+
return { ended, resume, status };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* P5 item 6 — a run whose row is listed (its runner is alive) but whose spine does not
|
|
595
|
+
* exist YET: the runner appends the row first, then opens the spine. It is `starting`,
|
|
596
|
+
* never `file missing`. Carries the same field names a normal row/detail does so every
|
|
597
|
+
* client reader (filters, groups) sees a live `▶` run, with every figure honestly null.
|
|
598
|
+
* @param {{ runid: string, job: string, at: string, via: string }} row
|
|
599
|
+
* @returns {any}
|
|
600
|
+
*/
|
|
601
|
+
function startingStub(row) {
|
|
602
|
+
return {
|
|
603
|
+
runid: row.runid,
|
|
604
|
+
job: row.job,
|
|
605
|
+
at: row.at,
|
|
606
|
+
via: row.via,
|
|
607
|
+
fileMissing: false,
|
|
608
|
+
starting: true,
|
|
609
|
+
died: false,
|
|
610
|
+
glyph: '▶',
|
|
611
|
+
status: statusFor({ outcome: null }),
|
|
612
|
+
outcome: null,
|
|
613
|
+
endedLine: null,
|
|
614
|
+
ended: null,
|
|
615
|
+
resume: null,
|
|
616
|
+
checkType: 'unknown',
|
|
617
|
+
checkTypeTitle: null,
|
|
618
|
+
model: null,
|
|
619
|
+
spend: 'unknown',
|
|
620
|
+
wall: 'unknown',
|
|
621
|
+
date: typeof row.at === 'string' ? row.at.slice(0, 10) : null,
|
|
622
|
+
spentUsd: null,
|
|
623
|
+
spendComplete: false,
|
|
624
|
+
spendFloorUsd: null,
|
|
625
|
+
wallFloorMs: null,
|
|
626
|
+
draftSpentUsd: null,
|
|
627
|
+
draftSpendComplete: null,
|
|
628
|
+
budgetUsd: null,
|
|
629
|
+
steps: [],
|
|
630
|
+
parts: [],
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
276
634
|
/**
|
|
277
635
|
* One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
|
|
278
636
|
* spine no longer exists on disk — never silently listed as if it were
|
|
@@ -283,6 +641,7 @@ function formatDurationMs(ms) {
|
|
|
283
641
|
*/
|
|
284
642
|
function summarizeRow(row) {
|
|
285
643
|
if (!existsSync(row.spine)) {
|
|
644
|
+
if (runIsAlive(row)) return startingStub(row);
|
|
286
645
|
return {
|
|
287
646
|
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
|
|
288
647
|
};
|
|
@@ -299,6 +658,7 @@ function summarizeRow(row) {
|
|
|
299
658
|
}
|
|
300
659
|
const line = summarizeForAllLine(summary);
|
|
301
660
|
const death = deriveDeath(row, rawRecords, summary.outcome);
|
|
661
|
+
const { ended, status } = endedForRow(row, rawRecords, summary, death);
|
|
302
662
|
return {
|
|
303
663
|
runid: row.runid,
|
|
304
664
|
job: row.job,
|
|
@@ -306,7 +666,11 @@ function summarizeRow(row) {
|
|
|
306
666
|
via: row.via,
|
|
307
667
|
fileMissing: false,
|
|
308
668
|
died: death.died,
|
|
309
|
-
|
|
669
|
+
endedLine: ended ? ended.line : null,
|
|
670
|
+
// P5-R: one card per run — how many times it was resumed (a resumed run is the same run, never a second card)
|
|
671
|
+
resumedCount: Math.max(0, summary.legs.length - 1),
|
|
672
|
+
glyph: status.sign,
|
|
673
|
+
status,
|
|
310
674
|
checkType: checkTypeLabel(summary.verdictType, row.at),
|
|
311
675
|
checkTypeTitle: checkTypeTitle(summary.verdictType, row.at),
|
|
312
676
|
model: summary.model,
|
|
@@ -326,13 +690,47 @@ function summarizeRow(row) {
|
|
|
326
690
|
// no drafting fold at all, exactly like `summary.draftSpentUsd` itself.
|
|
327
691
|
spentUsd: death.died ? null : summary.spentUsd,
|
|
328
692
|
spendComplete: death.died ? false : summary.spendComplete,
|
|
329
|
-
|
|
693
|
+
// P5 item 6: the SAME floors the right pane reads (`deriveDeath`), for a died OR a
|
|
694
|
+
// still-running spine — so a live card and its pane show the same numbers. Both
|
|
695
|
+
// null once a job-end exists (the real figures above are complete then).
|
|
696
|
+
spendFloorUsd: death.spendFloorUsd,
|
|
697
|
+
wallFloorMs: death.wallFloorMs,
|
|
330
698
|
draftSpentUsd: summary.draftSpentUsd,
|
|
331
699
|
draftSpendComplete: summary.draftSpendComplete,
|
|
332
700
|
budgetUsd: summary.budgetUsd,
|
|
333
701
|
};
|
|
334
702
|
}
|
|
335
703
|
|
|
704
|
+
/**
|
|
705
|
+
* The Resume route's reader (P5 item 2): the listed row plus the resume plan, from the
|
|
706
|
+
* ONE {@link resumePlanFor} the Ended block asks. `null` when the runid is not listed
|
|
707
|
+
* or its spine is gone.
|
|
708
|
+
* @param {string} runid
|
|
709
|
+
* @param {{ home?: string }} [opts]
|
|
710
|
+
* @returns {{row: any, plan: any}|null}
|
|
711
|
+
*/
|
|
712
|
+
export function getResumeContext(runid, opts = {}) {
|
|
713
|
+
const { rows } = readRunList(opts);
|
|
714
|
+
const row = rows.find((r) => r && r.runid === runid);
|
|
715
|
+
if (!row || !existsSync(row.spine)) return null;
|
|
716
|
+
return { row, plan: resumePlanFor(row, parseJsonl(row.spine).records) };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* The Stop route's reader (P5 item 5): the listed row, whether its LATEST leg is live (`runIsAlive`: the row's pid
|
|
721
|
+
* is the latest leg's, folded from the run list's `leg-start`), and whether its spine exists yet. `null` when the
|
|
722
|
+
* runid is not listed.
|
|
723
|
+
* @param {string} runid
|
|
724
|
+
* @param {{ home?: string }} [opts]
|
|
725
|
+
* @returns {{row: any, live: boolean, spineExists: boolean}|null}
|
|
726
|
+
*/
|
|
727
|
+
export function getStopContext(runid, opts = {}) {
|
|
728
|
+
const { rows } = readRunList(opts);
|
|
729
|
+
const row = rows.find((r) => r && r.runid === runid);
|
|
730
|
+
if (!row) return null;
|
|
731
|
+
return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
|
|
732
|
+
}
|
|
733
|
+
|
|
336
734
|
/**
|
|
337
735
|
* `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
|
|
338
736
|
* append order — a backfill scan appends rows in sorted-PATH order, which
|
|
@@ -361,6 +759,25 @@ export function listRuns(opts = {}) {
|
|
|
361
759
|
.map(({ row }) => summarizeRow(row));
|
|
362
760
|
}
|
|
363
761
|
|
|
762
|
+
/**
|
|
763
|
+
* The run-list row of a run, for the readers below. An IMPORTED run (`<importId>~<runid>`, see importrun.js) is not in
|
|
764
|
+
* the run list: its row is built from the imports file and a checked path inside that bundle's own `runs/` folder
|
|
765
|
+
* (read only, no pid — never live, never resumable from here). `undefined` when no such run.
|
|
766
|
+
* @param {string} runid
|
|
767
|
+
* @param {{ home?: string }} opts
|
|
768
|
+
* @returns {any}
|
|
769
|
+
*/
|
|
770
|
+
function findRunRow(runid, opts) {
|
|
771
|
+
const imp = parseImportedRunId(runid);
|
|
772
|
+
if (imp) {
|
|
773
|
+
const bundle = readImports(opts.home).find((x) => x.id === imp.importId);
|
|
774
|
+
const spine = bundle ? safeSpinePath(bundle.dir, imp.runid) : null;
|
|
775
|
+
if (!bundle || !spine) return undefined;
|
|
776
|
+
return { runid, job: bundle.job, at: spineStartedAt(spine), via: 'import', spine, patient: null };
|
|
777
|
+
}
|
|
778
|
+
return readRunList(opts).rows.find((r) => r && r.runid === runid);
|
|
779
|
+
}
|
|
780
|
+
|
|
364
781
|
/**
|
|
365
782
|
* `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
|
|
366
783
|
* iterations), counters, summary-box fields. Looks `runid` up in the run
|
|
@@ -372,28 +789,29 @@ export function listRuns(opts = {}) {
|
|
|
372
789
|
* @returns {any|null}
|
|
373
790
|
*/
|
|
374
791
|
export function getRunDetail(runid, opts = {}) {
|
|
375
|
-
const
|
|
376
|
-
const row = rows.find((r) => r && r.runid === runid);
|
|
792
|
+
const row = findRunRow(runid, opts);
|
|
377
793
|
if (!row) return null;
|
|
378
794
|
if (!existsSync(row.spine)) {
|
|
795
|
+
if (runIsAlive(row)) return startingStub(row);
|
|
379
796
|
return {
|
|
380
797
|
runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
|
|
381
798
|
};
|
|
382
799
|
}
|
|
383
800
|
// build item 7 (2026-09-28): resolve the audit path through the ONE
|
|
384
|
-
// shared resolver (`
|
|
801
|
+
// shared resolver (`resolveAuditPathsForRow`, already the sole owner for
|
|
385
802
|
// `scopedBehaviour`/`getRunAudit`) BEFORE calling `replayOne`, and hand it
|
|
386
803
|
// the result — otherwise `replayOne`'s own internal resolution only ever
|
|
387
804
|
// finds the FINISHED-run convention, so a still-LIVE run's part-level
|
|
388
805
|
// `byTool`/`toolCalls` (summary.parts, read from `replayRun`) read
|
|
389
806
|
// "unknown" even though the SAME run's Run-tab tools/cache summary and
|
|
390
|
-
// Audit tab (both driven through `
|
|
807
|
+
// Audit tab (both driven through `resolveAuditPathsForRow` already) can
|
|
391
808
|
// see the during-run sidecar just fine.
|
|
392
809
|
const rawSpineRecords = parseJsonl(row.spine).records;
|
|
393
|
-
const
|
|
394
|
-
const summary = replayOne(row.spine, { auditPathOverride:
|
|
810
|
+
const auditPaths = resolveAuditPathsForRow(row, rawSpineRecords);
|
|
811
|
+
const summary = replayOne(row.spine, { auditPathOverride: auditPaths.length > 0 ? auditPaths : null });
|
|
395
812
|
const timelineKind = summary.timelineKind;
|
|
396
813
|
const death = deriveDeath(row, rawSpineRecords, summary.outcome);
|
|
814
|
+
const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death);
|
|
397
815
|
// judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
|
|
398
816
|
// `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
|
|
399
817
|
// close's own paid judge seam, distinct from the worker `model` above.
|
|
@@ -461,7 +879,7 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
461
879
|
id: u.id,
|
|
462
880
|
occurrence: u.occurrence,
|
|
463
881
|
outcome: u.outcome ?? null,
|
|
464
|
-
state: stateFor(u.outcome, isLast),
|
|
882
|
+
state: u.stopReason ? 'stopped' : stateFor(u.outcome, isLast),
|
|
465
883
|
rounds: u.rounds,
|
|
466
884
|
toolCalls: u.toolCalls,
|
|
467
885
|
wallMs: u.wallMs,
|
|
@@ -473,6 +891,29 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
473
891
|
attempts: u.attempts,
|
|
474
892
|
};
|
|
475
893
|
});
|
|
894
|
+
// P5-R: a step cut off by its leg's halt and CONTINUED by the next leg is ONE step — the summary counts steps, not
|
|
895
|
+
// parts. The continued part replaces the stopped one in this list (so it is done iff its continuation is), and the
|
|
896
|
+
// two parts' rounds/time/spend/attempts are added up (an unknown figure stays unknown).
|
|
897
|
+
/** @type {any[]} */
|
|
898
|
+
const merged = [];
|
|
899
|
+
summary.steps.forEach((u, idx) => {
|
|
900
|
+
const cur = steps[idx];
|
|
901
|
+
const prev = merged[merged.length - 1];
|
|
902
|
+
if (u.continued === true && prev && prev.id === cur.id) {
|
|
903
|
+
const add = (/** @type {number|null} */ a, /** @type {number|null} */ b) => (typeof a === 'number' && typeof b === 'number' ? a + b : null);
|
|
904
|
+
merged[merged.length - 1] = {
|
|
905
|
+
...cur,
|
|
906
|
+
occurrence: prev.occurrence,
|
|
907
|
+
rounds: add(prev.rounds, cur.rounds),
|
|
908
|
+
toolCalls: add(prev.toolCalls, cur.toolCalls),
|
|
909
|
+
wallMs: add(prev.wallMs, cur.wallMs),
|
|
910
|
+
spentUsd: add(prev.spentUsd, cur.spentUsd),
|
|
911
|
+
unpricedRounds: (prev.unpricedRounds ?? 0) + (cur.unpricedRounds ?? 0),
|
|
912
|
+
attempts: [...(prev.attempts ?? []), ...(cur.attempts ?? [])].map((a, n) => ({ ...a, n: n + 1 })),
|
|
913
|
+
};
|
|
914
|
+
} else merged.push(cur);
|
|
915
|
+
});
|
|
916
|
+
steps = merged;
|
|
476
917
|
}
|
|
477
918
|
// died before any step/iteration ever started (steps empty — the run was
|
|
478
919
|
// still in scout/planning) — one placeholder box, never an empty map. Its
|
|
@@ -519,10 +960,32 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
519
960
|
provider: summary.provider,
|
|
520
961
|
judgeModel,
|
|
521
962
|
budgetUsd: summary.budgetUsd,
|
|
522
|
-
glyph:
|
|
963
|
+
glyph: status.sign,
|
|
964
|
+
status,
|
|
965
|
+
// the header's times: when the run started (its list row) and when its last job-end was written (null while live / died)
|
|
966
|
+
startedAt: typeof row.at === 'string' ? row.at : null,
|
|
967
|
+
endedAt: lastJobEndTs(rawSpineRecords),
|
|
523
968
|
outcome: summary.outcome,
|
|
524
969
|
died: death.died,
|
|
525
970
|
stopReason: death.died ? death.why : summary.stopReason,
|
|
971
|
+
// P5 item 1: the Ended block — `{reason, next, line, actions}` (code-owned fixed
|
|
972
|
+
// sentences, {@link endedFor}), `null` while the run is live. `resume` carries the
|
|
973
|
+
// numbers the Resume confirm box prefills (the signed caps, spent so far); `null`
|
|
974
|
+
// whenever Resume is not offered.
|
|
975
|
+
ended,
|
|
976
|
+
// P5 item 5: is the run's LATEST leg live (the page offers Stop only then), and has a stop been asked for
|
|
977
|
+
// (the request file exists beside the spine) — the page reads "stopping after this turn…" until the leg ends
|
|
978
|
+
live: row.via !== 'import' && runIsAlive(row),
|
|
979
|
+
stopping: row.via !== 'import' && runIsAlive(row) && existsSync(stopFilePath(row.spine)),
|
|
980
|
+
// P5-R: the run's legs in order (`after` = how the leg before ended) and the resume count — the Audit tab's
|
|
981
|
+
// dividers and the map's connectors read these; never a second derivation of where a leg starts
|
|
982
|
+
legs: summary.legs,
|
|
983
|
+
legDividers: legDividersFor(rawSpineRecords, summary.parts),
|
|
984
|
+
resumedCount: Math.max(0, summary.legs.length - 1),
|
|
985
|
+
resume: resume && resume.ok ? {
|
|
986
|
+
budgetUsd: resume.budgetUsd, maxWallMin: resume.maxWallMin, spentUsd: resume.spentUsd, spendComplete: resume.spendComplete,
|
|
987
|
+
wallUsedMs: resume.wallUsedMs,
|
|
988
|
+
} : null,
|
|
526
989
|
spentUsd: summary.spentUsd,
|
|
527
990
|
// draftSpentUsd (hamr's ruling 2026-09-28) — the drafting share of this
|
|
528
991
|
// run's cap, or null when this run carried none. `src/replay.js`'s
|
|
@@ -577,7 +1040,7 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
577
1040
|
// gate-audit rows the Audit tab now shows (this run's own job-start..
|
|
578
1041
|
// job-end ts window, item 2's fix for a real measured defect: a
|
|
579
1042
|
// gate-audit sidecar CAN carry other runs' rows when several spines
|
|
580
|
-
// share one filename — see {@link
|
|
1043
|
+
// share one filename — see {@link runAuditWindows}'s doc) — never
|
|
581
1044
|
// `replayRun`'s own top-level `summary.behaviour`, which reads the
|
|
582
1045
|
// WHOLE sidecar file unscoped and is contaminated on a real archived run
|
|
583
1046
|
// (measured: pulselog-person-live-2's mu2p83go reads 142 unscoped tool
|
|
@@ -609,12 +1072,16 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
609
1072
|
* about which rows belong to a run) — kept here only to find `job-start`/
|
|
610
1073
|
* `job-end` off the raw `spineRecords` array the panel already has in hand.
|
|
611
1074
|
* @param {any[]} spineRecords
|
|
612
|
-
* @returns {{startTs: number, endTs: number}}
|
|
1075
|
+
* @returns {{startTs: number, endTs: number}[]} one window per leg of the run (P5-R); one for a run nobody resumed
|
|
613
1076
|
*/
|
|
614
|
-
function
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1077
|
+
function runAuditWindows(spineRecords) {
|
|
1078
|
+
return auditWindowsOf(spineRecords);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** @param {string} ts @param {{startTs: number, endTs: number}[]} windows */
|
|
1082
|
+
function inAnyWindow(ts, windows) {
|
|
1083
|
+
const ms = Date.parse(ts);
|
|
1084
|
+
return Number.isFinite(ms) && windows.some((w) => ms >= w.startTs && ms <= w.endTs);
|
|
618
1085
|
}
|
|
619
1086
|
|
|
620
1087
|
/**
|
|
@@ -689,7 +1156,7 @@ function makeRoundLookup(spineRecords) {
|
|
|
689
1156
|
* `backfill` row (never a live run) and on some pre-cutoff `run-u` rows
|
|
690
1157
|
* — both correctly fall through to `null` here.
|
|
691
1158
|
* Callers still scope the rows they read from this file by this run's own
|
|
692
|
-
* job-start..job-end ts window ({@link
|
|
1159
|
+
* job-start..job-end ts window ({@link runAuditWindows}) — a shared tree/
|
|
693
1160
|
* worktree gate-audit file can carry another run's rows too (this file's own
|
|
694
1161
|
* header comment on `runAuditWindow`), live or finished.
|
|
695
1162
|
*
|
|
@@ -703,42 +1170,44 @@ function makeRoundLookup(spineRecords) {
|
|
|
703
1170
|
* rename target, not a panel-server read-path bug).
|
|
704
1171
|
* @param {{spine: string, patient: string|null}} row
|
|
705
1172
|
* @param {any[]} spineRecords
|
|
706
|
-
* @returns {string
|
|
1173
|
+
* @returns {string[]} every file the run's tool log is in, earliest leg first (empty: none was ever written)
|
|
707
1174
|
*/
|
|
708
|
-
function
|
|
1175
|
+
function resolveAuditPathsForRow(row, spineRecords) {
|
|
1176
|
+
/** @type {string[]} */
|
|
1177
|
+
const paths = [];
|
|
709
1178
|
const { auditPath } = resolveSiblings(row.spine);
|
|
710
|
-
if (auditPath
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1179
|
+
if (auditPath && existsSync(auditPath)) paths.push(auditPath);
|
|
1180
|
+
// P5-R: a resumed run's LIVE leg writes its tool log in the patient tree while the earlier legs' rows already sit in
|
|
1181
|
+
// the run's own file — both are the run's, in that order. "Live" = the LATEST leg has no job-end yet.
|
|
1182
|
+
const last = legsOf(spineRecords).at(-1);
|
|
1183
|
+
const hasJobEnd = last ? last.jobEnd !== null : spineRecords.some((r) => r && r.type === 'job-end');
|
|
1184
|
+
if (hasJobEnd) return paths;
|
|
1185
|
+
if (paths.length > 0 && legsOf(spineRecords).length <= 1) return paths; // a never-resumed run's sibling is the whole log
|
|
1186
|
+
if (typeof row.patient !== 'string' || row.patient.length === 0) return paths;
|
|
714
1187
|
const live = join(row.patient, 'gate-audit.jsonl');
|
|
715
|
-
|
|
1188
|
+
if (existsSync(live) && !paths.includes(live)) paths.push(live);
|
|
1189
|
+
return paths;
|
|
716
1190
|
}
|
|
717
1191
|
|
|
718
1192
|
/**
|
|
719
1193
|
* `runBehaviour`, fed only the gate-audit rows inside this run's own ts
|
|
720
|
-
* window (see {@link
|
|
1194
|
+
* window (see {@link runAuditWindows}) — the panel-side fix for the same
|
|
721
1195
|
* contamination `getRunAudit` fixes for the Audit tab, kept as ONE shared
|
|
722
1196
|
* scoping function so the Audit tab and the Run tab's tools/cache summary
|
|
723
1197
|
* can never disagree with each other about which rows belong to this run.
|
|
724
1198
|
* `null` when no gate-audit sidecar exists at all (never a fake all-zero
|
|
725
1199
|
* object — same rule `replayRun`'s own `auditAvailable` already follows).
|
|
726
1200
|
* `auditPath` resolution (finished sibling, or the live during-run fallback)
|
|
727
|
-
* is {@link
|
|
1201
|
+
* is {@link resolveAuditPathsForRow} — the one shared owner.
|
|
728
1202
|
* @param {{spine: string, patient: string|null}} row
|
|
729
1203
|
* @param {any[]} spineRecords
|
|
730
1204
|
* @returns {ReturnType<typeof runBehaviour>|null}
|
|
731
1205
|
*/
|
|
732
1206
|
function scopedBehaviour(row, spineRecords) {
|
|
733
|
-
const
|
|
734
|
-
if (
|
|
735
|
-
const
|
|
736
|
-
const
|
|
737
|
-
const windowed = records.filter((r) => {
|
|
738
|
-
if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
|
|
739
|
-
const ms = Date.parse(r.ts);
|
|
740
|
-
return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
|
|
741
|
-
});
|
|
1207
|
+
const auditPaths = resolveAuditPathsForRow(row, spineRecords);
|
|
1208
|
+
if (auditPaths.length === 0) return null;
|
|
1209
|
+
const windows = runAuditWindows(spineRecords);
|
|
1210
|
+
const windowed = auditPaths.flatMap((ap) => parseJsonl(ap).records).filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
|
|
742
1211
|
return runBehaviour(windowed);
|
|
743
1212
|
}
|
|
744
1213
|
|
|
@@ -879,8 +1348,7 @@ function shortenPath(path, root) {
|
|
|
879
1348
|
* @returns {{runid: string, rows: any[], raw: string, empty: boolean, reason: 'no-sidecar'|'sidecar-empty'|null}|null}
|
|
880
1349
|
*/
|
|
881
1350
|
export function getRunAudit(runid, opts = {}) {
|
|
882
|
-
const
|
|
883
|
-
const row = rows.find((r) => r && r.runid === runid);
|
|
1351
|
+
const row = findRunRow(runid, opts);
|
|
884
1352
|
if (!row) return null;
|
|
885
1353
|
if (!existsSync(row.spine)) {
|
|
886
1354
|
return {
|
|
@@ -888,18 +1356,18 @@ export function getRunAudit(runid, opts = {}) {
|
|
|
888
1356
|
};
|
|
889
1357
|
}
|
|
890
1358
|
const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
|
|
891
|
-
// {@link
|
|
1359
|
+
// {@link resolveAuditPathsForRow} needs `spineRecords` (to know whether this
|
|
892
1360
|
// run has reached `job-end` yet) before it can decide whether the LIVE
|
|
893
1361
|
// during-run fallback is even worth trying — so the spine is parsed once,
|
|
894
1362
|
// here, before the sidecar path is resolved (moved up from right after the
|
|
895
1363
|
// old `resolveSiblings`-only check this replaces).
|
|
896
|
-
const
|
|
897
|
-
if (
|
|
1364
|
+
const auditPaths = resolveAuditPathsForRow(row, spineRecords);
|
|
1365
|
+
if (auditPaths.length === 0) {
|
|
898
1366
|
return {
|
|
899
1367
|
runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
|
|
900
1368
|
};
|
|
901
1369
|
}
|
|
902
|
-
const
|
|
1370
|
+
const windows = runAuditWindows(spineRecords);
|
|
903
1371
|
const roundOf = makeRoundLookup(spineRecords);
|
|
904
1372
|
const roundRecords = sortedRoundRecords(spineRecords);
|
|
905
1373
|
// `preParsedSpine` avoids a second parse of the same spine file just read
|
|
@@ -912,30 +1380,25 @@ export function getRunAudit(runid, opts = {}) {
|
|
|
912
1380
|
});
|
|
913
1381
|
const partOf = makePartLookup(summary);
|
|
914
1382
|
|
|
915
|
-
const { records } = parseJsonl(
|
|
916
|
-
const windowed = records.filter((r) =>
|
|
917
|
-
if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
|
|
918
|
-
const ms = Date.parse(r.ts);
|
|
919
|
-
return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
|
|
920
|
-
});
|
|
1383
|
+
const { records } = { records: auditPaths.flatMap((ap) => parseJsonl(ap).records) };
|
|
1384
|
+
const windowed = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
|
|
921
1385
|
// item 1 (build item, 2026-09-26): "Raw log" must show only THIS run's own
|
|
922
1386
|
// window, same rule as `windowed` above (a sidecar can carry other runs'
|
|
923
|
-
// rows — see {@link
|
|
1387
|
+
// rows — see {@link runAuditWindows}'s doc). Filtered at the LINE level
|
|
924
1388
|
// (never the parsed-record level) so a matched line stays byte-for-byte —
|
|
925
1389
|
// "raw" means raw, not a re-serialized JSON.stringify of the parsed
|
|
926
1390
|
// object. A line whose `ts` can't be parsed (malformed JSON, or a
|
|
927
1391
|
// well-formed row missing/mistyping `ts`) is dropped rather than guessed
|
|
928
1392
|
// into the window, matching the same honesty rule `windowed` already
|
|
929
1393
|
// follows for its own rows.
|
|
930
|
-
const rawText = readFileSync(
|
|
1394
|
+
const rawText = auditPaths.map((ap) => readFileSync(ap, 'utf8'))
|
|
1395
|
+
.join('\n')
|
|
931
1396
|
.split('\n')
|
|
932
1397
|
.filter((line) => line.trim() !== '')
|
|
933
1398
|
.filter((line) => {
|
|
934
1399
|
let r;
|
|
935
1400
|
try { r = JSON.parse(line); } catch { return false; }
|
|
936
|
-
|
|
937
|
-
const ms = Date.parse(r.ts);
|
|
938
|
-
return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
|
|
1401
|
+
return !!r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows);
|
|
939
1402
|
})
|
|
940
1403
|
.join('\n');
|
|
941
1404
|
// item 2 (2026-09-26 build spec): every row's path shortened relative to
|
|
@@ -1014,8 +1477,7 @@ export const ROUNDS_PAGE_MAX = 200;
|
|
|
1014
1477
|
* @returns {any|null}
|
|
1015
1478
|
*/
|
|
1016
1479
|
export function getRunRounds(runid, query = {}, opts = {}) {
|
|
1017
|
-
const
|
|
1018
|
-
const row = rows.find((r) => r && r.runid === runid);
|
|
1480
|
+
const row = findRunRow(runid, opts);
|
|
1019
1481
|
if (!row) return null;
|
|
1020
1482
|
if (!existsSync(row.spine)) return null;
|
|
1021
1483
|
|
|
@@ -1543,8 +2005,7 @@ function jobsDir() {
|
|
|
1543
2005
|
* @returns {any|null}
|
|
1544
2006
|
*/
|
|
1545
2007
|
export function getRunJob(runid, opts = {}) {
|
|
1546
|
-
const
|
|
1547
|
-
const row = rows.find((r) => r && r.runid === runid);
|
|
2008
|
+
const row = findRunRow(runid, opts);
|
|
1548
2009
|
if (!row) return null;
|
|
1549
2010
|
|
|
1550
2011
|
const near = existsSync(row.spine) ? sourceNearSpine(row.spine) : { specPath: null, sourceJsonPath: null };
|
|
@@ -1693,6 +2154,290 @@ export function getRunJob(runid, opts = {}) {
|
|
|
1693
2154
|
return none();
|
|
1694
2155
|
}
|
|
1695
2156
|
|
|
2157
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
2158
|
+
// REUSE WORKFLOW (hamr 2026-10-03; replaces P5 item 3's "Start from this"). A GREEN run — or an imported job — can be
|
|
2159
|
+
// run again as the SAME signed workflow on a new source. This section is the ONE owner of (a) the prefill, (b) which
|
|
2160
|
+
// boxes are locked, (c) the track record. The page asks; it never decides. A reuse is a NEW run (a new runid).
|
|
2161
|
+
// Nothing here drafts: changing the goal, checks, guardrails, judge examples or model is Clear (then draft).
|
|
2162
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* The signed spec a run's LATEST leg ran under — the `resolved-spec*.json` beside the run whose hash is the one its
|
|
2166
|
+
* latest `job-start` carries (the same lookup `resumePlanFor` does). `null` = no such file.
|
|
2167
|
+
* @param {{ spine: string }} row
|
|
2168
|
+
* @param {any[]} records
|
|
2169
|
+
* @returns {{spec: any, specPath: string, specHash: string}|null}
|
|
2170
|
+
*/
|
|
2171
|
+
function signedSpecForRun(row, records) {
|
|
2172
|
+
const jobStart = (legsOf(records).findLast((l) => l.jobStart !== null) ?? null)?.jobStart ?? null;
|
|
2173
|
+
if (!jobStart || typeof jobStart.specHash !== 'string') return null;
|
|
2174
|
+
const near = sourceNearSpine(row.spine);
|
|
2175
|
+
if (!near.specPath) return null;
|
|
2176
|
+
const dir = dirname(near.specPath);
|
|
2177
|
+
/** @type {string[]} */
|
|
2178
|
+
let names = [];
|
|
2179
|
+
try { names = readdirSync(dir).filter((n) => n === 'resolved-spec.json' || /^resolved-spec-r\d+\.json$/.test(n)); } catch { names = []; }
|
|
2180
|
+
for (const n of names) {
|
|
2181
|
+
let spec;
|
|
2182
|
+
try { spec = JSON.parse(readFileSync(join(dir, n), 'utf8')); } catch { continue; }
|
|
2183
|
+
if (!spec || typeof spec !== 'object') continue;
|
|
2184
|
+
if (jobSpecHash(spec) === jobStart.specHash) return { spec, specPath: join(dir, n), specHash: jobStart.specHash };
|
|
2185
|
+
}
|
|
2186
|
+
return null;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/**
|
|
2190
|
+
* The worker a signed spec ran: provider, baseUrl and model, the model falling back to the provider's default tier
|
|
2191
|
+
* when the spec names none (the same fallback `resolveWorkerModel` makes at run time).
|
|
2192
|
+
* @param {any} spec
|
|
2193
|
+
* @returns {{provider: string, baseUrl?: string, model: string}}
|
|
2194
|
+
*/
|
|
2195
|
+
function workerOfSpec(spec) {
|
|
2196
|
+
const provider = typeof spec?.provider === 'string' ? spec.provider : '';
|
|
2197
|
+
let model = typeof spec?.model === 'string' ? spec.model : '';
|
|
2198
|
+
if (!model) { try { model = resolveProvider(provider).tiers.sonnet; } catch { model = ''; } }
|
|
2199
|
+
return { provider, ...(typeof spec?.baseUrl === 'string' && spec.baseUrl ? { baseUrl: spec.baseUrl } : {}), model };
|
|
2200
|
+
}
|
|
2201
|
+
/** @param {{provider: string, baseUrl?: string, model: string}} w @returns {string} one spelling of "the same worker" */
|
|
2202
|
+
const workerId = (w) => `${w.provider}\u0000${w.baseUrl ?? ''}\u0000${w.model}`;
|
|
2203
|
+
/**
|
|
2204
|
+
* The worker the reuse card's CURRENT Model box names: the Settings row's provider, baseUrl and Name. `null` when the
|
|
2205
|
+
* box is blank or names no row.
|
|
2206
|
+
* @param {string} model a Settings Name
|
|
2207
|
+
* @param {{ home?: string }} opts
|
|
2208
|
+
* @returns {{provider: string, baseUrl?: string, model: string}|null}
|
|
2209
|
+
*/
|
|
2210
|
+
function workerOfChoice(model, opts) {
|
|
2211
|
+
const c = modelChoiceFor(rowsForHome(keysHome(opts.home)), model);
|
|
2212
|
+
return c ? { provider: c.provider, ...(c.baseUrl ? { baseUrl: c.baseUrl } : {}), model: c.name } : null;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/**
|
|
2216
|
+
* The track record of a WORKFLOW: every LISTED run whose signed spec (the one its latest leg ran under) has this
|
|
2217
|
+
* `workflowKey` — the same goal, checks, guardrails, model and close, whatever source, destination or caps. One run =
|
|
2218
|
+
* ONE entry (a resumed run is one run, its FINAL outcome — `legsOf`), and a run that is still live is neither green
|
|
2219
|
+
* nor not-green yet. `avgSpendUsd` / `avgWallMs` average the FINISHED runs (a recorded terminal) whose figure is exact
|
|
2220
|
+
* (chain spend priced; working time from readable stamps, resume gaps excluded) — `null` when no run qualifies, never 0.
|
|
2221
|
+
* Read from the runs the panel already lists: no registry, no plan handover (hamr ruling A).
|
|
2222
|
+
* `opts.worker` narrows the record to runs on the same WORKER (provider + baseUrl + model — Model is open on a reuse, so
|
|
2223
|
+
* the key no longer separates models): `undefined` = every model, a worker = only runs whose signed spec ran that one,
|
|
2224
|
+
* `null` = no model chosen, so no run matches.
|
|
2225
|
+
* @param {string} key a {@link workflowKey}
|
|
2226
|
+
* @param {{ home?: string, worker?: {provider: string, baseUrl?: string, model: string}|null }} [opts]
|
|
2227
|
+
* @returns {{runs: number, green: number, notGreen: number, live: number, finished: number, avgSpendUsd: number|null, avgWallMs: number|null}}
|
|
2228
|
+
*/
|
|
2229
|
+
export function trackRecordFor(key, opts = {}) {
|
|
2230
|
+
const { rows } = readRunList(opts);
|
|
2231
|
+
let green = 0;
|
|
2232
|
+
let notGreen = 0;
|
|
2233
|
+
let live = 0;
|
|
2234
|
+
let finished = 0;
|
|
2235
|
+
let spendSum = 0;
|
|
2236
|
+
let spendN = 0;
|
|
2237
|
+
let wallSum = 0;
|
|
2238
|
+
let wallN = 0;
|
|
2239
|
+
for (const row of rows) {
|
|
2240
|
+
if (!row || !existsSync(row.spine)) continue;
|
|
2241
|
+
let records;
|
|
2242
|
+
try { records = parseJsonl(row.spine).records; } catch { continue; }
|
|
2243
|
+
const legs = legsOf(records);
|
|
2244
|
+
const signed = signedSpecForRun(row, records);
|
|
2245
|
+
if (!signed || workflowKey(signed.spec) !== key) continue;
|
|
2246
|
+
if (opts.worker !== undefined && (opts.worker === null || workerId(workerOfSpec(signed.spec)) !== workerId(opts.worker))) continue;
|
|
2247
|
+
const outcome = legs.at(-1)?.outcome ?? null;
|
|
2248
|
+
if (outcome === null && runIsAlive(row)) { live += 1; continue; }
|
|
2249
|
+
if (outcome === 'green') green += 1; else notGreen += 1;
|
|
2250
|
+
// the averages read FINISHED runs only (a recorded terminal), and only figures that are exact: an unpriced run
|
|
2251
|
+
// or one with unreadable stamps is left out, never counted as $0 or 0 min (unknown is never zero)
|
|
2252
|
+
if (outcome === null) continue;
|
|
2253
|
+
finished += 1;
|
|
2254
|
+
const sp = chainSpend(records);
|
|
2255
|
+
if (sp.complete) { spendSum += sp.usd; spendN += 1; }
|
|
2256
|
+
const wall = legsWallMs(legs);
|
|
2257
|
+
if (wall.ms !== null && wall.complete) { wallSum += wall.ms; wallN += 1; }
|
|
2258
|
+
}
|
|
2259
|
+
return {
|
|
2260
|
+
runs: green + notGreen + live, green, notGreen, live, finished,
|
|
2261
|
+
avgSpendUsd: spendN > 0 ? spendSum / spendN : null,
|
|
2262
|
+
avgWallMs: wallN > 0 ? wallSum / wallN : null,
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* The estimate line above a reuse card (code-owned fixed text): `Same job — G green · N not green · about $X and
|
|
2268
|
+
* M min a run`, from the workflow's own listed runs. What is unknown is said, never rendered as $0 or 0 min.
|
|
2269
|
+
* @param {{runs?: number, green: number, notGreen: number, finished?: number, avgSpendUsd: number|null, avgWallMs?: number|null}|null} record
|
|
2270
|
+
* @returns {string}
|
|
2271
|
+
*/
|
|
2272
|
+
export function reuseLine(record) {
|
|
2273
|
+
if (!record) return 'Same job';
|
|
2274
|
+
if ('runs' in record && record.runs === 0) return 'Same job — no runs yet on this model';
|
|
2275
|
+
const head = `Same job — ${record.green} green · ${record.notGreen} not green`;
|
|
2276
|
+
const wallMs = record.avgWallMs ?? null;
|
|
2277
|
+
const cost = record.avgSpendUsd === null ? null : panelMoney2(record.avgSpendUsd);
|
|
2278
|
+
const mins = wallMs === null ? null : (wallMs < 60_000 ? '<1' : String(Math.round(wallMs / 60_000)));
|
|
2279
|
+
if (cost !== null && mins !== null) return `${head} · about ${cost} and ${mins} min a run`;
|
|
2280
|
+
if (cost !== null) return `${head} · about ${cost} a run, time not recorded`;
|
|
2281
|
+
if (mins !== null) return `${head} · about ${mins} min a run, cost not recorded`;
|
|
2282
|
+
return (record.finished ?? record.green + record.notGreen) === 0
|
|
2283
|
+
? `${head} · no finished run yet to price or time`
|
|
2284
|
+
: `${head} · cost and time not recorded`;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
/**
|
|
2288
|
+
* `start-from` (the route keeps its name) for one listed run: the card prefilled from the run's SIGNED job, which
|
|
2289
|
+
* boxes are locked and which are open, and the track record. Locked boxes carry the signed values, read by the
|
|
2290
|
+
* SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
|
|
2291
|
+
* $ cap and the Time cap — start at what the run had. `ok:false` when the run's signed job is not on disk (nothing
|
|
2292
|
+
* to copy: change the job: Clear the card). `null` when the runid is not listed.
|
|
2293
|
+
* @param {string} runid
|
|
2294
|
+
* @param {{ home?: string, model?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
|
|
2295
|
+
* @returns {{ok: true, origin: {runid: string, job: string}, card: Record<string, any>, from: 'signed job',
|
|
2296
|
+
* locked: readonly string[], open: readonly string[], note: string|null,
|
|
2297
|
+
* specHash: string, workflowKey: string, spec: any, specPath: string,
|
|
2298
|
+
* trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
2299
|
+
*/
|
|
2300
|
+
export function getStartFrom(runid, opts = {}) {
|
|
2301
|
+
const { rows } = readRunList(opts);
|
|
2302
|
+
const row = rows.find((r) => r && r.runid === runid);
|
|
2303
|
+
if (!row) return null;
|
|
2304
|
+
if (!existsSync(row.spine)) return { ok: false, error: 'this run has no log on disk — nothing to reuse' };
|
|
2305
|
+
const records = parseJsonl(row.spine).records;
|
|
2306
|
+
const signed = signedSpecForRun(row, records);
|
|
2307
|
+
if (!signed) return { ok: false, error: "this run's signed job is not on disk — nothing to reuse (change the job: Clear the card and draft a new one)" };
|
|
2308
|
+
const near = sourceNearSpine(row.spine);
|
|
2309
|
+
const spec = signed.spec;
|
|
2310
|
+
/** @type {Record<string, any>} */
|
|
2311
|
+
let saved = {};
|
|
2312
|
+
if (near.specPath) {
|
|
2313
|
+
try {
|
|
2314
|
+
const raw = JSON.parse(readFileSync(join(dirname(near.specPath), 'card.json'), 'utf8'));
|
|
2315
|
+
if (raw && typeof raw === 'object') saved = raw;
|
|
2316
|
+
} catch { saved = {}; }
|
|
2317
|
+
}
|
|
2318
|
+
let manifest = null;
|
|
2319
|
+
if (near.sourceJsonPath) { try { manifest = JSON.parse(readFileSync(near.sourceJsonPath, 'utf8')); } catch { manifest = null; } }
|
|
2320
|
+
const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
2321
|
+
const card = reuseCardFromSpec(spec, {
|
|
2322
|
+
model: typeof saved.model === 'string' && saved.model ? saved.model
|
|
2323
|
+
: (findRow(rowsForHome(keysHome(opts.home)), { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name
|
|
2324
|
+
?? (typeof jobStart?.model === 'string' ? jobStart.model : '')),
|
|
2325
|
+
source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
|
|
2326
|
+
judgeExamples: typeof saved.judgeExamples === 'string' ? saved.judgeExamples : '',
|
|
2327
|
+
jobName: row.job,
|
|
2328
|
+
});
|
|
2329
|
+
const key = workflowKey(spec);
|
|
2330
|
+
const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
|
|
2331
|
+
return {
|
|
2332
|
+
ok: true,
|
|
2333
|
+
origin: { runid, job: row.job },
|
|
2334
|
+
card,
|
|
2335
|
+
from: 'signed job',
|
|
2336
|
+
locked: REUSE_LOCKED_FIELDS,
|
|
2337
|
+
open: REUSE_OPEN_FIELDS,
|
|
2338
|
+
note: card.checkType === 'rubric' && card.judgeExamples === '' ? 'judge examples were not saved for this run' : null,
|
|
2339
|
+
specHash: signed.specHash,
|
|
2340
|
+
workflowKey: key,
|
|
2341
|
+
spec,
|
|
2342
|
+
specPath: signed.specPath,
|
|
2343
|
+
trackRecord,
|
|
2344
|
+
line: reuseLine(trackRecord),
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
/**
|
|
2349
|
+
* The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
|
|
2350
|
+
* the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
|
|
2351
|
+
* @param {any} spec
|
|
2352
|
+
* @param {{model: string, source: string, judgeExamples: string, jobName: string}} extra
|
|
2353
|
+
* @returns {Record<string, any>}
|
|
2354
|
+
*/
|
|
2355
|
+
function reuseCardFromSpec(spec, extra) {
|
|
2356
|
+
const writeScope = Array.isArray(spec.writeScope) ? spec.writeScope.filter((/** @type {any} */ x) => typeof x === 'string') : [];
|
|
2357
|
+
return {
|
|
2358
|
+
jobName: typeof spec.job === 'string' && spec.job ? spec.job : extra.jobName,
|
|
2359
|
+
checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
|
|
2360
|
+
model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
|
|
2361
|
+
goal: typeof spec.goal === 'string' ? spec.goal : '',
|
|
2362
|
+
source: extra.source,
|
|
2363
|
+
destination: writeScope.join(', '),
|
|
2364
|
+
success: successFromSpec(spec) ?? '',
|
|
2365
|
+
guardrails: guardrailsFromSpec(spec) ?? '',
|
|
2366
|
+
judgeExamples: extra.judgeExamples,
|
|
2367
|
+
capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
|
|
2368
|
+
maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/**
|
|
2373
|
+
* P5 item 4 — the facts of a spec for an IMPORTED job's view, read by the SAME readers the Job tab uses for a run
|
|
2374
|
+
* (so an imported job and a run read alike): check type, goal, success checks, guardrails, model, caps.
|
|
2375
|
+
* @param {any} spec
|
|
2376
|
+
* @returns {{checkType: string, goal: string, success: string, guardrails: string, model: string, budgetUsd: number|null, maxWallMs: number|null}}
|
|
2377
|
+
*/
|
|
2378
|
+
function describeSpec(spec) {
|
|
2379
|
+
const nr = (/** @type {string|null} */ v) => (typeof v === 'string' && v.length > 0 ? v : 'not recorded');
|
|
2380
|
+
return {
|
|
2381
|
+
checkType: checkTypeLabel(typeof spec.verdictType === 'string' ? spec.verdictType : null, null),
|
|
2382
|
+
goal: nr(typeof spec.goal === 'string' ? spec.goal : null),
|
|
2383
|
+
success: nr(successFromSpec(spec)),
|
|
2384
|
+
guardrails: nr(guardrailsFromSpec(spec)),
|
|
2385
|
+
model: nr(typeof spec.model === 'string' ? spec.model : null),
|
|
2386
|
+
budgetUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
|
|
2387
|
+
maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : null,
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* Reuse workflow, for an IMPORTED job: the bundle is read AGAIN now and must be clean (`readBundle` — its own hash
|
|
2393
|
+
* covers every close script, so a swapped script is `bundle-tampered` here), must resolve its own `bareloop`
|
|
2394
|
+
* dependency (`checkBundleDeps`), and every close stage's signed sha256 must match the script's bytes on disk
|
|
2395
|
+
* (`checkCloseByteSignature`, over the spec with `$BARELOOP_BUNDLE` resolved to this folder — the same in-memory
|
|
2396
|
+
* substitution `bareloop run <bundle>` makes). The reuse spec is that resolved spec; the close scripts stay where
|
|
2397
|
+
* they were just verified (the engine re-verifies their bytes at run start and before every close run). The person
|
|
2398
|
+
* signs it on this machine at [Sign & run]. Same card, same locked/open boxes as a run's reuse. `null` when no such
|
|
2399
|
+
* imported job.
|
|
2400
|
+
* @param {string} id
|
|
2401
|
+
* @param {{ home?: string, model?: string }} [opts] `model` as for {@link getStartFrom}
|
|
2402
|
+
* @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
|
|
2403
|
+
* locked: readonly string[], open: readonly string[], note: string|null, specHash: string, workflowKey: string, spec: any,
|
|
2404
|
+
* specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
2405
|
+
*/
|
|
2406
|
+
export function getStartFromImport(id, opts = {}) {
|
|
2407
|
+
const row = readImports(opts.home).find((r) => r.id === id);
|
|
2408
|
+
if (!row) return null;
|
|
2409
|
+
const st = bundleStatus(row);
|
|
2410
|
+
if (st.status === 'changed') return { ok: false, error: IMPORT_CHANGED_LINE };
|
|
2411
|
+
if (st.status !== 'ok') return { ok: false, error: `${st.statusText ?? 'this imported job cannot be read'} — re-import it before reusing it` };
|
|
2412
|
+
const deps = checkBundleDeps(row.dir);
|
|
2413
|
+
if (!deps.ok) return { ok: false, error: deps.reds[0]?.detail ?? 'the imported job cannot resolve its own dependency' };
|
|
2414
|
+
const { spec } = resolveBundleSpec(st.bundle, row.dir);
|
|
2415
|
+
const bytes = checkCloseByteSignature(spec, row.dir);
|
|
2416
|
+
if (!bytes.ok) return { ok: false, error: `a close script of this imported job does not match its signed bytes (${bytes.reds.map((r) => r.stage).join(', ')}) — refusing to reuse it` };
|
|
2417
|
+
// the bundle's provider is no longer forced: Model is open on a reuse, so the person's chosen Settings row is the worker.
|
|
2418
|
+
// The card opens on the row matching the bundle's own provider when there is one, else blank (the page's menu picks).
|
|
2419
|
+
const rows = rowsForHome(keysHome(opts.home));
|
|
2420
|
+
const named = findRow(rows, { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name ?? '';
|
|
2421
|
+
const card = reuseCardFromSpec(spec, { model: named, source: '', judgeExamples: '', jobName: row.job });
|
|
2422
|
+
const key = workflowKey(spec);
|
|
2423
|
+
const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
|
|
2424
|
+
return {
|
|
2425
|
+
ok: true,
|
|
2426
|
+
origin: { runid: null, job: row.job, importId: id },
|
|
2427
|
+
card,
|
|
2428
|
+
from: 'imported job',
|
|
2429
|
+
locked: REUSE_LOCKED_FIELDS,
|
|
2430
|
+
open: REUSE_OPEN_FIELDS,
|
|
2431
|
+
note: 'a Source is needed — an exported job carries none; it is checked and signed on this machine',
|
|
2432
|
+
specHash: jobSpecHash(spec),
|
|
2433
|
+
workflowKey: key,
|
|
2434
|
+
spec,
|
|
2435
|
+
specPath: null,
|
|
2436
|
+
trackRecord,
|
|
2437
|
+
line: reuseLine(trackRecord),
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
|
|
1696
2441
|
/** @param {any} res @param {number} code @param {any} body */
|
|
1697
2442
|
function sendJson(res, code, body) {
|
|
1698
2443
|
const text = JSON.stringify(body);
|
|
@@ -1722,7 +2467,7 @@ function sendText(res, code, text) {
|
|
|
1722
2467
|
* only `createPanelServer` below always supplies one.
|
|
1723
2468
|
* @param {import('node:http').IncomingMessage} req
|
|
1724
2469
|
* @param {import('node:http').ServerResponse} res
|
|
1725
|
-
* @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes> }} opts
|
|
2470
|
+
* @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes> }} opts
|
|
1726
2471
|
*/
|
|
1727
2472
|
export function handleRequest(req, res, opts) {
|
|
1728
2473
|
/** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
|
|
@@ -1731,6 +2476,8 @@ export function handleRequest(req, res, opts) {
|
|
|
1731
2476
|
function routesFor(o, p) {
|
|
1732
2477
|
if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
|
|
1733
2478
|
if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
|
|
2479
|
+
if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop)$/.test(p)) return o.runRoutes;
|
|
2480
|
+
if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
|
|
1734
2481
|
return null;
|
|
1735
2482
|
}
|
|
1736
2483
|
const method = req.method ?? 'GET';
|
|
@@ -1888,7 +2635,7 @@ export function handleRequest(req, res, opts) {
|
|
|
1888
2635
|
* this package's own `bin/bareloop.mjs`).
|
|
1889
2636
|
* @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
|
|
1890
2637
|
* sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
|
|
1891
|
-
* fetchImpl?: typeof fetch }} [opts]
|
|
2638
|
+
* fetchImpl?: typeof fetch, settleMs?: number, userHome?: string }} [opts]
|
|
1892
2639
|
* @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
|
|
1893
2640
|
*/
|
|
1894
2641
|
export function createPanelServer(opts = {}) {
|
|
@@ -1906,10 +2653,14 @@ export function createPanelServer(opts = {}) {
|
|
|
1906
2653
|
let authorRoutes;
|
|
1907
2654
|
/** @type {ReturnType<typeof createSettingsRoutes>|undefined} */
|
|
1908
2655
|
let settingsRoutes;
|
|
2656
|
+
/** @type {ReturnType<typeof createRunRoutes>|undefined} */
|
|
2657
|
+
let runRoutes;
|
|
2658
|
+
/** @type {ReturnType<typeof createImportRoutes>|undefined} */
|
|
2659
|
+
let importRoutes;
|
|
1909
2660
|
const server = createServer((req, res) => {
|
|
1910
2661
|
try {
|
|
1911
2662
|
handleRequest(req, res, {
|
|
1912
|
-
home, port: boundPort, token, authorRoutes, settingsRoutes,
|
|
2663
|
+
home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes,
|
|
1913
2664
|
});
|
|
1914
2665
|
} catch (e) {
|
|
1915
2666
|
sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
|
|
@@ -1932,10 +2683,28 @@ export function createPanelServer(opts = {}) {
|
|
|
1932
2683
|
bareloopBin: opts.bareloopBin,
|
|
1933
2684
|
fetchImpl: opts.fetchImpl,
|
|
1934
2685
|
home,
|
|
2686
|
+
startFrom: {
|
|
2687
|
+
get: (runid, o = {}) => getStartFrom(runid, { home, ...o }),
|
|
2688
|
+
getImport: (id, o = {}) => getStartFromImport(id, { home, ...o }),
|
|
2689
|
+
},
|
|
1935
2690
|
});
|
|
1936
2691
|
settingsRoutes = createSettingsRoutes({
|
|
1937
2692
|
port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
|
|
1938
2693
|
});
|
|
2694
|
+
runRoutes = createRunRoutes({
|
|
2695
|
+
port: boundPort,
|
|
2696
|
+
token,
|
|
2697
|
+
home,
|
|
2698
|
+
env: opts.env,
|
|
2699
|
+
spawnFn: opts.spawnFn,
|
|
2700
|
+
bareloopBin: opts.bareloopBin,
|
|
2701
|
+
settleMs: opts.settleMs,
|
|
2702
|
+
getResumeContext: (runid) => getResumeContext(runid, { home }),
|
|
2703
|
+
getStopContext: (runid) => getStopContext(runid, { home }),
|
|
2704
|
+
});
|
|
2705
|
+
importRoutes = createImportRoutes({
|
|
2706
|
+
port: boundPort, token, home, userHome: opts.userHome, describeSpec,
|
|
2707
|
+
});
|
|
1939
2708
|
resolve({
|
|
1940
2709
|
server,
|
|
1941
2710
|
port: boundPort,
|