bareloop 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/bareloop.context.md +53 -30
- package/package.json +1 -1
- package/src/authorjob.js +6 -0
- package/src/declaredclose.js +2 -2
- package/src/interviewrun.js +0 -1
- package/src/job.js +38 -11
- package/src/panel/authorroutes.js +107 -33
- package/src/panel/authorsession.js +59 -27
- package/src/panel/index.html +813 -484
- package/src/panel/runroutes.js +163 -5
- package/src/panel/scheduler.js +156 -0
- package/src/panel/schedules.js +667 -0
- package/src/panel/schedulestart.js +243 -0
- package/src/panel/server.js +451 -48
- package/src/planrun.js +39 -7
- package/src/providerrows.js +3 -3
- package/src/reviewdoor.js +37 -0
- package/src/runlist.js +35 -0
- package/src/schedulewords.js +296 -0
- package/src/trigger.js +48 -0
- package/src/triggerclock.js +127 -0
- package/src/triggerfiles.js +215 -0
- package/src/triggerledger.js +79 -0
- package/src/userrun.js +6 -7
- package/types/declaredclose.d.ts +1 -1
- package/types/job.d.ts +8 -1
- package/types/panel/authorroutes.d.ts +34 -1
- package/types/panel/authorsession.d.ts +37 -12
- package/types/panel/runroutes.d.ts +32 -1
- package/types/panel/scheduler.d.ts +17 -0
- package/types/panel/schedules.d.ts +412 -0
- package/types/panel/schedulestart.d.ts +106 -0
- package/types/panel/server.d.ts +143 -14
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/reviewdoor.d.ts +27 -0
- package/types/runlist.d.ts +20 -0
- package/types/schedulewords.d.ts +135 -0
- package/types/trigger.d.ts +37 -0
- package/types/triggerclock.d.ts +52 -0
- package/types/triggerfiles.d.ts +93 -0
- package/types/triggerledger.d.ts +35 -0
package/src/panel/runroutes.js
CHANGED
|
@@ -16,12 +16,13 @@ import {
|
|
|
16
16
|
openSync, closeSync, writeFileSync, readFileSync, readdirSync,
|
|
17
17
|
} from 'node:fs';
|
|
18
18
|
import { dirname, join } from 'node:path';
|
|
19
|
-
import { checkHumanGuard } from './authorroutes.js';
|
|
19
|
+
import { checkHumanGuard, OLD_JOB_ACCEPT_WORDS, OLD_JOB_RESUME_WORDS } from './authorroutes.js';
|
|
20
20
|
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
21
21
|
import { ConfigError } from '../config.js';
|
|
22
22
|
import { keysForDoor } from '../keysfile.js';
|
|
23
|
-
import { jobSpecHash } from '../job.js';
|
|
23
|
+
import { jobSpecHash, validateJob } from '../job.js';
|
|
24
24
|
import { stopFilePath } from '../legs.js';
|
|
25
|
+
import { appendNeedsRemoved } from '../runlist.js';
|
|
25
26
|
|
|
26
27
|
/** the tail of an engine log shown to the person when the engine refuses at start (bytes) */
|
|
27
28
|
const LOG_TAIL_BYTES = 4000;
|
|
@@ -29,6 +30,9 @@ const LOG_TAIL_BYTES = 4000;
|
|
|
29
30
|
/** how long a resume waits for an early engine exit before reporting "started" (ms) */
|
|
30
31
|
export const DEFAULT_SETTLE_MS = 6000;
|
|
31
32
|
|
|
33
|
+
/** how long an Accept waits for the engine's answer (it re-runs the mechanical close stages) before saying it is still checking (ms) */
|
|
34
|
+
export const DEFAULT_ACCEPT_WAIT_MS = 5 * 60_000;
|
|
35
|
+
|
|
32
36
|
/**
|
|
33
37
|
* The next free `resolved-spec-r<k>.json` name in `dir` (k from 1) — never an existing one.
|
|
34
38
|
* @param {string} dir
|
|
@@ -55,6 +59,58 @@ function readLogTail(file) {
|
|
|
55
59
|
} catch { return ''; }
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
/**
|
|
63
|
+
* RULED 2026-10-16: the person never sees raw engine output. Of the engine's log tail, only a line it
|
|
64
|
+
* owns as a plain sentence passes through (a `Nothing spent.` / `Nothing was spent.` refusal, `DOOR REFUSED (...) — nothing was
|
|
65
|
+
* recorded...`, `CHECKPOINT EXPIRED — ...`); anything else (validator reds, exit statuses, stack traces, argv) becomes ONE fixed
|
|
66
|
+
* sentence naming the log. The one owner for the Resume and Accept routes.
|
|
67
|
+
* @param {string} tail the log tail
|
|
68
|
+
* @param {'resume'|'accept'} kind
|
|
69
|
+
* @param {string} logFile
|
|
70
|
+
* @param {(stage: string) => string|null} [questionOf] stage name to the panel's plain question (`null` = none)
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function engineRefusalWords(tail, kind, logFile, questionOf = () => null) {
|
|
74
|
+
const known = /(?:\bNothing (?:was )?spent\.|^DOOR REFUSED \(.*\) — nothing was recorded|^CHECKPOINT EXPIRED — )/;
|
|
75
|
+
const raw = /validateJob|red\(s\)|exit status|node-\d|\bat \S+:\d+|\s--[a-z]|^\s*at |Error:/;
|
|
76
|
+
for (const line of String(tail ?? '').split('\n').map((l) => l.trim())) {
|
|
77
|
+
if (line !== '' && known.test(line) && !raw.test(line)) {
|
|
78
|
+
// RULED 2026-10-16 (A): a refused Accept names each failed check by the panel's own plain question
|
|
79
|
+
const asked = kind === 'accept' ? doorRedStages(tail).map((n) => questionOf(n)).filter((q) => typeof q === 'string' && q !== '') : [];
|
|
80
|
+
if (asked.length > 0) {
|
|
81
|
+
const shown = asked.slice(0, 3).map((q) => `"${q}"`).join('; ');
|
|
82
|
+
const more = asked.length > 3 ? ` and ${asked.length - 3} more` : '';
|
|
83
|
+
return `Accept did not go through: the check ${shown}${more} said no. Nothing was recorded.`;
|
|
84
|
+
}
|
|
85
|
+
return line;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return kind === 'resume'
|
|
89
|
+
? `Resume did not start. Nothing was spent on it. The details are in ${logFile}.`
|
|
90
|
+
: `Accept did not go through. Nothing was recorded. The details are in ${logFile}.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The stage names a door-accept-red refusal failed on, read from the engine's own refusal lines (src/userrun.js):
|
|
95
|
+
* the `door-accept-red:stage.<name> — ...` line and the `<name> <verdict>` lines of "the mechanical re-run, stage by stage"
|
|
96
|
+
* whose verdict is not `satisfied`. Names only — the caller maps them to the panel's plain questions.
|
|
97
|
+
* @param {string} tail
|
|
98
|
+
* @returns {string[]}
|
|
99
|
+
*/
|
|
100
|
+
function doorRedStages(tail) {
|
|
101
|
+
/** @type {string[]} */
|
|
102
|
+
const names = [];
|
|
103
|
+
let inList = false;
|
|
104
|
+
for (const line of String(tail ?? '').split('\n')) {
|
|
105
|
+
const red = /^\s*door-accept-red:stage\.(.+?) — /.exec(line);
|
|
106
|
+
if (red) { names.push(red[1]); continue; }
|
|
107
|
+
if (/^\s*the mechanical re-run, stage by stage:/.test(line)) { inList = true; continue; }
|
|
108
|
+
const row = inList ? /^\s{4}(\S.*?)\s{2,}(\S+)\s*$/.exec(line) : null;
|
|
109
|
+
if (row && row[2] !== 'satisfied') names.push(row[1]);
|
|
110
|
+
}
|
|
111
|
+
return [...new Set(names)];
|
|
112
|
+
}
|
|
113
|
+
|
|
58
114
|
/**
|
|
59
115
|
* The run routes' handler. `getResumeContext(runid)` is the server's own reader (the same
|
|
60
116
|
* `resumePlanFor` the Ended block uses, so the page never offers what this refuses):
|
|
@@ -62,7 +118,11 @@ function readLogTail(file) {
|
|
|
62
118
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>, home?: string,
|
|
63
119
|
* spawnFn?: typeof realSpawn, bareloopBin?: string, settleMs?: number,
|
|
64
120
|
* getResumeContext: (runid: string) => ({row: any, plan: any}|null),
|
|
65
|
-
* getStopContext?: (runid: string) => ({row: any, live: boolean, spineExists: boolean}|null)
|
|
121
|
+
* getStopContext?: (runid: string) => ({row: any, live: boolean, spineExists: boolean}|null),
|
|
122
|
+
* getNeedsContext?: (runid: string) => ({row: any, needs: 'Accept'|'Resume'|null}|null),
|
|
123
|
+
* getAcceptContext?: (runid: string) => ({row: any, live: boolean, waiting: boolean, specPath: string|null, specHash: string|null}|null),
|
|
124
|
+
* stageQuestionsOfSpec?: (spec: any) => Map<string, string|null>,
|
|
125
|
+
* acceptWaitMs?: number }} opts
|
|
66
126
|
*/
|
|
67
127
|
export function createRunRoutes(opts) {
|
|
68
128
|
const envNow = () => keysForDoor({ env: opts.env, keysHome: opts.home }).env;
|
|
@@ -98,6 +158,93 @@ export function createRunRoutes(opts) {
|
|
|
98
158
|
return true;
|
|
99
159
|
}
|
|
100
160
|
|
|
161
|
+
/**
|
|
162
|
+
* T Part 8 — `POST /api/runs/:runid/accept`. The person's Accept of a run waiting at its REVIEW DOOR, recorded through the
|
|
163
|
+
* library's own door path and nothing else: `run-u --spec <signed spec> --door <runid> --decide accept --approve <hash>`
|
|
164
|
+
* (src/userrun.js, `answerReviewDoor`, src/reviewdoor.js). The panel invents no verdict path: the engine re-runs the MECHANICAL
|
|
165
|
+
* stages against the tree as it stands, a red there is `door-accept-red` and refuses, and only then is the disposition written
|
|
166
|
+
* to the run's own spine. The verdict the run minted is untouched. The engine's own words are what the page shows.
|
|
167
|
+
* @param {import('node:http').IncomingMessage} req
|
|
168
|
+
* @param {(code: number, obj: any) => void} send
|
|
169
|
+
* @param {string} runid
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
function handleAccept(req, send, runid) {
|
|
173
|
+
const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
|
|
174
|
+
if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
|
|
175
|
+
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
176
|
+
const ctx = opts.getAcceptContext ? opts.getAcceptContext(runid) : null;
|
|
177
|
+
if (!ctx) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
178
|
+
if (ctx.live) { send(409, { ok: false, error: 'This run is still running, so there is nothing to accept yet.' }); return true; }
|
|
179
|
+
if (!ctx.waiting) { send(409, { ok: false, error: 'This run is not waiting for your Accept (it opened no review door, the door was already answered, or it has expired).' }); return true; }
|
|
180
|
+
if (!ctx.specPath || !ctx.specHash) { send(409, { ok: false, error: "The signed job this run was judged by cannot be found on disk, so its door cannot be answered from here. Nothing was recorded." }); return true; }
|
|
181
|
+
// RULED 2026-10-15: an old run whose saved job fails today's checks is refused at $0 in plain words, before any spawn
|
|
182
|
+
/** @type {any} */
|
|
183
|
+
let signed = null;
|
|
184
|
+
try { signed = JSON.parse(readFileSync(ctx.specPath, 'utf8')); } catch { signed = null; }
|
|
185
|
+
if (signed !== null) {
|
|
186
|
+
let jv;
|
|
187
|
+
try { jv = validateJob(signed, { shellCapUsd: signed.budgetUsd }); } catch { jv = { ok: false, reds: [] }; }
|
|
188
|
+
if (!jv.ok) { send(409, { ok: false, error: OLD_JOB_ACCEPT_WORDS, reds: jv.reds.map((r) => `${r.code} at ${r.path}`) }); return true; }
|
|
189
|
+
}
|
|
190
|
+
const questionOf = (/** @type {string} */ n) => (signed !== null && opts.stageQuestionsOfSpec ? opts.stageQuestionsOfSpec(signed).get(n) ?? null : null);
|
|
191
|
+
const dir = dirname(ctx.specPath);
|
|
192
|
+
const logFile = join(dir, `accept-${runid}-${Date.now()}.log`);
|
|
193
|
+
const args = [
|
|
194
|
+
'--wait', 'systemd-inhibit', '--why=bareloop panel accept',
|
|
195
|
+
process.execPath, bareloopBin,
|
|
196
|
+
'run-u', '--spec', ctx.specPath, '--door', runid, '--decide', 'accept', '--approve', ctx.specHash,
|
|
197
|
+
];
|
|
198
|
+
/** @type {number|null} */
|
|
199
|
+
let logFd = null;
|
|
200
|
+
try { logFd = openSync(logFile, 'a'); } catch { logFd = null; }
|
|
201
|
+
const child = spawnFn('setsid', args, { detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: envNow() });
|
|
202
|
+
if (logFd !== null) { try { closeSync(logFd); } catch { /* the child holds its own copy */ } }
|
|
203
|
+
if (typeof child?.unref === 'function') child.unref();
|
|
204
|
+
let done = false;
|
|
205
|
+
const finish = (/** @type {number} */ code, /** @type {any} */ obj) => { if (!done) { done = true; send(code, obj); } };
|
|
206
|
+
// an accept that outlasts the wait is still going: say so (202), never claim it passed
|
|
207
|
+
const timer = setTimeout(() => finish(202, { ok: true, pending: true, runid, log: logFile }), opts.acceptWaitMs ?? DEFAULT_ACCEPT_WAIT_MS);
|
|
208
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
209
|
+
if (typeof child?.once === 'function') {
|
|
210
|
+
child.once('exit', (/** @type {number|null} */ code) => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
if (code === 0) finish(200, { ok: true, accepted: true, runid, log: logFile });
|
|
213
|
+
else finish(409, { ok: false, error: engineRefusalWords(readLogTail(logFile), 'accept', logFile, questionOf), log: logFile });
|
|
214
|
+
});
|
|
215
|
+
child.once('error', (/** @type {Error} */ e) => {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
finish(500, { ok: false, error: `could not start the engine: ${e.message}` });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* RULED 2026-10-15 — `POST /api/runs/:runid/needs-remove`. Takes a stopped run's card out of Needs you and does nothing else: ONE
|
|
225
|
+
* `needs-removed` row appended to runs.jsonl (status, the run's row, Resume and Reuse workflow are untouched). Refused (409) unless the run
|
|
226
|
+
* is in Needs you with the Resume tag right now; a run waiting for the person's Accept is never removable. A second click finds it already out.
|
|
227
|
+
* @param {import('node:http').IncomingMessage} req
|
|
228
|
+
* @param {(code: number, obj: any) => void} send
|
|
229
|
+
* @param {string} runid
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
function handleNeedsRemove(req, send, runid) {
|
|
233
|
+
const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
|
|
234
|
+
if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
|
|
235
|
+
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
236
|
+
const ctx = opts.getNeedsContext ? opts.getNeedsContext(runid) : null;
|
|
237
|
+
if (!ctx) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
238
|
+
if (ctx.needs === 'Accept') { send(409, { ok: false, error: 'This run is waiting for your Accept, so it stays in Needs you until you answer it.' }); return true; }
|
|
239
|
+
if (ctx.needs !== 'Resume') { send(409, { ok: false, error: 'This run is not in Needs you, so there is nothing to remove.' }); return true; }
|
|
240
|
+
try { appendNeedsRemoved(runid, { home: opts.home }); } catch (e) {
|
|
241
|
+
send(500, { ok: false, error: `could not record it: ${/** @type {Error} */ (e).message}` });
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
send(200, { ok: true, runid, removed: true });
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
101
248
|
/**
|
|
102
249
|
* @param {import('node:http').IncomingMessage} req
|
|
103
250
|
* @param {import('node:http').ServerResponse} res
|
|
@@ -108,13 +255,17 @@ export function createRunRoutes(opts) {
|
|
|
108
255
|
function handle(req, res, pathname, body) {
|
|
109
256
|
const stopM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/stop$/.exec(pathname);
|
|
110
257
|
const m = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/resume$/.exec(pathname);
|
|
111
|
-
|
|
258
|
+
const acceptM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/accept$/.exec(pathname);
|
|
259
|
+
const needsM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/needs-remove$/.exec(pathname);
|
|
260
|
+
if (!m && !stopM && !acceptM && !needsM) return false;
|
|
112
261
|
const send = (/** @type {number} */ code, /** @type {any} */ obj) => {
|
|
113
262
|
const text = JSON.stringify(obj);
|
|
114
263
|
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
|
|
115
264
|
res.end(text);
|
|
116
265
|
};
|
|
117
266
|
if (stopM) return handleStop(req, send, stopM[1]);
|
|
267
|
+
if (acceptM) return handleAccept(req, send, acceptM[1]);
|
|
268
|
+
if (needsM) return handleNeedsRemove(req, send, needsM[1]);
|
|
118
269
|
if (!m) return false;
|
|
119
270
|
const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
|
|
120
271
|
if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
|
|
@@ -125,6 +276,13 @@ export function createRunRoutes(opts) {
|
|
|
125
276
|
const { plan } = ctx;
|
|
126
277
|
if (!plan.ok) { send(409, { ok: false, error: `Resume is not available for this run (${plan.why}).` }); return true; }
|
|
127
278
|
|
|
279
|
+
// RULED 2026-10-16: like Accept, an old run whose saved job fails today's checks is refused at $0 in plain words, before any spawn
|
|
280
|
+
{
|
|
281
|
+
let jv;
|
|
282
|
+
try { jv = validateJob(plan.spec, { shellCapUsd: plan.spec.budgetUsd }); } catch { jv = { ok: false, reds: [] }; }
|
|
283
|
+
if (!jv.ok) { send(409, { ok: false, error: OLD_JOB_RESUME_WORDS, reds: jv.reds.map((r) => `${r.code} at ${r.path}`) }); return true; }
|
|
284
|
+
}
|
|
285
|
+
|
|
128
286
|
// the caps: blank/absent = the signed ones. A raised (or lowered) cap is a new spec.
|
|
129
287
|
/** @type {any} */
|
|
130
288
|
const spec = { ...plan.spec };
|
|
@@ -207,7 +365,7 @@ export function createRunRoutes(opts) {
|
|
|
207
365
|
ok: true, runid: m[1], specHash, capsChanged: changed, log: logFile,
|
|
208
366
|
});
|
|
209
367
|
} else {
|
|
210
|
-
finish(409, { ok: false, error: readLogTail(logFile)
|
|
368
|
+
finish(409, { ok: false, error: engineRefusalWords(readLogTail(logFile), 'resume', logFile), log: logFile });
|
|
211
369
|
}
|
|
212
370
|
});
|
|
213
371
|
child.once('error', (/** @type {Error} */ e) => {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// T Part 5 (PANEL-BUILD.md T addendum, C.5): the panel's scheduler. The panel server runs the clock and the folder watch
|
|
2
|
+
// while it is up; with the panel off nothing fires (T6), and slots missed collapse to ONE catch-up when it comes back.
|
|
3
|
+
//
|
|
4
|
+
// ONE `tick()` does the sync work; the tests call it by hand with an injected clock and await what it returns (the fires
|
|
5
|
+
// it started). The real timer only calls it.
|
|
6
|
+
// - 500 ms: the folder watch needs a file's size and mtime to hold for STABLE_POLLS (2) polls, so a drop is seen about
|
|
7
|
+
// 1.5 s after it stops changing. Poll only, never fs.watch (EMFILE on hamr's machine, and it misses atomic renames).
|
|
8
|
+
// - the timer is unref'd, `start`/`stop` are idempotent, the server's close calls `stop`.
|
|
9
|
+
// - with no schedule on disk a tick is one readdir and nothing else (no runs.jsonl read).
|
|
10
|
+
// What a tick does for one job with a signed schedule:
|
|
11
|
+
// 1. arm it if it is not armed under this signing (a crash between the sign and the arm);
|
|
12
|
+
// 2. clock kinds: `due`; a due slot goes to `door.fire` and is NOT consumed up front: the starter's `beforeSpawn` writes
|
|
13
|
+
// the `fire` row only after busy, monthly, wouldAccept and the session all said yes, so a refused slot retries;
|
|
14
|
+
// 3. file kinds: `poll`; each file taken is recorded `arrived` (THIS is the only writer of `arrived` rows), then the
|
|
15
|
+
// queue runs: busy tells each queued file once and waits; free, the head is looked at again (deleted or changed is
|
|
16
|
+
// dropped with a reason, the next head is tried) and goes through `door.fire`; `ran` is written by `beforeSpawn` only.
|
|
17
|
+
// One job's exception is written once as a `refused` row and never stops the others (fwd scheduler.js:94-110).
|
|
18
|
+
// BUSY IS CHEAP (T19): the run list is read at most ONCE per tick, and only when a fire or a queued head needs it.
|
|
19
|
+
// Rewritten from fwdloop src/panel/scheduler.js.
|
|
20
|
+
import { due } from '../triggerclock.js';
|
|
21
|
+
import { isClockTrigger, isFileTrigger } from '../trigger.js';
|
|
22
|
+
import { appendRow, readLedger } from '../triggerledger.js';
|
|
23
|
+
import {
|
|
24
|
+
createFileTrigger, queueOf, readSeen, recheck, recordArrived, recordDropped, recordRan,
|
|
25
|
+
} from '../triggerfiles.js';
|
|
26
|
+
import { recordFire } from '../triggerclock.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ door: ReturnType<typeof import('./schedules.js').createScheduleDoor>, now?: () => number, intervalMs?: number }} opts
|
|
30
|
+
* `intervalMs` 0 = no timer (a test drives `tick`).
|
|
31
|
+
*/
|
|
32
|
+
export function createScheduler(opts) {
|
|
33
|
+
const { door, now = Date.now, intervalMs = 500 } = opts;
|
|
34
|
+
/** @type {Map<string, ReturnType<typeof createFileTrigger>>} */
|
|
35
|
+
const watchers = new Map();
|
|
36
|
+
/** `job|sig` keys already armed this process @type {Set<string>} */
|
|
37
|
+
const armedKeys = new Set();
|
|
38
|
+
/** jobs with a fire still awaiting its start (a headless prepare can take minutes): never fired a second time meanwhile @type {Set<string>} */
|
|
39
|
+
const inflight = new Set();
|
|
40
|
+
/** @type {ReturnType<typeof setInterval>|null} */
|
|
41
|
+
let timer = null;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The queue of a file trigger (arrived minus ran minus dropped, oldest first).
|
|
45
|
+
* @param {string} job @param {string} ledger @param {string} sig @param {number} nowMs @param {() => boolean} isBusy @param {Promise<unknown>[]} pending
|
|
46
|
+
*/
|
|
47
|
+
function runQueue(job, ledger, sig, nowMs, isBusy, pending) {
|
|
48
|
+
let queue = queueOf(readSeen(ledger));
|
|
49
|
+
if (queue.length === 0 || inflight.has(job)) return;
|
|
50
|
+
const busy = isBusy();
|
|
51
|
+
if (busy) {
|
|
52
|
+
// every queued file is told ONCE (`busy`, once per sha) and waits
|
|
53
|
+
for (const q of queue) pending.push(door.fire(job, { kind: 'file', path: q.path, sha: q.sha, size: q.size }, { busy: true }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
while (queue.length > 0) {
|
|
57
|
+
const q = queue[0];
|
|
58
|
+
const seen = recheck(q);
|
|
59
|
+
if (seen.state !== 'ok') {
|
|
60
|
+
const why = seen.state === 'deleted' ? 'deleted' : 'changed';
|
|
61
|
+
recordDropped(ledger, q, why, nowMs, sig);
|
|
62
|
+
appendRow(ledger, {
|
|
63
|
+
kind: why, sig, at: nowMs, sha: q.sha, file: q.path,
|
|
64
|
+
say: seen.state === 'deleted' ? `${q.name} did not run: ${seen.say}.` : `${q.name} did not run: it changed before its turn; the new version arrives again.`,
|
|
65
|
+
});
|
|
66
|
+
queue = queue.slice(1);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const item = { path: seen.path, sha: seen.sha, size: seen.size };
|
|
70
|
+
pending.push(startFire(job, { kind: 'file', ...item }, { busy: false, beforeSpawn: () => { recordRan(ledger, item, nowMs, sig); } }, sig));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fire and remember it is in flight until it settles. A throw is written once and never escapes.
|
|
77
|
+
* @param {string} job @param {Parameters<ReturnType<typeof import('./schedules.js').createScheduleDoor>['fire']>[1]} cause
|
|
78
|
+
* @param {{busy?: boolean, beforeSpawn?: () => void}} hooks @param {string} sig
|
|
79
|
+
*/
|
|
80
|
+
function startFire(job, cause, hooks, sig) {
|
|
81
|
+
inflight.add(job);
|
|
82
|
+
return door.fire(job, cause, hooks)
|
|
83
|
+
.catch((e) => { door.tellOnce(job, sig, `fire|${/** @type {Error} */ (e).message}`, `The run could not start: ${/** @type {Error} */ (e).message}`); })
|
|
84
|
+
.finally(() => { inflight.delete(job); });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @returns {Promise<void>} resolves when every fire this tick started has settled */
|
|
88
|
+
function tick() {
|
|
89
|
+
const jobs = door.scan();
|
|
90
|
+
if (jobs.length === 0) { watchers.clear(); armedKeys.clear(); return Promise.resolve(); }
|
|
91
|
+
const nowMs = now();
|
|
92
|
+
/** @type {Promise<unknown>[]} */
|
|
93
|
+
const pending = [];
|
|
94
|
+
/** the ONE read of the run list this tick, taken the first time a job needs it @type {Map<string, boolean>} */
|
|
95
|
+
const busyMemo = new Map();
|
|
96
|
+
const live = new Set();
|
|
97
|
+
for (const { job, schedule } of jobs) {
|
|
98
|
+
const sig = schedule.specHash;
|
|
99
|
+
const key = `${job}|${sig}`;
|
|
100
|
+
live.add(key);
|
|
101
|
+
const isBusy = () => {
|
|
102
|
+
if (!busyMemo.has(job)) busyMemo.set(job, door.busy(job));
|
|
103
|
+
return /** @type {boolean} */ (busyMemo.get(job));
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
if (!armedKeys.has(key)) { door.armFor(job, schedule); armedKeys.add(key); }
|
|
107
|
+
const ledger = door.ledgerOf(job);
|
|
108
|
+
const { trigger } = schedule;
|
|
109
|
+
if (isFileTrigger(trigger)) {
|
|
110
|
+
let w = watchers.get(key);
|
|
111
|
+
if (!w) {
|
|
112
|
+
w = createFileTrigger({
|
|
113
|
+
mode: trigger.kind === 'new-file' ? 'new' : 'updated', target: trigger.kind === 'new-file' ? trigger.folder : trigger.path, ledger, sig, now,
|
|
114
|
+
});
|
|
115
|
+
watchers.set(key, w);
|
|
116
|
+
}
|
|
117
|
+
const { arrived, refused } = w.poll();
|
|
118
|
+
for (const r of refused) door.tellOnce(job, sig, `refused|${r.path}|${r.why}`, `${r.path}: ${r.why}`);
|
|
119
|
+
// THE writer of `arrived`: the arrival time is this clock, never the file's mtime
|
|
120
|
+
for (const f of arrived) recordArrived(ledger, f, nowMs, sig);
|
|
121
|
+
// RULED 2026-10-15: a schedule that gave up is paused (read from the ledger): files still queue as `arrived`, none runs
|
|
122
|
+
if (!door.paused(job)) runQueue(job, ledger, sig, nowMs, isBusy, pending);
|
|
123
|
+
} else if (isClockTrigger(trigger)) {
|
|
124
|
+
if (inflight.has(job) || door.paused(job)) continue;
|
|
125
|
+
const d = due(trigger, readLedger(ledger).rows, nowMs);
|
|
126
|
+
if (d.fire) {
|
|
127
|
+
pending.push(startFire(job, { kind: 'clock', slot: /** @type {number} */ (d.slot), catchUp: d.catchUp, missed: d.missed },
|
|
128
|
+
{ busy: isBusy(), beforeSpawn: () => { recordFire(ledger, d, nowMs, sig); } }, sig));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
const why = /** @type {Error} */ (e).message;
|
|
133
|
+
try { door.tellOnce(job, sig, `tick|${why}`, why); } catch { /* the ledger itself is unwritable: nothing more to do for this job */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const k of [...watchers.keys()]) if (!live.has(k)) watchers.delete(k);
|
|
137
|
+
for (const k of [...armedKeys]) if (!live.has(k)) armedKeys.delete(k);
|
|
138
|
+
return Promise.all(pending).then(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
tick,
|
|
143
|
+
/** Start the real timer (idempotent). */
|
|
144
|
+
start() {
|
|
145
|
+
if (intervalMs <= 0 || timer !== null) return;
|
|
146
|
+
timer = setInterval(() => { tick().catch(() => { /* one bad tick never stops the next */ }); }, intervalMs);
|
|
147
|
+
timer.unref();
|
|
148
|
+
},
|
|
149
|
+
/** Stop it (idempotent). The server's close calls this. */
|
|
150
|
+
stop() {
|
|
151
|
+
if (timer !== null) { clearInterval(timer); timer = null; }
|
|
152
|
+
},
|
|
153
|
+
/** whether the real timer is running (a test reads this) */
|
|
154
|
+
get running() { return timer !== null; },
|
|
155
|
+
};
|
|
156
|
+
}
|