@bongos/core 1.20.79 → 1.20.81

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 (53) hide show
  1. package/.bongos-core.json +104 -49
  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 +13 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0161-publish-on-merge.md +1 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +59 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +258 -4
  11. package/docs/api-reference.md +7 -3
  12. package/docs/copy-inventory.md +59 -53
  13. package/docs/copy-registry.json +120 -66
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +2 -1
  16. package/docs/page-readings.json +86 -81
  17. package/docs/recipes/private-npm-distribution.md +2 -0
  18. package/docs/recipes/upgrading-the-core.md +1 -1
  19. package/modules/autonomy/cadence.js +3 -0
  20. package/modules/autonomy/db.js +136 -0
  21. package/modules/autonomy/fence.js +88 -3
  22. package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
  23. package/modules/autonomy/routes/autonomy.js +123 -3
  24. package/modules/government/catalog.js +9 -1
  25. package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
  26. package/modules/hall-ui/public/gate.html +5 -2
  27. package/modules/hall-ui/public/gate.js +73 -4
  28. package/modules/hall-ui/public/settings-autobongos.js +156 -0
  29. package/modules/hall-ui/public/settings.html +19 -0
  30. package/modules/hall-ui/public/settings.js +1 -0
  31. package/modules/lifecycle/module.json +2 -1
  32. package/modules/lifecycle/routes/lifecycle.js +7 -0
  33. package/modules/lifecycle/workflow-dispatch.js +45 -0
  34. package/modules/npm-release/module.json +2 -1
  35. package/modules/npm-release/public/work.js +74 -0
  36. package/modules/npm-release/release.js +129 -0
  37. package/modules/npm-release/routes/release.js +65 -0
  38. package/modules/npm-release/work.js +16 -0
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +16 -0
  42. package/scripts/gds/provision-core-upgrade.js +30 -2
  43. package/scripts/gds/release-core.js +80 -0
  44. package/scripts/gds/update-channel.js +12 -3
  45. package/src/bongos/core-update.js +17 -8
  46. package/src/module-api.js +1 -1
  47. package/tests/autonomy_builder_scope.mjs +474 -0
  48. package/tests/autonomy_fence_priority.mjs +3 -1
  49. package/tests/core_update_banner.mjs +41 -5
  50. package/tests/core_upgrade_runner.mjs +66 -1
  51. package/tests/npm_release_release.mjs +195 -0
  52. package/tests/release_core.mjs +110 -0
  53. package/tests/update_channel.mjs +7 -0
@@ -49,8 +49,78 @@ const REFUSALS = {
49
49
  goal_not_allowlisted: 'that goal is not on the allowlist',
50
50
  protected_path: 'the task declares work on a permission, migration, infrastructure or deploy-pipeline surface',
51
51
  grader_unavailable: 'the grader is bypassed, and it is the only review between an unattended worker and main',
52
+ not_permitted: 'your rank does not hold autonomy.run, so your runner may not work on this project',
53
+ builder_paused: 'the owner paused your runner',
54
+ no_builder_scope: 'you have not picked any goals for your runner — choose them in Settings',
52
55
  };
53
56
 
