@bongos/core 1.24.77 → 1.24.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.bongos-core.json +89 -44
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +12 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +15 -0
  8. package/docs/api/openapi.json +209 -4
  9. package/docs/api-reference.md +7 -3
  10. package/docs/copy-inventory.md +109 -92
  11. package/docs/copy-registry.json +291 -108
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-inventory.json +2 -0
  14. package/docs/page-readings.json +111 -97
  15. package/docs/recipes/autobongos-runner-setup.md +148 -0
  16. package/docs/recipes/autobongos-windows-host.md +10 -7
  17. package/modules/autonomy/cadence.js +3 -0
  18. package/modules/autonomy/db.js +86 -7
  19. package/modules/autonomy/fence.js +20 -5
  20. package/modules/autonomy/migrations/autonomy_006_runner_device.sql +45 -0
  21. package/modules/autonomy/module.json +2 -1
  22. package/modules/autonomy/routes/autonomy.js +165 -3
  23. package/modules/autonomy/runner-health.js +27 -0
  24. package/modules/autonomy/skills/autobongos-setup/SKILL.md +86 -0
  25. package/modules/hall-ui/public/commands-lib.js +1 -1
  26. package/modules/hall-ui/public/gate.js +12 -5
  27. package/modules/hall-ui/public/runner-button.css +55 -0
  28. package/modules/hall-ui/public/runner-button.js +187 -0
  29. package/modules/hall-ui/public/settings-autobongos.js +349 -63
  30. package/modules/hall-ui/public/settings.html +23 -10
  31. package/modules/hall-ui/records/runner-button.md +16 -0
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +14 -0
  35. package/scripts/bongos/autobongos-host.js +16 -2
  36. package/scripts/bongos/autobongos-run.js +18 -3
  37. package/scripts/bongos/stranded-rows.js +39 -2
  38. package/scripts/hall-preview/server.js +7 -0
  39. package/src/module-api.js +1 -1
  40. package/tests/autobongos_host.mjs +28 -0
  41. package/tests/autonomy_config_idle.mjs +9 -4
  42. package/tests/autonomy_runner_device.mjs +444 -0
  43. package/tests/autonomy_runner_identity.mjs +3 -1
  44. package/tests/hall_runner_button.mjs +467 -0
  45. package/tests/hall_runner_panel_connect.mjs +73 -0
  46. package/tests/stranded_rows.mjs +50 -0
