bareloop 0.41.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 +109 -1
- package/README.md +13 -21
- package/bareloop.context.md +97 -34
- package/package.json +4 -3
- package/src/authorflow.js +337 -29
- package/src/authoring.js +92 -7
- package/src/authorjob.js +84 -16
- package/src/authorreadout.js +89 -5
- package/src/authorrun.js +14 -5
- package/src/authorscout.js +108 -0
- package/src/declaredclose.js +25 -14
- package/src/facts.js +317 -0
- package/src/factwords.js +129 -0
- package/src/inputlook.js +301 -0
- package/src/interviewrun.js +0 -1
- package/src/job.js +40 -12
- package/src/jobblock.js +7 -2
- package/src/kinds.js +84 -3
- package/src/labelscan.js +170 -0
- package/src/pagefetch.js +125 -0
- package/src/panel/authorroutes.js +121 -34
- package/src/panel/authorsession.js +154 -45
- package/src/panel/index.html +845 -490
- package/src/panel/jobplan.js +34 -11
- 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 +467 -48
- package/src/planrun.js +42 -9
- package/src/providerrows.js +3 -3
- package/src/reviewdoor.js +37 -0
- package/src/rubricstep.js +804 -0
- package/src/runlist.js +35 -0
- package/src/schedulewords.js +296 -0
- package/src/source.js +166 -1
- 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/u-readout.js +1 -1
- package/src/userrun.js +20 -9
- package/types/authorflow.d.ts +267 -13
- package/types/authoring.d.ts +29 -1
- package/types/authorjob.d.ts +7 -5
- package/types/authorreadout.d.ts +27 -2
- package/types/authorscout.d.ts +70 -0
- package/types/declaredclose.d.ts +5 -3
- package/types/facts.d.ts +71 -0
- package/types/factwords.d.ts +42 -0
- package/types/inputlook.d.ts +238 -0
- package/types/job.d.ts +8 -1
- package/types/jobblock.d.ts +8 -1
- package/types/labelscan.d.ts +38 -0
- package/types/pagefetch.d.ts +71 -0
- package/types/panel/authorroutes.d.ts +34 -1
- package/types/panel/authorsession.d.ts +55 -14
- package/types/panel/jobplan.d.ts +12 -4
- 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 +145 -14
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/reviewdoor.d.ts +27 -0
- package/types/rubricstep.d.ts +286 -0
- package/types/runlist.d.ts +20 -0
- package/types/schedulewords.d.ts +135 -0
- package/types/source.d.ts +94 -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 +11 -0
|
@@ -22,10 +22,16 @@ import { spawn as realSpawn } from 'node:child_process';
|
|
|
22
22
|
import { openSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { readFileSync } from 'node:fs';
|
|
25
|
-
import {
|
|
25
|
+
import { planMatchesClose } from '../rubricstep.js';
|
|
26
|
+
import { createSession, validateJobCard, validateReuseCard, jobBoxesChanged, buildReuseSpec, cardFields, nextFreeJobName, startDestinationProblem, STEP_LABELS } from './authorsession.js';
|
|
26
27
|
import { parseInputs } from '../jobblock.js';
|
|
27
|
-
import { proveInputFiles } from '../source.js';
|
|
28
|
+
import { proveInputFiles, looksLikeRubricInput } from '../source.js';
|
|
28
29
|
import { validateJob } from '../job.js';
|
|
30
|
+
|
|
31
|
+
/** plain words for a run whose saved job no longer passes today's `validateJob` (RULED 2026-10-15): never the raw red codes */
|
|
32
|
+
export const OLD_JOB_REUSE_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be reused. Start a new job instead.";
|
|
33
|
+
export const OLD_JOB_RESUME_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be resumed. Use Reuse workflow on a newer run, or start a new job.";
|
|
34
|
+
export const OLD_JOB_ACCEPT_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be accepted. Use Reuse workflow on a newer run, or start a new job.";
|
|
29
35
|
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
30
36
|
import { ConfigError } from '../config.js';
|
|
31
37
|
import { keysForDoor, keysHome } from '../keysfile.js';
|
|
@@ -94,6 +100,9 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
|
|
|
94
100
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>,
|
|
95
101
|
* sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
|
|
96
102
|
* jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
|
|
103
|
+
* schedules?: ReturnType<typeof import('./schedules.js').createScheduleDoor>,
|
|
104
|
+
* sessionDeps?: Record<string, any>,
|
|
105
|
+
* runJobNames?: () => string[],
|
|
97
106
|
* startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
|
|
98
107
|
*/
|
|
99
108
|
export function createAuthorRoutes(opts) {
|
|
@@ -109,8 +118,37 @@ export function createAuthorRoutes(opts) {
|
|
|
109
118
|
/** @type {Map<string, ReturnType<typeof createSession>>} */
|
|
110
119
|
const sessions = new Map();
|
|
111
120
|
|
|
121
|
+
/** Headless (scheduled) sessions live apart from the person's: neither blocks nor is blocked by the other (T I.5). Not addressable over HTTP. */
|
|
122
|
+
/** @type {Map<string, ReturnType<typeof createSession>>} */
|
|
123
|
+
const headless = new Map();
|
|
124
|
+
|
|
112
125
|
const hasLiveSession = () => [...sessions.values()].some((s) => !TERMINAL_PHASES.has(s.state.phase));
|
|
113
126
|
|
|
127
|
+
/**
|
|
128
|
+
* THE one session start (T Part 4, C.4.2): prove the Inputs at $0, then create the session — the route's tail, factored so the
|
|
129
|
+
* HTTP route and the schedule starter share ONE function. A scheduled fire is exactly a Reuse the person did not click.
|
|
130
|
+
* `headless` puts the session in its own map and skips the one-live-session rule.
|
|
131
|
+
* @param {any} card the form (fields only)
|
|
132
|
+
* @param {{spec: any, workflowKey: string}|null} reuse
|
|
133
|
+
* @param {import('../jobblock.js').JobInput[]} inputs the parsed Inputs
|
|
134
|
+
* @param {{headless?: boolean, filesJob?: boolean}} [o]
|
|
135
|
+
* @returns {Promise<{ok: true, session: ReturnType<typeof createSession>}|{ok: false, status: number, error: string}>}
|
|
136
|
+
*/
|
|
137
|
+
function startSession(card, reuse, inputs, o = {}) {
|
|
138
|
+
return (o.filesJob ? Promise.resolve(/** @type {{ok: true}} */ ({ ok: true })) : proveInputFiles(inputs)).then((proof) => {
|
|
139
|
+
if (!proof.ok) return { ok: /** @type {false} */ (false), status: 400, error: proof.error };
|
|
140
|
+
if (!o.headless && hasLiveSession()) return { ok: /** @type {false} */ (false), status: 409, error: 'an authoring session is already live — one at a time' };
|
|
141
|
+
// the SAME Destination check the session runs, at $0, BEFORE the session (and its folder) exists
|
|
142
|
+
const destProblem = startDestinationProblem(card);
|
|
143
|
+
if (destProblem !== null) return { ok: /** @type {false} */ (false), status: 400, error: destProblem };
|
|
144
|
+
const session = createSession(cardFields(card), {
|
|
145
|
+
env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}), ...(opts.sessionDeps ?? {}),
|
|
146
|
+
});
|
|
147
|
+
(o.headless ? headless : sessions).set(session.id, session);
|
|
148
|
+
return { ok: /** @type {true} */ (true), session };
|
|
149
|
+
}).catch((e) => ({ ok: /** @type {false} */ (false), status: 500, error: `could not check the inputs: ${/** @type {Error} */ (e)?.message ?? e}` }));
|
|
150
|
+
}
|
|
151
|
+
|
|
114
152
|
/**
|
|
115
153
|
* @param {import('node:http').IncomingMessage} req
|
|
116
154
|
* @param {import('node:http').ServerResponse} res
|
|
@@ -216,10 +254,20 @@ export function createAuthorRoutes(opts) {
|
|
|
216
254
|
const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
|
|
217
255
|
if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
218
256
|
if (!pre.ok) { send(409, pre); return true; }
|
|
257
|
+
// the card of a SCHEDULED job opens with the folder that schedule delivers to (a plain-files job keeps it on the schedule, not in the spec)
|
|
258
|
+
const sched = sp.get('mode') === 'schedule' && opts.schedules ? opts.schedules.readSchedule(pre.origin.job) : null;
|
|
259
|
+
const card = typeof sched?.destination === 'string' ? { ...pre.card, destination: sched.destination } : pre.card;
|
|
219
260
|
send(200, {
|
|
220
|
-
ok: true, origin: pre.origin, card
|
|
221
|
-
|
|
261
|
+
ok: true, origin: pre.origin, card, from: pre.from, note: pre.note,
|
|
262
|
+
// ONE door (v1.101): `green` = the origin earned a real green (an imported job counts), so an unchanged job reuses the signed plan;
|
|
263
|
+
// `nextName` = the Job name box once a job box changes (the box shows the origin's name until then). Nothing is locked.
|
|
264
|
+
green: pre.origin.importId !== undefined || pre.outcome === 'green' || pre.outcome === 'already-green',
|
|
265
|
+
nextName: nextFreeJobName(pre.origin.job, opts.runJobNames ? opts.runJobNames() : [], { jobsDir: opts.jobsDir }),
|
|
266
|
+
specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
|
|
222
267
|
line: pre.line, specNamesModel: pre.specNamesModel, specWorker: pre.specWorker,
|
|
268
|
+
// T Part 3: `mode=schedule` also returns the job's current schedule (data only) for the When field's prefill; Part 7 adds the
|
|
269
|
+
// card's own words (`scheduleCard`: now / refusal / the button table). A reuse never touches a schedule (v1.100).
|
|
270
|
+
...(sp.get('mode') === 'schedule' && opts.schedules ? { schedule: opts.schedules.read(pre.origin.job), scheduleCard: opts.schedules.cardFor(pre.origin.job) } : {}),
|
|
223
271
|
});
|
|
224
272
|
return true;
|
|
225
273
|
}
|
|
@@ -235,6 +283,17 @@ export function createAuthorRoutes(opts) {
|
|
|
235
283
|
return true;
|
|
236
284
|
}
|
|
237
285
|
|
|
286
|
+
// ── T Part 3: the schedule door, two clicks, both $0 (no drafter, no run, no provider call). Click 1 signs and
|
|
287
|
+
// writes nothing; click 2 carries the hash it was shown. `when: null` cancels (never refused). Placed with the
|
|
288
|
+
// other exact matches, before the session regex below.
|
|
289
|
+
if (pathname === '/api/author/schedule-prepare' || pathname === '/api/author/schedule') {
|
|
290
|
+
if (!opts.schedules) { send(404, { ok: false, error: 'not found' }); return true; }
|
|
291
|
+
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
292
|
+
const r = pathname.endsWith('-prepare') ? opts.schedules.prepare(body ?? {}) : opts.schedules.set(body ?? {});
|
|
293
|
+
send(r.status, r.body);
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
238
297
|
// A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
|
|
239
298
|
// or `null`. The server's session map is the only truth; nothing is stored page-side.
|
|
240
299
|
if (pathname === '/api/author/live') {
|
|
@@ -249,11 +308,15 @@ export function createAuthorRoutes(opts) {
|
|
|
249
308
|
if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
|
|
250
309
|
const card = body ?? {};
|
|
251
310
|
const rows = rowsForHome(keysHome(opts.home), envNow());
|
|
252
|
-
// Reuse workflow
|
|
253
|
-
//
|
|
254
|
-
//
|
|
311
|
+
// Reuse workflow, ONE DOOR (v1.101): every box is open; the CARD's job boxes (job text, check type, thinking) are compared with
|
|
312
|
+
// the origin run's. A GREEN origin (or an imported job) with no job box changed signs the origin's plan verbatim, under the
|
|
313
|
+
// origin's name, with no drafting. Any other start drafts: a changed job is a NEW workflow under the Job name box; an unchanged
|
|
314
|
+
// job from a run that is neither green nor already-green drafts again under the ORIGIN's name (another try of the same job). The name is decided
|
|
315
|
+
// here, before drafting, so the hash the person signs already covers it. The server never trusts the page.
|
|
255
316
|
/** @type {{spec: any, workflowKey: string}|null} */
|
|
256
317
|
let reuse = null;
|
|
318
|
+
/** @type {string|undefined} */
|
|
319
|
+
let allowName;
|
|
257
320
|
const from = card.startFrom;
|
|
258
321
|
const fromRunid = typeof from === 'string' ? from : (typeof from?.runid === 'string' ? from.runid : null);
|
|
259
322
|
const fromImport = typeof from?.importId === 'string' ? from.importId : null;
|
|
@@ -261,39 +324,46 @@ export function createAuthorRoutes(opts) {
|
|
|
261
324
|
const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
|
|
262
325
|
if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
|
|
263
326
|
if (!pre.ok) { send(409, pre); return true; }
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
327
|
+
const changed = jobBoxesChanged(card, pre.card);
|
|
328
|
+
if (changed) {
|
|
329
|
+
// a NEW workflow: its name may not be any existing job's, the origin's included (a different job cannot pass for the old one)
|
|
330
|
+
if (typeof card.jobName === 'string' && (card.jobName === pre.origin.job || (opts.runJobNames && opts.runJobNames().includes(card.jobName)))) {
|
|
331
|
+
send(400, { ok: false, error: `A job named "${card.jobName}" already exists and is a different job. Pick another name.` });
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
// a scheduled run's green waits for the person's Accept (quarantine): its plan is neither reused nor re-drafted until then
|
|
336
|
+
if (pre.outcome === 'green-held') { send(409, { ok: false, error: 'This run was started by a schedule and is waiting for your Accept. Accept it first, then it can be reused.' }); return true; }
|
|
337
|
+
card.jobName = pre.origin.job; // an unchanged job keeps its name; a typed one is ignored
|
|
338
|
+
if (fromImport !== null || pre.outcome === 'green' || pre.outcome === 'already-green') {
|
|
339
|
+
const rv = validateReuseCard(card, { rows });
|
|
340
|
+
if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
|
|
341
|
+
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
342
|
+
// validateJob can THROW (not only red) on a saved job the guard-battery lookup no longer admits: same plain words, never a raw 500
|
|
343
|
+
/** @type {ReturnType<typeof validateJob>|null} */
|
|
344
|
+
let jv = null;
|
|
345
|
+
try { jv = validateJob(spec, { shellCapUsd: spec.budgetUsd }); } catch { jv = null; }
|
|
346
|
+
// RULED 2026-10-15: an old run's saved job that fails today's checks gets plain words; the raw red codes ride in `reds`, which the page never shows
|
|
347
|
+
if (jv === null || !jv.ok) { send(400, { ok: false, error: OLD_JOB_REUSE_WORDS, reds: jv === null ? [] : jv.reds.map((r) => `${r.code} at ${r.path}`) }); return true; }
|
|
348
|
+
reuse = { spec, workflowKey: pre.workflowKey };
|
|
349
|
+
} else allowName = pre.origin.job;
|
|
270
350
|
}
|
|
271
|
-
const locked = lockedFieldChanged(card, pre.card);
|
|
272
|
-
if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
|
|
273
|
-
const rv = validateReuseCard(card, { rows });
|
|
274
|
-
if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
|
|
275
|
-
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
276
|
-
const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
|
|
277
|
-
if (!jv.ok) { send(400, { ok: false, error: `the spec this reuse would sign is not valid (${jv.reds.map((r) => `${r.code} at ${r.path}`).join('; ')})` }); return true; }
|
|
278
|
-
reuse = { spec, workflowKey: pre.workflowKey };
|
|
279
351
|
}
|
|
280
352
|
if (reuse === null) {
|
|
281
|
-
const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows });
|
|
353
|
+
const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows, ...(allowName !== undefined ? { allowName } : {}) });
|
|
282
354
|
if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
|
|
283
355
|
}
|
|
284
356
|
// P7: the Inputs are proven at $0 before a session (so before any token): line 1 the repo, lines 2+ tracked files inside it.
|
|
285
357
|
// A miss is a plain 400 naming the line, shown under the card exactly like every other refused start.
|
|
286
358
|
const ip = parseInputs(card.inputs);
|
|
287
359
|
if (!ip.ok) { send(400, { ok: false, error: ip.error }); return true; }
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
send(200, { ok: true, sessionId: session.id, reuse: reuse !== null, state: session.state });
|
|
296
|
-
}).catch((e) => { send(500, { ok: false, error: `could not check the inputs: ${/** @type {Error} */ (e)?.message ?? e}` }); });
|
|
360
|
+
// Part 1a: a rubric job whose first input is a local FILE has no repo to prove inputs against; the session proves every
|
|
361
|
+
// line itself (`proveRubricInputs`), at $0, before any model call
|
|
362
|
+
const filesJob = reuse === null && card?.checkType === 'rubric' && looksLikeRubricInput(ip.inputs[0].value);
|
|
363
|
+
startSession(card, reuse, ip.inputs, { filesJob }).then((r) => {
|
|
364
|
+
if (!r.ok) { send(r.status, { ok: false, error: r.error }); return; }
|
|
365
|
+
send(200, { ok: true, sessionId: r.session.id, reuse: reuse !== null, state: r.session.state });
|
|
366
|
+
});
|
|
297
367
|
return true;
|
|
298
368
|
}
|
|
299
369
|
|
|
@@ -332,7 +402,7 @@ export function createAuthorRoutes(opts) {
|
|
|
332
402
|
}
|
|
333
403
|
if (sub === 'sign') {
|
|
334
404
|
const claimedHash = String(body?.specHash ?? '');
|
|
335
|
-
const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
|
|
405
|
+
const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home, ...(opts.schedules ? { schedules: opts.schedules } : {}) });
|
|
336
406
|
send(r.ok ? 200 : 400, r);
|
|
337
407
|
return true;
|
|
338
408
|
}
|
|
@@ -340,7 +410,11 @@ export function createAuthorRoutes(opts) {
|
|
|
340
410
|
return true;
|
|
341
411
|
}
|
|
342
412
|
|
|
343
|
-
return {
|
|
413
|
+
return {
|
|
414
|
+
handle, sessions, headless, startSession, envNow, spawnFn, bareloopBin,
|
|
415
|
+
/** a headless session the starter has removed from disk leaves its map too @param {string} id */
|
|
416
|
+
forgetHeadless: (id) => { headless.delete(id); },
|
|
417
|
+
};
|
|
344
418
|
}
|
|
345
419
|
|
|
346
420
|
/**
|
|
@@ -374,7 +448,7 @@ export function panelMoney2(n) {
|
|
|
374
448
|
* it to sign"), and this is where it is checked, not trusted.
|
|
375
449
|
* @param {ReturnType<typeof createSession>} session
|
|
376
450
|
* @param {string} claimedHash
|
|
377
|
-
* @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string}} o
|
|
451
|
+
* @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string, reviewDoor?: boolean, onSpawnError?: (e: Error) => void, onExit?: (code: number|null) => void}} o
|
|
378
452
|
* @returns {{ok: boolean, error?: string, job?: string}}
|
|
379
453
|
*/
|
|
380
454
|
export function signRun(session, claimedHash, o) {
|
|
@@ -393,6 +467,15 @@ export function signRun(session, claimedHash, o) {
|
|
|
393
467
|
if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
|
|
394
468
|
return { ok: false, error: 'could not read the signed spec to check the monthly limit — refusing to sign' };
|
|
395
469
|
}
|
|
470
|
+
// PART 1a, the sign-vs-run invariant: the checks on the plan card the person saw are exactly the checks of the close about to be
|
|
471
|
+
// signed, per line. A mismatch is refused in plain words and nothing is spawned.
|
|
472
|
+
if (session.state.finalPlan) {
|
|
473
|
+
try {
|
|
474
|
+
const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
|
|
475
|
+
const m = planMatchesClose(session.state.finalPlan.checkItems, spec.closeDecl);
|
|
476
|
+
if (!m.ok) return { ok: false, error: m.error };
|
|
477
|
+
} catch { return { ok: false, error: 'could not read the signed spec to compare it with the plan you were shown — refusing to sign' }; }
|
|
478
|
+
}
|
|
396
479
|
const logFile = join(session.state.outDir, 'run.log');
|
|
397
480
|
// array argv, never a shell string — `--approve <hash>` is a literal
|
|
398
481
|
// element, checkable byte-for-byte by a test stubbing `spawnFn`, and there
|
|
@@ -417,6 +500,8 @@ export function signRun(session, claimedHash, o) {
|
|
|
417
500
|
'systemd-inhibit', '--why=bareloop panel run',
|
|
418
501
|
process.execPath, o.bareloopBin,
|
|
419
502
|
'run-u', '--spec', session.state.resolvedSpecPath, '--approve', session.state.specHash,
|
|
503
|
+
// T (F.1): a scheduled run opens the review door even when deterministic, so its green waits for the person's Accept
|
|
504
|
+
...(o.reviewDoor === true ? ['--review-door'] : []),
|
|
420
505
|
...(draftSpentUsd !== null ? ['--draft-spent-usd', String(draftSpentUsd), ...(draftIncomplete ? ['--draft-spend-incomplete'] : [])] : []),
|
|
421
506
|
];
|
|
422
507
|
/** @type {number|null} */
|
|
@@ -426,6 +511,8 @@ export function signRun(session, claimedHash, o) {
|
|
|
426
511
|
detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: o.env,
|
|
427
512
|
});
|
|
428
513
|
if (typeof child?.unref === 'function') child.unref();
|
|
514
|
+
if (o.onSpawnError && typeof child?.once === 'function') child.once('error', o.onSpawnError);
|
|
515
|
+
if (o.onExit && typeof child?.once === 'function') child.once('exit', o.onExit);
|
|
429
516
|
session.state.phase = 'signed';
|
|
430
517
|
// hamr 2026-10-05: the sign shows as the last step of the one progress list, never as a thread bubble
|
|
431
518
|
// hamr 2026-10-05 (option A): the drafting-spend line is that step's detail — nothing when there was no drafting spend (a Reuse)
|