@bongos/core 1.20.80 → 1.20.82

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 (56) hide show
  1. package/.bongos-core.json +98 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +12 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +245 -4
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +84 -71
  10. package/docs/copy-registry.json +271 -145
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +2 -1
  13. package/docs/page-readings.json +139 -119
  14. package/modules/autonomy/cadence.js +3 -0
  15. package/modules/autonomy/db.js +136 -0
  16. package/modules/autonomy/fence.js +88 -3
  17. package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
  18. package/modules/autonomy/routes/autonomy.js +123 -3
  19. package/modules/economy/earnings.js +117 -0
  20. package/modules/economy/module.json +1 -1
  21. package/modules/economy/routes/earnings.js +47 -0
  22. package/modules/government/catalog.js +9 -1
  23. package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
  24. package/modules/hall-ui/public/gate.css +49 -0
  25. package/modules/hall-ui/public/gate.html +8 -7
  26. package/modules/hall-ui/public/gate.js +272 -115
  27. package/modules/hall-ui/public/profile-charts.js +206 -104
  28. package/modules/hall-ui/public/profile.css +23 -0
  29. package/modules/hall-ui/public/profile.js +3 -3
  30. package/modules/hall-ui/public/profile.states.json +8 -4
  31. package/modules/hall-ui/public/settings-autobongos.js +156 -0
  32. package/modules/hall-ui/public/settings.html +19 -0
  33. package/modules/hall-ui/public/settings.js +1 -0
  34. package/modules/hall-ui/records/profile-roles.md +21 -0
  35. package/modules/lifecycle/db-goals.js +6 -0
  36. package/modules/lifecycle/db-versions.js +3 -0
  37. package/modules/lifecycle/done-when.js +4 -0
  38. package/modules/lifecycle/goal-close-sweep.js +34 -0
  39. package/modules/provisioning/app-status.js +17 -4
  40. package/modules/public-landing/public/projects.html +7 -0
  41. package/package-lock.json +2 -2
  42. package/package.json +1 -1
  43. package/release-notes.json +28 -0
  44. package/scripts/hall-preview/server.js +4 -0
  45. package/src/module-api.js +1 -1
  46. package/tests/auto_satisfy_criteria.mjs +8 -6
  47. package/tests/autonomy_builder_scope.mjs +474 -0
  48. package/tests/autonomy_fence_priority.mjs +7 -3
  49. package/tests/autonomy_runner_identity.mjs +1 -1
  50. package/tests/criterion_goal_attach.mjs +5 -1
  51. package/tests/goal_close_cancels_requests.mjs +82 -0
  52. package/tests/goal_closure_invariants.mjs +5 -2
  53. package/tests/hall_profile_world.mjs +57 -58
  54. package/tests/profile_earnings.mjs +237 -0
  55. package/tests/projects_hub_app_status.mjs +17 -0
  56. package/tests/provisioning_app_status.mjs +25 -1
@@ -248,86 +248,242 @@
248
248
  // cosmetic: POST /autonomy/fence and the two allowlist routes carry
249
249
  // `autonomy.fence.manage`, which the catalog declares system:true + archon, so
250
250
  // it cannot be granted to a custom rank. The wall is the server's.
