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
|
@@ -23,10 +23,15 @@ import { openSync } from 'node:fs';
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { readFileSync } from 'node:fs';
|
|
25
25
|
import { planMatchesClose } from '../rubricstep.js';
|
|
26
|
-
import { createSession, validateJobCard, validateReuseCard,
|
|
26
|
+
import { createSession, validateJobCard, validateReuseCard, jobBoxesChanged, buildReuseSpec, cardFields, nextFreeJobName, startDestinationProblem, STEP_LABELS } from './authorsession.js';
|
|
27
27
|
import { parseInputs } from '../jobblock.js';
|
|
28
28
|
import { proveInputFiles, looksLikeRubricInput } from '../source.js';
|
|
29
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.";
|
|
30
35
|
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
31
36
|
import { ConfigError } from '../config.js';
|
|
32
37
|
import { keysForDoor, keysHome } from '../keysfile.js';
|
|
@@ -95,6 +100,9 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
|
|
|
95
100
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>,
|
|
96
101
|
* sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
|
|
97
102
|
* jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
|
|
103
|
+
* schedules?: ReturnType<typeof import('./schedules.js').createScheduleDoor>,
|
|
104
|
+
* sessionDeps?: Record<string, any>,
|
|
105
|
+
* runJobNames?: () => string[],
|
|
98
106
|
* startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
|
|
99
107
|
*/
|
|
100
108
|
export function createAuthorRoutes(opts) {
|
|
@@ -110,8 +118,37 @@ export function createAuthorRoutes(opts) {
|
|
|
110
118
|
/** @type {Map<string, ReturnType<typeof createSession>>} */
|
|
111
119
|
const sessions = new Map();
|
|
112
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
|
+
|
|
113
125
|
const hasLiveSession = () => [...sessions.values()].some((s) => !TERMINAL_PHASES.has(s.state.phase));
|
|
114
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
|
+
|
|
115
152
|
/**
|
|
116
153
|
* @param {import('node:http').IncomingMessage} req
|
|
117
154
|
* @param {import('node:http').ServerResponse} res
|
|
@@ -217,10 +254,20 @@ export function createAuthorRoutes(opts) {
|
|
|
217
254
|
const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
|
|
218
255
|
if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
219
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;
|
|
220
260
|
send(200, {
|
|
221
|
-
ok: true, origin: pre.origin, card
|
|
222
|
-
|
|
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,
|
|
223
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) } : {}),
|
|
224
271
|
});
|
|
225
272
|
return true;
|
|
226
273
|
}
|
|
@@ -236,6 +283,17 @@ export function createAuthorRoutes(opts) {
|
|
|
236
283
|
return true;
|
|
237
284
|
}
|
|
238
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
|
+
|
|
239
297
|
// A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
|
|
240
298
|
// or `null`. The server's session map is the only truth; nothing is stored page-side.
|
|
241
299
|
if (pathname === '/api/author/live') {
|
|
@@ -250,11 +308,15 @@ export function createAuthorRoutes(opts) {
|
|
|
250
308
|
if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
|
|
251
309
|
const card = body ?? {};
|
|
252
310
|
const rows = rowsForHome(keysHome(opts.home), envNow());
|
|
253
|
-
// Reuse workflow
|
|
254
|
-
//
|
|
255
|
-
//
|
|
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.
|
|
256
316
|
/** @type {{spec: any, workflowKey: string}|null} */
|
|
257
317
|
let reuse = null;
|
|
318
|
+
/** @type {string|undefined} */
|
|
319
|
+
let allowName;
|
|
258
320
|
const from = card.startFrom;
|
|
259
321
|
const fromRunid = typeof from === 'string' ? from : (typeof from?.runid === 'string' ? from.runid : null);
|
|
260
322
|
const fromImport = typeof from?.importId === 'string' ? from.importId : null;
|
|
@@ -262,24 +324,33 @@ export function createAuthorRoutes(opts) {
|
|
|
262
324
|
const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
|
|
263
325
|
if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
|
|
264
326
|
if (!pre.ok) { send(409, pre); return true; }
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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;
|
|
271
350
|
}
|
|
272
|
-
const locked = lockedFieldChanged(card, pre.card);
|
|
273
|
-
if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
|
|
274
|
-
const rv = validateReuseCard(card, { rows });
|
|
275
|
-
if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
|
|
276
|
-
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
277
|
-
const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
|
|
278
|
-
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; }
|
|
279
|
-
reuse = { spec, workflowKey: pre.workflowKey };
|
|
280
351
|
}
|
|
281
352
|
if (reuse === null) {
|
|
282
|
-
const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows });
|
|
353
|
+
const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows, ...(allowName !== undefined ? { allowName } : {}) });
|
|
283
354
|
if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
|
|
284
355
|
}
|
|
285
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.
|
|
@@ -289,15 +360,10 @@ export function createAuthorRoutes(opts) {
|
|
|
289
360
|
// Part 1a: a rubric job whose first input is a local FILE has no repo to prove inputs against; the session proves every
|
|
290
361
|
// line itself (`proveRubricInputs`), at $0, before any model call
|
|
291
362
|
const filesJob = reuse === null && card?.checkType === 'rubric' && looksLikeRubricInput(ip.inputs[0].value);
|
|
292
|
-
(
|
|
293
|
-
if (!
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}),
|
|
297
|
-
});
|
|
298
|
-
sessions.set(session.id, session);
|
|
299
|
-
send(200, { ok: true, sessionId: session.id, reuse: reuse !== null, state: session.state });
|
|
300
|
-
}).catch((e) => { send(500, { ok: false, error: `could not check the inputs: ${/** @type {Error} */ (e)?.message ?? e}` }); });
|
|
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
|
+
});
|
|
301
367
|
return true;
|
|
302
368
|
}
|
|
303
369
|
|
|
@@ -336,7 +402,7 @@ export function createAuthorRoutes(opts) {
|
|
|
336
402
|
}
|
|
337
403
|
if (sub === 'sign') {
|
|
338
404
|
const claimedHash = String(body?.specHash ?? '');
|
|
339
|
-
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 } : {}) });
|
|
340
406
|
send(r.ok ? 200 : 400, r);
|
|
341
407
|
return true;
|
|
342
408
|
}
|
|
@@ -344,7 +410,11 @@ export function createAuthorRoutes(opts) {
|
|
|
344
410
|
return true;
|
|
345
411
|
}
|
|
346
412
|
|
|
347
|
-
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
|
+
};
|
|
348
418
|
}
|
|
349
419
|
|
|
350
420
|
/**
|
|
@@ -378,7 +448,7 @@ export function panelMoney2(n) {
|
|
|
378
448
|
* it to sign"), and this is where it is checked, not trusted.
|
|
379
449
|
* @param {ReturnType<typeof createSession>} session
|
|
380
450
|
* @param {string} claimedHash
|
|
381
|
-
* @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
|
|
382
452
|
* @returns {{ok: boolean, error?: string, job?: string}}
|
|
383
453
|
*/
|
|
384
454
|
export function signRun(session, claimedHash, o) {
|
|
@@ -430,6 +500,8 @@ export function signRun(session, claimedHash, o) {
|
|
|
430
500
|
'systemd-inhibit', '--why=bareloop panel run',
|
|
431
501
|
process.execPath, o.bareloopBin,
|
|
432
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'] : []),
|
|
433
505
|
...(draftSpentUsd !== null ? ['--draft-spent-usd', String(draftSpentUsd), ...(draftIncomplete ? ['--draft-spend-incomplete'] : [])] : []),
|
|
434
506
|
];
|
|
435
507
|
/** @type {number|null} */
|
|
@@ -439,6 +511,8 @@ export function signRun(session, claimedHash, o) {
|
|
|
439
511
|
detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: o.env,
|
|
440
512
|
});
|
|
441
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);
|
|
442
516
|
session.state.phase = 'signed';
|
|
443
517
|
// hamr 2026-10-05: the sign shows as the last step of the one progress list, never as a thread bubble
|
|
444
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)
|
|
@@ -198,6 +198,25 @@ export function jobNameTaken(name, opts = {}) {
|
|
|
198
198
|
} catch { return false; }
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* The next free name for a NEW workflow made from `origin` (RULED 2026-10-15, v1.101): `<base>-N`, N the next free number from 2.
|
|
203
|
+
* Base: when the origin ends in `-<digits>` AND the part before it is itself a job (in the run list or jobs/), that part (so `x-2`
|
|
204
|
+
* suggests `x-3`, never `x-2-2`); else the origin's own name. Free = not a run-list job and not {@link jobNameTaken}.
|
|
205
|
+
* @param {string} origin
|
|
206
|
+
* @param {Iterable<string>} runJobs job names in the panel's run list
|
|
207
|
+
* @param {{jobsDir?: string}} [opts] test seam for {@link jobNameTaken}
|
|
208
|
+
* @returns {string}
|
|
209
|
+
*/
|
|
210
|
+
export function nextFreeJobName(origin, runJobs, opts = {}) {
|
|
211
|
+
const used = new Set(runJobs);
|
|
212
|
+
const m = /^(.+)-\d+$/.exec(origin);
|
|
213
|
+
const base = m && (used.has(m[1]) || jobNameTaken(m[1], opts)) ? m[1] : origin;
|
|
214
|
+
for (let n = 2; ; n += 1) {
|
|
215
|
+
const name = `${base}-${n}`;
|
|
216
|
+
if (!used.has(name) && !jobNameTaken(name, opts)) return name;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
201
220
|
/** the form's fields, in card order — what `card.json` stores, verbatim (P5 item 3) */
|
|
202
221
|
export const CARD_FIELDS = Object.freeze(['jobName', 'checkType', 'provider', 'model', 'thinking', 'jobText', 'inputs', 'destination', 'capUsd', 'maxWallMs']);
|
|
203
222
|
|
|
@@ -225,30 +244,25 @@ export function cardFields(card) {
|
|
|
225
244
|
}
|
|
226
245
|
|
|
227
246
|
/**
|
|
228
|
-
* REUSE WORKFLOW (
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
247
|
+
* REUSE WORKFLOW, ONE DOOR (RULED 2026-10-15, v1.101; replaces v1.88's five open boxes). Every box on the card is open. What decides
|
|
248
|
+
* the path is the CARD compared with the origin run's card, never the drafted spec: the JOB boxes are the job text (numbered lines,
|
|
249
|
+
* `~` rules, `~ PASS`/`~ FAIL`), the check type and the thinking tick. The RUN boxes (Inputs, Destination, Model, $ cap, Time cap)
|
|
250
|
+
* never decide it. A green origin with no job box changed reuses the signed plan verbatim (no drafting); anything else drafts.
|
|
232
251
|
*/
|
|
233
|
-
export const
|
|
234
|
-
/** every card field that is NOT open on a reuse card, in card order */
|
|
235
|
-
export const REUSE_LOCKED_FIELDS = Object.freeze(CARD_FIELDS.filter((f) => !REUSE_OPEN_FIELDS.includes(f)));
|
|
236
|
-
const REUSE_LABELS = Object.freeze(/** @type {Record<string, string>} */ ({
|
|
237
|
-
jobName: 'Job name', checkType: 'Check type', jobText: 'The job',
|
|
238
|
-
}));
|
|
252
|
+
export const JOB_BOXES = Object.freeze(['jobText', 'checkType', 'thinking']);
|
|
239
253
|
|
|
240
254
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
255
|
+
* Did any JOB box on `card` change from what the origin run prefilled (`origin`)? Whitespace is not compared (a one-line input drops
|
|
256
|
+
* a text's newlines); an absent thinking tick reads as off.
|
|
243
257
|
* @param {Record<string, any>} card the form as it was submitted
|
|
244
258
|
* @param {Record<string, any>} origin the form as the server prefilled it
|
|
245
|
-
* @returns {
|
|
259
|
+
* @returns {boolean}
|
|
246
260
|
*/
|
|
247
|
-
export function
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
261
|
+
export function jobBoxesChanged(card, origin) {
|
|
262
|
+
const text = (/** @type {any} */ v) => (typeof v === 'string' ? v.replace(/\s+/g, '') : '');
|
|
263
|
+
return text(card?.jobText) !== text(origin?.jobText)
|
|
264
|
+
|| (card?.checkType === 'rubric') !== (origin?.checkType === 'rubric')
|
|
265
|
+
|| (card?.thinking === true) !== (origin?.thinking === true);
|
|
252
266
|
}
|
|
253
267
|
|
|
254
268
|
/**
|
|
@@ -267,7 +281,9 @@ export function lockedFieldChanged(card, origin) {
|
|
|
267
281
|
*/
|
|
268
282
|
export function buildReuseSpec(originSpec, card, rows) {
|
|
269
283
|
const spec = JSON.parse(JSON.stringify(originSpec));
|
|
270
|
-
|
|
284
|
+
// an ABSOLUTE Destination is a per-run output folder (a plain-files job): it is never part of the signed spec, and the spec's own
|
|
285
|
+
// fence (inside the run's tree) stays as signed. A repo job's Destination is the fence and is relative (an absolute one is refused).
|
|
286
|
+
if (!isAbsoluteDestination(String(card.destination ?? '').trim())) spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
|
|
271
287
|
const ip = parseInputs(card.inputs);
|
|
272
288
|
if (ip.ok) spec.inputs = ip.inputs; else delete spec.inputs;
|
|
273
289
|
spec.budgetUsd = card.capUsd;
|
|
@@ -310,6 +326,27 @@ const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(desti
|
|
|
310
326
|
|
|
311
327
|
export { nonRepoSourceMessage };
|
|
312
328
|
|
|
329
|
+
/** the refusal for a plain-files rubric job whose Destination is not an absolute folder (ends with the words the session start shows) */
|
|
330
|
+
export const FILES_DESTINATION_WORDS = 'Destination must be the full path of a folder, like /home/me/out — the finished files are delivered there. Nothing spent.';
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* THE ONE Destination check of a session start, at $0, before any session, copy or model call. The session's own pipeline, the HTTP
|
|
334
|
+
* start (`startSession`), the schedule's two clicks and a scheduled fire's `wouldAccept` all call THIS (never a copy of it), so a start
|
|
335
|
+
* that would be refused for its Destination is refused before anything is made.
|
|
336
|
+
* @param {{inputs?: unknown, destination?: unknown, checkType?: unknown}} card the Inputs text (line 1 = the source), the Destination and the check type
|
|
337
|
+
* @returns {string|null} the plain refusal, or null when the Destination is fine
|
|
338
|
+
*/
|
|
339
|
+
export function startDestinationProblem(card) {
|
|
340
|
+
const ip = parseInputs(card?.inputs);
|
|
341
|
+
const source = ip.ok ? (ip.inputs[0]?.value ?? '') : '';
|
|
342
|
+
const destination = typeof card?.destination === 'string' ? card.destination : '';
|
|
343
|
+
const isRepoLike = looksLikeRepoSource(source);
|
|
344
|
+
const repo = repoDestinationProblem(destination, isRepoLike);
|
|
345
|
+
if (repo !== null) return repo;
|
|
346
|
+
if (card?.checkType === 'rubric' && !isRepoLike && !isAbsoluteDestination(destination)) return FILES_DESTINATION_WORDS;
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
313
350
|
/**
|
|
314
351
|
* The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
|
|
315
352
|
* relative to the repo, so an absolute path is refused — at $0, before any copy or model call. A FOLDER source (or a
|
|
@@ -327,14 +364,14 @@ export function repoDestinationProblem(destination, isRepoLike) {
|
|
|
327
364
|
* creates a session or spends anything — never inside the async pipeline,
|
|
328
365
|
* so a bad card refuses synchronously, in the same HTTP response.
|
|
329
366
|
* @param {any} card
|
|
330
|
-
* @param {{jobsDir?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card
|
|
367
|
+
* @param {{jobsDir?: string, allowName?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card; `allowName` = the origin job's own name on an unchanged red Reuse (it is the same job, not a collision)
|
|
331
368
|
* @returns {{ok: true}|{ok: false, error: string}}
|
|
332
369
|
*/
|
|
333
370
|
export function validateJobCard(card, opts = {}) {
|
|
334
371
|
if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
|
|
335
372
|
const { jobName, checkType, model, destination, capUsd } = card;
|
|
336
373
|
if (!isSlug(jobName)) return { ok: false, error: 'Job name must be a kebab-case slug (letters, digits, dashes)' };
|
|
337
|
-
if (jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
|
|
374
|
+
if (jobName !== opts.allowName && jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
|
|
338
375
|
return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
|
|
339
376
|
}
|
|
340
377
|
if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
|
|
@@ -685,7 +722,7 @@ export function createSession(card, deps = {}) {
|
|
|
685
722
|
// line is one file, frozen by `proveRubricInputs`; there is no repo, no language and no scout (docs/product/ITEM33-BUILD.md)
|
|
686
723
|
const RUBRIC_FILES = card.checkType === 'rubric' && !isRepoLike;
|
|
687
724
|
// hamr's ruling B (2026-10-05): ONE rule for both a fresh card and a reuse card, at $0, before the copy and any model call
|
|
688
|
-
const destProblem =
|
|
725
|
+
const destProblem = startDestinationProblem(card);
|
|
689
726
|
if (destProblem !== null) { refuse(destProblem); return; }
|
|
690
727
|
|
|
691
728
|
state.phase = 'preparing-source';
|
|
@@ -707,10 +744,6 @@ export function createSession(card, deps = {}) {
|
|
|
707
744
|
// fence read as a directory and refused as "not absolute" — hiding the real problem with the Source. A scope-shaped
|
|
708
745
|
// (relative) Destination is therefore handed to the door only when the source is repo-like.
|
|
709
746
|
const destIsDir = isAbsoluteDestination(card.destination);
|
|
710
|
-
if (RUBRIC_FILES && !destIsDir) {
|
|
711
|
-
refuse('Destination must be the full path of a folder, like /home/me/out — the finished files are delivered there. Nothing spent.');
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
747
|
/** @type {any} */
|
|
715
748
|
let prep;
|
|
716
749
|
if (RUBRIC_FILES) {
|
|
@@ -945,7 +978,6 @@ export function createSession(card, deps = {}) {
|
|
|
945
978
|
model: modelChoice.name,
|
|
946
979
|
// the "Thinking on" tick is signed ONLY when ticked (unticked writes no field: the hash stays what it would have been)
|
|
947
980
|
...(card.thinking === true && draftThinkingPlan?.support !== false ? { thinking: true } : {}),
|
|
948
|
-
cadence: { unit: 'day', every: 1 },
|
|
949
981
|
budgetUsd: card.capUsd,
|
|
950
982
|
...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
|
|
951
983
|
writeScope,
|