bareloop 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bareloop.context.md +53 -30
  3. package/package.json +1 -1
  4. package/src/authorjob.js +6 -0
  5. package/src/declaredclose.js +2 -2
  6. package/src/interviewrun.js +0 -1
  7. package/src/job.js +38 -11
  8. package/src/panel/authorroutes.js +107 -33
  9. package/src/panel/authorsession.js +59 -27
  10. package/src/panel/index.html +813 -484
  11. package/src/panel/runroutes.js +163 -5
  12. package/src/panel/scheduler.js +156 -0
  13. package/src/panel/schedules.js +667 -0
  14. package/src/panel/schedulestart.js +243 -0
  15. package/src/panel/server.js +451 -48
  16. package/src/planrun.js +39 -7
  17. package/src/providerrows.js +3 -3
  18. package/src/reviewdoor.js +37 -0
  19. package/src/runlist.js +35 -0
  20. package/src/schedulewords.js +296 -0
  21. package/src/trigger.js +48 -0
  22. package/src/triggerclock.js +127 -0
  23. package/src/triggerfiles.js +215 -0
  24. package/src/triggerledger.js +79 -0
  25. package/src/userrun.js +6 -7
  26. package/types/declaredclose.d.ts +1 -1
  27. package/types/job.d.ts +8 -1
  28. package/types/panel/authorroutes.d.ts +34 -1
  29. package/types/panel/authorsession.d.ts +37 -12
  30. package/types/panel/runroutes.d.ts +32 -1
  31. package/types/panel/scheduler.d.ts +17 -0
  32. package/types/panel/schedules.d.ts +412 -0
  33. package/types/panel/schedulestart.d.ts +106 -0
  34. package/types/panel/server.d.ts +143 -14
  35. package/types/planrun.d.ts +2 -0
  36. package/types/providerrows.d.ts +2 -2
  37. package/types/reviewdoor.d.ts +27 -0
  38. package/types/runlist.d.ts +20 -0
  39. package/types/schedulewords.d.ts +135 -0
  40. package/types/trigger.d.ts +37 -0
  41. package/types/triggerclock.d.ts +52 -0
  42. package/types/triggerfiles.d.ts +93 -0
  43. package/types/triggerledger.d.ts +35 -0
