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.
Files changed (68) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bareloop.context.md +276 -20
  3. package/package.json +1 -1
  4. package/src/authorrun.js +3 -2
  5. package/src/bundlerun.js +17 -12
  6. package/src/draftspend.js +197 -0
  7. package/src/exits.js +10 -1
  8. package/src/index.js +3 -3
  9. package/src/interviewrun.js +7 -7
  10. package/src/job.js +37 -0
  11. package/src/judged.js +2 -2
  12. package/src/kinds.js +2 -1
  13. package/src/ledger.js +101 -5
  14. package/src/legs.js +171 -0
  15. package/src/monthly.js +111 -72
  16. package/src/movefile.js +22 -0
  17. package/src/npminstall.js +55 -0
  18. package/src/panel/authorroutes.js +84 -14
  19. package/src/panel/authorsession.js +490 -125
  20. package/src/panel/importroutes.js +359 -0
  21. package/src/panel/importrun.js +203 -0
  22. package/src/panel/index.html +1194 -213
  23. package/src/panel/runroutes.js +219 -0
  24. package/src/panel/server.js +964 -79
  25. package/src/panel/settingsroutes.js +52 -2
  26. package/src/panel/status.js +51 -0
  27. package/src/planrun.js +60 -26
  28. package/src/providerrows.js +17 -0
  29. package/src/ralph.js +4 -1
  30. package/src/replay.js +139 -19
  31. package/src/replayio.js +11 -4
  32. package/src/reuse.js +20 -3
  33. package/src/run.js +6 -3
  34. package/src/runlist.js +75 -7
  35. package/src/source.js +43 -4
  36. package/src/text.js +20 -0
  37. package/src/u-patient.js +3 -2
  38. package/src/u-readout.js +9 -1
  39. package/src/u-watchdog.mjs +4 -2
  40. package/src/userrun.js +192 -65
  41. package/src/worktree.js +120 -0
  42. package/types/draftspend.d.ts +141 -0
  43. package/types/job.d.ts +25 -0
  44. package/types/ledger.d.ts +44 -0
  45. package/types/legs.d.ts +103 -0
  46. package/types/monthly.d.ts +23 -18
  47. package/types/movefile.d.ts +9 -0
  48. package/types/npminstall.d.ts +30 -0
  49. package/types/panel/authorroutes.d.ts +23 -1
  50. package/types/panel/authorsession.d.ts +110 -2
  51. package/types/panel/importroutes.d.ts +129 -0
  52. package/types/panel/importrun.d.ts +77 -0
  53. package/types/panel/runroutes.d.ts +32 -0
  54. package/types/panel/server.d.ts +266 -2
  55. package/types/panel/settingsroutes.d.ts +6 -1
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/planrun.d.ts +13 -21
  58. package/types/providerrows.d.ts +12 -0
  59. package/types/ralph.d.ts +19 -0
  60. package/types/replay.d.ts +25 -1
  61. package/types/replayio.d.ts +2 -2
  62. package/types/reuse.d.ts +5 -0
  63. package/types/run.d.ts +6 -2
  64. package/types/runlist.d.ts +52 -2
  65. package/types/source.d.ts +13 -5
  66. package/types/text.d.ts +19 -0
  67. package/types/userrun.d.ts +10 -0
  68. 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 === `127.0.0.1:${port}` ? { ok: true } : { ok: false, reason: 'wrong 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 = `127.0.0.1:${port}`;
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 }} opts
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 v = validateJobCard(card, { jobsDir: opts.jobsDir, rows: rowsForHome(keysHome(opts.home)) });
197
- if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
198
- const session = createSession(card, { env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}) });
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
- ? ` — drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
404
+ ? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
335
405
  : '';
336
- session.state.messages.push({ role: 'system', text: `signed — spec hash ${session.state.specHash}${draftLine} — run starting detached, own log at ${logFile}` });
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
  }