@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.
- package/.bongos-core.json +104 -49
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0161-publish-on-merge.md +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +258 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +59 -53
- package/docs/copy-registry.json +120 -66
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +86 -81
- package/docs/recipes/private-npm-distribution.md +2 -0
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/cadence.js +3 -0
- package/modules/autonomy/db.js +136 -0
- package/modules/autonomy/fence.js +88 -3
- package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
- package/modules/autonomy/routes/autonomy.js +123 -3
- package/modules/government/catalog.js +9 -1
- package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
- package/modules/hall-ui/public/gate.html +5 -2
- package/modules/hall-ui/public/gate.js +73 -4
- package/modules/hall-ui/public/settings-autobongos.js +156 -0
- package/modules/hall-ui/public/settings.html +19 -0
- package/modules/hall-ui/public/settings.js +1 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/lifecycle.js +7 -0
- package/modules/lifecycle/workflow-dispatch.js +45 -0
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/public/work.js +74 -0
- package/modules/npm-release/release.js +129 -0
- package/modules/npm-release/routes/release.js +65 -0
- package/modules/npm-release/work.js +16 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/provision-core-upgrade.js +30 -2
- package/scripts/gds/release-core.js +80 -0
- package/scripts/gds/update-channel.js +12 -3
- package/src/bongos/core-update.js +17 -8
- package/src/module-api.js +1 -1
- package/tests/autonomy_builder_scope.mjs +474 -0
- package/tests/autonomy_fence_priority.mjs +3 -1
- package/tests/core_update_banner.mjs +41 -5
- package/tests/core_upgrade_runner.mjs +66 -1
- package/tests/npm_release_release.mjs +195 -0
- package/tests/release_core.mjs +110 -0
- 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
|
|
238
|
+
code,
|
|
154
239
|
reason: refused.length
|
|
155
240
|
? `${REFUSALS.goal_not_allowlisted} (asked for ${refused.join(', ')})`
|
|
156
|
-
: REFUSALS
|
|
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
|
|
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 (!
|
|
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">
|
|
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-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
|
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
|
}
|