@bongos/core 1.19.876 → 1.19.878
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 +110 -40
- package/.gitattributes +6 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -1
- package/docs/api/openapi.json +149 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +7 -6
- package/docs/copy-registry.json +16 -7
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/autobongos-windows-host.md +163 -0
- package/docs/recipes/instance-deploy.md +29 -3
- package/modules/autonomy/cadence.js +276 -0
- package/modules/autonomy/db.js +47 -0
- package/modules/autonomy/digest.js +344 -0
- package/modules/autonomy/migrations/autonomy_002_runner_heartbeat.sql +93 -0
- package/modules/autonomy/routes/autonomy.js +69 -0
- package/modules/autonomy/runner-health.js +97 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/gate.html +4 -1
- package/modules/hall-ui/public/gate.js +51 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-digest.js +123 -0
- package/scripts/gds/autobongos-run.js +375 -11
- package/scripts/gds/autobongos-service.cmd +38 -0
- package/scripts/gds/autobongos-service.ps1 +135 -0
- package/scripts/gds/deploy-main-audit-gate.sh +90 -0
- package/scripts/gds/main-audit.js +26 -4
- package/scripts/gds/provision-core-upgrade.js +23 -3
- package/scripts/gds/run-unit-tests.js +16 -1
- package/src/module-api.js +1 -1
- package/tests/autobongos_cadence.mjs +602 -0
- package/tests/autonomy_digest.mjs +302 -0
- package/tests/autonomy_runner_health.mjs +135 -0
- package/tests/bump_version.mjs +12 -1
- package/tests/cli_package.mjs +11 -1
- package/tests/core_upgrade_runner.mjs +43 -1
- package/tests/deploy_audit_gate.mjs +154 -0
- package/tests/deploy_divergence_line.mjs +47 -1
- package/tests/helpers.mjs +61 -0
- package/tests/main_audit_db_roster.mjs +97 -0
- package/tests/publish_watch.mjs +9 -0
- package/tests/setup_device_flow_fallback.mjs +2 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// tests/autonomy_digest.mjs — the night, readable afterwards (task 1003906).
|
|
2
|
+
//
|
|
3
|
+
// The done-when is "a night produces one digest answering all five questions
|
|
4
|
+
// without anyone opening a log". So the spine of this file is one test that
|
|
5
|
+
// feeds a realistic night through and asserts all five answers are present and
|
|
6
|
+
// correct — because five separately-passing sections can still add up to a page
|
|
7
|
+
// that does not answer the question.
|
|
8
|
+
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { buildDigest, renderDigest, windowsFromGauge, humanDuration } = require('../modules/autonomy/digest.js');
|
|
15
|
+
const cli = require('../scripts/gds/autobongos-digest.js');
|
|
16
|
+
|
|
17
|
+
const T0 = Date.parse('2026-09-19T02:00:00.000Z');
|
|
18
|
+
const at = (m) => new Date(T0 + m * 60000).toISOString();
|
|
19
|
+
const NOW_S = Math.floor((T0 + 8 * 3600e3) / 1000);
|
|
20
|
+
|
|
21
|
+
const night = () => [
|
|
22
|
+
{ at: at(0), event: 'boot_reconcile', ok: true, leftover: 0 },
|
|
23
|
+
{ at: at(1), event: 'worked', task_id: 111, title: 'A shipped thing', goal_id: 1000119,
|
|
24
|
+
duration_s: 2820, cost_usd: 3.41, verified: true, ledger_shape: 'shipped',
|
|
25
|
+
undetermined_decisions: ['Cached for 60s rather than 5m'] },
|
|
26
|
+
{ at: at(50), event: 'worked', task_id: 222, title: 'A stranded thing', goal_id: 1000119,
|
|
27
|
+
duration_s: 5400, cost_usd: 6.8, verified: false, ledger_shape: 'unlanded',
|
|
28
|
+
reason: 'the grade passed and the land never finished', blocker_id: '1000161',
|
|
29
|
+
undetermined_decisions: ['Sorted inactive last', 'Kept the id tie-break', 'Chose ascending'] },
|
|
30
|
+
{ at: at(120), event: 'fenced', code: 'grader_unavailable', reason: 'the grader is bypassed' },
|
|
31
|
+
{ at: at(121), event: 'fenced', code: 'grader_unavailable', reason: 'the grader is bypassed' },
|
|
32
|
+
{ at: at(122), event: 'cadence', mode: 'probing', class: 'transient', consecutive_failures: 3, wait_s: 60 },
|
|
33
|
+
{ at: at(200), event: 'nothing_claimable', goals: [1000119],
|
|
34
|
+
skipped: [{ id: 333, goal_id: 1000119, reasons: ['needs_migration'] }] },
|
|
35
|
+
{ at: at(201), event: 'hold', reason: 'seven-day pace: 78% used' },
|
|
36
|
+
];
|
|
37
|
+
const gauge = {
|
|
38
|
+
five_hour: { used_percentage: 41, resets_at: NOW_S + 3600 },
|
|
39
|
+
seven_day: { used_percentage: 78, resets_at: NOW_S + 240000 },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ── the done-when, in one test ─────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
test('one night produces one page answering all five questions', () => {
|
|
45
|
+
const text = renderDigest(buildDigest(night(), { gauge, nowEpochS: NOW_S }));
|
|
46
|
+
|
|
47
|
+
// 1. what shipped — and ONLY what the ledger agreed shipped.
|
|
48
|
+
assert.match(text, /SHIPPED — 1 task/);
|
|
49
|
+
assert.match(text, /111\s+A shipped thing/);
|
|
50
|
+
assert.doesNotMatch(text.split('ON HOLD')[0], /222/, 'a task that ran but did not ship is not "shipped"');
|
|
51
|
+
|
|
52
|
+
// 2. what is on hold, and why.
|
|
53
|
+
assert.match(text, /ON HOLD/);
|
|
54
|
+
assert.match(text, /222\s+ran but did not ship — unlanded/);
|
|
55
|
+
assert.match(text, /blocker 1000161/);
|
|
56
|
+
assert.match(text, /fence: grader_unavailable ×2/);
|
|
57
|
+
assert.match(text, /every one halted/);
|
|
58
|
+
assert.match(text, /capacity: held 1 time/);
|
|
59
|
+
|
|
60
|
+
// 3. what each worker decided that its spec did not determine.
|
|
61
|
+
assert.match(text, /DECIDED FOR YOU — 4 decisions across 2 tasks/);
|
|
62
|
+
assert.match(text, /Cached for 60s rather than 5m/);
|
|
63
|
+
assert.match(text, /sentence the task description should have contained/);
|
|
64
|
+
assert.match(text, /Thinnest spec: 222/);
|
|
65
|
+
|
|
66
|
+
// 4. what it cost.
|
|
67
|
+
assert.match(text, /COST/);
|
|
68
|
+
assert.match(text, /\$10\.21 across 2 worker runs/);
|
|
69
|
+
|
|
70
|
+
// 5. where both limit windows stand.
|
|
71
|
+
assert.match(text, /Five-hour window\s+41% used, resets in 60m/);
|
|
72
|
+
assert.match(text, /Seven-day window\s+78% used/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('every section prints even when the night was empty', () => {
|
|
76
|
+
// A digest whose quiet sections vanish teaches its reader that absence means
|
|
77
|
+
// "fine" — and then the morning a section is missing because the DATA is
|
|
78
|
+
// missing reads exactly the same.
|
|
79
|
+
const text = renderDigest(buildDigest([], { gauge, nowEpochS: NOW_S }));
|
|
80
|
+
for (const heading of ['SHIPPED', 'ON HOLD', 'DECIDED FOR YOU', 'COST', 'LIMITS', 'RUNNER']) {
|
|
81
|
+
assert.ok(text.includes(heading), `${heading} must print on an empty night`);
|
|
82
|
+
}
|
|
83
|
+
assert.match(text, /nothing shipped/);
|
|
84
|
+
assert.match(text, /nothing is waiting on you/);
|
|
85
|
+
assert.match(text, /no worker had to fill a gap/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── the answers, individually ──────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
test('shipped is the LEDGER’s answer, never the worker’s', () => {
|
|
91
|
+
const d = buildDigest([
|
|
92
|
+
{ at: at(0), event: 'worked', task_id: 1, verified: true, ledger_shape: 'shipped' },
|
|
93
|
+
{ at: at(1), event: 'worked', task_id: 2, verified: false, ledger_shape: 'still_held' },
|
|
94
|
+
{ at: at(2), event: 'worked', task_id: 3, outcome: 'claims_shipped', ledger_shape: 'shipped_no_artifact', verified: false },
|
|
95
|
+
], { nowEpochS: NOW_S });
|
|
96
|
+
assert.deepEqual(d.shipped.map((s) => s.taskId), ['1']);
|
|
97
|
+
assert.deepEqual(d.onHold.ranButDidNotShip.map((r) => r.shape), ['still_held', 'shipped_no_artifact']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('an unworkable queue is reported as a queue problem, not as breakage', () => {
|
|
101
|
+
const d = buildDigest([{ at: at(0), event: 'nothing_claimable', skipped: [{ id: 9, reasons: ['needs_migration'] }] }], { nowEpochS: NOW_S });
|
|
102
|
+
assert.equal(d.onHold.unworkable.length, 1);
|
|
103
|
+
const text = renderDigest(d);
|
|
104
|
+
assert.match(text, /this is not a runner failure/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the decisions list groups by task and names the thinnest spec', () => {
|
|
108
|
+
// The SHAPE is the signal: one task with six decisions is a thin spec, six
|
|
109
|
+
// tasks with one each is a normal night.
|
|
110
|
+
const d = buildDigest([
|
|
111
|
+
{ at: at(0), event: 'worked', task_id: 1, verified: true, undetermined_decisions: ['a'] },
|
|
112
|
+
{ at: at(1), event: 'worked', task_id: 2, verified: true, undetermined_decisions: ['b', 'c', 'd'] },
|
|
113
|
+
{ at: at(2), event: 'worked', task_id: 3, verified: true, undetermined_decisions: [] },
|
|
114
|
+
], { nowEpochS: NOW_S });
|
|
115
|
+
assert.equal(d.decisionCount, 4);
|
|
116
|
+
assert.equal(d.decisions.length, 2, 'a task with no decisions is not listed');
|
|
117
|
+
assert.equal(d.thinnestSpec.taskId, '2');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a decision from a task that did NOT ship is still surfaced', () => {
|
|
121
|
+
// These are the ones that matter most: the judgement was made, the work is not
|
|
122
|
+
// in, and the call is sitting in a branch nobody has read.
|
|
123
|
+
const text = renderDigest(buildDigest([
|
|
124
|
+
{ at: at(0), event: 'worked', task_id: 7, title: 'T', verified: false, ledger_shape: 'unlanded', undetermined_decisions: ['picked a default'] },
|
|
125
|
+
], { nowEpochS: NOW_S }));
|
|
126
|
+
assert.match(text, /picked a default/);
|
|
127
|
+
assert.match(text, /\[did not ship\]/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('an unreported cost is excluded and counted, never coerced to zero', () => {
|
|
131
|
+
const d = buildDigest([
|
|
132
|
+
{ at: at(0), event: 'worked', task_id: 1, verified: true, cost_usd: 2.5 },
|
|
133
|
+
{ at: at(1), event: 'worked', task_id: 2, verified: true, cost_usd: null },
|
|
134
|
+
{ at: at(2), event: 'worked', task_id: 3, verified: true },
|
|
135
|
+
], { nowEpochS: NOW_S });
|
|
136
|
+
assert.equal(d.cost.totalUsd, 2.5, 'a worker whose cost was not reported must not make the night look cheaper');
|
|
137
|
+
assert.equal(d.cost.reported, 1);
|
|
138
|
+
assert.equal(d.cost.unreported, 2);
|
|
139
|
+
assert.match(renderDigest(d), /2 did not report a cost/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── the limit windows ──────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
test('a window whose reset has PASSED is reported as stale, not as headroom', () => {
|
|
145
|
+
// "41% used, resets in 0s" invites a decision about headroom that no longer
|
|
146
|
+
// exists: the percentage was true of a window that has since rolled.
|
|
147
|
+
const [five] = windowsFromGauge({ five_hour: { used_percentage: 41, resets_at: NOW_S - 60 } }, NOW_S);
|
|
148
|
+
assert.equal(five.known, false);
|
|
149
|
+
assert.equal(five.stale, true);
|
|
150
|
+
assert.match(five.detail, /has since reset/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a window the gauge never reported says so rather than reading as zero', () => {
|
|
154
|
+
const [five, seven] = windowsFromGauge({ seven_day: { used_percentage: 12, resets_at: NOW_S + 999 } }, NOW_S);
|
|
155
|
+
assert.equal(five.known, false);
|
|
156
|
+
assert.equal(five.detail, 'not reported');
|
|
157
|
+
assert.equal(seven.known, true);
|
|
158
|
+
for (const g of [null, undefined, {}, 'nope']) {
|
|
159
|
+
assert.deepEqual(windowsFromGauge(g, NOW_S).map((w) => w.known), [false, false], JSON.stringify(g));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── the runner footer ──────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
test('a night that ended probing says so — "nothing shipped" has two causes', () => {
|
|
166
|
+
const text = renderDigest(buildDigest([
|
|
167
|
+
{ at: at(0), event: 'cadence', mode: 'full', class: 'quiet' },
|
|
168
|
+
{ at: at(1), event: 'cadence', mode: 'probing', class: 'transient' },
|
|
169
|
+
], { nowEpochS: NOW_S }));
|
|
170
|
+
assert.match(text, /Ended the night PROBING/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('an event this digest does not know about is counted, never dropped', () => {
|
|
174
|
+
// A digest that silently ignores what it does not understand is how a new
|
|
175
|
+
// failure mode stays invisible for a week.
|
|
176
|
+
const d = buildDigest([{ at: at(0), event: 'something_new_entirely' }], { nowEpochS: NOW_S });
|
|
177
|
+
assert.equal(d.runner.unrecognised, 1);
|
|
178
|
+
assert.equal(d.runner.byEvent.something_new_entirely, 1);
|
|
179
|
+
assert.match(renderDigest(d), /a kind this digest does not know about/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('malformed rows never take the night down', () => {
|
|
183
|
+
const d = buildDigest([null, undefined, 42, 'nope', {}, { event: 'worked', verified: true }], { nowEpochS: NOW_S });
|
|
184
|
+
assert.equal(d.shipped.length, 1);
|
|
185
|
+
assert.doesNotThrow(() => renderDigest(d));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── the CLI half ───────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
test('--since accepts a span and defaults to local midnight', () => {
|
|
191
|
+
const now = new Date('2026-09-19T14:30:00');
|
|
192
|
+
assert.equal(cli.sinceMs('24h', now), now.getTime() - 86400e3);
|
|
193
|
+
assert.equal(cli.sinceMs('90m', now), now.getTime() - 5400e3);
|
|
194
|
+
assert.equal(cli.sinceMs('3d', now), now.getTime() - 3 * 86400e3);
|
|
195
|
+
// Local midnight, because "last night" is a local idea — a UTC cut would split
|
|
196
|
+
// an owner's evening in half depending on where they live.
|
|
197
|
+
const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
|
|
198
|
+
assert.equal(cli.sinceMs(null, now), midnight.getTime());
|
|
199
|
+
assert.equal(cli.sinceMs('soon', now), null, 'an unparseable span must refuse, not guess');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a torn last line costs one row, not the night', async () => {
|
|
203
|
+
// A JSONL written by a process that can be killed mid-write has a torn last
|
|
204
|
+
// line more often than never.
|
|
205
|
+
const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path');
|
|
206
|
+
const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ab-digest-')), 'runs.jsonl');
|
|
207
|
+
fs.writeFileSync(f, `${JSON.stringify({ at: at(0), event: 'worked', task_id: 1, verified: true })}\n{"at":"x","event":"wor`);
|
|
208
|
+
const { rows, skipped, missing } = cli.readRows(f);
|
|
209
|
+
assert.equal(missing, false);
|
|
210
|
+
assert.equal(rows.length, 1);
|
|
211
|
+
assert.equal(skipped, 1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('a missing log is reported, not mistaken for a quiet night', async () => {
|
|
215
|
+
const { rows, missing } = cli.readRows('/definitely/not/here/runs.jsonl');
|
|
216
|
+
assert.deepEqual(rows, []);
|
|
217
|
+
assert.equal(missing, true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('humanDuration reads like a person wrote it', () => {
|
|
221
|
+
assert.equal(humanDuration(45), '45s');
|
|
222
|
+
assert.equal(humanDuration(2820), '47m');
|
|
223
|
+
assert.equal(humanDuration(10080), '2.8h');
|
|
224
|
+
assert.equal(humanDuration(null), null);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── the grader's three findings (task 1003906) ─────────────────────────────────
|
|
228
|
+
|
|
229
|
+
test('the "did not ship" reason is the LEDGER’s, not the worker’s', () => {
|
|
230
|
+
// THE INVERSION THIS GOAL EXISTS TO UNDO. `row.reason` is verdict.reason — the
|
|
231
|
+
// WORKER's account of itself — and quoting it here means the digest reports the
|
|
232
|
+
// thing being checked rather than the check. A worker that stopped dead needing
|
|
233
|
+
// a decision reports success; the ledger says what actually happened.
|
|
234
|
+
const d = buildDigest([{
|
|
235
|
+
at: at(0), event: 'worked', task_id: 9, title: 'T', verified: false, ledger_shape: 'unlanded',
|
|
236
|
+
reason: 'I finished the work and shipped it',
|
|
237
|
+
ledger_reason: 'the grade passed and the land never finished',
|
|
238
|
+
ledger_remedy: '/merge-mode is the documented manual fallback',
|
|
239
|
+
}], { nowEpochS: NOW_S });
|
|
240
|
+
|
|
241
|
+
assert.equal(d.onHold.ranButDidNotShip[0].reason, 'the grade passed and the land never finished');
|
|
242
|
+
assert.equal(d.onHold.ranButDidNotShip[0].workerSaid, 'I finished the work and shipped it');
|
|
243
|
+
|
|
244
|
+
const text = renderDigest(d);
|
|
245
|
+
assert.match(text, /the grade passed and the land never finished/);
|
|
246
|
+
assert.match(text, /next: \/merge-mode/);
|
|
247
|
+
// The disagreement is the single most useful line on the page.
|
|
248
|
+
assert.match(text, /the worker said: I finished the work and shipped it/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('the worker’s account is printed only when it disagrees', () => {
|
|
252
|
+
// A worker that agreed with the ledger tells the reader nothing twice.
|
|
253
|
+
const same = 'the grade parked this';
|
|
254
|
+
const text = renderDigest(buildDigest([{
|
|
255
|
+
at: at(0), event: 'worked', task_id: 9, verified: false, ledger_shape: 'ungraded', reason: same, ledger_reason: same,
|
|
256
|
+
}], { nowEpochS: NOW_S }));
|
|
257
|
+
assert.equal((text.match(/the grade parked this/g) || []).length, 1);
|
|
258
|
+
assert.doesNotMatch(text, /the worker said:/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('a row from before ledger_reason existed still shows something', () => {
|
|
262
|
+
const d = buildDigest([{ at: at(0), event: 'worked', task_id: 9, verified: false, ledger_shape: 'unlanded', reason: 'older row' }], { nowEpochS: NOW_S });
|
|
263
|
+
assert.equal(d.onHold.ranButDidNotShip[0].reason, 'older row');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('the digest calls a gauge stale on the SAME rule the runner holds on', () => {
|
|
267
|
+
// A digest that applied a laxer test would print live headroom about a window
|
|
268
|
+
// the runner had already decided it could not vouch for — two surfaces
|
|
269
|
+
// disagreeing about one file, with the reader believing the wrong one.
|
|
270
|
+
const gaugeMod = require('../modules/autonomy/gauge.js');
|
|
271
|
+
const tooOld = gaugeMod.DEFAULT_STALE_AFTER_S + 60;
|
|
272
|
+
|
|
273
|
+
const [fresh] = windowsFromGauge(
|
|
274
|
+
{ five_hour: { used_percentage: 41, resets_at: NOW_S + 7200, observed_at: NOW_S - 60 } }, NOW_S);
|
|
275
|
+
assert.equal(fresh.known, true, 'a recent reading is usable');
|
|
276
|
+
|
|
277
|
+
const [stale] = windowsFromGauge(
|
|
278
|
+
{ five_hour: { used_percentage: 41, resets_at: NOW_S + 7200, observed_at: NOW_S - tooOld } }, NOW_S);
|
|
279
|
+
assert.equal(stale.known, false);
|
|
280
|
+
assert.equal(stale.stale, true);
|
|
281
|
+
assert.match(stale.detail, /older than the runner trusts/);
|
|
282
|
+
assert.doesNotMatch(stale.detail, /resets in/, 'never offer headroom from a reading the runner rejected');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('a window with no observed_at falls back to the file’s written_at', () => {
|
|
286
|
+
// One window can go unreported while the other is carried forward — the exact
|
|
287
|
+
// shape gauge.js reads.
|
|
288
|
+
const gaugeMod = require('../modules/autonomy/gauge.js');
|
|
289
|
+
const old = NOW_S - gaugeMod.DEFAULT_STALE_AFTER_S - 60;
|
|
290
|
+
const [w] = windowsFromGauge({ written_at: old, five_hour: { used_percentage: 41, resets_at: NOW_S + 7200 } }, NOW_S);
|
|
291
|
+
assert.equal(w.stale, true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('the period bounds survive a log too long to spread as arguments', () => {
|
|
295
|
+
// `Math.min(...times)` throws past the engine's argument cap, and a reporting
|
|
296
|
+
// CLI that dies on a big log fails exactly when the log is worth reading.
|
|
297
|
+
const many = Array.from({ length: 200000 }, (_, i) => ({ at: at(i % 1000), event: 'cadence', mode: 'full' }));
|
|
298
|
+
let d;
|
|
299
|
+
assert.doesNotThrow(() => { d = buildDigest(many, { nowEpochS: NOW_S }); });
|
|
300
|
+
assert.equal(d.period.rows, 200000);
|
|
301
|
+
assert.equal(d.period.fromMs, T0);
|
|
302
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// tests/autonomy_runner_health.mjs — a dead runner is visible, not silent (task 1003905).
|
|
2
|
+
//
|
|
3
|
+
// The done-when's second half: "a runner that has stopped heartbeating is visible
|
|
4
|
+
// from the hall." The asymmetry is the whole design — a running machine can say
|
|
5
|
+
// so, a dead one cannot say anything — so the signal is the AGE of the last
|
|
6
|
+
// check-in, never a status the runner writes. These pin the four answers and, more
|
|
7
|
+
// importantly, the three DIFFERENT kinds of quiet.
|
|
8
|
+
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { describeRunner, humanAge, ageS, ALIVE_S, LATE_S } = require('../modules/autonomy/runner-health.js');
|
|
15
|
+
|
|
16
|
+
const NOW = Date.parse('2026-09-19T12:00:00.000Z');
|
|
17
|
+
const agoS = (s) => new Date(NOW - s * 1000).toISOString();
|
|
18
|
+
const hb = (over = {}) => ({ host: 'winbox', pid: 4242, mode: 'full', last_seen_at: agoS(30), ...over });
|
|
19
|
+
|
|
20
|
+
test('no row at all is NEVER SEEN, and that is not a failure', () => {
|
|
21
|
+
// Telling an owner their runner "died" before it ever ran is how a status light
|
|
22
|
+
// trains people to ignore it.
|
|
23
|
+
for (const none of [null, undefined, 'nope', 42]) {
|
|
24
|
+
const d = describeRunner(none, { nowMs: NOW });
|
|
25
|
+
assert.equal(d.state, 'never_seen', String(none));
|
|
26
|
+
assert.equal(d.ok, true, 'never set up is not broken');
|
|
27
|
+
assert.match(d.detail, /has ever checked in/);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('the four states follow the age of the last check-in, nothing else', () => {
|
|
32
|
+
assert.equal(describeRunner(hb({ last_seen_at: agoS(10) }), { nowMs: NOW }).state, 'alive');
|
|
33
|
+
assert.equal(describeRunner(hb({ last_seen_at: agoS(ALIVE_S - 1) }), { nowMs: NOW }).state, 'alive');
|
|
34
|
+
assert.equal(describeRunner(hb({ last_seen_at: agoS(ALIVE_S + 1) }), { nowMs: NOW }).state, 'late');
|
|
35
|
+
assert.equal(describeRunner(hb({ last_seen_at: agoS(LATE_S - 1) }), { nowMs: NOW }).state, 'late');
|
|
36
|
+
assert.equal(describeRunner(hb({ last_seen_at: agoS(LATE_S + 1) }), { nowMs: NOW }).state, 'dead');
|
|
37
|
+
assert.equal(describeRunner(hb({ last_seen_at: agoS(86400) }), { nowMs: NOW }).state, 'dead');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a runner mid-worker goes quiet legitimately, and LATE says so rather than crying dead', () => {
|
|
41
|
+
// A worker may hold the loop for ninety minutes. Reporting every honest hour of
|
|
42
|
+
// work as a corpse is worse than saying nothing, because it teaches the owner to
|
|
43
|
+
// ignore the one that matters.
|
|
44
|
+
const building = describeRunner(hb({ last_seen_at: agoS(70 * 60), working_task_id: '1003905' }), { nowMs: NOW });
|
|
45
|
+
assert.equal(building.state, 'late');
|
|
46
|
+
assert.equal(building.ok, true, 'late is not an alarm');
|
|
47
|
+
assert.match(building.detail, /expected while task 1003905 builds/);
|
|
48
|
+
assert.equal(building.workingTaskId, '1003905');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a gap longer than any worker takes IS the alarm', () => {
|
|
52
|
+
const d = describeRunner(hb({ last_seen_at: agoS(5 * 3600), working_task_id: '1003905' }), { nowMs: NOW });
|
|
53
|
+
assert.equal(d.state, 'dead');
|
|
54
|
+
assert.equal(d.ok, false);
|
|
55
|
+
assert.match(d.detail, /longer than any worker takes/);
|
|
56
|
+
assert.match(d.detail, /winbox/, 'name the host — an owner with two machines needs to know which');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('an unreadable check-in raises the alarm rather than explaining it away', () => {
|
|
60
|
+
// A row that exists but cannot be read is a runner we heard from once and cannot
|
|
61
|
+
// account for now. On a liveness check the fail direction is to raise the alarm.
|
|
62
|
+
for (const bad of ['', 'not a date', null, undefined]) {
|
|
63
|
+
const d = describeRunner(hb({ last_seen_at: bad }), { nowMs: NOW });
|
|
64
|
+
assert.equal(d.state, 'dead', JSON.stringify(bad));
|
|
65
|
+
assert.equal(d.ok, false);
|
|
66
|
+
}
|
|
67
|
+
// And it is NOT never_seen — the row exists, so something ran once.
|
|
68
|
+
assert.notEqual(describeRunner(hb({ last_seen_at: 'x' }), { nowMs: NOW }).state, 'never_seen');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a probing runner reads as alive, because probing is working', () => {
|
|
72
|
+
const d = describeRunner(hb({ mode: 'probing', last_seen_at: agoS(20) }), { nowMs: NOW });
|
|
73
|
+
assert.equal(d.state, 'alive');
|
|
74
|
+
assert.equal(d.mode, 'probing');
|
|
75
|
+
assert.match(d.detail, /probing after failures/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a clock skew into the future never reads as ancient', () => {
|
|
79
|
+
const d = describeRunner(hb({ last_seen_at: new Date(NOW + 60_000).toISOString() }), { nowMs: NOW });
|
|
80
|
+
assert.equal(d.ageS, 0);
|
|
81
|
+
assert.equal(d.state, 'alive');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('every answer carries a readable age and a sentence', () => {
|
|
85
|
+
for (const s of [0, 30, 300, ALIVE_S + 60, LATE_S + 60, 86400 * 3]) {
|
|
86
|
+
const d = describeRunner(hb({ last_seen_at: agoS(s) }), { nowMs: NOW });
|
|
87
|
+
assert.ok(typeof d.detail === 'string' && d.detail.length, `${s}s needs a sentence`);
|
|
88
|
+
assert.ok(typeof d.age === 'string' && d.age.length, `${s}s needs a readable age`);
|
|
89
|
+
assert.equal(d.ageS, s);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('humanAge reads like a person wrote it', () => {
|
|
94
|
+
assert.equal(humanAge(45), '45s');
|
|
95
|
+
assert.equal(humanAge(300), '5m');
|
|
96
|
+
assert.equal(humanAge(7200), '2h');
|
|
97
|
+
assert.equal(humanAge(86400 * 3), '3d');
|
|
98
|
+
assert.equal(humanAge(NaN), 'unknown');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('ageS refuses to invent a number it cannot compute', () => {
|
|
102
|
+
assert.equal(ageS('nope', NOW), null);
|
|
103
|
+
assert.equal(ageS(agoS(10), NaN), null);
|
|
104
|
+
assert.equal(ageS(agoS(10), NOW), 10);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── the forgery the grader found (task 1003905) ─────────────────────────────────
|
|
108
|
+
|
|
109
|
+
test('a heartbeat row is keyed by the CALLER, so liveness cannot be forged', async () => {
|
|
110
|
+
// THE ATTACK: with `host` alone as the primary key, any authenticated builder
|
|
111
|
+
// could POST a heartbeat naming the real runner's hostname and keep the signal
|
|
112
|
+
// reading "alive" forever — defeating the one property this table exists for,
|
|
113
|
+
// that a dead machine holding broad spawn permissions becomes visible.
|
|
114
|
+
//
|
|
115
|
+
// An ownership check in the handler would also close it, and would be one `if`
|
|
116
|
+
// away from removal by a later edit that looked reasonable. The caller is in the
|
|
117
|
+
// KEY instead, so the forgery is impossible rather than merely checked for.
|
|
118
|
+
const fs = require('node:fs');
|
|
119
|
+
const path = require('node:path');
|
|
120
|
+
const sql = fs.readFileSync(path.join(process.cwd(), 'modules/autonomy/migrations/autonomy_002_runner_heartbeat.sql'), 'utf8');
|
|
121
|
+
assert.match(sql, /PRIMARY KEY \(builder_id, host\)/, 'the caller must be half the key');
|
|
122
|
+
assert.doesNotMatch(sql, /host\s+text PRIMARY KEY/, 'host alone as the key is the forgeable shape');
|
|
123
|
+
|
|
124
|
+
const db = fs.readFileSync(path.join(process.cwd(), 'modules/autonomy/db.js'), 'utf8');
|
|
125
|
+
assert.match(db, /ON CONFLICT \(builder_id, host\)/, 'the upsert must collide per builder, not per host');
|
|
126
|
+
assert.match(db, /readRunnerHeartbeats\(builderId\)/, 'the read is scoped to one builder');
|
|
127
|
+
assert.match(db, /WHERE builder_id = \$1/);
|
|
128
|
+
|
|
129
|
+
const routes = fs.readFileSync(path.join(process.cwd(), 'modules/autonomy/routes/autonomy.js'), 'utf8');
|
|
130
|
+
// The identity must come from the SESSION. A `host` from the body is fine; a
|
|
131
|
+
// builder id from the body would hand the attacker the key back.
|
|
132
|
+
assert.match(routes, /builderId: req\.builder\.id/);
|
|
133
|
+
assert.doesNotMatch(routes, /builderId: (b|req\.body)\./, 'never take the caller’s identity from the body');
|
|
134
|
+
assert.match(routes, /readRunnerHeartbeats\(req\.builder\.id\)/);
|
|
135
|
+
});
|
package/tests/bump_version.mjs
CHANGED
|
@@ -16,9 +16,11 @@ import fs from 'node:fs';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
|
+
import { shallowCloneReason } from './helpers.mjs';
|
|
19
20
|
|
|
20
21
|
const require = createRequire(import.meta.url);
|
|
21
22
|
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
|
|
22
24
|
const bump = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
|
|
23
25
|
const { bumpPatch, CONST_RE } = bump;
|
|
24
26
|
// The LIVE test below reads this repo's real release history (task 1003202).
|
|
@@ -347,11 +349,20 @@ test('a hand-bumped branch passes the LIVE check its own PR runs', () => {
|
|
|
347
349
|
assert.equal(bump.laneReleasedVersions(subjects).has('1.20.0'), true);
|
|
348
350
|
});
|
|
349
351
|
|
|
350
|
-
test('LIVE: the version HEAD declares is one this lane already released', () => {
|
|
352
|
+
test('LIVE: the version HEAD declares is one this lane already released', (t) => {
|
|
351
353
|
// The steady state the lane depends on: HEAD carries the version set by the
|
|
352
354
|
// previous release commit, so `taken` is true and the auto-bump fires. If this
|
|
353
355
|
// ever reads false on a normal main checkout, the lane would try to republish
|
|
354
356
|
// an existing version.
|
|
357
|
+
//
|
|
358
|
+
// This reads the whole release history, so it cannot run on a shallow clone — a
|
|
359
|
+
// cloud session sees ~46 release commits instead of hundreds and the assertion
|
|
360
|
+
// below fails on the clone, not on the code (task 1004049). Skip with the reason
|
|
361
|
+
// printed, never a silent return: an unrunnable case that reports as a pass is the
|
|
362
|
+
// defect task 1003993 is open on.
|
|
363
|
+
const shallow = shallowCloneReason(ROOT);
|
|
364
|
+
if (shallow) { t.skip(shallow); return; }
|
|
365
|
+
|
|
355
366
|
const headVersion = /^const CORE_VERSION = '([0-9.]+)';/m
|
|
356
367
|
.exec(fs.readFileSync(path.join(ROOT, 'src', 'module-api.js'), 'utf8'))[1];
|
|
357
368
|
const subjects = execFileSync('git', ['log', '--format=%s', 'HEAD'], { encoding: 'utf8', cwd: ROOT }).split('\n');
|
package/tests/cli_package.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import os from 'node:os';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { spawnSync } from 'node:child_process';
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
22
|
+
import { childEnv } from './helpers.mjs';
|
|
22
23
|
|
|
23
24
|
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
25
|
|
|
@@ -34,8 +35,17 @@ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
34
35
|
// rather than inlined twice: this is the third site in the repo to need it (after
|
|
35
36
|
// tests/cli_sessions.mjs in task 1003760 and tests/cli_surface.mjs in task 1003941),
|
|
36
37
|
// and every one of them got it half-right first.
|
|
38
|
+
//
|
|
39
|
+
// AND AN INHERITED API BASE IS NOT ISOLATION EITHER (task 1004049). The same
|
|
40
|
+
// shape of hole, one env var over: resolveApiBase() reads the CANONICAL
|
|
41
|
+
// <PREFIX>_API_BASE before any legacy spelling, so the `CLOUDBONGOS_API_BASE:
|
|
42
|
+
// 'http://127.0.0.1:45999'` the verbs below rely on to be a DEAD port is silently
|
|
43
|
+
// shadowed on any box that exports the canonical one — every Bongos session box.
|
|
44
|
+
// The verbs then made real calls to the live instance instead of failing fast
|
|
45
|
+
// against a closed port, which is why this test ran for minutes and timed out
|
|
46
|
+
// rather than failing with something readable. childEnv strips every spelling.
|
|
37
47
|
function homelessEnv(home, extra = {}) {
|
|
38
|
-
return {
|
|
48
|
+
return childEnv({ HOME: home, USERPROFILE: home, HOMEDRIVE: '', HOMEPATH: home, ...extra });
|
|
39
49
|
}
|
|
40
50
|
const BUILDER = path.join(REPO_ROOT, 'scripts', 'gds', 'build-cli-package.js');
|
|
41
51
|
// Running npm from a test (task 1003529). On Windows `npm` is a .cmd shim: bare
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
|
|
29
29
|
import { strict as assert } from 'node:assert';
|
|
30
30
|
import { createRequire } from 'node:module';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
31
33
|
|
|
32
34
|
const require = createRequire(import.meta.url);
|
|
33
35
|
const R = require('../scripts/gds/provision-core-upgrade.js');
|
|
@@ -46,13 +48,19 @@ const t = async (name, fn) => {
|
|
|
46
48
|
// instant. `apply:true` because the interesting behaviour is the applied path.
|
|
47
49
|
function makeDeps({ shape = 'control-plane', served = '1.19.760', available = ['1.19.758', '1.19.760', '1.19.762'], installed = '1.19.760', queries = [] } = {}) {
|
|
48
50
|
const ran = [];
|
|
49
|
-
|
|
51
|
+
// task 1004053: the CWD is half of what makes a relative CLI path resolve, and the
|
|
52
|
+
// bug that shipped was invisible to every assertion that read only the command
|
|
53
|
+
// string. Captured alongside, so a test can check the pair.
|
|
54
|
+
const ranWith = [];
|
|
55
|
+
const exec = (cmd, opts) => {
|
|
50
56
|
ran.push(cmd);
|
|
57
|
+
ranWith.push({ cmd, cwd: opts && opts.cwd });
|
|
51
58
|
if (/\/version/.test(cmd)) return { ok: true, stdout: served == null ? '' : JSON.stringify({ coreVersion: served }) };
|
|
52
59
|
return { ok: true, stdout: '' };
|
|
53
60
|
};
|
|
54
61
|
return {
|
|
55
62
|
ran,
|
|
63
|
+
ranWith,
|
|
56
64
|
queries,
|
|
57
65
|
db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
|
|
58
66
|
provisioning: { recordEvent: async () => {}, clearErrorNote: async () => {} },
|
|
@@ -245,6 +253,40 @@ await t('drives upgrade.js with the registry, the health gate, the version read-
|
|
|
245
253
|
assert.ok(cmd.includes('--commit-pin'));
|
|
246
254
|
});
|
|
247
255
|
|
|
256
|
+
await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that broke every standalone tenant (task 1004053)', async () => {
|
|
257
|
+
// The whole failure was a path that resolved nowhere:
|
|
258
|
+
// Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
|
|
259
|
+
// No assertion about FLAGS could have caught it, so this one resolves the composed
|
|
260
|
+
// cwd + relative CLI against the real filesystem, for every shape the door admits.
|
|
261
|
+
for (const shape of ['control-plane', 'standalone', 'co-tenant']) {
|
|
262
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
263
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: shape, slug: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
264
|
+
const call = d.ranWith.find((c) => /upgrade\.js/.test(c.cmd));
|
|
265
|
+
assert.ok(call, `${shape}: it ran the upgrade CLI`);
|
|
266
|
+
const rel = (call.cmd.match(/node (\S*scripts\/gds\/upgrade\.js)/) || [])[1];
|
|
267
|
+
assert.ok(rel, `${shape}: the command names the CLI`);
|
|
268
|
+
assert.ok(fs.existsSync(path.join(call.cwd, rel)),
|
|
269
|
+
`${shape}: ${path.join(call.cwd, rel)} must exist — this is the assertion the original bug would have failed`);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await t('a standalone tenant is NAMED with --instance, because the CLI no longer runs inside it', async () => {
|
|
274
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
275
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
276
|
+
const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
|
|
277
|
+
assert.match(cmd, /--instance \S*hermeslines-marketing/, 'the target is named explicitly');
|
|
278
|
+
assert.ok(cmd.includes('--service hermeslines-marketing'), 'and its own unit is still the one restarted');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await t('the control-plane row carries NO --instance — its target IS the checkout the CLI runs in', async () => {
|
|
282
|
+
// Naming it would be harmless but wrong, and the shapes that already worked must not
|
|
283
|
+
// change behaviour on the way to fixing the one that never did.
|
|
284
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
285
|
+
await R.coreUpgradeInstance(inst(), d, intent());
|
|
286
|
+
const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
|
|
287
|
+
assert.ok(!cmd.includes('--instance'), 'unchanged for the platform itself');
|
|
288
|
+
});
|
|
289
|
+
|
|
248
290
|
await t('the health URL and the version URL are LOOPBACK — the apex is behind basic_auth and a CDN (ADR 0293 D6)', async () => {
|
|
249
291
|
const d = makeDeps();
|
|
250
292
|
await R.coreUpgradeInstance(inst(), d, intent());
|