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.
Files changed (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bareloop.context.md +53 -30
  3. package/package.json +1 -1
  4. package/src/authorjob.js +6 -0
  5. package/src/declaredclose.js +2 -2
  6. package/src/interviewrun.js +0 -1
  7. package/src/job.js +38 -11
  8. package/src/panel/authorroutes.js +107 -33
  9. package/src/panel/authorsession.js +59 -27
  10. package/src/panel/index.html +813 -484
  11. package/src/panel/runroutes.js +163 -5
  12. package/src/panel/scheduler.js +156 -0
  13. package/src/panel/schedules.js +667 -0
  14. package/src/panel/schedulestart.js +243 -0
  15. package/src/panel/server.js +451 -48
  16. package/src/planrun.js +39 -7
  17. package/src/providerrows.js +3 -3
  18. package/src/reviewdoor.js +37 -0
  19. package/src/runlist.js +35 -0
  20. package/src/schedulewords.js +296 -0
  21. package/src/trigger.js +48 -0
  22. package/src/triggerclock.js +127 -0
  23. package/src/triggerfiles.js +215 -0
  24. package/src/triggerledger.js +79 -0
  25. package/src/userrun.js +6 -7
  26. package/types/declaredclose.d.ts +1 -1
  27. package/types/job.d.ts +8 -1
  28. package/types/panel/authorroutes.d.ts +34 -1
  29. package/types/panel/authorsession.d.ts +37 -12
  30. package/types/panel/runroutes.d.ts +32 -1
  31. package/types/panel/scheduler.d.ts +17 -0
  32. package/types/panel/schedules.d.ts +412 -0
  33. package/types/panel/schedulestart.d.ts +106 -0
  34. package/types/panel/server.d.ts +143 -14
  35. package/types/planrun.d.ts +2 -0
  36. package/types/providerrows.d.ts +2 -2
  37. package/types/reviewdoor.d.ts +27 -0
  38. package/types/runlist.d.ts +20 -0
  39. package/types/schedulewords.d.ts +135 -0
  40. package/types/trigger.d.ts +37 -0
  41. package/types/triggerclock.d.ts +52 -0
  42. package/types/triggerfiles.d.ts +93 -0
  43. package/types/triggerledger.d.ts +35 -0
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The spec one fire runs: the signed schedule's spec; for a file cause, Inputs line 1 is the arriving file. Every other
3
+ * field must be deep-equal and the workflow key unchanged (F.4): the person signed the SCHEDULE, each fire's hash is
4
+ * derived by code. A clock fire's spec equals the signed spec byte for byte (same hash).
5
+ * @param {{spec: any, specHash: string, workflowKey: string}} schedule
6
+ * @param {{kind: 'clock'}|{kind: 'file', path: string}} cause
7
+ * @returns {{ok: true, spec: any, hash: string}|{ok: false, why: string}}
8
+ */
9
+ export function perFireSpec(schedule: {
10
+ spec: any;
11
+ specHash: string;
12
+ workflowKey: string;
13
+ }, cause: {
14
+ kind: "clock";
15
+ } | {
16
+ kind: "file";
17
+ path: string;
18
+ }): {
19
+ ok: true;
20
+ spec: any;
21
+ hash: string;
22
+ } | {
23
+ ok: false;
24
+ why: string;
25
+ };
26
+ /**
27
+ * @param {{
28
+ * door: ReturnType<typeof import('./schedules.js').createScheduleDoor>,
29
+ * startSession: ReturnType<typeof import('./authorroutes.js').createAuthorRoutes>['startSession'],
30
+ * envNow: () => Record<string, string|undefined>,
31
+ * spawnFn: Function, bareloopBin: string, sessionsRoot?: string, home?: string,
32
+ * rowsFor: () => readonly import('../providerrows.js').KeyRow[],
33
+ * startFrom: (runid: string) => any,
34
+ * now?: () => number, prepareTimeoutMs?: number, pollMs?: number, childGraceMs?: number,
35
+ * forgetSession?: (id: string) => void, ranSince?: (job: string, sinceMs: number) => boolean,
36
+ * }} deps
37
+ */
38
+ export function createStarter(deps: {
39
+ door: ReturnType<typeof import("./schedules.js").createScheduleDoor>;
40
+ startSession: ReturnType<typeof import("./authorroutes.js").createAuthorRoutes>["startSession"];
41
+ envNow: () => Record<string, string | undefined>;
42
+ spawnFn: Function;
43
+ bareloopBin: string;
44
+ sessionsRoot?: string;
45
+ home?: string;
46
+ rowsFor: () => readonly import("../providerrows.js").KeyRow[];
47
+ startFrom: (runid: string) => any;
48
+ now?: () => number;
49
+ prepareTimeoutMs?: number;
50
+ pollMs?: number;
51
+ childGraceMs?: number;
52
+ forgetSession?: (id: string) => void;
53
+ ranSince?: (job: string, sinceMs: number) => boolean;
54
+ }): {
55
+ /**
56
+ * Cheap, $0, no copy, no session. `{ok: true}` or `{ok: false, why}` (words).
57
+ * @param {string} job
58
+ * @param {{kind: 'clock'} | {kind: 'file', path: string, sha: string}} cause
59
+ * @returns {{ok: true}|{ok: false, why: string}}
60
+ */
61
+ wouldAccept(job: string, cause: {
62
+ kind: "clock";
63
+ } | {
64
+ kind: "file";
65
+ path: string;
66
+ sha: string;
67
+ }): {
68
+ ok: true;
69
+ } | {
70
+ ok: false;
71
+ why: string;
72
+ };
73
+ /**
74
+ * Start the run. Never throws. `consumed` says whether `beforeSpawn` ran, so the caller knows whether a `released` row is owed.
75
+ * @param {string} job
76
+ * @param {{kind: 'clock', slot: number, catchUp: boolean, missed: number} | {kind: 'file', path: string, sha: string}} cause
77
+ * @param {{beforeSpawn?: () => void}} [hooks]
78
+ * @returns {Promise<{ok: true, sessionId: string, specHash: string, consumed: true}|{ok: false, why: string, consumed: boolean, installNeeded?: true}>}
79
+ */
80
+ start(job: string, cause: {
81
+ kind: "clock";
82
+ slot: number;
83
+ catchUp: boolean;
84
+ missed: number;
85
+ } | {
86
+ kind: "file";
87
+ path: string;
88
+ sha: string;
89
+ }, hooks?: {
90
+ beforeSpawn?: () => void;
91
+ }): Promise<{
92
+ ok: true;
93
+ sessionId: string;
94
+ specHash: string;
95
+ consumed: true;
96
+ } | {
97
+ ok: false;
98
+ why: string;
99
+ consumed: boolean;
100
+ installNeeded?: true;
101
+ }>;
102
+ };
103
+ /** how long a headless session may take to reach `prepared` (copy, install, signing gates) */
104
+ export const PREPARE_TIMEOUT_MS: number;
105
+ /** how long the starter listens for a child that refuses at once (run-u checks its spec and approve hash first thing) */
106
+ export const CHILD_GRACE_MS: 1500;
@@ -116,7 +116,7 @@ export function legDividersFor(spineRecords: any[], parts: any[]): {
116
116
  *
117
117
  * The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
118
118
  * Resume button appears only when `o.resume` says the engine would accept it; "Reuse workflow"
119
- * (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
119
+ * (replaces P5 item 3, 2026-10-03; one door on every ending since 2026-10-15) starts a NEW run.
120
120
  * @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
121
121
  * @param {{died: boolean, lastThing: string|null}} death
122
122
  * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
@@ -155,6 +155,28 @@ export function endedFor(summary: {
155
155
  label: string;
156
156
  }[];
157
157
  } | null;
158
+ /**
159
+ * 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.
160
+ * 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
161
+ * brings the run back — or after `needsYouDays`. `removedMs` is the newest removal that covers the current ending (or null), `expired` is true when only
162
+ * the age rule took a Resume run out.
163
+ * @param {{live: boolean, records: any[], resume: {ok?: boolean}|null|undefined, stoppedAt: string|null, nowMs: number, removedMs?: number}} f
164
+ * @returns {{needs: 'Accept'|'Resume'|null, removedMs: number|null, expired: boolean}}
165
+ */
166
+ export function needsStateFor(f: {
167
+ live: boolean;
168
+ records: any[];
169
+ resume: {
170
+ ok?: boolean;
171
+ } | null | undefined;
172
+ stoppedAt: string | null;
173
+ nowMs: number;
174
+ removedMs?: number;
175
+ }): {
176
+ needs: "Accept" | "Resume" | null;
177
+ removedMs: number | null;
178
+ expired: boolean;
179
+ };
158
180
  /**
159
181
  * The Resume route's reader (P5 item 2): the listed row plus the resume plan, from the
160
182
  * ONE {@link resumePlanFor} the Ended block asks. `null` when the runid is not listed
@@ -184,6 +206,43 @@ export function getStopContext(runid: string, opts?: {
184
206
  live: boolean;
185
207
  spineExists: boolean;
186
208
  } | null;
209
+ /**
210
+ * 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
211
+ * (`summarizeRow`, the one rule). `null` when the runid is not listed.
212
+ * @param {string} runid
213
+ * @param {{ home?: string, now?: number }} [opts]
214
+ * @returns {{row: any, needs: 'Accept'|'Resume'|null}|null}
215
+ */
216
+ export function getNeedsContext(runid: string, opts?: {
217
+ home?: string;
218
+ now?: number;
219
+ }): {
220
+ row: any;
221
+ needs: "Accept" | "Resume" | null;
222
+ } | null;
223
+ /**
224
+ * 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
225
+ * tab's Resume card shows, already asked once per row by `endedForRow`), until the run has been stopped `needsYouDays` (exactly that old is out;
226
+ * no readable stop time stays in). `Accept` comes from {@link doorWaiting} (T Part 8), asked in `summarizeRow`, never here.
227
+ * @param {{ok?: boolean}|null|undefined} resume @param {string|null} stoppedAt @param {number} nowMs
228
+ * @returns {'Accept'|'Resume'|null}
229
+ */
230
+ export function needsTag(resume: {
231
+ ok?: boolean;
232
+ } | null | undefined, stoppedAt: string | null, nowMs: number): "Accept" | "Resume" | null;
233
+ /**
234
+ * 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
235
+ * 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
236
+ * run is never quiet; an unknown age is never guessed into quiet.
237
+ * @param {any[]} rows `listRuns` rows @param {number} nowMs
238
+ * @returns {{job: string, headRunid: string|null, quiet: boolean, activityMs: number|null}[]}
239
+ */
240
+ export function summarizeJobs(rows: any[], nowMs: number): {
241
+ job: string;
242
+ headRunid: string | null;
243
+ quiet: boolean;
244
+ activityMs: number | null;
245
+ }[];
187
246
  /**
188
247
  * `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
189
248
  * append order — a backfill scan appends rows in sorted-PATH order, which
@@ -197,12 +256,53 @@ export function getStopContext(runid: string, opts?: {
197
256
  * SAME payload by job client-side (`groupRunsByJob` in `index.html`) rather
198
257
  * than a second server-side endpoint, so the glyph/checkType mapping is
199
258
  * still computed in exactly one place.
200
- * @param {{ home?: string }} [opts]
259
+ * @param {{ home?: string, now?: number }} [opts] `now` (ms) is injectable for the Needs-you age rule
201
260
  * @returns {any[]}
202
261
  */
203
262
  export function listRuns(opts?: {
204
263
  home?: string;
264
+ now?: number;
205
265
  }): any[];
266
+ /**
267
+ * `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
268
+ * quiet flag, the limits — and the page works out nothing. `scheduled` is the triggers piece (PRD v1.99, T Part 7).
269
+ * @param {{ home?: string, now?: number, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
270
+ */
271
+ export function runsListPayload(opts?: {
272
+ home?: string;
273
+ now?: number;
274
+ schedules?: ReturnType<typeof createScheduleDoor>;
275
+ }): {
276
+ runs: any[];
277
+ jobs: {
278
+ job: string;
279
+ headRunid: string | null;
280
+ quiet: boolean;
281
+ activityMs: number | null;
282
+ }[];
283
+ scheduled: any[];
284
+ gaveUp: any[];
285
+ limits: Readonly<{
286
+ quietDays: 30;
287
+ olderRuns: 7;
288
+ needsYouDays: 7;
289
+ }>;
290
+ };
291
+ /**
292
+ * T Part 8 — what the Accept route needs: the run's row, whether its runner is alive, whether it waits at its review door
293
+ * ({@link doorWaiting}), and the signed spec it was judged by (the file and hash `run-u --door --approve` takes).
294
+ * @param {string} runid @param {{ home?: string }} opts
295
+ * @returns {{row: any, live: boolean, waiting: boolean, specPath: string|null, specHash: string|null}|null}
296
+ */
297
+ export function getAcceptContext(runid: string, opts?: {
298
+ home?: string;
299
+ }): {
300
+ row: any;
301
+ live: boolean;
302
+ waiting: boolean;
303
+ specPath: string | null;
304
+ specHash: string | null;
305
+ } | null;
206
306
  /**
207
307
  * `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
208
308
  * iterations), counters, summary-box fields. Looks `runid` up in the run
@@ -210,11 +310,12 @@ export function listRuns(opts?: {
210
310
  * header); `null` when the runid is not in the list at all (caller renders
211
311
  * 404).
212
312
  * @param {string} runid
213
- * @param {{ home?: string }} [opts]
313
+ * @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
214
314
  * @returns {any|null}
215
315
  */
216
316
  export function getRunDetail(runid: string, opts?: {
217
317
  home?: string;
318
+ schedules?: ReturnType<typeof createScheduleDoor>;
218
319
  }): any | null;
219
320
  /**
220
321
  * `GET /api/runs/:runid/audit` — the Audit tab's rows, off the run's own
@@ -343,11 +444,12 @@ export function inputsTextFromSpec(spec: any, source: string): string;
343
444
  * from the resolved SPEC only (a) (b) (c) — a job-start record and `none()`
344
445
  * carry no close/writeScope/tools at all, so those stay `'not recorded'`.
345
446
  * @param {string} runid
346
- * @param {{ home?: string }} [opts]
447
+ * @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
347
448
  * @returns {any|null}
348
449
  */
349
450
  export function getRunJob(runid: string, opts?: {
350
451
  home?: string;
452
+ schedules?: ReturnType<typeof createScheduleDoor>;
351
453
  }): any | null;
352
454
  /**
353
455
  * The track record of a WORKFLOW: every LISTED run whose signed spec (the one its latest leg ran under) has this
@@ -395,14 +497,14 @@ export function reuseLine(record: {
395
497
  } | null): string;
396
498
  /**
397
499
  * `start-from` (the route keeps its name) for one listed run: the card prefilled from the run's SIGNED job, which
398
- * boxes are locked and which are open, and the track record. Locked boxes carry the signed values, read by the
500
+ * the track record (v1.101: nothing is locked, every box is open). The job boxes carry the signed values, read by the
399
501
  * SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
400
502
  * $ cap and the Time cap — start at what the run had. `ok:false` when the run's signed job is not on disk (nothing
401
503
  * to copy: change the job: Clear the card). `null` when the runid is not listed.
402
504
  * @param {string} runid
403
505
  * @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
404
506
  * @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
405
- * locked: readonly string[], open: readonly string[], note: string|null,
507
+ * note: string|null,
406
508
  * specHash: string, workflowKey: string, spec: any, specPath: string, specNamesModel: boolean,
407
509
  * specWorker: {provider: string, endpoint: string, label: string},
408
510
  * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
@@ -420,8 +522,6 @@ export function getStartFrom(runid: string, opts?: {
420
522
  outcome: string | null;
421
523
  card: Record<string, any>;
422
524
  from: "signed job";
423
- locked: readonly string[];
424
- open: readonly string[];
425
525
  note: string | null;
426
526
  specHash: string;
427
527
  workflowKey: string;
@@ -439,6 +539,21 @@ export function getStartFrom(runid: string, opts?: {
439
539
  ok: false;
440
540
  error: string;
441
541
  } | null;
542
+ /**
543
+ * T Part 3: a job's listed runs, newest first, each with its signed spec (null when not on disk), its terminal outcome and
544
+ * its parsed spine. The schedule door's gate (`greenFor`) reads this; nothing here builds a run context.
545
+ * @param {string} job
546
+ * @param {{ home?: string }} [opts]
547
+ * @returns {{runid: string, outcome: string|null, spec: any, records: any[]}[]}
548
+ */
549
+ export function jobRunsOf(job: string, opts?: {
550
+ home?: string;
551
+ }): {
552
+ runid: string;
553
+ outcome: string | null;
554
+ spec: any;
555
+ records: any[];
556
+ }[];
442
557
  /**
443
558
  * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
444
559
  * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
@@ -469,7 +584,7 @@ export function listReuseJobs(opts?: {
469
584
  * @param {string} id
470
585
  * @param {{ home?: string, model?: string, provider?: string }} [opts] `model` as for {@link getStartFrom}
471
586
  * @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
472
- * locked: readonly string[], open: readonly string[], note: string|null, specHash: string, workflowKey: string, spec: any,
587
+ * note: string|null, specHash: string, workflowKey: string, spec: any,
473
588
  * specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
474
589
  */
475
590
  export function getStartFromImport(id: string, opts?: {
@@ -485,8 +600,6 @@ export function getStartFromImport(id: string, opts?: {
485
600
  };
486
601
  card: Record<string, any>;
487
602
  from: "imported job";
488
- locked: readonly string[];
489
- open: readonly string[];
490
603
  note: string | null;
491
604
  specHash: string;
492
605
  workflowKey: string;
@@ -514,7 +627,7 @@ export function getStartFromImport(id: string, opts?: {
514
627
  * only `createPanelServer` below always supplies one.
515
628
  * @param {import('node:http').IncomingMessage} req
516
629
  * @param {import('node:http').ServerResponse} res
517
- * @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
630
+ * @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
518
631
  */
519
632
  export function handleRequest(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, opts: {
520
633
  home?: string;
@@ -525,6 +638,7 @@ export function handleRequest(req: import("node:http").IncomingMessage, res: imp
525
638
  settingsRoutes?: ReturnType<typeof createSettingsRoutes>;
526
639
  runRoutes?: ReturnType<typeof createRunRoutes>;
527
640
  importRoutes?: ReturnType<typeof createImportRoutes>;
641
+ schedules?: ReturnType<typeof createScheduleDoor>;
528
642
  }): void;
529
643
  /**
530
644
  * Start the panel server. Binds `127.0.0.1` ONLY. A taken port is a LOUD,
@@ -545,8 +659,9 @@ export function handleRequest(req: import("node:http").IncomingMessage, res: imp
545
659
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
546
660
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
547
661
  * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
548
- * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string }} [opts]
549
- * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
662
+ * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string,
663
+ * 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)
664
+ * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, schedules: ReturnType<typeof createScheduleDoor>, scheduler: ReturnType<typeof createScheduler>, close: () => Promise<void> }>}
550
665
  */
551
666
  export function createPanelServer(opts?: {
552
667
  port?: number;
@@ -561,10 +676,16 @@ export function createPanelServer(opts?: {
561
676
  openFolderSpawn?: (cmd: string, args: string[], o: object) => any;
562
677
  platform?: string;
563
678
  packageJsonPath?: string;
679
+ scheduleNow?: () => number;
680
+ sessionDeps?: Record<string, any>;
681
+ schedulerIntervalMs?: number;
682
+ acceptWaitMs?: number;
564
683
  }): Promise<{
565
684
  server: import("node:http").Server;
566
685
  port: number;
567
686
  token: string;
687
+ schedules: ReturnType<typeof createScheduleDoor>;
688
+ scheduler: ReturnType<typeof createScheduler>;
568
689
  close: () => Promise<void>;
569
690
  }>;
570
691
  /**
@@ -604,13 +725,21 @@ export const DEFAULT_PORT: 4700;
604
725
  */
605
726
  export const SOFTGREEN_ADMITTED_ISO: "2026-08-18T00:00:00.000Z";
606
727
  export { DIED_MTIME_MS };
728
+ /** 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. */
729
+ export const RUNS_LIST_LIMITS: Readonly<{
730
+ quietDays: 30;
731
+ olderRuns: 7;
732
+ needsYouDays: 7;
733
+ }>;
607
734
  /** The default/max page size for {@link getRunRounds} — tighten-only,
608
735
  * named so a future change is a deliberate, visible edit (item 5,
609
736
  * 2026-09-25). */
610
737
  export const ROUNDS_PAGE_DEFAULT: 50;
611
738
  export const ROUNDS_PAGE_MAX: 200;
739
+ import { createScheduleDoor } from './schedules.js';
612
740
  import { createAuthorRoutes } from './authorroutes.js';
613
741
  import { createSettingsRoutes } from './settingsroutes.js';
614
742
  import { createRunRoutes } from './runroutes.js';
615
743
  import { createImportRoutes } from './importroutes.js';
744
+ import { createScheduler } from './scheduler.js';
616
745
  import { DIED_MTIME_MS } from '../runlist.js';
@@ -523,6 +523,8 @@ export const FORBIDDEN_WRITE_SEGMENT_PATTERN: RegExp;
523
523
  export const BOUND_REASON_MAX: 200;
524
524
  /** the recorded reason for a step attempt whose round was cut at the output cap (F122 2A b) */
525
525
  export const TRUNCATED_REASON: "output cut at the token limit (max_tokens)";
526
+ /** the plain sentence for a replan answer cut off at its length limit (hamr 2026-10-16, run mv9gm796) */
527
+ export const REPLAN_CUT_WORDS: "The replan answer was cut off at its length limit, so no new plan came back.";
526
528
  export type CategorizedError = Error & {
527
529
  category?: string;
528
530
  lib?: string;
@@ -251,8 +251,8 @@ export function judgeRowFor(rows: readonly KeyRow[], judge: {
251
251
  * the worker's own provider it calls the worker's endpoint, so a row on that endpoint with a line
252
252
  * NAMED for the judge's model prices it ({@link ratesFor}); a judge model that is absent or names
253
253
  * no line there keeps the worker's price (the worker's row, or none = the guess). A different
254
- * provider resolves with no endpoint. The judge's KEY is chosen elsewhere (`applyConfiguredKey`,
255
- * by the worker's model), so the two can come from different rows.
254
+ * provider resolves with no endpoint. The judge's KEY is chosen elsewhere (`judgeRowFor`:
255
+ * the worker's own row when the judge shares its provider), so the two can come from different rows.
256
256
  * @param {{ provider: string, model?: string|null }} judge
257
257
  * @param {string|null|undefined} workerProvider
258
258
  * @param {string|null|undefined} workerBaseUrl
@@ -4,6 +4,33 @@
4
4
  * @param {any[]} events the run's spine, parsed
5
5
  * @returns {any|null} */
6
6
  export function doorRecordOf(events: any[]): any | null;
7
+ /**
8
+ * Was this run's door ACCEPTED, honoured? A `door-decision` record with `decision: 'accept'` and `honoured: true`
9
+ * (written by {@link answerReviewDoor}, whichever surface asked: the CLI, or the panel's Accept). Pure, off the
10
+ * run's spine. A refused accept (`honoured: false`) does not count.
11
+ * @param {any[]} events the run's spine, parsed
12
+ * @returns {boolean}
13
+ */
14
+ export function doorAccepted(events: any[]): boolean;
15
+ /**
16
+ * T D.2 — a SCHEDULED run's green is held until the person accepts it (T10): a run that carries a `trigger.json` (a scheduled
17
+ * start wrote it) and holds no honoured accept. One shared predicate: `getStartFrom` reads its outcome as `'green-held'` (so
18
+ * the Reuse start refuses it unchanged), `listReuseJobs` skips it, and the schedule gate does not count it. A hand run is never held.
19
+ * @param {any[]} events the run's spine, parsed @param {any} triggerRec the run's `trigger.json`, or null
20
+ * @returns {boolean}
21
+ */
22
+ export function heldScheduled(events: any[], triggerRec: any): boolean;
23
+ /**
24
+ * Is this run WAITING for the person's Accept? Its spine holds a door record, the door has not expired ({@link doorAgeGate}),
25
+ * and no honoured `accept` or `rerun` decision follows it (a `pause` keeps the same three doors open). One owner: the runs
26
+ * list's Needs-you `Accept` tag, the Run tab's Accept button and the scheduled NEXT line all read this.
27
+ * @param {any[]} events the run's spine, parsed
28
+ * @param {{now?: () => number}} [opts]
29
+ * @returns {boolean}
30
+ */
31
+ export function doorWaiting(events: any[], opts?: {
32
+ now?: () => number;
33
+ }): boolean;
7
34
  /**
8
35
  * IS THE DOOR STILL OPEN? The same 60-day TTL a hitl checkpoint keeps
9
36
  * (`PAUSE_TTL_MS`) and deliberately the same NUMBER — the door replaced `cancel`
@@ -67,6 +67,26 @@ export function appendRun(row: RunRow, opts?: {
67
67
  export function appendRunEvent(event: RunEvent, opts?: {
68
68
  home?: string;
69
69
  }): void;
70
+ /**
71
+ * RULED 2026-10-15 ("Remove from Needs you"): the person took a stopped run's card out of the Needs-you list. ONE row
72
+ * `{runid, type:'needs-removed', at}` appended beside the run rows; it is not a run, a claim or an event ({@link readRunList}
73
+ * ignores it, so the monthly limit, the run counter and the scheduler never see it). Nothing is rewritten.
74
+ * @param {string} runid
75
+ * @param {{ home?: string, now?: () => number }} [opts]
76
+ * @returns {string} the `at` written
77
+ */
78
+ export function appendNeedsRemoved(runid: string, opts?: {
79
+ home?: string;
80
+ now?: () => number;
81
+ }): string;
82
+ /**
83
+ * The newest `needs-removed` time (ms) per run id. An absent file is an empty map; a row with no readable time is not a removal.
84
+ * @param {{ home?: string }} [opts]
85
+ * @returns {Map<string, number>}
86
+ */
87
+ export function readNeedsRemovals(opts?: {
88
+ home?: string;
89
+ }): Map<string, number>;
70
90
  /**
71
91
  * @typedef {{ type: 'leg-start', runid: string, leg: number, pid?: number, capUsd?: number, at: string }} LegStart
72
92
  * A resumed run's NEW claim (P5-R, hamr 2026-10-02: one run, one id, one row): appended beside the run's row
@@ -0,0 +1,135 @@
1
+ /**
2
+ * What starts the job, in words. `null` (by hand) has no sentence.
3
+ * @param {Trigger|null|undefined} t @param {string} [home]
4
+ * @returns {string|null}
5
+ */
6
+ export function scheduledWords(t: Trigger | null | undefined, home?: string): string | null;
7
+ /** "Mon 13 Oct, 09:00" — local time, fixed English names (never the machine's locale). @param {number} ms */
8
+ export function slotWords(ms: number): string;
9
+ /**
10
+ * The Scheduled card's first line: "today 17:00", "tomorrow 09:00", else the date the NEXT line already says
11
+ * ("Tue 20 Oct, 09:05"). Local time, by calendar day. @param {number} ms the next run @param {number} now
12
+ */
13
+ export function nextTimeWords(ms: number, now: number): string;
14
+ /** "next in 20m" / "next in 3h" / "next in 3d" (whole units, rounded down, never "0m"). @param {number} ms @param {number} now */
15
+ export function nextInWords(ms: number, now: number): string;
16
+ /**
17
+ * NEXT of a scheduled job: one sentence, first match wins.
18
+ * 1. a run of this job is waiting for the person's Accept (the next scheduled run does NOT wait for it, T7);
19
+ * 2. the latest outcome this month is `monthly-exhausted` (amounts from the row), RED;
20
+ * 3. a clock kind with a next slot; 4. a file kind that is armed; 5. not armed yet.
21
+ * A last outcome of `refused` goes in front of 3-5, so a trigger that cannot start is never silent.
22
+ * `red` is the typed flag for "the person must act": the page paints it and never guesses from the text.
23
+ * @param {{trigger: Trigger, armed: boolean, nextSlotMs: number|null, lastOutcome: any, waitingRunNo: number|null, now: number, retryInMs?: number|null, gaveUp?: any}} f
24
+ * `lastOutcome` = the newest outcome row of the CURRENT signing (`kind`, `at` in ms, `why`, `leftUsd`, `needUsd`, `atLeast`)
25
+ * @returns {{text: string, red: boolean}}
26
+ */
27
+ export function scheduledNextFor(f: {
28
+ trigger: Trigger;
29
+ armed: boolean;
30
+ nextSlotMs: number | null;
31
+ lastOutcome: any;
32
+ waitingRunNo: number | null;
33
+ now: number;
34
+ retryInMs?: number | null;
35
+ gaveUp?: any;
36
+ }): {
37
+ text: string;
38
+ red: boolean;
39
+ };
40
+ /** Is `atMs` in the same local calendar month as `nowMs`? A non-number is never. @param {unknown} atMs @param {number} nowMs */
41
+ export function sameMonthAs(atMs: unknown, nowMs: number): boolean;
42
+ /** RULED 2026-10-15: the red NEXT of a schedule that gave up. @param {unknown} why the refusal's own words */
43
+ export function gaveUpNextWords(why: unknown): string;
44
+ /** The give-up date on the Needs-you card, local `MM/DD HH:MM`. @param {number} ms */
45
+ export function gaveUpDateWords(ms: number): string;
46
+ /** RULED 2026-10-15: after an install-needed refusal, the wait that is left, in whole minutes rounded up; '' when no wait. @param {number|null|undefined} ms */
47
+ export function retryWords(ms: number | null | undefined): string;
48
+ /** The fixed catch-up sentence from a run's `trigger.json`. @param {any} rec */
49
+ export function catchUpWords(rec: any): "This run was a catch-up: it started more than 2 minutes after its set time (the panel was off)." | null;
50
+ /**
51
+ * A spawn error AFTER the consuming row: `tries` releases so far, `max` the bound. Under the bound it will be tried again;
52
+ * at the bound it stays consumed and says so. @param {string} why @param {number} tries @param {number} max
53
+ */
54
+ export function lateRefusalWords(why: string, tries: number, max: number): string;
55
+ /** "Thu 10/16 08:15" — local time, fixed English day names. @param {number} ms */
56
+ export function nextSlotShort(ms: number): string;
57
+ /**
58
+ * Job tab, "Started": how the run began, then its local start time. `rec` = the run's `trigger.json` (null on a hand run).
59
+ * @param {any} rec @param {number|null} atMs the run's start
60
+ */
61
+ export function startedWords(rec: any, atMs: number | null): string;
62
+ /**
63
+ * Audit, first row of a run a schedule started. @param {any} rec `trigger.json` @param {number|null} originNo the origin run's number
64
+ */
65
+ export function startedAuditWords(rec: any, originNo: number | null): string;
66
+ /**
67
+ * A fire that started nothing, in plain words. `short` = the SCHEDULE row's `last` reason; else the Audit group's line.
68
+ * @param {any} row a ledger row (`busy` | `monthly-exhausted` | `refused` | `gave-up`) @param {boolean} [short]
69
+ */
70
+ export function skippedWords(row: any, short?: boolean): string;
71
+ /**
72
+ * Job tab, "Schedule": the job's schedule NOW and what it is bound to. The first part IS the Run tab's line ({@link scheduledWords}),
73
+ * first letter lowered, so the two can never disagree.
74
+ * @param {{trigger: Trigger|null, originNo: number|null, model: string|null, destination: string|null, home?: string}} f
75
+ */
76
+ export function jobScheduleWords(f: {
77
+ trigger: Trigger | null;
78
+ originNo: number | null;
79
+ model: string | null;
80
+ destination: string | null;
81
+ home?: string;
82
+ }): string;
83
+ /** RULED 2026-10-15: the SCHEDULE row's `next` while an install-needed refusal is waiting to be tried again. @param {number} ms */
84
+ export function retryNextWords(ms: number): string;
85
+ /**
86
+ * RULED 2026-10-15: the sign on the head of the Audit's `schedule` section. ONE small rule (hamr may still adjust it): [▶] only while a
87
+ * run of the job is running (as everywhere in the panel), [✗] when the SCHEDULE row is red, else [·] (on, waiting).
88
+ * @param {{running: boolean, red: boolean}} f @returns {{sign: string}}
89
+ */
90
+ export function scheduleSignFor(f: {
91
+ running: boolean;
92
+ red: boolean;
93
+ }): {
94
+ sign: string;
95
+ };
96
+ /** Audit schedule section, the `this run` words of a run the schedule started (the head already says which schedule and from where). @param {any} rec `trigger.json` */
97
+ export function sectionThisRunWords(rec: any): string;
98
+ /** Audit schedule section, folded tail: `1 try started nothing` / `N tries started nothing`; '' for none. @param {number} n */
99
+ export function triesTailWords(n: number): string;
100
+ /**
101
+ * Every schedule button/word the page shows lives here, so a reword is ONE edit (hamr will reword on his walk).
102
+ * The server sends this table with the run detail and the schedule card; the page reads every label from it.
103
+ */
104
+ export const SCHEDULE_WORDS: Readonly<{
105
+ byHand: "By hand";
106
+ cancelSchedule: "Cancel schedule";
107
+ cancelWords: "Cancel schedule";
108
+ scheduleRun: "Schedule run";
109
+ cancelModify: "Cancel / modify schedule";
110
+ signSchedule: "Sign schedule";
111
+ nothingChanged: "Nothing changed yet. This card only sets or adjusts the schedule; it never runs the job.";
112
+ confirmPrefix: "Confirm: ";
113
+ whenLabel: "When";
114
+ nowPrefix: "Now: ";
115
+ scheduledLabel: "SCHEDULE";
116
+ nextLabel: "NEXT";
117
+ options: Readonly<{
118
+ hand: "By hand";
119
+ daily: "Every day at HH:MM";
120
+ weekdays: "Weekdays at HH:MM";
121
+ weekly: "Every <day> at HH:MM";
122
+ 'file-updated': "When <file> is updated";
123
+ 'new-file': "When a new file arrives in <folder>";
124
+ }>;
125
+ gaveUpPill: "Check";
126
+ problemNext: "next: fix it in Cancel / modify schedule";
127
+ monthNext: "next: raise the limit in Settings, or it starts on the 1st";
128
+ acceptWaiting: "waiting for your Accept (the next run does not wait)";
129
+ scheduleGroup: "schedule";
130
+ notGreen: "This job has not had a good run yet. Run it by hand and get it right first; then you can set when it runs. A rubric job also needs your Accept once. Nothing was changed.";
131
+ stale: "What you were shown is not what is on the card now. Nothing was signed. Look again, then sign.";
132
+ }>;
133
+ export function dateTimeWords(ms: number): string;
134
+ export function lowerFirst(s: string): string;
135
+ export type Trigger = import("./trigger.js").Trigger;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The only way a trigger enters the system. Returns the typed trigger, or says what to fix (and which field).
3
+ * @param {unknown} t
4
+ * @returns {{ok: true, trigger: Trigger} | {ok: false, say: string, code: 'invalid-value'|'unknown-field', path: string}}
5
+ */
6
+ export function validateTrigger(t: unknown): {
7
+ ok: true;
8
+ trigger: Trigger;
9
+ } | {
10
+ ok: false;
11
+ say: string;
12
+ code: "invalid-value" | "unknown-field";
13
+ path: string;
14
+ };
15
+ /** @typedef {import('./triggerclock.js').Schedule | {kind:'new-file', folder:string} | {kind:'file-updated', path:string}} Trigger */
16
+ export const TRIGGER_KINDS: readonly string[];
17
+ export const CLOCK_KINDS: readonly string[];
18
+ export const FILE_KINDS: readonly string[];
19
+ export function isClockTrigger(t: unknown): t is {
20
+ kind: "daily" | "weekdays" | "weekly";
21
+ hour: number;
22
+ minute: number;
23
+ };
24
+ export function isFileTrigger(t: unknown): t is {
25
+ kind: "new-file";
26
+ folder: string;
27
+ } | {
28
+ kind: "file-updated";
29
+ path: string;
30
+ };
31
+ export type Trigger = import("./triggerclock.js").Schedule | {
32
+ kind: "new-file";
33
+ folder: string;
34
+ } | {
35
+ kind: "file-updated";
36
+ path: string;
37
+ };