bareloop 0.34.0 → 0.36.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 +58 -0
- package/bareloop.context.md +276 -20
- package/package.json +1 -1
- package/src/authorrun.js +3 -2
- package/src/bundlerun.js +17 -12
- package/src/draftspend.js +197 -0
- package/src/exits.js +10 -1
- package/src/index.js +3 -3
- package/src/interviewrun.js +7 -7
- package/src/job.js +37 -0
- package/src/judged.js +2 -2
- package/src/kinds.js +2 -1
- package/src/ledger.js +101 -5
- package/src/legs.js +171 -0
- package/src/monthly.js +111 -72
- package/src/movefile.js +22 -0
- package/src/npminstall.js +55 -0
- package/src/panel/authorroutes.js +84 -14
- package/src/panel/authorsession.js +490 -125
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1194 -213
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +964 -79
- package/src/panel/settingsroutes.js +52 -2
- package/src/panel/status.js +51 -0
- package/src/planrun.js +60 -26
- package/src/providerrows.js +17 -0
- package/src/ralph.js +4 -1
- package/src/replay.js +139 -19
- package/src/replayio.js +11 -4
- package/src/reuse.js +20 -3
- package/src/run.js +6 -3
- package/src/runlist.js +75 -7
- package/src/source.js +43 -4
- package/src/text.js +20 -0
- package/src/u-patient.js +3 -2
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +192 -65
- package/src/worktree.js +120 -0
- package/types/draftspend.d.ts +141 -0
- package/types/job.d.ts +25 -0
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +23 -18
- package/types/movefile.d.ts +9 -0
- package/types/npminstall.d.ts +30 -0
- package/types/panel/authorroutes.d.ts +23 -1
- package/types/panel/authorsession.d.ts +110 -2
- package/types/panel/importroutes.d.ts +129 -0
- package/types/panel/importrun.d.ts +77 -0
- package/types/panel/runroutes.d.ts +32 -0
- package/types/panel/server.d.ts +266 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +12 -0
- package/types/ralph.d.ts +19 -0
- package/types/replay.d.ts +25 -1
- package/types/replayio.d.ts +2 -2
- package/types/reuse.d.ts +5 -0
- package/types/run.d.ts +6 -2
- package/types/runlist.d.ts +52 -2
- package/types/source.d.ts +13 -5
- package/types/text.d.ts +19 -0
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
|
@@ -22,7 +22,8 @@ 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 { createSession, validateJobCard } from './authorsession.js';
|
|
25
|
+
import { createSession, validateJobCard, validateReuseCard, lockedFieldChanged, buildReuseSpec, cardFields, STEP_LABELS } from './authorsession.js';
|
|
26
|
+
import { validateJob } from '../job.js';
|
|
26
27
|
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
27
28
|
import { ConfigError } from '../config.js';
|
|
28
29
|
import { keysForDoor, keysHome } from '../keysfile.js';
|
|
@@ -34,6 +35,12 @@ export function mintToken() {
|
|
|
34
35
|
return randomBytes(24).toString('hex');
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The ONE spelling of this panel's own bind address (`host:port`), shared by the Host and Origin checks.
|
|
40
|
+
* @param {number} port
|
|
41
|
+
*/
|
|
42
|
+
const bindAddress = (port) => `127.0.0.1:${port}`;
|
|
43
|
+
|
|
37
44
|
/**
|
|
38
45
|
* The ONE Host check: the request names this panel's own bind address. A page on another
|
|
39
46
|
* origin reaching the panel through a rebound DNS name sends that name as Host, so it fails here.
|
|
@@ -42,7 +49,7 @@ export function mintToken() {
|
|
|
42
49
|
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
43
50
|
*/
|
|
44
51
|
export function checkHostGuard(req, { port }) {
|
|
45
|
-
return req.headers.host ===
|
|
52
|
+
return req.headers.host === bindAddress(port) ? { ok: true } : { ok: false, reason: 'wrong Host' };
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
/**
|
|
@@ -57,7 +64,7 @@ export function checkHostGuard(req, { port }) {
|
|
|
57
64
|
export function checkHumanGuard(req, { token, port }) {
|
|
58
65
|
const got = req.headers['x-bareloop-token'];
|
|
59
66
|
if (got !== token) return { ok: false, reason: 'missing or wrong token' };
|
|
60
|
-
const want =
|
|
67
|
+
const want = bindAddress(port);
|
|
61
68
|
const origin = req.headers.origin;
|
|
62
69
|
if (typeof origin === 'string' && origin.length > 0) {
|
|
63
70
|
if (origin !== `http://${want}` && origin !== `https://${want}`) return { ok: false, reason: 'wrong Origin' };
|
|
@@ -83,7 +90,8 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
|
|
|
83
90
|
* deliberately has none of).
|
|
84
91
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>,
|
|
85
92
|
* sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
|
|
86
|
-
* jobsDir?: string, fetchImpl?: typeof fetch, home?: string
|
|
93
|
+
* jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
|
|
94
|
+
* startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any} }} opts
|
|
87
95
|
*/
|
|
88
96
|
export function createAuthorRoutes(opts) {
|
|
89
97
|
// the RAW env with the keys file re-merged on every use, so an edited file takes effect
|
|
@@ -180,7 +188,7 @@ export function createAuthorRoutes(opts) {
|
|
|
180
188
|
const cap = Number(q.get('cap'));
|
|
181
189
|
if (!Number.isFinite(cap) || cap <= 0) { send(200, { ok: true, refusal: null }); return true; }
|
|
182
190
|
try {
|
|
183
|
-
const room = checkMonthlyRoom({ capUsd: cap, home: opts.home });
|
|
191
|
+
const room = checkMonthlyRoom({ capUsd: cap, home: opts.home, sessionsRoot: opts.sessionsRoot });
|
|
184
192
|
send(200, { ok: true, refusal: monthlyRefusalText(room), leftUsd: room.leftUsd, atLeast: room.atLeast });
|
|
185
193
|
} catch (e) {
|
|
186
194
|
if (!(e instanceof ConfigError)) throw e;
|
|
@@ -189,19 +197,74 @@ export function createAuthorRoutes(opts) {
|
|
|
189
197
|
return true;
|
|
190
198
|
}
|
|
191
199
|
|
|
200
|
+
// ── REUSE WORKFLOW (replaces P5 item 3's Start from this). `GET /api/author/start-from?runid=` (or `?import=`) —
|
|
201
|
+
// the prefill from the SIGNED job, which boxes are locked, the track record. $0, read-only. The route keeps its name.
|
|
202
|
+
if (pathname === '/api/author/start-from') {
|
|
203
|
+
if (!opts.startFrom) { send(404, { ok: false, error: 'not found' }); return true; }
|
|
204
|
+
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
205
|
+
const sp = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
|
|
206
|
+
const runid = sp.get('runid') ?? '';
|
|
207
|
+
// `model` = the card's Model box right now: the estimate line counts only runs on that worker
|
|
208
|
+
const asModel = sp.has('model') ? { model: sp.get('model') ?? '' } : {};
|
|
209
|
+
const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
|
|
210
|
+
if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
211
|
+
if (!pre.ok) { send(409, pre); return true; }
|
|
212
|
+
send(200, {
|
|
213
|
+
ok: true, origin: pre.origin, card: pre.card, from: pre.from, note: pre.note,
|
|
214
|
+
locked: pre.locked, open: pre.open, specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
|
|
215
|
+
line: pre.line,
|
|
216
|
+
});
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
|
|
221
|
+
// or `null`. The server's session map is the only truth; nothing is stored page-side.
|
|
222
|
+
if (pathname === '/api/author/live') {
|
|
223
|
+
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
224
|
+
const live = [...sessions.values()].find((s) => !TERMINAL_PHASES.has(s.state.phase));
|
|
225
|
+
send(200, { ok: true, sessionId: live ? live.state.id : null, state: live ? live.state : null });
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
192
229
|
if (pathname === '/api/author/start') {
|
|
193
230
|
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
194
231
|
if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
|
|
195
232
|
const card = body ?? {};
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
233
|
+
const rows = rowsForHome(keysHome(opts.home));
|
|
234
|
+
// Reuse workflow — started from a green run or an imported job: only Source, Destination and the two caps may
|
|
235
|
+
// differ from the signed workflow. The SERVER refuses any other change (the page greys those boxes, but is
|
|
236
|
+
// never trusted); the spec it signs is a copy of the origin's with just those four set. Nothing is drafted.
|
|
237
|
+
/** @type {{spec: any, workflowKey: string}|null} */
|
|
238
|
+
let reuse = null;
|
|
239
|
+
const from = card.startFrom;
|
|
240
|
+
const fromRunid = typeof from === 'string' ? from : (typeof from?.runid === 'string' ? from.runid : null);
|
|
241
|
+
const fromImport = typeof from?.importId === 'string' ? from.importId : null;
|
|
242
|
+
if (fromRunid !== null || fromImport !== null) {
|
|
243
|
+
const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
|
|
244
|
+
if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
|
|
245
|
+
if (!pre.ok) { send(409, pre); return true; }
|
|
246
|
+
const locked = lockedFieldChanged(card, pre.card);
|
|
247
|
+
if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
|
|
248
|
+
const rv = validateReuseCard(card, { rows });
|
|
249
|
+
if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
|
|
250
|
+
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
251
|
+
const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
|
|
252
|
+
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; }
|
|
253
|
+
reuse = { spec, workflowKey: pre.workflowKey };
|
|
254
|
+
}
|
|
255
|
+
if (reuse === null) {
|
|
256
|
+
const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows });
|
|
257
|
+
if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
|
|
258
|
+
}
|
|
259
|
+
const session = createSession(cardFields(card), {
|
|
260
|
+
env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}),
|
|
261
|
+
});
|
|
199
262
|
sessions.set(session.id, session);
|
|
200
|
-
send(200, { ok: true, sessionId: session.id, state: session.state });
|
|
263
|
+
send(200, { ok: true, sessionId: session.id, reuse: reuse !== null, state: session.state });
|
|
201
264
|
return true;
|
|
202
265
|
}
|
|
203
266
|
|
|
204
|
-
const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|revise|sign-prepare|sign|check-deps))?$/.exec(pathname);
|
|
267
|
+
const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|revise|sign-prepare|sign|check-deps|abandon))?$/.exec(pathname);
|
|
205
268
|
if (!m) { send(404, { ok: false, error: 'not found' }); return true; }
|
|
206
269
|
const session = sessions.get(m[1]);
|
|
207
270
|
if (!session) { send(404, { ok: false, error: 'no such session' }); return true; }
|
|
@@ -223,6 +286,11 @@ export function createAuthorRoutes(opts) {
|
|
|
223
286
|
session.revise(String(body?.text ?? '')).then((r) => send(r.ok ? 200 : 400, { ...r, state: session.state }));
|
|
224
287
|
return true;
|
|
225
288
|
}
|
|
289
|
+
if (sub === 'abandon') {
|
|
290
|
+
const r = session.abandon();
|
|
291
|
+
send(r.ok ? 200 : 400, { ...r, state: session.state });
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
226
294
|
if (sub === 'check-deps') {
|
|
227
295
|
const r = session.checkDeps();
|
|
228
296
|
send(r.ok ? 200 : 400, { ...r, state: session.state });
|
|
@@ -258,7 +326,7 @@ export function createAuthorRoutes(opts) {
|
|
|
258
326
|
* @param {number} n a non-negative dollar amount
|
|
259
327
|
* @returns {string}
|
|
260
328
|
*/
|
|
261
|
-
function panelMoney2(n) {
|
|
329
|
+
export function panelMoney2(n) {
|
|
262
330
|
if (n > 0 && n < 0.01) return '<$0.01';
|
|
263
331
|
// two-step rounding (matching index.html's own panelMoney): clean to
|
|
264
332
|
// 6-decimal precision first (the same precision src/text.js's tallyCalls
|
|
@@ -290,7 +358,7 @@ export function signRun(session, claimedHash, o) {
|
|
|
290
358
|
// lower nothing (the cap is signed in) but may raise the limit in Settings and sign again.
|
|
291
359
|
try {
|
|
292
360
|
const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
|
|
293
|
-
const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: o.home }));
|
|
361
|
+
const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: o.home, sessionsRoot: o.sessionsRoot }));
|
|
294
362
|
if (refusal !== null) return { ok: false, error: refusal };
|
|
295
363
|
} catch (e) {
|
|
296
364
|
if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
|
|
@@ -330,9 +398,11 @@ export function signRun(session, claimedHash, o) {
|
|
|
330
398
|
});
|
|
331
399
|
if (typeof child?.unref === 'function') child.unref();
|
|
332
400
|
session.state.phase = 'signed';
|
|
401
|
+
// hamr 2026-10-05: the sign shows as the last step of the one progress list, never as a thread bubble
|
|
402
|
+
// hamr 2026-10-05 (option A): the drafting-spend line is that step's detail — nothing when there was no drafting spend (a Reuse)
|
|
333
403
|
const draftLine = draftSpentUsd !== null
|
|
334
|
-
? `
|
|
404
|
+
? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
|
|
335
405
|
: '';
|
|
336
|
-
session.state.
|
|
406
|
+
if (Array.isArray(session.state.steps)) session.state.steps.push({ id: 'signed', label: STEP_LABELS.signed, status: 'done', detail: draftLine });
|
|
337
407
|
return { ok: true, job: session.state.resolvedSpecPath };
|
|
338
408
|
}
|