@bongos/core 1.19.873 → 1.19.875

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.
@@ -1,25 +1,43 @@
1
- // deploy.js — the owner's deploy door (task 1003159, ADR 0293).
1
+ // deploy.js — the owner's deploy door (task 1003159, ADR 0293; widened to the
2
+ // hosted projects by task 1004046).
2
3
  //
3
4
  // WHAT THIS PAGE IS FOR. The owner, twice in a month: "I don't think I can make anything
4
- // live", then "this is above my paygrade." Moving this instance's core pin is gate 3 of
5
- // the release pipeline and the ONLY act that makes shipped work visible to users — and it
6
- // was reachable from exactly one place, a shell on the control-plane box holding an ssh
7
- // key. An agent could not close the loop either: the permission classifier blocks a
8
- // mutating `sudo -u lars` over ssh by design. So the one gate that is the owner's to hold
9
- // was the one gate with no instrument. This page is the instrument.
5
+ // live", then "this is above my paygrade." Moving a core pin is gate 3 of the release
6
+ // pipeline and the ONLY act that makes shipped work visible to users — and it was
7
+ // reachable from exactly one place, a shell on the control-plane box holding an ssh key.
8
+ // An agent could not close the loop either: the permission classifier blocks a mutating
9
+ // `sudo -u lars` over ssh by design. So the one gate that is the owner's to hold was the
10
+ // one gate with no instrument. This page is the instrument.
11
+ //
12
+ // WHY THE PROJECTS ARE HERE AND NOT IN THEIR OWN HALLS. The obvious shape — each owner
13
+ // pressing Upgrade in their own project's hall — is not buildable: the upgrade route and
14
+ // the intent queue belong to the PROVISIONING module, and a hosted project does not run
15
+ // it. Probed 2026-09-18: /api/bongos/provisioning/health answers 200 here and 404 on
16
+ // every tenant. The control plane is the only place that can act on them, so this is
17
+ // where the button lives. Their own halls get the banner instead (task 1004045), which
18
+ // each instance derives for itself from the registry and needs nothing from us.
19
+ //
20
+ // Nothing about the ENGINE had to change for this. UPGRADE_SHAPES already admitted
21
+ // 'co-tenant' and 'standalone' alongside 'control-plane', coreUpgradeInstance already
22
+ // resolved each shape's own checkout and built that box's own health and version URLs,
23
+ // and the preview route already took any instance id. Only this page was single-row.
10
24
  //
11
25
  // THE RULE THAT SHAPES EVERY LINE HERE: this page KNOWS NOTHING. Every version on it is a
12
26
  // reading the control-plane runner took on the box, carried through the server with the
13
27
  // time it was taken. Nothing is computed here, no version is compared here, and "up to
14
28
  // date" is never inferred from an absence — the server says `behind` or it does not, and
15
29
  // a missing reading renders as a missing reading. A deploy control that guesses is worse
16
- // than none, because it is trusted.
30
+ // than none, because it is trusted. That rule is now load-bearing for boxes the owner has
31
+ // never logged into, which is the harder case, not the easier one.
17
32
  //
18
33
  // TWO STEPS, NEVER ONE (ADR 0293 D4). The owner asked for the confirmation. Auto-rollback
19
34
  // already guards the bad release; this guards the wrong button. Step one shows what would
20
35
  // move; step two names the version it saw and sends that. The confirm button is built
21
36
  // FROM the previewed version, so a release that lands between the two steps cannot
