@bongos/core 1.20.81 → 1.20.83
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/.bongos-core.json +71 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +46 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +33 -26
- package/docs/copy-registry.json +164 -92
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +58 -43
- package/modules/economy/earnings.js +117 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/routes/earnings.js +47 -0
- package/modules/hall-ui/public/gate.css +49 -0
- package/modules/hall-ui/public/gate.html +5 -7
- package/modules/hall-ui/public/gate.js +215 -127
- package/modules/hall-ui/public/profile-charts.js +206 -104
- package/modules/hall-ui/public/profile.css +23 -0
- package/modules/hall-ui/public/profile.js +3 -3
- package/modules/hall-ui/public/profile.states.json +8 -4
- package/modules/hall-ui/records/profile-roles.md +21 -0
- package/modules/lifecycle/db-goals.js +6 -0
- package/modules/lifecycle/db-versions.js +3 -0
- package/modules/lifecycle/done-when.js +85 -41
- package/modules/lifecycle/goal-close-sweep.js +34 -0
- package/modules/provisioning/app-status.js +17 -4
- package/modules/public-landing/public/projects.html +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +28 -6
- package/tests/autonomy_builder_scope.mjs +1 -1
- package/tests/autonomy_fence_priority.mjs +5 -3
- package/tests/autonomy_runner_identity.mjs +1 -1
- package/tests/criterion_goal_attach.mjs +5 -1
- package/tests/goal_close_cancels_requests.mjs +82 -0
- package/tests/goal_closure_invariants.mjs +73 -2
- package/tests/hall_profile_world.mjs +57 -58
- package/tests/profile_earnings.mjs +237 -0
- package/tests/projects_hub_app_status.mjs +17 -0
- 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
|
+
}
|
|
321
|
+
|
|
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
|
+
}
|
|
251
371
|
|
|
252
|
-
function
|
|
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
|
|
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="
|
|
257
|
-
<span class="
|
|
258
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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}
|
|
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
|
|
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 ? '
|
|
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,92 +501,25 @@
|
|
|
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
|
-
|
|
372
|
-
return;
|
|
510
|
+
fenceView.runners = { error: true, list: [] };
|
|
373
511
|
}
|
|
374
|
-
|
|
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;
|
|
380
|
-
}
|
|
381
|
-
el.innerHTML = runners.map((r) => {
|
|
382
|
-
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>`;
|
|
410
|
-
}).join('');
|
|
512
|
+
renderFencePanel();
|
|
411
513
|
}
|
|
412
514
|
|
|
413
515
|
async function loadFence() {
|
|
414
|
-
let fence;
|
|
415
516
|
try {
|
|
416
|
-
fence = await fetchJson('/autonomy/fence');
|
|
517
|
+
fenceView.fence = await fetchJson('/autonomy/fence');
|
|
417
518
|
} catch (err) {
|
|
418
519
|
if (err && err.kind === '401') return;
|
|
419
|
-
|
|
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 = '';
|
|
424
|
-
return;
|
|
520
|
+
fenceView.fence = { error: true };
|
|
425
521
|
}
|
|
426
|
-
|
|
427
|
-
// project allowlist narrowed to the viewer's own picks. This panel is the
|
|
428
|
-
// project switch, so it draws the unnarrowed `project` the route carries
|
|
429
|
-
// alongside. A server that predates the split has no `project`, and then the
|
|
430
|
-
// top level IS the project fence.
|
|
431
|
-
const project = fence.project || fence;
|
|
432
|
-
renderFenceState(project);
|
|
433
|
-
renderFenceGoals(project.goals, project.priority_goal_id);
|
|
522
|
+
renderFencePanel();
|
|
434
523
|
}
|
|
435
524
|
|
|
436
525
|
// ---- every builder's runner (task 1004501) --------------------------------
|
|
@@ -446,9 +535,8 @@
|
|
|
446
535
|
: 'has picked no goals, so their runner takes no work';
|
|
447
536
|
const beats = (b.runners || []).map((r) => {
|
|
448
537
|
const h = r.health || {};
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
return `<span class="fact-pill ${cls}">${escapeHtml(label)}</span><span class="ov-fact">${escapeHtml(h.host || r.host || '')}</span>`;
|
|
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>`;
|
|
452
540
|
}).join('');
|
|
453
541
|
const paused = b.owner_paused === true;
|
|
454
542
|
return `
|
|
@@ -458,7 +546,7 @@
|
|
|
458
546
|
${paused ? '<span class="fact-pill fact-pill--warn">Paused</span>' : ''}
|
|
459
547
|
<span style="flex:1;">${goals}${paused && b.owner_paused_reason ? ` — paused: ${escapeHtml(b.owner_paused_reason)}` : ''}</span>
|
|
460
548
|
${beats || '<span class="ov-fact">no runner has checked in</span>'}
|
|
461
|
-
<button type="button" class="btn" data-pause="${escapeHtml(String(b.builder_id))}" data-paused="${paused ? '1' : '0'}">${paused ? 'Resume' : 'Pause'}</button>
|
|
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>
|
|
462
550
|
</li>`;
|
|
463
551
|
}
|
|
464
552
|
|
|
@@ -475,7 +563,7 @@
|
|
|
475
563
|
}
|
|
476
564
|
const builders = resp.builders || [];
|
|
477
565
|
el.innerHTML = `
|
|
478
|
-
<h3 class="
|
|
566
|
+
<h3 class="rn-sub">Each builder's runner</h3>
|
|
479
567
|
${builders.length ? `<ul class="ov-rows">${builders.map(builderRowHtml).join('')}</ul>`
|
|
480
568
|
: emptyStateHtml('No builder has picked goals or run a runner yet.')}`;
|
|
481
569
|
for (const btn of el.querySelectorAll('[data-pause]')) {
|