@bongos/core 1.20.80 → 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 +53 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +10 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +200 -4
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +59 -53
- package/docs/copy-registry.json +120 -66
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +86 -81
- 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/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/autonomy_builder_scope.mjs +474 -0
- package/tests/autonomy_fence_priority.mjs +3 -1
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// tests/autonomy_builder_scope.mjs — each builder's own runner scope, and the
|
|
2
|
+
// project-level runner permissions (task 1004501).
|
|
3
|
+
//
|
|
4
|
+
// Owner ruling, 2026-10-01: every builder picks which work THEIR runner takes; a
|
|
5
|
+
// builder who has picked nothing gets NO autonomous work; who may run a runner at
|
|
6
|
+
// all is a project permission (`autonomy.run`); and the owner can pause one
|
|
7
|
+
// builder's runner without throwing the master switch. The server composes all of
|
|
8
|
+
// it into the fence the runner reads, so the runner stays dumb and cannot widen
|
|
9
|
+
// its own fence.
|
|
10
|
+
//
|
|
11
|
+
// The pure composition is driven directly; the routes are driven as REAL requests
|
|
12
|
+
// through the assembled router with only the auth gates stubbed (the
|
|
13
|
+
// autonomy_fence_priority precedent); the SQL runs against a stubbed pool.
|
|
14
|
+
//
|
|
15
|
+
// DB-free, no network. Run: node --test tests/autonomy_builder_scope.mjs
|
|
16
|
+
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const req = createRequire(import.meta.url);
|
|
26
|
+
const express = req('express');
|
|
27
|
+
const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
28
|
+
const fence = req(path.join(ROOT, 'modules/autonomy/fence.js'));
|
|
29
|
+
const cadence = req(path.join(ROOT, 'modules/autonomy/cadence.js'));
|
|
30
|
+
|
|
31
|
+
const PROJECT = Object.freeze({
|
|
32
|
+
enabled: true, paused_reason: null, priority_goal_id: 1000118,
|
|
33
|
+
updated_at: '2026-10-01T09:53:08.874Z', updated_by: '3',
|
|
34
|
+
goals: [{ goal_id: 1000118, note: 'bug squash' }, { goal_id: 1000090, note: null }],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── the pure composition ─────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
test('picks NARROW the project allowlist and can never add to it', () => {
|
|
40
|
+
const f = fence.effectiveFence({ project: PROJECT, scope: { goals: [1000090, 4242] }, mayRun: true });
|
|
41
|
+
assert.equal(f.enabled, true);
|
|
42
|
+
assert.deepEqual(f.goals.map((g) => g.goal_id), [1000090], 'a picked goal the project does not allow is dropped');
|
|
43
|
+
assert.equal(f.priority_goal_id, null, 'the priority is not in the picks, so there is none');
|
|
44
|
+
assert.deepEqual(f.project, PROJECT, 'the owner page still gets the unnarrowed fence');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a builder who has picked nothing gets NO goals (owner ruling), never the whole allowlist', () => {
|
|
48
|
+
for (const scope of [null, { goals: [] }]) {
|
|
49
|
+
const f = fence.effectiveFence({ project: PROJECT, scope, mayRun: true });
|
|
50
|
+
assert.deepEqual(f.goals, [], `scope=${JSON.stringify(scope)}`);
|
|
51
|
+
const d = fence.decideRun({ fence: f, graderBypassed: false });
|
|
52
|
+
assert.equal(d.go, false);
|
|
53
|
+
assert.equal(d.code, 'no_builder_scope');
|
|
54
|
+
assert.match(d.reason, /Settings/, 'the runner log says where to fix it');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the three stops win in the order an operator asks "why is it idle"', () => {
|
|
59
|
+
const off = fence.effectiveFence({ project: { ...PROJECT, enabled: false, paused_reason: 'night off' }, scope: { goals: [1000118], owner_paused: true }, mayRun: false });
|
|
60
|
+
assert.equal(off.stop_code, 'kill_switch', 'the project switch stops everyone first');
|
|
61
|
+
assert.equal(fence.decideRun({ fence: off, graderBypassed: false }).code, 'kill_switch');
|
|
62
|
+
|
|
63
|
+
const noPerm = fence.effectiveFence({ project: PROJECT, scope: { goals: [1000118], owner_paused: true }, mayRun: false });
|
|
64
|
+
assert.equal(noPerm.enabled, false);
|
|
65
|
+
assert.equal(noPerm.stop_code, 'not_permitted', 'then whether this builder may run at all');
|
|
66
|
+
assert.equal(fence.decideRun({ fence: noPerm, graderBypassed: false }).code, 'not_permitted');
|
|
67
|
+
|
|
68
|
+
const paused = fence.effectiveFence({ project: PROJECT, scope: { goals: [1000118], owner_paused: true, owner_paused_reason: 'too many PRs' }, mayRun: true });
|
|
69
|
+
assert.equal(paused.stop_code, 'builder_paused', 'then the owner’s pause on this builder');
|
|
70
|
+
const d = fence.decideRun({ fence: paused, graderBypassed: false });
|
|
71
|
+
assert.equal(d.code, 'builder_paused');
|
|
72
|
+
assert.match(d.reason, /too many PRs/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('anything but an explicit true for autonomy.run refuses', () => {
|
|
76
|
+
for (const mayRun of [undefined, null, 'true', 1]) {
|
|
77
|
+
assert.equal(fence.effectiveFence({ project: PROJECT, scope: { goals: [1000118] }, mayRun }).enabled, false, String(mayRun));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a permitted, unpaused builder with picks gets exactly their picks, priority first', () => {
|
|
82
|
+
const f = fence.effectiveFence({ project: PROJECT, scope: { goals: [1000090, 1000118] }, mayRun: true });
|
|
83
|
+
const d = fence.decideRun({ fence: f, graderBypassed: false });
|
|
84
|
+
assert.equal(d.go, true);
|
|
85
|
+
assert.deepEqual(d.goals, [1000118, 1000090]);
|
|
86
|
+
assert.equal(d.priorityGoal, 1000118);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a pick or a pause moves updated_at, so it wakes a runner waiting on the fence', () => {
|
|
90
|
+
const later = '2026-10-02T00:00:00.000Z';
|
|
91
|
+
assert.equal(fence.effectiveFence({ project: PROJECT, scope: { goals: [], updated_at: later }, mayRun: true }).updated_at, later);
|
|
92
|
+
assert.equal(fence.effectiveFence({ project: PROJECT, scope: null, mayRun: true }).updated_at, new Date(PROJECT.updated_at).toISOString());
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('an unreadable project fence stays unreadable', () => {
|
|
96
|
+
assert.equal(fence.effectiveFence({ project: null, scope: { goals: [1] }, mayRun: true }), null);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a fence from a server that predates the split still reads as the kill switch', () => {
|
|
100
|
+
const d = fence.decideRun({ fence: { enabled: false, paused_reason: 'x', goals: [] }, graderBypassed: false });
|
|
101
|
+
assert.equal(d.code, 'kill_switch');
|
|
102
|
+
const odd = fence.decideRun({ fence: { enabled: false, stop_code: 'something_new', goals: [] }, graderBypassed: false });
|
|
103
|
+
assert.equal(odd.code, 'kill_switch', 'an unknown stop code is never a way to open the fence');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('cadence treats a per-builder stop like the kill switch: stopped, not a fault', () => {
|
|
107
|
+
for (const code of ['builder_paused', 'not_permitted']) {
|
|
108
|
+
assert.equal(cadence.classifyEvent({ event: 'fenced', code, reason: 'r' }).class, 'stopped', code);
|
|
109
|
+
}
|
|
110
|
+
assert.equal(cadence.classifyEvent({ event: 'fenced', code: 'no_builder_scope', reason: 'r' }).class, 'quiet');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── the routes ───────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
const permStub = (key) => {
|
|
116
|
+
const mw = (_req, _res, next) => next();
|
|
117
|
+
mw.permKey = key;
|
|
118
|
+
return mw;
|
|
119
|
+
};
|
|
120
|
+
// The session fold (task 1002556) rides requireBuilder as req.rawPermissionKeys;
|
|
121
|
+
// null = the session row did not carry it. Mutable so one test can drive both.
|
|
122
|
+
let sessionKeys = null;
|
|
123
|
+
const passBuilder = (reqq, _res, next) => { reqq.builder = { id: 7, rank: 'metic' }; reqq.rawPermissionKeys = sessionKeys; next(); };
|
|
124
|
+
|
|
125
|
+
function autonomyRouter() {
|
|
126
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
127
|
+
const saved = { b: api.requireBuilder, p: api.requirePermission };
|
|
128
|
+
api.requireBuilder = passBuilder;
|
|
129
|
+
api.requirePermission = permStub;
|
|
130
|
+
const routePath = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
|
|
131
|
+
delete req.cache[routePath];
|
|
132
|
+
try {
|
|
133
|
+
return req(routePath)();
|
|
134
|
+
} finally {
|
|
135
|
+
api.requireBuilder = saved.b;
|
|
136
|
+
api.requirePermission = saved.p;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function serve(router) {
|
|
141
|
+
const app = express();
|
|
142
|
+
app.use(express.json());
|
|
143
|
+
app.use(attachFail);
|
|
144
|
+
app.use(router);
|
|
145
|
+
const server = app.listen(0);
|
|
146
|
+
await new Promise((r) => server.once('listening', r));
|
|
147
|
+
return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const send = (base, method, p, body) => fetch(`${base}${p}`, {
|
|
151
|
+
method, headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function gateOf(router, p, method) {
|
|
155
|
+
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
156
|
+
assert.ok(layer, `${method.toUpperCase()} ${p} is registered`);
|
|
157
|
+
return layer.route.stack.map((s) => s.handle.permKey).filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
test('the gates: picks behind autonomy.run, the pause and the owner view behind the owner-only atom', () => {
|
|
161
|
+
const router = autonomyRouter();
|
|
162
|
+
assert.deepEqual(gateOf(router, '/autonomy/me/goals', 'put'), ['autonomy.run']);
|
|
163
|
+
assert.deepEqual(gateOf(router, '/autonomy/builders/:builderId/pause', 'post'), ['autonomy.fence.manage']);
|
|
164
|
+
assert.deepEqual(gateOf(router, '/autonomy/runners/all', 'get'), ['autonomy.fence.manage']);
|
|
165
|
+
assert.deepEqual(gateOf(router, '/autonomy/fence', 'get'), [], 'the fence read stays any-builder: a runner must always learn it should stop');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('GET /autonomy/fence answers with the CALLER’s fence, asking the permission of the government port', async () => {
|
|
169
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
170
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
171
|
+
const saved = { rf: db.readFence, rs: db.readBuilderScope, ro: api.resolveOptional };
|
|
172
|
+
const asked = [];
|
|
173
|
+
let holds = true;
|
|
174
|
+
db.readFence = async () => PROJECT;
|
|
175
|
+
db.readBuilderScope = async (id) => { asked.push(['scope', id]); return { goals: [1000090], owner_paused: false, updated_at: null }; };
|
|
176
|
+
api.resolveOptional = (name) => (name === 'government'
|
|
177
|
+
? { builderHasPermissions: async (id, keys) => { asked.push(['perm', id, keys]); return holds; } }
|
|
178
|
+
: saved.ro(name));
|
|
179
|
+
const s = await serve(autonomyRouter());
|
|
180
|
+
try {
|
|
181
|
+
let r = await send(s.base, 'GET', '/autonomy/fence');
|
|
182
|
+
assert.equal(r.status, 200);
|
|
183
|
+
let body = await r.json();
|
|
184
|
+
assert.equal(body.enabled, true);
|
|
185
|
+
assert.deepEqual(body.goals.map((g) => g.goal_id), [1000090]);
|
|
186
|
+
assert.equal(body.project.goals.length, 2);
|
|
187
|
+
assert.deepEqual(asked, [['scope', 7], ['perm', 7, ['autonomy.run']]], 'scope and permission are the SESSION builder’s');
|
|
188
|
+
|
|
189
|
+
holds = false;
|
|
190
|
+
body = await (await send(s.base, 'GET', '/autonomy/fence')).json();
|
|
191
|
+
assert.equal(body.enabled, false);
|
|
192
|
+
assert.equal(body.stop_code, 'not_permitted');
|
|
193
|
+
|
|
194
|
+
api.resolveOptional = () => ({ builderHasPermissions: async () => { throw new Error('resolver down'); } });
|
|
195
|
+
body = await (await send(s.base, 'GET', '/autonomy/fence')).json();
|
|
196
|
+
assert.equal(body.enabled, false, 'a resolver that throws turns the fence OFF, never on');
|
|
197
|
+
|
|
198
|
+
api.resolveOptional = () => null;
|
|
199
|
+
body = await (await send(s.base, 'GET', '/autonomy/fence')).json();
|
|
200
|
+
assert.equal(body.enabled, false, 'no government port: off');
|
|
201
|
+
} finally {
|
|
202
|
+
s.close();
|
|
203
|
+
db.readFence = saved.rf; db.readBuilderScope = saved.rs; api.resolveOptional = saved.ro;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the fence read answers autonomy.run from the session fold, adding NO query to the runner’s every-iteration read', async () => {
|
|
208
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
209
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
210
|
+
const saved = { rf: db.readFence, rs: db.readBuilderScope, ro: api.resolveOptional };
|
|
211
|
+
let resolves = 0;
|
|
212
|
+
const folded = [];
|
|
213
|
+
db.readFence = async () => PROJECT;
|
|
214
|
+
db.readBuilderScope = async () => ({ goals: [1000118], owner_paused: false, updated_at: null });
|
|
215
|
+
api.resolveOptional = () => ({
|
|
216
|
+
effectivePermissions: (keys) => { folded.push(keys); return keys; },
|
|
217
|
+
builderHasPermissions: async () => { resolves += 1; return true; },
|
|
218
|
+
});
|
|
219
|
+
const s = await serve(autonomyRouter());
|
|
220
|
+
try {
|
|
221
|
+
sessionKeys = ['autonomy.run', 'task.claim'];
|
|
222
|
+
let body = await (await send(s.base, 'GET', '/autonomy/fence')).json();
|
|
223
|
+
assert.equal(body.enabled, true);
|
|
224
|
+
assert.equal(resolves, 0, 'the fold answered — no DB resolve');
|
|
225
|
+
assert.deepEqual(folded, [['autonomy.run', 'task.claim']]);
|
|
226
|
+
|
|
227
|
+
sessionKeys = ['task.claim'];
|
|
228
|
+
body = await (await send(s.base, 'GET', '/autonomy/fence')).json();
|
|
229
|
+
assert.equal(body.stop_code, 'not_permitted', 'the fold says not held, and that is the answer');
|
|
230
|
+
assert.equal(resolves, 0);
|
|
231
|
+
|
|
232
|
+
sessionKeys = null;
|
|
233
|
+
body = await (await send(s.base, 'GET', '/autonomy/fence')).json();
|
|
234
|
+
assert.equal(resolves, 1, 'no fold on the session: the resolve is the fallback, as in requirePermission');
|
|
235
|
+
} finally {
|
|
236
|
+
s.close();
|
|
237
|
+
sessionKeys = null;
|
|
238
|
+
db.readFence = saved.rf; db.readBuilderScope = saved.rs; api.resolveOptional = saved.ro;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('GET /autonomy/runners/all attaches the server’s health verdict to every builder’s runner', async () => {
|
|
243
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
244
|
+
const saved = db.readAllRunnerScopes;
|
|
245
|
+
db.readAllRunnerScopes = async () => [
|
|
246
|
+
{ builder_id: '3', goals: [1000118], owner_paused: false, runners: [{ host: 'winbox', last_seen_at: new Date().toISOString(), last_event: 'cadence' }] },
|
|
247
|
+
{ builder_id: '9', goals: [], owner_paused: true, runners: [] },
|
|
248
|
+
];
|
|
249
|
+
const s = await serve(autonomyRouter());
|
|
250
|
+
try {
|
|
251
|
+
const r = await send(s.base, 'GET', '/autonomy/runners/all');
|
|
252
|
+
assert.equal(r.status, 200);
|
|
253
|
+
const { builders } = await r.json();
|
|
254
|
+
assert.equal(builders.length, 2);
|
|
255
|
+
assert.equal(builders[0].runners[0].health.state, 'alive');
|
|
256
|
+
assert.deepEqual(builders[1].runners, []);
|
|
257
|
+
|
|
258
|
+
db.readAllRunnerScopes = async () => { throw new Error('db down'); };
|
|
259
|
+
assert.equal((await send(s.base, 'GET', '/autonomy/runners/all')).status, 503, 'unreadable is a 503, never an empty list');
|
|
260
|
+
} finally {
|
|
261
|
+
s.close();
|
|
262
|
+
db.readAllRunnerScopes = saved;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('PUT /autonomy/me/goals writes the session builder’s picks and refuses a goal the project does not allow', async () => {
|
|
267
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
268
|
+
const saved = db.setBuilderGoals;
|
|
269
|
+
const seen = [];
|
|
270
|
+
let answer = { goals: [1000090, 1000118] };
|
|
271
|
+
db.setBuilderGoals = async (args) => { seen.push(args); return answer; };
|
|
272
|
+
const s = await serve(autonomyRouter());
|
|
273
|
+
try {
|
|
274
|
+
let r = await send(s.base, 'PUT', '/autonomy/me/goals', { goal_ids: [1000118, '1000090'] });
|
|
275
|
+
assert.equal(r.status, 200);
|
|
276
|
+
assert.deepEqual(await r.json(), { goals: [1000090, 1000118] });
|
|
277
|
+
assert.deepEqual(seen[0], { builderId: 7, goalIds: [1000118, 1000090] });
|
|
278
|
+
|
|
279
|
+
answer = { goals: [] };
|
|
280
|
+
r = await send(s.base, 'PUT', '/autonomy/me/goals', { goal_ids: [] });
|
|
281
|
+
assert.equal(r.status, 200, 'an empty list is a real answer: "my runner takes no work"');
|
|
282
|
+
|
|
283
|
+
answer = { notAllowed: [4242] };
|
|
284
|
+
r = await send(s.base, 'PUT', '/autonomy/me/goals', { goal_ids: [4242] });
|
|
285
|
+
assert.equal(r.status, 409);
|
|
286
|
+
assert.match((await r.json()).error.message, /4242/);
|
|
287
|
+
|
|
288
|
+
const before = seen.length;
|
|
289
|
+
for (const bad of [{ goal_ids: 'x' }, { goal_ids: [0] }, { goal_ids: [-3] }, { goal_ids: ['abc'] }]) {
|
|
290
|
+
r = await send(s.base, 'PUT', '/autonomy/me/goals', bad);
|
|
291
|
+
assert.equal(r.status, 400, JSON.stringify(bad));
|
|
292
|
+
}
|
|
293
|
+
assert.equal(seen.length, before, 'a malformed list never reaches the database');
|
|
294
|
+
} finally {
|
|
295
|
+
s.close();
|
|
296
|
+
db.setBuilderGoals = saved;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('POST /autonomy/builders/:id/pause pauses THAT builder, keeps the reason only while paused', async () => {
|
|
301
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
302
|
+
const saved = db.setBuilderPause;
|
|
303
|
+
const seen = [];
|
|
304
|
+
db.setBuilderPause = async (args) => { seen.push(args); return { builder_id: String(args.builderId), owner_paused: args.paused }; };
|
|
305
|
+
const s = await serve(autonomyRouter());
|
|
306
|
+
try {
|
|
307
|
+
let r = await send(s.base, 'POST', '/autonomy/builders/42/pause', { paused: true, reason: 'too many PRs' });
|
|
308
|
+
assert.equal(r.status, 200);
|
|
309
|
+
assert.deepEqual(seen[0], { builderId: 42, paused: true, reason: 'too many PRs', byBuilderId: 7 });
|
|
310
|
+
db.setBuilderPause = async () => null;
|
|
311
|
+
r = await send(s.base, 'POST', '/autonomy/builders/999999/pause', { paused: true });
|
|
312
|
+
assert.equal(r.status, 404, 'no such builder: refused, never an orphan row');
|
|
313
|
+
r = await send(s.base, 'POST', '/autonomy/builders/nope/pause', { paused: true });
|
|
314
|
+
assert.equal(r.status, 400);
|
|
315
|
+
r = await send(s.base, 'POST', '/autonomy/builders/42/pause', {});
|
|
316
|
+
assert.equal(r.status, 400, 'paused is required');
|
|
317
|
+
} finally {
|
|
318
|
+
s.close();
|
|
319
|
+
db.setBuilderPause = saved;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ── the SQL ──────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
function stubClient(replies) {
|
|
326
|
+
const calls = [];
|
|
327
|
+
const client = { query: async (sql, params) => { calls.push({ sql, params }); return replies.shift() || { rows: [] }; }, release: () => { calls.push({ sql: 'RELEASE' }); } };
|
|
328
|
+
return { client, calls };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
test('setBuilderGoals writes nothing when any goal is not allowlisted, and replaces the list otherwise', async () => {
|
|
332
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
333
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
334
|
+
const saved = api.pool.connect;
|
|
335
|
+
try {
|
|
336
|
+
let s = stubClient([{}, { rows: [{ goal_id: '1000118' }] }]);
|
|
337
|
+
api.pool.connect = async () => s.client;
|
|
338
|
+
assert.deepEqual(await db.setBuilderGoals({ builderId: 7, goalIds: [1000118, 4242] }), { notAllowed: [4242] });
|
|
339
|
+
assert.ok(s.calls.some((c) => c.sql === 'ROLLBACK'));
|
|
340
|
+
assert.ok(!s.calls.some((c) => /INSERT INTO autonomy_builder_goals|DELETE FROM autonomy_builder_goals/.test(c.sql)), 'nothing is written');
|
|
341
|
+
assert.equal(s.calls.at(-1).sql, 'RELEASE');
|
|
342
|
+
|
|
343
|
+
s = stubClient([{}, { rows: [{ goal_id: '1000118' }, { goal_id: '1000090' }] }]);
|
|
344
|
+
api.pool.connect = async () => s.client;
|
|
345
|
+
assert.deepEqual(await db.setBuilderGoals({ builderId: 7, goalIds: [1000118, 1000090, 1000118] }), { goals: [1000090, 1000118] });
|
|
346
|
+
const sqls = s.calls.map((c) => c.sql);
|
|
347
|
+
assert.ok(sqls.some((q) => /DELETE FROM autonomy_builder_goals WHERE builder_id = \$1/.test(q)));
|
|
348
|
+
assert.ok(sqls.some((q) => /INSERT INTO autonomy_builder_goals/.test(q)));
|
|
349
|
+
assert.ok(sqls.some((q) => /INSERT INTO autonomy_builder_scope/.test(q)), 'the scope row is bumped so a waiting runner wakes');
|
|
350
|
+
assert.ok(sqls.includes('COMMIT'));
|
|
351
|
+
|
|
352
|
+
s = stubClient([{}]);
|
|
353
|
+
api.pool.connect = async () => s.client;
|
|
354
|
+
assert.deepEqual(await db.setBuilderGoals({ builderId: 7, goalIds: [] }), { goals: [] });
|
|
355
|
+
assert.ok(!s.calls.some((c) => /INSERT INTO autonomy_builder_goals/.test(c.sql)), 'an empty list clears and inserts nothing');
|
|
356
|
+
} finally {
|
|
357
|
+
api.pool.connect = saved;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('readBuilderScope: null for a builder with no row and no picks; picks only while still allowlisted', async () => {
|
|
362
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
363
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
364
|
+
const saved = api.pool.query;
|
|
365
|
+
const sqls = [];
|
|
366
|
+
let scopeRows = []; let goalRows = [];
|
|
367
|
+
api.pool.query = async (sql) => { sqls.push(sql); return { rows: /autonomy_builder_scope/.test(sql) ? scopeRows : goalRows }; };
|
|
368
|
+
try {
|
|
369
|
+
assert.equal(await db.readBuilderScope(7), null);
|
|
370
|
+
assert.ok(sqls.some((q) => /JOIN autonomy_allowed_goals/.test(q)));
|
|
371
|
+
scopeRows = [{ owner_paused: true, owner_paused_reason: 'r', owner_paused_by: '3', updated_at: 't' }];
|
|
372
|
+
goalRows = [{ goal_id: '1000118' }];
|
|
373
|
+
assert.deepEqual(await db.readBuilderScope(7), { goals: [1000118], owner_paused: true, owner_paused_reason: 'r', owner_paused_by: '3', updated_at: 't' });
|
|
374
|
+
} finally {
|
|
375
|
+
api.pool.query = saved;
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('readAllRunnerScopes: a builder with picks and no runner, and one with a runner and no picks, both appear', async () => {
|
|
380
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
381
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
382
|
+
const saved = api.pool.query;
|
|
383
|
+
const sqls = [];
|
|
384
|
+
api.pool.query = async (sql, params) => {
|
|
385
|
+
sqls.push({ sql, params });
|
|
386
|
+
if (/FROM autonomy_builder_scope/.test(sql)) return { rows: [{ builder_id: '4', owner_paused: true, owner_paused_reason: 'too many PRs', updated_at: 't' }] };
|
|
387
|
+
if (/FROM autonomy_builder_goals/.test(sql)) return { rows: [{ builder_id: '3', goal_id: '1000118' }, { builder_id: '4', goal_id: '1000090' }] };
|
|
388
|
+
if (/FROM autonomy_runner_heartbeat/.test(sql)) return { rows: [{ builder_id: '9', host: 'pc9', working_task_id: '5', working_goal_id: null, last_seen_at: 't' }] };
|
|
389
|
+
if (/FROM builders/.test(sql)) return { rows: [{ id: '3', github_login: 'b3', display_name: 'Three', rank: 'archon' }, { id: '9', github_login: 'b9', display_name: 'Nine', rank: 'metic' }] };
|
|
390
|
+
return { rows: [] };
|
|
391
|
+
};
|
|
392
|
+
try {
|
|
393
|
+
const out = await db.readAllRunnerScopes();
|
|
394
|
+
const by = Object.fromEntries(out.map((b) => [b.builder_id, b]));
|
|
395
|
+
assert.deepEqual(Object.keys(by).sort(), ['3', '4', '9']);
|
|
396
|
+
assert.deepEqual(by['3'].goals, [1000118], 'picks only, no runner');
|
|
397
|
+
assert.deepEqual(by['3'].runners, []);
|
|
398
|
+
assert.equal(by['3'].display_name, 'Three');
|
|
399
|
+
assert.equal(by['4'].owner_paused, true);
|
|
400
|
+
assert.equal(by['4'].owner_paused_reason, 'too many PRs');
|
|
401
|
+
assert.equal(by['4'].display_name, undefined, 'no builders row: no name, and still listed');
|
|
402
|
+
assert.deepEqual(by['9'].goals, [], 'a runner and no picks');
|
|
403
|
+
assert.equal(by['9'].runners[0].host, 'pc9');
|
|
404
|
+
assert.equal(by['9'].runners[0].working_task_id, '5');
|
|
405
|
+
const beat = sqls.find((c) => /FROM autonomy_runner_heartbeat/.test(c.sql)).sql;
|
|
406
|
+
assert.match(beat, /PARTITION BY builder_id/, 'the cap is per builder, so a busy builder cannot push a quiet one out');
|
|
407
|
+
assert.match(beat, /rn <= 20/);
|
|
408
|
+
assert.deepEqual(sqls.find((c) => /FROM builders/.test(c.sql)).params[0].slice().sort(), ['3', '4', '9']);
|
|
409
|
+
} finally {
|
|
410
|
+
api.pool.query = saved;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('setBuilderPause writes only for a builder that exists', async () => {
|
|
415
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
416
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
417
|
+
const saved = api.pool.query;
|
|
418
|
+
let seen = null;
|
|
419
|
+
api.pool.query = async (sql, params) => { seen = { sql, params }; return { rows: [] }; };
|
|
420
|
+
try {
|
|
421
|
+
assert.equal(await db.setBuilderPause({ builderId: 999, paused: true, reason: 'x', byBuilderId: 3 }), null);
|
|
422
|
+
assert.match(seen.sql, /WHERE EXISTS \(SELECT 1 FROM builders WHERE id = \$1\)/);
|
|
423
|
+
assert.deepEqual(seen.params, [999, true, 'x', 3]);
|
|
424
|
+
await db.setBuilderPause({ builderId: 4, paused: false, reason: 'ignored', byBuilderId: 3 });
|
|
425
|
+
assert.equal(seen.params[2], null, 'the reason is kept only while paused');
|
|
426
|
+
} finally {
|
|
427
|
+
api.pool.query = saved;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// ── the schema and the permission ────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
const sqlOf = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8').split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
|
|
434
|
+
|
|
435
|
+
test('the migration ties every pick to the allowlist and drops it when the goal is revoked', () => {
|
|
436
|
+
const sql = sqlOf('modules/autonomy/migrations/autonomy_005_builder_scope.sql');
|
|
437
|
+
assert.match(sql, /CREATE TABLE IF NOT EXISTS autonomy_builder_goals/);
|
|
438
|
+
assert.match(sql, /REFERENCES autonomy_allowed_goals \(goal_id\) ON DELETE CASCADE/);
|
|
439
|
+
assert.match(sql, /PRIMARY KEY \(builder_id, goal_id\)/);
|
|
440
|
+
assert.match(sql, /owner_paused\s+boolean NOT NULL DEFAULT false/);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('autonomy.run is an operational Metic permission, granted to Metic and Archon', () => {
|
|
444
|
+
const { PERMISSIONS: CATALOG } = req(path.join(ROOT, 'modules/government/catalog.js'));
|
|
445
|
+
const entry = (CATALOG || []).find((p) => p.key === 'autonomy.run');
|
|
446
|
+
assert.ok(entry, 'in the catalog');
|
|
447
|
+
assert.equal(entry.system, false, 'composable into a custom rank — it cannot widen the fence');
|
|
448
|
+
assert.equal(entry.floor, 'metic');
|
|
449
|
+
const fm = (CATALOG || []).find((p) => p.key === 'autonomy.fence.manage');
|
|
450
|
+
assert.equal(fm.system, true, 'the fence itself stays the owner’s alone');
|
|
451
|
+
const grant = sqlOf('modules/government/migrations/government_022_autonomy_run.sql');
|
|
452
|
+
assert.match(grant, /SELECT 'metic', unnest\(ARRAY\[\s*'autonomy\.run'\s*\]\)/);
|
|
453
|
+
assert.match(grant, /SELECT 'archon', unnest\(ARRAY\[\s*'autonomy\.run'\s*\]\)/);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ── the pages ────────────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
test('the owner page draws the PROJECT fence and pauses one builder; the settings panel writes the caller’s own picks', () => {
|
|
459
|
+
const gate = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
|
|
460
|
+
assert.match(gate, /fence\.project \|\| fence/, 'the switch panel must not draw the owner’s own narrowed picks as the project list');
|
|
461
|
+
assert.match(gate, /\/autonomy\/runners\/all/);
|
|
462
|
+
assert.match(gate, /\/autonomy\/builders\/\$\{encodeURIComponent\(id\)\}\/pause/);
|
|
463
|
+
const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.html'), 'utf8');
|
|
464
|
+
assert.match(html, /id="fence-builders"/);
|
|
465
|
+
|
|
466
|
+
const panel = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-autobongos.js'), 'utf8');
|
|
467
|
+
assert.match(panel, /'PUT', `\$\{API\}\/autonomy\/me\/goals`/);
|
|
468
|
+
assert.match(panel, /scope\.may_run !== true/, 'a builder who may not run is told so instead of shown a form');
|
|
469
|
+
assert.match(panel, /'GET', `\$\{API\}\/goals`/, 'goals are named from the list this builder can already see');
|
|
470
|
+
assert.match(panel, /goalLabel\(g\.goal_id\)/);
|
|
471
|
+
const settings = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
|
|
472
|
+
assert.match(settings, /id="autobongos-scroll"/);
|
|
473
|
+
assert.match(settings, /settings-autobongos\.js/);
|
|
474
|
+
});
|
|
@@ -204,7 +204,9 @@ test('the migration ties the priority to the allowlist and clears it on revoke',
|
|
|
204
204
|
|
|
205
205
|
test('the gate page renders the priority, lists it first, and posts to the priority route', () => {
|
|
206
206
|
const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
|
|
207
|
-
|
|
207
|
+
// The PROJECT fence's priority (task 1004501): GET /autonomy/fence now answers
|
|
208
|
+
// with the caller's narrowed fence, and the switch panel draws `project`.
|
|
209
|
+
assert.match(src, /renderFenceGoals\(project\.goals, project\.priority_goal_id\)/, 'the page must pass the priority it read');
|
|
208
210
|
assert.match(src, /sendJson\('POST', '\/autonomy\/fence\/priority', \{ goal_id: goalId \}\)/);
|
|
209
211
|
assert.match(src, /Make priority/);
|
|
210
212
|
assert.match(src, /Clear priority/);
|