251
+ //
252
+ // ONE panel, two shapes (task 1004509). While anything the runner needs is
253
+ // missing it is a checklist, each unticked line saying what to do; once every
254
+ // line is ticked it collapses to a single status line with the switch beside
255
+ // it. The code version, the Claude account and the other PCs sit behind
256
+ // Details, because they only matter when something is wrong.
257
+
258
+ // The runner's liveness verdict is the SERVER's — modules/autonomy/runner-health.js
259
+ // decides what dead means and the route attaches it — so this page and any
260
+ // future alert cannot drift into two definitions of the same word. These are
261
+ // only the order to pick the runner to describe in, and the colour of its dot.
262
+ const HEALTH_RANK = { alive: 0, late: 1, restarting: 2, dead: 3, never_seen: 4 };
263
+ const HEALTH_DOT = { alive: 'ok', late: 'ok', restarting: 'info', dead: 'danger', never_seen: 'info' };
264
+
265
+ // The panel is drawn from both reads at once; each is kept so a write that only
266
+ // reloads one of them redraws with the other's last answer.
267
+ const fenceView = { runners: null, fence: null, detailsOpen: false, adding: false };
268
+
269
+ // The runner to describe: the healthiest one, so a second PC that died long
270
+ // ago does not hide the one that is working. The rest are listed in Details.
271
+ function primaryRunner(runners) {
272
+ return (runners || []).slice().sort((a, b) =>
273
+ (HEALTH_RANK[(a.health || {}).state] ?? 9) - (HEALTH_RANK[(b.health || {}).state] ?? 9))[0] || null;
274
+ }
275
+
276
+ // Each checklist line is ok, todo, or wait (nothing to do yet; it follows from
277
+ // another line). Only a todo keeps the checklist open. An unknown is never a
278
+ // todo: an older runner that does not report its Claude account is not drawn
279
+ // as signed out.
280
+ //
281
+ // `fence` is the PROJECT fence (the switch and the allowlist); `mine` is the
282
+ // caller's own, narrowed to the goals they picked in Settings and stopped if the
283
+ // owner paused them (task 1004501). A server that predates that split sends no
284
+ // `project`, and then there is no second layer to check.
285
+ function readiness(runner, fence, mine) {
286
+ const h = (runner && runner.health) || {};
287
+ const connected = ['alive', 'late', 'restarting'].includes(h.state);
288
+ const goals = (fence && fence.goals) || [];
289
+ const items = [];
290
+ if (!runner) {
291
+ items.push({ key: 'pc', state: 'todo', text: 'Connect a PC', hint: 'Install the runner on an always-on PC. It shows up here when it first checks in.' });
292
+ } else if (!connected) {
293
+ items.push({ key: 'pc', state: 'todo', text: `${h.host || 'The PC'} has gone quiet`, hint: `No check-in for ${h.age || 'a while'}. Check the PC is on, then restart the runner.` });
294
+ } else {
295
+ items.push({ key: 'pc', state: 'ok', text: 'PC connected', meta: h.host || '' });
296
+ }
297
+ if (!connected) items.push({ key: 'claude', state: 'wait', text: 'Claude signed in' });
298
+ else if (h.account) items.push({ key: 'claude', state: 'ok', text: 'Claude signed in' });
299
+ if (connected && h.code && h.code.behind === true) {
300
+ items.push({ key: 'code', state: 'wait', text: 'Updating to the latest code', hint: 'It does this by itself.' });
301
+ } else if (connected && h.code) {
302
+ items.push({ key: 'code', state: 'ok', text: 'Up to date' });
303
+ }
304
+ items.push(goals.length
305
+ ? { key: 'goals', state: 'ok', text: goals.length === 1 ? '1 goal picked' : `${goals.length} goals picked` }
306
+ : { key: 'goals', state: 'todo', text: 'Pick at least one goal', hint: 'The runner only works on goals you add below.', action: 'add-goal' });
307
+ if (mine && goals.length) {
308
+ items.push((mine.goals || []).length
309
+ ? { key: 'mine', state: 'ok', text: 'Your runner has its goals' }
310
+ : { key: 'mine', state: 'todo', text: 'Choose which goals your runner works', hint: 'Each builder picks from the goals below, in Settings.', action: 'settings' });
311
+ }
312
+ items.push(fence && fence.enabled
313
+ ? { key: 'switch', state: 'ok', text: 'Turned on' }
314
+ : { key: 'switch', state: 'todo', text: 'Turn it on', hint: fence && fence.paused_reason ? `Turned off: ${fence.paused_reason}` : 'Use the switch at the top.' });
315
+ // On, but not for you: no permission to run one, or paused on your builder.
316
+ if (mine && fence && fence.enabled && mine.enabled === false && (mine.goals || []).length) {
317
+ items.push({ key: 'mine-stop', state: 'todo', text: 'Your runner is held', hint: mine.paused_reason || 'It is not allowed to take work right now.' });
318
+ }
319
+ return { items, ready: !items.some((i) => i.state === 'todo') };
320
+ }
251
321
 
