bareloop 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/bareloop.context.md +53 -30
- package/package.json +1 -1
- package/src/authorjob.js +6 -0
- package/src/declaredclose.js +2 -2
- package/src/interviewrun.js +0 -1
- package/src/job.js +38 -11
- package/src/panel/authorroutes.js +107 -33
- package/src/panel/authorsession.js +59 -27
- package/src/panel/index.html +813 -484
- package/src/panel/runroutes.js +163 -5
- package/src/panel/scheduler.js +156 -0
- package/src/panel/schedules.js +667 -0
- package/src/panel/schedulestart.js +243 -0
- package/src/panel/server.js +451 -48
- package/src/planrun.js +39 -7
- package/src/providerrows.js +3 -3
- package/src/reviewdoor.js +37 -0
- package/src/runlist.js +35 -0
- package/src/schedulewords.js +296 -0
- package/src/trigger.js +48 -0
- package/src/triggerclock.js +127 -0
- package/src/triggerfiles.js +215 -0
- package/src/triggerledger.js +79 -0
- package/src/userrun.js +6 -7
- package/types/declaredclose.d.ts +1 -1
- package/types/job.d.ts +8 -1
- package/types/panel/authorroutes.d.ts +34 -1
- package/types/panel/authorsession.d.ts +37 -12
- package/types/panel/runroutes.d.ts +32 -1
- package/types/panel/scheduler.d.ts +17 -0
- package/types/panel/schedules.d.ts +412 -0
- package/types/panel/schedulestart.d.ts +106 -0
- package/types/panel/server.d.ts +143 -14
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/reviewdoor.d.ts +27 -0
- package/types/runlist.d.ts +20 -0
- package/types/schedulewords.d.ts +135 -0
- package/types/trigger.d.ts +37 -0
- package/types/triggerclock.d.ts +52 -0
- package/types/triggerfiles.d.ts +93 -0
- package/types/triggerledger.d.ts +35 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
// T Part 3 (PANEL-BUILD.md T addendum, C.3): the schedule door. The ONE place a trigger is set or cancelled, and
|
|
2
|
+
// the one reader the cards use. Two clicks, both $0 (no drafter, no run, no provider call):
|
|
3
|
+
// prepare(body) POST /api/author/schedule-prepare first click: every check, signs and writes nothing, returns {words, hash}
|
|
4
|
+
// set(body) POST /api/author/schedule second click: carries the hash; signs, writes schedule.json, arms
|
|
5
|
+
// cancel(body) (same route, `when: null`) never refused: reads no green gate, no spec, no keys, no monthly limit
|
|
6
|
+
// Files live under `<home>/triggers/<job>/` keyed by JOB NAME (never by signature hash), so a re-sign writes into
|
|
7
|
+
// the same folder and a queued file is never orphaned (T18):
|
|
8
|
+
// schedule.json the CURRENT signed schedule (atomic write; absent = by hand)
|
|
9
|
+
// ledger.jsonl ONE append-only ledger, every row carrying `sig` (the spec hash it was written under)
|
|
10
|
+
// Rewritten from fwdloop src/panel/authortrigger.js (prepare/set/read/armFor/shapeOf); bareloop's gate is `greenFor`.
|
|
11
|
+
import {
|
|
12
|
+
existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { homedir, userInfo } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { readRunList, runIsAlive, runlistHome } from '../runlist.js';
|
|
17
|
+
import { jobSpecHash, validateJob, workflowKey } from '../job.js';
|
|
18
|
+
import { parseInputs } from '../jobblock.js';
|
|
19
|
+
import { looksLikeRepoSource } from '../source.js';
|
|
20
|
+
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
21
|
+
import { ConfigError } from '../config.js';
|
|
22
|
+
import { doorAccepted } from '../reviewdoor.js';
|
|
23
|
+
import { findRow } from '../providerrows.js';
|
|
24
|
+
import { validateTrigger, isClockTrigger, isFileTrigger } from '../trigger.js';
|
|
25
|
+
import { armTrigger, nextSlot, recordFire } from '../triggerclock.js';
|
|
26
|
+
import { appendRow, readLedger, withoutReleased } from '../triggerledger.js';
|
|
27
|
+
import { checkTarget, createFileTrigger, queueOf, readSeen, recordDropped, recordRan } from '../triggerfiles.js';
|
|
28
|
+
import {
|
|
29
|
+
SCHEDULE_WORDS, dateTimeWords, gaveUpDateWords, lateRefusalWords, nextInWords, nextSlotShort, nextTimeWords, retryNextWords, sameMonthAs, scheduledNextFor, scheduledWords, skippedWords,
|
|
30
|
+
} from '../schedulewords.js';
|
|
31
|
+
import { buildReuseSpec, startDestinationProblem, validateReuseCard } from './authorsession.js';
|
|
32
|
+
|
|
33
|
+
/** a spawn error after the consuming row releases it at most this many times; then it stays consumed (bounded rows) */
|
|
34
|
+
export const MAX_RELEASES = 3;
|
|
35
|
+
/** a headless start counts as busy from the fire until runs.jsonl shows a row for the job, or this long */
|
|
36
|
+
const STARTING_MS = 120_000;
|
|
37
|
+
/** a live pid whose process started more than this after its run row was written is a RECYCLED pid, not that run */
|
|
38
|
+
export const PID_REUSE_SLACK_MS = 5000;
|
|
39
|
+
/** Linux counts process start time in clock ticks; 100 is the value on every mainstream build (getconf CLK_TCK) */
|
|
40
|
+
const CLK_TCK = 100;
|
|
41
|
+
const SLUG_RE = /^[a-z][a-z0-9-]*$/;
|
|
42
|
+
/** the outcome rows a card's "last outcome" may be (clock and file alike) */
|
|
43
|
+
/** the rows that are one FIRE's result (what the SCHEDULE row calls `last`) */
|
|
44
|
+
const FIRE_KINDS = ['started', 'busy', 'monthly-exhausted', 'refused'];
|
|
45
|
+
const OUTCOME_KINDS = ['started', 'busy', 'monthly-exhausted', 'refused', 'deleted', 'changed', 'gave-up'];
|
|
46
|
+
/** RULED 2026-10-15: waits (ms) between consecutive `install-needed` refusals of a job: Fibonacci 1, 2, 3, 5, 8, 13, 21 minutes. A test injects its own via `deps.installRetrySteps`. */
|
|
47
|
+
export const INSTALL_RETRY_STEPS_MS = Object.freeze([1, 2, 3, 5, 8, 13, 21].map((m) => m * 60_000));
|
|
48
|
+
/** RULED 2026-10-15: the total wait a slot or file gets. A wait that would pass it is not taken: the schedule gives up and pauses (the job waits in Needs you). */
|
|
49
|
+
export const INSTALL_GIVE_UP_MS = 60 * 60_000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* When a process started, in epoch ms, from `/proc/<pid>/stat` field 22 (clock ticks since boot) and `/proc/stat` btime
|
|
53
|
+
* (whole seconds, so good to about a second). `null` where /proc cannot be read: the caller then trusts `isLiveRunner` alone.
|
|
54
|
+
* @param {number} pid
|
|
55
|
+
* @param {(path: string) => string} [read]
|
|
56
|
+
* @returns {number|null}
|
|
57
|
+
*/
|
|
58
|
+
export function procStartMs(pid, read = (p) => readFileSync(p, 'utf8')) {
|
|
59
|
+
try {
|
|
60
|
+
const stat = read(`/proc/${pid}/stat`);
|
|
61
|
+
// the command name (field 2) sits in parentheses and may hold spaces or parentheses: count fields after the LAST ")"
|
|
62
|
+
const after = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
63
|
+
const ticks = Number(after[19]); // field 22 = index 19 after the state (field 3)
|
|
64
|
+
const btime = Number((/^btime (\d+)$/m.exec(read('/proc/stat')) ?? [])[1]);
|
|
65
|
+
if (!Number.isFinite(ticks) || !Number.isFinite(btime)) return null;
|
|
66
|
+
return btime * 1000 + (ticks / CLK_TCK) * 1000;
|
|
67
|
+
} catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Is this listed run's runner alive? `runIsAlive` (the pid is alive AND a bareloop runner) plus the PID REUSE GUARD: a
|
|
72
|
+
* pid whose process started MORE than 5 s after the row was written is a recycled pid, not that run. A resumed run's row
|
|
73
|
+
* keeps its first leg's time while its pid is the latest leg's, so the guard is skipped for it (the cmdline check stands).
|
|
74
|
+
* @param {any} row @param {number} nowMs
|
|
75
|
+
*/
|
|
76
|
+
export function runnerAlive(row, nowMs) {
|
|
77
|
+
if (!runIsAlive(row, nowMs)) return false;
|
|
78
|
+
if (!Number.isInteger(row.pid) || row.leg !== undefined) return true;
|
|
79
|
+
const at = Date.parse(row.at);
|
|
80
|
+
const started = procStartMs(row.pid);
|
|
81
|
+
if (!Number.isFinite(at) || started === null) return true;
|
|
82
|
+
return started <= at + PID_REUSE_SLACK_MS;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {{runid: string, outcome: string|null, spec: any, records: any[]}} JobRun
|
|
87
|
+
* @typedef {{job: string, trigger: import('../trigger.js').Trigger, spec: any, specHash: string, workflowKey: string,
|
|
88
|
+
* originRunid: string, inputsText: string, destination?: string, signedBy: string, signedAt: string}} Schedule
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {{
|
|
93
|
+
* home?: string,
|
|
94
|
+
* sessionsRoot?: string,
|
|
95
|
+
* now?: () => number,
|
|
96
|
+
* jobRuns: (job: string) => JobRun[],
|
|
97
|
+
* startFrom: (runid: string) => any,
|
|
98
|
+
* jobOfRun: (runid: string) => string|null,
|
|
99
|
+
* rowsFor: () => readonly import('../providerrows.js').KeyRow[],
|
|
100
|
+
* runRows?: () => any[],
|
|
101
|
+
* installRetrySteps?: readonly number[], installGiveUpMs?: number,
|
|
102
|
+
* userHome?: string,
|
|
103
|
+
* }} deps `userHome` = the folder shown as `~` in words (default the OS home). `installRetrySteps` (ms) and `installGiveUpMs` = the install-needed back-off (tests inject them). `runRows` = a test seam for the one run-list read the busy check does. `jobRuns(job)` = the job's listed runs, newest first, each with its signed spec when on disk and its terminal
|
|
104
|
+
* outcome off the spine; `startFrom(runid)` = the panel's `getStartFrom`; `jobOfRun` = the listed run's job name.
|
|
105
|
+
*/
|
|
106
|
+
export function createScheduleDoor(deps) {
|
|
107
|
+
const { now = Date.now } = deps;
|
|
108
|
+
const userHome = deps.userHome ?? homedir();
|
|
109
|
+
/** @type {ReturnType<typeof import('./schedulestart.js').createStarter>|null} */
|
|
110
|
+
let starter = null;
|
|
111
|
+
/** job -> ms the headless start began: closes the gap between the spawn and the row run-u appends (T19) @type {Map<string, number>} */
|
|
112
|
+
const starting = new Map();
|
|
113
|
+
const installSteps = deps.installRetrySteps ?? INSTALL_RETRY_STEPS_MS;
|
|
114
|
+
const giveUpMs = deps.installGiveUpMs ?? INSTALL_GIVE_UP_MS;
|
|
115
|
+
/** job -> {key, count, firstAt, until, why}: consecutive `install-needed` refusals; the job's fire waits until `until` (RULED 2026-10-15, Fibonacci waits). In memory: a restart begins at the first wait again. @type {Map<string, {key: string, count: number, firstAt: number, until: number, why: string}>} */
|
|
116
|
+
const installWait = new Map();
|
|
117
|
+
const triggersRoot = join(runlistHome(deps.home), 'triggers');
|
|
118
|
+
/** @param {string} job */
|
|
119
|
+
const dirOf = (job) => {
|
|
120
|
+
if (!SLUG_RE.test(job)) throw new Error(`"${job}" is not a job name`);
|
|
121
|
+
return join(triggersRoot, job);
|
|
122
|
+
};
|
|
123
|
+
/** @param {string} job */
|
|
124
|
+
const ledgerOf = (job) => join(dirOf(job), 'ledger.jsonl');
|
|
125
|
+
/** @param {string} job */
|
|
126
|
+
const scheduleFileOf = (job) => join(dirOf(job), 'schedule.json');
|
|
127
|
+
|
|
128
|
+
/** The current signed schedule of a job, or null (absent, unreadable or not an object). @param {string} job @returns {Schedule|null} */
|
|
129
|
+
function readSchedule(job) {
|
|
130
|
+
try {
|
|
131
|
+
const o = JSON.parse(readFileSync(scheduleFileOf(job), 'utf8'));
|
|
132
|
+
return o && typeof o === 'object' && o.trigger && typeof o.specHash === 'string' ? o : null;
|
|
133
|
+
} catch { return null; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @param {string} job @param {Schedule} schedule */
|
|
137
|
+
function writeSchedule(job, schedule) {
|
|
138
|
+
const file = scheduleFileOf(job);
|
|
139
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
140
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
141
|
+
writeFileSync(tmp, `${JSON.stringify(schedule, null, 2)}\n`, { mode: 0o600 });
|
|
142
|
+
renameSync(tmp, file);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* THE gate (T3). The job's CURRENT workflow key is the key of its newest listed run whose signed spec is on disk.
|
|
147
|
+
* It passes when a run with THAT key has a real `green` outcome (`already-green` does not count) and, for a rubric
|
|
148
|
+
* job, that run's spine holds an honoured accept. An edited job has a new key, so an older green does not count (T5).
|
|
149
|
+
* @param {string} job
|
|
150
|
+
* @returns {{ok: true, run: JobRun, key: string} | {ok: false, say: string}}
|
|
151
|
+
*/
|
|
152
|
+
function greenFor(job) {
|
|
153
|
+
const runs = deps.jobRuns(job);
|
|
154
|
+
const head = runs.find((r) => r.spec);
|
|
155
|
+
if (!head) return { ok: false, say: SCHEDULE_WORDS.notGreen };
|
|
156
|
+
const key = workflowKey(head.spec);
|
|
157
|
+
const hit = runs.find((r) => r.spec && workflowKey(r.spec) === key && r.outcome === 'green'
|
|
158
|
+
&& (r.spec.verdictType !== 'soft-green' || doorAccepted(r.records)));
|
|
159
|
+
return hit ? { ok: true, run: hit, key } : { ok: false, say: SCHEDULE_WORDS.notGreen };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The ONE writer of "this schedule was armed". A file kind records what is already there first (it never runs),
|
|
164
|
+
* then the armed row, so a failure between the two leaves it unarmed and the next call finishes it. Idempotent for
|
|
165
|
+
* a signing: a second call with the same sig writes nothing. Throws, naming why, when the target is not usable.
|
|
166
|
+
* @param {string} job @param {Schedule} schedule @param {{baseline?: boolean}} [o] `baseline: false` = a re-sign that kept the same folder/file: its queue and baseline stand
|
|
167
|
+
*/
|
|
168
|
+
function armFor(job, schedule, o = {}) {
|
|
169
|
+
const sig = schedule.specHash;
|
|
170
|
+
const ledger = ledgerOf(job);
|
|
171
|
+
if (readLedger(ledger).rows.some((r) => r.kind === 'armed' && r.sig === sig)) return { armed: true, already: true };
|
|
172
|
+
if (isFileTrigger(schedule.trigger) && o.baseline !== false) {
|
|
173
|
+
const t = schedule.trigger;
|
|
174
|
+
createFileTrigger({
|
|
175
|
+
mode: t.kind === 'new-file' ? 'new' : 'updated', target: t.kind === 'new-file' ? t.folder : t.path, ledger, sig, now,
|
|
176
|
+
}).arm();
|
|
177
|
+
}
|
|
178
|
+
armTrigger(ledger, now(), sig);
|
|
179
|
+
return { armed: true, already: false };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Every queued file gets one `dropped` row with its reason and one plain-words outcome row — never silent, never orphaned.
|
|
184
|
+
* @param {string} job @param {string} sig the signature the new rows are written under @param {'schedule-changed'|'schedule-cancelled'} why
|
|
185
|
+
*/
|
|
186
|
+
function dropQueue(job, sig, why) {
|
|
187
|
+
const ledger = ledgerOf(job);
|
|
188
|
+
for (const item of queueOf(readSeen(ledger))) {
|
|
189
|
+
recordDropped(ledger, item, why, now(), sig);
|
|
190
|
+
appendRow(ledger, {
|
|
191
|
+
kind: 'changed', sig, at: now(), sha: item.sha, file: item.name, why,
|
|
192
|
+
say: `${item.name} was waiting to run and did not: ${why === 'schedule-changed' ? 'the schedule was changed to watch something else' : 'the schedule was cancelled'}.`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Remove a job's schedule (a cancel), writing the plain rows first. An edit never does (v1.100); old `edited` rows still read. @param {string} job @param {Schedule} cur */
|
|
198
|
+
function removeSchedule(job, cur) {
|
|
199
|
+
const sig = cur.specHash;
|
|
200
|
+
dropQueue(job, sig, 'schedule-cancelled');
|
|
201
|
+
appendRow(ledgerOf(job), { kind: 'cancelled', sig, at: now(), say: 'The schedule was cancelled. The job is by hand again.' });
|
|
202
|
+
rmSync(scheduleFileOf(job), { force: true });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Every $0 check of a schedule change, shared by both clicks.
|
|
207
|
+
* @param {any} body
|
|
208
|
+
*/
|
|
209
|
+
function inspect(body) {
|
|
210
|
+
/** @param {number} status @param {Record<string, any>} b */
|
|
211
|
+
const reply = (status, b) => ({ ok: /** @type {false} */ (false), reply: { status, body: { ok: false, ...b } } });
|
|
212
|
+
const runid = typeof body?.runid === 'string' ? body.runid : '';
|
|
213
|
+
const job = (typeof body?.job === 'string' && SLUG_RE.test(body.job) ? body.job : null) ?? deps.jobOfRun(runid);
|
|
214
|
+
if (!job || !SLUG_RE.test(job)) return reply(400, { refused: 'run', say: 'That run is not in the list. Nothing was changed.' });
|
|
215
|
+
// by hand: a cancel reads nothing else (T3, never refused)
|
|
216
|
+
if (body?.when === null) {
|
|
217
|
+
const cur = readSchedule(job);
|
|
218
|
+
return { ok: /** @type {true} */ (true), cancel: /** @type {true} */ (true), job, cur, words: SCHEDULE_WORDS.cancelWords, hash: cancelHash(job, cur) };
|
|
219
|
+
}
|
|
220
|
+
const green = greenFor(job);
|
|
221
|
+
if (!green.ok) return reply(409, { refused: 'no-green-run', say: green.say });
|
|
222
|
+
const tv = validateTrigger(body?.when);
|
|
223
|
+
if (!tv.ok) return reply(400, { refused: 'bad-trigger', say: `${tv.say}. Nothing was changed.` });
|
|
224
|
+
const { trigger } = tv;
|
|
225
|
+
const pre = deps.startFrom(green.run.runid);
|
|
226
|
+
if (!pre || !pre.ok) return reply(409, { refused: 'no-green-run', say: SCHEDULE_WORDS.notGreen });
|
|
227
|
+
const rows = deps.rowsFor();
|
|
228
|
+
const worker = findRow(rows, { provider: pre.spec.provider, baseUrl: pre.spec.baseUrl, model: pre.spec.model });
|
|
229
|
+
// Model stays the green run's (T9): the card's model is the signed one, never an open box here
|
|
230
|
+
const card = {
|
|
231
|
+
inputs: body?.inputs, destination: body?.destination, capUsd: body?.capUsd, maxWallMs: body?.maxWallMs,
|
|
232
|
+
model: pre.spec.model, ...(worker ? { provider: worker.envName } : {}),
|
|
233
|
+
};
|
|
234
|
+
const rv = validateReuseCard(card, { rows });
|
|
235
|
+
if (!rv.ok) return reply(400, { refused: 'bad-card', say: `${rv.error}. Nothing was changed.` });
|
|
236
|
+
// the SAME Destination check the session start runs (never a copy), at both clicks: a fire that would be refused for its
|
|
237
|
+
// Destination is never signed
|
|
238
|
+
const destProblem = startDestinationProblem({ ...card, checkType: pre.spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic' });
|
|
239
|
+
if (destProblem !== null) return reply(400, { refused: 'bad-card', say: `${destProblem.replace(/ Nothing spent\.$/, '')} Nothing was changed.` });
|
|
240
|
+
const ip = parseInputs(card.inputs);
|
|
241
|
+
if (isFileTrigger(trigger)) {
|
|
242
|
+
// a repo job's later inputs must be tracked files inside the repo, so an outside file cannot be one (I.3)
|
|
243
|
+
const first = ip.ok ? ip.inputs[0]?.value : '';
|
|
244
|
+
if (pre.spec.verdictType !== 'soft-green' || (first && looksLikeRepoSource(first))) {
|
|
245
|
+
return reply(400, { refused: 'bad-trigger', say: 'A job that works on a repo cannot start when a file arrives. Only a job on your own files can. Nothing was changed.' });
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
checkTarget(trigger.kind === 'new-file' ? 'new' : 'updated', trigger.kind === 'new-file' ? trigger.folder : trigger.path);
|
|
249
|
+
if (trigger.kind === 'file-updated') {
|
|
250
|
+
if (!existsSync(dirname(trigger.path))) throw new Error(`the folder of ${trigger.path} does not exist`);
|
|
251
|
+
}
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return reply(400, { refused: 'bad-trigger', say: `${/** @type {Error} */ (e).message}. Nothing was changed.` });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
257
|
+
spec.trigger = trigger;
|
|
258
|
+
const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
|
|
259
|
+
if (!jv.ok) return reply(400, { refused: 'bad-spec', say: `The job this would sign is not valid (${jv.reds.map((r) => `${r.code} at ${r.path}`).join('; ')}). Nothing was changed.` });
|
|
260
|
+
if (workflowKey(spec) !== green.key) return reply(400, { refused: 'bad-spec', say: 'The job this would sign is not the one that had the good run. Nothing was changed.' });
|
|
261
|
+
let monthlyNote = null;
|
|
262
|
+
try {
|
|
263
|
+
const room = checkMonthlyRoom({ capUsd: spec.budgetUsd, home: deps.home, sessionsRoot: deps.sessionsRoot });
|
|
264
|
+
const refusal = monthlyRefusalText(room);
|
|
265
|
+
if (refusal) monthlyNote = `${refusal}. Each run is checked against the monthly limit when it starts.`;
|
|
266
|
+
} catch (e) {
|
|
267
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
268
|
+
monthlyNote = /** @type {Error} */ (e).message;
|
|
269
|
+
}
|
|
270
|
+
// an absolute Destination (a plain-files job's per-run folder) is not in the spec, so it rides on the schedule and in the hash the
|
|
271
|
+
// two clicks bind: changing it between the clicks is a stale hash. The signature (`hash`) stays the spec's own.
|
|
272
|
+
const destination = typeof card.destination === 'string' && /^(\/|[a-zA-Z]:[\\/])/.test(card.destination.trim()) ? card.destination.trim() : null;
|
|
273
|
+
return {
|
|
274
|
+
ok: /** @type {true} */ (true), cancel: /** @type {false} */ (false), job, trigger, spec, key: green.key, originRunid: green.run.runid,
|
|
275
|
+
inputsText: typeof card.inputs === 'string' ? card.inputs : '', words: scheduledWords(trigger, userHome), hash: jobSpecHash(spec), monthlyNote,
|
|
276
|
+
destination, bind: destination === null ? jobSpecHash(spec) : jobSpecHash({ spec, destination }),
|
|
277
|
+
cur: readSchedule(job),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** the hash a cancel's second click carries: binds the job and the signing it removes @param {string} job @param {Schedule|null} cur */
|
|
282
|
+
function cancelHash(job, cur) {
|
|
283
|
+
return jobSpecHash({ cancel: job, of: cur ? cur.specHash : null });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Does a re-sign keep the same kind and the same folder/file (so the queue is carried over)? @param {Schedule|null} cur @param {import('../trigger.js').Trigger} next */
|
|
287
|
+
function sameTarget(cur, next) {
|
|
288
|
+
const a = cur?.trigger;
|
|
289
|
+
if (!a || !isFileTrigger(a) || !isFileTrigger(next) || a.kind !== next.kind) return false;
|
|
290
|
+
return a.kind === 'new-file' ? a.folder === /** @type {any} */ (next).folder : a.path === /** @type {any} */ (next).path;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Is a run of this job live, or a start in progress? Reads the run list ONCE and asks `runIsAlive` of this job's rows;
|
|
295
|
+
* a run waiting at its review door has ended (its pid is gone), so it is NOT busy (T7). Carries the pid-reuse guard.
|
|
296
|
+
* @param {string} job
|
|
297
|
+
*/
|
|
298
|
+
function busy(job) {
|
|
299
|
+
const rows = (deps.runRows ? deps.runRows() : readRunList({ home: deps.home }).rows).filter((r) => r && r.job === job);
|
|
300
|
+
const t = starting.get(job);
|
|
301
|
+
if (t !== undefined) {
|
|
302
|
+
if (now() - t >= STARTING_MS || rows.some((r) => Date.parse(r.at) >= t)) starting.delete(job);
|
|
303
|
+
else return true;
|
|
304
|
+
}
|
|
305
|
+
return rows.some((r) => runnerAlive(r, now()));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The schedule and what the ledger says about it, as DATA: armed under the CURRENT signing, the next slot (clock kinds),
|
|
310
|
+
* the last outcome (rows with `sig` equal to the current signing, never a wall-clock compare, T19). No green gate.
|
|
311
|
+
* @param {string} job
|
|
312
|
+
*/
|
|
313
|
+
function stateOf(job) {
|
|
314
|
+
const schedule = readSchedule(job);
|
|
315
|
+
if (!schedule) return { schedule: null, trigger: null, armed: false, armedAt: null, nextSlotMs: null, lastOutcome: null, retryInMs: null, gaveUp: null };
|
|
316
|
+
const rows = withoutReleased(readLedger(ledgerOf(job)).rows).filter((r) => r.sig === schedule.specHash);
|
|
317
|
+
const armed = rows.find((r) => r.kind === 'armed');
|
|
318
|
+
const outcomes = rows.filter((r) => OUTCOME_KINDS.includes(r.kind));
|
|
319
|
+
return {
|
|
320
|
+
schedule, trigger: schedule.trigger, armed: armed !== undefined, armedAt: armed ? armed.at : null,
|
|
321
|
+
nextSlotMs: isClockTrigger(schedule.trigger) && armed ? nextSlot(/** @type {any} */ (schedule.trigger), now()) : null,
|
|
322
|
+
lastOutcome: outcomes.length > 0 ? outcomes[outcomes.length - 1] : null,
|
|
323
|
+
retryInMs: (() => { const w = installWait.get(job); return w && w.until > now() ? w.until - now() : null; })(),
|
|
324
|
+
gaveUp: pausedRow(rows),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* RULED 2026-10-15: the `gave-up` row that pauses a schedule, or null. Read from the ledger (a restart does not resume trying): the
|
|
330
|
+
* newest of the `gave-up` / `resumed` rows of the CURRENT signing is a `gave-up`. A re-sign writes `resumed` (so an identical re-sign unpauses too).
|
|
331
|
+
* @param {import('../triggerledger.js').LedgerRow[]} rows the live rows of the current signing
|
|
332
|
+
*/
|
|
333
|
+
function pausedRow(rows) {
|
|
334
|
+
const marks = rows.filter((r) => r.kind === 'gave-up' || r.kind === 'resumed');
|
|
335
|
+
const last = marks.length > 0 ? marks[marks.length - 1] : null;
|
|
336
|
+
return last !== null && last.kind === 'gave-up' ? last : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Is this job's schedule paused (it gave up)? @param {string} job */
|
|
340
|
+
function paused(job) {
|
|
341
|
+
const schedule = readSchedule(job);
|
|
342
|
+
if (!schedule) return false;
|
|
343
|
+
return pausedRow(withoutReleased(readLedger(ledgerOf(job)).rows).filter((r) => r.sig === schedule.specHash)) !== null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The newest fire of the CURRENT signing: a `started` row or one that started nothing (`busy`, `monthly-exhausted`, `refused`), or null.
|
|
348
|
+
* @param {string} job @param {Schedule} schedule
|
|
349
|
+
*/
|
|
350
|
+
function lastFireRow(job, schedule) {
|
|
351
|
+
const rows = withoutReleased(readLedger(ledgerOf(job)).rows).filter((r) => r.sig === schedule.specHash && FIRE_KINDS.includes(r.kind));
|
|
352
|
+
return rows.length > 0 ? rows[rows.length - 1] : null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** @type {Map<string, {key: string, green: ReturnType<typeof greenFor>}>} */
|
|
356
|
+
const greenMemo = new Map();
|
|
357
|
+
/**
|
|
358
|
+
* `greenFor`, memoised on the job's run-list state (each of its spines' size and mtime), so the Run tab's poll does not
|
|
359
|
+
* re-parse every run of the job each time. A finished run changes its spine, so the key moves and the gate is asked again.
|
|
360
|
+
* @param {string} job
|
|
361
|
+
*/
|
|
362
|
+
function cachedGreen(job) {
|
|
363
|
+
const rows = (deps.runRows ? deps.runRows() : readRunList({ home: deps.home }).rows).filter((r) => r && r.job === job);
|
|
364
|
+
const key = rows.map((r) => {
|
|
365
|
+
try { const st = statSync(r.spine); return `${r.runid}:${st.size}:${st.mtimeMs}`; } catch { return `${r.runid}:gone`; }
|
|
366
|
+
}).join('|');
|
|
367
|
+
const hit = greenMemo.get(job);
|
|
368
|
+
if (hit && hit.key === key) return hit.green;
|
|
369
|
+
const green = greenFor(job);
|
|
370
|
+
greenMemo.set(job, { key, green });
|
|
371
|
+
return green;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
/** The starter is built after the author routes (which need this door), so it is attached here. @param {ReturnType<typeof import('./schedulestart.js').createStarter>} s */
|
|
376
|
+
attachStarter(s) { starter = s; },
|
|
377
|
+
|
|
378
|
+
busy,
|
|
379
|
+
paused,
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* RULED 2026-10-15 (v1.104): the fires that started NOTHING (busy, month used up, refused) of ALL signings of the job's schedule
|
|
383
|
+
* (the job's ledger is the job's, so every signing's rows are in it; cancelled-and-set-again rows stay). Newest first. Lines are
|
|
384
|
+
* `MM/DD HH:MM <words>`. The caller shows the first 7 and counts them all.
|
|
385
|
+
* @param {string} job
|
|
386
|
+
* @returns {{at: number, line: string}[]}
|
|
387
|
+
*/
|
|
388
|
+
skippedFor(job) {
|
|
389
|
+
if (!SLUG_RE.test(job)) return [];
|
|
390
|
+
return withoutReleased(readLedger(ledgerOf(job)).rows).map((r, i) => ({ r, i }))
|
|
391
|
+
.filter(({ r }) => r.kind === 'busy' || r.kind === 'monthly-exhausted' || r.kind === 'refused')
|
|
392
|
+
.sort((a, b) => (Number(b.r.at) - Number(a.r.at)) || (b.i - a.i))
|
|
393
|
+
.map(({ r }) => ({ at: Number(r.at), line: `${dateTimeWords(Number(r.at))} ${skippedWords(r)}` }));
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* One fire, in this order (T15): busy, then monthly, then wouldAccept, then the starter (which calls `beforeSpawn`, the
|
|
398
|
+
* ONE consuming row, only after every "no" is behind it). Each outcome is ONE row in ledger.jsonl, told once per
|
|
399
|
+
* `key|outcome|why` (read back from disk, so a restart does not repeat it). RULED 2026-10-14: a busy, monthly or refused
|
|
400
|
+
* fire consumes NOTHING, so the next tick tries again; a spawn error after `beforeSpawn` writes a `released` row (bounded).
|
|
401
|
+
* @param {string} job
|
|
402
|
+
* @param {{kind: 'clock', slot: number, catchUp: boolean, missed: number} | {kind: 'file', path: string, sha: string, size?: number}} cause
|
|
403
|
+
* @param {{beforeSpawn?: () => void, busy?: boolean}} [hooks] `busy` = the scheduler's one read of the run list this tick
|
|
404
|
+
* @returns {Promise<{outcome: 'busy'|'monthly-exhausted'|'refused'|'started', why?: string, told: boolean, sessionId?: string}>}
|
|
405
|
+
*/
|
|
406
|
+
async fire(job, cause, hooks = {}) {
|
|
407
|
+
const schedule = readSchedule(job);
|
|
408
|
+
if (!schedule || !starter) return { outcome: 'refused', why: 'This job has no schedule any more. Nothing started.', told: false };
|
|
409
|
+
const sig = schedule.specHash;
|
|
410
|
+
const ledger = ledgerOf(job);
|
|
411
|
+
const key = cause.kind === 'file' ? cause.sha : String(cause.slot);
|
|
412
|
+
const consumeId = cause.kind === 'file' ? `ran:${cause.sha}` : `fire:${cause.slot}`;
|
|
413
|
+
const base = cause.kind === 'file' ? { file: cause.path, sha: cause.sha } : { slot: cause.slot, catchUp: cause.catchUp, missed: cause.missed };
|
|
414
|
+
/** @param {'busy'|'monthly-exhausted'|'refused'} outcome @param {string} why @param {Record<string, unknown>} [extra] */
|
|
415
|
+
const told = (outcome, why, extra = {}) => {
|
|
416
|
+
const tag = `${key}|${outcome}|${why}`;
|
|
417
|
+
const seen = readLedger(ledger).rows.some((r) => r.told === tag);
|
|
418
|
+
if (!seen) appendRow(ledger, { kind: outcome, sig, at: now(), told: tag, key, ...base, why, ...extra });
|
|
419
|
+
return { outcome, why, told: !seen };
|
|
420
|
+
};
|
|
421
|
+
if (paused(job)) return { outcome: 'refused', why: 'This schedule gave up and is waiting for you. Nothing started.', told: false };
|
|
422
|
+
if (hooks.busy ?? busy(job)) return told('busy', 'A run of this job is still going. This one starts when it is done.');
|
|
423
|
+
try {
|
|
424
|
+
const room = checkMonthlyRoom({ capUsd: schedule.spec.budgetUsd, home: deps.home, sessionsRoot: deps.sessionsRoot });
|
|
425
|
+
if (!room.ok) {
|
|
426
|
+
return told('monthly-exhausted', 'Not enough money left this month for this run.', { leftUsd: room.leftUsd, needUsd: schedule.spec.budgetUsd, atLeast: room.atLeast === true });
|
|
427
|
+
}
|
|
428
|
+
} catch (e) {
|
|
429
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
430
|
+
return told('refused', `${e.message} — refusing rather than guess the monthly limit. Nothing started.`);
|
|
431
|
+
}
|
|
432
|
+
// already consumed (a live `fire`/`ran` row, e.g. given up on after releases): never start it a second time
|
|
433
|
+
if (readLedger(ledger).rows.length > 0 && withoutReleased(readLedger(ledger).rows).some((r) => r.id === consumeId)) {
|
|
434
|
+
return { outcome: 'refused', why: 'This one was already started or given up on.', told: false };
|
|
435
|
+
}
|
|
436
|
+
// RULED 2026-10-15: inside an install-needed wait nothing is written, no session made, no copy or install run
|
|
437
|
+
const wait = installWait.get(job);
|
|
438
|
+
if (wait && wait.key === key && now() < wait.until) return { outcome: 'refused', why: wait.why, told: false };
|
|
439
|
+
const accept = starter.wouldAccept(job, cause);
|
|
440
|
+
if (!accept.ok) return told('refused', accept.why);
|
|
441
|
+
const beforeSpawn = hooks.beforeSpawn ?? (() => {
|
|
442
|
+
if (cause.kind === 'file') recordRan(ledger, { sha: cause.sha, path: cause.path, size: cause.size ?? 0 }, now(), sig);
|
|
443
|
+
else recordFire(ledger, { fire: true, slot: cause.slot, catchUp: cause.catchUp, missed: cause.missed }, now(), sig);
|
|
444
|
+
});
|
|
445
|
+
starting.set(job, now());
|
|
446
|
+
const r = await starter.start(job, cause, { beforeSpawn });
|
|
447
|
+
if (r.ok) {
|
|
448
|
+
installWait.delete(job);
|
|
449
|
+
appendRow(ledger, { kind: 'started', sig, at: now(), key, ...base, sessionId: r.sessionId, specHash: r.specHash });
|
|
450
|
+
return { outcome: 'started', told: true, sessionId: r.sessionId };
|
|
451
|
+
}
|
|
452
|
+
starting.delete(job);
|
|
453
|
+
/** @type {string|null} set when this refusal is the one that gives up */
|
|
454
|
+
let gaveUpWhy = null;
|
|
455
|
+
if (r.installNeeded === true) {
|
|
456
|
+
const prev = installWait.get(job);
|
|
457
|
+
const count = (prev?.count ?? 0) + 1;
|
|
458
|
+
const firstAt = prev?.firstAt ?? now();
|
|
459
|
+
const step = installSteps[count - 1];
|
|
460
|
+
if (step === undefined || now() + step - firstAt > giveUpMs) { installWait.delete(job); gaveUpWhy = r.why; }
|
|
461
|
+
else installWait.set(job, { key, count, firstAt, until: now() + step, why: r.why });
|
|
462
|
+
}
|
|
463
|
+
if (r.consumed) {
|
|
464
|
+
// a spawn error AFTER the consuming row: release it so the slot or file is retryable, a bounded number of times
|
|
465
|
+
const releases = readLedger(ledger).rows.filter((x) => x.kind === 'released' && x.of === consumeId).length;
|
|
466
|
+
if (releases < MAX_RELEASES) {
|
|
467
|
+
appendRow(ledger, { kind: 'released', of: consumeId, sig, at: now(), why: r.why });
|
|
468
|
+
return told('refused', lateRefusalWords(r.why, releases, MAX_RELEASES));
|
|
469
|
+
}
|
|
470
|
+
return told('refused', lateRefusalWords(r.why, releases, MAX_RELEASES));
|
|
471
|
+
}
|
|
472
|
+
const out = told('refused', r.why);
|
|
473
|
+
// ONE `gave-up` row, written after the refusal row so it is the newest outcome; the paused ledger state is what stops every later fire
|
|
474
|
+
if (gaveUpWhy !== null) appendRow(ledger, { kind: 'gave-up', sig, at: now(), key, ...base, why: gaveUpWhy });
|
|
475
|
+
return out;
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
greenFor,
|
|
479
|
+
armFor,
|
|
480
|
+
readSchedule,
|
|
481
|
+
ledgerOf,
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* A failure the scheduler met on a job (a folder that went away, a file refused), written as ONE `refused` row per
|
|
485
|
+
* `tag`, read back from disk so a restart does not repeat it (T13). Returns whether it wrote.
|
|
486
|
+
* @param {string} job @param {string} sig @param {string} tag @param {string} why
|
|
487
|
+
*/
|
|
488
|
+
tellOnce(job, sig, tag, why) {
|
|
489
|
+
const ledger = ledgerOf(job);
|
|
490
|
+
const key = `scheduler|${tag}`;
|
|
491
|
+
if (readLedger(ledger).rows.some((r) => r.told === key)) return false;
|
|
492
|
+
appendRow(ledger, { kind: 'refused', sig, at: now(), told: key, why });
|
|
493
|
+
return true;
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
/** Click 1. All $0 checks; signs and writes nothing; returns `{words, hash, monthlyNote}`. @param {any} body */
|
|
497
|
+
prepare(body) {
|
|
498
|
+
const s = inspect(body);
|
|
499
|
+
if (!s.ok) return s.reply;
|
|
500
|
+
return { status: 200, body: { ok: true, job: s.job, words: s.words, hash: s.cancel ? s.hash : s.bind, monthlyNote: s.cancel ? null : s.monthlyNote, cancel: s.cancel } };
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
/** Click 2, carrying the hash it was shown. $0: no drafter, no run. @param {any} body */
|
|
504
|
+
set(body) {
|
|
505
|
+
const s = inspect(body);
|
|
506
|
+
if (!s.ok) return s.reply;
|
|
507
|
+
if (typeof body.hash !== 'string' || body.hash !== (s.cancel ? s.hash : s.bind)) return { status: 409, body: { ok: false, refused: 'stale-hash', say: SCHEDULE_WORDS.stale } };
|
|
508
|
+
if (s.cancel) {
|
|
509
|
+
if (s.cur) removeSchedule(s.job, s.cur);
|
|
510
|
+
return { status: 200, body: { ok: true, job: s.job, cancelled: true, words: SCHEDULE_WORDS.cancelWords } };
|
|
511
|
+
}
|
|
512
|
+
const cur = s.cur;
|
|
513
|
+
const keep = sameTarget(cur, s.trigger);
|
|
514
|
+
if (cur && !keep && isFileTrigger(cur.trigger)) dropQueue(s.job, jobSpecHash(s.spec), 'schedule-changed');
|
|
515
|
+
/** @type {Schedule} */
|
|
516
|
+
const schedule = {
|
|
517
|
+
job: s.job, trigger: s.trigger, spec: s.spec, specHash: s.hash, workflowKey: s.key, originRunid: s.originRunid,
|
|
518
|
+
inputsText: s.inputsText, ...(s.destination === null ? {} : { destination: s.destination }), signedBy: userInfo().username, signedAt: new Date(now()).toISOString(),
|
|
519
|
+
};
|
|
520
|
+
writeSchedule(s.job, schedule);
|
|
521
|
+
appendRow(ledgerOf(s.job), { kind: 'signed', sig: s.hash, at: now() });
|
|
522
|
+
// a re-sign of a schedule that gave up unpauses it (the same signature included) and starts the install waits over
|
|
523
|
+
if (paused(s.job)) appendRow(ledgerOf(s.job), { kind: 'resumed', sig: s.hash, at: now() });
|
|
524
|
+
installWait.delete(s.job);
|
|
525
|
+
let armed = true; let say = null;
|
|
526
|
+
try { armFor(s.job, schedule, { baseline: !keep }); } catch (e) { armed = false; say = `The schedule is signed but not armed yet: ${/** @type {Error} */ (e).message}.`; }
|
|
527
|
+
return { status: 200, body: { ok: true, job: s.job, words: s.words, hash: s.hash, armed, ...(say ? { say } : {}) } };
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
/** Cancel a job's schedule, never refused. @param {string} job @returns {boolean} there was one */
|
|
531
|
+
cancel(job) {
|
|
532
|
+
const cur = SLUG_RE.test(job) ? readSchedule(job) : null;
|
|
533
|
+
installWait.delete(job);
|
|
534
|
+
if (!cur) { rmSync(scheduleFileOf(job), { force: true }); return false; }
|
|
535
|
+
removeSchedule(job, cur);
|
|
536
|
+
return true;
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
/** Every job with a schedule.json: `{job, dir, schedule}`. One readdir; nothing else read when there is none. */
|
|
540
|
+
scan() {
|
|
541
|
+
/** @type {{job: string, dir: string, schedule: Schedule}[]} */
|
|
542
|
+
const out = [];
|
|
543
|
+
let names = [];
|
|
544
|
+
try { names = readdirSync(triggersRoot); } catch { return out; }
|
|
545
|
+
for (const job of names) {
|
|
546
|
+
if (!SLUG_RE.test(job)) continue;
|
|
547
|
+
const schedule = readSchedule(job);
|
|
548
|
+
if (schedule) out.push({ job, dir: dirOf(job), schedule });
|
|
549
|
+
}
|
|
550
|
+
return out;
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* What a card shows of a job's schedule, as DATA (no words): the trigger, whether it is armed under the CURRENT
|
|
555
|
+
* signing, the next slot (clock kinds), the last outcome (rows under the current signing only, never a wall-clock
|
|
556
|
+
* compare, T19), whether the job has a green run, and the refusal sentence when it has none.
|
|
557
|
+
* @param {string} job
|
|
558
|
+
*/
|
|
559
|
+
read(job) {
|
|
560
|
+
if (!SLUG_RE.test(job)) return null;
|
|
561
|
+
const green = greenFor(job);
|
|
562
|
+
return { job, hasGreen: green.ok, refusal: green.ok ? null : green.say, ...stateOf(job) };
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
stateOf,
|
|
566
|
+
userHome,
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* What the Run tab shows of a job's schedule (A.12: one read of the schedule files and the ledger; the green gate is
|
|
570
|
+
* memoised per run-list state, so a poll never re-parses every run). The SERVER decides: `scheduled` shows Cancel /
|
|
571
|
+
* modify, else `canSchedule` shows Schedule run, else no button. `fromRunid` = the green run the card opens from. `waitingRunNo` = the job's newest run when it waits
|
|
572
|
+
* for the person's Accept (the next scheduled run does NOT wait for it, T7).
|
|
573
|
+
* @param {string} job @param {{waitingRunNo?: number|null}} [o]
|
|
574
|
+
*/
|
|
575
|
+
view(job, o = {}) {
|
|
576
|
+
if (!SLUG_RE.test(job)) return null;
|
|
577
|
+
const st = stateOf(job);
|
|
578
|
+
if (!st.schedule) {
|
|
579
|
+
const g = cachedGreen(job);
|
|
580
|
+
return { scheduled: false, canSchedule: g.ok, words: SCHEDULE_WORDS, ...(g.ok ? { fromRunid: g.run.runid } : {}) };
|
|
581
|
+
}
|
|
582
|
+
const next = scheduledNextFor({
|
|
583
|
+
trigger: st.schedule.trigger, armed: st.armed, nextSlotMs: st.nextSlotMs, lastOutcome: st.lastOutcome, waitingRunNo: o.waitingRunNo ?? null, now: now(), retryInMs: st.retryInMs, gaveUp: st.gaveUp,
|
|
584
|
+
});
|
|
585
|
+
const last = lastFireRow(job, st.schedule);
|
|
586
|
+
const nextWords = !st.armed ? 'next: not armed yet' : isClockTrigger(st.schedule.trigger)
|
|
587
|
+
? (st.nextSlotMs === null ? 'next: not armed yet' : `next ${nextSlotShort(st.nextSlotMs)}`)
|
|
588
|
+
: (st.schedule.trigger.kind === 'new-file' ? 'next: waiting for a new file' : 'next: waiting for the file to change');
|
|
589
|
+
// hamr 2026-10-15: only a refusal that is not retrying, the give-up and a used-up month are problems (red); busy and an install back-off are not
|
|
590
|
+
let problem = false;
|
|
591
|
+
let ended = nextWords;
|
|
592
|
+
if (st.gaveUp !== null) { problem = true; ended = SCHEDULE_WORDS.problemNext; }
|
|
593
|
+
else if (last !== null && last.kind === 'monthly-exhausted' && sameMonthAs(last.at, now())) { problem = true; ended = SCHEDULE_WORDS.monthNext; }
|
|
594
|
+
else if (last !== null && last.kind === 'refused') {
|
|
595
|
+
if (typeof st.retryInMs === 'number') ended = retryNextWords(st.retryInMs);
|
|
596
|
+
else { problem = true; ended = SCHEDULE_WORDS.problemNext; }
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
scheduled: true, canSchedule: false, words: SCHEDULE_WORDS, fromRunid: st.schedule.originRunid,
|
|
600
|
+
text: scheduledWords(st.schedule.trigger, userHome), next: next.text, nextRed: next.red,
|
|
601
|
+
// aligned with fwdloop M9 am7: the SCHEDULE row's second line is put together by the server from these
|
|
602
|
+
lastRow: last, problem, nextWords: ended, accept: o.waitingRunNo !== undefined && o.waitingRunNo !== null,
|
|
603
|
+
};
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The schedule card's data for `start-from?mode=schedule`: the current schedule in the When field's own terms, the
|
|
608
|
+
* words, the refusal that disables the field (a job with no good run and no schedule), and the edit note.
|
|
609
|
+
* Cancel is never refused, so a scheduled job is never disabled here.
|
|
610
|
+
* @param {string} job
|
|
611
|
+
*/
|
|
612
|
+
cardFor(job) {
|
|
613
|
+
const st = stateOf(job);
|
|
614
|
+
const green = st.schedule ? null : cachedGreen(job);
|
|
615
|
+
const t = st.schedule ? st.schedule.trigger : null;
|
|
616
|
+
return {
|
|
617
|
+
scheduled: st.schedule !== null, trigger: t, now: t ? scheduledWords(t, userHome) : null,
|
|
618
|
+
refusal: green && !green.ok ? green.say : null,
|
|
619
|
+
words: SCHEDULE_WORDS,
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* RULED 2026-10-15: the jobs whose schedule gave up, for the runs list's Needs you section (the SERVER decides; the page works out nothing):
|
|
625
|
+
* `[{job, pill: 'Check', words, at: 'MM/DD HH:MM', runId}]`, newest give-up first. `runId` = the job's HEAD run by time. Never ages out.
|
|
626
|
+
* @param {Map<string, string|null>|Record<string, string|null>} heads
|
|
627
|
+
*/
|
|
628
|
+
listGaveUp(heads) {
|
|
629
|
+
const headOf = (/** @type {string} */ job) => (heads instanceof Map ? heads.get(job) : heads[job]) ?? null;
|
|
630
|
+
const out = [];
|
|
631
|
+
for (const { job } of this.scan()) {
|
|
632
|
+
try {
|
|
633
|
+
const g = stateOf(job).gaveUp;
|
|
634
|
+
if (!g) continue;
|
|
635
|
+
const at = typeof g.at === 'number' ? g.at : now();
|
|
636
|
+
out.push({ job, pill: SCHEDULE_WORDS.gaveUpPill, words: String(g.why ?? ''), at: gaveUpDateWords(at), runId: headOf(job), _ms: at });
|
|
637
|
+
} catch { /* unreadable: absent */ }
|
|
638
|
+
}
|
|
639
|
+
out.sort((x, y) => y._ms - x._ms || (x.job < y.job ? -1 : 1));
|
|
640
|
+
return out.map(({ _ms, ...rest }) => rest);
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The Scheduled section of the runs list: `[{job, at, in, words, runId}]`, soonest first; a file trigger (no clock
|
|
645
|
+
* time) after those that have one, by name. `heads` = job -> its HEAD run id by time (summarizeJobs), so the click
|
|
646
|
+
* opens the job's newest run, never "the first in server order". A job that cannot be read is absent.
|
|
647
|
+
* @param {Map<string, string|null>|Record<string, string|null>} heads
|
|
648
|
+
*/
|
|
649
|
+
listScheduled(heads) {
|
|
650
|
+
const nowMs = now();
|
|
651
|
+
const headOf = (/** @type {string} */ job) => (heads instanceof Map ? heads.get(job) : heads[job]) ?? null;
|
|
652
|
+
const out = [];
|
|
653
|
+
for (const { job, schedule } of this.scan()) {
|
|
654
|
+
try {
|
|
655
|
+
const st = stateOf(job);
|
|
656
|
+
const when = st.nextSlotMs;
|
|
657
|
+
out.push({
|
|
658
|
+
job, at: when === null ? null : nextTimeWords(when, nowMs), in: when === null ? null : nextInWords(when, nowMs),
|
|
659
|
+
words: scheduledWords(schedule.trigger, userHome), runId: headOf(job), _ms: when,
|
|
660
|
+
});
|
|
661
|
+
} catch { /* unreadable: absent */ }
|
|
662
|
+
}
|
|
663
|
+
out.sort((x, y) => (x._ms === null ? 1 : 0) - (y._ms === null ? 1 : 0) || (x._ms ?? 0) - (y._ms ?? 0) || (x.job < y.job ? -1 : x.job > y.job ? 1 : 0));
|
|
664
|
+
return out.map(({ _ms, ...rest }) => rest);
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
}
|