@@ -0,0 +1,444 @@
1
+ // tests/autonomy_runner_device.mjs — each device's own Run switch (task 1005094).
2
+ //
3
+ // Owner direction, 2026-11-09: running Autobongos on a device is ONE button on
4
+ // that device's card, with any cooldown shown on the button. The server half of
5
+ // that is a switch per device, and what must hold about it:
6
+ //
7
+ // · the switch is the LAST layer of the fence, read by the runner as part of
8
+ // the fence it already reads — and a caller that names no device is exactly
9
+ // as fenced as before;
10
+ // · nothing on the device can move it: the two routes that do refuse a
11
+ // runner's own login, and a check-in can only set the value a NEW row is
12
+ // born with;
13
+ // · a device set up the new way starts stopped; one that predates this keeps
14
+ // running;
15
+ // · a press wakes a waiting runner (the fence's updated_at moves);
16
+ // · a resting runner's resume time reaches the card, sanity-checked.
17
+ //
18
+ // The pure pieces are driven directly; the routes are driven as REAL requests
19
+ // through the assembled router with only the auth gates stubbed (the
20
+ // autonomy_builder_scope precedent); the SQL runs against a stubbed pool.
21
+ //
22
+ // DB-free, no network. Run: node --test tests/autonomy_runner_device.mjs
23
+
24
+ import { test } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import { createRequire } from 'node:module';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const req = createRequire(import.meta.url);
32
+ const express = req('express');
33
+ const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
34
+ const fence = req(path.join(ROOT, 'modules/autonomy/fence.js'));
35
+ const cadence = req(path.join(ROOT, 'modules/autonomy/cadence.js'));
36
+ const configIdle = req(path.join(ROOT, 'modules/autonomy/config-idle.js'));
37
+ const health = req(path.join(ROOT, 'modules/autonomy/runner-health.js'));
38
+
39
+ const PROJECT = Object.freeze({
40
+ enabled: true, paused_reason: null, priority_goal_id: null,
41
+ updated_at: '2026-11-01T09:00:00.000Z', updated_by: '3',
42
+ goals: [{ goal_id: 1000118, note: 'bug squash' }],
43
+ });
44
+ const SCOPE = Object.freeze({ goals: [1000118], owner_paused: false, updated_at: '2026-11-02T09:00:00.000Z' });
45
+
46
+ // ── the fence's last layer ───────────────────────────────────────────────────
47
+
48
+ test('a device its builder stopped is fenced off, and the runner is told which stop it is', () => {
49
+ const f = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device: { host: 'ada-laptop', run_requested: false, run_changed_at: '2026-11-03T09:00:00.000Z' } });
50
+ assert.equal(f.enabled, false);
51
+ assert.equal(f.stop_code, 'device_stopped');
52
+ assert.deepEqual(f.device, { host: 'ada-laptop', run_requested: false });
53
+ const d = fence.decideRun({ fence: f, graderBypassed: false });
54
+ assert.equal(d.go, false);
55
+ assert.equal(d.code, 'device_stopped');
56
+ assert.match(d.reason, /press Run/, 'the runner log says how to start it');
57
+ });
58
+
59
+ test('a device that is switched on, or a caller that names no device, is exactly as fenced as before', () => {
60
+ const before = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true });
61
+ for (const device of [null, undefined, { host: 'pc', run_requested: true, run_changed_at: null }]) {
62
+ const f = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device });
63
+ assert.equal(f.enabled, true, JSON.stringify(device));
64
+ assert.deepEqual(f.goals, before.goals);
65
+ assert.equal(f.stop_code, null);
66
+ }
67
+ assert.equal(before.device, null);
68
+ // Only an explicit false stops: a row that somehow lacks the column is not a stop.
69
+ assert.equal(fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device: { host: 'pc' } }).enabled, true);
70
+ });
71
+
72
+ test('the device switch is the LAST reason given: every wider stop is reported first', () => {
73
+ const device = { host: 'pc', run_requested: false };
74
+ const off = fence.effectiveFence({ project: { ...PROJECT, enabled: false, paused_reason: 'night off' }, scope: SCOPE, mayRun: true, device });
75
+ assert.equal(off.stop_code, 'kill_switch');
76
+ assert.equal(fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: false, device }).stop_code, 'not_permitted');
77
+ assert.equal(fence.effectiveFence({ project: PROJECT, scope: { ...SCOPE, owner_paused: true }, mayRun: true, device }).stop_code, 'builder_paused');
78
+ });
79
+
80
+ test('a press of Run moves the fence stamp a waiting runner is watching', () => {
81
+ const pressed = '2026-11-09T12:00:00.000Z';
82
+ const f = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device: { host: 'pc', run_requested: true, run_changed_at: pressed } });
83
+ assert.equal(f.updated_at, pressed, 'later than the project and the picks, so it wins');
84
+ const older = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device: { host: 'pc', run_requested: true, run_changed_at: '2026-10-01T00:00:00.000Z' } });
85
+ assert.equal(older.updated_at, new Date(SCOPE.updated_at).toISOString(), 'an old press never moves the stamp backwards');
86
+ });
87
+
88
+ test('the runner waits out a device stop as a stop, and nobody is alerted about it', () => {
89
+ const row = { event: 'fenced', code: 'device_stopped', reason: 'this device is stopped' };
90
+ assert.equal(cadence.classifyEvent(row).class, 'stopped', 'a person decided: no failure ladder, no probing');
91
+ assert.equal(configIdle.configIdleCode(row), null, 'the builder’s own Stop is never a setting someone must fix');
92
+ assert.equal(configIdle.trackIdle(null, row, Date.now()), null);
93
+ });
94
+
95
+ // ── what the card is told ────────────────────────────────────────────────────
96
+
97
+ const NOW = Date.parse('2026-11-09T12:00:00.000Z');
98
+ const beat = (over = {}) => ({ host: 'ada-laptop', last_seen_at: '2026-11-09T11:59:30.000Z', last_event: 'loop', run_requested: true, ...over });
99
+
100
+ test('a stopped device that is still checking in reads as stopped, not running and not an alarm', () => {
101
+ const h = health.describeRunner(beat({ run_requested: false, last_event: 'fenced' }), { nowMs: NOW });
102
+ assert.equal(h.state, 'stopped');
103
+ assert.equal(h.ok, true);
104
+ assert.equal(h.runRequested, false);
105
+ assert.match(h.detail, /stopped on ada-laptop/);
106
+ });
107
+
108
+ test('a device stopped mid-task is still the working runner it is until the task ends', () => {
109
+ const h = health.describeRunner(beat({ run_requested: false, working_task_id: 1004711 }), { nowMs: NOW });
110
+ assert.equal(h.state, 'alive');
111
+ assert.equal(h.workingTaskId, '1004711');
112
+ assert.equal(h.runRequested, false, 'and the card can say Stopping');
113
+ });
114
+
115
+ test('silence wins over the switch: a stopped device nobody has heard from is late or dead first', () => {
116
+ assert.equal(health.describeRunner(beat({ run_requested: false, last_seen_at: '2026-11-09T11:00:00.000Z' }), { nowMs: NOW }).state, 'late');
117
+ assert.equal(health.describeRunner(beat({ run_requested: false, last_seen_at: '2026-11-08T00:00:00.000Z' }), { nowMs: NOW }).state, 'dead');
118
+ });
119
+
120
+ test('every state carries the switch and a rest time that is still ahead', () => {
121
+ const resting = health.describeRunner(beat({ last_event: 'hold', hold_until: '2026-11-09T15:40:00.000Z' }), { nowMs: NOW });
122
+ assert.equal(resting.state, 'alive');
123
+ assert.equal(resting.holdUntil, '2026-11-09T15:40:00.000Z');
124
+ assert.equal(resting.runRequested, true);
125
+ assert.equal(health.describeRunner(beat({ hold_until: new Date('2026-11-09T15:40:00.000Z') }), { nowMs: NOW }).holdUntil, '2026-11-09T15:40:00.000Z', 'the database hands back a Date');
126
+ assert.equal(health.describeRunner(beat({ hold_until: '2026-11-09T11:00:00.000Z' }), { nowMs: NOW }).holdUntil, null, 'a rest already over is not a rest');
127
+ assert.equal(health.describeRunner(beat({ hold_until: 'soon' }), { nowMs: NOW }).holdUntil, null);
128
+ assert.equal(health.describeRunner(beat({ run_requested: undefined }), { nowMs: NOW }).runRequested, true, 'a row from before the column reads as switched on');
129
+ });
130
+
131
+ // ── the routes ───────────────────────────────────────────────────────────────
132
+
133
+ const permStub = (key) => {
134
+ const mw = (_req, _res, next) => next();
135
+ mw.permKey = key;
136
+ return mw;
137
+ };
138
+ // Who is calling: an ordinary session of builder 7, or builder 7's runner login.
139
+ let caller = { runner: false, sessionId: 501 };
140
+ const passBuilder = (reqq, _res, next) => {
141
+ reqq.builder = { id: 7, rank: 'metic', runner: caller.runner };
142
+ reqq.gdsSession = { session_id: caller.sessionId, source: caller.runner ? 'runner' : 'web' };
143
+ reqq.rawPermissionKeys = ['autonomy.run'];
144
+ next();
145
+ };
146
+
147
+ function autonomyRouter() {
148
+ const api = req(path.join(ROOT, 'src/module-api.js'));
149
+ const saved = { b: api.requireBuilder, p: api.requirePermission };
150
+ api.requireBuilder = passBuilder;
151
+ api.requirePermission = permStub;
152
+ const routePath = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
153
+ delete req.cache[routePath];
154
+ try { return req(routePath)(); } finally { api.requireBuilder = saved.b; api.requirePermission = saved.p; }
155
+ }
156
+
157
+ async function serve(router) {
158
+ const app = express();
159
+ app.use(express.json());
160
+ app.use(attachFail);
161
+ app.use(router);
162
+ const server = app.listen(0);
163
+ await new Promise((r) => server.once('listening', r));
164
+ return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
165
+ }
166
+
167
+ const send = (base, method, p, body) => fetch(`${base}${p}`, {
168
+ method, headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body),
169
+ });
170
+
171
+ function gateOf(router, p, method) {
172
+ const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
173
+ assert.ok(layer, `${method.toUpperCase()} ${p} is registered`);
174
+ return layer.route.stack.map((s) => s.handle.permKey).filter(Boolean);
175
+ }
176
+
177
+ // Swap db functions for one test, and put them back whatever happens.
178
+ async function withDb(stubs, fn) {
179
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
180
+ const api = req(path.join(ROOT, 'src/module-api.js'));
181
+ const saved = Object.fromEntries(Object.keys(stubs).map((k) => [k, db[k]]));
182
+ const savedResolve = api.resolveOptional;
183
+ Object.assign(db, stubs);
184
+ api.resolveOptional = (name) => (name === 'government' ? { effectivePermissions: (keys) => keys, builderHasPermissions: async () => true } : savedResolve(name));
185
+ const s = await serve(autonomyRouter());
186
+ try { await fn(s.base); } finally {
187
+ s.close();
188
+ Object.assign(db, saved);
189
+ api.resolveOptional = savedResolve;
190
+ caller = { runner: false, sessionId: 501 };
191
+ }
192
+ }
193
+
194
+ test('the gates: Run and Stop behind autonomy.run, Remove open to any builder, the setup scripts public', () => {
195
+ const router = autonomyRouter();
196
+ assert.deepEqual(gateOf(router, '/autonomy/runners/:host/run', 'post'), ['autonomy.run']);
197
+ assert.deepEqual(gateOf(router, '/autonomy/runners/:host', 'delete'), [], 'a builder who lost autonomy.run can still clear their own device');
198
+ assert.deepEqual(gateOf(router, '/autonomy/runner-setup.ps1', 'get'), []);
199
+ assert.deepEqual(gateOf(router, '/autonomy/runner-setup.sh', 'get'), []);
200
+ });
201
+
202
+ test('the fence read folds in the named device, and only that builder’s', async () => {
203
+ const asked = [];
204
+ let device = { host: 'ada-laptop', run_requested: false, run_changed_at: '2026-11-09T12:00:00.000Z' };
205
+ await withDb({
206
+ readFence: async () => PROJECT,
207
+ readBuilderScope: async () => SCOPE,
208
+ readRunnerDevice: async (builderId, host) => { asked.push([builderId, host]); return device; },
209
+ }, async (base) => {
210
+ let body = await (await send(base, 'GET', '/autonomy/fence?host=ADA-Laptop')).json();
211
+ assert.equal(body.enabled, false);
212
+ assert.equal(body.stop_code, 'device_stopped');
213
+ assert.deepEqual(asked, [[7, 'ada-laptop']], 'the SESSION builder’s device, by its lowercased name');
214
+
215
+ device = { host: 'ada-laptop', run_requested: true, run_changed_at: '2026-11-09T12:05:00.000Z' };
216
+ body = await (await send(base, 'GET', '/autonomy/fence?host=ada-laptop')).json();
217
+ assert.equal(body.enabled, true);
218
+ assert.equal(body.updated_at, '2026-11-09T12:05:00.000Z');
219
+
220
+ // No device named: no device read at all, and the answer is the builder's fence.
221
+ asked.length = 0;
222
+ body = await (await send(base, 'GET', '/autonomy/fence')).json();
223
+ assert.equal(body.enabled, true);
224
+ assert.equal(body.device, null);
225
+ assert.deepEqual(asked, []);
226
+
227
+ // A name that is not a device name is treated as not named — never a failed read.
228
+ const odd = await send(base, 'GET', `/autonomy/fence?host=${encodeURIComponent('pc; DROP TABLE')}`);
229
+ assert.equal(odd.status, 200);
230
+ assert.deepEqual(asked, []);
231
+
232
+ // A device that has never checked in has no row: no device layer.
233
+ device = null;
234
+ body = await (await send(base, 'GET', '/autonomy/fence?host=new-pc')).json();
235
+ assert.equal(body.enabled, true);
236
+ });
237
+ });
238
+
239
+ test('a runner’s own login cannot dodge its device’s Stop by leaving the name off, or by naming another device', async () => {
240
+ const asked = [];
241
+ let mine = { host: 'ada-laptop', run_requested: false, run_changed_at: '2026-11-09T12:00:00.000Z' };
242
+ await withDb({
243
+ readFence: async () => PROJECT,
244
+ readBuilderScope: async () => SCOPE,
245
+ readRunnerDeviceBySession: async (builderId, sessionId) => { asked.push(['session', builderId, sessionId]); return mine; },
246
+ readRunnerDevice: async (builderId, host) => { asked.push(['host', builderId, host]); return { host, run_requested: true, run_changed_at: null }; },
247
+ }, async (base) => {
248
+ caller = { runner: true, sessionId: 9 };
249
+ for (const query of ['', '?host=ada-laptop', '?host=some-other-device-that-is-running']) {
250
+ asked.length = 0;
251
+ const body = await (await send(base, 'GET', `/autonomy/fence${query}`)).json();
252
+ assert.equal(body.enabled, false, `a stopped device stays stopped (${query || 'no name'})`);
253
+ assert.equal(body.stop_code, 'device_stopped');
254
+ assert.deepEqual(asked, [['session', 7, 9]], 'found by the session the server holds; the name sent is never consulted');
255
+ }
256
+
257
+ // Switched on, it works — by the same lookup.
258
+ mine = { host: 'ada-laptop', run_requested: true, run_changed_at: '2026-11-09T12:05:00.000Z' };
259
+ assert.equal((await (await send(base, 'GET', '/autonomy/fence')).json()).enabled, true);
260
+
261
+ // A runner login that has not checked in yet has not said which device it is:
262
+ // it reads as stopped, never as a caller with no device layer.
263
+ mine = null;
264
+ const fresh = await (await send(base, 'GET', '/autonomy/fence?host=brand-new')).json();
265
+ assert.equal(fresh.enabled, false);
266
+ assert.equal(fresh.stop_code, 'device_stopped');
267
+
268
+ // The builder's own session is still asked by name, and only when it gives one.
269
+ caller = { runner: false, sessionId: 501 };
270
+ asked.length = 0;
271
+ assert.equal((await (await send(base, 'GET', '/autonomy/fence?host=old-box')).json()).enabled, true);
272
+ assert.deepEqual(asked, [['host', 7, 'old-box']]);
273
+ });
274
+ });
275
+
276
+ test('Run and Stop set that one device’s switch, for the caller only', async () => {
277
+ const writes = [];
278
+ await withDb({
279
+ setRunnerRun: async (args) => { writes.push(args); return args.host === 'ada-laptop' ? { host: 'ada-laptop', run_requested: args.run, run_changed_at: '2026-11-09T12:00:00.000Z' } : null; },
280
+ }, async (base) => {
281
+ let r = await send(base, 'POST', '/autonomy/runners/Ada-Laptop/run', { run: true });
282
+ assert.equal(r.status, 200);
283
+ assert.deepEqual(await r.json(), { host: 'ada-laptop', run_requested: true, run_changed_at: '2026-11-09T12:00:00.000Z' });
284
+ r = await send(base, 'POST', '/autonomy/runners/ada-laptop/run', { run: false });
285
+ assert.equal((await r.json()).run_requested, false);
286
+ assert.deepEqual(writes, [{ builderId: 7, host: 'ada-laptop', run: true }, { builderId: 7, host: 'ada-laptop', run: false }]);
287
+
288
+ assert.equal((await send(base, 'POST', '/autonomy/runners/someone-elses-pc/run', { run: true })).status, 404, 'a device that is not the caller’s is simply not found');
289
+ assert.equal((await send(base, 'POST', '/autonomy/runners/ada-laptop/run', {})).status, 400, 'run is required');
290
+ assert.equal((await send(base, 'POST', '/autonomy/runners/ada-laptop/run', { run: 'yes' })).status, 400, 'and must be a boolean');
291
+ assert.equal((await send(base, 'POST', '/autonomy/runners/ada-laptop/run', { run: true, builder_id: 3 })).status, 400, 'no extra field can name another builder');
292
+ });
293
+ });
294
+
295
+ test('a runner’s own login can neither start, stop nor remove itself', async () => {
296
+ let touched = 0;
297
+ await withDb({
298
+ setRunnerRun: async () => { touched += 1; return { host: 'ada-laptop', run_requested: true }; },
299
+ removeRunner: async () => { touched += 1; return { host: 'ada-laptop', session_id: '9' }; },
300
+ }, async (base) => {
301
+ caller = { runner: true, sessionId: 9 };
302
+ for (const [method, p, body] of [['POST', '/autonomy/runners/ada-laptop/run', { run: true }], ['POST', '/autonomy/runners/ada-laptop/run', { run: false }], ['DELETE', '/autonomy/runners/ada-laptop', undefined]]) {
303
+ const r = await send(base, method, p, body);
304
+ assert.equal(r.status, 403, `${method} ${p}`);
305
+ assert.equal((await r.json()).error.code, 'runner_session');
306
+ }
307
+ assert.equal(touched, 0, 'refused before anything was read or written');
308
+ });
309
+ });
310
+
311
+ test('Remove forgets the caller’s device and says whether it had a login of its own', async () => {
312
+ const removed = [];
313
+ await withDb({
314
+ removeRunner: async (args) => { removed.push(args); return args.host === 'ada-laptop' ? { host: 'ada-laptop', session_id: '8801' } : (args.host === 'old-box' ? { host: 'old-box', session_id: null } : null); },
315
+ }, async (base) => {
316
+ assert.deepEqual(await (await send(base, 'DELETE', '/autonomy/runners/ada-laptop')).json(), { removed: true, host: 'ada-laptop', had_own_login: true });
317
+ assert.deepEqual(await (await send(base, 'DELETE', '/autonomy/runners/old-box')).json(), { removed: true, host: 'old-box', had_own_login: false });
318
+ assert.equal((await send(base, 'DELETE', '/autonomy/runners/not-mine')).status, 404);
319
+ assert.deepEqual(removed.map((a) => a.builderId), [7, 7, 7], 'always the session builder');
320
+ });
321
+ });
322
+
323
+ test('a check-in records the rest time and the runner’s own login from the SESSION, and a new device starts stopped', async () => {
324
+ const beats = [];
325
+ await withDb({ recordRunnerHeartbeat: async (args) => { beats.push(args); return { host: args.host }; } }, async (base) => {
326
+ const inTwoHours = Math.floor(Date.now() / 1000) + 7200;
327
+
328
+ // A runner's own login: its session id is recorded, and a new row starts stopped.
329
+ caller = { runner: true, sessionId: 9 };
330
+ let r = await send(base, 'POST', '/autonomy/heartbeat', { host: 'ada-laptop', last_event: 'hold', hold_until: inTwoHours, session_id: 4242 });
331
+ assert.equal(r.status, 400, 'the body cannot name a session');
332
+ r = await send(base, 'POST', '/autonomy/heartbeat', { host: 'ada-laptop', last_event: 'hold', hold_until: inTwoHours });
333
+ assert.equal(r.status, 200);
334
+ assert.equal(beats[0].sessionId, 9, 'from the session that checked in');
335
+ assert.equal(beats[0].startStopped, true);
336
+ assert.equal(Math.round(beats[0].holdUntil.getTime() / 1000), inTwoHours);
337
+
338
+ // The builder's own CLI session (a runner that predates this): no login of its
339
+ // own to revoke, and a new row is born running, as it always was.
340
+ caller = { runner: false, sessionId: 501 };
341
+ await send(base, 'POST', '/autonomy/heartbeat', { host: 'old-box', last_event: 'loop' });
342
+ assert.equal(beats[1].sessionId, null);
343
+ assert.equal(beats[1].startStopped, false);
344
+ assert.equal(beats[1].holdUntil, null);
345
+
346
+ // A rest time that no real rest can have is dropped, never printed on a button.
347
+ for (const bad of [Math.floor(Date.now() / 1000) + 30 * 24 * 3600, 5, -1]) {
348
+ beats.length = 0;
349
+ await send(base, 'POST', '/autonomy/heartbeat', { host: 'old-box', hold_until: bad });
350
+ assert.equal(beats[0].holdUntil, null, String(bad));
351
+ }
352
+ assert.equal((await send(base, 'POST', '/autonomy/heartbeat', { host: 'old-box', hold_until: 'soon' })).status, 400);
353
+ });
354
+ });
355
+
356
+ test('the caller’s devices come back with the switch, the rest time and the server’s verdict', async () => {
357
+ await withDb({
358
+ readRunnerHeartbeats: async () => [
359
+ { host: 'ada-laptop', last_seen_at: new Date().toISOString(), last_event: 'fenced', run_requested: false, run_changed_at: null, hold_until: null, session_id: '8801' },
360
+ { host: 'old-box', last_seen_at: new Date().toISOString(), last_event: 'hold', run_requested: true, run_changed_at: null, hold_until: new Date(Date.now() + 3600 * 1000).toISOString(), session_id: null },
361
+ ],
362
+ }, async (base) => {
363
+ const { runners } = await (await send(base, 'GET', '/autonomy/runners')).json();
364
+ assert.equal(runners[0].health.state, 'stopped');
365
+ assert.equal(runners[0].run_requested, false);
366
+ assert.equal(runners[0].session_id, '8801', 'the caller’s own session id, for Remove');
367
+ assert.equal(runners[1].health.state, 'alive');
368
+ assert.ok(runners[1].health.holdUntil, 'a rest still ahead rides on the verdict');
369
+ });
370
+ });
371
+
372
+ test('the project serves the two first lines of setup to a device with no login', async () => {
373
+ await withDb({}, async (base) => {
374
+ const ps = await send(base, 'GET', '/autonomy/runner-setup.ps1');
375
+ assert.equal(ps.status, 200);
376
+ assert.match(ps.headers.get('content-type'), /text\/plain/);
377
+ assert.equal(ps.headers.get('cache-control'), 'no-store');
378
+ const psText = await ps.text();
379
+ assert.match(psText, /autobongos-host\.js/);
380
+ assert.match(psText, /\$Code/);
381
+
382
+ const sh = await send(base, 'GET', '/autonomy/runner-setup.sh');
383
+ assert.equal(sh.status, 200);
384
+ const shText = await sh.text();
385
+ assert.match(shText, /^#!\/usr\/bin\/env bash/);
386
+ // Nothing about THIS instance is written into either: no address, no code.
387
+ for (const text of [psText, shText]) assert.doesNotMatch(text, /abr_[0-9a-f]{16}/);
388
+ });
389
+ });
390
+
391
+ // ── the SQL ──────────────────────────────────────────────────────────────────
392
+
393
+ // The module's queries, run against a stand-in pool (the doorway's shared pool
394
+ // object, with its query swapped for the length of one test).
395
+ async function withPool(answer, fn) {
396
+ const api = req(path.join(ROOT, 'src/module-api.js'));
397
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
398
+ const queries = [];
399
+ const saved = api.pool.query;
400
+ api.pool.query = async (text, params) => { queries.push({ text, params }); return answer(text, params) || { rows: [] }; };
401
+ try { await fn(db, queries); } finally { api.pool.query = saved; }
402
+ }
403
+
404
+ test('a check-in can set the switch only on a row it creates: the conflict arm never names it', async () => {
405
+ await withPool(() => ({ rows: [{ host: 'pc' }] }), async (db, queries) => {
406
+ await db.recordRunnerHeartbeat({ builderId: 7, host: 'PC', sessionId: 9, startStopped: true, holdUntil: new Date('2026-11-09T15:40:00Z') });
407
+ await db.recordRunnerHeartbeat({ builderId: 7, host: 'old-box' });
408
+ const [fresh, legacy] = queries;
409
+ const [insert, conflict] = fresh.text.split('ON CONFLICT');
410
+ assert.match(insert, /run_requested/);
411
+ assert.doesNotMatch(conflict.split('RETURNING')[0], /run_requested/, 'an existing device keeps the switch its builder set, whatever checks in');
412
+ assert.match(conflict, /hold_until = EXCLUDED\.hold_until/, 'a runner that stopped resting stops saying so');
413
+ assert.match(conflict, /session_id = EXCLUDED\.session_id/);
414
+ assert.equal(fresh.params[0], 'pc', 'the host is the lowercased key');
415
+ assert.equal(fresh.params[15], false, 'a new device added the new way is born stopped');
416
+ assert.equal(legacy.params[15], true, 'a runner on its builder’s own session is born running, as before');
417
+ assert.equal(fresh.params[14], 9);
418
+ assert.equal(legacy.params[14], null);
419
+ });
420
+ });
421
+
422
+ test('the switch and the removal are keyed by the builder AND the device', async () => {
423
+ await withPool((text) => (/^\s*DELETE/.test(text) ? { rows: [{ host: 'pc', session_id: 8801n }] } : { rows: [{ host: 'pc', run_requested: true, run_changed_at: 't' }] }), async (db, queries) => {
424
+ await db.setRunnerRun({ builderId: 7, host: 'PC', run: 'truthy-but-not-true' });
425
+ assert.match(queries[0].text, /WHERE builder_id = \$1 AND host = \$2/);
426
+ assert.match(queries[0].text, /run_changed_at = now\(\)/);
427
+ assert.deepEqual(queries[0].params, [7, 'pc', false], 'only a real true switches it on');
428
+
429
+ const out = await db.removeRunner({ builderId: 7, host: 'PC' });
430
+ assert.match(queries[1].text, /DELETE FROM autonomy_runner_heartbeat WHERE builder_id = \$1 AND host = \$2/);
431
+ assert.deepEqual(out, { host: 'pc', session_id: '8801' });
432
+
433
+ await db.readRunnerDevice(7, 'PC');
434
+ assert.deepEqual(queries[2].params, [7, 'pc']);
435
+ assert.equal(await db.readRunnerDevice(7, ''), null, 'no name, no read');
436
+ assert.equal(queries.length, 3);
437
+
438
+ await db.readRunnerDeviceBySession(7, 9);
439
+ assert.match(queries[3].text, /WHERE builder_id = \$1 AND session_id = \$2/);
440
+ assert.deepEqual(queries[3].params, [7, 9]);
441
+ assert.equal(await db.readRunnerDeviceBySession(7, null), null, 'no session, no read');
442
+ assert.equal(queries.length, 4);
443
+ });
444
+ });
@@ -189,7 +189,9 @@ test('the heartbeat SQL overwrites the identity every beat and reads it back', a
189
189
  for (const col of ['head', 'disk_head', 'main_head', 'claude_account']) {
190
190
  assert.match(calls[0].sql, new RegExp(`\\b${col} = EXCLUDED\\.${col}\\b`), `${col} is overwritten, never kept from the last process`);
191
191
  }
192
- assert.deepEqual(calls[0].params.slice(9), [A, B, B, 'me@example.com']);
192
+ // The four identity values, in their own places; what follows them is the
193
+ // device's rest time, login and birth switch (task 1005094).
194
+ assert.deepEqual(calls[0].params.slice(9, 13), [A, B, B, 'me@example.com']);
193
195
  await db.readRunnerHeartbeats(3);
194
196
  assert.match(calls[1].sql, /head, disk_head, main_head, claude_account/);
195
197
  } finally {