bareloop 0.42.0 → 0.44.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 +88 -0
- package/bareloop.context.md +103 -41
- package/package.json +2 -2
- package/src/authorflow.js +26 -11
- package/src/authoring.js +1 -1
- package/src/authorjob.js +33 -15
- package/src/authorreadout.js +7 -6
- package/src/declaredclose.js +6 -3
- package/src/draftspend.js +20 -2
- package/src/facts.js +54 -9
- package/src/factwords.js +58 -6
- package/src/inputlook.js +38 -3
- package/src/interviewrun.js +0 -1
- package/src/job.js +39 -12
- package/src/kinds.js +5 -0
- package/src/pagedropwords.js +64 -0
- package/src/pagemap.js +264 -0
- package/src/pagerules.js +319 -0
- package/src/panel/authorroutes.js +138 -60
- package/src/panel/authorsession.js +88 -32
- package/src/panel/index.html +1251 -522
- package/src/panel/jobplan.js +4 -2
- package/src/panel/runroutes.js +188 -6
- 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 +1234 -68
- package/src/panel/status.js +33 -0
- package/src/panelmoney.js +29 -0
- package/src/planrun.js +92 -11
- package/src/promptregisters.js +4 -0
- package/src/providerrows.js +3 -3
- package/src/replay.js +118 -7
- package/src/reviewdoor.js +37 -0
- package/src/rubricstep.js +123 -11
- package/src/runlist.js +37 -1
- package/src/schedulewords.js +299 -0
- package/src/source.js +18 -0
- package/src/thinking.js +63 -2
- 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 +34 -21
- package/types/authorflow.d.ts +42 -6
- package/types/authorjob.d.ts +10 -2
- package/types/declaredclose.d.ts +3 -1
- package/types/draftspend.d.ts +27 -1
- package/types/facts.d.ts +15 -0
- package/types/factwords.d.ts +26 -3
- package/types/inputlook.d.ts +15 -0
- package/types/job.d.ts +8 -1
- package/types/kinds.d.ts +5 -0
- package/types/pagedropwords.d.ts +26 -0
- package/types/pagemap.d.ts +154 -0
- package/types/pagerules.d.ts +86 -0
- package/types/panel/authorroutes.d.ts +50 -14
- 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 +297 -15
- package/types/panel/status.d.ts +51 -0
- package/types/panelmoney.d.ts +5 -0
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/replay.d.ts +21 -1
- package/types/reviewdoor.d.ts +27 -0
- package/types/rubricstep.d.ts +57 -1
- package/types/runlist.d.ts +24 -1
- package/types/schedulewords.d.ts +137 -0
- package/types/source.d.ts +11 -0
- package/types/thinking.d.ts +39 -0
- package/types/trigger.d.ts +37 -0
- package/types/triggerclock.d.ts +52 -0
- package/types/triggerfiles.d.ts +93 -0
- package/types/triggerledger.d.ts +35 -0
- package/types/userrun.d.ts +4 -0
package/src/panel/jobplan.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// `cls` is 'machine check' | 'judge' | null (null = not known yet: the confirm card's checks are the model's plan, the
|
|
12
12
|
// stage kinds exist only after drafting). Pure; no file IO.
|
|
13
13
|
import { KIND_CATALOGUE } from '../authoring.js';
|
|
14
|
-
import { checksOfClose, proseNotChecked, lineRules } from '../rubricstep.js';
|
|
14
|
+
import { checksOfClose, proseNotChecked, lineRules, confirmedGoalOf } from '../rubricstep.js';
|
|
15
15
|
|
|
16
16
|
/** @typedef {{name: string, cls: 'machine check'|'judge'|null}} PlanCheck */
|
|
17
17
|
/** @typedef {{n: number, text: string, rule: string, rules: string[], checks: PlanCheck[], refused: string[]}} PlanLine */
|
|
@@ -100,7 +100,9 @@ export function planFromSpec(spec) {
|
|
|
100
100
|
if (line && typeof r.reason === 'string') line.refused.push(ruleText ? `"${ruleText}" — ${r.reason}` : r.reason);
|
|
101
101
|
}
|
|
102
102
|
// the model's signed goal sentence rides along: the page draws it as the "AI's summary" row (an older job shows it as line 1 instead)
|
|
103
|
-
|
|
103
|
+
// (a rubric job's signed goal is the whole worker brief: only the sentence the person confirmed is the summary)
|
|
104
|
+
const summary = typeof spec?.goal === 'string' && spec.goal ? confirmedGoalOf(spec.goal) : null;
|
|
105
|
+
return { lines, loose, notChecked: notes, alwaysOn, older: false, ...(summary ? { goal: summary } : {}) };
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
/**
|
package/src/panel/runroutes.js
CHANGED
|
@@ -16,12 +16,16 @@ import {
|
|
|
16
16
|
openSync, closeSync, writeFileSync, readFileSync, readdirSync,
|
|
17
17
|
} from 'node:fs';
|
|
18
18
|
import { dirname, join } from 'node:path';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
checkHumanGuard, OLD_JOB_ACCEPT_WORDS, OLD_JOB_RESUME_WORDS, OLD_JOB_RERUN_WORDS, OLD_JOB_PAUSE_WORDS,
|
|
21
|
+
} from './authorroutes.js';
|
|
20
22
|
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
21
23
|
import { ConfigError } from '../config.js';
|
|
22
24
|
import { keysForDoor } from '../keysfile.js';
|
|
23
|
-
import { jobSpecHash } from '../job.js';
|
|
25
|
+
import { jobSpecHash, validateJob } from '../job.js';
|
|
24
26
|
import { stopFilePath } from '../legs.js';
|
|
27
|
+
import { panelMoney2 } from '../panelmoney.js';
|
|
28
|
+
import { appendNeedsRemoved } from '../runlist.js';
|
|
25
29
|
|
|
26
30
|
/** the tail of an engine log shown to the person when the engine refuses at start (bytes) */
|
|
27
31
|
const LOG_TAIL_BYTES = 4000;
|
|
@@ -29,6 +33,9 @@ const LOG_TAIL_BYTES = 4000;
|
|
|
29
33
|
/** how long a resume waits for an early engine exit before reporting "started" (ms) */
|
|
30
34
|
export const DEFAULT_SETTLE_MS = 6000;
|
|
31
35
|
|
|
36
|
+
/** how long an Accept waits for the engine's answer (it re-runs the mechanical close stages) before saying it is still checking (ms) */
|
|
37
|
+
export const DEFAULT_ACCEPT_WAIT_MS = 5 * 60_000;
|
|
38
|
+
|
|
32
39
|
/**
|
|
33
40
|
* The next free `resolved-spec-r<k>.json` name in `dir` (k from 1) — never an existing one.
|
|
34
41
|
* @param {string} dir
|
|
@@ -55,6 +62,59 @@ function readLogTail(file) {
|
|
|
55
62
|
} catch { return ''; }
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/**
|
|
66
|
+
* RULED 2026-10-16: the person never sees raw engine output. Of the engine's log tail, only a line it
|
|
67
|
+
* owns as a plain sentence passes through (a `Nothing spent.` / `Nothing was spent.` refusal, `DOOR REFUSED (...) — nothing was
|
|
68
|
+
* recorded...`, `CHECKPOINT EXPIRED — ...`); anything else (validator reds, exit statuses, stack traces, argv) becomes ONE fixed
|
|
69
|
+
* sentence naming the log. The one owner for the Resume and Accept routes.
|
|
70
|
+
* @param {string} tail the log tail
|
|
71
|
+
* @param {'resume'|'accept'|'rerun'|'pause'} kind
|
|
72
|
+
* @param {string} logFile
|
|
73
|
+
* @param {(stage: string) => string|null} [questionOf] stage name to the panel's plain question (`null` = none)
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function engineRefusalWords(tail, kind, logFile, questionOf = () => null) {
|
|
77
|
+
const known = /(?:\bNothing (?:was )?spent\.|^DOOR REFUSED \(.*\) — nothing was recorded|^CHECKPOINT EXPIRED — )/;
|
|
78
|
+
const raw = /validateJob|red\(s\)|exit status|node-\d|\bat \S+:\d+|\s--[a-z]|^\s*at |Error:/;
|
|
79
|
+
for (const line of String(tail ?? '').split('\n').map((l) => l.trim())) {
|
|
80
|
+
if (line !== '' && known.test(line) && !raw.test(line)) {
|
|
81
|
+
// RULED 2026-10-16 (A): a refused Accept names each failed check by the panel's own plain question
|
|
82
|
+
const asked = kind === 'accept' ? doorRedStages(tail).map((n) => questionOf(n)).filter((q) => typeof q === 'string' && q !== '') : [];
|
|
83
|
+
if (asked.length > 0) {
|
|
84
|
+
const shown = asked.slice(0, 3).map((q) => `"${q}"`).join('; ');
|
|
85
|
+
const more = asked.length > 3 ? ` and ${asked.length - 3} more` : '';
|
|
86
|
+
return `Accept did not go through: the check ${shown}${more} said no. Nothing was recorded.`;
|
|
87
|
+
}
|
|
88
|
+
return line;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (kind === 'resume') return `Resume did not start. Nothing was spent on it. The details are in ${logFile}.`;
|
|
92
|
+
if (kind === 'rerun') return `Rerun did not start. Nothing was spent on it. The details are in ${logFile}.`;
|
|
93
|
+
if (kind === 'pause') return `Pause did not go through. Nothing was recorded. The details are in ${logFile}.`;
|
|
94
|
+
return `Accept did not go through. Nothing was recorded. The details are in ${logFile}.`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The stage names a door-accept-red refusal failed on, read from the engine's own refusal lines (src/userrun.js):
|
|
99
|
+
* the `door-accept-red:stage.<name> — ...` line and the `<name> <verdict>` lines of "the mechanical re-run, stage by stage"
|
|
100
|
+
* whose verdict is not `satisfied`. Names only — the caller maps them to the panel's plain questions.
|
|
101
|
+
* @param {string} tail
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
function doorRedStages(tail) {
|
|
105
|
+
/** @type {string[]} */
|
|
106
|
+
const names = [];
|
|
107
|
+
let inList = false;
|
|
108
|
+
for (const line of String(tail ?? '').split('\n')) {
|
|
109
|
+
const red = /^\s*door-accept-red:stage\.(.+?) — /.exec(line);
|
|
110
|
+
if (red) { names.push(red[1]); continue; }
|
|
111
|
+
if (/^\s*the mechanical re-run, stage by stage:/.test(line)) { inList = true; continue; }
|
|
112
|
+
const row = inList ? /^\s{4}(\S.*?)\s{2,}(\S+)\s*$/.exec(line) : null;
|
|
113
|
+
if (row && row[2] !== 'satisfied') names.push(row[1]);
|
|
114
|
+
}
|
|
115
|
+
return [...new Set(names)];
|
|
116
|
+
}
|
|
117
|
+
|
|
58
118
|
/**
|
|
59
119
|
* The run routes' handler. `getResumeContext(runid)` is the server's own reader (the same
|
|
60
120
|
* `resumePlanFor` the Ended block uses, so the page never offers what this refuses):
|
|
@@ -62,7 +122,11 @@ function readLogTail(file) {
|
|
|
62
122
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>, home?: string,
|
|
63
123
|
* spawnFn?: typeof realSpawn, bareloopBin?: string, settleMs?: number,
|
|
64
124
|
* getResumeContext: (runid: string) => ({row: any, plan: any}|null),
|
|
65
|
-
* getStopContext?: (runid: string) => ({row: any, live: boolean, spineExists: boolean}|null)
|
|
125
|
+
* getStopContext?: (runid: string) => ({row: any, live: boolean, spineExists: boolean}|null),
|
|
126
|
+
* getNeedsContext?: (runid: string) => ({row: any, needs: 'Accept'|'Resume'|null}|null),
|
|
127
|
+
* getAcceptContext?: (runid: string) => ({row: any, live: boolean, waiting: boolean, specPath: string|null, specHash: string|null}|null),
|
|
128
|
+
* stageQuestionsOfSpec?: (spec: any) => Map<string, string|null>,
|
|
129
|
+
* acceptWaitMs?: number }} opts
|
|
66
130
|
*/
|
|
67
131
|
export function createRunRoutes(opts) {
|
|
68
132
|
const envNow = () => keysForDoor({ env: opts.env, keysHome: opts.home }).env;
|
|
@@ -98,6 +162,109 @@ export function createRunRoutes(opts) {
|
|
|
98
162
|
return true;
|
|
99
163
|
}
|
|
100
164
|
|
|
165
|
+
/**
|
|
166
|
+
* T Part 8 + PRD v1.107 Part 6 — the person's answer to a run waiting at its REVIEW DOOR: `accept`, `rerun` (with their words) or `pause`, recorded through
|
|
167
|
+
* the library's own door path and nothing else: `run-u --spec <signed spec> --door <runid> --decide <accept|rerun|pause> [--text …] --approve <hash>`
|
|
168
|
+
* (src/userrun.js, `answerReviewDoor`, src/reviewdoor.js). The panel invents no verdict path: the engine re-runs the MECHANICAL
|
|
169
|
+
* stages against the tree as it stands for an accept (a red there is `door-accept-red` and refuses), and only then is the disposition written
|
|
170
|
+
* to the run's own spine. The verdict the run minted is untouched. The engine's own words are what the page shows.
|
|
171
|
+
* An accept and a pause end when the engine does; a RERUN launches a fresh engagement (a paid run), so it answers like Resume: started, or the engine's refusal.
|
|
172
|
+
* @param {import('node:http').IncomingMessage} req
|
|
173
|
+
* @param {(code: number, obj: any) => void} send
|
|
174
|
+
* @param {string} runid
|
|
175
|
+
* @param {'accept'|'rerun'|'pause'} decide
|
|
176
|
+
* @param {any} body the request body (`text` for a rerun)
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
function handleDoor(req, send, runid, decide, body) {
|
|
180
|
+
const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
|
|
181
|
+
if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
|
|
182
|
+
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
183
|
+
const ctx = opts.getAcceptContext ? opts.getAcceptContext(runid) : null;
|
|
184
|
+
if (!ctx) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
185
|
+
if (ctx.live) { send(409, { ok: false, error: decide === 'accept' ? 'This run is still running, so there is nothing to accept yet.' : `This run is still running, so there is nothing to ${decide} yet.` }); return true; }
|
|
186
|
+
if (!ctx.waiting) { send(409, { ok: false, error: decide === 'accept' ? 'This run is not waiting for your Accept (it opened no review door, the door was already answered, or it has expired).' : `This run is not waiting for your answer, so it cannot be ${decide === 'rerun' ? 'rerun' : 'paused'} (it opened no review door, the door was already answered, or it has expired).` }); return true; }
|
|
187
|
+
// a rerun's words ARE the gap the worker is given: empty words are refused here in plain words (the engine refuses them too, src/kinds.js normalizeHumanRuling)
|
|
188
|
+
const text = decide === 'rerun' && typeof body?.text === 'string' ? body.text.trim() : '';
|
|
189
|
+
if (decide === 'rerun' && text === '') { send(400, { ok: false, error: 'Write what you want done differently, then press Rerun. Your words are what the next attempt is given.' }); return true; }
|
|
190
|
+
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; }
|
|
191
|
+
// RULED 2026-10-15: an old run whose saved job fails today's checks is refused at $0 in plain words, before any spawn
|
|
192
|
+
/** @type {any} */
|
|
193
|
+
let signed = null;
|
|
194
|
+
try { signed = JSON.parse(readFileSync(ctx.specPath, 'utf8')); } catch { signed = null; }
|
|
195
|
+
if (signed !== null) {
|
|
196
|
+
let jv;
|
|
197
|
+
try { jv = validateJob(signed, { shellCapUsd: signed.budgetUsd }); } catch { jv = { ok: false, reds: [] }; }
|
|
198
|
+
if (!jv.ok) {
|
|
199
|
+
const words = decide === 'accept' ? OLD_JOB_ACCEPT_WORDS : (decide === 'rerun' ? OLD_JOB_RERUN_WORDS : OLD_JOB_PAUSE_WORDS);
|
|
200
|
+
send(409, { ok: false, error: words, reds: jv.reds.map((r) => `${r.code} at ${r.path}`) });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const questionOf = (/** @type {string} */ n) => (signed !== null && opts.stageQuestionsOfSpec ? opts.stageQuestionsOfSpec(signed).get(n) ?? null : null);
|
|
205
|
+
const dir = dirname(ctx.specPath);
|
|
206
|
+
const logFile = join(dir, `${decide}-${runid}-${Date.now()}.log`);
|
|
207
|
+
const args = [
|
|
208
|
+
'--wait', 'systemd-inhibit', `--why=bareloop panel ${decide}`,
|
|
209
|
+
process.execPath, bareloopBin,
|
|
210
|
+
'run-u', '--spec', ctx.specPath, '--door', runid, '--decide', decide, '--approve', ctx.specHash,
|
|
211
|
+
// last, so no flag lookup in the engine can land on the person's words
|
|
212
|
+
...(decide === 'rerun' ? ['--text', text] : []),
|
|
213
|
+
];
|
|
214
|
+
/** @type {number|null} */
|
|
215
|
+
let logFd = null;
|
|
216
|
+
try { logFd = openSync(logFile, 'a'); } catch { logFd = null; }
|
|
217
|
+
const child = spawnFn('setsid', args, { detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: envNow() });
|
|
218
|
+
if (logFd !== null) { try { closeSync(logFd); } catch { /* the child holds its own copy */ } }
|
|
219
|
+
if (typeof child?.unref === 'function') child.unref();
|
|
220
|
+
let done = false;
|
|
221
|
+
const finish = (/** @type {number} */ code, /** @type {any} */ obj) => { if (!done) { done = true; send(code, obj); } };
|
|
222
|
+
const answered = { accept: { accepted: true }, rerun: { rerunning: true }, pause: { paused: true } }[decide];
|
|
223
|
+
// an accept that outlasts the wait is still going: say so (202), never claim it passed. A rerun is a run: it is "started" once it outlives the settle window.
|
|
224
|
+
const timer = setTimeout(
|
|
225
|
+
() => (decide === 'rerun' ? finish(200, { ok: true, ...answered, runid, log: logFile }) : finish(202, { ok: true, pending: true, runid, log: logFile })),
|
|
226
|
+
decide === 'rerun' ? settleMs : (opts.acceptWaitMs ?? DEFAULT_ACCEPT_WAIT_MS),
|
|
227
|
+
);
|
|
228
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
229
|
+
if (typeof child?.once === 'function') {
|
|
230
|
+
child.once('exit', (/** @type {number|null} */ code) => {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
if (code === 0) finish(200, { ok: true, ...answered, runid, log: logFile });
|
|
233
|
+
else finish(409, { ok: false, error: engineRefusalWords(readLogTail(logFile), decide, logFile, questionOf), log: logFile });
|
|
234
|
+
});
|
|
235
|
+
child.once('error', (/** @type {Error} */ e) => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
finish(500, { ok: false, error: `could not start the engine: ${e.message}` });
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 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
|
|
245
|
+
* `needs-removed` row appended to runs.jsonl (status, the run's row, Resume and Reuse workflow are untouched). Refused (409) unless the run
|
|
246
|
+
* 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.
|
|
247
|
+
* @param {import('node:http').IncomingMessage} req
|
|
248
|
+
* @param {(code: number, obj: any) => void} send
|
|
249
|
+
* @param {string} runid
|
|
250
|
+
* @returns {boolean}
|
|
251
|
+
*/
|
|
252
|
+
function handleNeedsRemove(req, send, runid) {
|
|
253
|
+
const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
|
|
254
|
+
if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
|
|
255
|
+
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
256
|
+
const ctx = opts.getNeedsContext ? opts.getNeedsContext(runid) : null;
|
|
257
|
+
if (!ctx) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
258
|
+
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; }
|
|
259
|
+
if (ctx.needs !== 'Resume') { send(409, { ok: false, error: 'This run is not in Needs you, so there is nothing to remove.' }); return true; }
|
|
260
|
+
try { appendNeedsRemoved(runid, { home: opts.home }); } catch (e) {
|
|
261
|
+
send(500, { ok: false, error: `could not record it: ${/** @type {Error} */ (e).message}` });
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
send(200, { ok: true, runid, removed: true });
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
101
268
|
/**
|
|
102
269
|
* @param {import('node:http').IncomingMessage} req
|
|
103
270
|
* @param {import('node:http').ServerResponse} res
|
|
@@ -108,13 +275,21 @@ export function createRunRoutes(opts) {
|
|
|
108
275
|
function handle(req, res, pathname, body) {
|
|
109
276
|
const stopM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/stop$/.exec(pathname);
|
|
110
277
|
const m = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/resume$/.exec(pathname);
|
|
111
|
-
|
|
278
|
+
const acceptM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/accept$/.exec(pathname);
|
|
279
|
+
const rerunM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/rerun$/.exec(pathname);
|
|
280
|
+
const pauseM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/pause$/.exec(pathname);
|
|
281
|
+
const needsM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/needs-remove$/.exec(pathname);
|
|
282
|
+
if (!m && !stopM && !acceptM && !needsM && !rerunM && !pauseM) return false;
|
|
112
283
|
const send = (/** @type {number} */ code, /** @type {any} */ obj) => {
|
|
113
284
|
const text = JSON.stringify(obj);
|
|
114
285
|
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
|
|
115
286
|
res.end(text);
|
|
116
287
|
};
|
|
117
288
|
if (stopM) return handleStop(req, send, stopM[1]);
|
|
289
|
+
if (acceptM) return handleDoor(req, send, acceptM[1], 'accept', body);
|
|
290
|
+
if (rerunM) return handleDoor(req, send, rerunM[1], 'rerun', body);
|
|
291
|
+
if (pauseM) return handleDoor(req, send, pauseM[1], 'pause', body);
|
|
292
|
+
if (needsM) return handleNeedsRemove(req, send, needsM[1]);
|
|
118
293
|
if (!m) return false;
|
|
119
294
|
const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
|
|
120
295
|
if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
|
|
@@ -125,6 +300,13 @@ export function createRunRoutes(opts) {
|
|
|
125
300
|
const { plan } = ctx;
|
|
126
301
|
if (!plan.ok) { send(409, { ok: false, error: `Resume is not available for this run (${plan.why}).` }); return true; }
|
|
127
302
|
|
|
303
|
+
// 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
|
|
304
|
+
{
|
|
305
|
+
let jv;
|
|
306
|
+
try { jv = validateJob(plan.spec, { shellCapUsd: plan.spec.budgetUsd }); } catch { jv = { ok: false, reds: [] }; }
|
|
307
|
+
if (!jv.ok) { send(409, { ok: false, error: OLD_JOB_RESUME_WORDS, reds: jv.reds.map((r) => `${r.code} at ${r.path}`) }); return true; }
|
|
308
|
+
}
|
|
309
|
+
|
|
128
310
|
// the caps: blank/absent = the signed ones. A raised (or lowered) cap is a new spec.
|
|
129
311
|
/** @type {any} */
|
|
130
312
|
const spec = { ...plan.spec };
|
|
@@ -142,7 +324,7 @@ export function createRunRoutes(opts) {
|
|
|
142
324
|
// tighten-only: a cap at or below what the run has ALREADY used buys an immediate re-halt
|
|
143
325
|
// (the engine only warns), so it is refused here at $0, before anything is written or spawned
|
|
144
326
|
if (typeof plan.spentUsd === 'number' && spec.budgetUsd <= plan.spentUsd) {
|
|
145
|
-
send(400, { ok: false, error: `The money cap must be above what is already spent (${plan.spendComplete ? '' : 'at least '}
|
|
327
|
+
send(400, { ok: false, error: `The money cap must be above what is already spent (${plan.spendComplete ? '' : 'at least '}${panelMoney2(plan.spentUsd)}) — raise it, then Resume.` });
|
|
146
328
|
return true;
|
|
147
329
|
}
|
|
148
330
|
if (typeof plan.wallUsedMs === 'number' && typeof spec.maxWallMs === 'number' && spec.maxWallMs <= plan.wallUsedMs) {
|
|
@@ -207,7 +389,7 @@ export function createRunRoutes(opts) {
|
|
|
207
389
|
ok: true, runid: m[1], specHash, capsChanged: changed, log: logFile,
|
|
208
390
|
});
|
|
209
391
|
} else {
|
|
210
|
-
finish(409, { ok: false, error: readLogTail(logFile)
|
|
392
|
+
finish(409, { ok: false, error: engineRefusalWords(readLogTail(logFile), 'resume', logFile), log: logFile });
|
|
211
393
|
}
|
|
212
394
|
});
|
|
213
395
|
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
|
+
}
|