bareloop 0.42.0 → 0.44.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 +88 -0
- package/bareloop.context.md +103 -41
- package/package.json +2 -2
- package/src/authorflow.js +26 -11
- package/src/authoring.js +1 -1
- package/src/authorjob.js +33 -15
- package/src/authorreadout.js +7 -6
- package/src/declaredclose.js +6 -3
- package/src/draftspend.js +20 -2
- package/src/facts.js +54 -9
- package/src/factwords.js +58 -6
- package/src/inputlook.js +38 -3
- package/src/interviewrun.js +0 -1
- package/src/job.js +39 -12
- package/src/kinds.js +5 -0
- package/src/pagedropwords.js +64 -0
- package/src/pagemap.js +264 -0
- package/src/pagerules.js +319 -0
- package/src/panel/authorroutes.js +138 -60
- package/src/panel/authorsession.js +88 -32
- package/src/panel/index.html +1251 -522
- package/src/panel/jobplan.js +4 -2
- package/src/panel/runroutes.js +188 -6
- package/src/panel/scheduler.js +156 -0
- package/src/panel/schedules.js +667 -0
- package/src/panel/schedulestart.js +243 -0
- package/src/panel/server.js +1234 -68
- package/src/panel/status.js +33 -0
- package/src/panelmoney.js +29 -0
- package/src/planrun.js +92 -11
- package/src/promptregisters.js +4 -0
- package/src/providerrows.js +3 -3
- package/src/replay.js +118 -7
- package/src/reviewdoor.js +37 -0
- package/src/rubricstep.js +123 -11
- package/src/runlist.js +37 -1
- package/src/schedulewords.js +299 -0
- package/src/source.js +18 -0
- package/src/thinking.js +63 -2
- package/src/trigger.js +48 -0
- package/src/triggerclock.js +127 -0
- package/src/triggerfiles.js +215 -0
- package/src/triggerledger.js +79 -0
- package/src/userrun.js +34 -21
- package/types/authorflow.d.ts +42 -6
- package/types/authorjob.d.ts +10 -2
- package/types/declaredclose.d.ts +3 -1
- package/types/draftspend.d.ts +27 -1
- package/types/facts.d.ts +15 -0
- package/types/factwords.d.ts +26 -3
- package/types/inputlook.d.ts +15 -0
- package/types/job.d.ts +8 -1
- package/types/kinds.d.ts +5 -0
- package/types/pagedropwords.d.ts +26 -0
- package/types/pagemap.d.ts +154 -0
- package/types/pagerules.d.ts +86 -0
- package/types/panel/authorroutes.d.ts +50 -14
- package/types/panel/authorsession.d.ts +37 -12
- package/types/panel/runroutes.d.ts +32 -1
- package/types/panel/scheduler.d.ts +17 -0
- package/types/panel/schedules.d.ts +412 -0
- package/types/panel/schedulestart.d.ts +106 -0
- package/types/panel/server.d.ts +297 -15
- package/types/panel/status.d.ts +51 -0
- package/types/panelmoney.d.ts +5 -0
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/replay.d.ts +21 -1
- package/types/reviewdoor.d.ts +27 -0
- package/types/rubricstep.d.ts +57 -1
- package/types/runlist.d.ts +24 -1
- package/types/schedulewords.d.ts +137 -0
- package/types/source.d.ts +11 -0
- package/types/thinking.d.ts +39 -0
- package/types/trigger.d.ts +37 -0
- package/types/triggerclock.d.ts +52 -0
- package/types/triggerfiles.d.ts +93 -0
- package/types/triggerledger.d.ts +35 -0
- package/types/userrun.d.ts +4 -0
package/src/panel/server.js
CHANGED
|
@@ -25,13 +25,19 @@
|
|
|
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 {
|
|
28
|
+
import {
|
|
29
|
+
readFileSync, existsSync, readdirSync, lstatSync, realpathSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
29
32
|
import {
|
|
30
33
|
dirname, join, basename, relative, isAbsolute, sep,
|
|
31
34
|
} from 'node:path';
|
|
32
35
|
import { fileURLToPath } from 'node:url';
|
|
33
|
-
import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
|
|
34
|
-
import {
|
|
36
|
+
import { readRunList, readNeedsRemovals, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
|
|
37
|
+
import { webFetchState } from '../source.js';
|
|
38
|
+
import { isWebUrl } from '../pagefetch.js';
|
|
39
|
+
import { draftingPartFor, sessionDirForRow, readDraftSteps } from '../draftspend.js';
|
|
40
|
+
import { readingOfQuestion, isAgree } from '../pagemap.js';
|
|
35
41
|
import {
|
|
36
42
|
replayOne, parseJsonl, resolveSiblings, isSidecarByName,
|
|
37
43
|
} from '../replayio.js';
|
|
@@ -42,19 +48,30 @@ import { runBehaviour } from '../behaviour.js';
|
|
|
42
48
|
import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
|
|
43
49
|
import { jobSpecHash, workflowKey } from '../job.js';
|
|
44
50
|
import { confirmProtections } from '../authorflow.js';
|
|
51
|
+
import { createScheduleDoor } from './schedules.js';
|
|
52
|
+
import { createStarter } from './schedulestart.js';
|
|
53
|
+
import { createScheduler } from './scheduler.js';
|
|
54
|
+
import {
|
|
55
|
+
catchUpWords, dateTimeWords, jobScheduleWords, lowerFirst, scheduleSignFor, scheduledWords, sectionThisRunWords, skippedWords, startedAuditWords, startedWords, triesTailWords, SCHEDULE_WORDS,
|
|
56
|
+
} from '../schedulewords.js';
|
|
57
|
+
import { doorWaiting, heldScheduled } from '../reviewdoor.js';
|
|
45
58
|
import { createAuthorRoutes, mintToken, checkHostGuard, panelMoney2 } from './authorroutes.js';
|
|
46
59
|
import { createRunRoutes } from './runroutes.js';
|
|
47
60
|
import { createImportRoutes, readImports, bundleStatus, IMPORT_CHANGED_LINE } from './importroutes.js';
|
|
48
61
|
import { parseImportedRunId, safeSpinePath, spineStartedAt } from './importrun.js';
|
|
49
62
|
import { checkBundleDeps, resolveBundleSpec } from '../bundle.js';
|
|
50
63
|
import { checkCloseByteSignature } from '../close-integrity.js';
|
|
51
|
-
import { keysHome } from '../keysfile.js';
|
|
64
|
+
import { keysHome, keysForDoor } from '../keysfile.js';
|
|
52
65
|
import { rowsForHome, findRow, modelChoiceFor, tickedLines, endpointOf } from '../providerrows.js';
|
|
53
66
|
import { SHAPES } from '../providershapes.js';
|
|
54
67
|
import { resolveProvider } from '../providers.js';
|
|
55
|
-
import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
|
|
56
68
|
import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
|
|
57
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
statusFor, partStatusFor, GOAL_MET_LINE, doorViewOf, ACTOR_MARKER, DOOR_WORDS,
|
|
71
|
+
} from './status.js';
|
|
72
|
+
import { thinkingJobText } from '../thinking.js';
|
|
73
|
+
import { judgedFilesOf, lineRules, stepFileRel, proseNotCheckedItems } from '../rubricstep.js';
|
|
74
|
+
import { factPartOf, factSentence, ruleWords, moneyAs } from '../facts.js';
|
|
58
75
|
import { createSettingsRoutes } from './settingsroutes.js';
|
|
59
76
|
import { planFromSpec } from './jobplan.js';
|
|
60
77
|
|
|
@@ -193,6 +210,8 @@ function describeLastRecord(r) {
|
|
|
193
210
|
'scout-start': () => 'starting the scout',
|
|
194
211
|
'scout-result': () => 'the scout finishing',
|
|
195
212
|
'check-run': () => 'a check run',
|
|
213
|
+
'output-judged': () => 'recording the file a check read',
|
|
214
|
+
'rubric-evidence': () => 'recording the words a check read',
|
|
196
215
|
'check-preflight': () => 'a preflight check',
|
|
197
216
|
'check-menu': () => 'building the check menu',
|
|
198
217
|
'step-start': (rec) => `starting step ${rec.step ?? '?'}`,
|
|
@@ -420,7 +439,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
|
|
|
420
439
|
*
|
|
421
440
|
* The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
|
|
422
441
|
* Resume button appears only when `o.resume` says the engine would accept it; "Reuse workflow"
|
|
423
|
-
* (replaces P5 item 3, 2026-10-03
|
|
442
|
+
* (replaces P5 item 3, 2026-10-03; one door on every ending since 2026-10-15) starts a NEW run.
|
|
424
443
|
* @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
|
|
425
444
|
* @param {{died: boolean, lastThing: string|null}} death
|
|
426
445
|
* @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
|
|
@@ -429,7 +448,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
|
|
|
429
448
|
* @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
|
|
430
449
|
*/
|
|
431
450
|
export function endedFor(summary, death, o = {}) {
|
|
432
|
-
const base =
|
|
451
|
+
const base = withReuseAction(endedBase(summary, death, o));
|
|
433
452
|
const wt = o.worktree;
|
|
434
453
|
if (!base || !wt) return base;
|
|
435
454
|
// P6 item 1 — the run worked on a worktree in the person's own repo. GREEN: the branch carries the work (the folder is
|
|
@@ -445,13 +464,13 @@ export function endedFor(summary, death, o = {}) {
|
|
|
445
464
|
}
|
|
446
465
|
|
|
447
466
|
/**
|
|
448
|
-
*
|
|
449
|
-
* after Resume when both show
|
|
467
|
+
* ONE door (hamr 2026-10-15, v1.101): "Reuse workflow" is offered on EVERY ending (green, failed, capped, stopped, died, every other
|
|
468
|
+
* red), after Resume when both show. ONE place, so no ending branch can forget it.
|
|
450
469
|
* @param {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null} ended
|
|
451
470
|
*/
|
|
452
|
-
function
|
|
471
|
+
function withReuseAction(ended) {
|
|
453
472
|
if (!ended || ended.actions.some((a) => a.id === 'reuse')) return ended;
|
|
454
|
-
return { ...ended, actions: [...ended.actions, { id: '
|
|
473
|
+
return { ...ended, actions: [...ended.actions, { id: 'reuse', label: 'Reuse workflow' }] };
|
|
455
474
|
}
|
|
456
475
|
|
|
457
476
|
/** @param {any} summary @param {any} death @param {any} o */
|
|
@@ -460,9 +479,9 @@ function endedBase(summary, death, o) {
|
|
|
460
479
|
/** @type {{id: string, label: string}[]} */
|
|
461
480
|
const RESUME = [{ id: 'resume', label: 'Resume' }];
|
|
462
481
|
/** Reuse workflow (replaces P5 item 3's Start from this, hamr 2026-10-03): the same signed job on a new source — a NEW
|
|
463
|
-
* run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: press
|
|
482
|
+
* run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: press Reuse workflow" instead. */
|
|
464
483
|
const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
|
|
465
|
-
const CHANGE = 'Change the job: press
|
|
484
|
+
const CHANGE = 'Change the job: press Reuse workflow.';
|
|
466
485
|
const detailOf = (/** @type {string|null} */ s) => {
|
|
467
486
|
if (typeof s !== 'string' || s.length === 0) return '';
|
|
468
487
|
return s.length > 120 ? `${s.slice(0, 120)}…` : s;
|
|
@@ -519,8 +538,8 @@ function endedBase(summary, death, o) {
|
|
|
519
538
|
|
|
520
539
|
if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
|
|
521
540
|
if (o.destinationRefused) {
|
|
522
|
-
// Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below);
|
|
523
|
-
// (
|
|
541
|
+
// Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below); every ending offers Reuse workflow
|
|
542
|
+
// (withReuseAction adds it) and is told to change the job
|
|
524
543
|
const earned = outcome === 'green';
|
|
525
544
|
return {
|
|
526
545
|
reason: 'Goal met, but the output could not be delivered.',
|
|
@@ -530,7 +549,7 @@ function endedBase(summary, death, o) {
|
|
|
530
549
|
};
|
|
531
550
|
}
|
|
532
551
|
// hamr 2026-10-08: already-green (the check passed before any work) is not a reusable job — only a run that
|
|
533
|
-
// earned its green offers Reuse workflow;
|
|
552
|
+
// earned its green offers Reuse workflow; every ending offers Reuse workflow (withReuseAction adds it). `satisfied` is a
|
|
534
553
|
// close VERDICT, never a run outcome (ralph and the job-end emitters write `green`), so it rides with already-green.
|
|
535
554
|
if (outcome !== 'green') return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
|
|
536
555
|
return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
|
|
@@ -574,11 +593,21 @@ function endedBase(summary, death, o) {
|
|
|
574
593
|
if (outcome === 'step-stalled') {
|
|
575
594
|
return {
|
|
576
595
|
reason: 'A step stopped making progress.',
|
|
577
|
-
next: resumeOr('Resume, or change the job: press
|
|
596
|
+
next: resumeOr('Resume, or change the job: press Reuse workflow.'),
|
|
578
597
|
line: resumeOk ? 'step stalled — resume' : 'step stalled',
|
|
579
598
|
actions: resumeOk ? RESUME : [],
|
|
580
599
|
};
|
|
581
600
|
}
|
|
601
|
+
// hamr 2026-10-16: a replan answer cut off at its length limit is a bounded failed attempt in plain words, not the
|
|
602
|
+
// provider's fault and not "the checks said no" (the engine files it as a plan-red escalation with cause 'truncated')
|
|
603
|
+
if (outcome === 'plan-red' && esc?.cause === 'truncated' && esc?.phase === 'replan') {
|
|
604
|
+
return {
|
|
605
|
+
reason: 'The replan answer was cut off at its length limit, so no new plan came back.',
|
|
606
|
+
next: CHANGE,
|
|
607
|
+
line: 'replan cut off',
|
|
608
|
+
actions: [],
|
|
609
|
+
};
|
|
610
|
+
}
|
|
582
611
|
if (GOAL_NOT_MET.has(outcome)) {
|
|
583
612
|
const d = detailOf(summary.stopReason);
|
|
584
613
|
return {
|
|
@@ -624,6 +653,29 @@ function lastJobEndTs(/** @type {any[]} */ records) {
|
|
|
624
653
|
return null;
|
|
625
654
|
}
|
|
626
655
|
|
|
656
|
+
/** the timestamp of the spine's LAST record that carries one, or null (a died run has no job-end: this is when it went quiet) */
|
|
657
|
+
function lastRecordTs(/** @type {any[]} */ records) {
|
|
658
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
659
|
+
const r = records[i];
|
|
660
|
+
if (r && typeof r === 'object' && typeof r.ts === 'string') return r.ts;
|
|
661
|
+
}
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* A live run's plain "why" for the runs list: `step N of M` (the step it is on, of the plan's steps), or null while no plan is accepted yet
|
|
667
|
+
* (the card then says only `running`). Same words the Run tab's header uses.
|
|
668
|
+
* @param {any[]} records @param {any} summary
|
|
669
|
+
* @returns {string|null}
|
|
670
|
+
*/
|
|
671
|
+
function liveStepWhy(records, summary) {
|
|
672
|
+
const plan = records.findLast((r) => r && r.type === 'plan-accepted');
|
|
673
|
+
const total = Array.isArray(plan?.plan?.steps) ? plan.plan.steps.length : 0;
|
|
674
|
+
if (total === 0) return null;
|
|
675
|
+
const done = new Set((summary.steps ?? []).filter((/** @type {any} */ u) => u && u.outcome !== null && u.outcome !== undefined && !u.stopReason).map((/** @type {any} */ u) => u.id)).size;
|
|
676
|
+
return `step ${Math.min(done + 1, total)} of ${total}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
627
679
|
function endedForRow(row, records, summary, death, withWorktree = false) {
|
|
628
680
|
const out = summary.outcome;
|
|
629
681
|
const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
|
|
@@ -648,17 +700,29 @@ function endedForRow(row, records, summary, death, withWorktree = false) {
|
|
|
648
700
|
* exist YET: the runner appends the row first, then opens the spine. It is `starting`,
|
|
649
701
|
* never `file missing`. Carries the same field names a normal row/detail does so every
|
|
650
702
|
* client reader (filters, groups) sees a live `▶` run, with every figure honestly null.
|
|
651
|
-
* @param {{ runid: string, job: string, at: string, via: string }} row
|
|
703
|
+
* @param {{ runid: string, job: string, at: string, via: string, spine: string }} row
|
|
652
704
|
* @returns {any}
|
|
653
705
|
*/
|
|
654
706
|
function startingStub(row) {
|
|
707
|
+
// PRD v1.106 Part 5 Q2 (moved after the listing 2026-10-19, run mvez8s54): while the run's own refetch of a web input is still going
|
|
708
|
+
// (source.json has a web input and no refrozenAt at or after the row), the words are the server's: "fetching the page…"
|
|
709
|
+
const near = sourceNearSpine(row.spine);
|
|
710
|
+
const fetching = near.sourceJsonPath !== null && webFetchState(near.sourceJsonPath, row.at).fetching;
|
|
655
711
|
return {
|
|
712
|
+
startingWord: fetching ? 'fetching the page…' : 'starting…',
|
|
713
|
+
startingNote: fetching ? 'the run has not started yet' : 'the run has not written its log yet',
|
|
656
714
|
runid: row.runid,
|
|
657
715
|
job: row.job,
|
|
658
716
|
at: row.at,
|
|
659
717
|
via: row.via,
|
|
660
718
|
fileMissing: false,
|
|
661
719
|
starting: true,
|
|
720
|
+
live: true,
|
|
721
|
+
liveWhy: null,
|
|
722
|
+
needs: null,
|
|
723
|
+
stoppedAt: null,
|
|
724
|
+
liveSince: typeof row.at === 'string' ? row.at : null,
|
|
725
|
+
priorWallMs: 0,
|
|
662
726
|
died: false,
|
|
663
727
|
glyph: '▶',
|
|
664
728
|
status: statusFor({ outcome: null }),
|
|
@@ -684,19 +748,63 @@ function startingStub(row) {
|
|
|
684
748
|
};
|
|
685
749
|
}
|
|
686
750
|
|
|
751
|
+
/**
|
|
752
|
+
* A run that REFUSED before it started (its refetch of a web input was refused, run mvez8s54 shape): listed, settled at $0, no spine.
|
|
753
|
+
* The status word is the table's `failed` (statusFor: "refusals, instrument stops -> failed"); the plain words ride on `endedLine`.
|
|
754
|
+
* @param {{ runid: string, job: string, at: string, via: string }} row @param {string} words
|
|
755
|
+
* @returns {any}
|
|
756
|
+
*/
|
|
757
|
+
function refusedStub(row, words) {
|
|
758
|
+
return {
|
|
759
|
+
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: false, refused: true, live: false, needs: null, stoppedAt: null,
|
|
760
|
+
died: false, glyph: statusFor({ outcome: 'refused-before-start' }).sign, status: statusFor({ outcome: 'refused-before-start' }),
|
|
761
|
+
outcome: 'refused-before-start', endedLine: words, ended: null, resume: null, checkType: 'unknown', checkTypeTitle: null, model: null,
|
|
762
|
+
spend: '$0', wall: '', date: typeof row.at === 'string' ? row.at.slice(0, 10) : null, spentUsd: 0, spendComplete: true,
|
|
763
|
+
spendFloorUsd: null, wallFloorMs: null, draftSpentUsd: null, draftSpendComplete: null, budgetUsd: null, steps: [], parts: [],
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** the plain words of a run that settled as "refused before start" (leg 1), or null @param {any[]} events @param {{runid: string}} row */
|
|
768
|
+
function refusalWordsOf(events, row) {
|
|
769
|
+
const e = events.find((x) => x && x.type === 'settled' && x.runid === row.runid && x.reason === 'refused before start' && !(x.leg > 1));
|
|
770
|
+
return e && typeof e.words === 'string' && e.words !== '' ? e.words : null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* RULED 2026-10-15 (Remove from Needs you): the ONE place a run's Needs-you state is decided. `Accept` (a live door) is never removable and never ages out.
|
|
775
|
+
* A `Resume` tag leaves when the person removed it AFTER (or at) the stop it is for — a removal covers only the ending it was made on, so a later stop
|
|
776
|
+
* brings the run back — or after `needsYouDays`. `removedMs` is the newest removal that covers the current ending (or null), `expired` is true when only
|
|
777
|
+
* the age rule took a Resume run out.
|
|
778
|
+
* @param {{live: boolean, records: any[], resume: {ok?: boolean}|null|undefined, stoppedAt: string|null, nowMs: number, removedMs?: number}} f
|
|
779
|
+
* @returns {{needs: 'Accept'|'Resume'|null, removedMs: number|null, expired: boolean}}
|
|
780
|
+
*/
|
|
781
|
+
export function needsStateFor(f) {
|
|
782
|
+
if (!f.live && doorWaiting(f.records, { now: () => f.nowMs })) return { needs: 'Accept', removedMs: null, expired: false };
|
|
783
|
+
const stopMs = typeof f.stoppedAt === 'string' ? Date.parse(f.stoppedAt) : NaN;
|
|
784
|
+
const covers = typeof f.removedMs === 'number' && (!Number.isFinite(stopMs) || f.removedMs >= stopMs);
|
|
785
|
+
const tag = needsTag(f.resume, f.stoppedAt, f.nowMs);
|
|
786
|
+
const canResume = !f.live && !!f.resume && f.resume.ok === true;
|
|
787
|
+
if (covers && canResume) return { needs: null, removedMs: /** @type {number} */ (f.removedMs), expired: false };
|
|
788
|
+
return { needs: tag, removedMs: null, expired: canResume && tag === null };
|
|
789
|
+
}
|
|
790
|
+
|
|
687
791
|
/**
|
|
688
792
|
* One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
|
|
689
793
|
* spine no longer exists on disk — never silently listed as if it were
|
|
690
794
|
* still there (the same rule {@link import('../runlist.js').formatRunRow}
|
|
691
795
|
* already applies to the CLI's own listing).
|
|
692
796
|
* @param {import('../runlist.js').RunRow} row
|
|
797
|
+
* @param {number} [nowMs] @param {Map<string, number>} [removals] `readNeedsRemovals`: when each run was taken out of Needs you
|
|
798
|
+
* @param {any[]} [events] the run list's settled/released events (a run that refused before it started has no spine; its words are here)
|
|
693
799
|
* @returns {any}
|
|
694
800
|
*/
|
|
695
|
-
function summarizeRow(row) {
|
|
801
|
+
function summarizeRow(row, nowMs = Date.now(), removals = undefined, events = []) {
|
|
696
802
|
if (!existsSync(row.spine)) {
|
|
803
|
+
const refusal = refusalWordsOf(events, row);
|
|
804
|
+
if (refusal !== null) return refusedStub(row, refusal);
|
|
697
805
|
if (runIsAlive(row)) return startingStub(row);
|
|
698
806
|
return {
|
|
699
|
-
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
|
|
807
|
+
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true, live: false, needs: null, stoppedAt: null,
|
|
700
808
|
};
|
|
701
809
|
}
|
|
702
810
|
let summary;
|
|
@@ -707,17 +815,33 @@ function summarizeRow(row) {
|
|
|
707
815
|
} catch (e) {
|
|
708
816
|
return {
|
|
709
817
|
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: false, readError: String(/** @type {Error} */ (e).message),
|
|
818
|
+
live: false, needs: null, stoppedAt: null,
|
|
710
819
|
};
|
|
711
820
|
}
|
|
712
821
|
const line = summarizeForAllLine(summary);
|
|
713
822
|
const death = deriveDeath(row, rawRecords, summary.outcome);
|
|
714
|
-
const { ended, status } = endedForRow(row, rawRecords, summary, death);
|
|
823
|
+
const { ended, status, resume } = endedForRow(row, rawRecords, summary, death);
|
|
824
|
+
// P9 v2: the server decides every state the runs list draws (the page works out nothing).
|
|
825
|
+
const live = !death.died && (summary.outcome === null || summary.outcome === undefined) && runIsAlive(row);
|
|
826
|
+
const stoppedAt = live ? null : (lastJobEndTs(rawRecords) ?? (death.died ? lastRecordTs(rawRecords) : null));
|
|
827
|
+
const legs = legsOf(rawRecords);
|
|
828
|
+
const priorWall = legs.length <= 1 ? { ms: 0, complete: true } : legsWallMs(legs.slice(0, -1));
|
|
829
|
+
const liveStart = live ? legs.at(-1)?.startMs ?? null : null;
|
|
715
830
|
return {
|
|
716
831
|
runid: row.runid,
|
|
717
832
|
job: row.job,
|
|
718
833
|
at: row.at,
|
|
719
834
|
via: row.via,
|
|
720
835
|
fileMissing: false,
|
|
836
|
+
live,
|
|
837
|
+
liveWhy: live ? liveStepWhy(rawRecords, summary) : null,
|
|
838
|
+
// a run waiting at its review door needs the person's Accept (never ages out, T10); else the Resume rule
|
|
839
|
+
needs: needsStateFor({ live, records: rawRecords, resume, stoppedAt, nowMs, removedMs: removals ? removals.get(row.runid) : undefined }).needs,
|
|
840
|
+
stoppedAt,
|
|
841
|
+
// a live run's wall counts up from the start of its CURRENT leg; earlier legs add `priorWallMs` (the gap between legs is nobody's).
|
|
842
|
+
// `priorWallMs` is null when the earlier legs' wall is not exact: the card then shows this leg's wall only, and says so.
|
|
843
|
+
liveSince: liveStart === null ? (live && typeof row.at === 'string' ? row.at : null) : new Date(liveStart).toISOString(),
|
|
844
|
+
priorWallMs: live ? (priorWall.complete ? priorWall.ms : null) : null,
|
|
721
845
|
died: death.died,
|
|
722
846
|
endedLine: ended ? ended.line : null,
|
|
723
847
|
// P5-R: one card per run — how many times it was resumed (a resumed run is the same run, never a second card)
|
|
@@ -784,6 +908,21 @@ export function getStopContext(runid, opts = {}) {
|
|
|
784
908
|
return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
|
|
785
909
|
}
|
|
786
910
|
|
|
911
|
+
/**
|
|
912
|
+
* The Remove-from-Needs-you route's reader (RULED 2026-10-15): the listed row and the Needs-you tag the runs list shows for it right now
|
|
913
|
+
* (`summarizeRow`, the one rule). `null` when the runid is not listed.
|
|
914
|
+
* @param {string} runid
|
|
915
|
+
* @param {{ home?: string, now?: number }} [opts]
|
|
916
|
+
* @returns {{row: any, needs: 'Accept'|'Resume'|null}|null}
|
|
917
|
+
*/
|
|
918
|
+
export function getNeedsContext(runid, opts = {}) {
|
|
919
|
+
const { rows } = readRunList(opts);
|
|
920
|
+
const row = rows.find((r) => r && r.runid === runid);
|
|
921
|
+
if (!row) return null;
|
|
922
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
923
|
+
return { row, needs: summarizeRow(row, nowMs, readNeedsRemovals(opts)).needs ?? null };
|
|
924
|
+
}
|
|
925
|
+
|
|
787
926
|
/**
|
|
788
927
|
* @param {any} r
|
|
789
928
|
* @returns {number}
|
|
@@ -818,6 +957,57 @@ function runNumbers(rows) {
|
|
|
818
957
|
return out;
|
|
819
958
|
}
|
|
820
959
|
|
|
960
|
+
/** The named limits of the runs list, in ONE place (P9 v2 D.13): quiet after 30 days, 7 older runs shown, a Resume tag ages out after 7 days. Sent in `GET /api/runs`; the page reads them from there. */
|
|
961
|
+
export const RUNS_LIST_LIMITS = Object.freeze({ quietDays: 30, olderRuns: 7, needsYouDays: 7 });
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The ONE rule for a run's Needs-you tag (P9 v2 E): `Resume` when the engine would accept a resume (the SAME `resumePlanFor` answer the Run
|
|
965
|
+
* tab's Resume card shows, already asked once per row by `endedForRow`), until the run has been stopped `needsYouDays` (exactly that old is out;
|
|
966
|
+
* no readable stop time stays in). `Accept` comes from {@link doorWaiting} (T Part 8), asked in `summarizeRow`, never here.
|
|
967
|
+
* @param {{ok?: boolean}|null|undefined} resume @param {string|null} stoppedAt @param {number} nowMs
|
|
968
|
+
* @returns {'Accept'|'Resume'|null}
|
|
969
|
+
*/
|
|
970
|
+
export function needsTag(resume, stoppedAt, nowMs) {
|
|
971
|
+
if (!resume || resume.ok !== true) return null;
|
|
972
|
+
const t = typeof stoppedAt === 'string' ? Date.parse(stoppedAt) : NaN;
|
|
973
|
+
if (Number.isFinite(t) && nowMs - t >= RUNS_LIST_LIMITS.needsYouDays * 86_400_000) return null;
|
|
974
|
+
return 'Resume';
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* One entry per job, newest activity first: `headRunid` is the job's newest run BY TIME (never "the first row in server order"), `activityMs` the
|
|
979
|
+
* newest KNOWN time over its runs (`stoppedAt`, else `at`), `quiet` only when that time is known and older than the quiet limit. A job with a live
|
|
980
|
+
* run is never quiet; an unknown age is never guessed into quiet.
|
|
981
|
+
* @param {any[]} rows `listRuns` rows @param {number} nowMs
|
|
982
|
+
* @returns {{job: string, headRunid: string|null, quiet: boolean, activityMs: number|null}[]}
|
|
983
|
+
*/
|
|
984
|
+
export function summarizeJobs(rows, nowMs) {
|
|
985
|
+
/** @type {Map<string, {head: any, headMs: number, act: number|null, live: boolean}>} */
|
|
986
|
+
const by = new Map();
|
|
987
|
+
const ms = (/** @type {any} */ x) => { const t = typeof x === 'string' ? Date.parse(x) : NaN; return Number.isFinite(t) ? t : null; };
|
|
988
|
+
for (const r of rows) {
|
|
989
|
+
if (!r || typeof r.job !== 'string') continue;
|
|
990
|
+
const e = by.get(r.job) ?? { head: null, headMs: -Infinity, act: null, live: false };
|
|
991
|
+
const at = ms(r.at);
|
|
992
|
+
if (e.head === null || (at !== null && at > e.headMs)) { e.head = r; e.headMs = at ?? -Infinity; }
|
|
993
|
+
const t = Math.max(at ?? -Infinity, ms(r.stoppedAt) ?? -Infinity);
|
|
994
|
+
if (t > -Infinity && (e.act === null || t > e.act)) e.act = t;
|
|
995
|
+
if (r.live === true) e.live = true;
|
|
996
|
+
by.set(r.job, e);
|
|
997
|
+
}
|
|
998
|
+
const quietMs = RUNS_LIST_LIMITS.quietDays * 86_400_000;
|
|
999
|
+
return [...by.entries()]
|
|
1000
|
+
.map(([job, e]) => ({ job, headRunid: e.head ? e.head.runid : null, quiet: !e.live && e.act !== null && nowMs - e.act > quietMs, activityMs: e.act }))
|
|
1001
|
+
.sort((a, b) => {
|
|
1002
|
+
if (a.activityMs !== b.activityMs) {
|
|
1003
|
+
if (a.activityMs === null) return 1;
|
|
1004
|
+
if (b.activityMs === null) return -1;
|
|
1005
|
+
return b.activityMs - a.activityMs;
|
|
1006
|
+
}
|
|
1007
|
+
return a.job < b.job ? -1 : a.job > b.job ? 1 : 0;
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
821
1011
|
/**
|
|
822
1012
|
* `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
|
|
823
1013
|
* append order — a backfill scan appends rows in sorted-PATH order, which
|
|
@@ -831,16 +1021,38 @@ function runNumbers(rows) {
|
|
|
831
1021
|
* SAME payload by job client-side (`groupRunsByJob` in `index.html`) rather
|
|
832
1022
|
* than a second server-side endpoint, so the glyph/checkType mapping is
|
|
833
1023
|
* still computed in exactly one place.
|
|
834
|
-
* @param {{ home?: string }} [opts]
|
|
1024
|
+
* @param {{ home?: string, now?: number }} [opts] `now` (ms) is injectable for the Needs-you age rule
|
|
835
1025
|
* @returns {any[]}
|
|
836
1026
|
*/
|
|
837
1027
|
export function listRuns(opts = {}) {
|
|
838
|
-
const { rows } = readRunList(opts);
|
|
1028
|
+
const { rows, events } = readRunList(opts);
|
|
1029
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
839
1030
|
const nos = runNumbers(rows);
|
|
1031
|
+
const removals = readNeedsRemovals(opts);
|
|
840
1032
|
return rows
|
|
841
1033
|
.map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
|
|
842
1034
|
.sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
|
|
843
|
-
.map(({ row }) => ({ ...summarizeRow(row), runNo: nos.get(row.runid) }));
|
|
1035
|
+
.map(({ row }) => ({ ...summarizeRow(row, nowMs, removals, events), runNo: nos.get(row.runid) }));
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* `GET /api/runs` (P9 v2 F): `{runs, jobs, limits}`. The server decides every state the left list draws — `live`, `needs`, each job's head run and
|
|
1040
|
+
* quiet flag, the limits — and the page works out nothing. `scheduled` is the triggers piece (PRD v1.99, T Part 7).
|
|
1041
|
+
* @param {{ home?: string, now?: number, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
|
|
1042
|
+
*/
|
|
1043
|
+
export function runsListPayload(opts = {}) {
|
|
1044
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
1045
|
+
const runs = listRuns({ ...opts, now: nowMs });
|
|
1046
|
+
const jobs = summarizeJobs(runs, nowMs);
|
|
1047
|
+
// T Part 7: the Scheduled section, `[{job, at, in, words, runId}]`; runId = the job's HEAD run by time (summarizeJobs), never the first in server order
|
|
1048
|
+
const scheduled0 = opts.schedules ? opts.schedules.listScheduled(new Map(jobs.map((j) => [j.job, j.headRunid]))) : [];
|
|
1049
|
+
// RULED 2026-10-15: schedules that gave up wait in Needs you; the server builds the cards (job, pill, words, date, the head run to open)
|
|
1050
|
+
const gaveUp0 = opts.schedules ? opts.schedules.listGaveUp(new Map(jobs.map((j) => [j.job, j.headRunid]))) : [];
|
|
1051
|
+
// RULED 2026-10-15: both cards name the run they open (`job · run-N`), the number from the run list
|
|
1052
|
+
const noOf = (/** @type {any} */ x) => { const r = runs.find((q) => q.runid === x.runId); return r && typeof r.runNo === 'number' ? { ...x, runNo: r.runNo } : x; };
|
|
1053
|
+
const gaveUp = gaveUp0.map(noOf);
|
|
1054
|
+
const scheduled = scheduled0.map(noOf);
|
|
1055
|
+
return { runs, jobs, scheduled, gaveUp, limits: RUNS_LIST_LIMITS };
|
|
844
1056
|
}
|
|
845
1057
|
|
|
846
1058
|
/**
|
|
@@ -880,6 +1092,391 @@ function findRunRow(runid, opts) {
|
|
|
880
1092
|
return readRunList(opts).rows.find((r) => r && r.runid === runid);
|
|
881
1093
|
}
|
|
882
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* T Part 7 — `detail.schedule`: `{scheduled, canSchedule, words, text?, next?, nextRed?}` from the schedule door's ONE read of the
|
|
1097
|
+
* schedule files and ledger (A.12). `waitingRunNo` = the job's NEWEST run when it waits for the person's Accept: ONE spine parse,
|
|
1098
|
+
* never a run context per run. A job that cannot be read has no schedule block.
|
|
1099
|
+
* @param {string} job @param {any[]} rows the run list @param {Map<string, number>} nos @param {{ schedules?: ReturnType<typeof createScheduleDoor> }} opts
|
|
1100
|
+
*/
|
|
1101
|
+
function scheduleDetailFor(job, rows, nos, opts) {
|
|
1102
|
+
try {
|
|
1103
|
+
const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
|
|
1104
|
+
let newest = null;
|
|
1105
|
+
for (const r of rows) if (r && r.job === job && (newest === null || atMs(r) >= atMs(newest))) newest = r;
|
|
1106
|
+
let waitingRunNo = null;
|
|
1107
|
+
if (newest && existsSync(newest.spine) && door.readSchedule(job) && !runIsAlive(newest)) {
|
|
1108
|
+
const records = parseJsonl(newest.spine).records;
|
|
1109
|
+
if (doorWaiting(records)) waitingRunNo = nos.get(newest.runid) ?? null;
|
|
1110
|
+
}
|
|
1111
|
+
return door.view(job, { waitingRunNo });
|
|
1112
|
+
} catch { return null; }
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The record `trigger.json` a SCHEDULED start wrote beside its signed spec (why the run started), or null for every hand run.
|
|
1117
|
+
* @param {{ spine: string }} row @returns {any|null}
|
|
1118
|
+
*/
|
|
1119
|
+
function triggerRecordOf(row) {
|
|
1120
|
+
const near = sourceNearSpine(row.spine);
|
|
1121
|
+
if (!near.specPath) return null;
|
|
1122
|
+
try { return JSON.parse(readFileSync(join(dirname(near.specPath), 'trigger.json'), 'utf8')); } catch { return null; }
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* T Part 8 — what the Accept route needs: the run's row, whether its runner is alive, whether it waits at its review door
|
|
1127
|
+
* ({@link doorWaiting}), and the signed spec it was judged by (the file and hash `run-u --door --approve` takes).
|
|
1128
|
+
* @param {string} runid @param {{ home?: string }} opts
|
|
1129
|
+
* @returns {{row: any, live: boolean, waiting: boolean, specPath: string|null, specHash: string|null}|null}
|
|
1130
|
+
*/
|
|
1131
|
+
export function getAcceptContext(runid, opts = {}) {
|
|
1132
|
+
const row = findRunRow(runid, opts);
|
|
1133
|
+
if (!row || row.via === 'import') return null;
|
|
1134
|
+
if (!existsSync(row.spine)) return { row, live: runIsAlive(row), waiting: false, specPath: null, specHash: null };
|
|
1135
|
+
const records = parseJsonl(row.spine).records;
|
|
1136
|
+
const signed = signedSpecForRun(row, records);
|
|
1137
|
+
return { row, live: runIsAlive(row), waiting: doorWaiting(records), specPath: signed ? signed.specPath : null, specHash: signed ? signed.specHash : null };
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ── PRD v1.107 Part 6: the Result tab (rubric jobs only) ─────────────────────────────────────────────────────────────────────────────
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* The files a run delivered: its own `destination-written` spine records (userrun.js, on a minted green only), one per path, the last record of a
|
|
1144
|
+
* path winning. This is the ONLY source of a delivered path anywhere in the panel — no request ever names one.
|
|
1145
|
+
* @param {any[]} records
|
|
1146
|
+
* @returns {{path: string, bytes: number|null, sha256: string|null}[]}
|
|
1147
|
+
*/
|
|
1148
|
+
export function deliveredOf(records) {
|
|
1149
|
+
/** @type {Map<string, {path: string, bytes: number|null, sha256: string|null}>} */
|
|
1150
|
+
const byPath = new Map();
|
|
1151
|
+
for (const r of records) {
|
|
1152
|
+
if (!r || r.type !== 'destination-written' || typeof r.path !== 'string' || !isAbsolute(r.path)) continue;
|
|
1153
|
+
byPath.set(r.path, { path: r.path, bytes: typeof r.bytes === 'number' ? r.bytes : null, sha256: typeof r.sha256 === 'string' ? r.sha256 : null });
|
|
1154
|
+
}
|
|
1155
|
+
return [...byPath.values()];
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** @param {any} spec @returns {any[]} the signed close's rubric-step stages, in declared order */
|
|
1159
|
+
function rubricStagesOf(spec) {
|
|
1160
|
+
const stages = Array.isArray(spec?.closeDecl?.stages) ? spec.closeDecl.stages : [];
|
|
1161
|
+
return stages.filter((/** @type {any} */ s) => s && s.kind === 'rubric-step' && s.params && Number.isInteger(s.params.step));
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** what the Result tab's open door asks, after the door's own head words */
|
|
1165
|
+
export const RESULT_DOOR_ASK = 'the checks passed; is this what you wanted?';
|
|
1166
|
+
|
|
1167
|
+
/** the two sentences for a delivered file that is no longer what the run wrote (code-owned, one spelling) */
|
|
1168
|
+
export const RESULT_FILE_GONE = 'the saved file is gone';
|
|
1169
|
+
export const RESULT_FILE_CHANGED = 'the saved file changed after the run';
|
|
1170
|
+
/** the same two states for a red run's file, which is the last one the check read (not a delivered file) */
|
|
1171
|
+
export const RESULT_JUDGED_GONE = 'the file is gone';
|
|
1172
|
+
export const RESULT_JUDGED_CHANGED = 'the file changed after the check';
|
|
1173
|
+
/** what a red run's rows are, said once above them */
|
|
1174
|
+
export const RESULT_JUDGED_NOTE = 'a red run delivers nothing; these rows are the last file the check read';
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* One delivered file, read for the Result tab or a download: it must still be a regular file at the recorded path whose sha256 equals the
|
|
1178
|
+
* record's. A mismatch or a missing file is a state, never content — stale bytes are never returned.
|
|
1179
|
+
* @param {{path: string, sha256: string|null}} rec a record from {@link deliveredOf}
|
|
1180
|
+
* @returns {{state: 'ok', buf: Buffer}|{state: 'gone'|'changed', buf: null}}
|
|
1181
|
+
*/
|
|
1182
|
+
function readDelivered(rec) {
|
|
1183
|
+
let st;
|
|
1184
|
+
try { st = lstatSync(rec.path); } catch { return { state: 'gone', buf: null }; }
|
|
1185
|
+
if (!st.isFile()) return { state: 'gone', buf: null };
|
|
1186
|
+
let buf;
|
|
1187
|
+
try { buf = readFileSync(rec.path); } catch { return { state: 'gone', buf: null }; }
|
|
1188
|
+
if (rec.sha256 === null || createHash('sha256').update(buf).digest('hex') !== rec.sha256) return { state: 'changed', buf: null };
|
|
1189
|
+
return { state: 'ok', buf };
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* A red run's rows: the file the LAST check judged for job line `n`, from the run's own `output-judged` spine record `{step, iteration, path,
|
|
1194
|
+
* bytes, sha256}` (src/planrun.js, one per check run, written before the next try overwrites the file). The spine step id is a step NAME, so the
|
|
1195
|
+
* line is matched by the path the signed close judges (`stepFileRel(n)`), never by a path taken from a record or a request. It is resolved against
|
|
1196
|
+
* the run's own tree root (`treeRootForRun`: `row.patient`, else the source-seed `tree/`), must stay inside it, and must still match the record's
|
|
1197
|
+
* sha256 — stale content is never returned. `none` = no such record (an old spine, or a try that never reached its check).
|
|
1198
|
+
* @param {any[]} records @param {number} n @param {string|null} root
|
|
1199
|
+
* @returns {{state: 'ok', buf: Buffer}|{state: 'gone'|'changed'|'none', buf: null}}
|
|
1200
|
+
*/
|
|
1201
|
+
function readJudged(records, n, root) {
|
|
1202
|
+
const rel = stepFileRel(n);
|
|
1203
|
+
const rec = records.findLast((r) => r && r.type === 'output-judged' && r.path === rel && typeof r.sha256 === 'string');
|
|
1204
|
+
if (!rec) return { state: 'none', buf: null };
|
|
1205
|
+
if (root === null) return { state: 'gone', buf: null };
|
|
1206
|
+
let abs;
|
|
1207
|
+
try {
|
|
1208
|
+
abs = realpathSync(join(root, rel));
|
|
1209
|
+
const base = realpathSync(root);
|
|
1210
|
+
if (!abs.startsWith(base + sep)) return { state: 'gone', buf: null };
|
|
1211
|
+
} catch { return { state: 'gone', buf: null }; }
|
|
1212
|
+
const got = readDelivered({ path: abs, sha256: rec.sha256 });
|
|
1213
|
+
return got.state === 'ok' ? got : { state: got.state, buf: null };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** a delivered step file's job line: `steps/<n>-YYYY-MM-DD[-k].json` (copyOut's dated name of `output/steps/<n>.json`), else null */
|
|
1217
|
+
function stepOfDelivered(path) {
|
|
1218
|
+
const m = /^(\d+)-\d{4}-\d{2}-\d{2}(?:-\d+)?\.json$/.exec(basename(path));
|
|
1219
|
+
return m ? Number(m[1]) : null;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Did this stage's check pass in the run's own record? The latest `check-run` for the stage says `satisfied` AND the run's last job-end is green.
|
|
1224
|
+
* Read only — the Result tab never decides anything.
|
|
1225
|
+
* @param {any[]} records @param {string} stageName
|
|
1226
|
+
* @returns {boolean}
|
|
1227
|
+
*/
|
|
1228
|
+
function stagePassedIn(records, stageName) {
|
|
1229
|
+
const end = records.findLast((r) => r && r.type === 'job-end');
|
|
1230
|
+
if (!end || end.outcome !== 'green') return false;
|
|
1231
|
+
const run = records.findLast((r) => r && r.type === 'check-run' && r.name === stageName);
|
|
1232
|
+
return !!run && run.verdict === 'satisfied';
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** the words beside a Result-tab page link: the page is read again from the web, so it can differ from what the run read */
|
|
1236
|
+
export const RESULT_PAGE_MAY_CHANGE = 'The page may have changed since this run.';
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* The web address each input LABEL was read from, off the run's own `source.json` (`inputs[].url`, written at drafting and rewritten at run start).
|
|
1240
|
+
* Only http(s) addresses; a file or repo input has no `url` and so no entry. No source.json beside the run = an empty map (no link, nothing guessed).
|
|
1241
|
+
* @param {string} spinePath @returns {Map<string, string>}
|
|
1242
|
+
*/
|
|
1243
|
+
function pageUrlsByLabel(spinePath) {
|
|
1244
|
+
/** @type {Map<string, string>} */
|
|
1245
|
+
const out = new Map();
|
|
1246
|
+
const { sourceJsonPath } = sourceNearSpine(spinePath);
|
|
1247
|
+
if (sourceJsonPath === null) return out;
|
|
1248
|
+
try {
|
|
1249
|
+
const m = JSON.parse(readFileSync(sourceJsonPath, 'utf8'));
|
|
1250
|
+
for (const r of Array.isArray(m?.inputs) ? m.inputs : []) {
|
|
1251
|
+
if (r && typeof r.label === 'string' && typeof r.url === 'string' && isWebUrl(r.url)) out.set(r.label, r.url.trim());
|
|
1252
|
+
}
|
|
1253
|
+
} catch { /* unreadable record: no link */ }
|
|
1254
|
+
return out;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* What a result's check read, from the run's own `rubric-evidence` record (report-only; bareguard 0.25 `full.evidence`), for the proof fold.
|
|
1259
|
+
* `null` = no evidence (an old run, a malformed claim, or a quote that is not the one the check read): the page shows the plain quote.
|
|
1260
|
+
* `text` is bareguard's normalized quote; `marks` are `[start, end]` UTF-16 offsets into it, sorted, overlaps merged. Which spans: every span the
|
|
1261
|
+
* check read EXCEPT a bind-only membership token that is not this claim's (`canon` present, `matched` absent) - hamr 2026-10-19, ruled A.
|
|
1262
|
+
* The offsets are bareguard's; nothing here searches the quote for anything.
|
|
1263
|
+
* @param {any} ev one `evidence` entry @param {unknown} quote the result's own quote as the file holds it
|
|
1264
|
+
* @returns {{text: string, marks: number[][]}|null}
|
|
1265
|
+
*/
|
|
1266
|
+
function proofOf(ev, quote) {
|
|
1267
|
+
if (!ev || typeof ev.text !== 'string' || !Array.isArray(ev.spans) || typeof quote !== 'string') return null;
|
|
1268
|
+
// the entry must be about THIS quote (bareguard's normalization: marks removed, whitespace collapsed, trimmed), else it is another try's
|
|
1269
|
+
if (quote.replace(/\*\*|__/g, '').replace(/\s+/g, ' ').trim() !== ev.text) return null;
|
|
1270
|
+
const spans = ev.spans
|
|
1271
|
+
.filter((/** @type {any} */ sp) => sp && Number.isInteger(sp.start) && Number.isInteger(sp.end) && sp.start >= 0 && sp.end > sp.start && sp.end <= ev.text.length
|
|
1272
|
+
&& !(sp.canon !== undefined && sp.matched !== true))
|
|
1273
|
+
.map((/** @type {any} */ sp) => [sp.start, sp.end])
|
|
1274
|
+
.sort((/** @type {number[]} */ x, /** @type {number[]} */ y) => x[0] - y[0] || x[1] - y[1]);
|
|
1275
|
+
/** @type {number[][]} */
|
|
1276
|
+
const marks = [];
|
|
1277
|
+
for (const sp of spans) {
|
|
1278
|
+
const last = marks[marks.length - 1];
|
|
1279
|
+
if (last && sp[0] < last[1]) last[1] = Math.max(last[1], sp[1]);
|
|
1280
|
+
else marks.push([sp[0], sp[1]]);
|
|
1281
|
+
}
|
|
1282
|
+
return { text: ev.text, marks };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* A money fact's amount exactly as the page wrote it ("144 US dollars", "€344"): the quote's text from the first to the last of the value's own
|
|
1287
|
+
* currency/unit/value spans in bareguard's evidence for this claim (a membership token that is not the claim's own is left out). `null` = nothing
|
|
1288
|
+
* located, or more than a short run: the caller keeps today's text. Offsets are bareguard's; nothing is searched.
|
|
1289
|
+
* @param {any} ev one `evidence` entry (already known to be about this result's quote) @param {number} j the fact's index (bareguard's decide index)
|
|
1290
|
+
* @returns {string|null}
|
|
1291
|
+
*/
|
|
1292
|
+
function moneyAsRead(ev, j) {
|
|
1293
|
+
const own = ev.spans.filter((/** @type {any} */ sp) => sp && sp.decide === j && ['value', 'currency', 'unit'].includes(sp.role)
|
|
1294
|
+
&& Number.isInteger(sp.start) && Number.isInteger(sp.end) && sp.start >= 0 && sp.end > sp.start && sp.end <= ev.text.length
|
|
1295
|
+
&& !(sp.canon !== undefined && sp.matched !== true));
|
|
1296
|
+
if (own.length === 0) return null;
|
|
1297
|
+
const t = ev.text.slice(Math.min(...own.map((/** @type {any} */ sp) => sp.start)), Math.max(...own.map((/** @type {any} */ sp) => sp.end))).trim();
|
|
1298
|
+
return t !== '' && t.length <= 40 ? t : null;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** a fact's cell: a money amount in the fact's own signed currency/unit (src/facts.js moneyAs), anything else as the file wrote it */
|
|
1302
|
+
function resultCell(f, v) {
|
|
1303
|
+
const t = cellText(v);
|
|
1304
|
+
return f.shape === 'money' && /^\d[\d.,]*$/.test(t.trim()) ? moneyAs(f, t.trim(), true) : t;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** the words after a fact whose limit the person agreed to in a Part 5b look ask */
|
|
1308
|
+
export const RESULT_YOU_CONFIRMED = '(you confirmed)';
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* The facts of line `n` whose limit came from a Part 5b `agree` answer, read from the drafting session's `human` rows (source 'look', this line,
|
|
1312
|
+
* the answer `agree`): the ask proposed `<fact name> <ruleWords>`, and `agree` wrote exactly that as a `~` rule on the line. A fact is confirmed only
|
|
1313
|
+
* when that proposal equals what the SIGNED fact says now AND the line's signed `~` rules hold it word for word. No draft log (a Reuse run, a CLI
|
|
1314
|
+
* run, a deleted session) = nothing is confirmed; nothing is guessed.
|
|
1315
|
+
* @param {any} row @param {any} jobLine the signed job line @param {any} fp the signed fact part @param {{ home?: string, sessionsRoot?: string }} opts
|
|
1316
|
+
* @returns {Set<string>} fact names
|
|
1317
|
+
*/
|
|
1318
|
+
function confirmedFactNames(row, jobLine, fp, opts) {
|
|
1319
|
+
const names = new Set();
|
|
1320
|
+
const dir = sessionDirForRow(row, opts);
|
|
1321
|
+
if (!dir || !jobLine) return names;
|
|
1322
|
+
const written = lineRules(jobLine);
|
|
1323
|
+
const humans = readDraftSteps(dir).flatMap((st) => st.humans);
|
|
1324
|
+
for (const h of humans) {
|
|
1325
|
+
if (h.source !== 'look' || h.line !== jobLine.n || !isAgree(h.answer)) continue;
|
|
1326
|
+
const reading = readingOfQuestion(h.question);
|
|
1327
|
+
if (reading === null || !written.includes(reading)) continue;
|
|
1328
|
+
for (const f of fp.facts) if (f.rule && reading === `${f.name} ${ruleWords(f)}`) names.add(f.name);
|
|
1329
|
+
}
|
|
1330
|
+
return names;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** a result value as the file wrote it, for a table cell */
|
|
1334
|
+
function cellText(v) {
|
|
1335
|
+
if (typeof v === 'string') return v;
|
|
1336
|
+
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
|
|
1337
|
+
return '';
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* `GET /api/runs/:runid/result` — the Result tab: for each rubric job line, its words and `~` rules from the signed spec, the facts it requires
|
|
1342
|
+
* (worded by {@link factSentence}), and — read from the run's own delivered step file, sha256-checked against its `destination-written`
|
|
1343
|
+
* record — one row per result with each fact's cell. `passed` is read from the run's own `check-run` record; the tab never re-decides. A red run
|
|
1344
|
+
* delivers nothing, so it has no rows: it carries the check's own first gap instead. `null` = no such run or no result tab for it.
|
|
1345
|
+
* @param {string} runid @param {{ home?: string }} opts
|
|
1346
|
+
*/
|
|
1347
|
+
export function getRunResult(runid, /** @type {{ home?: string, sessionsRoot?: string }} */ opts = {}) {
|
|
1348
|
+
const row = findRunRow(runid, opts);
|
|
1349
|
+
if (!row || row.via === 'import' || !existsSync(row.spine)) return null;
|
|
1350
|
+
const records = parseJsonl(row.spine).records;
|
|
1351
|
+
const spec = resolveSpecForRow(row);
|
|
1352
|
+
const stages = rubricStagesOf(spec);
|
|
1353
|
+
if (stages.length === 0) return null;
|
|
1354
|
+
const delivered = deliveredOf(records);
|
|
1355
|
+
const read = delivered.map((d) => readDelivered(d));
|
|
1356
|
+
const jobLines = Array.isArray(spec?.jobLines) ? spec.jobLines : [];
|
|
1357
|
+
// Not checked, split by the line the SIGNED plan ties each item to: a refusal carries its line (closeDecl.refused[].line, planFromSpec's
|
|
1358
|
+
// `lines[].refused`), a prose step carries its stage's step. What the plan ties to no line stays in `notChecked` (the bottom). Together they are
|
|
1359
|
+
// exactly the Job tab's items (its per-line "not checked" rows plus its Not checked fold) — never a second spelling.
|
|
1360
|
+
const plan = planFromSpec(spec);
|
|
1361
|
+
const proseByLine = proseNotCheckedItems(spec?.closeDecl);
|
|
1362
|
+
const prosePool = proseByLine.map((i) => i.text);
|
|
1363
|
+
const tiedToLine = (/** @type {number} */ n) => [...(plan.lines.find((l) => l.n === n)?.refused ?? []), ...proseByLine.filter((i) => i.line === n).map((i) => i.text)];
|
|
1364
|
+
const bottom = [...plan.notChecked];
|
|
1365
|
+
for (const t of prosePool) { const k = bottom.indexOf(t); if (k >= 0) bottom.splice(k, 1); }
|
|
1366
|
+
const pageUrls = pageUrlsByLabel(row.spine);
|
|
1367
|
+
const greenEnd = records.findLast((r) => r && r.type === 'job-end')?.outcome === 'green';
|
|
1368
|
+
const lines = stages.map((/** @type {any} */ st) => {
|
|
1369
|
+
const n = st.params.step;
|
|
1370
|
+
const jl = jobLines.find((/** @type {any} */ l) => l && l.n === n) ?? null;
|
|
1371
|
+
const fp = factPartOf(st.params);
|
|
1372
|
+
const di = delivered.map((d, i) => ({ d, i })).filter(({ d }) => stepOfDelivered(d.path) === n).pop() ?? null;
|
|
1373
|
+
const passed = stagePassedIn(records, st.name);
|
|
1374
|
+
/** @type {any} */
|
|
1375
|
+
const out = {
|
|
1376
|
+
notChecked: tiedToLine(n),
|
|
1377
|
+
n, text: jl && typeof jl.text === 'string' ? jl.text : '', rules: jl ? lineRules(jl) : [], kind: fp ? 'facts' : 'text', passed,
|
|
1378
|
+
file: di ? { i: di.i, state: read[di.i].state, words: read[di.i].state === 'gone' ? RESULT_FILE_GONE : (read[di.i].state === 'changed' ? RESULT_FILE_CHANGED : null) } : null,
|
|
1379
|
+
};
|
|
1380
|
+
let obj = null;
|
|
1381
|
+
if (di && read[di.i].buf) { try { obj = JSON.parse(/** @type {Buffer} */ (read[di.i].buf).toString('utf8')); } catch { obj = null; } }
|
|
1382
|
+
if (!di && !greenEnd) {
|
|
1383
|
+
// a red run: the last file the check judged (never a ✓: `passed` is false for it)
|
|
1384
|
+
const j = readJudged(records, n, treeRootForRun(row));
|
|
1385
|
+
if (j.state !== 'none') {
|
|
1386
|
+
out.file = { i: null, state: j.state, words: j.state === 'gone' ? RESULT_JUDGED_GONE : (j.state === 'changed' ? RESULT_JUDGED_CHANGED : null), judged: true, note: RESULT_JUDGED_NOTE };
|
|
1387
|
+
if (j.buf) { try { obj = JSON.parse(j.buf.toString('utf8')); } catch { obj = null; } }
|
|
1388
|
+
}
|
|
1389
|
+
} else if (di && !read[di.i].buf) {
|
|
1390
|
+
// the delivered file is gone or changed: that is said ONCE, in the Saved card. The line shows the rows of the file the check read, if the
|
|
1391
|
+
// run's own tree still holds exactly it (sha256 against the output-judged record), else the page says there is no saved result to show
|
|
1392
|
+
const j = readJudged(records, n, treeRootForRun(row));
|
|
1393
|
+
if (j.buf) { try { obj = JSON.parse(j.buf.toString('utf8')); out.fromCheck = true; } catch { obj = null; } }
|
|
1394
|
+
}
|
|
1395
|
+
if (fp) {
|
|
1396
|
+
out.source = fp.source;
|
|
1397
|
+
out.page = pageUrls.get(fp.source) ?? null;
|
|
1398
|
+
const confirmed = confirmedFactNames(row, jl, fp, opts);
|
|
1399
|
+
out.facts = fp.facts.map((/** @type {any} */ f) => ({
|
|
1400
|
+
name: f.name, kind: f.shape === 'phrase' ? 'phrase' : 'value', sentence: factSentence(f, fp.source),
|
|
1401
|
+
...(confirmed.has(f.name) ? { note: RESULT_YOU_CONFIRMED } : {}),
|
|
1402
|
+
}));
|
|
1403
|
+
const results = obj && Array.isArray(obj.results) ? obj.results : null;
|
|
1404
|
+
// what the check read, from the run's last `rubric-evidence` record for this line (the claim index IS the result's index in `results[]`)
|
|
1405
|
+
const evRec = records.findLast((r) => r && r.type === 'rubric-evidence' && r.step === n && Array.isArray(r.evidence));
|
|
1406
|
+
const evCheck = `p${st.params.parts.indexOf(fp) + 1}-facts`;
|
|
1407
|
+
out.rows = results === null ? null : results.map((/** @type {any} */ r, /** @type {number} */ k) => {
|
|
1408
|
+
const values = r && typeof r === 'object' && r.values && typeof r.values === 'object' ? r.values : {};
|
|
1409
|
+
const quote = r && typeof r === 'object' && typeof r.quote === 'string' ? r.quote : '';
|
|
1410
|
+
const ev = evRec ? evRec.evidence.find((/** @type {any} */ e) => e && e.check === evCheck && e.claim === k) : null;
|
|
1411
|
+
const proof = proofOf(ev, quote);
|
|
1412
|
+
return {
|
|
1413
|
+
n: k + 1,
|
|
1414
|
+
quote,
|
|
1415
|
+
...(proof ? { proof } : {}),
|
|
1416
|
+
// a money amount is shown the way the check read it off the page; without evidence (or nothing located) it is the fact's own spelling
|
|
1417
|
+
cells: fp.facts.map((/** @type {any} */ f, /** @type {number} */ j) => (f.shape === 'phrase' ? String(f.phrase)
|
|
1418
|
+
: ((f.shape === 'money' && proof ? moneyAsRead(ev, j) : null) ?? resultCell(f, values[f.name])))),
|
|
1419
|
+
};
|
|
1420
|
+
});
|
|
1421
|
+
} else if (obj && typeof obj === 'object') {
|
|
1422
|
+
out.answer = typeof obj.text === 'string' ? obj.text : null;
|
|
1423
|
+
const cites = obj.cites && typeof obj.cites === 'object' ? obj.cites : {};
|
|
1424
|
+
out.cites = Object.entries(cites).filter(([, list]) => Array.isArray(list)).map(([label, list]) => ({
|
|
1425
|
+
label,
|
|
1426
|
+
items: /** @type {any[]} */ (list).map((c) => ({ claim: cellText(c?.claim), quote: cellText(c?.quote) })),
|
|
1427
|
+
}));
|
|
1428
|
+
}
|
|
1429
|
+
return out;
|
|
1430
|
+
});
|
|
1431
|
+
const end = records.findLast((r) => r && r.type === 'job-end');
|
|
1432
|
+
// a run that did not end green delivered nothing: say the check's own first gap (the Audit's try line words), never rows
|
|
1433
|
+
let gap = null;
|
|
1434
|
+
if (!end || end.outcome !== 'green') {
|
|
1435
|
+
const ev = records.findLast((r) => r && r.type === 'exit-eval' && Array.isArray(r.results) && r.results.some((/** @type {any} */ x) => x && x.type === 'check-passes' && x.pass === false));
|
|
1436
|
+
const bad = ev ? ev.results.find((/** @type {any} */ x) => x && x.type === 'check-passes' && x.pass === false) : null;
|
|
1437
|
+
const g = bad ? gapOneLine(bad.detail) : null;
|
|
1438
|
+
gap = g ? { line: g.line, more: g.more, moreText: g.more > 0 ? `(+${g.more} more)` : null } : null;
|
|
1439
|
+
}
|
|
1440
|
+
// the distinct web pages the results read, in line order: ONE = the page draws a single link at the top, 2+ = each line carries its own `page`
|
|
1441
|
+
const pages = [...new Set(lines.map((/** @type {any} */ l) => l.page).filter((/** @type {any} */ u) => typeof u === 'string'))];
|
|
1442
|
+
return {
|
|
1443
|
+
runid,
|
|
1444
|
+
lines,
|
|
1445
|
+
pages,
|
|
1446
|
+
pageNote: pages.length ? RESULT_PAGE_MAY_CHANGE : null,
|
|
1447
|
+
notChecked: bottom,
|
|
1448
|
+
// the door's words, from the one table (src/panel/status.js DOOR_WORDS); the page types none of them
|
|
1449
|
+
door: { head: DOOR_WORDS.waiting.head, ask: RESULT_DOOR_ASK },
|
|
1450
|
+
delivered: delivered.map((d, i) => ({
|
|
1451
|
+
i, path: d.path, name: basename(d.path), bytes: d.bytes, state: read[i].state,
|
|
1452
|
+
words: read[i].state === 'gone' ? RESULT_FILE_GONE : (read[i].state === 'changed' ? RESULT_FILE_CHANGED : null),
|
|
1453
|
+
})),
|
|
1454
|
+
gap,
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/** download content types by extension; anything else is plain bytes */
|
|
1459
|
+
const DOWNLOAD_TYPES = { '.json': 'application/json', '.md': 'text/plain', '.txt': 'text/plain', '.csv': 'text/csv' };
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* `GET /api/runs/:runid/download?i=<n>` — delivered file `n` (an index into the run's own `destination-written` list), or why not. The path is
|
|
1463
|
+
* the record's, never the request's; the sha256 must still match the record.
|
|
1464
|
+
* @param {string} runid @param {unknown} index @param {{ home?: string }} opts
|
|
1465
|
+
* @returns {{ok: true, name: string, type: string, buf: Buffer}|{ok: false, code: number, error: string}}
|
|
1466
|
+
*/
|
|
1467
|
+
export function getRunDownload(runid, index, opts = {}) {
|
|
1468
|
+
const row = findRunRow(runid, opts);
|
|
1469
|
+
if (!row || row.via === 'import' || !existsSync(row.spine)) return { ok: false, code: 404, error: 'no such run' };
|
|
1470
|
+
const delivered = deliveredOf(parseJsonl(row.spine).records);
|
|
1471
|
+
const i = typeof index === 'string' && /^\d{1,6}$/.test(index) ? Number(index) : -1;
|
|
1472
|
+
if (i < 0 || i >= delivered.length) return { ok: false, code: 404, error: 'this run saved no such file' };
|
|
1473
|
+
const got = readDelivered(delivered[i]);
|
|
1474
|
+
if (got.state !== 'ok') return { ok: false, code: 409, error: got.state === 'gone' ? RESULT_FILE_GONE : RESULT_FILE_CHANGED };
|
|
1475
|
+
const name = basename(delivered[i].path);
|
|
1476
|
+
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : '';
|
|
1477
|
+
return { ok: true, name, type: Object.hasOwn(DOWNLOAD_TYPES, ext) ? /** @type {Record<string,string>} */ (DOWNLOAD_TYPES)[ext] : 'application/octet-stream', buf: got.buf };
|
|
1478
|
+
}
|
|
1479
|
+
|
|
883
1480
|
/**
|
|
884
1481
|
* `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
|
|
885
1482
|
* iterations), counters, summary-box fields. Looks `runid` up in the run
|
|
@@ -887,25 +1484,95 @@ function findRunRow(runid, opts) {
|
|
|
887
1484
|
* header); `null` when the runid is not in the list at all (caller renders
|
|
888
1485
|
* 404).
|
|
889
1486
|
* @param {string} runid
|
|
890
|
-
* @param {{ home?: string }} [opts]
|
|
1487
|
+
* @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
|
|
891
1488
|
* @returns {any|null}
|
|
892
1489
|
*/
|
|
893
1490
|
export function getRunDetail(runid, opts = {}) {
|
|
894
1491
|
const d = getRunDetailBody(runid, opts);
|
|
895
1492
|
if (!d) return d;
|
|
896
|
-
const
|
|
897
|
-
|
|
1493
|
+
const { rows } = readRunList(opts);
|
|
1494
|
+
const nos = runNumbers(rows);
|
|
1495
|
+
const runNo = nos.get(runid);
|
|
1496
|
+
// T Part 7: what the Run tab shows of the job's schedule — the SERVER decides every state and word (the page works out nothing)
|
|
1497
|
+
const schedule = opts.schedules && typeof d.job === 'string' ? scheduleDetailFor(d.job, rows, nos, opts) : null;
|
|
1498
|
+
const extra = opts.schedules && typeof d.job === 'string' ? scheduleExtrasFor(runid, d.job, rows, nos, opts, schedule) : {};
|
|
1499
|
+
return { ...d, ...(runNo === undefined ? {} : { runNo }), ...(schedule ? { schedule: extra.schedule ?? schedule } : {}), ...(extra.audit ?? {}) };
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Aligned with fwdloop M9 am7: the SCHEDULE row's second line and the Audit's schedule rows, made on the server from the door's data.
|
|
1504
|
+
* `schedule` = the door's view with `line2`, `problem` (and no raw ledger row); `audit` = `{scheduleSection}` or, for a run a cancelled schedule started, `{startedAudit}`.
|
|
1505
|
+
* @param {string} runid @param {string} job @param {any[]} rows @param {Map<string, number>} nos @param {any} opts @param {any} view
|
|
1506
|
+
* @returns {{schedule?: any, audit?: {startedAudit?: string, scheduleSection?: any}}}
|
|
1507
|
+
*/
|
|
1508
|
+
function scheduleExtrasFor(runid, job, rows, nos, opts, view) {
|
|
1509
|
+
try {
|
|
1510
|
+
const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
|
|
1511
|
+
/** @type {any} */
|
|
1512
|
+
let schedule;
|
|
1513
|
+
if (view) {
|
|
1514
|
+
const { lastRow, nextWords, accept, ...rest } = view;
|
|
1515
|
+
schedule = { ...rest, line2: null };
|
|
1516
|
+
if (view.scheduled) {
|
|
1517
|
+
let lastWords = null;
|
|
1518
|
+
if (lastRow) {
|
|
1519
|
+
if (lastRow.kind === 'started') {
|
|
1520
|
+
const mine = rows.filter((r) => r && r.job === job).sort((a, b) => atMs(b) - atMs(a)).slice(0, 25);
|
|
1521
|
+
let hit = null; let fallback = null;
|
|
1522
|
+
for (const r of mine) {
|
|
1523
|
+
const rec = triggerRecordOf(r);
|
|
1524
|
+
if (!rec) continue;
|
|
1525
|
+
if (fallback === null) fallback = { r, rec };
|
|
1526
|
+
if (rec.sessionId && rec.sessionId === lastRow.sessionId) { hit = { r, rec }; break; }
|
|
1527
|
+
}
|
|
1528
|
+
const got = hit ?? fallback;
|
|
1529
|
+
const no = got ? nos.get(got.r.runid) : undefined;
|
|
1530
|
+
const word = got ? summarizeRow(got.r).status?.word : undefined;
|
|
1531
|
+
lastWords = `last ${dateTimeWords(Number(lastRow.at))}${no ? ` · run-${no}` : ''}${word ? ` · ${word}` : ''}${got?.rec.catchUp === true ? ' · catch-up' : ''}`;
|
|
1532
|
+
} else {
|
|
1533
|
+
lastWords = `last ${dateTimeWords(Number(lastRow.at))} · ${skippedWords(lastRow, true)}`;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
schedule.line2 = [lastWords ?? 'no run yet', accept ? SCHEDULE_WORDS.acceptWaiting : null, nextWords].filter(Boolean).join(' · ');
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
const row = rows.find((r) => r && r.runid === runid);
|
|
1540
|
+
const rec = row ? triggerRecordOf(row) : null;
|
|
1541
|
+
const cur = door.readSchedule(job);
|
|
1542
|
+
if (!cur) {
|
|
1543
|
+
// no schedule now: a run a cancelled schedule started keeps its one old line; a hand run has nothing
|
|
1544
|
+
const origin = rec ? (rec.originRunid ?? null) : null;
|
|
1545
|
+
return { ...(schedule ? { schedule } : {}), audit: rec ? { startedAudit: startedAuditWords(rec, origin ? (nos.get(origin) ?? null) : null) } : {} };
|
|
1546
|
+
}
|
|
1547
|
+
// RULED 2026-10-15 (v1.104): ONE `schedule` section on every run of a job that has a schedule now, folded when the page opens
|
|
1548
|
+
const originNo = nos.get(cur.originRunid) ?? null;
|
|
1549
|
+
const tries = door.skippedFor(job);
|
|
1550
|
+
const running = rows.some((r) => r && r.job === job && r.via !== 'import' && runIsAlive(r));
|
|
1551
|
+
const { sign, word } = scheduleSignFor({ running, red: !!(view && view.problem) });
|
|
1552
|
+
const words = scheduledWords(cur.trigger, door.userHome);
|
|
1553
|
+
const head = [lowerFirst(words ?? ''), originNo === null ? null : `from run-${originNo}`, triesTailWords(tries.length) || null].filter(Boolean).join(' · ');
|
|
1554
|
+
const when = row ? dateTimeWords(atMs(row)) : '';
|
|
1555
|
+
const thisRun = rec ? sectionThisRunWords(rec) : `by hand.${runid === cur.originRunid ? ' The schedule was set from this run.' : ''}`;
|
|
1556
|
+
const shown = tries.slice(0, 7).map((x, i) => ({ label: i === 0 ? 'tries' : '', text: x.line }));
|
|
1557
|
+
const scheduleSection = {
|
|
1558
|
+
title: SCHEDULE_WORDS.scheduleGroup, sign, word, head,
|
|
1559
|
+
rows: [{ label: 'this run', text: `${when} ${thisRun}` }, ...(shown.length > 0 ? shown : [{ label: 'tries', text: 'none' }])],
|
|
1560
|
+
};
|
|
1561
|
+
return { ...(schedule ? { schedule } : {}), audit: { scheduleSection } };
|
|
1562
|
+
} catch { return {}; }
|
|
898
1563
|
}
|
|
899
1564
|
|
|
900
1565
|
/**
|
|
901
1566
|
* @param {string} runid
|
|
902
|
-
* @param {{ home?: string }} opts
|
|
1567
|
+
* @param {{ home?: string, now?: number }} opts
|
|
903
1568
|
* @returns {any|null}
|
|
904
1569
|
*/
|
|
905
1570
|
function getRunDetailBody(runid, opts) {
|
|
906
1571
|
const row = findRunRow(runid, opts);
|
|
907
1572
|
if (!row) return null;
|
|
908
1573
|
if (!existsSync(row.spine)) {
|
|
1574
|
+
const refusal = refusalWordsOf(readRunList(opts).events, row);
|
|
1575
|
+
if (refusal !== null) return refusedStub(row, refusal);
|
|
909
1576
|
if (runIsAlive(row)) return startingStub(row);
|
|
910
1577
|
return {
|
|
911
1578
|
runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
|
|
@@ -927,7 +1594,8 @@ function getRunDetailBody(runid, opts) {
|
|
|
927
1594
|
// the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
|
|
928
1595
|
const draftingPartHere = draftingFor(row, summary, opts);
|
|
929
1596
|
const draftingShiftHere = draftingShift(row, summary, opts);
|
|
930
|
-
const
|
|
1597
|
+
const specForParts = resolveSpecForRow(row);
|
|
1598
|
+
const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(specForParts), specForParts);
|
|
931
1599
|
const death = deriveDeath(row, rawSpineRecords, summary.outcome);
|
|
932
1600
|
const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
|
|
933
1601
|
// judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
|
|
@@ -1091,6 +1759,12 @@ function getRunDetailBody(runid, opts) {
|
|
|
1091
1759
|
// numbers the Resume confirm box prefills (the signed caps, spent so far); `null`
|
|
1092
1760
|
// whenever Resume is not offered.
|
|
1093
1761
|
ended,
|
|
1762
|
+
// aligned with fwdloop M9 am7: `MM/DD HH:MM · <outcome word> · <reason>` (local end time); null while the run is live
|
|
1763
|
+
endedText: (() => {
|
|
1764
|
+
const ts = lastJobEndTs(rawSpineRecords) ?? (death.died ? lastRecordTs(rawSpineRecords) : null);
|
|
1765
|
+
const ms = ts === null ? NaN : Date.parse(ts);
|
|
1766
|
+
return ended && Number.isFinite(ms) ? `${dateTimeWords(ms)} · ${status.word} · ${ended.reason}` : null;
|
|
1767
|
+
})(),
|
|
1094
1768
|
// P5 item 5: is the run's LATEST leg live (the page offers Stop only then), and has a stop been asked for
|
|
1095
1769
|
// (the request file exists beside the spine) — the page reads "stopping after this turn…" until the leg ends
|
|
1096
1770
|
live: row.via !== 'import' && runIsAlive(row),
|
|
@@ -1174,7 +1848,33 @@ function getRunDetailBody(runid, opts) {
|
|
|
1174
1848
|
// carries no contamination risk); `null` when the spine carries no
|
|
1175
1849
|
// `memory-cache` record at all (never armed on this run).
|
|
1176
1850
|
behaviour: scopedBehaviour(row, rawSpineRecords),
|
|
1851
|
+
// PRD v1.106 Part 5 F: the two Run summary rows, worded here (the page prints them)
|
|
1852
|
+
callsLine: callsLineOf(rawSpineRecords, scopedBehaviour(row, rawSpineRecords)),
|
|
1853
|
+
checksLine: checksLineOf(rawSpineRecords, enrichedParts),
|
|
1177
1854
|
memoryCache: summary.memoryCache,
|
|
1855
|
+
// T Part 8: is this run waiting for the person's Accept? The SAME answer fills the runs list's Needs-you tag (`doorWaiting`, one owner)
|
|
1856
|
+
accept: !runIsAlive(row) && doorWaiting(rawSpineRecords),
|
|
1857
|
+
// PRD v1.107 Part 6: the Result tab shows for a rubric run whose signed close has a rubric-step stage, never otherwise; `delivered` is the
|
|
1858
|
+
// files the run's own `destination-written` records name (the Result tab's downloads index into this list, never a path from a request)
|
|
1859
|
+
hasResult: checkTypeLabel(summary.verdictType, row.at) === 'rubric' && rubricStagesOf(specForParts).length > 0,
|
|
1860
|
+
delivered: deliveredOf(rawSpineRecords).map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })),
|
|
1861
|
+
// T Part 7: why a SCHEDULED run started (the words `trigger.json` carried) and the fixed catch-up sentence; null on every hand run
|
|
1862
|
+
...(() => {
|
|
1863
|
+
const trig = triggerRecordOf(row);
|
|
1864
|
+
return { startedBy: trig && typeof trig.reason === 'string' ? trig.reason : null, catchUp: catchUpWords(trig) };
|
|
1865
|
+
})(),
|
|
1866
|
+
// RULED 2026-10-15 (Remove from Needs you): the server says whether the button shows (a Resume card only, never a live Accept) and the Audit line
|
|
1867
|
+
...(() => {
|
|
1868
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
1869
|
+
const live = !death.died && (summary.outcome === null || summary.outcome === undefined) && runIsAlive(row);
|
|
1870
|
+
const stoppedAt = live ? null : (lastJobEndTs(rawSpineRecords) ?? (death.died ? lastRecordTs(rawSpineRecords) : null));
|
|
1871
|
+
const st = needsStateFor({ live, records: rawSpineRecords, resume, stoppedAt, nowMs, removedMs: readNeedsRemovals(opts).get(row.runid) });
|
|
1872
|
+
/** @type {string|null} */
|
|
1873
|
+
let needsAudit = null;
|
|
1874
|
+
if (st.removedMs !== null) needsAudit = `removed from Needs you by you · ${dateTimeWords(st.removedMs)}`;
|
|
1875
|
+
else if (st.expired) needsAudit = `removed from Needs you after ${RUNS_LIST_LIMITS.needsYouDays} days`;
|
|
1876
|
+
return { canRemoveFromNeeds: st.needs === 'Resume', needsAudit };
|
|
1877
|
+
})(),
|
|
1178
1878
|
};
|
|
1179
1879
|
}
|
|
1180
1880
|
|
|
@@ -1594,7 +2294,7 @@ export const ROUNDS_PAGE_MAX = 200;
|
|
|
1594
2294
|
* resolve to a real window, or the spine file is missing — the caller
|
|
1595
2295
|
* renders 404, same posture as {@link getRunAudit}/{@link getRunJob}.
|
|
1596
2296
|
* @param {string} runid
|
|
1597
|
-
* @param {{ part?: number, attempt?: number, offset?: number, limit?: number }} query
|
|
2297
|
+
* @param {{ all?: boolean, part?: number, attempt?: number, offset?: number, limit?: number }} query
|
|
1598
2298
|
* @param {{ home?: string }} [opts]
|
|
1599
2299
|
* @returns {any|null}
|
|
1600
2300
|
*/
|
|
@@ -1616,6 +2316,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
|
|
|
1616
2316
|
if (shift && partIndex === 0) return null;
|
|
1617
2317
|
const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
|
|
1618
2318
|
if (!part) return null;
|
|
2319
|
+
// PRD v1.106 Part 5 (C): a plan step with graded tries shows ONE table for the whole step (a Try column, round numbers from 1 per step,
|
|
2320
|
+
// a check row after each try, 20 rows a page); `all=1` asks for it. Every other part keeps its per-attempt table.
|
|
2321
|
+
if (query.all === true && part.kind === 'step' && Array.isArray(part.attempts) && part.attempts.some((x) => Array.isArray(x.checks))) {
|
|
2322
|
+
return stepRoundsPage(runid, row, part, { partIndex, offset, limit: Number.isInteger(query.limit) && /** @type {number} */ (query.limit) > 0 ? Math.min(/** @type {number} */ (query.limit), ROUNDS_PAGE_MAX) : STEP_ROWS_PAGE }, opts);
|
|
2323
|
+
}
|
|
1619
2324
|
const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
|
|
1620
2325
|
if (!a) return null;
|
|
1621
2326
|
/** @type {{startSeq: number, endSeq: number, outcome: string|null, detail: string|null}} */
|
|
@@ -1677,6 +2382,90 @@ export function getRunRounds(runid, query = {}, opts = {}) {
|
|
|
1677
2382
|
};
|
|
1678
2383
|
}
|
|
1679
2384
|
|
|
2385
|
+
/** the Audit step table's page size (PRD v1.106 Part 5 choice 3): rows, not rounds */
|
|
2386
|
+
export const STEP_ROWS_PAGE = 20;
|
|
2387
|
+
|
|
2388
|
+
/** the Round table's check row: the sign and the plain words, ONE place (PRD v1.106 Part 5 C) @param {number} passed @param {number} failed */
|
|
2389
|
+
export function checkRowWords(passed, failed) {
|
|
2390
|
+
return failed > 0 ? { sign: '✗', text: `${failed} failed, ${passed} passed` } : { sign: '✓', text: `${passed} passed` };
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* One plan step's whole Round table (PRD v1.106 Part 5 C): every model call, every tool call and one check row per graded try, in order, each
|
|
2395
|
+
* with its Try; round numbers restart at 1 inside the step (the flat Audit view keeps the run-wide numbers). A page is `limit` ROWS.
|
|
2396
|
+
* @param {string} runid @param {any} row @param {any} part @param {{partIndex: number, offset: number, limit: number}} q @param {{ home?: string }} opts
|
|
2397
|
+
*/
|
|
2398
|
+
function stepRoundsPage(runid, row, part, q, opts) {
|
|
2399
|
+
const attempts = part.attempts.filter((/** @type {any} */ x) => Array.isArray(x.checks));
|
|
2400
|
+
const cut = part.cutOffTry ?? null;
|
|
2401
|
+
/** @type {Array<{n: number, startSeq: number, endSeq: number, a: any}>} */
|
|
2402
|
+
const windows = attempts.map((/** @type {any} */ a) => ({ n: a.n, startSeq: a.startSeq, endSeq: a.endSeq, a }));
|
|
2403
|
+
if (cut) windows.push({ n: cut.n, startSeq: cut.startSeq, endSeq: cut.endSeq, a: null });
|
|
2404
|
+
const lo = windows[0].startSeq;
|
|
2405
|
+
const hi = windows[windows.length - 1].endSeq;
|
|
2406
|
+
const tryOfSeq = (/** @type {number} */ seq) => windows.find((w) => seq > w.startSeq && seq <= w.endSeq)?.n ?? windows[windows.length - 1].n;
|
|
2407
|
+
const { records: spineRecords } = parseJsonl(row.spine);
|
|
2408
|
+
const roundOf = makeRoundLookup(spineRecords);
|
|
2409
|
+
const spend = spineRecords
|
|
2410
|
+
.filter((/** @type {any} */ r) => r && SPEND_RECORD_TYPES.includes(r.type) && typeof r.seq === 'number' && r.seq > lo && r.seq <= hi)
|
|
2411
|
+
.sort((/** @type {any} */ a, /** @type {any} */ b) => a.seq - b.seq);
|
|
2412
|
+
const auditResult = getRunAudit(runid, opts);
|
|
2413
|
+
const toolLogSaved = !!auditResult && auditResult.reason !== 'no-sidecar';
|
|
2414
|
+
/** @type {Map<number, any[]>} */
|
|
2415
|
+
const toolsByRound = new Map();
|
|
2416
|
+
if (toolLogSaved && auditResult) {
|
|
2417
|
+
for (const r of auditResult.rows) {
|
|
2418
|
+
if (r.kind !== 'tool-call' || typeof r.round !== 'number') continue;
|
|
2419
|
+
const bucket = toolsByRound.get(r.round) ?? [];
|
|
2420
|
+
bucket.push(r);
|
|
2421
|
+
toolsByRound.set(r.round, bucket);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
const spec = resolveSpecForRow(row);
|
|
2425
|
+
const exitTs = new Map(spineRecords.filter((/** @type {any} */ r) => r && r.type === 'exit-eval' && typeof r.seq === 'number').map((/** @type {any} */ r) => [r.seq, r.ts]));
|
|
2426
|
+
/** @type {Array<{seq: number, rows: any[]}>} */
|
|
2427
|
+
const blocks = [];
|
|
2428
|
+
spend.forEach((/** @type {any} */ r, i) => {
|
|
2429
|
+
const runWide = roundOf(r.ts);
|
|
2430
|
+
const tryN = tryOfSeq(r.seq);
|
|
2431
|
+
const local = i + 1;
|
|
2432
|
+
const modelCall = toolLogSaved && auditResult ? auditResult.rows.find((x) => x.kind === 'model-call' && x.round === runWide) : null;
|
|
2433
|
+
const rows = [{
|
|
2434
|
+
kind: 'model', round: local, try: tryN, time: typeof r.ts === 'string' ? r.ts : null,
|
|
2435
|
+
costUsd: typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? r.costUsd : null,
|
|
2436
|
+
tokens: typeof r.tokens === 'number' && Number.isFinite(r.tokens) ? r.tokens : null,
|
|
2437
|
+
durationMs: modelCall ? modelCall.durationMs : null,
|
|
2438
|
+
}];
|
|
2439
|
+
for (const tc of (runWide === null ? [] : (toolsByRound.get(runWide) ?? []))) rows.push({ ...tc, kind: 'tool', round: local, try: tryN });
|
|
2440
|
+
blocks.push({ seq: r.seq, rows });
|
|
2441
|
+
});
|
|
2442
|
+
for (const w of windows) {
|
|
2443
|
+
if (!w.a) continue; // a try cut off before its checks ran has no check row
|
|
2444
|
+
const passed = w.a.checks.filter((/** @type {any} */ c) => c.pass).length;
|
|
2445
|
+
const failed = w.a.checks.length - passed;
|
|
2446
|
+
const named = w.a.checks.find((/** @type {any} */ c) => c.type === 'check-passes');
|
|
2447
|
+
const judgedPath = named && named.name ? judgedFilesOf(spec?.closeDecl, named.name)[0] ?? null : null;
|
|
2448
|
+
blocks.push({
|
|
2449
|
+
seq: w.endSeq,
|
|
2450
|
+
rows: [{ kind: 'check', round: null, try: w.n, time: exitTs.get(w.endSeq) ?? null, path: judgedPath, ...checkRowWords(passed, failed) }],
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
blocks.sort((a, b) => a.seq - b.seq);
|
|
2454
|
+
const all = blocks.flatMap((b) => b.rows);
|
|
2455
|
+
return {
|
|
2456
|
+
runid,
|
|
2457
|
+
part: q.partIndex,
|
|
2458
|
+
partKind: 'step',
|
|
2459
|
+
mode: 'step',
|
|
2460
|
+
step: partDisplayLabel(part),
|
|
2461
|
+
rows: all.slice(q.offset, q.offset + q.limit),
|
|
2462
|
+
totalRows: all.length,
|
|
2463
|
+
offset: q.offset,
|
|
2464
|
+
limit: q.limit,
|
|
2465
|
+
toolLogSaved,
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
|
|
1680
2469
|
/**
|
|
1681
2470
|
* The `↳ close:`-style text for one iteration's OWN close-verdict — same
|
|
1682
2471
|
* shape as `src/replay.js`'s private `closeStageLine`, but that helper is
|
|
@@ -1930,7 +2719,7 @@ function stageQuestionText({
|
|
|
1930
2719
|
* ran. `null` when the spec has no `closeDecl.stages` array at all (a
|
|
1931
2720
|
* command-close spec, or no spec resolved).
|
|
1932
2721
|
* @param {any} spec
|
|
1933
|
-
* @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null}
|
|
2722
|
+
* @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>|null}
|
|
1934
2723
|
*/
|
|
1935
2724
|
function stageKindMetaFromSpec(spec) {
|
|
1936
2725
|
if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
|
|
@@ -1946,7 +2735,7 @@ function stageKindMetaFromSpec(spec) {
|
|
|
1946
2735
|
if (key !== null) suiteCmdKeys.add(key);
|
|
1947
2736
|
}
|
|
1948
2737
|
}
|
|
1949
|
-
/** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>} */
|
|
2738
|
+
/** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>} */
|
|
1950
2739
|
const map = new Map();
|
|
1951
2740
|
for (const s of spec.closeDecl.stages) {
|
|
1952
2741
|
if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
|
|
@@ -1958,12 +2747,21 @@ function stageKindMetaFromSpec(spec) {
|
|
|
1958
2747
|
kind, params, genre, suiteCmdKeys,
|
|
1959
2748
|
});
|
|
1960
2749
|
map.set(s.name, {
|
|
1961
|
-
kind, direction, baselineKind, question,
|
|
2750
|
+
kind, direction, baselineKind, question, fromLine: Array.isArray(s.fromLine) ? s.fromLine.filter(Number.isInteger) : [],
|
|
1962
2751
|
});
|
|
1963
2752
|
}
|
|
1964
2753
|
return map;
|
|
1965
2754
|
}
|
|
1966
2755
|
|
|
2756
|
+
/**
|
|
2757
|
+
* Stage name to the panel's plain question, off a signed spec — the SAME owner ({@link stageKindMetaFromSpec}) the step map and Audit use.
|
|
2758
|
+
* @param {any} spec
|
|
2759
|
+
* @returns {Map<string, string|null>}
|
|
2760
|
+
*/
|
|
2761
|
+
function stageQuestionsOfSpec(spec) {
|
|
2762
|
+
return new Map([...(stageKindMetaFromSpec(spec) ?? new Map())].map(([n, m]) => [n, m.question]));
|
|
2763
|
+
}
|
|
2764
|
+
|
|
1967
2765
|
/**
|
|
1968
2766
|
* Attaches one stage's `{kind, direction, baselineKind, question}` (from
|
|
1969
2767
|
* {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
|
|
@@ -2019,21 +2817,261 @@ function attachStageKind(stage, kindMeta) {
|
|
|
2019
2817
|
* @param {Map<string, any>|null} kindMeta
|
|
2020
2818
|
* @returns {any[]|null|undefined}
|
|
2021
2819
|
*/
|
|
2022
|
-
function enrichPartsWithStageKind(parts, kindMeta) {
|
|
2820
|
+
function enrichPartsWithStageKind(parts, kindMeta, spec = null) {
|
|
2023
2821
|
if (!Array.isArray(parts)) return parts;
|
|
2024
2822
|
const declaredStagesTotal = kindMeta ? kindMeta.size : null;
|
|
2025
2823
|
const declaredStages = kindMeta
|
|
2026
2824
|
? Array.from(kindMeta, ([name, meta]) => ({ name, question: meta.question }))
|
|
2027
2825
|
: null;
|
|
2028
|
-
return parts.map((part) =>
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2826
|
+
return parts.map((part) => {
|
|
2827
|
+
const attempts = Array.isArray(part.attempts) ? part.attempts.map((a) => {
|
|
2828
|
+
// PRD v1.106 Part 5 (D1): a plan step's attempt carries the checks its try RAN (replay), worded here; they also stand in as the attempt's
|
|
2829
|
+
// `stages`, which is what the page's headline and check list read (before this they were null, so every check read "not run")
|
|
2830
|
+
const checks = Array.isArray(a.checks) ? tryChecksOf(a.checks, kindMeta, spec) : null;
|
|
2831
|
+
return {
|
|
2832
|
+
...a,
|
|
2833
|
+
...(checks ? { checks, judged: (Array.isArray(a.outputJudged) ? a.outputJudged : []).map(judgedFileLine), stages: checks.map(stageOfCheck) } : { stages: (kindMeta && Array.isArray(a.stages)) ? a.stages.map((s) => attachStageKind(s, kindMeta)) : a.stages }),
|
|
2834
|
+
...(declaredStagesTotal ? { declaredStagesTotal } : {}),
|
|
2835
|
+
...(declaredStages ? { declaredStages } : {}),
|
|
2836
|
+
};
|
|
2837
|
+
}) : part.attempts;
|
|
2838
|
+
return {
|
|
2839
|
+
...part,
|
|
2840
|
+
attempts,
|
|
2841
|
+
...(part.kind === 'step' ? (() => { const tries = triesOf(attempts, part.cutOffTry); return { tries, triesLine: triesLineOf(tries), marker: ACTOR_MARKER.model, tally: toolsTallyLine(part.byTool, part.blocked) }; })() : {}),
|
|
2842
|
+
...(part.kind === 'door' && part.door ? { marker: ACTOR_MARKER.human, doorView: doorViewOf(part.door) } : {}),
|
|
2843
|
+
};
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
/** the words for a try the wall (or a cap, or a stop) ended before any of its checks ran (D2) */
|
|
2848
|
+
const CUT_OFF_WORDS = 'cut off before its checks ran';
|
|
2849
|
+
|
|
2850
|
+
/**
|
|
2851
|
+
* "71 model · 91 tool · 0 denied" — the Run summary's `calls:` row (PRD v1.106 Part 5 F). Model calls are the run's own spend records
|
|
2852
|
+
* (`worker-round`, `judge-round`; the attempt-level `worker-result` echoes are not rounds and drafting is not the run's), tool and denied come
|
|
2853
|
+
* from the gate-audit rows scoped to this run (the same figure `tools:` shows). No tool log saved says so, never a zero.
|
|
2854
|
+
* @param {any[]} records the run's raw spine @param {{totalCalls: number, denied: number}|null} behaviour
|
|
2855
|
+
* @returns {string}
|
|
2856
|
+
*/
|
|
2857
|
+
export function callsLineOf(records, behaviour) {
|
|
2858
|
+
const model = records.filter((r) => r && (r.type === 'worker-round' || r.type === 'judge-round')).length;
|
|
2859
|
+
return behaviour
|
|
2860
|
+
? `${model} model · ${behaviour.totalCalls} tool · ${behaviour.denied} denied`
|
|
2861
|
+
: `${model} model · tool calls: no log saved`;
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
/** "1-5", "2", "1-2, 4" from try numbers @param {number[]} ns */
|
|
2865
|
+
function tryRanges(ns) {
|
|
2866
|
+
const out = [];
|
|
2867
|
+
for (let i = 0; i < ns.length; i += 1) {
|
|
2868
|
+
let j = i;
|
|
2869
|
+
while (j + 1 < ns.length && ns[j + 1] === ns[j] + 1) j += 1;
|
|
2870
|
+
out.push(j > i ? `${ns[i]}-${ns[j]}` : String(ns[i]));
|
|
2871
|
+
i = j;
|
|
2872
|
+
}
|
|
2873
|
+
return out.join(', ');
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
/**
|
|
2877
|
+
* "16 run · 8 passed · 8 failed (write-flight-facts tries 1-5, then tries 1-3)" — counts of what ran, never a fraction (the "checks N/M"
|
|
2878
|
+
* headline stays withdrawn). Every `exit-eval` result plus every graded `outer-close` stage; the parenthesis names the steps and try ranges
|
|
2879
|
+
* that failed, from the same `tries[]` the Audit reads. A run with neither says `not recorded`.
|
|
2880
|
+
* @param {any[]} records the run's raw spine @param {any[]|null|undefined} parts the enriched parts
|
|
2881
|
+
* @returns {string}
|
|
2882
|
+
*/
|
|
2883
|
+
export function checksLineOf(records, parts) {
|
|
2884
|
+
let run = 0;
|
|
2885
|
+
let passed = 0;
|
|
2886
|
+
let outerFailed = false;
|
|
2887
|
+
for (const r of records) {
|
|
2888
|
+
if (!r) continue;
|
|
2889
|
+
if (r.type === 'exit-eval' && Array.isArray(r.results)) {
|
|
2890
|
+
for (const x of r.results) { run += 1; if (x && x.pass === true) passed += 1; }
|
|
2891
|
+
} else if (r.type === 'outer-close' && Array.isArray(r.stages)) {
|
|
2892
|
+
for (const x of r.stages) { run += 1; if (x && x.verdict === 'satisfied') passed += 1; else outerFailed = true; }
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
if (run === 0) return 'not recorded';
|
|
2896
|
+
/** @type {string[]} */
|
|
2897
|
+
const named = [];
|
|
2898
|
+
/** @type {Set<string>} */
|
|
2899
|
+
const seenLabel = new Set();
|
|
2900
|
+
for (const p of parts ?? []) {
|
|
2901
|
+
if (p.kind !== 'step' || !Array.isArray(p.tries)) continue;
|
|
2902
|
+
const bad = p.tries.filter((/** @type {any} */ t) => t.failed > 0).map((/** @type {any} */ t) => t.n);
|
|
2903
|
+
if (bad.length === 0) continue;
|
|
2904
|
+
const text = `tries ${tryRanges(bad)}`;
|
|
2905
|
+
named.push(seenLabel.has(p.label) ? `then ${text}` : `${p.label} ${text}`);
|
|
2906
|
+
seenLabel.add(p.label);
|
|
2907
|
+
}
|
|
2908
|
+
if (outerFailed) named.push('the outer close');
|
|
2909
|
+
const failed = run - passed;
|
|
2910
|
+
return `${run} run · ${passed} passed · ${failed} failed${named.length ? ` (${named.join(', ')})` : ''}`;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
/** 1 -> "1st", 4 -> "4th", 12 -> "12th" @param {number} n */
|
|
2914
|
+
function ordinal(n) {
|
|
2915
|
+
const v = n % 100;
|
|
2916
|
+
if (v >= 11 && v <= 13) return `${n}th`;
|
|
2917
|
+
return `${n}${({ 1: 'st', 2: 'nd', 3: 'rd' })[/** @type {1|2|3} */ (n % 10)] ?? 'th'}`;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
/**
|
|
2921
|
+
* The step card's extra line (PRD v1.106 Part 5 G): "5 tries · last try: 1 check passed, 1 failed", with the cut-off try named when there is one
|
|
2922
|
+
* ("4 tries (the 4th cut off before its checks ran) · last try: no checks ran"). Words for what passed and failed, never a fraction. `null`
|
|
2923
|
+
* when the step has no graded try (an old spine).
|
|
2924
|
+
* @param {Array<{n: number, cutOff: boolean, passed: number, failed: number, ran: number}>} tries
|
|
2925
|
+
* @returns {string|null}
|
|
2926
|
+
*/
|
|
2927
|
+
export function triesLineOf(tries) {
|
|
2928
|
+
if (!Array.isArray(tries) || tries.length === 0) return null;
|
|
2929
|
+
const last = tries[tries.length - 1];
|
|
2930
|
+
const count = `${tries.length} ${tries.length === 1 ? 'try' : 'tries'}${last.cutOff ? ` (the ${ordinal(last.n)} ${CUT_OFF_WORDS})` : ''}`;
|
|
2931
|
+
const checks = (/** @type {number} */ n) => `${n} ${n === 1 ? 'check' : 'checks'}`;
|
|
2932
|
+
let lastWords;
|
|
2933
|
+
if (last.cutOff || last.ran === 0) lastWords = 'no checks ran';
|
|
2934
|
+
else if (last.failed === 0) lastWords = `${checks(last.passed)} passed`;
|
|
2935
|
+
else if (last.passed === 0) lastWords = `${checks(last.failed)} failed`;
|
|
2936
|
+
else lastWords = `${checks(last.passed)} passed, ${last.failed} failed`;
|
|
2937
|
+
return `${count} · last try: ${lastWords}`;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/** tool kinds in the order the Audit tally lists them; anything else follows alphabetically */
|
|
2941
|
+
const TALLY_ORDER = ['read', 'grep', 'write', 'edit'];
|
|
2942
|
+
|
|
2943
|
+
/**
|
|
2944
|
+
* "tools: read 16 · grep 22 · write 5 · denied 0" — the step's tool calls by kind, then how many the gate denied (PRD v1.106 Part 5 C). The
|
|
2945
|
+
* `shell_`/`ctx_` prefixes are stripped like the rest of the page. `null` when no tool log was saved (nothing honest to count).
|
|
2946
|
+
* @param {Record<string, number>|null|undefined} byTool @param {number|null|undefined} blocked
|
|
2947
|
+
* @returns {string|null}
|
|
2948
|
+
*/
|
|
2949
|
+
export function toolsTallyLine(byTool, blocked) {
|
|
2950
|
+
if (!byTool || typeof byTool !== 'object') return null;
|
|
2951
|
+
const named = Object.entries(byTool).map(([k, n]) => [k.replace(/^shell_/, '').replace(/^ctx_/, ''), n]);
|
|
2952
|
+
named.sort((a, b) => {
|
|
2953
|
+
const ia = TALLY_ORDER.indexOf(String(a[0]));
|
|
2954
|
+
const ib = TALLY_ORDER.indexOf(String(b[0]));
|
|
2955
|
+
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || String(a[0]).localeCompare(String(b[0]));
|
|
2956
|
+
});
|
|
2957
|
+
return `tools: ${[...named.map(([k, n]) => `${k} ${n}`), `denied ${typeof blocked === 'number' ? blocked : 0}`].join(' · ')}`;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/**
|
|
2961
|
+
* A step's tries in order: each graded try (it ran checks) and, last, the one cut off before its checks ran (D2). `ran` is how many checks ran,
|
|
2962
|
+
* `passed`/`failed` how they went; a cut-off try ran none. The Audit, the step card and the Run summary all read this one list.
|
|
2963
|
+
* @param {any[]} attempts @param {any} cutOffTry
|
|
2964
|
+
* @returns {Array<{words?: string, n: number, cutOff: boolean, outcome: string|null, iteration: number|null, ran: number, passed: number, failed: number, thinking: string|null}>}
|
|
2965
|
+
*/
|
|
2966
|
+
function triesOf(attempts, cutOffTry) {
|
|
2967
|
+
const graded = (Array.isArray(attempts) ? attempts : []).filter((a) => Array.isArray(a.checks)).map((a) => ({
|
|
2968
|
+
n: a.n,
|
|
2969
|
+
cutOff: false,
|
|
2970
|
+
outcome: a.outcome ?? null,
|
|
2971
|
+
iteration: typeof a.iteration === 'number' ? a.iteration : null,
|
|
2972
|
+
ran: a.checks.length,
|
|
2973
|
+
passed: a.checks.filter((/** @type {any} */ c) => c.pass).length,
|
|
2974
|
+
failed: a.checks.filter((/** @type {any} */ c) => !c.pass).length,
|
|
2975
|
+
thinking: typeof a.thinking === 'string' ? a.thinking : null,
|
|
2036
2976
|
}));
|
|
2977
|
+
if (!cutOffTry) return graded;
|
|
2978
|
+
return [...graded, {
|
|
2979
|
+
words: CUT_OFF_WORDS, n: graded.length + 1, cutOff: true, outcome: null, iteration: typeof cutOffTry.iteration === 'number' ? cutOffTry.iteration : null, ran: 0, passed: 0, failed: 0, thinking: typeof cutOffTry.thinking === 'string' ? cutOffTry.thinking : null,
|
|
2980
|
+
}];
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
/** the words for a `check-passes` result that no `check-run` named (an old spine), and the one tree-changed question, both code-owned here */
|
|
2984
|
+
const FILES_CHANGED_QUESTION = 'did it change any file?';
|
|
2985
|
+
|
|
2986
|
+
/**
|
|
2987
|
+
* A rule line's failing reasons, one per `close: <stage>: …` line of a check's detail, each with that prefix (and the `line N: ` the check row
|
|
2988
|
+
* already names) removed. A detail with no `close:` lines is one reason: its own first line.
|
|
2989
|
+
* @param {string|null|undefined} detail
|
|
2990
|
+
* @returns {string[]}
|
|
2991
|
+
*/
|
|
2992
|
+
function reasonsOf(detail) {
|
|
2993
|
+
if (typeof detail !== 'string' || detail.trim() === '') return [];
|
|
2994
|
+
const lines = detail.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
2995
|
+
const closeLines = lines.filter((l) => l.startsWith('close: '));
|
|
2996
|
+
if (closeLines.length === 0) return [lines[0]];
|
|
2997
|
+
return closeLines.map((l) => l.replace(/^close: [^:]+: /, '').replace(/^line \d+: /, ''));
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
/**
|
|
3001
|
+
* The try line's gap: the first failing reason, and how many more there are (PRD v1.106 Part 5 C). ONE function, beside {@link stageQuestionText}.
|
|
3002
|
+
* @param {string|null|undefined} detail
|
|
3003
|
+
* @returns {{ line: string, more: number }|null}
|
|
3004
|
+
*/
|
|
3005
|
+
export function gapOneLine(detail) {
|
|
3006
|
+
const reasons = reasonsOf(detail);
|
|
3007
|
+
return reasons.length === 0 ? null : { line: reasons[0], more: reasons.length - 1 };
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
/**
|
|
3011
|
+
* "from line 1 ~ under $450; not a red-eye" — the job line(s) a check serves and the `~` rules written under them (the signed `jobLines`).
|
|
3012
|
+
* `null` when the stage serves no job line (a mandatory guard).
|
|
3013
|
+
* @param {number[]} fromLine @param {any} spec
|
|
3014
|
+
* @returns {string|null}
|
|
3015
|
+
*/
|
|
3016
|
+
export function fromLineText(fromLine, spec) {
|
|
3017
|
+
if (!Array.isArray(fromLine) || fromLine.length === 0) return null;
|
|
3018
|
+
const lines = Array.isArray(spec?.jobLines) ? spec.jobLines : [];
|
|
3019
|
+
return fromLine.map((n) => {
|
|
3020
|
+
const rule = lines.find((l) => l && l.n === n)?.rule;
|
|
3021
|
+
return `from line ${n}` + (typeof rule === 'string' && rule !== '' ? ` ~ ${rule}` : '');
|
|
3022
|
+
}).join(' · ');
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
/**
|
|
3026
|
+
* One try's checks in the order they RAN (choice 6), worded: `{n, type, name, question, pass, detail, gap, more, reasons, from}`.
|
|
3027
|
+
* A `tree-changed` result is the declared `files-changed` stage; a `check-passes` result is the stage its `check-run` named.
|
|
3028
|
+
* @param {Array<{type: string, pass: boolean, detail: string|null, name: string|null}>} checks
|
|
3029
|
+
* @param {Map<string, any>|null} kindMeta @param {any} spec
|
|
3030
|
+
*/
|
|
3031
|
+
function tryChecksOf(checks, kindMeta, spec) {
|
|
3032
|
+
const metas = kindMeta ? [...kindMeta] : [];
|
|
3033
|
+
const filesChanged = metas.find(([, m]) => m.kind === 'files-changed') ?? null;
|
|
3034
|
+
const others = metas.filter(([, m]) => m.kind !== 'files-changed');
|
|
3035
|
+
return checks.map((c, i) => {
|
|
3036
|
+
let name = c.name;
|
|
3037
|
+
let meta = name && kindMeta ? kindMeta.get(name) ?? null : null;
|
|
3038
|
+
if (c.type === 'tree-changed' && filesChanged) { name = filesChanged[0]; meta = filesChanged[1]; }
|
|
3039
|
+
if (c.type === 'check-passes' && !meta && others.length === 1) { name = others[0][0]; meta = others[0][1]; }
|
|
3040
|
+
const reasons = c.pass ? [] : reasonsOf(c.detail);
|
|
3041
|
+
const gap = c.pass ? null : gapOneLine(c.detail);
|
|
3042
|
+
return {
|
|
3043
|
+
n: i + 1,
|
|
3044
|
+
type: c.type,
|
|
3045
|
+
name: name ?? c.type,
|
|
3046
|
+
question: c.type === 'tree-changed' ? FILES_CHANGED_QUESTION : (meta?.question ?? null),
|
|
3047
|
+
pass: c.pass,
|
|
3048
|
+
// a files-changed result names its count; a failing check-passes names its reasons (folded on the page), a passing one nothing
|
|
3049
|
+
note: c.type === 'tree-changed' ? c.detail : (c.pass ? null : `${reasons.length} reason${reasons.length === 1 ? '' : 's'}`),
|
|
3050
|
+
gap: gap ? gap.line : null,
|
|
3051
|
+
more: gap ? gap.more : 0,
|
|
3052
|
+
moreText: gap && gap.more > 0 ? `(+${gap.more} more)` : null,
|
|
3053
|
+
reasonsLabel: reasons.length > 1 ? `the ${reasons.length} reasons` : null,
|
|
3054
|
+
reasons,
|
|
3055
|
+
from: meta ? fromLineText(meta.fromLine, spec) : null,
|
|
3056
|
+
};
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
/**
|
|
3061
|
+
* "output judged: output/steps/1.json · 2.1 KB · sha256 674b…30" — the file a try's check read (PRD v1.106 Part 5 choice 14). ONE place owns
|
|
3062
|
+
* these words. `size` is bytes under 1 KB, else KB with one decimal; the hash shows its first 4 and last 2 characters.
|
|
3063
|
+
* @param {{path: string|null, bytes: number|null, sha256: string|null}} o
|
|
3064
|
+
* @returns {string}
|
|
3065
|
+
*/
|
|
3066
|
+
export function judgedFileLine(o) {
|
|
3067
|
+
const size = typeof o.bytes === 'number' ? (o.bytes < 1024 ? `${o.bytes} B` : `${(o.bytes / 1024).toFixed(1)} KB`) : null;
|
|
3068
|
+
const sha = typeof o.sha256 === 'string' && o.sha256.length >= 8 ? `sha256 ${o.sha256.slice(0, 4)}…${o.sha256.slice(-2)}` : null;
|
|
3069
|
+
return ['output judged:', [o.path ?? 'a file', size, sha].filter(Boolean).join(' · ')].join(' ');
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
/** a worded check as the `{name, verdict, question}` stage shape the attempt headline reads */
|
|
3073
|
+
function stageOfCheck(c) {
|
|
3074
|
+
return { name: c.name, verdict: c.pass ? 'satisfied' : 'needs_revision', question: c.question };
|
|
2037
3075
|
}
|
|
2038
3076
|
|
|
2039
3077
|
/**
|
|
@@ -2185,10 +3223,50 @@ function jobsDir() {
|
|
|
2185
3223
|
* from the resolved SPEC only (a) (b) (c) — a job-start record and `none()`
|
|
2186
3224
|
* carry no close/writeScope/tools at all, so those stay `'not recorded'`.
|
|
2187
3225
|
* @param {string} runid
|
|
2188
|
-
* @param {{ home?: string }} [opts]
|
|
3226
|
+
* @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
|
|
2189
3227
|
* @returns {any|null}
|
|
2190
3228
|
*/
|
|
2191
3229
|
export function getRunJob(runid, opts = {}) {
|
|
3230
|
+
const body = withThinkingRow(getRunJobBody(runid, opts), runid, opts);
|
|
3231
|
+
if (!body || !opts.schedules) return body;
|
|
3232
|
+
try {
|
|
3233
|
+
const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
|
|
3234
|
+
const { rows } = readRunList(opts);
|
|
3235
|
+
const row = rows.find((r) => r && r.runid === runid);
|
|
3236
|
+
const job = row ? row.job : body.job;
|
|
3237
|
+
const nos = runNumbers(rows);
|
|
3238
|
+
const cur = door.readSchedule(job);
|
|
3239
|
+
const started = startedWords(row ? triggerRecordOf(row) : null, row ? atMs(row) : null);
|
|
3240
|
+
const spec = cur ? cur.spec : null;
|
|
3241
|
+
const schedule = jobScheduleWords({
|
|
3242
|
+
trigger: cur ? cur.trigger : null, originNo: cur ? (nos.get(cur.originRunid) ?? null) : null,
|
|
3243
|
+
model: spec && typeof spec.model === 'string' ? spec.model : null,
|
|
3244
|
+
destination: cur ? (typeof cur.destination === 'string' && cur.destination !== '' ? cur.destination : (Array.isArray(spec?.writeScope) ? spec.writeScope.join(', ') : null)) : null,
|
|
3245
|
+
home: door.userHome,
|
|
3246
|
+
});
|
|
3247
|
+
return { ...body, started, schedule };
|
|
3248
|
+
} catch { return body; }
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
/**
|
|
3252
|
+
* PRD v1.106 Part 3 — the Job tab's `Thinking` row, worded by src/thinking.js and ALWAYS present (hamr 2026-10-18). The rule shown is
|
|
3253
|
+
* the job's signed "Thinking on" tick for a run whose spine recorded thinking decisions, `—` for a run from before the thinking rule; a tick that cannot be read counts as not ticked (the default), never an empty row.
|
|
3254
|
+
* @param {any|null} body the Job tab body @param {string} runid @param {{ home?: string }} opts
|
|
3255
|
+
* @returns {any|null}
|
|
3256
|
+
*/
|
|
3257
|
+
function withThinkingRow(body, runid, opts) {
|
|
3258
|
+
if (!body) return body;
|
|
3259
|
+
const { thinkingTick, ...rest } = body;
|
|
3260
|
+
let recorded = false;
|
|
3261
|
+
try {
|
|
3262
|
+
const row = findRunRow(runid, opts);
|
|
3263
|
+
recorded = !!row && existsSync(row.spine) && parseJsonl(row.spine).records.some((/** @type {any} */ r) => r?.type === 'thinking-decision');
|
|
3264
|
+
} catch { /* an unreadable spine has no records: the row reads — */ }
|
|
3265
|
+
return { ...rest, thinking: thinkingJobText(thinkingTick === true, recorded) };
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
/** @param {string} runid @param {{ home?: string }} opts */
|
|
3269
|
+
function getRunJobBody(runid, opts) {
|
|
2192
3270
|
const row = findRunRow(runid, opts);
|
|
2193
3271
|
if (!row) return null;
|
|
2194
3272
|
|
|
@@ -2262,6 +3340,7 @@ export function getRunJob(runid, opts = {}) {
|
|
|
2262
3340
|
guardrails: notRecorded(guardrailsFromSpec(spec)),
|
|
2263
3341
|
tools: notRecorded(toolsFromSpec(spec)),
|
|
2264
3342
|
toolsList: toolsListFromSpec(spec),
|
|
3343
|
+
thinkingTick: spec.thinking === true,
|
|
2265
3344
|
note,
|
|
2266
3345
|
});
|
|
2267
3346
|
const none = () => ({
|
|
@@ -2283,6 +3362,7 @@ export function getRunJob(runid, opts = {}) {
|
|
|
2283
3362
|
guardrails: 'not recorded',
|
|
2284
3363
|
tools: 'not recorded',
|
|
2285
3364
|
toolsList: null,
|
|
3365
|
+
thinkingTick: false,
|
|
2286
3366
|
note: 'no resolvable spec for this run (only bareloop-run bundle-layout runs carry one; a run-u run has none on disk)',
|
|
2287
3367
|
});
|
|
2288
3368
|
|
|
@@ -2497,14 +3577,14 @@ export function reuseLine(record) {
|
|
|
2497
3577
|
|
|
2498
3578
|
/**
|
|
2499
3579
|
* `start-from` (the route keeps its name) for one listed run: the card prefilled from the run's SIGNED job, which
|
|
2500
|
-
*
|
|
3580
|
+
* the track record (v1.101: nothing is locked, every box is open). The job boxes carry the signed values, read by the
|
|
2501
3581
|
* SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
|
|
2502
3582
|
* $ cap and the Time cap — start at what the run had. `ok:false` when the run's signed job is not on disk (nothing
|
|
2503
3583
|
* to copy: change the job: Clear the card). `null` when the runid is not listed.
|
|
2504
3584
|
* @param {string} runid
|
|
2505
3585
|
* @param {{ home?: string, model?: string, provider?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
|
|
2506
3586
|
* @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
|
|
2507
|
-
*
|
|
3587
|
+
* note: string|null,
|
|
2508
3588
|
* specHash: string, workflowKey: string, spec: any, specPath: string, specNamesModel: boolean,
|
|
2509
3589
|
* specWorker: {provider: string, endpoint: string, label: string},
|
|
2510
3590
|
* trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
@@ -2537,6 +3617,9 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2537
3617
|
source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
|
|
2538
3618
|
// a card saved by P7 or later keeps the Inputs text the person typed
|
|
2539
3619
|
inputs: typeof saved.inputs === 'string' && saved.inputs ? saved.inputs : '',
|
|
3620
|
+
// a plain-files job delivers to an absolute folder the SPEC does not hold (its fence is `output/` inside the run's tree): the
|
|
3621
|
+
// Destination opens with the folder the origin run was given, never with that fence
|
|
3622
|
+
destination: [saved.destination, manifest?.destination].find((d) => typeof d === 'string' && /^(\/|[a-zA-Z]:[\\/])/.test(d)) ?? '',
|
|
2540
3623
|
jobName: row.job,
|
|
2541
3624
|
});
|
|
2542
3625
|
const key = workflowKey(spec);
|
|
@@ -2544,12 +3627,14 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2544
3627
|
return {
|
|
2545
3628
|
ok: true,
|
|
2546
3629
|
origin: { runid, job: row.job },
|
|
2547
|
-
// the run's terminal outcome — the Reuse start (POST /api/author/start)
|
|
2548
|
-
|
|
3630
|
+
// the run's terminal outcome — the Reuse start (POST /api/author/start) reuses a green or already-green run only.
|
|
3631
|
+
// T D.2: a SCHEDULED run's green reads `green-held` until the person accepts it, so that same refusal holds it unchanged
|
|
3632
|
+
outcome: (() => {
|
|
3633
|
+
const o = legsOf(records).at(-1)?.outcome ?? null;
|
|
3634
|
+
return o === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : o;
|
|
3635
|
+
})(),
|
|
2549
3636
|
card,
|
|
2550
3637
|
from: 'signed job',
|
|
2551
|
-
locked: REUSE_LOCKED_FIELDS,
|
|
2552
|
-
open: REUSE_OPEN_FIELDS,
|
|
2553
3638
|
note: Array.isArray(spec.jobLines) && spec.jobLines.length ? null : 'older job — numbered lines were not saved',
|
|
2554
3639
|
specHash: signed.specHash,
|
|
2555
3640
|
workflowKey: key,
|
|
@@ -2568,6 +3653,29 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2568
3653
|
};
|
|
2569
3654
|
}
|
|
2570
3655
|
|
|
3656
|
+
/**
|
|
3657
|
+
* T Part 3: a job's listed runs, newest first, each with its signed spec (null when not on disk), its terminal outcome and
|
|
3658
|
+
* its parsed spine. The schedule door's gate (`greenFor`) reads this; nothing here builds a run context.
|
|
3659
|
+
* @param {string} job
|
|
3660
|
+
* @param {{ home?: string }} [opts]
|
|
3661
|
+
* @returns {{runid: string, outcome: string|null, spec: any, records: any[]}[]}
|
|
3662
|
+
*/
|
|
3663
|
+
export function jobRunsOf(job, opts = {}) {
|
|
3664
|
+
const { rows } = readRunList(opts);
|
|
3665
|
+
/** @type {{runid: string, outcome: string|null, spec: any, records: any[]}[]} */
|
|
3666
|
+
const out = [];
|
|
3667
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
3668
|
+
const row = rows[i];
|
|
3669
|
+
if (!row || row.job !== job || !existsSync(row.spine)) continue;
|
|
3670
|
+
let records;
|
|
3671
|
+
try { records = parseJsonl(row.spine).records; } catch { continue; }
|
|
3672
|
+
const outcome = legsOf(records).at(-1)?.outcome ?? null;
|
|
3673
|
+
// T D.2: a scheduled run's green is HELD until accepted, so it is not a good run for the schedule gate either
|
|
3674
|
+
out.push({ runid: row.runid, outcome: outcome === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : outcome, spec: signedSpecForRun(row, records)?.spec ?? null, records });
|
|
3675
|
+
}
|
|
3676
|
+
return out;
|
|
3677
|
+
}
|
|
3678
|
+
|
|
2571
3679
|
/**
|
|
2572
3680
|
* The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
|
|
2573
3681
|
* that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
|
|
@@ -2587,9 +3695,9 @@ export function listReuseJobs(opts = {}) {
|
|
|
2587
3695
|
let records;
|
|
2588
3696
|
try { records = parseJsonl(row.spine).records; } catch { continue; }
|
|
2589
3697
|
const outcome = legsOf(records).at(-1)?.outcome ?? null;
|
|
2590
|
-
//
|
|
2591
|
-
|
|
2592
|
-
if (
|
|
3698
|
+
// a real green or an already-green (hamr 2026-10-15: if it is already fixed and nothing is to be done, it is green and can be reused)
|
|
3699
|
+
if (outcome !== 'green' && outcome !== 'already-green') continue;
|
|
3700
|
+
if (heldScheduled(records, triggerRecordOf(row))) continue; // T D.2: a scheduled green waits for the person's Accept
|
|
2593
3701
|
const signed = signedSpecForRun(row, records);
|
|
2594
3702
|
if (!signed) continue;
|
|
2595
3703
|
const key = workflowKey(signed.spec);
|
|
@@ -2606,7 +3714,7 @@ export function listReuseJobs(opts = {}) {
|
|
|
2606
3714
|
* The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
|
|
2607
3715
|
* the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
|
|
2608
3716
|
* @param {any} spec
|
|
2609
|
-
* @param {{model: string, source: string, inputs?: string, jobName: string}} extra `inputs` = the Inputs text the person saved (else built from the spec's inputs, else `repo: <source>`)
|
|
3717
|
+
* @param {{model: string, source: string, inputs?: string, jobName: string, destination?: string}} extra `destination` = an absolute per-run folder (a plain-files job), else the spec's fence; `inputs` = the Inputs text the person saved (else built from the spec's inputs, else `repo: <source>`)
|
|
2610
3718
|
* @returns {Record<string, any>}
|
|
2611
3719
|
*/
|
|
2612
3720
|
function reuseCardFromSpec(spec, extra) {
|
|
@@ -2616,8 +3724,9 @@ function reuseCardFromSpec(spec, extra) {
|
|
|
2616
3724
|
checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
|
|
2617
3725
|
model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
|
|
2618
3726
|
jobText: jobTextFromSpec(spec),
|
|
3727
|
+
thinking: spec.thinking === true,
|
|
2619
3728
|
inputs: extra.inputs || inputsTextFromSpec(spec, extra.source),
|
|
2620
|
-
destination: writeScope.join(', '),
|
|
3729
|
+
destination: extra.destination || writeScope.join(', '),
|
|
2621
3730
|
capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
|
|
2622
3731
|
maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
|
|
2623
3732
|
};
|
|
@@ -2656,7 +3765,7 @@ function describeSpec(spec) {
|
|
|
2656
3765
|
* @param {string} id
|
|
2657
3766
|
* @param {{ home?: string, model?: string, provider?: string }} [opts] `model` as for {@link getStartFrom}
|
|
2658
3767
|
* @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
|
|
2659
|
-
*
|
|
3768
|
+
* note: string|null, specHash: string, workflowKey: string, spec: any,
|
|
2660
3769
|
* specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
2661
3770
|
*/
|
|
2662
3771
|
export function getStartFromImport(id, opts = {}) {
|
|
@@ -2682,8 +3791,6 @@ export function getStartFromImport(id, opts = {}) {
|
|
|
2682
3791
|
origin: { runid: null, job: row.job, importId: id },
|
|
2683
3792
|
card,
|
|
2684
3793
|
from: 'imported job',
|
|
2685
|
-
locked: REUSE_LOCKED_FIELDS,
|
|
2686
|
-
open: REUSE_OPEN_FIELDS,
|
|
2687
3794
|
note: 'a Source is needed — an exported job carries none; it is checked and signed on this machine',
|
|
2688
3795
|
specHash: jobSpecHash(spec),
|
|
2689
3796
|
workflowKey: key,
|
|
@@ -2723,7 +3830,7 @@ function sendText(res, code, text) {
|
|
|
2723
3830
|
* only `createPanelServer` below always supplies one.
|
|
2724
3831
|
* @param {import('node:http').IncomingMessage} req
|
|
2725
3832
|
* @param {import('node:http').ServerResponse} res
|
|
2726
|
-
* @param {{ home?: string, packageJsonPath?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes> }} opts
|
|
3833
|
+
* @param {{ home?: string, sessionsRoot?: string, packageJsonPath?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes>, schedules?: ReturnType<typeof createScheduleDoor> }} opts
|
|
2727
3834
|
*/
|
|
2728
3835
|
export function handleRequest(req, res, opts) {
|
|
2729
3836
|
/** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
|
|
@@ -2732,7 +3839,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2732
3839
|
function routesFor(o, p) {
|
|
2733
3840
|
if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
|
|
2734
3841
|
if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
|
|
2735
|
-
if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop)$/.test(p)) return o.runRoutes;
|
|
3842
|
+
if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop|accept|rerun|pause|needs-remove)$/.test(p)) return o.runRoutes;
|
|
2736
3843
|
if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
|
|
2737
3844
|
return null;
|
|
2738
3845
|
}
|
|
@@ -2837,9 +3944,9 @@ export function handleRequest(req, res, opts) {
|
|
|
2837
3944
|
return;
|
|
2838
3945
|
}
|
|
2839
3946
|
|
|
2840
|
-
if (pathname === '/api/runs') { send(200,
|
|
3947
|
+
if (pathname === '/api/runs') { send(200, runsListPayload({ home: opts.home, schedules: opts.schedules })); return; }
|
|
2841
3948
|
|
|
2842
|
-
const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
|
|
3949
|
+
const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds|result|download))?$/.exec(pathname);
|
|
2843
3950
|
if (runMatch) {
|
|
2844
3951
|
const rawRunid = runMatch[1];
|
|
2845
3952
|
const sub = runMatch[3] ?? null;
|
|
@@ -2856,15 +3963,36 @@ export function handleRequest(req, res, opts) {
|
|
|
2856
3963
|
return;
|
|
2857
3964
|
}
|
|
2858
3965
|
if (sub === 'job') {
|
|
2859
|
-
const result = getRunJob(runid, { home: opts.home });
|
|
3966
|
+
const result = getRunJob(runid, { home: opts.home, schedules: opts.schedules });
|
|
2860
3967
|
if (!result) { sendText(res, 404, 'no such run'); return; }
|
|
2861
3968
|
send(200, result);
|
|
2862
3969
|
return;
|
|
2863
3970
|
}
|
|
3971
|
+
if (sub === 'result') {
|
|
3972
|
+
const result = getRunResult(runid, { home: opts.home, sessionsRoot: opts.sessionsRoot });
|
|
3973
|
+
if (!result) { sendText(res, 404, 'no such run, or it has no result tab'); return; }
|
|
3974
|
+
send(200, result);
|
|
3975
|
+
return;
|
|
3976
|
+
}
|
|
3977
|
+
if (sub === 'download') {
|
|
3978
|
+
const got = getRunDownload(runid, url.searchParams.get('i'), { home: opts.home });
|
|
3979
|
+
if (!got.ok) { sendText(res, got.code, got.error); return; }
|
|
3980
|
+
// the name is the delivered file's own basename; quotes and control characters never reach the header
|
|
3981
|
+
const plain = got.name.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
3982
|
+
res.writeHead(200, {
|
|
3983
|
+
'content-type': `${got.type}; charset=utf-8`,
|
|
3984
|
+
'content-length': got.buf.length,
|
|
3985
|
+
'content-disposition': `attachment; filename="${plain}"; filename*=UTF-8''${encodeURIComponent(got.name)}`,
|
|
3986
|
+
'x-content-type-options': 'nosniff',
|
|
3987
|
+
});
|
|
3988
|
+
if (method === 'HEAD') res.end(); else res.end(got.buf);
|
|
3989
|
+
return;
|
|
3990
|
+
}
|
|
2864
3991
|
if (sub === 'rounds') {
|
|
2865
3992
|
/** @param {string|null} v @returns {number|undefined} */
|
|
2866
3993
|
const num = (v) => (v !== null && /^\d+$/.test(v) ? Number(v) : undefined);
|
|
2867
3994
|
const query = {
|
|
3995
|
+
all: url.searchParams.get('all') === '1',
|
|
2868
3996
|
part: num(url.searchParams.get('part')),
|
|
2869
3997
|
attempt: num(url.searchParams.get('attempt')),
|
|
2870
3998
|
offset: num(url.searchParams.get('offset')),
|
|
@@ -2875,7 +4003,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2875
4003
|
send(200, result);
|
|
2876
4004
|
return;
|
|
2877
4005
|
}
|
|
2878
|
-
const result = getRunDetail(runid, { home: opts.home });
|
|
4006
|
+
const result = getRunDetail(runid, { home: opts.home, schedules: opts.schedules });
|
|
2879
4007
|
if (!result) { sendText(res, 404, 'no such run'); return; }
|
|
2880
4008
|
send(200, result);
|
|
2881
4009
|
return;
|
|
@@ -2903,8 +4031,9 @@ export function handleRequest(req, res, opts) {
|
|
|
2903
4031
|
* @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
|
|
2904
4032
|
* sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
|
|
2905
4033
|
* fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
|
|
2906
|
-
* openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string
|
|
2907
|
-
*
|
|
4034
|
+
* openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string,
|
|
4035
|
+
* scheduleNow?: () => number, sessionDeps?: Record<string, any>, schedulerIntervalMs?: number, acceptWaitMs?: number }} [opts] `scheduleNow`, `sessionDeps` and `schedulerIntervalMs` are TEST SEAMS (the schedule clock; createSession's stubs; the scheduler's timer, 0 = none)
|
|
4036
|
+
* @returns {Promise<{ server: import('node:http').Server, port: number, token: string, schedules: ReturnType<typeof createScheduleDoor>, scheduler: ReturnType<typeof createScheduler>, close: () => Promise<void> }>}
|
|
2908
4037
|
*/
|
|
2909
4038
|
export function createPanelServer(opts = {}) {
|
|
2910
4039
|
const requestedPort = opts.port ?? DEFAULT_PORT;
|
|
@@ -2925,10 +4054,14 @@ export function createPanelServer(opts = {}) {
|
|
|
2925
4054
|
let runRoutes;
|
|
2926
4055
|
/** @type {ReturnType<typeof createImportRoutes>|undefined} */
|
|
2927
4056
|
let importRoutes;
|
|
4057
|
+
/** @type {ReturnType<typeof createScheduler>|undefined} */
|
|
4058
|
+
let scheduler;
|
|
4059
|
+
/** @type {ReturnType<typeof createScheduleDoor>|undefined} */
|
|
4060
|
+
let schedulesRef;
|
|
2928
4061
|
const server = createServer((req, res) => {
|
|
2929
4062
|
try {
|
|
2930
4063
|
handleRequest(req, res, {
|
|
2931
|
-
home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath,
|
|
4064
|
+
home, sessionsRoot: opts.sessionsRoot, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
|
|
2932
4065
|
});
|
|
2933
4066
|
} catch (e) {
|
|
2934
4067
|
sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
|
|
@@ -2942,7 +4075,22 @@ export function createPanelServer(opts = {}) {
|
|
|
2942
4075
|
server.listen(requestedPort, '127.0.0.1', () => {
|
|
2943
4076
|
const addr = server.address();
|
|
2944
4077
|
boundPort = typeof addr === 'object' && addr !== null ? addr.port : requestedPort;
|
|
4078
|
+
const rowsFor = () => rowsForHome(keysHome(home), keysForDoor({ env: opts.env, keysHome: home }).env);
|
|
4079
|
+
const schedules = createScheduleDoor({
|
|
4080
|
+
home,
|
|
4081
|
+
...(opts.userHome ? { userHome: opts.userHome } : {}),
|
|
4082
|
+
...(opts.scheduleNow ? { now: opts.scheduleNow } : {}),
|
|
4083
|
+
sessionsRoot: opts.sessionsRoot,
|
|
4084
|
+
jobRuns: (job) => jobRunsOf(job, { home }),
|
|
4085
|
+
startFrom: (runid) => getStartFrom(runid, { home }),
|
|
4086
|
+
jobOfRun: (runid) => readRunList({ home }).rows.find((r) => r && r.runid === runid)?.job ?? null,
|
|
4087
|
+
rowsFor,
|
|
4088
|
+
});
|
|
4089
|
+
schedulesRef = schedules;
|
|
2945
4090
|
authorRoutes = createAuthorRoutes({
|
|
4091
|
+
schedules,
|
|
4092
|
+
runJobNames: () => readRunList({ home }).rows.flatMap((r) => (r && typeof r.job === 'string' ? [r.job] : [])),
|
|
4093
|
+
...(opts.sessionDeps ? { sessionDeps: opts.sessionDeps } : {}),
|
|
2946
4094
|
port: boundPort,
|
|
2947
4095
|
token,
|
|
2948
4096
|
env: opts.env,
|
|
@@ -2957,6 +4105,18 @@ export function createPanelServer(opts = {}) {
|
|
|
2957
4105
|
listJobs: (o = {}) => listReuseJobs({ home, ...o }),
|
|
2958
4106
|
},
|
|
2959
4107
|
});
|
|
4108
|
+
schedules.attachStarter(createStarter({
|
|
4109
|
+
door: schedules, startSession: authorRoutes.startSession, envNow: authorRoutes.envNow, spawnFn: authorRoutes.spawnFn,
|
|
4110
|
+
bareloopBin: authorRoutes.bareloopBin, sessionsRoot: opts.sessionsRoot, home, rowsFor, startFrom: (runid) => getStartFrom(runid, { home }),
|
|
4111
|
+
forgetSession: authorRoutes.forgetHeadless,
|
|
4112
|
+
...(opts.scheduleNow ? { now: opts.scheduleNow } : {}), // the SAME clock the scheduler reads, so lateMs and startedAt agree with the slot
|
|
4113
|
+
ranSince: (job, sinceMs) => readRunList({ home }).rows.some((r) => r && r.job === job && Date.parse(r.at) >= sinceMs - 1000),
|
|
4114
|
+
}));
|
|
4115
|
+
// T Part 5: the scheduler runs the clock and the folder watch while THIS server is up (panel only, T6); stopped on close
|
|
4116
|
+
scheduler = createScheduler({
|
|
4117
|
+
door: schedules, ...(opts.scheduleNow ? { now: opts.scheduleNow } : {}), ...(opts.schedulerIntervalMs !== undefined ? { intervalMs: opts.schedulerIntervalMs } : {}),
|
|
4118
|
+
});
|
|
4119
|
+
scheduler.start();
|
|
2960
4120
|
settingsRoutes = createSettingsRoutes({
|
|
2961
4121
|
port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
|
|
2962
4122
|
openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
|
|
@@ -2971,6 +4131,10 @@ export function createPanelServer(opts = {}) {
|
|
|
2971
4131
|
settleMs: opts.settleMs,
|
|
2972
4132
|
getResumeContext: (runid) => getResumeContext(runid, { home }),
|
|
2973
4133
|
getStopContext: (runid) => getStopContext(runid, { home }),
|
|
4134
|
+
getAcceptContext: (runid) => getAcceptContext(runid, { home }),
|
|
4135
|
+
stageQuestionsOfSpec,
|
|
4136
|
+
getNeedsContext: (runid) => getNeedsContext(runid, { home }),
|
|
4137
|
+
...(opts.acceptWaitMs !== undefined ? { acceptWaitMs: opts.acceptWaitMs } : {}),
|
|
2974
4138
|
});
|
|
2975
4139
|
importRoutes = createImportRoutes({
|
|
2976
4140
|
port: boundPort, token, home, userHome: opts.userHome, describeSpec,
|
|
@@ -2979,7 +4143,9 @@ export function createPanelServer(opts = {}) {
|
|
|
2979
4143
|
server,
|
|
2980
4144
|
port: boundPort,
|
|
2981
4145
|
token,
|
|
2982
|
-
|
|
4146
|
+
schedules,
|
|
4147
|
+
scheduler: /** @type {ReturnType<typeof createScheduler>} */ (scheduler),
|
|
4148
|
+
close: () => new Promise((res2) => { scheduler?.stop(); server.close(() => res2(undefined)); }),
|
|
2983
4149
|
});
|
|
2984
4150
|
});
|
|
2985
4151
|
});
|