252
- function renderFenceState(fence) {
322
+ // The headline when everything is ticked: what it is doing, in four words.
323
+ function headline(runner) {
324
+ const h = (runner && runner.health) || {};
325
+ if (h.state === 'restarting') return 'Restarting to pick up new code';
326
+ if (h.workingTaskId) return `Working on task ${h.workingTaskId}`;
327
+ return 'Ready — waiting for a task';
328
+ }
329
+
330
+ const ICON = { ok: '✓', todo: '!', wait: '…' };
331
+
332
+ function checklistHtml(items) {
333
+ return `<ul class="rn-check">${items.map((i) => `
334
+ <li class="rn-check__item rn-check__item--${i.state}">
335
+ <span class="rn-check__icon" aria-hidden="true">${ICON[i.state]}</span>
336
+ <span class="rn-check__text">${escapeHtml(i.text)}${i.meta ? ` <span class="rn-meta">${escapeHtml(i.meta)}</span>` : ''}
337
+ ${i.hint && i.state !== 'ok' ? `<span class="rn-check__hint">${escapeHtml(i.hint)}</span>` : ''}</span>
338
+ ${i.action === 'add-goal' ? '<button type="button" class="btn btn--sm" data-rn="add-goal">Choose goals</button>' : ''}
339
+ ${i.action === 'settings' ? '<a class="btn btn--sm" href="/builders/settings#autobongos">Open Settings</a>' : ''}
340
+ </li>`).join('')}</ul>`;
341
+ }
342
+
343
+ function goalChipsHtml(goals, priorityGoalId) {
344
+ const pri = priorityGoalId == null ? null : Number(priorityGoalId);
345
+ // The priority goal first, because that is the order the runner works them in.
346
+ const ordered = (goals || []).slice().sort((a, b) => (Number(b.goal_id) === pri) - (Number(a.goal_id) === pri));
347
+ const chips = ordered.map((g) => {
348
+ const id = escapeHtml(String(g.goal_id));
349
+ const isPri = Number(g.goal_id) === pri;
350
+ const name = escapeHtml(g.note || `Goal ${g.goal_id}`);
351
+ return `<li class="rn-chip${isPri ? ' rn-chip--pri' : ''}">
352
+ <button type="button" class="rn-chip__star" data-priority="${isPri ? '' : id}"
353
+ aria-label="${isPri ? `Stop working goal ${id} first` : `Work goal ${id} first`}"
354
+ title="${isPri ? 'Worked first — click to clear' : 'Work this goal first'}">${isPri ? '★' : '☆'}</button>
355
+ <a href="/builders#/goal/${encodeURIComponent(g.goal_id)}" title="Goal ${id}">${name}</a>
356
+ <button type="button" class="rn-chip__x" data-revoke="${id}" aria-label="Remove goal ${id}" title="Remove">×</button>
357
+ </li>`;
358
+ }).join('');
359
+ const add = fenceView.adding
360
+ ? `<li><form id="fence-add" class="rn-add">
361
+ <label class="sr-only" for="fence-goal-id">Goal number</label>
362
+ <input type="number" id="fence-goal-id" min="1" step="1" required placeholder="Goal number">
363
+ <label class="sr-only" for="fence-goal-note">Name (optional)</label>
364
+ <input type="text" id="fence-goal-note" maxlength="500" placeholder="Name (optional)">
365
+ <button type="submit" class="btn btn--sm">Add</button>
366
+ <button type="button" class="btn btn--ghost btn--sm" data-rn="cancel-add">Cancel</button>
367
+ </form></li>`
368
+ : '<li><button type="button" class="rn-chip rn-chip--add" data-rn="add-goal">+ Add goal</button></li>';
369
+ return `<h3 class="rn-sub">Goals it can work on</h3><ul class="rn-chips">${chips}${add}</ul>`;
370
+ }
371
+
372
+ function factsHtml(runner) {
373
+ const h = runner.health || {};
374
+ const facts = [];
375
+ if (h.host) facts.push(`PC <strong>${escapeHtml(h.host)}</strong>`);
376
+ if (h.account) facts.push(`Claude account ${escapeHtml(h.account)}`);
377
+ if (h.code) {
378
+ const c = h.code;
379
+ const status = c.behind === true ? ` — behind main (${escapeHtml(c.mainShort)})`
380
+ : c.behind === false ? ' — up to date' : '';
381
+ const disk = c.diskShort ? `, updated in place to ${escapeHtml(c.diskShort)}` : '';
382
+ facts.push(`code <code>${escapeHtml(c.short)}</code>${disk}${status}`);
383
+ }
384
+ facts.push(`${escapeHtml(h.detail || '')}${h.age ? `, last check-in ${escapeHtml(h.age)} ago` : ''}`);
385
+ return `<li>${facts.join(' · ')}</li>`;
386
+ }
387
+
388
+ function renderFencePanel() {
389
+ const el = document.getElementById('fence-body');
390
+ const { runners } = fenceView;
391
+ const raw = fenceView.fence;
392
+ if (!el || runners === null || raw === null) return;
393
+ if (raw.error) {
394
+ // A fence the HALL cannot read is only a rendering problem — the RUNNER
395
+ // reads it separately and refuses on the same failure. Say so plainly rather
396
+ // than showing a switch whose position is a guess.
397
+ el.innerHTML = emptyStateHtml('Could not read the runner switch just now.');
398
+ return;
399
+ }
400
+ const runner = runners.error ? null : primaryRunner(runners.list);
401
+ const h = (runner && runner.health) || {};
402
+ // GET /autonomy/fence answers with the CALLER'S fence (task 1004501); the
403
+ // switch and the chips are the project's, which rides along as `project`.
404
+ const fence = raw.project || raw;
405
+ const mine = raw.project ? raw : null;
406
+ const { items, ready } = readiness(runner, fence, mine);
407
+ // Read the fold off the page before redrawing: the toggle event fires after
408
+ // a click that also redraws, so a listener would record it too late.
409
+ const openNow = el.querySelector('.rn-details');
410
+ if (openNow) fenceView.detailsOpen = openNow.open;
253
411
  const on = fence.enabled === true;
254
- const el = document.getElementById('fence-state');
412
+ const dot = ready ? (HEALTH_DOT[h.state] || 'info') : 'warn';
413
+ const title = ready ? headline(runner) : 'Set up the runner';
414
+ const sub = runners.error ? 'Could not read the runner’s check-ins just now.'
415
+ : ready ? `${escapeHtml(h.host || '')} · checked in ${escapeHtml(h.age || '?')} ago`
416
+ : `${items.filter((i) => i.state === 'ok').length} of ${items.length} done`;
417
+
255
418
  el.innerHTML = `
256
- <div class="ov-foot" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
257
- <span class="fact-pill ${on ? 'fact-pill--ok' : 'fact-pill--warn'}">${on ? 'Allowed to work' : 'Stopped'}</span>
258
- <span>${on ? 'The runner may claim and work allowlisted goals.' : escapeHtml(fence.paused_reason || 'The runner is doing nothing.')}</span>
259
- <button type="button" id="fence-toggle" class="btn">${on ? 'Stop the runner' : 'Start the runner'}</button>
260
- </div>`;
419
+ <div class="rn-head">
420
+ <span class="rn-dot rn-dot--${dot}" aria-hidden="true"></span>
421
+ <div class="rn-head__text">
422
+ <p class="rn-title">${escapeHtml(title)}</p>
423
+ <p class="rn-meta">${sub}</p>
424
+ </div>
425
+ <button type="button" id="fence-toggle" class="rn-switch" role="switch" aria-checked="${on}"
426
+ aria-label="Runner allowed to work"><span class="rn-switch__label">${on ? 'On' : 'Off'}</span><span class="rn-switch__track" aria-hidden="true"></span></button>
427
+ </div>
428
+ ${ready ? '' : checklistHtml(items)}
429
+ ${goalChipsHtml(fence.goals, fence.priority_goal_id)}
430
+ <details class="rn-details"${fenceView.detailsOpen ? ' open' : ''}>
431
+ <summary>Details</summary>
432
+ <p>Runners work only the goals above, and only while the switch is on. Each builder picks which of them their own runner takes, and you can pause any one builder's runner below. All of it lives on the server, so a runner's PC can read it but cannot change it.</p>
433
+ ${runners.list && runners.list.length ? `<ul>${runners.list.map(factsHtml).join('')}</ul>` : ''}
434
+ </details>`;
435
+ wireFencePanel(el, on);
436
+ }
437
+
438
+ function wireFencePanel(el, on) {
261
439
  document.getElementById('fence-toggle').addEventListener('click', async () => {
262
440
  // Turning it OFF asks why. Stopping an unattended machine is the kind of act
263
441
  // someone reconstructs weeks later, and "halted" answers none of their
264
442
  // questions. Turning it ON needs no reason — the allowlist already is one.
265
443
  let reason = null;
266
444
  if (on) {
267
- reason = window.prompt('Why are you stopping the runner? (shown to anyone who asks why it is idle)');
445
+ reason = window.prompt('Why are you turning the runner off? (shown to anyone who asks why it is idle)');
268
446
  if (reason === null) return; // cancelled — not the same as an empty reason
269
447
  }
270
448
  try {
271
449
  await sendJson('POST', '/autonomy/fence', { enabled: !on, reason: reason || undefined });
272
- toast(on ? 'Runner stopped.' : 'Runner started.');
450
+ toast(on ? 'Runner turned off.' : 'Runner turned on.');
273
451
  await loadFence();
274
452
  } catch (err) { toast(err.message || 'Could not change the switch.'); }
275
453
  });
276
- }
277
454
 
278
- // The priority goal (task 1004453) is one of the allowlisted rows, marked and
279
- // listed first, because that is the order the runner works them in. Its button
280
- // clears it; every other row's button makes that goal the priority.
281
- function renderFenceGoals(goals, priorityGoalId) {
282
- const el = document.getElementById('fence-goals');
283
- const pri = priorityGoalId == null ? null : Number(priorityGoalId);
284
- // true - false = 1, so the priority row sorts ahead of every other; the rest keep their order.
285
- const ordered = (goals || []).slice().sort((a, b) => (Number(b.goal_id) === pri) - (Number(a.goal_id) === pri));
286
- const rows = ordered.map((g) => {
287
- const isPri = Number(g.goal_id) === pri;
288
- return `
289
- <li class="ov-row" style="display:flex;align-items:center;gap:12px;">
290
- <a href="/builders#/goal/${encodeURIComponent(g.goal_id)}">Goal ${escapeHtml(String(g.goal_id))}</a>
291
- ${isPri ? '<span class="fact-pill fact-pill--ok">Priority</span>' : ''}
292
- <span style="flex:1;">${escapeHtml(g.note || '')}</span>
293
- <button type="button" class="btn" data-priority="${isPri ? '' : escapeHtml(String(g.goal_id))}">${isPri ? 'Clear priority' : 'Make priority'}</button>
294
- <button type="button" class="btn" data-revoke="${escapeHtml(String(g.goal_id))}">Remove</button>
295
- </li>`;
296
- }).join('');
297
- const priLine = pri === null
298
- ? 'No priority goal: the runner works these in any order.'
299
- : `The runner works goal ${escapeHtml(String(pri))} first, then the others once it has nothing left to claim there.`;
300
- el.innerHTML = `
301
- <h3 class="scroll__h" style="font-size:1rem;">Allowlisted goals</h3>
302
- ${goals && goals.length ? `<p class="ov-foot">${priLine}</p>` : ''}
303
- ${goals && goals.length ? `<ul class="ov-rows">${rows}</ul>`
304
- : emptyStateHtml('No goal is allowlisted, so the runner has nothing it may work on.')}
305
- <form id="fence-add" class="ov-foot" style="display:flex;gap:8px;align-items:center;margin-top:12px;">
306
- <label for="fence-goal-id">Allow goal</label>
307
- <input type="number" id="fence-goal-id" min="1" step="1" required placeholder="1000119">
308
- <input type="text" id="fence-goal-note" maxlength="500" placeholder="why (optional)">
309
- <button type="submit" class="btn">Add</button>
310
- </form>`;
311
-
312
- document.getElementById('fence-add').addEventListener('submit', async (e) => {
455
+ for (const btn of el.querySelectorAll('[data-rn="add-goal"]')) {
456
+ btn.addEventListener('click', () => {
457
+ fenceView.adding = true;
458
+ renderFencePanel();
459
+ const input = document.getElementById('fence-goal-id');
460
+ if (input) input.focus();
461
+ });
462
+ }
463
+ const cancel = el.querySelector('[data-rn="cancel-add"]');
464
+ if (cancel) cancel.addEventListener('click', () => { fenceView.adding = false; renderFencePanel(); });
465
+
466
+ const form = document.getElementById('fence-add');
467
+ if (form) form.addEventListener('submit', async (e) => {
313
468
  e.preventDefault();
314
469
  const goalId = Number(document.getElementById('fence-goal-id').value);
315
470
  if (!Number.isSafeInteger(goalId) || goalId <= 0) { toast('Enter a goal number.'); return; }
316
471
  try {
317
472
  await sendJson('POST', '/autonomy/fence/goals', { goal_id: goalId, note: document.getElementById('fence-goal-note').value || undefined });
318
- toast(`Goal ${goalId} allowlisted.`);
473
+ toast(`Goal ${goalId} added.`);
474
+ fenceView.adding = false;
319
475
  await loadFence();
320
476
  } catch (err) { toast(err.message || 'Could not add that goal.'); }
321
477
  });
322
478
 
323
479
  for (const btn of el.querySelectorAll('[data-priority]')) {
324
480
  btn.addEventListener('click', async () => {
325
- // An empty attribute is the Clear button on the current priority row.
481
+ // An empty attribute is the star on the current priority goal: clear it.
326
482
  const v = btn.getAttribute('data-priority');
327
483
  const goalId = v ? Number(v) : null;
328
484
  try {
329
485
  await sendJson('POST', '/autonomy/fence/priority', { goal_id: goalId });
330
- toast(goalId === null ? 'Priority cleared.' : `Goal ${goalId} is now the priority.`);
486
+ toast(goalId === null ? 'No goal is worked first now.' : `Goal ${goalId} is worked first.`);
331
487
  await loadFence();
332
488
  } catch (err) { toast(err.message || 'Could not change the priority.'); }
333
489
  });
@@ -345,86 +501,87 @@
345
501
  }
346
502
  }
347
503
 
348
- // The runner's liveness (task 1003905). Rendered ABOVE the switch on purpose:
349
- // "is it running at all" is the first question, and an owner who has just
350
- // flipped the switch on wants to see the machine notice.
351
- //
352
- // The verdict is the SERVER's — modules/autonomy/runner-health.js decides what
353
- // dead means and the route attaches it — so this page and any future alert
354
- // cannot drift into two definitions of the same word.
355
- // This page's OWN class vocabulary (style.css `.fact-pill--{ok,warn,danger}`),
356
- // the same one renderRow spends for a PR's verdict. The first cut reached for
357
- // inline `--color-text-*` tokens from a different design system: they do not
358
- // exist here, resolved to inherited grey, and drew a dead runner in exactly the
359
- // same colour as a healthy one. Named classes cannot fail that way silently —
360
- // a missing class is visible in the stylesheet, a missing var is not.
361
- const HEALTH_CLASS = { alive: 'fact-pill--ok', late: 'fact-pill--warn', dead: 'fact-pill--danger', never_seen: 'fact-pill--info', restarting: 'fact-pill--info' };
362
-
363
504
  async function loadRunners() {
364
- const el = document.getElementById('fence-runner');
365
- if (!el) return;
366
- let resp;
367
505
  try {
368
- resp = await fetchJson('/autonomy/runners');
506
+ const resp = await fetchJson('/autonomy/runners');
507
+ fenceView.runners = { list: resp.runners || [] };
369
508
  } catch (err) {
370
509
  if (err && err.kind === '401') return;
371
- el.innerHTML = emptyStateHtml('Could not read the runner\u2019s check-ins just now.');
372
- return;
510
+ fenceView.runners = { error: true, list: [] };
373
511
  }
374
- const runners = resp.runners || [];
375
- if (!runners.length) {
376
- // never_seen, and the server said so rather than this page guessing. Not an
377
- // alarm: nobody has set the host up yet.
378
- el.innerHTML = `<p class="ov-foot">${escapeHtml(resp.none ? resp.none.detail : 'No runner has ever checked in.')}</p>`;
379
- return;
512
+ renderFencePanel();
513
+ }
514
+
515
+ async function loadFence() {
516
+ try {
517
+ fenceView.fence = await fetchJson('/autonomy/fence');
518
+ } catch (err) {
519
+ if (err && err.kind === '401') return;
520
+ fenceView.fence = { error: true };
380
521
  }
381
- el.innerHTML = runners.map((r) => {
522
+ renderFencePanel();
523
+ }
524
+
525
+ // ---- every builder's runner (task 1004501) --------------------------------
526
+ //
527
+ // One row per builder who has picked goals or whose runner has checked in:
528
+ // what they picked, whether the owner paused them, and each runner's health
529
+ // (the server's verdict, the same one loadRunners draws). Pause asks why, like
530
+ // the master switch, because a stopped machine is reconstructed weeks later.
531
+ function builderRowHtml(b) {
532
+ const name = b.display_name || b.github_login || `builder ${b.builder_id}`;
533
+ const goals = (b.goals || []).length
534
+ ? `works goal${b.goals.length === 1 ? '' : 's'} ${b.goals.map((g) => escapeHtml(String(g))).join(', ')}`
535
+ : 'has picked no goals, so their runner takes no work';
536
+ const beats = (b.runners || []).map((r) => {
382
537
  const h = r.health || {};
383
- const cls = HEALTH_CLASS[h.state] || 'fact-pill--info';
384
- // NOT the word "running": the switch below already uses it for a different
385
- // thing (allowed to work), and the two lines sit one above the other. A
386
- // reader should never have to work out which sense is meant.
387
- const label = { dead: 'Not checking in', late: 'Quiet', alive: 'Checking in', restarting: 'Restarting' }[h.state] || 'Unknown';
388
- // Which PC, which Claude account, which code (task 1004407). Each line is
389
- // drawn only when the runner reported it, so an older runner shows less
390
- // rather than a guess.
391
- const facts = [];
392
- if (h.host) facts.push(`<span class="ov-fact">PC <strong>${escapeHtml(h.host)}</strong></span>`);
393
- if (h.account) facts.push(`<span class="ov-fact">Claude account ${escapeHtml(h.account)}</span>`);
394
- if (h.code) {
395
- const c = h.code;
396
- const status = c.behind === true ? ` — behind main (${escapeHtml(c.mainShort)})`
397
- : c.behind === false ? ' — up to date' : '';
398
- const disk = c.diskShort ? `, updated in place to ${escapeHtml(c.diskShort)}` : '';
399
- facts.push(`<span class="ov-fact">code <code>${escapeHtml(c.short)}</code>${disk}${status}</span>`);
400
- }
401
- return `<div class="ov-foot">
402
- <p style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin:0;">
403
- <span class="fact-pill ${cls}">${escapeHtml(label)}</span>
404
- <span>${escapeHtml(h.detail || '')}</span>
405
- ${r.working_goal_id ? `<span class="ov-fact">from goal ${escapeHtml(String(r.working_goal_id))}</span>` : ''}
406
- <span class="ov-fact">last check-in ${escapeHtml(h.age || 'unknown')} ago</span>
407
- </p>
408
- ${facts.length ? `<p style="display:flex;gap:14px;flex-wrap:wrap;margin:4px 0 0;">${facts.join('')}</p>` : ''}
409
- </div>`;
538
+ const label = { dead: 'not checking in', late: 'quiet', alive: 'checking in', restarting: 'restarting' }[h.state] || 'unknown';
539
+ return `<span class="ov-fact"><span class="rn-dot rn-dot--sm rn-dot--${HEALTH_DOT[h.state] || 'info'}" aria-hidden="true"></span> ${escapeHtml(h.host || r.host || '')} ${escapeHtml(label)}</span>`;
410
540
  }).join('');
541
+ const paused = b.owner_paused === true;
542
+ return `
543
+ <li class="ov-row" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
544
+ <strong>${escapeHtml(name)}</strong>
545
+ ${b.rank ? `<span class="ov-fact">${escapeHtml(rankLabel(b.rank))}</span>` : ''}
546
+ ${paused ? '<span class="fact-pill fact-pill--warn">Paused</span>' : ''}
547
+ <span style="flex:1;">${goals}${paused && b.owner_paused_reason ? ` — paused: ${escapeHtml(b.owner_paused_reason)}` : ''}</span>
548
+ ${beats || '<span class="ov-fact">no runner has checked in</span>'}
549
+ <button type="button" class="btn btn--ghost btn--sm" data-pause="${escapeHtml(String(b.builder_id))}" data-paused="${paused ? '1' : '0'}">${paused ? 'Resume' : 'Pause'}</button>
550
+ </li>`;
411
551
  }
412
552
 
413
- async function loadFence() {
414
- let fence;
553
+ async function loadBuilderRunners() {
554
+ const el = document.getElementById('fence-builders');
555
+ if (!el) return;
556
+ let resp;
415
557
  try {
416
- fence = await fetchJson('/autonomy/fence');
558
+ resp = await fetchJson('/autonomy/runners/all');
417
559
  } catch (err) {
418
560
  if (err && err.kind === '401') return;
419
- // A fence the HALL cannot read is only a rendering problem — the RUNNER
420
- // reads it separately and refuses on the same failure. Say so plainly rather
421
- // than showing a switch whose position is a guess.
422
- document.getElementById('fence-state').innerHTML = emptyStateHtml('Could not read the runner switch just now.');
423
- document.getElementById('fence-goals').innerHTML = '';
561
+ el.innerHTML = emptyStateHtml('Could not read each builder’s runner just now.');
424
562
  return;
425
563
  }
426
- renderFenceState(fence);
427
- renderFenceGoals(fence.goals, fence.priority_goal_id);
564
+ const builders = resp.builders || [];
565
+ el.innerHTML = `
566
+ <h3 class="rn-sub">Each builder's runner</h3>
567
+ ${builders.length ? `<ul class="ov-rows">${builders.map(builderRowHtml).join('')}</ul>`
568
+ : emptyStateHtml('No builder has picked goals or run a runner yet.')}`;
569
+ for (const btn of el.querySelectorAll('[data-pause]')) {
570
+ btn.addEventListener('click', async () => {
571
+ const id = btn.getAttribute('data-pause');
572
+ const pausing = btn.getAttribute('data-paused') !== '1';
573
+ let reason = null;
574
+ if (pausing) {
575
+ reason = window.prompt('Why are you pausing this builder’s runner? (shown to them)');
576
+ if (reason === null) return;
577
+ }
578
+ try {
579
+ await sendJson('POST', `/autonomy/builders/${encodeURIComponent(id)}/pause`, { paused: pausing, reason: reason || undefined });
580
+ toast(pausing ? 'Runner paused.' : 'Runner resumed.');
581
+ await loadBuilderRunners();
582
+ } catch (err) { toast(err.message || 'Could not change that runner.'); }
583
+ });
584
+ }
428
585
  }
429
586
 
430
587
  async function init() {
@@ -449,8 +606,8 @@
449
606
  document.getElementById('gate-body').hidden = false;
450
607
  if (rank === 'archon') {
451
608
  document.getElementById('fence-panel').hidden = false;
452
- // Independent reads, so one round trip rather than two.
453
- await Promise.all([loadRunners(), loadFence()]);
609
+ // Independent reads, so one round trip rather than three.
610
+ await Promise.all([loadRunners(), loadFence(), loadBuilderRunners()]);
454
611
  }
455
612
  await load();
456
613
  }