57
+ // The stop codes effectiveFence may set, and the refusal each one becomes.
58
+ const STOP_CODES = new Set(['kill_switch', 'not_permitted', 'builder_paused']);
59
+
60
+ // effectiveFence — the fence ONE builder's runner reads (task 1004501).
61
+ //
62
+ // The project fence is one switch and one allowlist; each builder narrows it to the
63
+ // goals they picked, and the owner can pause one builder without throwing the
64
+ // master switch. This composes the three on the SERVER, so the runner stays dumb:
65
+ // it reads `enabled` and `goals` exactly as before and cannot compute its own
66
+ // widening, and a runner that predates this change gets the narrowing for free.
67
+ //
68
+ // The order is the order an operator asks "why is it idle": the project switch
69
+ // first (it stops everyone), then whether this builder may run at all, then the
70
+ // owner's pause on this builder. Each one wins over the ones after it.
71
+ //
72
+ // Empty picks mean NO goals, by owner ruling (2026-10-01): a builder who has set
73
+ // nothing gets no autonomous work. Never "the whole allowlist".
74
+ //
75
+ // `project` is db.readFence()'s shape; `scope` is db.readBuilderScope()'s
76
+ // ({ goals:[ids], owner_paused, owner_paused_reason, updated_at }) or null for a
77
+ // builder with no row; `mayRun` is the autonomy.run answer, where anything but
78
+ // `true` refuses. Returns null when the project fence is null (unreadable).
79
+ function effectiveFence({ project, scope = null, mayRun = false } = {}) {
80
+ if (!project || typeof project !== 'object') return null;
81
+ const picks = new Set(((scope && scope.goals) || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0));
82
+ const projectGoals = Array.isArray(project.goals) ? project.goals : [];
83
+ const goals = projectGoals.filter((g) => picks.has(Number(g && g.goal_id)));
84
+ const ids = goals.map((g) => Number(g.goal_id));
85
+ const priority = project.priority_goal_id != null && ids.includes(Number(project.priority_goal_id))
86
+ ? Number(project.priority_goal_id) : null;
87
+
88
+ let enabled = project.enabled === true;
89
+ let stopCode = enabled ? null : 'kill_switch';
90
+ let pausedReason = enabled ? null : (project.paused_reason || null);
91
+ if (enabled && mayRun !== true) {
92
+ enabled = false; stopCode = 'not_permitted'; pausedReason = REFUSALS.not_permitted;
93
+ } else if (enabled && scope && scope.owner_paused === true) {
94
+ enabled = false; stopCode = 'builder_paused';
95
+ pausedReason = scope.owner_paused_reason ? `${REFUSALS.builder_paused}: ${scope.owner_paused_reason}` : REFUSALS.builder_paused;
96
+ }
97
+
98
+ // The later of the two rows, so a pick or a pause wakes a runner that waits on
99
+ // the fence's updated_at exactly as a project change does.
100
+ const stamps = [project.updated_at, scope && scope.updated_at].filter(Boolean).map((t) => new Date(t).getTime()).filter(Number.isFinite);
101
+ const updatedAt = stamps.length ? new Date(Math.max(...stamps)).toISOString() : (project.updated_at || null);
102
+
103
+ return {
104
+ enabled,
105
+ paused_reason: pausedReason,
106
+ stop_code: stopCode,
107
+ priority_goal_id: priority,
108
+ updated_at: updatedAt,
109
+ updated_by: project.updated_by ?? null,
110
+ goals,
111
+ // What the caller chose and may do, so the Settings panel and the runner log
112
+ // can say WHY the goals are what they are without a second read.
113
+ scope: {
114
+ may_run: mayRun === true,
115
+ goals: [...picks],
116
+ owner_paused: !!(scope && scope.owner_paused === true),
117
+ owner_paused_reason: (scope && scope.owner_paused_reason) || null,
118
+ },
119
+ // The unnarrowed project fence, for the owner's gate page.
120
+ project,
121
+ };
122
+ }
123
+
54
124
  // normaliseFence — turn whatever the route returned into something decidable, or
55
125
  // null for "unreadable". Deliberately strict: a shape this does not recognise is
56
126
  // unreadable, never a permissive default.
