@bongos/core 1.19.871 → 1.19.873
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 +167 -97
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- 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 +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0180-dependency-intake-and-drift-policy.md +6 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +10 -0
- package/docs/api/openapi.json +61 -3
- package/docs/api-reference.md +7 -1
- package/docs/architecture.md +7 -7
- package/docs/branding-contract.md +1 -1
- package/docs/copy-inventory.md +190 -189
- package/docs/copy-registry.json +264 -255
- package/docs/design/hall-direction-v2.md +2 -2
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/README.md +4 -4
- package/docs/onboarding/drachmae-and-karma.md +2 -2
- package/docs/onboarding/primer.md +1 -1
- package/docs/recipes/README.md +1 -1
- package/docs/recipes/bongos-cli-release.md +4 -4
- package/docs/recipes/core-release-pipeline.md +3 -3
- package/docs/recipes/instance-deploy.md +2 -2
- package/docs/recipes/ops-gotchas.md +2 -2
- package/docs/recipes/private-npm-distribution.md +16 -21
- package/docs/recipes/search-index-ingestion.md +2 -2
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/gauge.js +218 -0
- package/modules/dev-box/app/README.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/blockers.js +138 -34
- package/modules/hall-ui/public/builders.js +2 -1
- package/modules/hall-ui/public/collab.html +2 -2
- package/modules/hall-ui/public/collab.js +6 -3
- package/modules/hall-ui/public/dom-utils.js +46 -5
- package/modules/hall-ui/public/goals-page.js +7 -3
- package/modules/hall-ui/public/goals-render.js +4 -3
- package/modules/hall-ui/public/hall-render.js +1 -1
- package/modules/hall-ui/public/people.js +3 -3
- package/modules/hall-ui/public/profile-charts.js +5 -2
- package/modules/hall-ui/public/profile.html +3 -3
- package/modules/hall-ui/public/profile.js +6 -6
- package/modules/hall-ui/public/settings-specialities.js +2 -2
- package/modules/hall-ui/public/settings.html +4 -4
- package/modules/hall-ui/public/settings.js +2 -2
- package/modules/hall-ui/public/shell.js +70 -0
- package/modules/hall-ui/public/style.css +31 -0
- package/modules/hall-ui/public/work.js +12 -14
- package/modules/provisioning/module.json +3 -3
- package/modules/public-landing/public/terms.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-loop.js +170 -0
- package/scripts/gds/autobongos-run.js +317 -0
- package/scripts/gds/autobongos-verify.js +377 -0
- package/scripts/gds/autonomy-gauge-statusline.js +124 -0
- package/scripts/gds/autonomy-gauge.js +84 -0
- package/scripts/gds/provision-repo.js +4 -2
- package/scripts/gds/run-unit-tests.js +120 -2
- package/scripts/gds/sequence.js +3 -0
- package/scripts/gds/start.js +16 -10
- package/src/bongos/core-update.js +147 -0
- package/src/bongos/routes/core-update.js +49 -0
- package/src/bongos/routes.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +3 -1
- package/tests/autobongos_loop.mjs +446 -0
- package/tests/autobongos_verify.mjs +325 -0
- package/tests/autonomy_gauge.mjs +344 -0
- package/tests/blockers_hall.mjs +20 -6
- package/tests/blockers_kit_pager.mjs +260 -0
- package/tests/blockers_page_ui.mjs +5 -4
- package/tests/box_ship_permissions.mjs +30 -0
- package/tests/core_update_banner.mjs +308 -0
- package/tests/eviction_policy.mjs +22 -1
- package/tests/git_guard.mjs +68 -0
- package/tests/goal_criterion_confirm_ui.mjs +7 -0
- package/tests/goal_invite_consent.mjs +7 -0
- package/tests/goal_sort_staleness.mjs +6 -2
- package/tests/hall_board_world.mjs +4 -1
- package/tests/hall_builders_page_pass.mjs +7 -0
- package/tests/hall_landing_boot.mjs +7 -0
- package/tests/hall_profile_world.mjs +20 -2
- package/tests/hall_record_world.mjs +7 -0
- package/tests/hall_roster_world.mjs +2 -1
- package/tests/hall_studio_world.mjs +7 -0
- package/tests/hook_shell_tools.mjs +23 -1
- package/tests/idea_detail_page.mjs +7 -0
- package/tests/idea_spark_hall.mjs +7 -0
- package/tests/integration_parked_ratchet.mjs +79 -0
- package/tests/module_contributions.mjs +134 -8
- package/tests/package_core.mjs +31 -0
- package/tests/people_directory_clip.mjs +7 -0
- package/tests/profile_activity.mjs +7 -0
- package/tests/profile_ui_cross_project.mjs +9 -1
- package/tests/provision_disconnect.mjs +7 -0
- package/tests/provision_restart.mjs +7 -0
- package/tests/provision_settings_apply.mjs +7 -0
- package/tests/sessions_kit_pager.mjs +7 -0
- package/tests/watch_applications_queue.mjs +7 -0
- package/tests/watch_roster_self_row.mjs +7 -0
- package/tests/watch_sealed_floor.mjs +7 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// tests/autobongos_verify.mjs — completion is read from the ledger (task 1003903).
|
|
2
|
+
//
|
|
3
|
+
// The claim under test is narrow and load-bearing: a worker saying "shipped" is
|
|
4
|
+
// not evidence, and every way the ledger can disagree with it has a NAME and a
|
|
5
|
+
// remedy. Several of these guard incidents that already happened once — a task
|
|
6
|
+
// credited with no artifact on any branch (1002459), a claim left held by a
|
|
7
|
+
// session that had exited, and a link filed in the wrong order so the task went
|
|
8
|
+
// straight back into the pool.
|
|
9
|
+
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const verify = require('../scripts/gds/autobongos-verify.js');
|
|
16
|
+
const { classifyLedger, blockerFor, probeArtifact, verifyShip } = verify;
|
|
17
|
+
const strandWatch = require('../scripts/gds/strand-watch.js');
|
|
18
|
+
|
|
19
|
+
const NOW = Date.parse('2026-09-18T12:00:00.000Z');
|
|
20
|
+
const task = (over = {}) => ({ id: '1003903', title: 'A task', status: 'shipped', updated_at: '2026-09-18T11:59:00.000Z', ...over });
|
|
21
|
+
const onMain = { checked: true, onMain: true, sha: 'abcdef1234567890', subject: 'feat: the thing (task 1003903)' };
|
|
22
|
+
const notOnMain = { checked: true, onMain: false, sha: null, subject: null };
|
|
23
|
+
|
|
24
|
+
// ── the happy path is the ONLY verified one ────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
test('shipped with a commit on main is the one verified shape', () => {
|
|
27
|
+
const v = classifyLedger({ task: task(), artifact: onMain, nowMs: NOW });
|
|
28
|
+
assert.equal(v.shape, 'shipped');
|
|
29
|
+
assert.equal(v.verified, true);
|
|
30
|
+
assert.equal(v.releaseClaim, false);
|
|
31
|
+
assert.equal(v.fileBlocker, false);
|
|
32
|
+
assert.equal(v.halt, false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('every shape that is not `shipped` is unverified', () => {
|
|
36
|
+
for (const status of ['confirmed', 'completed', 'active', 'ready', 'backlog', 'blocked', 'abandoned', 'planning']) {
|
|
37
|
+
const v = classifyLedger({ task: task({ status }), nowMs: NOW });
|
|
38
|
+
assert.equal(v.verified, false, `'${status}' must not read as a delivery`);
|
|
39
|
+
assert.ok(v.shape && v.shape !== 'shipped', `'${status}' needs a name of its own, got ${v.shape}`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ── the shapes, by name ────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
test('each known strand shape is detected by name', () => {
|
|
46
|
+
const cases = [
|
|
47
|
+
// [ledger row, expected shape]
|
|
48
|
+
[task({ status: 'confirmed', needs_rebase: true, needs_rebase_reason: 'strand:land_not_proven' }), 'unprovable'],
|
|
49
|
+
[task({ status: 'confirmed' }), 'unlanded'],
|
|
50
|
+
[task({ status: 'completed' }), 'ungraded'],
|
|
51
|
+
[task({ status: 'active' }), 'still_held'],
|
|
52
|
+
[task({ status: 'ready' }), 'not_started'],
|
|
53
|
+
[task({ status: 'backlog' }), 'not_started'],
|
|
54
|
+
[task({ status: 'blocked' }), 'blocked'],
|
|
55
|
+
[task({ status: 'abandoned' }), 'abandoned'],
|
|
56
|
+
[task({ status: 'shipped' }), 'shipped'],
|
|
57
|
+
];
|
|
58
|
+
for (const [row, shape] of cases) {
|
|
59
|
+
assert.equal(classifyLedger({ task: row, nowMs: NOW }).shape, shape, `status '${row.status}' → ${shape}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the three mid-ship names are strand-watch’s, not new ones', () => {
|
|
64
|
+
// One condition must not have two names: /strand-fix routes on these strings,
|
|
65
|
+
// so a rename here without a rename there sends a builder to the wrong remedy.
|
|
66
|
+
for (const [row, shape] of [
|
|
67
|
+
[task({ status: 'confirmed', needs_rebase: true, needs_rebase_reason: 'strand:land_not_proven' }), 'unprovable'],
|
|
68
|
+
[task({ status: 'confirmed' }), 'unlanded'],
|
|
69
|
+
[task({ status: 'completed' }), 'ungraded'],
|
|
70
|
+
]) {
|
|
71
|
+
const mine = classifyLedger({ task: row, nowMs: NOW });
|
|
72
|
+
// strand-watch only names it once it is old enough; the shape must match.
|
|
73
|
+
const theirs = strandWatch.classifyStrand(row, { nowMs: NOW + 40 * 3600_000 });
|
|
74
|
+
assert.equal(mine.shape, shape);
|
|
75
|
+
assert.equal(theirs.shape, shape, 'the two classifiers disagree about what to call this');
|
|
76
|
+
assert.equal(mine.remedy, theirs.remedy, 'same condition, same remedy');
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('this classifier is stricter than the board check, never quieter', () => {
|
|
81
|
+
// strand-watch waits an hour on `confirmed` because a slow CI queue is not a
|
|
82
|
+
// strand. Here the session that was supposed to finish has already exited, so
|
|
83
|
+
// the wait cannot change the verdict — a one-second-old confirm is a strand.
|
|
84
|
+
const fresh = task({ status: 'confirmed', updated_at: new Date(NOW - 1000).toISOString() });
|
|
85
|
+
assert.equal(strandWatch.classifyStrand(fresh, { nowMs: NOW }), null, 'the board check is right to stay quiet');
|
|
86
|
+
assert.equal(classifyLedger({ task: fresh, nowMs: NOW }).shape, 'unlanded');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an unparseable updated_at still gets a shape — quiet is the wrong failure here', () => {
|
|
90
|
+
// classifyStrand fails QUIET on bad dates, correctly: its line is injected into
|
|
91
|
+
// every session and a false alarm trains people to scroll past. Quiet HERE would
|
|
92
|
+
// mean the loop reads a strand as a success.
|
|
93
|
+
for (const [status, shape] of [['confirmed', 'unlanded'], ['completed', 'ungraded']]) {
|
|
94
|
+
const bad = task({ status, updated_at: 'not a date' });
|
|
95
|
+
assert.equal(strandWatch.classifyStrand(bad, { nowMs: NOW }), null);
|
|
96
|
+
const v = classifyLedger({ task: bad, nowMs: NOW });
|
|
97
|
+
assert.equal(v.shape, shape);
|
|
98
|
+
assert.equal(v.fileBlocker, true);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── the incident this exists for ───────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
test('shipped with nothing on main is called out, not celebrated', () => {
|
|
105
|
+
const v = classifyLedger({ task: task(), artifact: notOnMain, nowMs: NOW });
|
|
106
|
+
assert.equal(v.shape, 'shipped_no_artifact');
|
|
107
|
+
assert.equal(v.verified, false);
|
|
108
|
+
assert.equal(v.fileBlocker, true);
|
|
109
|
+
// Shipped is shipped: credits are paid and parking it helps nobody.
|
|
110
|
+
assert.equal(v.releaseClaim, false);
|
|
111
|
+
assert.equal(v.linkBlocker, false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a probe that could not run is NOT evidence the artifact is missing', () => {
|
|
115
|
+
for (const artifact of [null, { checked: false, reason: 'git fetch failed: no network' }]) {
|
|
116
|
+
const v = classifyLedger({ task: task(), artifact, nowMs: NOW });
|
|
117
|
+
assert.equal(v.shape, 'shipped', 'not looking must never manufacture a missing artifact');
|
|
118
|
+
assert.equal(v.verified, true);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── what each shape makes the supervisor DO ────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
test('only a still-held claim is released — the ledger decides, not the worker', () => {
|
|
125
|
+
const released = ['active', 'claimed', 'in_progress'];
|
|
126
|
+
for (const status of [...released, 'ready', 'backlog', 'blocked', 'confirmed', 'completed', 'shipped', 'abandoned']) {
|
|
127
|
+
const v = classifyLedger({ task: task({ status }), artifact: onMain, nowMs: NOW });
|
|
128
|
+
assert.equal(v.releaseClaim, released.includes(status), `release on '${status}' should be ${released.includes(status)}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a task the loop could pick straight back up gets linked; one it cannot, does not', () => {
|
|
133
|
+
// The link is what parks the task at `blocked` and removes it from
|
|
134
|
+
// claimable_tasks. It only bites on `ready` (LINK_BLOCKABLE_STATUSES), so it is
|
|
135
|
+
// asked for exactly where the task would otherwise return to the pool.
|
|
136
|
+
for (const status of ['active', 'ready', 'backlog']) {
|
|
137
|
+
assert.equal(classifyLedger({ task: task({ status }), nowMs: NOW }).linkBlocker, true, status);
|
|
138
|
+
}
|
|
139
|
+
for (const status of ['confirmed', 'completed', 'shipped', 'abandoned', 'blocked']) {
|
|
140
|
+
assert.equal(classifyLedger({ task: task({ status }), artifact: onMain, nowMs: NOW }).linkBlocker, false, status);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('an already-blocked task does not collect a second blocker', () => {
|
|
145
|
+
const v = classifyLedger({ task: task({ status: 'blocked' }), nowMs: NOW });
|
|
146
|
+
assert.equal(v.fileBlocker, false);
|
|
147
|
+
assert.equal(v.releaseClaim, false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('an unreadable ledger halts the run and touches nothing', () => {
|
|
151
|
+
const v = classifyLedger({ task: null, nowMs: NOW });
|
|
152
|
+
assert.equal(v.shape, 'undetermined');
|
|
153
|
+
assert.equal(v.halt, true);
|
|
154
|
+
assert.equal(v.releaseClaim, false, 'releasing a claim on a guess is how work gets stranded');
|
|
155
|
+
assert.equal(v.fileBlocker, false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('an abandoned task halts the run — cutting work is not a worker’s call', () => {
|
|
159
|
+
const v = classifyLedger({ task: task({ status: 'abandoned' }), nowMs: NOW });
|
|
160
|
+
assert.equal(v.shape, 'abandoned');
|
|
161
|
+
assert.equal(v.halt, true);
|
|
162
|
+
assert.equal(v.fileBlocker, true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── the blocker row ────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
test('the blocker names the shape, the ledger status, and what the worker claimed', () => {
|
|
168
|
+
const v = classifyLedger({ task: task({ status: 'active' }), nowMs: NOW });
|
|
169
|
+
const row = blockerFor(v, { taskId: '1003903', taskTitle: 'A task', worker: { outcome: 'claims_shipped', reason: 'I shipped it' } });
|
|
170
|
+
assert.match(row.title, /1003903/);
|
|
171
|
+
assert.match(row.title, /still_held/);
|
|
172
|
+
assert.ok(row.title.length <= 200, 'the title column caps at 200 chars');
|
|
173
|
+
assert.match(row.body_md, /still_held/);
|
|
174
|
+
assert.match(row.body_md, /active/);
|
|
175
|
+
assert.match(row.body_md, /claims_shipped/, 'the worker’s own story belongs in the record');
|
|
176
|
+
assert.match(row.body_md, /I shipped it/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('the dedupe key is per task AND per shape', () => {
|
|
180
|
+
// POST /blockers has ON CONFLICT (source, source_ref) DO NOTHING. Re-failing the
|
|
181
|
+
// same way must not pile up rows; failing a NEW way must still be said out loud.
|
|
182
|
+
const held = blockerFor(classifyLedger({ task: task({ status: 'active' }), nowMs: NOW }), { taskId: '1003903' });
|
|
183
|
+
const held2 = blockerFor(classifyLedger({ task: task({ status: 'active' }), nowMs: NOW }), { taskId: '1003903' });
|
|
184
|
+
const graded = blockerFor(classifyLedger({ task: task({ status: 'completed' }), nowMs: NOW }), { taskId: '1003903' });
|
|
185
|
+
assert.equal(held.source, 'autobongos');
|
|
186
|
+
assert.equal(held.source_ref, held2.source_ref);
|
|
187
|
+
assert.notEqual(held.source_ref, graded.source_ref);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('a reconciler-recorded strand reason reaches the blocker body', () => {
|
|
191
|
+
const v = classifyLedger({ task: task({ status: 'confirmed', needs_rebase: true, needs_rebase_reason: 'strand:land_not_proven' }), nowMs: NOW });
|
|
192
|
+
assert.match(blockerFor(v, { taskId: '1003903' }).body_md, /strand:land_not_proven/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── the artifact probe ─────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
test('the artifact probe reads main, fixed-string, for the ship-commit convention', async () => {
|
|
198
|
+
const calls = [];
|
|
199
|
+
const git = async (args) => {
|
|
200
|
+
calls.push(args);
|
|
201
|
+
if (args[0] === 'fetch') return { ok: true, stdout: '', stderr: '' };
|
|
202
|
+
return { ok: true, stdout: 'deadbeefcafe1234 feat: the thing (task 42)\n', stderr: '' };
|
|
203
|
+
};
|
|
204
|
+
const r = await probeArtifact(42, { git });
|
|
205
|
+
assert.deepEqual(r, { checked: true, onMain: true, sha: 'deadbeefcafe1234', subject: 'feat: the thing (task 42)' });
|
|
206
|
+
assert.deepEqual(calls[0], ['fetch', '-q', 'origin', 'main']);
|
|
207
|
+
assert.ok(calls[1].includes('-F'), 'the pattern is a fixed string, not a regex');
|
|
208
|
+
assert.ok(calls[1].includes('--grep=(task 42)'));
|
|
209
|
+
assert.ok(calls[1].includes('FETCH_HEAD'), 'read what was just fetched, not a stale local ref');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('a failed fetch reports `checked: false`, never `onMain: false`', async () => {
|
|
213
|
+
const git = async (args) => (args[0] === 'fetch'
|
|
214
|
+
? { ok: false, stdout: '', stderr: 'fatal: could not read from remote' }
|
|
215
|
+
: { ok: true, stdout: '', stderr: '' });
|
|
216
|
+
const r = await probeArtifact(42, { git });
|
|
217
|
+
assert.equal(r.checked, false);
|
|
218
|
+
assert.equal(r.onMain, undefined);
|
|
219
|
+
assert.match(r.reason, /could not read from remote/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('an empty log is a real answer: no trace on main', async () => {
|
|
223
|
+
const git = async () => ({ ok: true, stdout: '\n', stderr: '' });
|
|
224
|
+
assert.deepEqual(await probeArtifact(42, { git }), { checked: true, onMain: false, sha: null, subject: null });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a task id that is not a plain number is never interpolated into a git flag', async () => {
|
|
228
|
+
let called = false;
|
|
229
|
+
const r = await probeArtifact('42 --output=/tmp/pwned', { git: async () => { called = true; return { ok: true, stdout: '' }; } });
|
|
230
|
+
assert.equal(r.checked, false);
|
|
231
|
+
assert.equal(called, false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── the order of operations ────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const fakeApi = () => ({ tasks: { getTasksId: async () => ({ ok: false }) }, blockers: {} });
|
|
237
|
+
|
|
238
|
+
test('release happens BEFORE the link, or the task goes straight back in the pool', async () => {
|
|
239
|
+
// linkBlockerToTask() parks the task only when it is at `ready`, and releasing is
|
|
240
|
+
// what puts it there. Link first and you get a row that changes nothing, then a
|
|
241
|
+
// release that hands the task back to the loop to fail the same way.
|
|
242
|
+
const order = [];
|
|
243
|
+
const out = await verifyShip({ taskId: 42 }, {
|
|
244
|
+
api: fakeApi(),
|
|
245
|
+
task: task({ id: '42', status: 'active' }),
|
|
246
|
+
release: async () => { order.push('release'); return { ok: true }; },
|
|
247
|
+
fileBlocker: async (_api, _row, opts) => { order.push(`file(link=${opts.link})`); return { filed: true, blockerId: 7, linked: true, parked: true }; },
|
|
248
|
+
});
|
|
249
|
+
assert.deepEqual(order, ['release', 'file(link=true)']);
|
|
250
|
+
assert.equal(out.released, true);
|
|
251
|
+
assert.equal(out.blocker.parked, true, 'the task must actually have moved, not just been linked');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('a release that fails does not get reported as a release', async () => {
|
|
255
|
+
const out = await verifyShip({ taskId: 42 }, {
|
|
256
|
+
api: fakeApi(),
|
|
257
|
+
task: task({ id: '42', status: 'active' }),
|
|
258
|
+
release: async () => ({ ok: false, stderr: 'Only the original session can release it.' }),
|
|
259
|
+
fileBlocker: async (_a, _r, opts) => ({ filed: true, blockerId: 7, linked: opts.link }),
|
|
260
|
+
});
|
|
261
|
+
assert.equal(out.released, false);
|
|
262
|
+
assert.match(out.release_error, /original session/);
|
|
263
|
+
// The link is not attempted when the task never made it back to `ready` — it
|
|
264
|
+
// would write a row that parks nothing and muddies the blocker board.
|
|
265
|
+
assert.equal(out.blocker.linked, false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('the artifact probe only runs when the ledger claims success', async () => {
|
|
269
|
+
let probes = 0;
|
|
270
|
+
const probeArtifactSpy = async () => { probes += 1; return notOnMain; };
|
|
271
|
+
await verifyShip({ taskId: 42 }, { api: fakeApi(), task: task({ status: 'completed' }), probeArtifact: probeArtifactSpy, act: false });
|
|
272
|
+
assert.equal(probes, 0, 'what is on main cannot make a `completed` task shipped');
|
|
273
|
+
await verifyShip({ taskId: 42 }, { api: fakeApi(), task: task({ status: 'shipped' }), probeArtifact: probeArtifactSpy, act: false });
|
|
274
|
+
assert.equal(probes, 1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('read-only mode changes nothing', async () => {
|
|
278
|
+
let touched = false;
|
|
279
|
+
const out = await verifyShip({ taskId: 42 }, {
|
|
280
|
+
api: fakeApi(), task: task({ status: 'active' }), act: false,
|
|
281
|
+
release: async () => { touched = true; return { ok: true }; },
|
|
282
|
+
fileBlocker: async () => { touched = true; return { filed: true }; },
|
|
283
|
+
});
|
|
284
|
+
assert.equal(touched, false);
|
|
285
|
+
assert.equal(out.shape, 'still_held');
|
|
286
|
+
assert.equal(out.releaseClaim, true, 'it still SAYS what should happen');
|
|
287
|
+
assert.equal(out.released, false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('an unreadable task means an undetermined halt, with no writes attempted', async () => {
|
|
291
|
+
let touched = false;
|
|
292
|
+
const out = await verifyShip({ taskId: 42 }, {
|
|
293
|
+
api: fakeApi(), task: null,
|
|
294
|
+
release: async () => { touched = true; return { ok: true }; },
|
|
295
|
+
fileBlocker: async () => { touched = true; return { filed: true }; },
|
|
296
|
+
});
|
|
297
|
+
assert.equal(out.shape, 'undetermined');
|
|
298
|
+
assert.equal(out.halt, true);
|
|
299
|
+
assert.equal(touched, false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('a missing release function is reported, never read as "no release needed"', async () => {
|
|
303
|
+
const out = await verifyShip({ taskId: 42 }, {
|
|
304
|
+
api: fakeApi(), task: task({ status: 'active' }),
|
|
305
|
+
fileBlocker: async () => ({ filed: true, blockerId: 7 }),
|
|
306
|
+
// release deliberately absent
|
|
307
|
+
});
|
|
308
|
+
assert.equal(out.released, false);
|
|
309
|
+
assert.match(out.release_error, /no release function/, 'silently skipping the write that unlocks a task is the bug this step exists to catch');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('an unreadable ledger keeps WHY it was unreadable', async () => {
|
|
313
|
+
const api = { tasks: { getTasksId: async () => ({ ok: false, status: 401 }) }, blockers: {} };
|
|
314
|
+
const out = await verifyShip({ taskId: 42 }, { api });
|
|
315
|
+
assert.equal(out.shape, 'undetermined');
|
|
316
|
+
assert.match(out.read_error, /401/, '"undetermined" alone cannot tell an expired session from an unreachable instance');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('a thrown client error is a read failure, not a crash', async () => {
|
|
320
|
+
const api = { tasks: { getTasksId: async () => { throw new Error('fetch failed'); } }, blockers: {} };
|
|
321
|
+
const out = await verifyShip({ taskId: 42 }, { api });
|
|
322
|
+
assert.equal(out.shape, 'undetermined');
|
|
323
|
+
assert.equal(out.halt, true);
|
|
324
|
+
assert.match(out.read_error, /fetch failed/);
|
|
325
|
+
});
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// tests/autonomy_gauge.mjs — the Autobongos usage gauge (task 1003901, goal 1000119).
|
|
2
|
+
//
|
|
3
|
+
// The two assertions the task's done-when names are 1) and 2) below; everything
|
|
4
|
+
// else guards the ways this could quietly decide "plenty left" when it does not
|
|
5
|
+
// know. That direction is the only dangerous one: a gauge that holds too often
|
|
6
|
+
// wastes a window, a gauge that GOES when it should hold spends one it does not
|
|
7
|
+
// have and kills a worker mid-task.
|
|
8
|
+
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const { decideCapacity, readGauge, reserveFor, weeklyPace, whenPaceAllows, cfg } =
|
|
18
|
+
require('../modules/autonomy/gauge.js');
|
|
19
|
+
const statusline = require('../scripts/gds/autonomy-gauge-statusline.js');
|
|
20
|
+
const cli = require('../scripts/gds/autonomy-gauge.js');
|
|
21
|
+
|
|
22
|
+
// Run fn with env vars set, always restoring them.
|
|
23
|
+
function withEnv(vars, fn) {
|
|
24
|
+
const prev = {};
|
|
25
|
+
for (const [k, v] of Object.entries(vars)) { prev[k] = process.env[k]; if (v === undefined) delete process.env[k]; else process.env[k] = String(v); }
|
|
26
|
+
try { return fn(); } finally {
|
|
27
|
+
for (const [k, v] of Object.entries(prev)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function inTempGauge(fn) {
|
|
31
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gauge-'));
|
|
32
|
+
const target = path.join(dir, 'autobongos-gauge.json');
|
|
33
|
+
try { return withEnv({ AUTOBONGOS_GAUGE_FILE: target }, () => fn(target)); }
|
|
34
|
+
finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NOW = 1_800_000_000; // a fixed clock; nothing here reads the real one
|
|
38
|
+
const HOUR = 3600;
|
|
39
|
+
const gaugeAt = (agoS, over = {}) => ({
|
|
40
|
+
written_at: NOW - agoS,
|
|
41
|
+
five_hour: { used_percentage: 10, resets_at: NOW + 2 * HOUR },
|
|
42
|
+
seven_day: { used_percentage: 10, resets_at: NOW + 6 * 24 * HOUR },
|
|
43
|
+
...over,
|
|
44
|
+
});
|
|
45
|
+
// 3am in New York — squarely outside the owner's hours, so the reserve is the
|
|
46
|
+
// overnight one and the five-hour ceiling is effectively the whole window.
|
|
47
|
+
const NIGHT = Math.floor(Date.parse('2027-01-15T08:00:00Z') / 1000);
|
|
48
|
+
const DAY = Math.floor(Date.parse('2027-01-15T17:00:00Z') / 1000); // noon in New York
|
|
49
|
+
|
|
50
|
+
// ── 1) done-when: a stale gauge is UNKNOWN, never headroom ──────────────────────
|
|
51
|
+
test('a stale gauge file is unknown, not headroom', () => {
|
|
52
|
+
const d = decideCapacity({ gauge: gaugeAt(4 * HOUR), now: NOW });
|
|
53
|
+
assert.equal(d.go, false, 'a four-hour-old reading must never authorise new work');
|
|
54
|
+
assert.equal(d.unknown, true);
|
|
55
|
+
assert.match(d.reason, /stale|unknown/i);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('every unreadable shape decides the same way — unknown, hold', () => {
|
|
59
|
+
for (const [label, g] of [
|
|
60
|
+
['missing file', null],
|
|
61
|
+
['not an object', 'nonsense'],
|
|
62
|
+
['no written_at', { five_hour: { used_percentage: 1, resets_at: NOW + HOUR } }],
|
|
63
|
+
['neither window', { written_at: NOW }],
|
|
64
|
+
['unparseable numbers', { written_at: NOW, five_hour: { used_percentage: 'lots', resets_at: 'soon' } }],
|
|
65
|
+
['dated in the future', { ...gaugeAt(0), written_at: NOW + 600 }],
|
|
66
|
+
]) {
|
|
67
|
+
const d = decideCapacity({ gauge: g, now: NOW });
|
|
68
|
+
assert.equal(d.go, false, `${label} must hold`);
|
|
69
|
+
assert.equal(d.unknown, true, `${label} must read as unknown`);
|
|
70
|
+
assert.ok(d.reason && d.reason.length > 0, `${label} must say why`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a fresh gauge with headroom goes', () => {
|
|
75
|
+
const d = decideCapacity({ gauge: gaugeAt(30), now: NOW });
|
|
76
|
+
assert.equal(d.go, true);
|
|
77
|
+
assert.equal(d.sleepUntil, null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── 2) done-when: the reserve line sends it to sleep until the REAL reset ───────
|
|
81
|
+
test('crossing the five-hour reserve sleeps to that window\'s exact reset', () => {
|
|
82
|
+
const resetsAt = NOW + 77 * 60; // a deliberately un-round time
|
|
83
|
+
const g = gaugeAt(30, { five_hour: { used_percentage: 99, resets_at: resetsAt } });
|
|
84
|
+
const d = decideCapacity({ gauge: g, now: NOW, config: { reserve: { working: 25, overnight: 2 } } });
|
|
85
|
+
assert.equal(d.go, false);
|
|
86
|
+
assert.equal(d.unknown, false);
|
|
87
|
+
assert.equal(d.window, 'five_hour');
|
|
88
|
+
assert.equal(d.sleepUntil, resetsAt, 'wake time must be the reported reset second, not a rounded guess');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the reserve is what decides it — same burn, two verdicts', () => {
|
|
92
|
+
const used = 90;
|
|
93
|
+
const g = gaugeAt(30, { five_hour: { used_percentage: used, resets_at: NOW + HOUR } });
|
|
94
|
+
const cfg = { timezone: 'America/New_York', ownerHours: { start: 8, end: 20 }, reserve: { working: 25, overnight: 2 } };
|
|
95
|
+
|
|
96
|
+
const working = decideCapacity({ gauge: { ...g, written_at: DAY }, now: DAY, config: cfg });
|
|
97
|
+
assert.equal(working.go, false, '90% with a 25-point working reserve is over the ceiling');
|
|
98
|
+
|
|
99
|
+
const overnight = decideCapacity({ gauge: { ...g, written_at: NIGHT }, now: NIGHT, config: cfg });
|
|
100
|
+
assert.equal(overnight.go, true, 'the same 90% overnight is capacity the owner is not using');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('reserveFor names the window it picked and why', () => {
|
|
104
|
+
const cfg = { timezone: 'America/New_York', ownerHours: { start: 8, end: 20 }, reserve: { working: 25, overnight: 2 } };
|
|
105
|
+
assert.equal(reserveFor(DAY, cfg).window, 'working');
|
|
106
|
+
assert.equal(reserveFor(NIGHT, cfg).window, 'overnight');
|
|
107
|
+
assert.ok(reserveFor(DAY, cfg).why.includes('America/New_York'));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('an unresolvable timezone takes the CAUTIOUS reserve, and does not throw', () => {
|
|
111
|
+
const r = reserveFor(NOW, { timezone: 'Not/AZone' });
|
|
112
|
+
assert.equal(r.window, 'working', 'guessing "overnight" wrongly is what throttles the owner');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── the seven-day window is the binding one ─────────────────────────────────────
|
|
116
|
+
test('a burn ahead of the weekly pace holds, and wakes when the pace catches up', () => {
|
|
117
|
+
// One day into the week, 60% already spent: far ahead of an even burn.
|
|
118
|
+
const sevenDay = { used_percentage: 60, resets_at: NOW + 6 * 24 * HOUR };
|
|
119
|
+
const g = gaugeAt(30, { seven_day: sevenDay });
|
|
120
|
+
const d = decideCapacity({ gauge: g, now: NOW, config: { weeklySlack: 15 } });
|
|
121
|
+
assert.equal(d.go, false, 'maxing every five-hour window is how a week goes dark on Wednesday');
|
|
122
|
+
assert.equal(d.window, 'seven_day');
|
|
123
|
+
assert.ok(d.sleepUntil > NOW, 'must name a future wake');
|
|
124
|
+
assert.ok(d.sleepUntil < sevenDay.resets_at, 'pace catches up before the week resets — do not sleep the whole week');
|
|
125
|
+
assert.equal(d.sleepUntil, whenPaceAllows({ used: 60, resetsAt: sevenDay.resets_at }, { weeklySlack: 15 }));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a burn within slack of the pace still goes', () => {
|
|
129
|
+
const g = gaugeAt(30, { seven_day: { used_percentage: 20, resets_at: NOW + 6 * 24 * HOUR } });
|
|
130
|
+
assert.equal(decideCapacity({ gauge: g, now: NOW, config: { weeklySlack: 15 } }).go, true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a fully spent week sleeps to the weekly reset, not the five-hour one', () => {
|
|
134
|
+
const weekReset = NOW + 3 * 24 * HOUR;
|
|
135
|
+
const g = gaugeAt(30, {
|
|
136
|
+
five_hour: { used_percentage: 0, resets_at: NOW + HOUR },
|
|
137
|
+
seven_day: { used_percentage: 100, resets_at: weekReset },
|
|
138
|
+
});
|
|
139
|
+
const d = decideCapacity({ gauge: g, now: NOW });
|
|
140
|
+
assert.equal(d.window, 'seven_day', 'an empty five-hour window means nothing if the week is gone');
|
|
141
|
+
assert.equal(d.sleepUntil, weekReset);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('every HOLD names a wake that is in the future and not past its own reset', () => {
|
|
145
|
+
// Swept rather than hand-picked: a wake time in the past is a spin loop, and
|
|
146
|
+
// one past the reset sleeps through capacity that already came back. The first
|
|
147
|
+
// draft of this test tried to force the clamp with a single crafted row and
|
|
148
|
+
// could not — the pace branch only fires when (used-slack)/100 exceeds the
|
|
149
|
+
// elapsed fraction, which is exactly the condition that puts the catch-up time
|
|
150
|
+
// in the future. So the clamp in decideCapacity is defensive, and what is
|
|
151
|
+
// actually worth pinning is the invariant it defends.
|
|
152
|
+
let holds = 0;
|
|
153
|
+
for (const used5 of [0, 40, 80, 95, 100]) {
|
|
154
|
+
for (const used7 of [0, 25, 50, 75, 99, 100]) {
|
|
155
|
+
for (const weekRemainingD of [6.9, 5, 3.5, 1, 0.01]) {
|
|
156
|
+
for (const slack of [0, 15, 40]) {
|
|
157
|
+
const fiveReset = NOW + 37 * 60;
|
|
158
|
+
const weekReset = NOW + Math.round(weekRemainingD * 24 * HOUR);
|
|
159
|
+
const d = decideCapacity({
|
|
160
|
+
gauge: gaugeAt(30, {
|
|
161
|
+
five_hour: { used_percentage: used5, resets_at: fiveReset },
|
|
162
|
+
seven_day: { used_percentage: used7, resets_at: weekReset },
|
|
163
|
+
}),
|
|
164
|
+
now: NOW,
|
|
165
|
+
config: { weeklySlack: slack },
|
|
166
|
+
});
|
|
167
|
+
if (d.go || d.unknown) continue;
|
|
168
|
+
holds += 1;
|
|
169
|
+
const label = `5h=${used5} 7d=${used7} rem=${weekRemainingD}d slack=${slack}`;
|
|
170
|
+
assert.ok(d.sleepUntil > NOW, `${label}: wake must be in the future`);
|
|
171
|
+
const ownReset = d.window === 'five_hour' ? fiveReset : weekReset;
|
|
172
|
+
assert.ok(d.sleepUntil <= ownReset, `${label}: must not sleep past its own reset`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
assert.ok(holds > 20, `the sweep must actually produce holds to assert on (got ${holds})`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('whenPaceAllows is monotonic in what has been spent', () => {
|
|
181
|
+
const resetsAt = NOW + 5 * 24 * HOUR;
|
|
182
|
+
const at = (used) => whenPaceAllows({ used, resetsAt }, { weeklySlack: 0 });
|
|
183
|
+
assert.ok(at(80) > at(50), 'spending more must push the catch-up later, never earlier');
|
|
184
|
+
assert.ok(at(50) > at(20));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('weeklyPace reads elapsed time from resets_at, needing no stored week-start', () => {
|
|
188
|
+
const half = weeklyPace({ used: 0, resetsAt: NOW + 3.5 * 24 * HOUR }, NOW, { weeklySlack: 0 });
|
|
189
|
+
assert.ok(Math.abs(half.elapsedFraction - 0.5) < 0.01);
|
|
190
|
+
assert.ok(Math.abs(half.allowedPct - 50) < 1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── the writer ──────────────────────────────────────────────────────────────────
|
|
194
|
+
test('the writer never invents a gauge it was not given', () => {
|
|
195
|
+
assert.match(statusline.main(JSON.stringify({ model: { id: 'x' } })), /not reported/i,
|
|
196
|
+
'a render before the first API response must not overwrite a real reading with an empty one');
|
|
197
|
+
assert.match(statusline.main('not json at all'), /no status json/i);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('the writer persists both windows and reports them', () => {
|
|
201
|
+
// Redirect via AUTOBONGOS_GAUGE_FILE, NOT XDG_CONFIG_HOME: instance-config's
|
|
202
|
+
// configPath does not read XDG, so the first version of this test believed it
|
|
203
|
+
// was isolated while writing into the real ~/.config/cloudbongos.
|
|
204
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gauge-'));
|
|
205
|
+
const target = path.join(dir, 'autobongos-gauge.json');
|
|
206
|
+
const prev = process.env.AUTOBONGOS_GAUGE_FILE;
|
|
207
|
+
process.env.AUTOBONGOS_GAUGE_FILE = target;
|
|
208
|
+
try {
|
|
209
|
+
const line = statusline.main(JSON.stringify({
|
|
210
|
+
rate_limits: {
|
|
211
|
+
five_hour: { used_percentage: 42.4, resets_at: NOW + HOUR },
|
|
212
|
+
seven_day: { used_percentage: 71.2, resets_at: NOW + 5 * 24 * HOUR },
|
|
213
|
+
},
|
|
214
|
+
}));
|
|
215
|
+
assert.match(line, /5h 42%/);
|
|
216
|
+
assert.match(line, /7d 71%/);
|
|
217
|
+
// and it must land where it was told, not in the real config dir
|
|
218
|
+
assert.ok(fs.existsSync(target), 'the writer must honour AUTOBONGOS_GAUGE_FILE');
|
|
219
|
+
const onDisk = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
220
|
+
assert.equal(onDisk.five_hour.used_percentage, 42.4, 'persisted value must be exact, not the rounded display one');
|
|
221
|
+
assert.ok(Number.isFinite(onDisk.written_at));
|
|
222
|
+
} finally {
|
|
223
|
+
if (prev === undefined) delete process.env.AUTOBONGOS_GAUGE_FILE; else process.env.AUTOBONGOS_GAUGE_FILE = prev;
|
|
224
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── the round trip ──────────────────────────────────────────────────────────────
|
|
229
|
+
test('a file the writer produced is one the reader accepts', () => {
|
|
230
|
+
const now = Math.floor(Date.now() / 1000);
|
|
231
|
+
const written = { written_at: now, source: 'statusline',
|
|
232
|
+
five_hour: { used_percentage: 5, resets_at: now + HOUR },
|
|
233
|
+
seven_day: { used_percentage: 5, resets_at: now + 6 * 24 * HOUR } };
|
|
234
|
+
const round = JSON.parse(JSON.stringify(written)); // through disk, as JSON
|
|
235
|
+
const d = decideCapacity({ gauge: round, now });
|
|
236
|
+
assert.equal(d.go, true, 'the two halves must agree on the file format');
|
|
237
|
+
assert.equal(readGauge(round, now).known, true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── grader finding (major): a render must not destroy the window it did not report ──
|
|
241
|
+
test('a render reporting only five_hour CARRIES FORWARD the known seven_day', () => {
|
|
242
|
+
inTempGauge((target) => {
|
|
243
|
+
const now = Math.floor(Date.now() / 1000);
|
|
244
|
+
statusline.main(JSON.stringify({ rate_limits: {
|
|
245
|
+
five_hour: { used_percentage: 10, resets_at: now + HOUR },
|
|
246
|
+
seven_day: { used_percentage: 64, resets_at: now + 4 * 24 * HOUR },
|
|
247
|
+
} }));
|
|
248
|
+
const first = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
249
|
+
assert.equal(first.seven_day.used_percentage, 64);
|
|
250
|
+
|
|
251
|
+
// The next render omits seven_day — transient, and the half the weekly
|
|
252
|
+
// pacing depends on. Rebuilding from scratch used to drop it silently.
|
|
253
|
+
statusline.main(JSON.stringify({ rate_limits: { five_hour: { used_percentage: 20, resets_at: now + HOUR } } }));
|
|
254
|
+
const second = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
255
|
+
assert.ok(second.seven_day, 'the seven-day reading must survive a render that did not mention it');
|
|
256
|
+
assert.equal(second.seven_day.used_percentage, 64);
|
|
257
|
+
assert.equal(second.five_hour.used_percentage, 20, 'the reported half must still update');
|
|
258
|
+
assert.equal(second.seven_day.observed_at, first.seven_day.observed_at,
|
|
259
|
+
'a carried window keeps its ORIGINAL observed_at, or it would read as fresh forever');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('a carried-forward window ages out on its own, even in a freshly written file', () => {
|
|
264
|
+
const now = 1_900_000_000;
|
|
265
|
+
const raw = {
|
|
266
|
+
written_at: now, // the FILE is fresh…
|
|
267
|
+
five_hour: { used_percentage: 5, resets_at: now + HOUR, observed_at: now },
|
|
268
|
+
seven_day: { used_percentage: 99, resets_at: now + 3 * 24 * HOUR, observed_at: now - 6 * HOUR },
|
|
269
|
+
};
|
|
270
|
+
const g = readGauge(raw, now, { staleAfterS: 1800 });
|
|
271
|
+
assert.equal(g.known, true, 'the fresh five-hour half is still usable');
|
|
272
|
+
assert.equal(g.sevenDay, null, '…but a six-hour-old seven-day reading is not evidence');
|
|
273
|
+
// And therefore the 99% must NOT hold it — that number is no longer known.
|
|
274
|
+
assert.equal(decideCapacity({ gauge: raw, now, config: { staleAfterS: 1800 } }).go, true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('when every window has aged out the verdict is unknown, not headroom', () => {
|
|
278
|
+
const now = 1_900_000_000;
|
|
279
|
+
const raw = { written_at: now,
|
|
280
|
+
five_hour: { used_percentage: 5, resets_at: now + HOUR, observed_at: now - 6 * HOUR },
|
|
281
|
+
seven_day: { used_percentage: 5, resets_at: now + 3 * 24 * HOUR, observed_at: now - 6 * HOUR } };
|
|
282
|
+
const d = decideCapacity({ gauge: raw, now, config: { staleAfterS: 1800 } });
|
|
283
|
+
assert.equal(d.go, false);
|
|
284
|
+
assert.equal(d.unknown, true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('a file written before observed_at existed still reads (falls back to written_at)', () => {
|
|
288
|
+
const now = 1_900_000_000;
|
|
289
|
+
const legacy = { written_at: now - 60,
|
|
290
|
+
five_hour: { used_percentage: 5, resets_at: now + HOUR },
|
|
291
|
+
seven_day: { used_percentage: 5, resets_at: now + 3 * 24 * HOUR } };
|
|
292
|
+
assert.equal(readGauge(legacy, now).known, true);
|
|
293
|
+
assert.equal(decideCapacity({ gauge: legacy, now }).go, true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ── grader finding (major): the CLI's own env parsing and file resolution ───────
|
|
297
|
+
test('readConfig reads every knob, and ignores unparseable values', () => {
|
|
298
|
+
withEnv({
|
|
299
|
+
AUTOBONGOS_TIMEZONE: 'Europe/Oslo', AUTOBONGOS_STALE_AFTER_S: '900',
|
|
300
|
+
AUTOBONGOS_WEEKLY_SLACK: '5', AUTOBONGOS_RESERVE_WORKING: '30',
|
|
301
|
+
AUTOBONGOS_RESERVE_OVERNIGHT: '1', AUTOBONGOS_HOURS_START: '7', AUTOBONGOS_HOURS_END: '19',
|
|
302
|
+
}, () => {
|
|
303
|
+
const c = cli.readConfig();
|
|
304
|
+
assert.equal(c.timezone, 'Europe/Oslo');
|
|
305
|
+
assert.equal(c.staleAfterS, 900);
|
|
306
|
+
assert.equal(c.weeklySlack, 5);
|
|
307
|
+
assert.deepEqual(c.reserve, { working: 30, overnight: 1 });
|
|
308
|
+
assert.deepEqual(c.ownerHours, { start: 7, end: 19 });
|
|
309
|
+
});
|
|
310
|
+
withEnv({
|
|
311
|
+
AUTOBONGOS_TIMEZONE: undefined, AUTOBONGOS_STALE_AFTER_S: 'soon',
|
|
312
|
+
AUTOBONGOS_WEEKLY_SLACK: undefined, AUTOBONGOS_RESERVE_WORKING: undefined,
|
|
313
|
+
AUTOBONGOS_RESERVE_OVERNIGHT: undefined, AUTOBONGOS_HOURS_START: undefined, AUTOBONGOS_HOURS_END: undefined,
|
|
314
|
+
}, () => {
|
|
315
|
+
const c = cli.readConfig();
|
|
316
|
+
assert.equal(c.staleAfterS, undefined, 'an unparseable value must fall through to the default, not become NaN');
|
|
317
|
+
assert.equal(c.timezone, undefined);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('gaugePath honours the redirect, and evaluate reads the file the writer wrote', () => {
|
|
322
|
+
inTempGauge((target) => {
|
|
323
|
+
assert.equal(cli.gaugePath(), target);
|
|
324
|
+
assert.equal(cli.loadGauge(target), null, 'no file yet');
|
|
325
|
+
|
|
326
|
+
const now = Math.floor(Date.now() / 1000);
|
|
327
|
+
statusline.main(JSON.stringify({ rate_limits: {
|
|
328
|
+
five_hour: { used_percentage: 3, resets_at: now + HOUR },
|
|
329
|
+
seven_day: { used_percentage: 3, resets_at: now + 6 * 24 * HOUR },
|
|
330
|
+
} }));
|
|
331
|
+
|
|
332
|
+
const { path: p, decision } = cli.evaluate(now);
|
|
333
|
+
assert.equal(p, target);
|
|
334
|
+
assert.equal(decision.go, true, 'the CLI must reach the same verdict as the pure core');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('evaluate on a missing file is UNKNOWN — the CLI never defaults to go', () => {
|
|
339
|
+
inTempGauge(() => {
|
|
340
|
+
const d = cli.evaluate(Math.floor(Date.now() / 1000)).decision;
|
|
341
|
+
assert.equal(d.go, false);
|
|
342
|
+
assert.equal(d.unknown, true);
|
|
343
|
+
});
|
|
344
|
+
});
|