@bongos/core 1.20.80 → 1.20.82
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 +98 -53
- 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 +12 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +245 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +84 -71
- package/docs/copy-registry.json +271 -145
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +139 -119
- 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/economy/earnings.js +117 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/routes/earnings.js +47 -0
- package/modules/government/catalog.js +9 -1
- package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
- package/modules/hall-ui/public/gate.css +49 -0
- package/modules/hall-ui/public/gate.html +8 -7
- package/modules/hall-ui/public/gate.js +272 -115
- package/modules/hall-ui/public/profile-charts.js +206 -104
- package/modules/hall-ui/public/profile.css +23 -0
- package/modules/hall-ui/public/profile.js +3 -3
- package/modules/hall-ui/public/profile.states.json +8 -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/hall-ui/records/profile-roles.md +21 -0
- package/modules/lifecycle/db-goals.js +6 -0
- package/modules/lifecycle/db-versions.js +3 -0
- package/modules/lifecycle/done-when.js +4 -0
- package/modules/lifecycle/goal-close-sweep.js +34 -0
- package/modules/provisioning/app-status.js +17 -4
- package/modules/public-landing/public/projects.html +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +8 -6
- package/tests/autonomy_builder_scope.mjs +474 -0
- package/tests/autonomy_fence_priority.mjs +7 -3
- package/tests/autonomy_runner_identity.mjs +1 -1
- package/tests/criterion_goal_attach.mjs +5 -1
- package/tests/goal_close_cancels_requests.mjs +82 -0
- package/tests/goal_closure_invariants.mjs +5 -2
- package/tests/hall_profile_world.mjs +57 -58
- package/tests/profile_earnings.mjs +237 -0
- package/tests/projects_hub_app_status.mjs +17 -0
- package/tests/provisioning_app_status.mjs +25 -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, /const fence = raw\.project \|\| raw;/, '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,8 +204,12 @@ 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
|
+
// The chips (task 1004509) are drawn from that project fence.
|
|
210
|
+
assert.match(src, /goalChipsHtml\(fence\.goals, fence\.priority_goal_id\)/, 'the page must pass the priority it read');
|
|
208
211
|
assert.match(src, /sendJson\('POST', '\/autonomy\/fence\/priority', \{ goal_id: goalId \}\)/);
|
|
209
|
-
|
|
210
|
-
assert.match(src, /
|
|
212
|
+
// The star on a chip sets it, and the star on the priority chip clears it.
|
|
213
|
+
assert.match(src, /Work this goal first/);
|
|
214
|
+
assert.match(src, /Worked first — click to clear/);
|
|
211
215
|
});
|
|
@@ -208,7 +208,7 @@ test('the migration adds the four columns, additively', () => {
|
|
|
208
208
|
|
|
209
209
|
test('the gate card draws the PC, the account, the code and the restarting label', () => {
|
|
210
210
|
const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
|
|
211
|
-
assert.match(src, /
|
|
211
|
+
assert.match(src, /Restarting to pick up new code/);
|
|
212
212
|
assert.match(src, /PC <strong>\$\{escapeHtml\(h\.host\)\}<\/strong>/);
|
|
213
213
|
assert.match(src, /Claude account \$\{escapeHtml\(h\.account\)\}/);
|
|
214
214
|
assert.match(src, /behind main/);
|
|
@@ -557,9 +557,13 @@ await test('setGoalStatus writes on the transaction client (task 1003840)', asyn
|
|
|
557
557
|
const seen = [];
|
|
558
558
|
const fakeClient = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ id: 3, status: 'achieved' }] }; } };
|
|
559
559
|
await realGoals.setGoalStatus({ goalId: 3, status: 'achieved' }, { client: fakeClient });
|
|
560
|
-
|
|
560
|
+
// Two since task 1003660: the status flip, then the sweep of the goal's pending
|
|
561
|
+
// invites/requests. BOTH must land on the injected client, or the sweep would
|
|
562
|
+
// reintroduce the very pool-vs-transaction deadlock this test guards.
|
|
563
|
+
assert.equal(seen.length, 2, 'the UPDATE and the request sweep reached the injected client');
|
|
561
564
|
assert.match(seen[0].sql, /UPDATE goals/);
|
|
562
565
|
assert.deepEqual(seen[0].params, [3, 'achieved']);
|
|
566
|
+
assert.match(seen[1].sql, /UPDATE goal_membership_requests/);
|
|
563
567
|
});
|
|
564
568
|
|
|
565
569
|
// The wrapper installed above is removed, so nothing after this file's own tests
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// tests/goal_close_cancels_requests.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003660: closing a goal (achieved / archived) cancels its pending invites
|
|
4
|
+
// and join-requests, so a stale row leaves the inbox instead of waiting to be
|
|
5
|
+
// refused at accept time. DB-free: a SQL-aware fake records the statements.
|
|
6
|
+
//
|
|
7
|
+
// Run: node tests/goal_close_cancels_requests.mjs
|
|
8
|
+
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
12
|
+
|
|
13
|
+
process.env.NODE_ENV = 'test';
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { setGoalStatus } = require('../modules/lifecycle/db-goals.js');
|
|
16
|
+
const { cancelPendingMembershipRequests } = require('../modules/lifecycle/goal-close-sweep.js');
|
|
17
|
+
|
|
18
|
+
const { test, summary } = makeRunner();
|
|
19
|
+
|
|
20
|
+
function fakePool(handler) {
|
|
21
|
+
const c = makeSqlAwareClient(handler);
|
|
22
|
+
return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
|
|
23
|
+
}
|
|
24
|
+
const norm = (q) => String(q.sql).replace(/\s+/g, ' ');
|
|
25
|
+
const sweeps = (pool) => pool.queries.filter((q) => /^UPDATE goal_membership_requests/.test(norm(q)));
|
|
26
|
+
const goalRow = (status) => ({ id: 7, status });
|
|
27
|
+
const flip = (status) => (s) => (/^UPDATE goals/.test(s) ? { rows: [goalRow(status)] } : null);
|
|
28
|
+
|
|
29
|
+
await test('cancelPendingMembershipRequests: cancels BOTH kinds, pending only, for the given goals', async () => {
|
|
30
|
+
const pool = fakePool(() => ({ rows: [], rowCount: 2 }));
|
|
31
|
+
const n = await cancelPendingMembershipRequests(pool, [7, '8']);
|
|
32
|
+
assert.equal(n, 2);
|
|
33
|
+
const [q] = sweeps(pool);
|
|
34
|
+
assert.match(norm(q), /SET status = 'cancelled'/);
|
|
35
|
+
assert.match(norm(q), /status = 'pending'/);
|
|
36
|
+
assert.doesNotMatch(norm(q), /kind\s*=/, 'neither the invite nor the request arm is excluded');
|
|
37
|
+
assert.deepEqual(q.params, [[7, 8]]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await test('cancelPendingMembershipRequests: no ids → no statement', async () => {
|
|
41
|
+
const pool = fakePool(() => ({ rows: [] }));
|
|
42
|
+
assert.equal(await cancelPendingMembershipRequests(pool, []), 0);
|
|
43
|
+
assert.equal(await cancelPendingMembershipRequests(pool, null), 0);
|
|
44
|
+
assert.equal(pool.queries.length, 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for (const status of ['achieved', 'archived']) {
|
|
48
|
+
await test(`setGoalStatus(${status}) sweeps the goal's pending requests`, async () => {
|
|
49
|
+
const pool = fakePool(flip(status));
|
|
50
|
+
await setGoalStatus({ goalId: 7, status }, { pool });
|
|
51
|
+
assert.equal(sweeps(pool).length, 1);
|
|
52
|
+
assert.deepEqual(sweeps(pool)[0].params, [[7]]);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await test('setGoalStatus(open) — a reopen — sweeps nothing', async () => {
|
|
57
|
+
const pool = fakePool(flip('open'));
|
|
58
|
+
await setGoalStatus({ goalId: 7, status: 'open' }, { pool });
|
|
59
|
+
assert.equal(sweeps(pool).length, 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await test('setGoalStatus on an unknown goal sweeps nothing', async () => {
|
|
63
|
+
const pool = fakePool(() => ({ rows: [] }));
|
|
64
|
+
await setGoalStatus({ goalId: 99, status: 'archived' }, { pool });
|
|
65
|
+
assert.equal(sweeps(pool).length, 0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await test('every in-module writer that archives or achieves a goal also sweeps', async () => {
|
|
69
|
+
const { readFileSync } = await import('node:fs');
|
|
70
|
+
const read = (f) => readFileSync(new URL(`../modules/lifecycle/${f}`, import.meta.url), 'utf8');
|
|
71
|
+
const count = (s, re) => (s.match(re) || []).length;
|
|
72
|
+
const closes = /status = 'archived'|status = 'achieved'/g;
|
|
73
|
+
for (const f of ['db-goals.js', 'db-versions.js', 'done-when.js']) {
|
|
74
|
+
const src = read(f);
|
|
75
|
+
// setGoalStatus takes the status as a parameter (covered above); every
|
|
76
|
+
// literal close in these files must have a sweep call.
|
|
77
|
+
assert.ok(count(src, /cancelPendingMembershipRequests\(/g) >= count(src, closes),
|
|
78
|
+
`${f}: each literal goal close needs a cancelPendingMembershipRequests call`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
summary();
|
|
@@ -256,10 +256,13 @@ test('every statement goes through the caller exec — none reaches the module p
|
|
|
256
256
|
// nothing reaches the module pool — is exactly what makes auto-close correct on
|
|
257
257
|
// a ship: the goal it keys on was flipped in this uncommitted transaction, and
|
|
258
258
|
// a pool read would not see it.
|
|
259
|
-
|
|
259
|
+
// Task 1003660 added a fourth: the sweep of the achieved goal's pending
|
|
260
|
+
// invites/requests, between the cascade and the version auto-close.
|
|
261
|
+
assert.equal(exec.calls.length, 4, 'the criterion close, the cascade, the request sweep and the version auto-close, all on exec');
|
|
260
262
|
assert.match(exec.calls[0].sql, /UPDATE done_when_criteria/);
|
|
261
263
|
assert.match(exec.calls[1].sql, /UPDATE goals g/);
|
|
262
|
-
assert.match(exec.calls[2].sql, /UPDATE
|
|
264
|
+
assert.match(exec.calls[2].sql, /UPDATE goal_membership_requests/);
|
|
265
|
+
assert.match(exec.calls[3].sql, /UPDATE versions v/);
|
|
263
266
|
});
|
|
264
267
|
|
|
265
268
|
// ---------------------------------------------------------------------------
|