22
- // silently change what the press does.
37
+ // silently change what the press does. With several boxes on one page the step is held
38
+ // PER BOX, and each button's handler CLOSES OVER the preview object it was built from —
39
+ // so the version sent is the one that box displayed, and a repaint cannot swap it. A
40
+ // page-wide "currently previewing" would be a wrong-box press waiting to happen.
23
41
  (() => {
24
42
  'use strict';
25
43
 
@@ -29,10 +47,9 @@
29
47
  const toast = (msg) => window.OTB.toast(msg);
30
48
  const $ = (id) => document.getElementById(id);
31
49
 
32
- // The preview the server last handed us. The confirm step reads its version from HERE
33
- // rather than from the DOM, so what is sent is what was previewed even if a poll
34
- // repaints between the two clicks.
35
- let previewed = null;
50
+ // instance_id -> 'preview' | 'confirm'. Held per box so opening the confirm on one does
51
+ // not arm it on another.
52
+ const steps = new Map();
36
53
 
37
54
  async function fetchJson(path) {
38
55
  const res = await api.request('GET', `${API}${path}`);
@@ -74,7 +91,7 @@
74
91
  return v ? `<span class="ov-mono">${escapeHtml(String(v))}</span>` : '<span class="ps-pill ps-pill--absent">no reading</span>';
75
92
  }
76
93
 
77
- // ---- the three states this page can be in --------------------------------
94
+ // ---- the states ONE box can be in ----------------------------------------
78
95
 
79
96
  // Something is already running. The press is not offered at all — offering a button
80
97
  // that the server will refuse with 'intent_conflict' is a worse experience than saying
@@ -92,69 +109,108 @@
92
109
  <p class="ov-row__sub">Core <strong>${escapeHtml(String(p.recommended_version))}</strong> is published and
93
110
  this box is not on it yet.</p>
94
111
  <div class="ps-controls">
95
- <button type="button" class="btn" id="dp-preview">See what would change</button>
112
+ <button type="button" class="btn" id="dp-preview-${escapeHtml(String(p.instance_id))}">See what would change</button>
96
113
  </div>`;
97
114
  }
98
115
 
99
- // Step two. The version is written into the button's own label and into `previewed`,
100
- // so the owner confirms the thing they were shown rather than "the newest", which is a
101
- // different act by the time it is resolved.
102
- function stepTwoHtml(p) {
116
+ // Step two. The version is written into the button's own label, and the handler wired
117
+ // to that button closes over this same preview object — so the owner confirms the thing
118
+ // they were shown rather than "the newest", which is a different act by the time it is
119
+ // resolved.
120
+ function stepTwoHtml(p, isSelf) {
121
+ const id = escapeHtml(String(p.instance_id));
103
122
  return `
104
- <div class="ps-controls" id="dp-confirm-wrap">
105
- <p class="ov-row__sub">This restarts the platform onto core
123
+ <div class="ps-controls">
124
+ <p class="ov-row__sub">This restarts ${isSelf ? 'the platform' : escapeHtml(String(p.slug))} onto core
106
125
  <strong>${escapeHtml(String(p.recommended_version))}</strong>. It waits for the box to answer and
107
126
  puts itself back on ${escapeHtml(String(p.served_version))} if it does not.</p>
108
- <button type="button" class="btn btn--primary" id="dp-confirm">Move to ${escapeHtml(String(p.recommended_version))}</button>
109
- <button type="button" class="btn" id="dp-cancel">Cancel</button>
127
+ <button type="button" class="btn btn--primary" id="dp-confirm-${id}">Move to ${escapeHtml(String(p.recommended_version))}</button>
128
+ <button type="button" class="btn" id="dp-cancel-${id}">Cancel</button>
110
129
  </div>`;
111
130
  }
112
131
 
113
- function panelHtml(p, step) {
114
- const rows = [
115
- `<li class="ov-row"><div class="ov-row__main">
116
- <span class="ov-row__title">Serving now</span>
117
- <p class="ov-row__sub">${versionHtml(p.served_version)} — read from the box itself, ${escapeHtml(ago(p.observed_at))}.</p>
118
- ${p.reading === 'no_answer'
119
- ? `<p class="ps-invalid">The box did not answer when the control plane last looked, so this version is unknown rather than unchanged. A move is not offered while that is true.</p>`
120
- : ''}
121
- </div></li>`,
122
- ];
123
- let action = '';
132
+ // One box's card. `isSelf` only changes the words, never what is offered: the runner
133
+ // treats the platform's own row and a tenant's identically, and a page that implied
134
+ // otherwise would be describing a rule that does not exist.
135
+ function boxHtml(p, isSelf) {
136
+ const step = steps.get(p.instance_id) || 'preview';
137
+ let action;
124
138
  if (p.open_intent) action = busyHtml(p);
125
139
  else if (p.reading === 'no_answer') action = '';
126
140
  else if (!p.behind) action = `<p class="ov-row__sub">Nothing newer is published that this box can take. There is nothing to deploy.</p>`;
127
- else action = step === 'confirm' ? stepTwoHtml(p) : stepOneHtml(p);
128
- rows.push(`<li class="ov-row"><div class="ov-row__main">
129
- <span class="ov-row__title">Make it live</span>
141
+ else action = step === 'confirm' ? stepTwoHtml(p, isSelf) : stepOneHtml(p);
142
+ return `
143
+ <li class="ov-row" id="dp-box-${escapeHtml(String(p.instance_id))}"><div class="ov-row__main">
144
+ <span class="ov-row__title">${escapeHtml(String(p.slug))}${isSelf ? ' — the box this platform runs on' : ''}</span>
145
+ <p class="ov-row__sub">Serving ${versionHtml(p.served_version)} — read from the box itself, ${escapeHtml(ago(p.observed_at))}.</p>
146
+ ${p.reading === 'no_answer'
147
+ ? `<p class="ps-invalid">The box did not answer when the control plane last looked, so this version is unknown rather than unchanged. A move is not offered while that is true.</p>`
148
+ : ''}
130
149
  ${action}
131
- </div></li>`);
132
- return `<ul class="ov-lines">${rows.join('')}</ul>
133
- <p class="ps-prov">reads the control plane's own observation · ADR 0293 · every move is recorded with who asked</p>`;
150
+ </div></li>`;
151
+ }
152
+
153
+ // A project whose preview could not be read at all. It is LISTED rather than dropped:
154
+ // a box silently missing from a deploy page reads as a box that does not need
155
+ // deploying, which is the one wrong impression this page must never give.
156
+ function unreadableHtml(inst, message) {
157
+ return `
158
+ <li class="ov-row"><div class="ov-row__main">
159
+ <span class="ov-row__title">${escapeHtml(String(inst.slug))}</span>
160
+ <p class="ps-invalid">Could not read this project's deploy state: ${escapeHtml(message)}</p>
161
+ </div></li>`;
134
162
  }
135
163
 
136
164
  // ---- wiring --------------------------------------------------------------
137
165
 
138
- function render(p, step) {
139
- previewed = p;
140
- $('dp-panel').innerHTML = panelHtml(p, step);
141
- const preview = $('dp-preview');
142
- if (preview) preview.addEventListener('click', () => render(p, 'confirm'));
143
- const cancel = $('dp-cancel');
144
- if (cancel) cancel.addEventListener('click', () => render(p, 'preview'));
145
- const confirm = $('dp-confirm');
166
+ function wire(p) {
167
+ const id = String(p.instance_id);
168
+ const preview = $(`dp-preview-${id}`);
169
+ if (preview) preview.addEventListener('click', () => { steps.set(p.instance_id, 'confirm'); repaint(); });
170
+ const cancel = $(`dp-cancel-${id}`);
171
+ if (cancel) cancel.addEventListener('click', () => { steps.set(p.instance_id, 'preview'); repaint(); });
172
+ const confirm = $(`dp-confirm-${id}`);
146
173
  if (confirm) confirm.addEventListener('click', () => move(p));
147
174
  }
148
175
 
176
+ // What the page is currently showing: the self row (or null), the readable project
177
+ // previews, and the ones that would not read. Held so a repaint after a click does not
178
+ // have to re-fetch everything.
179
+ let view = { self: null, boxes: [], unreadable: [], notEnrolled: null };
180
+
181
+ function repaint() {
182
+ const items = [];
183
+ if (view.self) items.push(boxHtml(view.self, true));
184
+ else if (view.notEnrolled) {
185
+ // The server's own sentence names the one command that fixes it, which is why it
186
+ // is shown rather than a generic failure line.
187
+ items.push(`<li class="ov-row"><div class="ov-row__main">
188
+ <span class="ov-row__title">This platform</span>
189
+ <p class="ps-invalid">${escapeHtml(view.notEnrolled)}</p>
190
+ </div></li>`);
191
+ }
192
+ for (const p of view.boxes) items.push(boxHtml(p, false));
193
+ for (const u of view.unreadable) items.push(unreadableHtml(u.inst, u.message));
194
+ if (!items.length) {
195
+ items.push(`<li class="ov-row"><div class="ov-row__main">
196
+ <p class="ov-row__sub">No running box to deploy.</p>
197
+ </div></li>`);
198
+ }
199
+ $('dp-panel').innerHTML = `<ul class="ov-lines">${items.join('')}</ul>
200
+ <p class="ps-prov">reads the control plane's own observation · ADR 0293 · every move is recorded with who asked</p>`;
201
+ if (view.self) wire(view.self);
202
+ for (const p of view.boxes) wire(p);
203
+ }
204
+
149
205
  async function move(p) {
150
- const btn = $('dp-confirm');
206
+ const btn = $(`dp-confirm-${p.instance_id}`);
151
207
  if (btn) { btn.disabled = true; btn.textContent = 'Queueing…'; }
152
208
  try {
153
- // `p.recommended_version` — the version this owner was SHOWN, not whatever is
154
- // newest at the moment of the click.
209
+ // `p.recommended_version` — the version this owner was SHOWN for THIS box, not
210
+ // whatever is newest at the moment of the click.
155
211
  const r = await sendJson('POST', `/provisioning/instances/${p.instance_id}/core-upgrade`, { to: p.recommended_version });
156
212
  toast(r.message || 'Queued.');
157
- await load();
213
+ await refreshOne(p);
158
214
  } catch (e) {
159
215
  if (e && e.kind === '401') return;
160
216
  toast(e.message || 'Could not queue the move.');
@@ -162,12 +218,90 @@
162
218
  }
163
219
  }
