@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
|
@@ -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/);
|
|
@@ -21,8 +21,10 @@ const CU = require_(path.join(ROOT, 'src/bongos/core-update.js'));
|
|
|
21
21
|
// through it rather than reaching past it into internals that no route uses.
|
|
22
22
|
// `store` is per-call so the unit assertions never share the module's own cache.
|
|
23
23
|
const freshStore = () => ({ at: 0, versions: [], ok: false, inflight: null });
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// `released` is the registry's `latest` label. It defaults to the newest version listed, which is
|
|
25
|
+
// what it was before merges published as candidates (task 1004298); the candidate tests pass it.
|
|
26
|
+
const verdictFor = (running, available, channel, released = available[available.length - 1]) =>
|
|
27
|
+
CU.readCoreUpdate({ running, channel, store: freshStore(), fetch: async () => ({ ok: true, versions: available, released }) });
|
|
26
28
|
const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
27
29
|
|
|
28
30
|
// ── the verdict ────────────────────────────────────────────────────────────────
|
|
@@ -53,6 +55,40 @@ test('a prerelease ahead is not offered', async () => {
|
|
|
53
55
|
assert.equal(v.behind, false);
|
|
54
56
|
});
|
|
55
57
|
|
|
58
|
+
// ── only released versions are offered (task 1004298, ADR 0361) ──────────────────
|
|
59
|
+
|
|
60
|
+
test('a candidate newer than the release is NOT offered; the release is', async () => {
|
|
61
|
+
const v = await verdictFor('1.19.861', ['1.19.861', '1.19.865', '1.19.871'], undefined, '1.19.865');
|
|
62
|
+
assert.equal(v.latest, '1.19.865', 'the newest RELEASED version, not the newest published');
|
|
63
|
+
assert.equal(v.behind, true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a project already on the release is current, however many candidates follow it', async () => {
|
|
67
|
+
const v = await verdictFor('1.19.865', ['1.19.865', '1.19.870', '1.19.871'], undefined, '1.19.865');
|
|
68
|
+
assert.equal(v.behind, false);
|
|
69
|
+
assert.equal(v.latest, null);
|
|
70
|
+
assert.equal(v.reading, 'ok');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the platform hall, running ahead of the release, reads current — never "behind" a version older than it', async () => {
|
|
74
|
+
const v = await verdictFor('1.19.871', ['1.19.865', '1.19.871'], undefined, '1.19.865');
|
|
75
|
+
assert.equal(v.behind, false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a registry that names no release is NO ANSWER, never the newest candidate', async () => {
|
|
79
|
+
const v = await verdictFor('1.19.861', ['1.19.861', '1.19.871'], undefined, null);
|
|
80
|
+
assert.equal(v.reading, 'no_answer');
|
|
81
|
+
assert.equal(v.latest, null);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the packument\'s `latest` label is what bounds the offer', async () => {
|
|
85
|
+
const v = await CU.readCoreUpdate({
|
|
86
|
+
running: '1.19.861', store: freshStore(),
|
|
87
|
+
get: fakeGet((res) => { res._body = JSON.stringify({ 'dist-tags': { latest: '1.19.865', next: '1.19.871' }, versions: { '1.19.861': {}, '1.19.865': {}, '1.19.871': {} } }); }),
|
|
88
|
+
});
|
|
89
|
+
assert.equal(v.latest, '1.19.865');
|
|
90
|
+
});
|
|
91
|
+
|
|
56
92
|
test('NO ANSWER is not "up to date" — the distinction the banner turns on', async () => {
|
|
57
93
|
// A registry that cannot be reached, and a box that cannot name its own core:
|
|
58
94
|
// two different roads to the same must-say-nothing state.
|
|
@@ -96,7 +132,7 @@ test('a real packument is parsed, and its newest version reaches the verdict', a
|
|
|
96
132
|
// Drives the ACTUAL https parsing path, only the socket is fake.
|
|
97
133
|
const v = await CU.readCoreUpdate({
|
|
98
134
|
running: '1.19.861', store: freshStore(),
|
|
99
|
-
get: fakeGet((res) => { res._body = JSON.stringify({ versions: { '1.19.861': {}, '1.19.871': {} } }); }),
|
|
135
|
+
get: fakeGet((res) => { res._body = JSON.stringify({ 'dist-tags': { latest: '1.19.871' }, versions: { '1.19.861': {}, '1.19.871': {} } }); }),
|
|
100
136
|
});
|
|
101
137
|
assert.equal(v.reading, 'ok');
|
|
102
138
|
assert.equal(v.latest, '1.19.871');
|
|
@@ -120,7 +156,7 @@ test('the cache spares the registry a second read inside the TTL', async () => {
|
|
|
120
156
|
test('concurrent cold-cache reads make ONE outbound call, not one each', async () => {
|
|
121
157
|
const store = freshStore();
|
|
122
158
|
let calls = 0;
|
|
123
|
-
const fetch = () => { calls++; return new Promise((r) => setTimeout(() => r({ ok: true, versions: ['1.19.871'] }), 5)); };
|
|
159
|
+
const fetch = () => { calls++; return new Promise((r) => setTimeout(() => r({ ok: true, versions: ['1.19.871'], released: '1.19.871' }), 5)); };
|
|
124
160
|
const args = { running: '1.19.861', store, fetch, now: () => 1 };
|
|
125
161
|
const all = await Promise.all([CU.readCoreUpdate(args), CU.readCoreUpdate(args), CU.readCoreUpdate(args)]);
|
|
126
162
|
assert.equal(calls, 1, 'three simultaneous cold reads share one registry call');
|
|
@@ -179,7 +215,7 @@ test('a real request through the assembled router returns the verdict, not a 500
|
|
|
179
215
|
|
|
180
216
|
// Warm the module's OWN cache through the public door, so the route's own
|
|
181
217
|
// dependency-free call is served from it and the test never touches the network.
|
|
182
|
-
await CU.readCoreUpdate({ running: '1.19.861', fetch: async () => ({ ok: true, versions: ['1.19.861', '1.19.871'] }) });
|
|
218
|
+
await CU.readCoreUpdate({ running: '1.19.861', fetch: async () => ({ ok: true, versions: ['1.19.861', '1.19.871'], released: '1.19.871' }) });
|
|
183
219
|
const express = r2('express');
|
|
184
220
|
const app = express();
|
|
185
221
|
app.use(r2(routePath)());
|
|
@@ -1416,7 +1416,14 @@ console.log('\nthe project\'s own update rule (task 1004445):');
|
|
|
1416
1416
|
const realChannel = require('../scripts/gds/update-channel.js');
|
|
1417
1417
|
const withRealChannel = (over) => {
|
|
1418
1418
|
const d = makeDeps(over);
|
|
1419
|
-
|
|
1419
|
+
// readReleaseTags is stubbed too: the real one shells out to npm. `released` defaults to the newest
|
|
1420
|
+
// version listed, which is what `latest` was before candidates (task 1004298).
|
|
1421
|
+
const released = over.released !== undefined ? over.released : over.available[over.available.length - 1];
|
|
1422
|
+
d.updateChannel = {
|
|
1423
|
+
...realChannel,
|
|
1424
|
+
listAvailableVersions: () => ({ ok: true, versions: over.available }),
|
|
1425
|
+
readReleaseTags: () => (released ? { ok: true, released, tags: { latest: released } } : { ok: false, error: 'no latest label', released: null, tags: {} }),
|
|
1426
|
+
};
|
|
1420
1427
|
return d;
|
|
1421
1428
|
};
|
|
1422
1429
|
|
|
@@ -1528,5 +1535,63 @@ await t('the drain loop writes the class beside the sentence (behaviour: tests/p
|
|
|
1528
1535
|
assert.match(src, /SET failure_class = \$2, failure_reason = \$3/);
|
|
1529
1536
|
});
|
|
1530
1537
|
|
|
1538
|
+
console.log('\nwho is offered a candidate (task 1004298, ADR 0361):');
|
|
1539
|
+
|
|
1540
|
+
await t('the platform hall (control-plane) is moved to a candidate newer than the release', async () => {
|
|
1541
|
+
const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1083', available: ['1.19.1081', '1.19.1082', '1.19.1083'], released: '1.19.1082' });
|
|
1542
|
+
const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane' }), d, intent({ target_version: '1.19.1083' }));
|
|
1543
|
+
assert.ok(!(r.error && /not a published|unknown version|outside/.test(r.error)), `the candidate must be admitted: ${r.error}`);
|
|
1544
|
+
assert.ok(d.ran.some((c) => /upgrade\.js/.test(c) && /--to 1\.19\.1083/.test(c)), 'the upgrade was invoked for the candidate');
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
await t('any other project is refused a candidate the owner has not released', async () => {
|
|
1548
|
+
const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1081', available: ['1.19.1081', '1.19.1082', '1.19.1083'], released: '1.19.1082' });
|
|
1549
|
+
const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'co-tenant', slug: 'demo' }), d, intent({ target_version: '1.19.1083' }));
|
|
1550
|
+
assert.equal(r.ok, false);
|
|
1551
|
+
assert.ok(!d.ran.some((c) => /upgrade\.js/.test(c)), 'and nothing was run');
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
await t('…and takes the released version', async () => {
|
|
1555
|
+
const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1082', available: ['1.19.1081', '1.19.1082', '1.19.1083'], released: '1.19.1082' });
|
|
1556
|
+
const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'co-tenant', slug: 'demo' }), d, intent({ target_version: '1.19.1082' }));
|
|
1557
|
+
assert.ok(d.ran.some((c) => /upgrade\.js/.test(c) && /--to 1\.19\.1082/.test(c)), `the release was invoked: ${r.error}`);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
await t('a project that follows releases is offered nothing when the registry names no release', async () => {
|
|
1561
|
+
const queries = [];
|
|
1562
|
+
await R.snapshotCoreVersions(inst({ hosting_shape: 'co-tenant', slug: 'demo' }), withRealChannel({ served: '1.19.1081', available: ['1.19.1081', '1.19.1083'], released: null, queries }));
|
|
1563
|
+
const q = queries.find((x) => /provisioning_core_version_snapshot/.test(x.sql));
|
|
1564
|
+
assert.equal(q.params[3], null, 'no recommended version on a guess');
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
await t('the snapshot offers a tenant the release and the platform hall the newest candidate', async () => {
|
|
1568
|
+
const available = ['1.19.1081', '1.19.1082', '1.19.1083'];
|
|
1569
|
+
const offered = async (hosting_shape) => {
|
|
1570
|
+
const queries = [];
|
|
1571
|
+
await R.snapshotCoreVersions(inst({ hosting_shape, slug: hosting_shape }), withRealChannel({ served: '1.19.1081', available, released: '1.19.1082', queries }));
|
|
1572
|
+
return queries.find((x) => /provisioning_core_version_snapshot/.test(x.sql)).params[3];
|
|
1573
|
+
};
|
|
1574
|
+
assert.equal(await offered('co-tenant'), '1.19.1082');
|
|
1575
|
+
assert.equal(await offered('control-plane'), '1.19.1083');
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
await t('a pass over several projects reads the released label ONCE, not once per project', async () => {
|
|
1579
|
+
const d = withRealChannel({ served: '1.19.1081', available: ['1.19.1081', '1.19.1082', '1.19.1083'], released: '1.19.1082', queries: [] });
|
|
1580
|
+
let reads = 0;
|
|
1581
|
+
const real = d.updateChannel.readReleaseTags;
|
|
1582
|
+
d.updateChannel.readReleaseTags = (...a) => { reads++; return real(...a); };
|
|
1583
|
+
for (const slug of ['a', 'b', 'c']) await R.snapshotCoreVersions(inst({ hosting_shape: 'co-tenant', slug }), d);
|
|
1584
|
+
assert.equal(reads, 1, `three projects, one npm view — read ${reads} times`);
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
await t('a FAILED label read is not kept: the next project asks again', async () => {
|
|
1588
|
+
const d = withRealChannel({ served: '1.19.1081', available: ['1.19.1081', '1.19.1083'], released: null, queries: [] });
|
|
1589
|
+
let reads = 0;
|
|
1590
|
+
const real = d.updateChannel.readReleaseTags;
|
|
1591
|
+
d.updateChannel.readReleaseTags = (...a) => { reads++; return real(...a); };
|
|
1592
|
+
for (const slug of ['a', 'b']) await R.snapshotCoreVersions(inst({ hosting_shape: 'co-tenant', slug }), d);
|
|
1593
|
+
assert.equal(reads, 2);
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1531
1596
|
console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
|
|
1532
1597
|
if (failed) process.exit(1);
|