@@ -81,9 +151,18 @@ function normaliseFence(raw) {
81
151
  // the old behaviour, not a refusal: the allowlist alone is still a safe answer.
82
152
  const p = Number(raw.priority_goal_id);
83
153
  const priorityGoal = raw.priority_goal_id != null && Number.isSafeInteger(p) && p > 0 && ids.includes(p) ? p : null;
154
+ // Which layer turned it off (task 1004501). Only the known codes are honoured;
155
+ // anything else, including a server that predates them, reads as the kill switch,
156
+ // which refuses just the same.
157
+ const stopCode = STOP_CODES.has(raw.stop_code) ? raw.stop_code : 'kill_switch';
158
+ // A builder who has picked nothing gets nothing (owner ruling, 2026-10-01). Said
159
+ // as its own refusal so the log does not blame an empty project allowlist.
160
+ const noScope = !!(raw.scope && Array.isArray(raw.scope.goals) && raw.scope.goals.length === 0);
84
161
  return {
85
162
  enabled: raw.enabled === true,
86
163
  pausedReason: typeof raw.paused_reason === 'string' ? raw.paused_reason : null,
164
+ stopCode,
165
+ noScope,
87
166
  goals: ids,
88
167
  priorityGoal,
89
168
  };
@@ -129,6 +208,11 @@ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null
129
208
  if (!fence) return { go: false, code: 'fence_unreadable', reason: REFUSALS.fence_unreadable, goals: [] };
130
209
 
131
210
  if (!fence.enabled) {
211
+ // The builder-level stops carry their full sentence already (effectiveFence
212
+ // wrote it); only the project switch is prefixed, as before.
213
+ if (fence.stopCode !== 'kill_switch') {
214
+ return { go: false, code: fence.stopCode, goals: [], reason: fence.pausedReason || REFUSALS[fence.stopCode] };
215
+ }
132
216
  return {
133
217
  go: false, code: 'kill_switch', goals: [],
134
218
  reason: fence.pausedReason ? `${REFUSALS.kill_switch}: ${fence.pausedReason}` : REFUSALS.kill_switch,
@@ -148,12 +232,13 @@ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null
148
232
 
149
233
  const { goals, refused } = allowedGoals(fence, requestedGoals);
150
234
  if (!goals.length) {
235
+ const code = refused.length ? 'goal_not_allowlisted' : (fence.noScope ? 'no_builder_scope' : 'no_allowlist');
151
236
  return {
152
237
  go: false, goals: [], refusedGoals: refused,
153
- code: refused.length ? 'goal_not_allowlisted' : 'no_allowlist',
238
+ code,
154
239
  reason: refused.length
155
240
  ? `${REFUSALS.goal_not_allowlisted} (asked for ${refused.join(', ')})`
156
- : REFUSALS.no_allowlist,
241
+ : REFUSALS[code],
157
242
  };
158
243
  }
159
244
  // priorityGoal is reported only when it survived the intersection, so a row can
@@ -191,4 +276,4 @@ function decideTask({ task, goals = [], protectedHits = [] } = {}) {
191
276
  return { go: true, code: null, reason: null };
192
277
  }
193
278
 
194
- module.exports = { REFUSALS, normaliseFence, prioritise, allowedGoals, decideRun, decideTask };
279
+ module.exports = { REFUSALS, normaliseFence, prioritise, allowedGoals, effectiveFence, decideRun, decideTask };
@@ -0,0 +1,55 @@
1
+ -- autonomy_005_builder_scope.sql — each builder's own runner scope, and the
2
+ -- owner's per-builder pause (task 1004501).
3
+ --
4
+ -- Owner ruling, 2026-10-01: every builder says which work THEIR runner takes, and
5
+ -- a builder who has said nothing gets NO autonomous work. Until now the fence was
6
+ -- one switch and one list for every runner on the instance, so the owner could not
7
+ -- stop one builder's machine without stopping all of them, and a builder could not
8
+ -- narrow theirs to the goals they care about.
9
+ --
10
+ -- A NARROWING, NEVER A WIDENING. A builder's picks are a subset of the project
11
+ -- allowlist, and the database holds that rather than a route remembering it:
12
+ -- goal_id references autonomy_allowed_goals, so a goal that is not allowlisted
13
+ -- cannot be picked at all. ON DELETE CASCADE is the safe direction: revoking a
14
+ -- goal from the project removes it from every builder's picks, and re-allowing it
15
+ -- later does NOT silently put it back. The runner's effective goals are
16
+ -- project allowlist ∩ the caller's picks, computed on the server.
17
+ --
18
+ -- ON THE SERVER, for autonomy_001's reason. These rows bound what a machine nobody
19
+ -- is watching may do; a pick list in a file on that machine would be writable by
20
+ -- the very worker it bounds. The builder writes their own picks through an
21
+ -- authenticated route keyed by their session, never by a body field.
22
+ --
23
+ -- autonomy_builder_scope holds the one row per builder that is not a list:
24
+ -- owner_paused the owner stopped THIS builder's runner. Written only by
25
+ -- the autonomy.fence.manage route; the builder cannot clear
26
+ -- it, because a pause the paused party can lift is not one.
27
+ -- owner_paused_reason why, shown to the builder and printed by their runner.
28
+ -- owner_paused_by who.
29
+ -- updated_at bumped on every pick or pause change. The route folds it
30
+ -- into the fence's updated_at, which is how a change wakes
31
+ -- a runner that is waiting for a signal.
32
+ --
33
+ -- No foreign key to builders, like autonomy_002: a row for a removed builder is
34
+ -- inert, because no session can ever read it.
35
+ --
36
+ -- Additive and idempotent (ADR 0083 §Decision #5): safe to re-run.
37
+
38
+ BEGIN;
39
+
40
+ CREATE TABLE IF NOT EXISTS autonomy_builder_goals (
41
+ builder_id bigint NOT NULL,
42
+ goal_id bigint NOT NULL REFERENCES autonomy_allowed_goals (goal_id) ON DELETE CASCADE,
43
+ added_at timestamptz NOT NULL DEFAULT now(),
44
+ PRIMARY KEY (builder_id, goal_id)
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS autonomy_builder_scope (
48
+ builder_id bigint PRIMARY KEY,
49
+ owner_paused boolean NOT NULL DEFAULT false,
50
+ owner_paused_reason text,
51
+ owner_paused_by bigint,
52
+ updated_at timestamptz NOT NULL DEFAULT now()
53
+ );
54
+
55
+ COMMIT;
@@ -28,6 +28,7 @@ const auth = api;
28
28
  // neither core internals nor a sibling module, so importing it here is allowed.
29
29
  const autonomyGate = require('../../../scripts/gds/autonomy-gate');
30
30
  const db = require('../db');
31
+ const fence = require('../fence');
31
32
  const runnerHealth = require('../runner-health');
32
33
  // The doorway's namespaced logger and strict body validator. Raw console.* is
33
34
  // ratcheted repo-wide (fitness.js console_call_count) and unstructured besides.
@@ -52,6 +53,49 @@ function runsLimit(raw) {
52
53
  return Math.min(RUNS_MAX_LIMIT, Math.max(1, n));
53
54
  }
54
55
 
56
+ // holdsPermission — does this builder hold `key`? Asked of the government port
57
+ // rather than mounted as a gate, because GET /autonomy/fence must answer EVERY
58
+ // builder (a runner must always be able to learn it should stop); only the answer
59
+ // changes. Fails closed: no port, or a resolver that throws, reads as "not held",
60
+ // which turns the caller's fence off rather than on. The rot.js precedent.
61
+ //
62
+ // THE FOLD FIRST. This runs on the runner's read, every iteration of every runner,
63
+ // so it must not add a query. requireBuilder already carries the session's grant
64
+ // keys (`req.rawPermissionKeys`, the session-grants fold of task 1002556), and the
65
+ // government port folds them into the held set in memory — the same path
66
+ // requirePermission takes. The DB resolve is only the fallback for a session row
67
+ // that did not carry the keys, exactly as in requirePermission.
68
+ async function holdsPermission(req, key) {
69
+ try {
70
+ const governance = api.resolveOptional('government');
71
+ if (!governance) return false;
72
+ if (Array.isArray(req.rawPermissionKeys) && typeof governance.effectivePermissions === 'function') {
73
+ try {
74
+ return new Set(governance.effectivePermissions(req.rawPermissionKeys)).has(key);
75
+ } catch (err) {
76
+ log.error(`autonomy: permission fold failed for ${key} — falling back to the resolve: ${err && err.message}`);
77
+ }
78
+ }
79
+ if (typeof governance.builderHasPermissions !== 'function') return false;
80
+ return (await governance.builderHasPermissions(Number(req.builder.id), [key])) === true;
81
+ } catch (err) {
82
+ log.error(`autonomy: permission resolve failed for ${key} — denying fail-closed: ${err && err.message}`);
83
+ return false;
84
+ }
85
+ }
86
+
87
+ // goalIdsOrNull — a list of positive integer goal ids, or null for a malformed one.
88
+ function goalIdsOrNull(v) {
89
+ if (!Array.isArray(v) || v.length > 200) return null;
90
+ const out = [];
91
+ for (const g of v) {
92
+ const n = Number(g);
93
+ if (!Number.isSafeInteger(n) || n <= 0) return null;
94
+ out.push(n);
95
+ }
96
+ return out;
97
+ }
98
+
55
99
  // commitOrNull — a full lowercase commit sha, or null (task 1004407).
56
100
  function commitOrNull(v) {
57
101
  return typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : null;
@@ -102,15 +146,24 @@ module.exports = function buildAutonomyRouter() {
102
146
  // you must stop should never require an elevated session, because the failure
103
147
  // mode of "could not read the fence" is a machine that keeps going. The body
104
148
  // carries no secret — it is a boolean, a reason string and a list of goal ids.
149
+ //
150
+ // THE CALLER'S fence, not the project's (task 1004501). The top-level `enabled`
151
+ // and `goals` are what THIS builder's runner may do: the project switch, then
152
+ // `autonomy.run`, then the owner's pause on this builder, and the allowlist
153
+ // narrowed to their own picks (fence.js effectiveFence). Computing it here keeps
154
+ // the runner dumb and means a runner that predates this change is narrowed too.
155
+ // The unnarrowed project fence rides along as `project` for the owner's page.
105
156
  router.get('/autonomy/fence', auth.requireBuilder, async (req, res) => {
106
157
  try {
107
- const fence = await db.readFence();
158
+ const [project, scope, mayRun] = await Promise.all([
159
+ db.readFence(), db.readBuilderScope(req.builder.id), holdsPermission(req, 'autonomy.run'),
160
+ ]);
108
161
  // Null means the singleton row is missing, i.e. the migration has not run.
109
162
  // Say so rather than synthesising a default: a fabricated { enabled: false }
110
163
  // is indistinguishable from a real one, and an operator would chase a kill
111
164
  // switch nobody threw.
112
- if (!fence) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
113
- res.json(fence);
165
+ if (!project) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
166
+ res.json(fence.effectiveFence({ project, scope, mayRun }));
114
167
  } catch (err) {
115
168
  log.error('[gds] GET /autonomy/fence', err);
116
169
  // FAIL CLOSED, unlike the precheck above. That one is fail-open because an
@@ -202,6 +255,73 @@ module.exports = function buildAutonomyRouter() {
202
255
  }
203
256
  });
204
257
 
258
+ // ── each builder's own runner scope (task 1004501) ─────────────────────────
259
+
260
+ // A builder picks which allowlisted goals THEIR runner works. Replaces the whole
261
+ // list; `[]` means "my runner takes no work". Keyed by the session, never the
262
+ // body, so nobody can set another builder's picks. Behind `autonomy.run`: a
263
+ // builder who may not run a runner has nothing to scope. A goal not on the
264
+ // project allowlist is refused with 409 and nothing is written — the pick can
265
+ // narrow the project fence and never widen it.
266
+ router.put('/autonomy/me/goals', auth.requireBuilder, auth.requirePermission('autonomy.run'), async (req, res) => {
267
+ if (validateOrRespond(req, res, { goal_ids: { required: true } })) return;
268
+ const goalIds = goalIdsOrNull(req.body && req.body.goal_ids);
269
+ if (!goalIds) return res.fail('goal_ids_required', { status: 400, message: 'pass { "goal_ids": [<number>, …] } — an empty list means no work' });
270
+ try {
271
+ const out = await db.setBuilderGoals({ builderId: req.builder.id, goalIds });
272
+ if (out.notAllowed) return res.fail('goal_not_allowlisted', { status: 409, message: `not on the project allowlist: ${out.notAllowed.join(', ')}` });
273
+ log.info(`[gds] autonomy scope: builder ${req.builder.id} picked goals [${out.goals.join(', ')}]`);
274
+ res.json({ goals: out.goals });
275
+ } catch (err) {
276
+ log.error('[gds] PUT /autonomy/me/goals', err);
277
+ res.fail('scope_write_failed', { status: 500, message: 'internal error' });
278
+ }
279
+ });
280
+
281
+ // The owner pauses or resumes ONE builder's runner without throwing the master
282
+ // switch. Same atom as the kill switch: stopping a machine is the same act
283
+ // whether it is everyone's or one person's, and the paused builder must not be
284
+ // able to lift it (autonomy.run does not reach this route).
285
+ router.post('/autonomy/builders/:builderId/pause', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
286
+ if (validateOrRespond(req, res, {
287
+ paused: { required: true, type: 'boolean' },
288
+ reason: { type: 'string', maxLength: 500 },
289
+ })) return;
290
+ const builderId = Number(req.params.builderId);
291
+ if (!Number.isSafeInteger(builderId) || builderId <= 0) return res.fail('bad_builder_id', 400);
292
+ const reason = typeof req.body.reason === 'string' ? req.body.reason : null;
293
+ try {
294
+ const row = await db.setBuilderPause({ builderId, paused: req.body.paused, reason, byBuilderId: req.builder.id });
295
+ if (!row) return res.fail('builder_not_found', { status: 404, message: `no builder ${builderId}` });
296
+ log.info(`[gds] autonomy: builder ${builderId}'s runner ${req.body.paused ? 'PAUSED' : 'RESUMED'} by builder ${req.builder.id} (${req.builder.rank})${reason ? `: ${reason}` : ''}`);
297
+ res.json(row);
298
+ } catch (err) {
299
+ log.error('[gds] POST /autonomy/builders/:builderId/pause', err);
300
+ res.fail('pause_write_failed', { status: 500, message: 'internal error' });
301
+ }
302
+ });
303
+
304
+ // The owner's view of every builder's runners: who, which goals they picked,
305
+ // whether they are paused, and each runner's health — the server's verdict, as
306
+ // GET /autonomy/runners attaches it. Owner-only: a heartbeat names a machine
307
+ // and its hostname, which is each builder's own business (task 1003905), and the
308
+ // owner is the one person who needs every row to run the fence.
309
+ router.get('/autonomy/runners/all', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
310
+ try {
311
+ const rows = await db.readAllRunnerScopes();
312
+ const nowMs = Date.now();
313
+ res.json({
314
+ builders: rows.map((b) => ({
315
+ ...b,
316
+ runners: b.runners.map((r) => ({ ...r, health: runnerHealth.describeRunner(r, { nowMs }) })),
317
+ })),
318
+ });
319
+ } catch (err) {
320
+ log.error('[gds] GET /autonomy/runners/all', err);
321
+ res.fail('runners_unreadable', { status: 503, message: 'could not read the runner scopes' });
322
+ }
323
+ });
324
+
205
325
  // ── the runner heartbeat (task 1003905) ────────────────────────────────────
206
326
  //
207
327
  // A dead runner must be VISIBLE, not silent. The runner also writes a heartbeat
@@ -119,7 +119,15 @@ const PERMISSIONS = [
119
119
  // suggestion. Deliberately NOT paired with a read permission: seeing whether the
120
120
  // runner is on is harmless, and GET /autonomy/fence is any-builder so the runner
121
121
  // itself does not need an elevated session merely to learn it must stop.
122
- { key: 'autonomy.fence.manage', system: true, floor: 'archon', guards: 'POST /autonomy/fence (the kill switch), POST|DELETE /autonomy/fence/goals (the allowlist) — the Autobongos fence (ADR 0307 §6)' },
122
+ { key: 'autonomy.fence.manage', system: true, floor: 'archon', guards: 'POST /autonomy/fence (the kill switch), POST|DELETE /autonomy/fence/goals (the allowlist), POST /autonomy/builders/:id/pause (one builder\'s runner), GET /autonomy/runners/all — the Autobongos fence (ADR 0307 §6)' },
123
+ // autonomy.run (task 1004501) — WHO MAY RUN A RUNNER AT ALL. Held, a builder may
124
+ // pick which allowlisted goals their own runner works, and GET /autonomy/fence
125
+ // hands their runner those goals; not held, the fence they read is off. NOT
126
+ // system: it is operational, and an owner who wants a custom rank of
127
+ // "runner operators" should be able to compose one. It can never widen the fence
128
+ // — the project allowlist and the kill switch stay behind autonomy.fence.manage —
129
+ // so delegating it delegates only "whose machine may work the allowed goals".
130
+ { key: 'autonomy.run', system: false, floor: 'metic', guards: 'PUT /autonomy/me/goals (a builder\'s own runner scope); also decides whether GET /autonomy/fence answers enabled for the caller' },
123
131
  // BV1.R21 (task 1003608, goal 1000086). ADR 0250 §6 amends ADR 0157 for exactly
124
132
  // this key: `version.create` returns to the Archon floor.
125
133
  //
@@ -0,0 +1,38 @@
1
+ -- government_022_autonomy_run.sql — grant the new `autonomy.run` atom to Metic and
2
+ -- Archon (task 1004501).
3
+ --
4
+ -- WHAT IT IS. Whether a builder may run an Autobongos runner at all: pick the
5
+ -- allowlisted goals their own runner works, and be answered `enabled` when their
6
+ -- runner reads GET /autonomy/fence. Without it, a builder's runner reads a fence
7
+ -- that is off for them, whatever the project switch says.
8
+ --
9
+ -- METIC FLOOR. A runner spawns sessions with blanket bypassPermissions on the
10
+ -- builder's own machine, and sub-Metic builders stay out of the build pipeline
11
+ -- (ADR 0043). NOT system: an owner may add it to a custom rank. That delegates
12
+ -- only whose machine may work the allowed goals — the allowlist and the kill
13
+ -- switch stay behind the archon-only `autonomy.fence.manage`.
14
+ --
15
+ -- WHY THIS FILE EXISTS AT ALL. RANK_SEED is derived from the catalog's floors and
16
+ -- tests/government_seed.mjs compares it against the run-once seed plus every later
17
+ -- grant migration, per rank. A catalog row without this migration leaves the key
18
+ -- held on paper and not in the database.
19
+ --
20
+ -- Additive only; idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
21
+ -- The `SELECT '<rank>', unnest(ARRAY[…])` shape is load-bearing: the drift guard
22
+ -- scans for it literally.
23
+
24
+ BEGIN;
25
+
26
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
27
+ SELECT 'metic', unnest(ARRAY[
28
+ 'autonomy.run'
29
+ ])
30
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
31
+
32
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
33
+ SELECT 'archon', unnest(ARRAY[
34
+ 'autonomy.run'
35
+ ])
36
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
37
+
38
+ COMMIT;
@@ -51,12 +51,15 @@
51
51
  behind it are system:true/archon server-side regardless. -->
52
52
  <section class="scroll" id="fence-panel" aria-labelledby="fence-h" hidden>
53
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>
54
+ <p class="scroll__lede">Runners work only the goals listed here, and only while this switch is on. Each builder then picks which of these goals their own runner takes, and you can pause any one builder's runner. All of it lives on the server, so a runner's machine can read it and cannot change it.</p>
55
55
  <!-- Liveness first (task 1003905): "is it running at all" is the
56
56
  question before "is it allowed to run". -->
57
57
  <div id="fence-runner"><span class="kit-skel"></span></div>
58
58
  <div id="fence-state"><span class="kit-skel"></span></div>
59
59
  <div id="fence-goals"></div>
60
+ <!-- Every builder's runner (task 1004501): what each one picked to
61
+ work, whether you paused it, and whether it is checking in. -->
62
+ <div id="fence-builders"></div>
60
63
  </section>
61
64
 
62
65
  <section class="scroll" aria-labelledby="gate-list-h">
@@ -81,6 +84,6 @@
81
84
  <script src="/builders/palette.js" defer></script>
82
85
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
83
86
  <script src="/builders/hall-kit.js"></script>
84
- <script src="/builders/gate.js?v=2026-09-19-runner"></script>
87
+ <script src="/builders/gate.js?v=2026-10-01-builder-runners"></script>
85
88
  </body>
86
89
  </html>
@@ -423,8 +423,77 @@
423
423
  document.getElementById('fence-goals').innerHTML = '';
424
424
  return;
425
425
  }
426
- renderFenceState(fence);
427
- renderFenceGoals(fence.goals, fence.priority_goal_id);
426
+ // GET /autonomy/fence answers with the CALLER'S fence (task 1004501): the
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);
434
+ }
435
+
436
+ // ---- every builder's runner (task 1004501) --------------------------------
437
+ //
438
+ // One row per builder who has picked goals or whose runner has checked in:
439
+ // what they picked, whether the owner paused them, and each runner's health
440
+ // (the server's verdict, the same one loadRunners draws). Pause asks why, like
441
+ // the master switch, because a stopped machine is reconstructed weeks later.
442
+ function builderRowHtml(b) {
443
+ const name = b.display_name || b.github_login || `builder ${b.builder_id}`;
444
+ const goals = (b.goals || []).length
445
+ ? `works goal${b.goals.length === 1 ? '' : 's'} ${b.goals.map((g) => escapeHtml(String(g))).join(', ')}`
446
+ : 'has picked no goals, so their runner takes no work';
447
+ const beats = (b.runners || []).map((r) => {
448
+ const h = r.health || {};
449
+ const cls = HEALTH_CLASS[h.state] || 'fact-pill--info';
450
+ const label = { dead: 'Not checking in', late: 'Quiet', alive: 'Checking in', restarting: 'Restarting' }[h.state] || 'Unknown';
451
+ return `<span class="fact-pill ${cls}">${escapeHtml(label)}</span><span class="ov-fact">${escapeHtml(h.host || r.host || '')}</span>`;
452
+ }).join('');
453
+ const paused = b.owner_paused === true;
454
+ return `
455
+ <li class="ov-row" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
456
+ <strong>${escapeHtml(name)}</strong>
457
+ ${b.rank ? `<span class="ov-fact">${escapeHtml(rankLabel(b.rank))}</span>` : ''}
458
+ ${paused ? '<span class="fact-pill fact-pill--warn">Paused</span>' : ''}
459
+ <span style="flex:1;">${goals}${paused && b.owner_paused_reason ? ` — paused: ${escapeHtml(b.owner_paused_reason)}` : ''}</span>
460
+ ${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>
462
+ </li>`;
463
+ }
464
+
465
+ async function loadBuilderRunners() {
466
+ const el = document.getElementById('fence-builders');
467
+ if (!el) return;
468
+ let resp;
469
+ try {
470
+ resp = await fetchJson('/autonomy/runners/all');
471
+ } catch (err) {
472
+ if (err && err.kind === '401') return;
473
+ el.innerHTML = emptyStateHtml('Could not read each builder’s runner just now.');
474
+ return;
475
+ }
476
+ const builders = resp.builders || [];
477
+ el.innerHTML = `
478
+ <h3 class="scroll__h" style="font-size:1rem;margin-top:24px;">Each builder's runner</h3>
479
+ ${builders.length ? `<ul class="ov-rows">${builders.map(builderRowHtml).join('')}</ul>`
480
+ : emptyStateHtml('No builder has picked goals or run a runner yet.')}`;
481
+ for (const btn of el.querySelectorAll('[data-pause]')) {
482
+ btn.addEventListener('click', async () => {
483
+ const id = btn.getAttribute('data-pause');
484
+ const pausing = btn.getAttribute('data-paused') !== '1';
485
+ let reason = null;
486
+ if (pausing) {
487
+ reason = window.prompt('Why are you pausing this builder’s runner? (shown to them)');
488
+ if (reason === null) return;
489
+ }
490
+ try {
491
+ await sendJson('POST', `/autonomy/builders/${encodeURIComponent(id)}/pause`, { paused: pausing, reason: reason || undefined });
492
+ toast(pausing ? 'Runner paused.' : 'Runner resumed.');
493
+ await loadBuilderRunners();
494
+ } catch (err) { toast(err.message || 'Could not change that runner.'); }
495
+ });
496
+ }
428
497
  }
429
498
 
430
499
  async function init() {
@@ -449,8 +518,8 @@
449
518
  document.getElementById('gate-body').hidden = false;
450
519
  if (rank === 'archon') {
451
520
  document.getElementById('fence-panel').hidden = false;
452
- // Independent reads, so one round trip rather than two.
453
- await Promise.all([loadRunners(), loadFence()]);
521
+ // Independent reads, so one round trip rather than three.
522
+ await Promise.all([loadRunners(), loadFence(), loadBuilderRunners()]);
454
523
  }
455
524
  await load();
456
525
  }