164
220
 
221
+ // Re-read ONLY the box that just changed. The full load() fans out one probe per
222
+ // hosted project, and firing all of them to refresh one row is cache invalidation
223
+ // far wider than the change — it also re-probes boxes the owner never touched. A
224
+ // failure here falls back to the full read, because a stale row on a deploy page is
225
+ // the one thing worse than an extra request.
226
+ async function refreshOne(p) {
227
+ const id = String(p.instance_id);
228
+ const isSelf = view.self && String(view.self.instance_id) === id;
229
+ try {
230
+ const d = await fetchJson(isSelf ? '/provisioning/control-plane' : `/provisioning/instances/${id}/core-upgrade`);
231
+ if (isSelf) view.self = d.preview;
232
+ else view.boxes = view.boxes.map((b) => (String(b.instance_id) === id ? d.preview : b));
233
+ steps.set(p.instance_id, 'preview');
234
+ repaint();
235
+ } catch (e) {
236
+ if (e && e.kind === '401') return;
237
+ await load();
238
+ }
239
+ }
240
+
241
+ // The projects this control plane hosts. `/provisioning/fleet` is the whole roster and
242
+ // needs its own atom; a holder of core.pin.move who lacks it still gets their OWN
243
+ // projects rather than an error, because the pin move itself is gated server-side per
244
+ // instance either way — this choice only decides what the page can LIST.
245
+ async function hostedProjects() {
246
+ try {
247
+ const d = await fetchJson('/provisioning/fleet');
248
+ return d.instances || [];
249
+ } catch (e) {
250
+ if (e && e.kind === '403') {
251
+ const d = await fetchJson('/provisioning/instances');
252
+ return d.instances || [];
253
+ }
254
+ throw e;
255
+ }
256
+ }
257
+
258
+ // Only a RUNNING box can have its pin moved, and the runner refuses every other shape.
259
+ // Filtering here keeps torn-down rows — there are far more of them than live ones — off
260
+ // a page whose whole job is naming what is live.
261
+ const UPGRADEABLE = new Set(['standalone', 'co-tenant']);
262
+ const isLive = (i) => i && i.status === 'active' && UPGRADEABLE.has(i.hosting_shape);
263
+
165
264
  async function load() {
166
265
  try {
167
- const d = await fetchJson('/provisioning/control-plane');
168
- $('dp-sub').textContent = `${d.preview.slug} — the box this platform runs on`;
266
+ let self = null;
267
+ let notEnrolled = null;
268
+ try {
269
+ const d = await fetchJson('/provisioning/control-plane');
270
+ self = d.preview;
271
+ } catch (e) {
272
+ if (e && e.kind === '401') return;
273
+ if (e && e.kind === '403') throw e;
274
+ notEnrolled = e.message || 'This platform has no deploy door yet.';
275
+ }
276
+
277
+ const live = (await hostedProjects()).filter(isLive);
278
+ // One preview per project, in parallel and INDEPENDENTLY: one project that cannot
279
+ // be read must not blank the page for the others. The request count scales with the
280
+ // fleet, which is deliberate and bounded — each is a cached server-side reading, not
281
+ // a live probe of the box, and this page exists precisely to show every box's state
282
+ // at once. It is re-read on OPEN only; a move refreshes just its own row.
283
+ const settled = await Promise.all(live.map(async (inst) => {
284
+ try {
285
+ const d = await fetchJson(`/provisioning/instances/${inst.id}/core-upgrade`);
286
+ return { ok: true, preview: d.preview };
287
+ } catch (e) {
288
+ if (e && e.kind === '401') return { ok: false, inst, message: 'not signed in' };
289
+ return { ok: false, inst, message: (e && e.message) || 'unknown error' };
290
+ }
291
+ }));
292
+
293
+ view = {
294
+ self,
295
+ notEnrolled,
296
+ boxes: settled.filter((s) => s.ok).map((s) => s.preview),
297
+ unreadable: settled.filter((s) => !s.ok).map((s) => ({ inst: s.inst, message: s.message })),
298
+ };
299
+ const n = view.boxes.length + view.unreadable.length;
300
+ $('dp-sub').textContent = n
301
+ ? `This platform and ${n} hosted project${n === 1 ? '' : 's'}`
302
+ : 'This platform';
169
303
  $('dp-body').hidden = false;
170
- render(d.preview, 'preview');
304
+ repaint();
171
305
  } catch (e) {
172
306
  if (e && e.kind === '401') return;
173
307
  if (e && e.kind === '403') {
@@ -175,9 +309,6 @@
175
309
  $('dp-sub').textContent = 'Restricted';
176
310
  return;
177
311
  }
178
- // Including 'control_plane_not_enrolled', whose message names the one command
179
- // that fixes it — which is why the server's sentence is shown rather than a
180
- // generic failure line.
181
312
  $('dp-body').hidden = false;
182
313
  $('dp-sub').textContent = 'Unavailable';
183
314
  $('dp-panel').innerHTML = `<p class="ps-invalid">${escapeHtml(e.message || 'Could not read the deploy state.')}</p>`;
@@ -43,6 +43,19 @@
43
43
  with a verdict band along its top is gone (task 1003469). -->
44
44
  <div class="stat-row ov-summary" id="gate-summary" aria-label="Held changes summary"></div>
45
45
 
46
+ <!-- The Autobongos fence (task 1003908, ADR 0307 §6). It lives on THIS
47
+ page because the Gate is already the owner's protected-surface
48
+ surface, and because the switch and the held PRs answer the same
49
+ question from two ends: what is allowed to reach main unattended.
50
+ Archon-only — the panel stays hidden for a Metic, and the routes
51
+ behind it are system:true/archon server-side regardless. -->
52
+ <section class="scroll" id="fence-panel" aria-labelledby="fence-h" hidden>
53
+ <h2 class="scroll__h" id="fence-h">Autobongos runner</h2>
54
+ <p class="scroll__lede">The unattended runner works only the goals listed here, and only while it is on. This is the switch — it lives on the server, so the machine can read it and cannot change it.</p>
55
+ <div id="fence-state"><span class="kit-skel"></span></div>
56
+ <div id="fence-goals"></div>
57
+ </section>
58
+
46
59
  <section class="scroll" aria-labelledby="gate-list-h">
47
60
  <h2 class="scroll__h" id="gate-list-h">Held pull requests</h2>
48
61
  <p class="scroll__lede">Each one touched a protected surface and stopped at the gate check. Approving records your seal on that commit and re-runs the check.</p>
@@ -65,6 +78,6 @@
65
78
  <script src="/builders/palette.js" defer></script>
66
79
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
67
80
  <script src="/builders/hall-kit.js"></script>
68
- <script src="/builders/gate.js?v=2026-09-02-gate-rows"></script>
81
+ <script src="/builders/gate.js?v=2026-09-19-fence"></script>
69
82
  </body>
70
83
  </html>
@@ -39,10 +39,25 @@
39
39
  const res = await api.request(method, `${API}${path}`, { body: body || {} });
40
40
  if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
41
41
  const data = res.data || {};
42
- if (!res.ok) throw new Error(data.message || data.error || `${path} → ${res.status}`);
42
+ if (!res.ok) throw new Error(errorText(data, path, res.status));
43
43
  return data;
44
44
  }
45
45
 
46
+ // errorText — the readable half of a failed write.
47
+ //
48
+ // `data.error` is the ADR 0116 envelope, an OBJECT of { code, message }, so the
49
+ // old `data.message || data.error` fell through to it and produced the literal
50
+ // string "[object Object]" in the toast. Caught rendering this page against a
51
+ // 501 (task 1003908) — the button reported a failure the operator could not read,
52
+ // which is barely better than reporting nothing.
53
+ function errorText(data, path, status) {
54
+ const env = data && data.error;
55
+ if (env && typeof env === 'object' && typeof env.message === 'string') return env.message;
56
+ if (typeof env === 'string') return env;
57
+ if (typeof data?.message === 'string') return data.message;
58
+ return `${path} → ${status}`;
59
+ }
60
+
46
61
  // ---- formatters ----------------------------------------------------------
47
62
 
48
63
  const shortSha = (s) => (typeof s === 'string' ? s.slice(0, 7) : '');
@@ -222,6 +237,103 @@
222
237
  if (body) body.textContent = `Available from the ${rankLabel('metic')} rank up.`;
223
238
  }
