bareloop 0.42.0 → 0.43.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 +46 -0
- package/bareloop.context.md +53 -30
- package/package.json +1 -1
- package/src/authorjob.js +6 -0
- package/src/declaredclose.js +2 -2
- package/src/interviewrun.js +0 -1
- package/src/job.js +38 -11
- package/src/panel/authorroutes.js +107 -33
- package/src/panel/authorsession.js +59 -27
- package/src/panel/index.html +813 -484
- package/src/panel/runroutes.js +163 -5
- 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 +451 -48
- package/src/planrun.js +39 -7
- package/src/providerrows.js +3 -3
- package/src/reviewdoor.js +37 -0
- package/src/runlist.js +35 -0
- package/src/schedulewords.js +296 -0
- 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 +6 -7
- package/types/declaredclose.d.ts +1 -1
- package/types/job.d.ts +8 -1
- package/types/panel/authorroutes.d.ts +34 -1
- 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 +143 -14
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/reviewdoor.d.ts +27 -0
- package/types/runlist.d.ts +20 -0
- package/types/schedulewords.d.ts +135 -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/src/panel/server.js
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
dirname, join, basename, relative, isAbsolute, sep,
|
|
31
31
|
} from 'node:path';
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
|
-
import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
|
|
33
|
+
import { readRunList, readNeedsRemovals, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
|
|
34
34
|
import { draftingPartFor } from '../draftspend.js';
|
|
35
35
|
import {
|
|
36
36
|
replayOne, parseJsonl, resolveSiblings, isSidecarByName,
|
|
@@ -42,17 +42,23 @@ import { runBehaviour } from '../behaviour.js';
|
|
|
42
42
|
import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
|
|
43
43
|
import { jobSpecHash, workflowKey } from '../job.js';
|
|
44
44
|
import { confirmProtections } from '../authorflow.js';
|
|
45
|
+
import { createScheduleDoor } from './schedules.js';
|
|
46
|
+
import { createStarter } from './schedulestart.js';
|
|
47
|
+
import { createScheduler } from './scheduler.js';
|
|
48
|
+
import {
|
|
49
|
+
catchUpWords, dateTimeWords, jobScheduleWords, lowerFirst, scheduleSignFor, scheduledWords, sectionThisRunWords, skippedWords, startedAuditWords, startedWords, triesTailWords, SCHEDULE_WORDS,
|
|
50
|
+
} from '../schedulewords.js';
|
|
51
|
+
import { doorWaiting, heldScheduled } from '../reviewdoor.js';
|
|
45
52
|
import { createAuthorRoutes, mintToken, checkHostGuard, panelMoney2 } from './authorroutes.js';
|
|
46
53
|
import { createRunRoutes } from './runroutes.js';
|
|
47
54
|
import { createImportRoutes, readImports, bundleStatus, IMPORT_CHANGED_LINE } from './importroutes.js';
|
|
48
55
|
import { parseImportedRunId, safeSpinePath, spineStartedAt } from './importrun.js';
|
|
49
56
|
import { checkBundleDeps, resolveBundleSpec } from '../bundle.js';
|
|
50
57
|
import { checkCloseByteSignature } from '../close-integrity.js';
|
|
51
|
-
import { keysHome } from '../keysfile.js';
|
|
58
|
+
import { keysHome, keysForDoor } from '../keysfile.js';
|
|
52
59
|
import { rowsForHome, findRow, modelChoiceFor, tickedLines, endpointOf } from '../providerrows.js';
|
|
53
60
|
import { SHAPES } from '../providershapes.js';
|
|
54
61
|
import { resolveProvider } from '../providers.js';
|
|
55
|
-
import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
|
|
56
62
|
import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
|
|
57
63
|
import { statusFor, partStatusFor, GOAL_MET_LINE } from './status.js';
|
|
58
64
|
import { createSettingsRoutes } from './settingsroutes.js';
|
|
@@ -420,7 +426,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
|
|
|
420
426
|
*
|
|
421
427
|
* The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
|
|
422
428
|
* Resume button appears only when `o.resume` says the engine would accept it; "Reuse workflow"
|
|
423
|
-
* (replaces P5 item 3, 2026-10-03
|
|
429
|
+
* (replaces P5 item 3, 2026-10-03; one door on every ending since 2026-10-15) starts a NEW run.
|
|
424
430
|
* @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
|
|
425
431
|
* @param {{died: boolean, lastThing: string|null}} death
|
|
426
432
|
* @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
|
|
@@ -429,7 +435,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
|
|
|
429
435
|
* @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
|
|
430
436
|
*/
|
|
431
437
|
export function endedFor(summary, death, o = {}) {
|
|
432
|
-
const base =
|
|
438
|
+
const base = withReuseAction(endedBase(summary, death, o));
|
|
433
439
|
const wt = o.worktree;
|
|
434
440
|
if (!base || !wt) return base;
|
|
435
441
|
// 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 +451,13 @@ export function endedFor(summary, death, o = {}) {
|
|
|
445
451
|
}
|
|
446
452
|
|
|
447
453
|
/**
|
|
448
|
-
*
|
|
449
|
-
* after Resume when both show
|
|
454
|
+
* ONE door (hamr 2026-10-15, v1.101): "Reuse workflow" is offered on EVERY ending (green, failed, capped, stopped, died, every other
|
|
455
|
+
* red), after Resume when both show. ONE place, so no ending branch can forget it.
|
|
450
456
|
* @param {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null} ended
|
|
451
457
|
*/
|
|
452
|
-
function
|
|
458
|
+
function withReuseAction(ended) {
|
|
453
459
|
if (!ended || ended.actions.some((a) => a.id === 'reuse')) return ended;
|
|
454
|
-
return { ...ended, actions: [...ended.actions, { id: '
|
|
460
|
+
return { ...ended, actions: [...ended.actions, { id: 'reuse', label: 'Reuse workflow' }] };
|
|
455
461
|
}
|
|
456
462
|
|
|
457
463
|
/** @param {any} summary @param {any} death @param {any} o */
|
|
@@ -460,9 +466,9 @@ function endedBase(summary, death, o) {
|
|
|
460
466
|
/** @type {{id: string, label: string}[]} */
|
|
461
467
|
const RESUME = [{ id: 'resume', label: 'Resume' }];
|
|
462
468
|
/** 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
|
|
469
|
+
* run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: press Reuse workflow" instead. */
|
|
464
470
|
const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
|
|
465
|
-
const CHANGE = 'Change the job: press
|
|
471
|
+
const CHANGE = 'Change the job: press Reuse workflow.';
|
|
466
472
|
const detailOf = (/** @type {string|null} */ s) => {
|
|
467
473
|
if (typeof s !== 'string' || s.length === 0) return '';
|
|
468
474
|
return s.length > 120 ? `${s.slice(0, 120)}…` : s;
|
|
@@ -519,8 +525,8 @@ function endedBase(summary, death, o) {
|
|
|
519
525
|
|
|
520
526
|
if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
|
|
521
527
|
if (o.destinationRefused) {
|
|
522
|
-
// Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below);
|
|
523
|
-
// (
|
|
528
|
+
// Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below); every ending offers Reuse workflow
|
|
529
|
+
// (withReuseAction adds it) and is told to change the job
|
|
524
530
|
const earned = outcome === 'green';
|
|
525
531
|
return {
|
|
526
532
|
reason: 'Goal met, but the output could not be delivered.',
|
|
@@ -530,7 +536,7 @@ function endedBase(summary, death, o) {
|
|
|
530
536
|
};
|
|
531
537
|
}
|
|
532
538
|
// 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;
|
|
539
|
+
// earned its green offers Reuse workflow; every ending offers Reuse workflow (withReuseAction adds it). `satisfied` is a
|
|
534
540
|
// close VERDICT, never a run outcome (ralph and the job-end emitters write `green`), so it rides with already-green.
|
|
535
541
|
if (outcome !== 'green') return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
|
|
536
542
|
return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
|
|
@@ -574,11 +580,21 @@ function endedBase(summary, death, o) {
|
|
|
574
580
|
if (outcome === 'step-stalled') {
|
|
575
581
|
return {
|
|
576
582
|
reason: 'A step stopped making progress.',
|
|
577
|
-
next: resumeOr('Resume, or change the job: press
|
|
583
|
+
next: resumeOr('Resume, or change the job: press Reuse workflow.'),
|
|
578
584
|
line: resumeOk ? 'step stalled — resume' : 'step stalled',
|
|
579
585
|
actions: resumeOk ? RESUME : [],
|
|
580
586
|
};
|
|
581
587
|
}
|
|
588
|
+
// hamr 2026-10-16: a replan answer cut off at its length limit is a bounded failed attempt in plain words, not the
|
|
589
|
+
// provider's fault and not "the checks said no" (the engine files it as a plan-red escalation with cause 'truncated')
|
|
590
|
+
if (outcome === 'plan-red' && esc?.cause === 'truncated' && esc?.phase === 'replan') {
|
|
591
|
+
return {
|
|
592
|
+
reason: 'The replan answer was cut off at its length limit, so no new plan came back.',
|
|
593
|
+
next: CHANGE,
|
|
594
|
+
line: 'replan cut off',
|
|
595
|
+
actions: [],
|
|
596
|
+
};
|
|
597
|
+
}
|
|
582
598
|
if (GOAL_NOT_MET.has(outcome)) {
|
|
583
599
|
const d = detailOf(summary.stopReason);
|
|
584
600
|
return {
|
|
@@ -624,6 +640,29 @@ function lastJobEndTs(/** @type {any[]} */ records) {
|
|
|
624
640
|
return null;
|
|
625
641
|
}
|
|
626
642
|
|
|
643
|
+
/** 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) */
|
|
644
|
+
function lastRecordTs(/** @type {any[]} */ records) {
|
|
645
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
646
|
+
const r = records[i];
|
|
647
|
+
if (r && typeof r === 'object' && typeof r.ts === 'string') return r.ts;
|
|
648
|
+
}
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* 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
|
|
654
|
+
* (the card then says only `running`). Same words the Run tab's header uses.
|
|
655
|
+
* @param {any[]} records @param {any} summary
|
|
656
|
+
* @returns {string|null}
|
|
657
|
+
*/
|
|
658
|
+
function liveStepWhy(records, summary) {
|
|
659
|
+
const plan = records.findLast((r) => r && r.type === 'plan-accepted');
|
|
660
|
+
const total = Array.isArray(plan?.plan?.steps) ? plan.plan.steps.length : 0;
|
|
661
|
+
if (total === 0) return null;
|
|
662
|
+
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;
|
|
663
|
+
return `step ${Math.min(done + 1, total)} of ${total}`;
|
|
664
|
+
}
|
|
665
|
+
|
|
627
666
|
function endedForRow(row, records, summary, death, withWorktree = false) {
|
|
628
667
|
const out = summary.outcome;
|
|
629
668
|
const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
|
|
@@ -659,6 +698,12 @@ function startingStub(row) {
|
|
|
659
698
|
via: row.via,
|
|
660
699
|
fileMissing: false,
|
|
661
700
|
starting: true,
|
|
701
|
+
live: true,
|
|
702
|
+
liveWhy: null,
|
|
703
|
+
needs: null,
|
|
704
|
+
stoppedAt: null,
|
|
705
|
+
liveSince: typeof row.at === 'string' ? row.at : null,
|
|
706
|
+
priorWallMs: 0,
|
|
662
707
|
died: false,
|
|
663
708
|
glyph: '▶',
|
|
664
709
|
status: statusFor({ outcome: null }),
|
|
@@ -684,19 +729,38 @@ function startingStub(row) {
|
|
|
684
729
|
};
|
|
685
730
|
}
|
|
686
731
|
|
|
732
|
+
/**
|
|
733
|
+
* 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.
|
|
734
|
+
* 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
|
|
735
|
+
* brings the run back — or after `needsYouDays`. `removedMs` is the newest removal that covers the current ending (or null), `expired` is true when only
|
|
736
|
+
* the age rule took a Resume run out.
|
|
737
|
+
* @param {{live: boolean, records: any[], resume: {ok?: boolean}|null|undefined, stoppedAt: string|null, nowMs: number, removedMs?: number}} f
|
|
738
|
+
* @returns {{needs: 'Accept'|'Resume'|null, removedMs: number|null, expired: boolean}}
|
|
739
|
+
*/
|
|
740
|
+
export function needsStateFor(f) {
|
|
741
|
+
if (!f.live && doorWaiting(f.records, { now: () => f.nowMs })) return { needs: 'Accept', removedMs: null, expired: false };
|
|
742
|
+
const stopMs = typeof f.stoppedAt === 'string' ? Date.parse(f.stoppedAt) : NaN;
|
|
743
|
+
const covers = typeof f.removedMs === 'number' && (!Number.isFinite(stopMs) || f.removedMs >= stopMs);
|
|
744
|
+
const tag = needsTag(f.resume, f.stoppedAt, f.nowMs);
|
|
745
|
+
const canResume = !f.live && !!f.resume && f.resume.ok === true;
|
|
746
|
+
if (covers && canResume) return { needs: null, removedMs: /** @type {number} */ (f.removedMs), expired: false };
|
|
747
|
+
return { needs: tag, removedMs: null, expired: canResume && tag === null };
|
|
748
|
+
}
|
|
749
|
+
|
|
687
750
|
/**
|
|
688
751
|
* One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
|
|
689
752
|
* spine no longer exists on disk — never silently listed as if it were
|
|
690
753
|
* still there (the same rule {@link import('../runlist.js').formatRunRow}
|
|
691
754
|
* already applies to the CLI's own listing).
|
|
692
755
|
* @param {import('../runlist.js').RunRow} row
|
|
756
|
+
* @param {number} [nowMs] @param {Map<string, number>} [removals] `readNeedsRemovals`: when each run was taken out of Needs you
|
|
693
757
|
* @returns {any}
|
|
694
758
|
*/
|
|
695
|
-
function summarizeRow(row) {
|
|
759
|
+
function summarizeRow(row, nowMs = Date.now(), removals = undefined) {
|
|
696
760
|
if (!existsSync(row.spine)) {
|
|
697
761
|
if (runIsAlive(row)) return startingStub(row);
|
|
698
762
|
return {
|
|
699
|
-
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
|
|
763
|
+
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true, live: false, needs: null, stoppedAt: null,
|
|
700
764
|
};
|
|
701
765
|
}
|
|
702
766
|
let summary;
|
|
@@ -707,17 +771,33 @@ function summarizeRow(row) {
|
|
|
707
771
|
} catch (e) {
|
|
708
772
|
return {
|
|
709
773
|
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: false, readError: String(/** @type {Error} */ (e).message),
|
|
774
|
+
live: false, needs: null, stoppedAt: null,
|
|
710
775
|
};
|
|
711
776
|
}
|
|
712
777
|
const line = summarizeForAllLine(summary);
|
|
713
778
|
const death = deriveDeath(row, rawRecords, summary.outcome);
|
|
714
|
-
const { ended, status } = endedForRow(row, rawRecords, summary, death);
|
|
779
|
+
const { ended, status, resume } = endedForRow(row, rawRecords, summary, death);
|
|
780
|
+
// P9 v2: the server decides every state the runs list draws (the page works out nothing).
|
|
781
|
+
const live = !death.died && (summary.outcome === null || summary.outcome === undefined) && runIsAlive(row);
|
|
782
|
+
const stoppedAt = live ? null : (lastJobEndTs(rawRecords) ?? (death.died ? lastRecordTs(rawRecords) : null));
|
|
783
|
+
const legs = legsOf(rawRecords);
|
|
784
|
+
const priorWall = legs.length <= 1 ? { ms: 0, complete: true } : legsWallMs(legs.slice(0, -1));
|
|
785
|
+
const liveStart = live ? legs.at(-1)?.startMs ?? null : null;
|
|
715
786
|
return {
|
|
716
787
|
runid: row.runid,
|
|
717
788
|
job: row.job,
|
|
718
789
|
at: row.at,
|
|
719
790
|
via: row.via,
|
|
720
791
|
fileMissing: false,
|
|
792
|
+
live,
|
|
793
|
+
liveWhy: live ? liveStepWhy(rawRecords, summary) : null,
|
|
794
|
+
// a run waiting at its review door needs the person's Accept (never ages out, T10); else the Resume rule
|
|
795
|
+
needs: needsStateFor({ live, records: rawRecords, resume, stoppedAt, nowMs, removedMs: removals ? removals.get(row.runid) : undefined }).needs,
|
|
796
|
+
stoppedAt,
|
|
797
|
+
// 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).
|
|
798
|
+
// `priorWallMs` is null when the earlier legs' wall is not exact: the card then shows this leg's wall only, and says so.
|
|
799
|
+
liveSince: liveStart === null ? (live && typeof row.at === 'string' ? row.at : null) : new Date(liveStart).toISOString(),
|
|
800
|
+
priorWallMs: live ? (priorWall.complete ? priorWall.ms : null) : null,
|
|
721
801
|
died: death.died,
|
|
722
802
|
endedLine: ended ? ended.line : null,
|
|
723
803
|
// 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 +864,21 @@ export function getStopContext(runid, opts = {}) {
|
|
|
784
864
|
return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
|
|
785
865
|
}
|
|
786
866
|
|
|
867
|
+
/**
|
|
868
|
+
* 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
|
|
869
|
+
* (`summarizeRow`, the one rule). `null` when the runid is not listed.
|
|
870
|
+
* @param {string} runid
|
|
871
|
+
* @param {{ home?: string, now?: number }} [opts]
|
|
872
|
+
* @returns {{row: any, needs: 'Accept'|'Resume'|null}|null}
|
|
873
|
+
*/
|
|
874
|
+
export function getNeedsContext(runid, opts = {}) {
|
|
875
|
+
const { rows } = readRunList(opts);
|
|
876
|
+
const row = rows.find((r) => r && r.runid === runid);
|
|
877
|
+
if (!row) return null;
|
|
878
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
879
|
+
return { row, needs: summarizeRow(row, nowMs, readNeedsRemovals(opts)).needs ?? null };
|
|
880
|
+
}
|
|
881
|
+
|
|
787
882
|
/**
|
|
788
883
|
* @param {any} r
|
|
789
884
|
* @returns {number}
|
|
@@ -818,6 +913,57 @@ function runNumbers(rows) {
|
|
|
818
913
|
return out;
|
|
819
914
|
}
|
|
820
915
|
|
|
916
|
+
/** 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. */
|
|
917
|
+
export const RUNS_LIST_LIMITS = Object.freeze({ quietDays: 30, olderRuns: 7, needsYouDays: 7 });
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* 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
|
|
921
|
+
* tab's Resume card shows, already asked once per row by `endedForRow`), until the run has been stopped `needsYouDays` (exactly that old is out;
|
|
922
|
+
* no readable stop time stays in). `Accept` comes from {@link doorWaiting} (T Part 8), asked in `summarizeRow`, never here.
|
|
923
|
+
* @param {{ok?: boolean}|null|undefined} resume @param {string|null} stoppedAt @param {number} nowMs
|
|
924
|
+
* @returns {'Accept'|'Resume'|null}
|
|
925
|
+
*/
|
|
926
|
+
export function needsTag(resume, stoppedAt, nowMs) {
|
|
927
|
+
if (!resume || resume.ok !== true) return null;
|
|
928
|
+
const t = typeof stoppedAt === 'string' ? Date.parse(stoppedAt) : NaN;
|
|
929
|
+
if (Number.isFinite(t) && nowMs - t >= RUNS_LIST_LIMITS.needsYouDays * 86_400_000) return null;
|
|
930
|
+
return 'Resume';
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* 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
|
|
935
|
+
* 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
|
|
936
|
+
* run is never quiet; an unknown age is never guessed into quiet.
|
|
937
|
+
* @param {any[]} rows `listRuns` rows @param {number} nowMs
|
|
938
|
+
* @returns {{job: string, headRunid: string|null, quiet: boolean, activityMs: number|null}[]}
|
|
939
|
+
*/
|
|
940
|
+
export function summarizeJobs(rows, nowMs) {
|
|
941
|
+
/** @type {Map<string, {head: any, headMs: number, act: number|null, live: boolean}>} */
|
|
942
|
+
const by = new Map();
|
|
943
|
+
const ms = (/** @type {any} */ x) => { const t = typeof x === 'string' ? Date.parse(x) : NaN; return Number.isFinite(t) ? t : null; };
|
|
944
|
+
for (const r of rows) {
|
|
945
|
+
if (!r || typeof r.job !== 'string') continue;
|
|
946
|
+
const e = by.get(r.job) ?? { head: null, headMs: -Infinity, act: null, live: false };
|
|
947
|
+
const at = ms(r.at);
|
|
948
|
+
if (e.head === null || (at !== null && at > e.headMs)) { e.head = r; e.headMs = at ?? -Infinity; }
|
|
949
|
+
const t = Math.max(at ?? -Infinity, ms(r.stoppedAt) ?? -Infinity);
|
|
950
|
+
if (t > -Infinity && (e.act === null || t > e.act)) e.act = t;
|
|
951
|
+
if (r.live === true) e.live = true;
|
|
952
|
+
by.set(r.job, e);
|
|
953
|
+
}
|
|
954
|
+
const quietMs = RUNS_LIST_LIMITS.quietDays * 86_400_000;
|
|
955
|
+
return [...by.entries()]
|
|
956
|
+
.map(([job, e]) => ({ job, headRunid: e.head ? e.head.runid : null, quiet: !e.live && e.act !== null && nowMs - e.act > quietMs, activityMs: e.act }))
|
|
957
|
+
.sort((a, b) => {
|
|
958
|
+
if (a.activityMs !== b.activityMs) {
|
|
959
|
+
if (a.activityMs === null) return 1;
|
|
960
|
+
if (b.activityMs === null) return -1;
|
|
961
|
+
return b.activityMs - a.activityMs;
|
|
962
|
+
}
|
|
963
|
+
return a.job < b.job ? -1 : a.job > b.job ? 1 : 0;
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
821
967
|
/**
|
|
822
968
|
* `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
|
|
823
969
|
* append order — a backfill scan appends rows in sorted-PATH order, which
|
|
@@ -831,16 +977,38 @@ function runNumbers(rows) {
|
|
|
831
977
|
* SAME payload by job client-side (`groupRunsByJob` in `index.html`) rather
|
|
832
978
|
* than a second server-side endpoint, so the glyph/checkType mapping is
|
|
833
979
|
* still computed in exactly one place.
|
|
834
|
-
* @param {{ home?: string }} [opts]
|
|
980
|
+
* @param {{ home?: string, now?: number }} [opts] `now` (ms) is injectable for the Needs-you age rule
|
|
835
981
|
* @returns {any[]}
|
|
836
982
|
*/
|
|
837
983
|
export function listRuns(opts = {}) {
|
|
838
984
|
const { rows } = readRunList(opts);
|
|
985
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
839
986
|
const nos = runNumbers(rows);
|
|
987
|
+
const removals = readNeedsRemovals(opts);
|
|
840
988
|
return rows
|
|
841
989
|
.map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
|
|
842
990
|
.sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
|
|
843
|
-
.map(({ row }) => ({ ...summarizeRow(row), runNo: nos.get(row.runid) }));
|
|
991
|
+
.map(({ row }) => ({ ...summarizeRow(row, nowMs, removals), runNo: nos.get(row.runid) }));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* `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
|
|
996
|
+
* quiet flag, the limits — and the page works out nothing. `scheduled` is the triggers piece (PRD v1.99, T Part 7).
|
|
997
|
+
* @param {{ home?: string, now?: number, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
|
|
998
|
+
*/
|
|
999
|
+
export function runsListPayload(opts = {}) {
|
|
1000
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
1001
|
+
const runs = listRuns({ ...opts, now: nowMs });
|
|
1002
|
+
const jobs = summarizeJobs(runs, nowMs);
|
|
1003
|
+
// 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
|
|
1004
|
+
const scheduled0 = opts.schedules ? opts.schedules.listScheduled(new Map(jobs.map((j) => [j.job, j.headRunid]))) : [];
|
|
1005
|
+
// 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)
|
|
1006
|
+
const gaveUp0 = opts.schedules ? opts.schedules.listGaveUp(new Map(jobs.map((j) => [j.job, j.headRunid]))) : [];
|
|
1007
|
+
// RULED 2026-10-15: both cards name the run they open (`job · run-N`), the number from the run list
|
|
1008
|
+
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; };
|
|
1009
|
+
const gaveUp = gaveUp0.map(noOf);
|
|
1010
|
+
const scheduled = scheduled0.map(noOf);
|
|
1011
|
+
return { runs, jobs, scheduled, gaveUp, limits: RUNS_LIST_LIMITS };
|
|
844
1012
|
}
|
|
845
1013
|
|
|
846
1014
|
/**
|
|
@@ -880,6 +1048,51 @@ function findRunRow(runid, opts) {
|
|
|
880
1048
|
return readRunList(opts).rows.find((r) => r && r.runid === runid);
|
|
881
1049
|
}
|
|
882
1050
|
|
|
1051
|
+
/**
|
|
1052
|
+
* T Part 7 — `detail.schedule`: `{scheduled, canSchedule, words, text?, next?, nextRed?}` from the schedule door's ONE read of the
|
|
1053
|
+
* schedule files and ledger (A.12). `waitingRunNo` = the job's NEWEST run when it waits for the person's Accept: ONE spine parse,
|
|
1054
|
+
* never a run context per run. A job that cannot be read has no schedule block.
|
|
1055
|
+
* @param {string} job @param {any[]} rows the run list @param {Map<string, number>} nos @param {{ schedules?: ReturnType<typeof createScheduleDoor> }} opts
|
|
1056
|
+
*/
|
|
1057
|
+
function scheduleDetailFor(job, rows, nos, opts) {
|
|
1058
|
+
try {
|
|
1059
|
+
const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
|
|
1060
|
+
let newest = null;
|
|
1061
|
+
for (const r of rows) if (r && r.job === job && (newest === null || atMs(r) >= atMs(newest))) newest = r;
|
|
1062
|
+
let waitingRunNo = null;
|
|
1063
|
+
if (newest && existsSync(newest.spine) && door.readSchedule(job) && !runIsAlive(newest)) {
|
|
1064
|
+
const records = parseJsonl(newest.spine).records;
|
|
1065
|
+
if (doorWaiting(records)) waitingRunNo = nos.get(newest.runid) ?? null;
|
|
1066
|
+
}
|
|
1067
|
+
return door.view(job, { waitingRunNo });
|
|
1068
|
+
} catch { return null; }
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* The record `trigger.json` a SCHEDULED start wrote beside its signed spec (why the run started), or null for every hand run.
|
|
1073
|
+
* @param {{ spine: string }} row @returns {any|null}
|
|
1074
|
+
*/
|
|
1075
|
+
function triggerRecordOf(row) {
|
|
1076
|
+
const near = sourceNearSpine(row.spine);
|
|
1077
|
+
if (!near.specPath) return null;
|
|
1078
|
+
try { return JSON.parse(readFileSync(join(dirname(near.specPath), 'trigger.json'), 'utf8')); } catch { return null; }
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* T Part 8 — what the Accept route needs: the run's row, whether its runner is alive, whether it waits at its review door
|
|
1083
|
+
* ({@link doorWaiting}), and the signed spec it was judged by (the file and hash `run-u --door --approve` takes).
|
|
1084
|
+
* @param {string} runid @param {{ home?: string }} opts
|
|
1085
|
+
* @returns {{row: any, live: boolean, waiting: boolean, specPath: string|null, specHash: string|null}|null}
|
|
1086
|
+
*/
|
|
1087
|
+
export function getAcceptContext(runid, opts = {}) {
|
|
1088
|
+
const row = findRunRow(runid, opts);
|
|
1089
|
+
if (!row || row.via === 'import') return null;
|
|
1090
|
+
if (!existsSync(row.spine)) return { row, live: runIsAlive(row), waiting: false, specPath: null, specHash: null };
|
|
1091
|
+
const records = parseJsonl(row.spine).records;
|
|
1092
|
+
const signed = signedSpecForRun(row, records);
|
|
1093
|
+
return { row, live: runIsAlive(row), waiting: doorWaiting(records), specPath: signed ? signed.specPath : null, specHash: signed ? signed.specHash : null };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
883
1096
|
/**
|
|
884
1097
|
* `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
|
|
885
1098
|
* iterations), counters, summary-box fields. Looks `runid` up in the run
|
|
@@ -887,19 +1100,87 @@ function findRunRow(runid, opts) {
|
|
|
887
1100
|
* header); `null` when the runid is not in the list at all (caller renders
|
|
888
1101
|
* 404).
|
|
889
1102
|
* @param {string} runid
|
|
890
|
-
* @param {{ home?: string }} [opts]
|
|
1103
|
+
* @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
|
|
891
1104
|
* @returns {any|null}
|
|
892
1105
|
*/
|
|
893
1106
|
export function getRunDetail(runid, opts = {}) {
|
|
894
1107
|
const d = getRunDetailBody(runid, opts);
|
|
895
1108
|
if (!d) return d;
|
|
896
|
-
const
|
|
897
|
-
|
|
1109
|
+
const { rows } = readRunList(opts);
|
|
1110
|
+
const nos = runNumbers(rows);
|
|
1111
|
+
const runNo = nos.get(runid);
|
|
1112
|
+
// 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)
|
|
1113
|
+
const schedule = opts.schedules && typeof d.job === 'string' ? scheduleDetailFor(d.job, rows, nos, opts) : null;
|
|
1114
|
+
const extra = opts.schedules && typeof d.job === 'string' ? scheduleExtrasFor(runid, d.job, rows, nos, opts, schedule) : {};
|
|
1115
|
+
return { ...d, ...(runNo === undefined ? {} : { runNo }), ...(schedule ? { schedule: extra.schedule ?? schedule } : {}), ...(extra.audit ?? {}) };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* 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.
|
|
1120
|
+
* `schedule` = the door's view with `line2`, `problem` (and no raw ledger row); `audit` = `{scheduleSection}` or, for a run a cancelled schedule started, `{startedAudit}`.
|
|
1121
|
+
* @param {string} runid @param {string} job @param {any[]} rows @param {Map<string, number>} nos @param {any} opts @param {any} view
|
|
1122
|
+
* @returns {{schedule?: any, audit?: {startedAudit?: string, scheduleSection?: any}}}
|
|
1123
|
+
*/
|
|
1124
|
+
function scheduleExtrasFor(runid, job, rows, nos, opts, view) {
|
|
1125
|
+
try {
|
|
1126
|
+
const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
|
|
1127
|
+
/** @type {any} */
|
|
1128
|
+
let schedule;
|
|
1129
|
+
if (view) {
|
|
1130
|
+
const { lastRow, nextWords, accept, ...rest } = view;
|
|
1131
|
+
schedule = { ...rest, line2: null };
|
|
1132
|
+
if (view.scheduled) {
|
|
1133
|
+
let lastWords = null;
|
|
1134
|
+
if (lastRow) {
|
|
1135
|
+
if (lastRow.kind === 'started') {
|
|
1136
|
+
const mine = rows.filter((r) => r && r.job === job).sort((a, b) => atMs(b) - atMs(a)).slice(0, 25);
|
|
1137
|
+
let hit = null; let fallback = null;
|
|
1138
|
+
for (const r of mine) {
|
|
1139
|
+
const rec = triggerRecordOf(r);
|
|
1140
|
+
if (!rec) continue;
|
|
1141
|
+
if (fallback === null) fallback = { r, rec };
|
|
1142
|
+
if (rec.sessionId && rec.sessionId === lastRow.sessionId) { hit = { r, rec }; break; }
|
|
1143
|
+
}
|
|
1144
|
+
const got = hit ?? fallback;
|
|
1145
|
+
const no = got ? nos.get(got.r.runid) : undefined;
|
|
1146
|
+
const word = got ? summarizeRow(got.r).status?.word : undefined;
|
|
1147
|
+
lastWords = `last ${dateTimeWords(Number(lastRow.at))}${no ? ` · run-${no}` : ''}${word ? ` · ${word}` : ''}${got?.rec.catchUp === true ? ' · catch-up' : ''}`;
|
|
1148
|
+
} else {
|
|
1149
|
+
lastWords = `last ${dateTimeWords(Number(lastRow.at))} · ${skippedWords(lastRow, true)}`;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
schedule.line2 = [lastWords ?? 'no run yet', accept ? SCHEDULE_WORDS.acceptWaiting : null, nextWords].filter(Boolean).join(' · ');
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
const row = rows.find((r) => r && r.runid === runid);
|
|
1156
|
+
const rec = row ? triggerRecordOf(row) : null;
|
|
1157
|
+
const cur = door.readSchedule(job);
|
|
1158
|
+
if (!cur) {
|
|
1159
|
+
// no schedule now: a run a cancelled schedule started keeps its one old line; a hand run has nothing
|
|
1160
|
+
const origin = rec ? (rec.originRunid ?? null) : null;
|
|
1161
|
+
return { ...(schedule ? { schedule } : {}), audit: rec ? { startedAudit: startedAuditWords(rec, origin ? (nos.get(origin) ?? null) : null) } : {} };
|
|
1162
|
+
}
|
|
1163
|
+
// 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
|
|
1164
|
+
const originNo = nos.get(cur.originRunid) ?? null;
|
|
1165
|
+
const tries = door.skippedFor(job);
|
|
1166
|
+
const running = rows.some((r) => r && r.job === job && r.via !== 'import' && runIsAlive(r));
|
|
1167
|
+
const { sign } = scheduleSignFor({ running, red: !!(view && view.problem) });
|
|
1168
|
+
const words = scheduledWords(cur.trigger, door.userHome);
|
|
1169
|
+
const head = [lowerFirst(words ?? ''), originNo === null ? null : `from run-${originNo}`, triesTailWords(tries.length) || null].filter(Boolean).join(' · ');
|
|
1170
|
+
const when = row ? dateTimeWords(atMs(row)) : '';
|
|
1171
|
+
const thisRun = rec ? sectionThisRunWords(rec) : `by hand.${runid === cur.originRunid ? ' The schedule was set from this run.' : ''}`;
|
|
1172
|
+
const shown = tries.slice(0, 7).map((x, i) => ({ label: i === 0 ? 'tries' : '', text: x.line }));
|
|
1173
|
+
const scheduleSection = {
|
|
1174
|
+
title: SCHEDULE_WORDS.scheduleGroup, sign, head,
|
|
1175
|
+
rows: [{ label: 'this run', text: `${when} ${thisRun}` }, ...(shown.length > 0 ? shown : [{ label: 'tries', text: 'none' }])],
|
|
1176
|
+
};
|
|
1177
|
+
return { ...(schedule ? { schedule } : {}), audit: { scheduleSection } };
|
|
1178
|
+
} catch { return {}; }
|
|
898
1179
|
}
|
|
899
1180
|
|
|
900
1181
|
/**
|
|
901
1182
|
* @param {string} runid
|
|
902
|
-
* @param {{ home?: string }} opts
|
|
1183
|
+
* @param {{ home?: string, now?: number }} opts
|
|
903
1184
|
* @returns {any|null}
|
|
904
1185
|
*/
|
|
905
1186
|
function getRunDetailBody(runid, opts) {
|
|
@@ -1091,6 +1372,12 @@ function getRunDetailBody(runid, opts) {
|
|
|
1091
1372
|
// numbers the Resume confirm box prefills (the signed caps, spent so far); `null`
|
|
1092
1373
|
// whenever Resume is not offered.
|
|
1093
1374
|
ended,
|
|
1375
|
+
// aligned with fwdloop M9 am7: `MM/DD HH:MM · <outcome word> · <reason>` (local end time); null while the run is live
|
|
1376
|
+
endedText: (() => {
|
|
1377
|
+
const ts = lastJobEndTs(rawSpineRecords) ?? (death.died ? lastRecordTs(rawSpineRecords) : null);
|
|
1378
|
+
const ms = ts === null ? NaN : Date.parse(ts);
|
|
1379
|
+
return ended && Number.isFinite(ms) ? `${dateTimeWords(ms)} · ${status.word} · ${ended.reason}` : null;
|
|
1380
|
+
})(),
|
|
1094
1381
|
// P5 item 5: is the run's LATEST leg live (the page offers Stop only then), and has a stop been asked for
|
|
1095
1382
|
// (the request file exists beside the spine) — the page reads "stopping after this turn…" until the leg ends
|
|
1096
1383
|
live: row.via !== 'import' && runIsAlive(row),
|
|
@@ -1175,6 +1462,25 @@ function getRunDetailBody(runid, opts) {
|
|
|
1175
1462
|
// `memory-cache` record at all (never armed on this run).
|
|
1176
1463
|
behaviour: scopedBehaviour(row, rawSpineRecords),
|
|
1177
1464
|
memoryCache: summary.memoryCache,
|
|
1465
|
+
// 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)
|
|
1466
|
+
accept: !runIsAlive(row) && doorWaiting(rawSpineRecords),
|
|
1467
|
+
// T Part 7: why a SCHEDULED run started (the words `trigger.json` carried) and the fixed catch-up sentence; null on every hand run
|
|
1468
|
+
...(() => {
|
|
1469
|
+
const trig = triggerRecordOf(row);
|
|
1470
|
+
return { startedBy: trig && typeof trig.reason === 'string' ? trig.reason : null, catchUp: catchUpWords(trig) };
|
|
1471
|
+
})(),
|
|
1472
|
+
// 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
|
|
1473
|
+
...(() => {
|
|
1474
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
1475
|
+
const live = !death.died && (summary.outcome === null || summary.outcome === undefined) && runIsAlive(row);
|
|
1476
|
+
const stoppedAt = live ? null : (lastJobEndTs(rawSpineRecords) ?? (death.died ? lastRecordTs(rawSpineRecords) : null));
|
|
1477
|
+
const st = needsStateFor({ live, records: rawSpineRecords, resume, stoppedAt, nowMs, removedMs: readNeedsRemovals(opts).get(row.runid) });
|
|
1478
|
+
/** @type {string|null} */
|
|
1479
|
+
let needsAudit = null;
|
|
1480
|
+
if (st.removedMs !== null) needsAudit = `removed from Needs you by you · ${dateTimeWords(st.removedMs)}`;
|
|
1481
|
+
else if (st.expired) needsAudit = `removed from Needs you after ${RUNS_LIST_LIMITS.needsYouDays} days`;
|
|
1482
|
+
return { canRemoveFromNeeds: st.needs === 'Resume', needsAudit };
|
|
1483
|
+
})(),
|
|
1178
1484
|
};
|
|
1179
1485
|
}
|
|
1180
1486
|
|
|
@@ -1964,6 +2270,15 @@ function stageKindMetaFromSpec(spec) {
|
|
|
1964
2270
|
return map;
|
|
1965
2271
|
}
|
|
1966
2272
|
|
|
2273
|
+
/**
|
|
2274
|
+
* Stage name to the panel's plain question, off a signed spec — the SAME owner ({@link stageKindMetaFromSpec}) the step map and Audit use.
|
|
2275
|
+
* @param {any} spec
|
|
2276
|
+
* @returns {Map<string, string|null>}
|
|
2277
|
+
*/
|
|
2278
|
+
function stageQuestionsOfSpec(spec) {
|
|
2279
|
+
return new Map([...(stageKindMetaFromSpec(spec) ?? new Map())].map(([n, m]) => [n, m.question]));
|
|
2280
|
+
}
|
|
2281
|
+
|
|
1967
2282
|
/**
|
|
1968
2283
|
* Attaches one stage's `{kind, direction, baselineKind, question}` (from
|
|
1969
2284
|
* {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
|
|
@@ -2185,10 +2500,33 @@ function jobsDir() {
|
|
|
2185
2500
|
* from the resolved SPEC only (a) (b) (c) — a job-start record and `none()`
|
|
2186
2501
|
* carry no close/writeScope/tools at all, so those stay `'not recorded'`.
|
|
2187
2502
|
* @param {string} runid
|
|
2188
|
-
* @param {{ home?: string }} [opts]
|
|
2503
|
+
* @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
|
|
2189
2504
|
* @returns {any|null}
|
|
2190
2505
|
*/
|
|
2191
2506
|
export function getRunJob(runid, opts = {}) {
|
|
2507
|
+
const body = getRunJobBody(runid, opts);
|
|
2508
|
+
if (!body || !opts.schedules) return body;
|
|
2509
|
+
try {
|
|
2510
|
+
const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
|
|
2511
|
+
const { rows } = readRunList(opts);
|
|
2512
|
+
const row = rows.find((r) => r && r.runid === runid);
|
|
2513
|
+
const job = row ? row.job : body.job;
|
|
2514
|
+
const nos = runNumbers(rows);
|
|
2515
|
+
const cur = door.readSchedule(job);
|
|
2516
|
+
const started = startedWords(row ? triggerRecordOf(row) : null, row ? atMs(row) : null);
|
|
2517
|
+
const spec = cur ? cur.spec : null;
|
|
2518
|
+
const schedule = jobScheduleWords({
|
|
2519
|
+
trigger: cur ? cur.trigger : null, originNo: cur ? (nos.get(cur.originRunid) ?? null) : null,
|
|
2520
|
+
model: spec && typeof spec.model === 'string' ? spec.model : null,
|
|
2521
|
+
destination: cur ? (typeof cur.destination === 'string' && cur.destination !== '' ? cur.destination : (Array.isArray(spec?.writeScope) ? spec.writeScope.join(', ') : null)) : null,
|
|
2522
|
+
home: door.userHome,
|
|
2523
|
+
});
|
|
2524
|
+
return { ...body, started, schedule };
|
|
2525
|
+
} catch { return body; }
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
/** @param {string} runid @param {{ home?: string }} opts */
|
|
2529
|
+
function getRunJobBody(runid, opts) {
|
|
2192
2530
|
const row = findRunRow(runid, opts);
|
|
2193
2531
|
if (!row) return null;
|
|
2194
2532
|
|
|
@@ -2497,14 +2835,14 @@ export function reuseLine(record) {
|
|
|
2497
2835
|
|
|
2498
2836
|
/**
|
|
2499
2837
|
* `start-from` (the route keeps its name) for one listed run: the card prefilled from the run's SIGNED job, which
|
|
2500
|
-
*
|
|
2838
|
+
* the track record (v1.101: nothing is locked, every box is open). The job boxes carry the signed values, read by the
|
|
2501
2839
|
* SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
|
|
2502
2840
|
* $ 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
2841
|
* to copy: change the job: Clear the card). `null` when the runid is not listed.
|
|
2504
2842
|
* @param {string} runid
|
|
2505
2843
|
* @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
2844
|
* @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
|
|
2507
|
-
*
|
|
2845
|
+
* note: string|null,
|
|
2508
2846
|
* specHash: string, workflowKey: string, spec: any, specPath: string, specNamesModel: boolean,
|
|
2509
2847
|
* specWorker: {provider: string, endpoint: string, label: string},
|
|
2510
2848
|
* trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
@@ -2537,6 +2875,9 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2537
2875
|
source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
|
|
2538
2876
|
// a card saved by P7 or later keeps the Inputs text the person typed
|
|
2539
2877
|
inputs: typeof saved.inputs === 'string' && saved.inputs ? saved.inputs : '',
|
|
2878
|
+
// a plain-files job delivers to an absolute folder the SPEC does not hold (its fence is `output/` inside the run's tree): the
|
|
2879
|
+
// Destination opens with the folder the origin run was given, never with that fence
|
|
2880
|
+
destination: [saved.destination, manifest?.destination].find((d) => typeof d === 'string' && /^(\/|[a-zA-Z]:[\\/])/.test(d)) ?? '',
|
|
2540
2881
|
jobName: row.job,
|
|
2541
2882
|
});
|
|
2542
2883
|
const key = workflowKey(spec);
|
|
@@ -2544,12 +2885,14 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2544
2885
|
return {
|
|
2545
2886
|
ok: true,
|
|
2546
2887
|
origin: { runid, job: row.job },
|
|
2547
|
-
// the run's terminal outcome — the Reuse start (POST /api/author/start)
|
|
2548
|
-
|
|
2888
|
+
// the run's terminal outcome — the Reuse start (POST /api/author/start) reuses a green or already-green run only.
|
|
2889
|
+
// T D.2: a SCHEDULED run's green reads `green-held` until the person accepts it, so that same refusal holds it unchanged
|
|
2890
|
+
outcome: (() => {
|
|
2891
|
+
const o = legsOf(records).at(-1)?.outcome ?? null;
|
|
2892
|
+
return o === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : o;
|
|
2893
|
+
})(),
|
|
2549
2894
|
card,
|
|
2550
2895
|
from: 'signed job',
|
|
2551
|
-
locked: REUSE_LOCKED_FIELDS,
|
|
2552
|
-
open: REUSE_OPEN_FIELDS,
|
|
2553
2896
|
note: Array.isArray(spec.jobLines) && spec.jobLines.length ? null : 'older job — numbered lines were not saved',
|
|
2554
2897
|
specHash: signed.specHash,
|
|
2555
2898
|
workflowKey: key,
|
|
@@ -2568,6 +2911,29 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2568
2911
|
};
|
|
2569
2912
|
}
|
|
2570
2913
|
|
|
2914
|
+
/**
|
|
2915
|
+
* T Part 3: a job's listed runs, newest first, each with its signed spec (null when not on disk), its terminal outcome and
|
|
2916
|
+
* its parsed spine. The schedule door's gate (`greenFor`) reads this; nothing here builds a run context.
|
|
2917
|
+
* @param {string} job
|
|
2918
|
+
* @param {{ home?: string }} [opts]
|
|
2919
|
+
* @returns {{runid: string, outcome: string|null, spec: any, records: any[]}[]}
|
|
2920
|
+
*/
|
|
2921
|
+
export function jobRunsOf(job, opts = {}) {
|
|
2922
|
+
const { rows } = readRunList(opts);
|
|
2923
|
+
/** @type {{runid: string, outcome: string|null, spec: any, records: any[]}[]} */
|
|
2924
|
+
const out = [];
|
|
2925
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
2926
|
+
const row = rows[i];
|
|
2927
|
+
if (!row || row.job !== job || !existsSync(row.spine)) continue;
|
|
2928
|
+
let records;
|
|
2929
|
+
try { records = parseJsonl(row.spine).records; } catch { continue; }
|
|
2930
|
+
const outcome = legsOf(records).at(-1)?.outcome ?? null;
|
|
2931
|
+
// T D.2: a scheduled run's green is HELD until accepted, so it is not a good run for the schedule gate either
|
|
2932
|
+
out.push({ runid: row.runid, outcome: outcome === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : outcome, spec: signedSpecForRun(row, records)?.spec ?? null, records });
|
|
2933
|
+
}
|
|
2934
|
+
return out;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2571
2937
|
/**
|
|
2572
2938
|
* The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
|
|
2573
2939
|
* that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
|
|
@@ -2587,9 +2953,9 @@ export function listReuseJobs(opts = {}) {
|
|
|
2587
2953
|
let records;
|
|
2588
2954
|
try { records = parseJsonl(row.spine).records; } catch { continue; }
|
|
2589
2955
|
const outcome = legsOf(records).at(-1)?.outcome ?? null;
|
|
2590
|
-
//
|
|
2591
|
-
|
|
2592
|
-
if (
|
|
2956
|
+
// 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)
|
|
2957
|
+
if (outcome !== 'green' && outcome !== 'already-green') continue;
|
|
2958
|
+
if (heldScheduled(records, triggerRecordOf(row))) continue; // T D.2: a scheduled green waits for the person's Accept
|
|
2593
2959
|
const signed = signedSpecForRun(row, records);
|
|
2594
2960
|
if (!signed) continue;
|
|
2595
2961
|
const key = workflowKey(signed.spec);
|
|
@@ -2606,7 +2972,7 @@ export function listReuseJobs(opts = {}) {
|
|
|
2606
2972
|
* The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
|
|
2607
2973
|
* the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
|
|
2608
2974
|
* @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>`)
|
|
2975
|
+
* @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
2976
|
* @returns {Record<string, any>}
|
|
2611
2977
|
*/
|
|
2612
2978
|
function reuseCardFromSpec(spec, extra) {
|
|
@@ -2616,8 +2982,9 @@ function reuseCardFromSpec(spec, extra) {
|
|
|
2616
2982
|
checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
|
|
2617
2983
|
model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
|
|
2618
2984
|
jobText: jobTextFromSpec(spec),
|
|
2985
|
+
thinking: spec.thinking === true,
|
|
2619
2986
|
inputs: extra.inputs || inputsTextFromSpec(spec, extra.source),
|
|
2620
|
-
destination: writeScope.join(', '),
|
|
2987
|
+
destination: extra.destination || writeScope.join(', '),
|
|
2621
2988
|
capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
|
|
2622
2989
|
maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
|
|
2623
2990
|
};
|
|
@@ -2656,7 +3023,7 @@ function describeSpec(spec) {
|
|
|
2656
3023
|
* @param {string} id
|
|
2657
3024
|
* @param {{ home?: string, model?: string, provider?: string }} [opts] `model` as for {@link getStartFrom}
|
|
2658
3025
|
* @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
|
|
2659
|
-
*
|
|
3026
|
+
* note: string|null, specHash: string, workflowKey: string, spec: any,
|
|
2660
3027
|
* specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
2661
3028
|
*/
|
|
2662
3029
|
export function getStartFromImport(id, opts = {}) {
|
|
@@ -2682,8 +3049,6 @@ export function getStartFromImport(id, opts = {}) {
|
|
|
2682
3049
|
origin: { runid: null, job: row.job, importId: id },
|
|
2683
3050
|
card,
|
|
2684
3051
|
from: 'imported job',
|
|
2685
|
-
locked: REUSE_LOCKED_FIELDS,
|
|
2686
|
-
open: REUSE_OPEN_FIELDS,
|
|
2687
3052
|
note: 'a Source is needed — an exported job carries none; it is checked and signed on this machine',
|
|
2688
3053
|
specHash: jobSpecHash(spec),
|
|
2689
3054
|
workflowKey: key,
|
|
@@ -2723,7 +3088,7 @@ function sendText(res, code, text) {
|
|
|
2723
3088
|
* only `createPanelServer` below always supplies one.
|
|
2724
3089
|
* @param {import('node:http').IncomingMessage} req
|
|
2725
3090
|
* @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
|
|
3091
|
+
* @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>, schedules?: ReturnType<typeof createScheduleDoor> }} opts
|
|
2727
3092
|
*/
|
|
2728
3093
|
export function handleRequest(req, res, opts) {
|
|
2729
3094
|
/** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
|
|
@@ -2732,7 +3097,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2732
3097
|
function routesFor(o, p) {
|
|
2733
3098
|
if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
|
|
2734
3099
|
if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
|
|
2735
|
-
if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop)$/.test(p)) return o.runRoutes;
|
|
3100
|
+
if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop|accept|needs-remove)$/.test(p)) return o.runRoutes;
|
|
2736
3101
|
if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
|
|
2737
3102
|
return null;
|
|
2738
3103
|
}
|
|
@@ -2837,7 +3202,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2837
3202
|
return;
|
|
2838
3203
|
}
|
|
2839
3204
|
|
|
2840
|
-
if (pathname === '/api/runs') { send(200,
|
|
3205
|
+
if (pathname === '/api/runs') { send(200, runsListPayload({ home: opts.home, schedules: opts.schedules })); return; }
|
|
2841
3206
|
|
|
2842
3207
|
const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
|
|
2843
3208
|
if (runMatch) {
|
|
@@ -2856,7 +3221,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2856
3221
|
return;
|
|
2857
3222
|
}
|
|
2858
3223
|
if (sub === 'job') {
|
|
2859
|
-
const result = getRunJob(runid, { home: opts.home });
|
|
3224
|
+
const result = getRunJob(runid, { home: opts.home, schedules: opts.schedules });
|
|
2860
3225
|
if (!result) { sendText(res, 404, 'no such run'); return; }
|
|
2861
3226
|
send(200, result);
|
|
2862
3227
|
return;
|
|
@@ -2875,7 +3240,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2875
3240
|
send(200, result);
|
|
2876
3241
|
return;
|
|
2877
3242
|
}
|
|
2878
|
-
const result = getRunDetail(runid, { home: opts.home });
|
|
3243
|
+
const result = getRunDetail(runid, { home: opts.home, schedules: opts.schedules });
|
|
2879
3244
|
if (!result) { sendText(res, 404, 'no such run'); return; }
|
|
2880
3245
|
send(200, result);
|
|
2881
3246
|
return;
|
|
@@ -2903,8 +3268,9 @@ export function handleRequest(req, res, opts) {
|
|
|
2903
3268
|
* @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
|
|
2904
3269
|
* sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
|
|
2905
3270
|
* fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
|
|
2906
|
-
* openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string
|
|
2907
|
-
*
|
|
3271
|
+
* openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string,
|
|
3272
|
+
* 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)
|
|
3273
|
+
* @returns {Promise<{ server: import('node:http').Server, port: number, token: string, schedules: ReturnType<typeof createScheduleDoor>, scheduler: ReturnType<typeof createScheduler>, close: () => Promise<void> }>}
|
|
2908
3274
|
*/
|
|
2909
3275
|
export function createPanelServer(opts = {}) {
|
|
2910
3276
|
const requestedPort = opts.port ?? DEFAULT_PORT;
|
|
@@ -2925,10 +3291,14 @@ export function createPanelServer(opts = {}) {
|
|
|
2925
3291
|
let runRoutes;
|
|
2926
3292
|
/** @type {ReturnType<typeof createImportRoutes>|undefined} */
|
|
2927
3293
|
let importRoutes;
|
|
3294
|
+
/** @type {ReturnType<typeof createScheduler>|undefined} */
|
|
3295
|
+
let scheduler;
|
|
3296
|
+
/** @type {ReturnType<typeof createScheduleDoor>|undefined} */
|
|
3297
|
+
let schedulesRef;
|
|
2928
3298
|
const server = createServer((req, res) => {
|
|
2929
3299
|
try {
|
|
2930
3300
|
handleRequest(req, res, {
|
|
2931
|
-
home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath,
|
|
3301
|
+
home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
|
|
2932
3302
|
});
|
|
2933
3303
|
} catch (e) {
|
|
2934
3304
|
sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
|
|
@@ -2942,7 +3312,22 @@ export function createPanelServer(opts = {}) {
|
|
|
2942
3312
|
server.listen(requestedPort, '127.0.0.1', () => {
|
|
2943
3313
|
const addr = server.address();
|
|
2944
3314
|
boundPort = typeof addr === 'object' && addr !== null ? addr.port : requestedPort;
|
|
3315
|
+
const rowsFor = () => rowsForHome(keysHome(home), keysForDoor({ env: opts.env, keysHome: home }).env);
|
|
3316
|
+
const schedules = createScheduleDoor({
|
|
3317
|
+
home,
|
|
3318
|
+
...(opts.userHome ? { userHome: opts.userHome } : {}),
|
|
3319
|
+
...(opts.scheduleNow ? { now: opts.scheduleNow } : {}),
|
|
3320
|
+
sessionsRoot: opts.sessionsRoot,
|
|
3321
|
+
jobRuns: (job) => jobRunsOf(job, { home }),
|
|
3322
|
+
startFrom: (runid) => getStartFrom(runid, { home }),
|
|
3323
|
+
jobOfRun: (runid) => readRunList({ home }).rows.find((r) => r && r.runid === runid)?.job ?? null,
|
|
3324
|
+
rowsFor,
|
|
3325
|
+
});
|
|
3326
|
+
schedulesRef = schedules;
|
|
2945
3327
|
authorRoutes = createAuthorRoutes({
|
|
3328
|
+
schedules,
|
|
3329
|
+
runJobNames: () => readRunList({ home }).rows.flatMap((r) => (r && typeof r.job === 'string' ? [r.job] : [])),
|
|
3330
|
+
...(opts.sessionDeps ? { sessionDeps: opts.sessionDeps } : {}),
|
|
2946
3331
|
port: boundPort,
|
|
2947
3332
|
token,
|
|
2948
3333
|
env: opts.env,
|
|
@@ -2957,6 +3342,18 @@ export function createPanelServer(opts = {}) {
|
|
|
2957
3342
|
listJobs: (o = {}) => listReuseJobs({ home, ...o }),
|
|
2958
3343
|
},
|
|
2959
3344
|
});
|
|
3345
|
+
schedules.attachStarter(createStarter({
|
|
3346
|
+
door: schedules, startSession: authorRoutes.startSession, envNow: authorRoutes.envNow, spawnFn: authorRoutes.spawnFn,
|
|
3347
|
+
bareloopBin: authorRoutes.bareloopBin, sessionsRoot: opts.sessionsRoot, home, rowsFor, startFrom: (runid) => getStartFrom(runid, { home }),
|
|
3348
|
+
forgetSession: authorRoutes.forgetHeadless,
|
|
3349
|
+
...(opts.scheduleNow ? { now: opts.scheduleNow } : {}), // the SAME clock the scheduler reads, so lateMs and startedAt agree with the slot
|
|
3350
|
+
ranSince: (job, sinceMs) => readRunList({ home }).rows.some((r) => r && r.job === job && Date.parse(r.at) >= sinceMs - 1000),
|
|
3351
|
+
}));
|
|
3352
|
+
// T Part 5: the scheduler runs the clock and the folder watch while THIS server is up (panel only, T6); stopped on close
|
|
3353
|
+
scheduler = createScheduler({
|
|
3354
|
+
door: schedules, ...(opts.scheduleNow ? { now: opts.scheduleNow } : {}), ...(opts.schedulerIntervalMs !== undefined ? { intervalMs: opts.schedulerIntervalMs } : {}),
|
|
3355
|
+
});
|
|
3356
|
+
scheduler.start();
|
|
2960
3357
|
settingsRoutes = createSettingsRoutes({
|
|
2961
3358
|
port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
|
|
2962
3359
|
openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
|
|
@@ -2971,6 +3368,10 @@ export function createPanelServer(opts = {}) {
|
|
|
2971
3368
|
settleMs: opts.settleMs,
|
|
2972
3369
|
getResumeContext: (runid) => getResumeContext(runid, { home }),
|
|
2973
3370
|
getStopContext: (runid) => getStopContext(runid, { home }),
|
|
3371
|
+
getAcceptContext: (runid) => getAcceptContext(runid, { home }),
|
|
3372
|
+
stageQuestionsOfSpec,
|
|
3373
|
+
getNeedsContext: (runid) => getNeedsContext(runid, { home }),
|
|
3374
|
+
...(opts.acceptWaitMs !== undefined ? { acceptWaitMs: opts.acceptWaitMs } : {}),
|
|
2974
3375
|
});
|
|
2975
3376
|
importRoutes = createImportRoutes({
|
|
2976
3377
|
port: boundPort, token, home, userHome: opts.userHome, describeSpec,
|
|
@@ -2979,7 +3380,9 @@ export function createPanelServer(opts = {}) {
|
|
|
2979
3380
|
server,
|
|
2980
3381
|
port: boundPort,
|
|
2981
3382
|
token,
|
|
2982
|
-
|
|
3383
|
+
schedules,
|
|
3384
|
+
scheduler: /** @type {ReturnType<typeof createScheduler>} */ (scheduler),
|
|
3385
|
+
close: () => new Promise((res2) => { scheduler?.stop(); server.close(() => res2(undefined)); }),
|
|
2983
3386
|
});
|
|
2984
3387
|
});
|
|
2985
3388
|
});
|