@@ -0,0 +1,243 @@
1
+ // T Part 4 (PANEL-BUILD.md T addendum, C.4): the ONE starter of a scheduled run. A scheduled fire is exactly a Reuse the
2
+ // person did not click: it goes through the SAME start path as `POST /api/author/start` (`startSession`, which proves the
3
+ // Inputs at $0, prepares the copy, runs signing gates 1-3) and the SAME `signRun` (the only function that spawns a run).
4
+ // wouldAccept(job, cause) cheap, $0, no copy, no session: would a start go through?
5
+ // start(job, cause, hooks) runs the session to `prepared`, THEN calls hooks.beforeSpawn (the consuming row), writes
6
+ // trigger.json (wx), then signRun with --review-door. Returns {ok, ...} and never throws.
7
+ // RULED 2026-10-14 (retry when back up): everything that can say "no" says it BEFORE beforeSpawn, so a refusal consumes
8
+ // nothing. Only a spawn error comes after it; the caller then writes a `released` row (schedules.fire).
9
+ // Rewritten from fwdloop src/panel/authorstart.js `start` (the trigger.json `wx` write before the spawn); where fwd
10
+ // writes `ran` before the starter answers, the starter now answers first (T16).
11
+ import {
12
+ existsSync, readFileSync, rmSync, writeFileSync,
13
+ } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { jobSpecHash, workflowKey } from '../job.js';
16
+ import { parseInputs } from '../jobblock.js';
17
+ import { judgePick, keyValueOf } from '../providerrows.js';
18
+ import { recheck } from '../triggerfiles.js';
19
+ import { scheduledWords } from '../schedulewords.js';
20
+ import { signRun } from './authorroutes.js';
21
+ import { startDestinationProblem } from './authorsession.js';
22
+
23
+ /** how long a headless session may take to reach `prepared` (copy, install, signing gates) */
24
+ export const PREPARE_TIMEOUT_MS = 20 * 60_000;
25
+ /** how long the starter listens for a child that refuses at once (run-u checks its spec and approve hash first thing) */
26
+ export const CHILD_GRACE_MS = 1500;
27
+
28
+ /**
29
+ * The Destination a fire's session is given: the one the schedule was signed with (a plain-files job's per-run folder, which the
30
+ * spec does not hold), else the spec's own fence. @param {any} schedule @param {any} spec @param {string|undefined} fallback
31
+ */
32
+ function destinationOf(schedule, spec, fallback) {
33
+ if (typeof schedule.destination === 'string' && schedule.destination !== '') return schedule.destination;
34
+ return Array.isArray(spec.writeScope) ? spec.writeScope.join(', ') : fallback;
35
+ }
36
+
37
+ /**
38
+ * The spec one fire runs: the signed schedule's spec; for a file cause, Inputs line 1 is the arriving file. Every other
39
+ * field must be deep-equal and the workflow key unchanged (F.4): the person signed the SCHEDULE, each fire's hash is
40
+ * derived by code. A clock fire's spec equals the signed spec byte for byte (same hash).
41
+ * @param {{spec: any, specHash: string, workflowKey: string}} schedule
42
+ * @param {{kind: 'clock'}|{kind: 'file', path: string}} cause
43
+ * @returns {{ok: true, spec: any, hash: string}|{ok: false, why: string}}
44
+ */
45
+ export function perFireSpec(schedule, cause) {
46
+ if (jobSpecHash(schedule.spec) !== schedule.specHash) return { ok: false, why: 'The saved schedule no longer matches what you signed. Nothing started.' };
47
+ const spec = JSON.parse(JSON.stringify(schedule.spec));
48
+ if (cause.kind === 'file') {
49
+ const first = Array.isArray(spec.inputs) ? spec.inputs[0] : null;
50
+ if (!first) return { ok: false, why: 'The saved schedule has no input for the file to replace. Nothing started.' };
51
+ spec.inputs = [{ ...first, value: cause.path }, ...spec.inputs.slice(1)];
52
+ }
53
+ const { inputs: _a, ...restA } = spec;
54
+ const { inputs: _b, ...restB } = schedule.spec;
55
+ if (JSON.stringify(restA) !== JSON.stringify(restB) || workflowKey(spec) !== schedule.workflowKey) {
56
+ return { ok: false, why: 'This run would differ from what you signed by more than its input. Nothing started.' };
57
+ }
58
+ return { ok: true, spec, hash: jobSpecHash(spec) };
59
+ }
60
+
61
+ /**
62
+ * @param {{
63
+ * door: ReturnType<typeof import('./schedules.js').createScheduleDoor>,
64
+ * startSession: ReturnType<typeof import('./authorroutes.js').createAuthorRoutes>['startSession'],
65
+ * envNow: () => Record<string, string|undefined>,
66
+ * spawnFn: Function, bareloopBin: string, sessionsRoot?: string, home?: string,
67
+ * rowsFor: () => readonly import('../providerrows.js').KeyRow[],
68
+ * startFrom: (runid: string) => any,
69
+ * now?: () => number, prepareTimeoutMs?: number, pollMs?: number, childGraceMs?: number,
70
+ * forgetSession?: (id: string) => void, ranSince?: (job: string, sinceMs: number) => boolean,
71
+ * }} deps
72
+ */
73
+ export function createStarter(deps) {
74
+ const { now = Date.now, prepareTimeoutMs = PREPARE_TIMEOUT_MS, pollMs = 25, childGraceMs = CHILD_GRACE_MS } = deps;
75
+
76
+ /** Remove what a start that did not go through made: the session leaves the headless map and its folder is deleted. @param {any} session */
77
+ function discard(session) {
78
+ try { deps.forgetSession?.(session.state.id); } catch { /* the map entry is gone or never was */ }
79
+ try { if (typeof session.state.outDir === 'string' && session.state.outDir !== '') rmSync(session.state.outDir, { recursive: true, force: true }); } catch { /* already gone */ }
80
+ }
81
+
82
+ /** The signed copy still hashes to what the session signed (what `run-u --approve` checks first). @param {any} session @returns {string|null} words, or null when fine */
83
+ function specProblem(session) {
84
+ try {
85
+ const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
86
+ if (jobSpecHash(spec) === session.state.specHash) return null;
87
+ } catch { /* unreadable: the same words */ }
88
+ return "The saved copy of this job no longer matches what was signed. Nothing started.";
89
+ }
90
+
91
+ /** The child's own refusal line from its log, only when it says REFUSED (the log is never quoted otherwise). @param {string} outDir */
92
+ function refusalLine(outDir) {
93
+ try {
94
+ const line = readFileSync(join(outDir, 'run.log'), 'utf8').split('\n').map((x) => x.trim()).filter((x) => x.startsWith('REFUSED')).at(-1);
95
+ return line ? `: ${line.slice(0, 200)}` : '';
96
+ } catch { return ''; }
97
+ }
98
+
99
+ /** @param {any} schedule @param {any} cause @returns {{ok: true, spec: any, hash: string}|{ok: false, why: string}} */
100
+ function resolveFire(schedule, cause) {
101
+ return perFireSpec(schedule, cause.kind === 'file' ? { kind: 'file', path: cause.path } : { kind: 'clock' });
102
+ }
103
+
104
+ return {
105
+ /**
106
+ * Cheap, $0, no copy, no session. `{ok: true}` or `{ok: false, why}` (words).
107
+ * @param {string} job
108
+ * @param {{kind: 'clock'} | {kind: 'file', path: string, sha: string}} cause
109
+ * @returns {{ok: true}|{ok: false, why: string}}
110
+ */
111
+ wouldAccept(job, cause) {
112
+ const schedule = deps.door.readSchedule(job);
113
+ if (!schedule) return { ok: false, why: 'This job has no schedule any more. Nothing started.' };
114
+ const fire = resolveFire(schedule, cause);
115
+ if (!fire.ok) return fire;
116
+ // the origin run's saved job is still readable (the start reads it)
117
+ const pre = deps.startFrom(schedule.originRunid);
118
+ if (!pre || !pre.ok) return { ok: false, why: "The good run this schedule was made from is gone from the list, so its saved job cannot be read. Nothing started." };
119
+ // THE session start's Destination check (authorsession.js), on the card the start would build: before any session exists
120
+ const inputsText = cause.kind === 'file'
121
+ ? fire.spec.inputs.map((/** @type {any} */ i) => `${i.label}: ${i.value}`).join('\n')
122
+ : schedule.inputsText;
123
+ const destProblem = startDestinationProblem({
124
+ inputs: inputsText, destination: destinationOf(schedule, fire.spec, pre.card.destination), checkType: fire.spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
125
+ });
126
+ if (destProblem !== null) return { ok: false, why: `${destProblem.replace(/ Nothing spent\.$/, '')} Nothing started.` };
127
+ // the inputs are there: the arriving file, looked at again at use time; a clock fire's first input, when it is a local path
128
+ if (cause.kind === 'file') {
129
+ const r = recheck({ path: cause.path, sha: cause.sha });
130
+ if (r.state === 'deleted') return { ok: false, why: `The file ${cause.path} is not there (${r.say}). Nothing started.` };
131
+ if (r.state === 'changed') return { ok: false, why: `The file ${cause.path} changed before its turn. Nothing started.` };
132
+ } else {
133
+ for (const inp of fire.spec.inputs ?? []) {
134
+ const v = String(inp.value);
135
+ if (/^https?:\/\//i.test(v)) continue;
136
+ if (!existsSync(v)) return { ok: false, why: `The input "${inp.label}" is not there (${v}). Nothing started.` };
137
+ }
138
+ }
139
+ // the keys load, and the model line is still ticked, priced and keyed
140
+ const rows = deps.rowsFor();
141
+ const row = rows.find((r) => r.provider === fire.spec.provider && r.models.some((l) => l.model === fire.spec.model));
142
+ if (!row) return { ok: false, why: "The key for this job's model is not set up. Put it in your keys file and tick the model in Settings > Providers. Nothing started." };
143
+ const pick = judgePick(rows, { provider: row.envName, model: fire.spec.model });
144
+ if (!pick.ok) return { ok: false, why: `${pick.say.replace(/ Nothing was spent\.$/, '')}. Nothing started.`.replace(/\.\./g, '.') };
145
+ const key = keyValueOf(deps.envNow(), pick.choice.envName);
146
+ if (!key) return { ok: false, why: `${pick.choice.envName} is not set up: put its key in your keys file. Nothing started.` };
147
+ return { ok: true };
148
+ },
149
+
150
+ /**
151
+ * Start the run. Never throws. `consumed` says whether `beforeSpawn` ran, so the caller knows whether a `released` row is owed.
152
+ * @param {string} job
153
+ * @param {{kind: 'clock', slot: number, catchUp: boolean, missed: number} | {kind: 'file', path: string, sha: string}} cause
154
+ * @param {{beforeSpawn?: () => void}} [hooks]
155
+ * @returns {Promise<{ok: true, sessionId: string, specHash: string, consumed: true}|{ok: false, why: string, consumed: boolean, installNeeded?: true}>}
156
+ */
157
+ async start(job, cause, hooks = {}) {
158
+ let consumed = false;
159
+ try {
160
+ const schedule = deps.door.readSchedule(job);
161
+ if (!schedule) return { ok: false, why: 'This job has no schedule any more. Nothing started.', consumed };
162
+ const fire = resolveFire(schedule, cause);
163
+ if (!fire.ok) return { ok: false, why: fire.why, consumed };
164
+ const pre = deps.startFrom(schedule.originRunid);
165
+ if (!pre || !pre.ok) return { ok: false, why: "The good run this schedule was made from is gone from the list, so its saved job cannot be read. Nothing started.", consumed };
166
+ const spec = fire.spec;
167
+ const rows = deps.rowsFor();
168
+ const row = rows.find((r) => r.provider === spec.provider && r.models.some((l) => l.model === spec.model));
169
+ const inputsText = cause.kind === 'file'
170
+ ? spec.inputs.map((/** @type {any} */ i) => `${i.label}: ${i.value}`).join('\n')
171
+ : schedule.inputsText;
172
+ const card = {
173
+ ...pre.card,
174
+ inputs: inputsText,
175
+ destination: destinationOf(schedule, spec, pre.card.destination),
176
+ capUsd: spec.budgetUsd,
177
+ ...(typeof spec.maxWallMs === 'number' ? { maxWallMs: spec.maxWallMs } : { maxWallMs: undefined }),
178
+ model: spec.model,
179
+ ...(row ? { provider: row.envName } : {}),
180
+ };
181
+ const ip = parseInputs(card.inputs);
182
+ if (!ip.ok) return { ok: false, why: `${ip.error}. Nothing started.`, consumed };
183
+ const made = await deps.startSession(card, { spec, workflowKey: schedule.workflowKey }, ip.inputs, { headless: true });
184
+ if (!made.ok) return { ok: false, why: `${made.error.replace(/ Nothing spent\.$/, '')} Nothing started.`.replace(/\.\. /g, '. '), consumed };
185
+ const { session } = made;
186
+ const t0 = Date.now();
187
+ const settled = new Set(['prepared', 'refused', 'error', 'abandoned', 'signing-failed', 'install-needed']);
188
+ while (!settled.has(session.state.phase) && Date.now() - t0 < prepareTimeoutMs) await new Promise((r) => { setTimeout(r, pollMs); });
189
+ if (session.state.phase !== 'prepared') {
190
+ const phase = session.state.phase;
191
+ const finished = settled.has(phase);
192
+ if (!finished || phase === 'install-needed') session.abandon();
193
+ const why = phase === 'install-needed' ? "Packages are missing in the job's copy. Nothing started."
194
+ : (!finished ? 'Getting the job ready took too long. Nothing started.' : `The job could not be made ready: ${String(session.state.error ?? phase)}. Nothing started.`);
195
+ // a start that is refused leaves nothing behind. (A session still running when the time ran out is only abandoned: its pipeline may still be writing.)
196
+ if (finished) discard(session);
197
+ // an install-needed refusal tells the caller (the door owns the wait schedule and the give-up); every other refusal is tried again at once
198
+ return phase === 'install-needed' ? { ok: false, why, consumed, installNeeded: true } : { ok: false, why, consumed };
199
+ }
200
+ // what the child checks first, checked here by the same owner: the saved copy must still hash to what the session signed
201
+ const stale = specProblem(session);
202
+ if (stale !== null) { discard(session); return { ok: false, why: stale, consumed }; }
203
+ // EVERY "no" is behind us. From here the slot or file is consumed (the caller's row), and only a spawn error can undo it.
204
+ hooks.beforeSpawn?.();
205
+ consumed = true;
206
+ const outDir = session.state.outDir;
207
+ const words = scheduledWords(schedule.trigger, deps.door.userHome) ?? '';
208
+ const catchUp = cause.kind === 'clock' && cause.catchUp === true;
209
+ writeFileSync(join(outDir, 'trigger.json'), `${JSON.stringify({
210
+ kind: cause.kind, trigger: words, reason: `Started by the schedule: ${words}${catchUp ? ' (catch-up)' : ''}`, catchUp,
211
+ ...(cause.kind === 'clock' ? { slot: cause.slot, missed: cause.missed, lateMs: Math.max(0, now() - cause.slot) } : { file: cause.path, sha: cause.sha }),
212
+ // RULED 2026-10-15: what the Job tab and Audit say about a scheduled start (older records lack these; the words fall back)
213
+ triggerKind: schedule.trigger.kind, sessionId: session.state.id, originRunid: schedule.originRunid, sig: schedule.specHash, startedAt: now(),
214
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
215
+ /** @type {Error|null} */
216
+ let spawnError = null;
217
+ /** @type {number|null} */
218
+ let exitCode = null;
219
+ const spawnedAt = Date.now();
220
+ const signed = signRun(session, session.state.specHash, {
221
+ env: deps.envNow(), spawnFn: /** @type {any} */ (deps.spawnFn), bareloopBin: deps.bareloopBin, sessionsRoot: deps.sessionsRoot, home: deps.home,
222
+ reviewDoor: true, onSpawnError: (e) => { spawnError = e; }, onExit: (code) => { exitCode = typeof code === 'number' ? code : 1; },
223
+ });
224
+ // a spawn failure is reported on the next turn of the event loop; give it that turn before calling this started
225
+ await new Promise((r) => { setImmediate(r); });
226
+ if (!signed.ok) { discard(session); return { ok: false, why: `${signed.error ?? 'The run did not start'}. Nothing started.`.replace(/\.\./g, '.'), consumed }; }
227
+ if (spawnError) { discard(session); return { ok: false, why: `The run could not be launched (${/** @type {Error} */ (spawnError).message}). Nothing started.`, consumed }; }
228
+ // a child that refuses at once (run-u checks its spec, approve hash and prechecks first thing) exits non-zero within moments: listen
229
+ // for that short while. The real launcher forks (`setsid`), so there it reports 0 and the run goes on; only a child that is
230
+ // gone with a non-zero code, and wrote no row to the run list, is a refusal.
231
+ while (exitCode === null && Date.now() - spawnedAt < childGraceMs) await new Promise((r) => { setTimeout(r, Math.min(pollMs, 10)); });
232
+ if (exitCode !== null && exitCode !== 0 && !(deps.ranSince?.(job, spawnedAt) ?? false)) {
233
+ const why = `The run was refused as it started${refusalLine(outDir)}. Nothing started.`;
234
+ discard(session);
235
+ return { ok: false, why, consumed };
236
+ }
237
+ return { ok: true, sessionId: session.state.id, specHash: session.state.specHash, consumed: true };
238
+ } catch (e) {
239
+ return { ok: false, why: `The run could not start (${/** @type {Error} */ (e).message}). Nothing started.`, consumed };
240
+ }
241
+ },
242
+ };
243
+ }