224
239
 
240
+ // ---- the Autobongos fence (task 1003908, ADR 0307 §6) --------------------
241
+ //
242
+ // The kill switch and the goal allowlist for the unattended runner. On this page
243
+ // because the Gate is already where the owner decides what reaches main without
244
+ // a person in the loop; the held-PR list and this panel are the same question
245
+ // from two ends.
246
+ //
247
+ // ARCHON ONLY, and the panel simply is not rendered for anyone else. That is
248
+ // cosmetic: POST /autonomy/fence and the two allowlist routes carry
249
+ // `autonomy.fence.manage`, which the catalog declares system:true + archon, so
250
+ // it cannot be granted to a custom rank. The wall is the server's.
251
+
252
+ function renderFenceState(fence) {
253
+ const on = fence.enabled === true;
254
+ const el = document.getElementById('fence-state');
255
+ el.innerHTML = `
256
+ <div class="ov-foot" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
257
+ <strong>${on ? 'Running' : 'Stopped'}</strong>
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>`;
261
+ document.getElementById('fence-toggle').addEventListener('click', async () => {
262
+ // Turning it OFF asks why. Stopping an unattended machine is the kind of act
263
+ // someone reconstructs weeks later, and "halted" answers none of their
264
+ // questions. Turning it ON needs no reason — the allowlist already is one.
265
+ let reason = null;
266
+ if (on) {
267
+ reason = window.prompt('Why are you stopping the runner? (shown to anyone who asks why it is idle)');
268
+ if (reason === null) return; // cancelled — not the same as an empty reason
269
+ }
270
+ try {
271
+ await sendJson('POST', '/autonomy/fence', { enabled: !on, reason: reason || undefined });
272
+ toast(on ? 'Runner stopped.' : 'Runner started.');
273
+ await loadFence();
274
+ } catch (err) { toast(err.message || 'Could not change the switch.'); }
275
+ });
276
+ }
277
+
278
+ function renderFenceGoals(goals) {
279
+ const el = document.getElementById('fence-goals');
280
+ const rows = (goals || []).map((g) => `
281
+ <li class="ov-row" style="display:flex;align-items:center;gap:12px;">
282
+ <a href="/builders#/goal/${encodeURIComponent(g.goal_id)}">Goal ${escapeHtml(String(g.goal_id))}</a>
283
+ <span style="flex:1;">${escapeHtml(g.note || '')}</span>
284
+ <button type="button" class="btn" data-revoke="${escapeHtml(String(g.goal_id))}">Remove</button>
285
+ </li>`).join('');
286
+ el.innerHTML = `
287
+ <h3 class="scroll__h" style="font-size:1rem;">Allowlisted goals</h3>
288
+ ${goals && goals.length ? `<ul class="ov-rows">${rows}</ul>`
289
+ : emptyStateHtml('No goal is allowlisted, so the runner has nothing it may work on.')}
290
+ <form id="fence-add" class="ov-foot" style="display:flex;gap:8px;align-items:center;margin-top:12px;">
291
+ <label for="fence-goal-id">Allow goal</label>
292
+ <input type="number" id="fence-goal-id" min="1" step="1" required placeholder="1000119">
293
+ <input type="text" id="fence-goal-note" maxlength="500" placeholder="why (optional)">
294
+ <button type="submit" class="btn">Add</button>
295
+ </form>`;
296
+
297
+ document.getElementById('fence-add').addEventListener('submit', async (e) => {
298
+ e.preventDefault();
299
+ const goalId = Number(document.getElementById('fence-goal-id').value);
300
+ if (!Number.isSafeInteger(goalId) || goalId <= 0) { toast('Enter a goal number.'); return; }
301
+ try {
302
+ await sendJson('POST', '/autonomy/fence/goals', { goal_id: goalId, note: document.getElementById('fence-goal-note').value || undefined });
303
+ toast(`Goal ${goalId} allowlisted.`);
304
+ await loadFence();
305
+ } catch (err) { toast(err.message || 'Could not add that goal.'); }
306
+ });
307
+
308
+ for (const btn of el.querySelectorAll('[data-revoke]')) {
309
+ btn.addEventListener('click', async () => {
310
+ const goalId = btn.getAttribute('data-revoke');
311
+ try {
312
+ await sendJson('DELETE', `/autonomy/fence/goals/${encodeURIComponent(goalId)}`);
313
+ toast(`Goal ${goalId} removed.`);
314
+ await loadFence();
315
+ } catch (err) { toast(err.message || 'Could not remove that goal.'); }
316
+ });
317
+ }
318
+ }
319
+
320
+ async function loadFence() {
321
+ let fence;
322
+ try {
323
+ fence = await fetchJson('/autonomy/fence');
324
+ } catch (err) {
325
+ if (err && err.kind === '401') return;
326
+ // A fence the HALL cannot read is only a rendering problem — the RUNNER
327
+ // reads it separately and refuses on the same failure. Say so plainly rather
328
+ // than showing a switch whose position is a guess.
329
+ document.getElementById('fence-state').innerHTML = emptyStateHtml('Could not read the runner switch just now.');
330
+ document.getElementById('fence-goals').innerHTML = '';
331
+ return;
332
+ }
333
+ renderFenceState(fence);
334
+ renderFenceGoals(fence.goals);
335
+ }
336
+
225
337
  async function init() {
226
338
  // Confirm the viewer's rank for a friendly sealed notice; the server gates the page
227
339
  // + every endpoint regardless (cosmetic defence-in-depth).
@@ -242,6 +354,10 @@
242
354
 
243
355
  document.getElementById('gate-sub').textContent = 'Changes touching protected build surfaces, held for your approval.';
244
356
  document.getElementById('gate-body').hidden = false;
357
+ if (rank === 'archon') {
358
+ document.getElementById('fence-panel').hidden = false;
359
+ await loadFence();
360
+ }
245
361
  await load();
246
362
  }
247
363
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.873",
3
+ "version": "1.19.875",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.873",
9
+ "version": "1.19.875",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.873",
3
+ "version": "1.19.875",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",