bareloop 0.41.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 +109 -1
- package/README.md +13 -21
- package/bareloop.context.md +97 -34
- package/package.json +4 -3
- package/src/authorflow.js +337 -29
- package/src/authoring.js +92 -7
- package/src/authorjob.js +84 -16
- package/src/authorreadout.js +89 -5
- package/src/authorrun.js +14 -5
- package/src/authorscout.js +108 -0
- package/src/declaredclose.js +25 -14
- package/src/facts.js +317 -0
- package/src/factwords.js +129 -0
- package/src/inputlook.js +301 -0
- package/src/interviewrun.js +0 -1
- package/src/job.js +40 -12
- package/src/jobblock.js +7 -2
- package/src/kinds.js +84 -3
- package/src/labelscan.js +170 -0
- package/src/pagefetch.js +125 -0
- package/src/panel/authorroutes.js +121 -34
- package/src/panel/authorsession.js +154 -45
- package/src/panel/index.html +845 -490
- package/src/panel/jobplan.js +34 -11
- 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 +467 -48
- package/src/planrun.js +42 -9
- package/src/providerrows.js +3 -3
- package/src/reviewdoor.js +37 -0
- package/src/rubricstep.js +804 -0
- package/src/runlist.js +35 -0
- package/src/schedulewords.js +296 -0
- package/src/source.js +166 -1
- 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/u-readout.js +1 -1
- package/src/userrun.js +20 -9
- package/types/authorflow.d.ts +267 -13
- package/types/authoring.d.ts +29 -1
- package/types/authorjob.d.ts +7 -5
- package/types/authorreadout.d.ts +27 -2
- package/types/authorscout.d.ts +70 -0
- package/types/declaredclose.d.ts +5 -3
- package/types/facts.d.ts +71 -0
- package/types/factwords.d.ts +42 -0
- package/types/inputlook.d.ts +238 -0
- package/types/job.d.ts +8 -1
- package/types/jobblock.d.ts +8 -1
- package/types/labelscan.d.ts +38 -0
- package/types/pagefetch.d.ts +71 -0
- package/types/panel/authorroutes.d.ts +34 -1
- package/types/panel/authorsession.d.ts +55 -14
- package/types/panel/jobplan.d.ts +12 -4
- 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 +145 -14
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/reviewdoor.d.ts +27 -0
- package/types/rubricstep.d.ts +286 -0
- package/types/runlist.d.ts +20 -0
- package/types/schedulewords.d.ts +135 -0
- package/types/source.d.ts +94 -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 +11 -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';
|
|
@@ -60,6 +66,16 @@ import { planFromSpec } from './jobplan.js';
|
|
|
60
66
|
|
|
61
67
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
62
68
|
|
|
69
|
+
/** bareloop's own version, from its package.json (read each time the page is served); 'unknown' if it cannot be read, never a guess
|
|
70
|
+
* @param {string} [file] test seam: another package.json
|
|
71
|
+
* @returns {string} */
|
|
72
|
+
function packageVersion(file = join(HERE, '..', '..', 'package.json')) {
|
|
73
|
+
try {
|
|
74
|
+
const v = JSON.parse(readFileSync(file, 'utf8')).version;
|
|
75
|
+
return typeof v === 'string' && v.length > 0 ? v : 'unknown';
|
|
76
|
+
} catch { return 'unknown'; }
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
/** Largest POST body the panel buffers (job cards are short prose); a bigger one is refused with 413. */
|
|
64
80
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
65
81
|
|
|
@@ -410,7 +426,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
|
|
|
410
426
|
*
|
|
411
427
|
* The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
|
|
412
428
|
* Resume button appears only when `o.resume` says the engine would accept it; "Reuse workflow"
|
|
413
|
-
* (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.
|
|
414
430
|
* @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
|
|
415
431
|
* @param {{died: boolean, lastThing: string|null}} death
|
|
416
432
|
* @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
|
|
@@ -419,7 +435,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
|
|
|
419
435
|
* @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
|
|
420
436
|
*/
|
|
421
437
|
export function endedFor(summary, death, o = {}) {
|
|
422
|
-
const base =
|
|
438
|
+
const base = withReuseAction(endedBase(summary, death, o));
|
|
423
439
|
const wt = o.worktree;
|
|
424
440
|
if (!base || !wt) return base;
|
|
425
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
|
|
@@ -435,13 +451,13 @@ export function endedFor(summary, death, o = {}) {
|
|
|
435
451
|
}
|
|
436
452
|
|
|
437
453
|
/**
|
|
438
|
-
*
|
|
439
|
-
* 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.
|
|
440
456
|
* @param {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null} ended
|
|
441
457
|
*/
|
|
442
|
-
function
|
|
458
|
+
function withReuseAction(ended) {
|
|
443
459
|
if (!ended || ended.actions.some((a) => a.id === 'reuse')) return ended;
|
|
444
|
-
return { ...ended, actions: [...ended.actions, { id: '
|
|
460
|
+
return { ...ended, actions: [...ended.actions, { id: 'reuse', label: 'Reuse workflow' }] };
|
|
445
461
|
}
|
|
446
462
|
|
|
447
463
|
/** @param {any} summary @param {any} death @param {any} o */
|
|
@@ -450,9 +466,9 @@ function endedBase(summary, death, o) {
|
|
|
450
466
|
/** @type {{id: string, label: string}[]} */
|
|
451
467
|
const RESUME = [{ id: 'resume', label: 'Resume' }];
|
|
452
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
|
|
453
|
-
* 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. */
|
|
454
470
|
const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
|
|
455
|
-
const CHANGE = 'Change the job: press
|
|
471
|
+
const CHANGE = 'Change the job: press Reuse workflow.';
|
|
456
472
|
const detailOf = (/** @type {string|null} */ s) => {
|
|
457
473
|
if (typeof s !== 'string' || s.length === 0) return '';
|
|
458
474
|
return s.length > 120 ? `${s.slice(0, 120)}…` : s;
|
|
@@ -509,8 +525,8 @@ function endedBase(summary, death, o) {
|
|
|
509
525
|
|
|
510
526
|
if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
|
|
511
527
|
if (o.destinationRefused) {
|
|
512
|
-
// Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below);
|
|
513
|
-
// (
|
|
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
|
|
514
530
|
const earned = outcome === 'green';
|
|
515
531
|
return {
|
|
516
532
|
reason: 'Goal met, but the output could not be delivered.',
|
|
@@ -520,7 +536,7 @@ function endedBase(summary, death, o) {
|
|
|
520
536
|
};
|
|
521
537
|
}
|
|
522
538
|
// hamr 2026-10-08: already-green (the check passed before any work) is not a reusable job — only a run that
|
|
523
|
-
// earned its green offers Reuse workflow;
|
|
539
|
+
// earned its green offers Reuse workflow; every ending offers Reuse workflow (withReuseAction adds it). `satisfied` is a
|
|
524
540
|
// close VERDICT, never a run outcome (ralph and the job-end emitters write `green`), so it rides with already-green.
|
|
525
541
|
if (outcome !== 'green') return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
|
|
526
542
|
return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
|
|
@@ -564,11 +580,21 @@ function endedBase(summary, death, o) {
|
|
|
564
580
|
if (outcome === 'step-stalled') {
|
|
565
581
|
return {
|
|
566
582
|
reason: 'A step stopped making progress.',
|
|
567
|
-
next: resumeOr('Resume, or change the job: press
|
|
583
|
+
next: resumeOr('Resume, or change the job: press Reuse workflow.'),
|
|
568
584
|
line: resumeOk ? 'step stalled — resume' : 'step stalled',
|
|
569
585
|
actions: resumeOk ? RESUME : [],
|
|
570
586
|
};
|
|
571
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
|
+
}
|
|
572
598
|
if (GOAL_NOT_MET.has(outcome)) {
|
|
573
599
|
const d = detailOf(summary.stopReason);
|
|
574
600
|
return {
|
|
@@ -614,6 +640,29 @@ function lastJobEndTs(/** @type {any[]} */ records) {
|
|
|
614
640
|
return null;
|
|
615
641
|
}
|
|
616
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
|
+
|
|
617
666
|
function endedForRow(row, records, summary, death, withWorktree = false) {
|
|
618
667
|
const out = summary.outcome;
|
|
619
668
|
const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
|
|
@@ -649,6 +698,12 @@ function startingStub(row) {
|
|
|
649
698
|
via: row.via,
|
|
650
699
|
fileMissing: false,
|
|
651
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,
|
|
652
707
|
died: false,
|
|
653
708
|
glyph: '▶',
|
|
654
709
|
status: statusFor({ outcome: null }),
|
|
@@ -674,19 +729,38 @@ function startingStub(row) {
|
|
|
674
729
|
};
|
|
675
730
|
}
|
|
676
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
|
+
|
|
677
750
|
/**
|
|
678
751
|
* One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
|
|
679
752
|
* spine no longer exists on disk — never silently listed as if it were
|
|
680
753
|
* still there (the same rule {@link import('../runlist.js').formatRunRow}
|
|
681
754
|
* already applies to the CLI's own listing).
|
|
682
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
|
|
683
757
|
* @returns {any}
|
|
684
758
|
*/
|
|
685
|
-
function summarizeRow(row) {
|
|
759
|
+
function summarizeRow(row, nowMs = Date.now(), removals = undefined) {
|
|
686
760
|
if (!existsSync(row.spine)) {
|
|
687
761
|
if (runIsAlive(row)) return startingStub(row);
|
|
688
762
|
return {
|
|
689
|
-
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,
|
|
690
764
|
};
|
|
691
765
|
}
|
|
692
766
|
let summary;
|
|
@@ -697,17 +771,33 @@ function summarizeRow(row) {
|
|
|
697
771
|
} catch (e) {
|
|
698
772
|
return {
|
|
699
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,
|
|
700
775
|
};
|
|
701
776
|
}
|
|
702
777
|
const line = summarizeForAllLine(summary);
|
|
703
778
|
const death = deriveDeath(row, rawRecords, summary.outcome);
|
|
704
|
-
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;
|
|
705
786
|
return {
|
|
706
787
|
runid: row.runid,
|
|
707
788
|
job: row.job,
|
|
708
789
|
at: row.at,
|
|
709
790
|
via: row.via,
|
|
710
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,
|
|
711
801
|
died: death.died,
|
|
712
802
|
endedLine: ended ? ended.line : null,
|
|
713
803
|
// P5-R: one card per run — how many times it was resumed (a resumed run is the same run, never a second card)
|
|
@@ -774,6 +864,21 @@ export function getStopContext(runid, opts = {}) {
|
|
|
774
864
|
return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
|
|
775
865
|
}
|
|
776
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
|
+
|
|
777
882
|
/**
|
|
778
883
|
* @param {any} r
|
|
779
884
|
* @returns {number}
|
|
@@ -808,6 +913,57 @@ function runNumbers(rows) {
|
|
|
808
913
|
return out;
|
|
809
914
|
}
|
|
810
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
|
+
|
|
811
967
|
/**
|
|
812
968
|
* `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
|
|
813
969
|
* append order — a backfill scan appends rows in sorted-PATH order, which
|
|
@@ -821,16 +977,38 @@ function runNumbers(rows) {
|
|
|
821
977
|
* SAME payload by job client-side (`groupRunsByJob` in `index.html`) rather
|
|
822
978
|
* than a second server-side endpoint, so the glyph/checkType mapping is
|
|
823
979
|
* still computed in exactly one place.
|
|
824
|
-
* @param {{ home?: string }} [opts]
|
|
980
|
+
* @param {{ home?: string, now?: number }} [opts] `now` (ms) is injectable for the Needs-you age rule
|
|
825
981
|
* @returns {any[]}
|
|
826
982
|
*/
|
|
827
983
|
export function listRuns(opts = {}) {
|
|
828
984
|
const { rows } = readRunList(opts);
|
|
985
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
829
986
|
const nos = runNumbers(rows);
|
|
987
|
+
const removals = readNeedsRemovals(opts);
|
|
830
988
|
return rows
|
|
831
989
|
.map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
|
|
832
990
|
.sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
|
|
833
|
-
.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 };
|
|
834
1012
|
}
|
|
835
1013
|
|
|
836
1014
|
/**
|
|
@@ -870,6 +1048,51 @@ function findRunRow(runid, opts) {
|
|
|
870
1048
|
return readRunList(opts).rows.find((r) => r && r.runid === runid);
|
|
871
1049
|
}
|
|
872
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
|
+
|
|
873
1096
|
/**
|
|
874
1097
|
* `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
|
|
875
1098
|
* iterations), counters, summary-box fields. Looks `runid` up in the run
|
|
@@ -877,19 +1100,87 @@ function findRunRow(runid, opts) {
|
|
|
877
1100
|
* header); `null` when the runid is not in the list at all (caller renders
|
|
878
1101
|
* 404).
|
|
879
1102
|
* @param {string} runid
|
|
880
|
-
* @param {{ home?: string }} [opts]
|
|
1103
|
+
* @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
|
|
881
1104
|
* @returns {any|null}
|
|
882
1105
|
*/
|
|
883
1106
|
export function getRunDetail(runid, opts = {}) {
|
|
884
1107
|
const d = getRunDetailBody(runid, opts);
|
|
885
1108
|
if (!d) return d;
|
|
886
|
-
const
|
|
887
|
-
|
|
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 {}; }
|
|
888
1179
|
}
|
|
889
1180
|
|
|
890
1181
|
/**
|
|
891
1182
|
* @param {string} runid
|
|
892
|
-
* @param {{ home?: string }} opts
|
|
1183
|
+
* @param {{ home?: string, now?: number }} opts
|
|
893
1184
|
* @returns {any|null}
|
|
894
1185
|
*/
|
|
895
1186
|
function getRunDetailBody(runid, opts) {
|
|
@@ -1081,6 +1372,12 @@ function getRunDetailBody(runid, opts) {
|
|
|
1081
1372
|
// numbers the Resume confirm box prefills (the signed caps, spent so far); `null`
|
|
1082
1373
|
// whenever Resume is not offered.
|
|
1083
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
|
+
})(),
|
|
1084
1381
|
// P5 item 5: is the run's LATEST leg live (the page offers Stop only then), and has a stop been asked for
|
|
1085
1382
|
// (the request file exists beside the spine) — the page reads "stopping after this turn…" until the leg ends
|
|
1086
1383
|
live: row.via !== 'import' && runIsAlive(row),
|
|
@@ -1165,6 +1462,25 @@ function getRunDetailBody(runid, opts) {
|
|
|
1165
1462
|
// `memory-cache` record at all (never armed on this run).
|
|
1166
1463
|
behaviour: scopedBehaviour(row, rawSpineRecords),
|
|
1167
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
|
+
})(),
|
|
1168
1484
|
};
|
|
1169
1485
|
}
|
|
1170
1486
|
|
|
@@ -1810,6 +2126,7 @@ function resolveSpecForRow(row) {
|
|
|
1810
2126
|
* change any file?"
|
|
1811
2127
|
* - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
|
|
1812
2128
|
* casts or silencers added?"
|
|
2129
|
+
* - `rubric-step` (Part 1a) is one job line's output file: "line N — does its output pass the line's checks?"
|
|
1813
2130
|
* - `command-exit` is the genre's `suite-green` stage (the only shipped use
|
|
1814
2131
|
* of this kind): "does the test suite pass?"
|
|
1815
2132
|
* - `count-not-worse` splits three ways on its own declared
|
|
@@ -1877,6 +2194,8 @@ function stageQuestionText({
|
|
|
1877
2194
|
if (kind === 'files-changed') return 'did it change any file?';
|
|
1878
2195
|
if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
|
|
1879
2196
|
if (kind === 'command-exit') return 'does the test suite pass?';
|
|
2197
|
+
// Part 1a: one rubric-step stage = one job line's output file
|
|
2198
|
+
if (kind === 'rubric-step') return params && Number.isInteger(params.step) ? `line ${params.step} — does its output pass the line's checks?` : null;
|
|
1880
2199
|
if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
|
|
1881
2200
|
const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
|
|
1882
2201
|
if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
|
|
@@ -1951,6 +2270,15 @@ function stageKindMetaFromSpec(spec) {
|
|
|
1951
2270
|
return map;
|
|
1952
2271
|
}
|
|
1953
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
|
+
|
|
1954
2282
|
/**
|
|
1955
2283
|
* Attaches one stage's `{kind, direction, baselineKind, question}` (from
|
|
1956
2284
|
* {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
|
|
@@ -2172,10 +2500,33 @@ function jobsDir() {
|
|
|
2172
2500
|
* from the resolved SPEC only (a) (b) (c) — a job-start record and `none()`
|
|
2173
2501
|
* carry no close/writeScope/tools at all, so those stay `'not recorded'`.
|
|
2174
2502
|
* @param {string} runid
|
|
2175
|
-
* @param {{ home?: string }} [opts]
|
|
2503
|
+
* @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
|
|
2176
2504
|
* @returns {any|null}
|
|
2177
2505
|
*/
|
|
2178
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) {
|
|
2179
2530
|
const row = findRunRow(runid, opts);
|
|
2180
2531
|
if (!row) return null;
|
|
2181
2532
|
|
|
@@ -2484,14 +2835,14 @@ export function reuseLine(record) {
|
|
|
2484
2835
|
|
|
2485
2836
|
/**
|
|
2486
2837
|
* `start-from` (the route keeps its name) for one listed run: the card prefilled from the run's SIGNED job, which
|
|
2487
|
-
*
|
|
2838
|
+
* the track record (v1.101: nothing is locked, every box is open). The job boxes carry the signed values, read by the
|
|
2488
2839
|
* SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
|
|
2489
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
|
|
2490
2841
|
* to copy: change the job: Clear the card). `null` when the runid is not listed.
|
|
2491
2842
|
* @param {string} runid
|
|
2492
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
|
|
2493
2844
|
* @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
|
|
2494
|
-
*
|
|
2845
|
+
* note: string|null,
|
|
2495
2846
|
* specHash: string, workflowKey: string, spec: any, specPath: string, specNamesModel: boolean,
|
|
2496
2847
|
* specWorker: {provider: string, endpoint: string, label: string},
|
|
2497
2848
|
* trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
@@ -2524,6 +2875,9 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2524
2875
|
source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
|
|
2525
2876
|
// a card saved by P7 or later keeps the Inputs text the person typed
|
|
2526
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)) ?? '',
|
|
2527
2881
|
jobName: row.job,
|
|
2528
2882
|
});
|
|
2529
2883
|
const key = workflowKey(spec);
|
|
@@ -2531,12 +2885,14 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2531
2885
|
return {
|
|
2532
2886
|
ok: true,
|
|
2533
2887
|
origin: { runid, job: row.job },
|
|
2534
|
-
// the run's terminal outcome — the Reuse start (POST /api/author/start)
|
|
2535
|
-
|
|
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
|
+
})(),
|
|
2536
2894
|
card,
|
|
2537
2895
|
from: 'signed job',
|
|
2538
|
-
locked: REUSE_LOCKED_FIELDS,
|
|
2539
|
-
open: REUSE_OPEN_FIELDS,
|
|
2540
2896
|
note: Array.isArray(spec.jobLines) && spec.jobLines.length ? null : 'older job — numbered lines were not saved',
|
|
2541
2897
|
specHash: signed.specHash,
|
|
2542
2898
|
workflowKey: key,
|
|
@@ -2555,6 +2911,29 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2555
2911
|
};
|
|
2556
2912
|
}
|
|
2557
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
|
+
|
|
2558
2937
|
/**
|
|
2559
2938
|
* The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
|
|
2560
2939
|
* that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
|
|
@@ -2574,9 +2953,9 @@ export function listReuseJobs(opts = {}) {
|
|
|
2574
2953
|
let records;
|
|
2575
2954
|
try { records = parseJsonl(row.spine).records; } catch { continue; }
|
|
2576
2955
|
const outcome = legsOf(records).at(-1)?.outcome ?? null;
|
|
2577
|
-
//
|
|
2578
|
-
|
|
2579
|
-
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
|
|
2580
2959
|
const signed = signedSpecForRun(row, records);
|
|
2581
2960
|
if (!signed) continue;
|
|
2582
2961
|
const key = workflowKey(signed.spec);
|
|
@@ -2593,7 +2972,7 @@ export function listReuseJobs(opts = {}) {
|
|
|
2593
2972
|
* The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
|
|
2594
2973
|
* the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
|
|
2595
2974
|
* @param {any} spec
|
|
2596
|
-
* @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>`)
|
|
2597
2976
|
* @returns {Record<string, any>}
|
|
2598
2977
|
*/
|
|
2599
2978
|
function reuseCardFromSpec(spec, extra) {
|
|
@@ -2603,8 +2982,9 @@ function reuseCardFromSpec(spec, extra) {
|
|
|
2603
2982
|
checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
|
|
2604
2983
|
model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
|
|
2605
2984
|
jobText: jobTextFromSpec(spec),
|
|
2985
|
+
thinking: spec.thinking === true,
|
|
2606
2986
|
inputs: extra.inputs || inputsTextFromSpec(spec, extra.source),
|
|
2607
|
-
destination: writeScope.join(', '),
|
|
2987
|
+
destination: extra.destination || writeScope.join(', '),
|
|
2608
2988
|
capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
|
|
2609
2989
|
maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
|
|
2610
2990
|
};
|
|
@@ -2643,7 +3023,7 @@ function describeSpec(spec) {
|
|
|
2643
3023
|
* @param {string} id
|
|
2644
3024
|
* @param {{ home?: string, model?: string, provider?: string }} [opts] `model` as for {@link getStartFrom}
|
|
2645
3025
|
* @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
|
|
2646
|
-
*
|
|
3026
|
+
* note: string|null, specHash: string, workflowKey: string, spec: any,
|
|
2647
3027
|
* specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
2648
3028
|
*/
|
|
2649
3029
|
export function getStartFromImport(id, opts = {}) {
|
|
@@ -2669,8 +3049,6 @@ export function getStartFromImport(id, opts = {}) {
|
|
|
2669
3049
|
origin: { runid: null, job: row.job, importId: id },
|
|
2670
3050
|
card,
|
|
2671
3051
|
from: 'imported job',
|
|
2672
|
-
locked: REUSE_LOCKED_FIELDS,
|
|
2673
|
-
open: REUSE_OPEN_FIELDS,
|
|
2674
3052
|
note: 'a Source is needed — an exported job carries none; it is checked and signed on this machine',
|
|
2675
3053
|
specHash: jobSpecHash(spec),
|
|
2676
3054
|
workflowKey: key,
|
|
@@ -2710,7 +3088,7 @@ function sendText(res, code, text) {
|
|
|
2710
3088
|
* only `createPanelServer` below always supplies one.
|
|
2711
3089
|
* @param {import('node:http').IncomingMessage} req
|
|
2712
3090
|
* @param {import('node:http').ServerResponse} res
|
|
2713
|
-
* @param {{ home?: 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
|
|
2714
3092
|
*/
|
|
2715
3093
|
export function handleRequest(req, res, opts) {
|
|
2716
3094
|
/** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
|
|
@@ -2719,7 +3097,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2719
3097
|
function routesFor(o, p) {
|
|
2720
3098
|
if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
|
|
2721
3099
|
if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
|
|
2722
|
-
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;
|
|
2723
3101
|
if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
|
|
2724
3102
|
return null;
|
|
2725
3103
|
}
|
|
@@ -2806,6 +3184,9 @@ export function handleRequest(req, res, opts) {
|
|
|
2806
3184
|
return;
|
|
2807
3185
|
}
|
|
2808
3186
|
html = html.replace(/__BARELOOP_PANEL_PORT__/g, String(opts.port));
|
|
3187
|
+
// the topbar reads `bareloop · v<version> · ...`: the version is bareloop's OWN package.json, read when the page is served
|
|
3188
|
+
// and templated in exactly like the port — never written into the HTML
|
|
3189
|
+
html = html.replace(/__BARELOOP_PANEL_VERSION__/g, packageVersion(opts.packageJsonPath));
|
|
2809
3190
|
// PANEL-BUILD.md P3's human-click guard: a per-server-start token,
|
|
2810
3191
|
// templated into the page exactly like the port — absent on every P1/P2
|
|
2811
3192
|
// caller (no `opts.token`), which is fine: the page's own JS only reads
|
|
@@ -2821,7 +3202,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2821
3202
|
return;
|
|
2822
3203
|
}
|
|
2823
3204
|
|
|
2824
|
-
if (pathname === '/api/runs') { send(200,
|
|
3205
|
+
if (pathname === '/api/runs') { send(200, runsListPayload({ home: opts.home, schedules: opts.schedules })); return; }
|
|
2825
3206
|
|
|
2826
3207
|
const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
|
|
2827
3208
|
if (runMatch) {
|
|
@@ -2840,7 +3221,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2840
3221
|
return;
|
|
2841
3222
|
}
|
|
2842
3223
|
if (sub === 'job') {
|
|
2843
|
-
const result = getRunJob(runid, { home: opts.home });
|
|
3224
|
+
const result = getRunJob(runid, { home: opts.home, schedules: opts.schedules });
|
|
2844
3225
|
if (!result) { sendText(res, 404, 'no such run'); return; }
|
|
2845
3226
|
send(200, result);
|
|
2846
3227
|
return;
|
|
@@ -2859,7 +3240,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2859
3240
|
send(200, result);
|
|
2860
3241
|
return;
|
|
2861
3242
|
}
|
|
2862
|
-
const result = getRunDetail(runid, { home: opts.home });
|
|
3243
|
+
const result = getRunDetail(runid, { home: opts.home, schedules: opts.schedules });
|
|
2863
3244
|
if (!result) { sendText(res, 404, 'no such run'); return; }
|
|
2864
3245
|
send(200, result);
|
|
2865
3246
|
return;
|
|
@@ -2887,8 +3268,9 @@ export function handleRequest(req, res, opts) {
|
|
|
2887
3268
|
* @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
|
|
2888
3269
|
* sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
|
|
2889
3270
|
* fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
|
|
2890
|
-
* openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string
|
|
2891
|
-
*
|
|
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> }>}
|
|
2892
3274
|
*/
|
|
2893
3275
|
export function createPanelServer(opts = {}) {
|
|
2894
3276
|
const requestedPort = opts.port ?? DEFAULT_PORT;
|
|
@@ -2909,10 +3291,14 @@ export function createPanelServer(opts = {}) {
|
|
|
2909
3291
|
let runRoutes;
|
|
2910
3292
|
/** @type {ReturnType<typeof createImportRoutes>|undefined} */
|
|
2911
3293
|
let importRoutes;
|
|
3294
|
+
/** @type {ReturnType<typeof createScheduler>|undefined} */
|
|
3295
|
+
let scheduler;
|
|
3296
|
+
/** @type {ReturnType<typeof createScheduleDoor>|undefined} */
|
|
3297
|
+
let schedulesRef;
|
|
2912
3298
|
const server = createServer((req, res) => {
|
|
2913
3299
|
try {
|
|
2914
3300
|
handleRequest(req, res, {
|
|
2915
|
-
home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes,
|
|
3301
|
+
home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
|
|
2916
3302
|
});
|
|
2917
3303
|
} catch (e) {
|
|
2918
3304
|
sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
|
|
@@ -2926,7 +3312,22 @@ export function createPanelServer(opts = {}) {
|
|
|
2926
3312
|
server.listen(requestedPort, '127.0.0.1', () => {
|
|
2927
3313
|
const addr = server.address();
|
|
2928
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;
|
|
2929
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 } : {}),
|
|
2930
3331
|
port: boundPort,
|
|
2931
3332
|
token,
|
|
2932
3333
|
env: opts.env,
|
|
@@ -2941,6 +3342,18 @@ export function createPanelServer(opts = {}) {
|
|
|
2941
3342
|
listJobs: (o = {}) => listReuseJobs({ home, ...o }),
|
|
2942
3343
|
},
|
|
2943
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();
|
|
2944
3357
|
settingsRoutes = createSettingsRoutes({
|
|
2945
3358
|
port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
|
|
2946
3359
|
openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
|
|
@@ -2955,6 +3368,10 @@ export function createPanelServer(opts = {}) {
|
|
|
2955
3368
|
settleMs: opts.settleMs,
|
|
2956
3369
|
getResumeContext: (runid) => getResumeContext(runid, { home }),
|
|
2957
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 } : {}),
|
|
2958
3375
|
});
|
|
2959
3376
|
importRoutes = createImportRoutes({
|
|
2960
3377
|
port: boundPort, token, home, userHome: opts.userHome, describeSpec,
|
|
@@ -2963,7 +3380,9 @@ export function createPanelServer(opts = {}) {
|
|
|
2963
3380
|
server,
|
|
2964
3381
|
port: boundPort,
|
|
2965
3382
|
token,
|
|
2966
|
-
|
|
3383
|
+
schedules,
|
|
3384
|
+
scheduler: /** @type {ReturnType<typeof createScheduler>} */ (scheduler),
|
|
3385
|
+
close: () => new Promise((res2) => { scheduler?.stop(); server.close(() => res2(undefined)); }),
|
|
2967
3386
|
});
|
|
2968
3387
|
});
|
|
2969
3388
|
});
|