@bongos/core 1.21.5 → 1.21.7
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 +152 -72
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/api/openapi.json +2 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +156 -144
- package/docs/copy-registry.json +286 -158
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +35 -6
- package/docs/page-readings.json +1056 -1013
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/routes/autonomy.js +22 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/projects-hall-live.states.json +216 -0
- package/modules/public-landing/public/projects.html +43 -13
- package/modules/public-landing/public/projects.states.json +2 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances-hall-live.json +106 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +64 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/project_invite_ui.mjs +65 -7
- package/tests/projects_hub_dns_ready.mjs +7 -1
- package/tests/provision.mjs +78 -0
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_physics_more.mjs +19 -0
|
@@ -102,7 +102,9 @@ test('an unparseable updated_at still gets a shape — quiet is the wrong failur
|
|
|
102
102
|
// ── the incident this exists for ───────────────────────────────────────────────
|
|
103
103
|
|
|
104
104
|
test('shipped with nothing on main is called out, not celebrated', () => {
|
|
105
|
-
|
|
105
|
+
// Past the land grace window (task 1004538) — inside it the same facts are
|
|
106
|
+
// `pending_land`, covered below.
|
|
107
|
+
const v = classifyLedger({ task: task({ shipped_at: '2026-09-16T12:00:00.000Z' }), artifact: notOnMain, nowMs: NOW });
|
|
106
108
|
assert.equal(v.shape, 'shipped_no_artifact');
|
|
107
109
|
assert.equal(v.verified, false);
|
|
108
110
|
assert.equal(v.fileBlocker, true);
|
|
@@ -199,16 +201,44 @@ test('the artifact probe reads main, fixed-string, for the ship-commit conventio
|
|
|
199
201
|
const git = async (args) => {
|
|
200
202
|
calls.push(args);
|
|
201
203
|
if (args[0] === 'fetch') return { ok: true, stdout: '', stderr: '' };
|
|
202
|
-
return { ok: true, stdout: 'deadbeefcafe1234
|
|
204
|
+
return { ok: true, stdout: 'deadbeefcafe1234\x1ffeat: the thing (task 42)\x1f\x1e\n', stderr: '' };
|
|
203
205
|
};
|
|
204
206
|
const r = await probeArtifact(42, { git });
|
|
205
207
|
assert.deepEqual(r, { checked: true, onMain: true, sha: 'deadbeefcafe1234', subject: 'feat: the thing (task 42)' });
|
|
206
208
|
assert.deepEqual(calls[0], ['fetch', '-q', 'origin', 'main']);
|
|
207
209
|
assert.ok(calls[1].includes('-F'), 'the pattern is a fixed string, not a regex');
|
|
208
|
-
assert.ok(calls[1].includes('--grep=
|
|
210
|
+
assert.ok(calls[1].includes('--grep=42'), 'git narrows by the bare id; the exact match is made in JS');
|
|
209
211
|
assert.ok(calls[1].includes('FETCH_HEAD'), 'read what was just fetched, not a stale local ref');
|
|
210
212
|
});
|
|
211
213
|
|
|
214
|
+
// task 1004538 — the commits PR 1456 landed for task 1004513 never wrote the
|
|
215
|
+
// literal `(task 1004513)`: the merge subject names the BRANCH
|
|
216
|
+
// (`…/autobongos-1004513-978953`) and a body line says "task 1004513 makes…".
|
|
217
|
+
// The old `(task N)`-only grep therefore said "no artifact" even AFTER the land,
|
|
218
|
+
// so no grace window alone could ever have cleared that blocker.
|
|
219
|
+
test('the probe recognises a ship branch in a merge subject and a body mention', async () => {
|
|
220
|
+
const log = (rows) => async (args) => (args[0] === 'fetch'
|
|
221
|
+
? { ok: true, stdout: '', stderr: '' }
|
|
222
|
+
: { ok: true, stdout: rows.map(([h, s, b]) => `${h}\x1f${s}\x1f${b || ''}\x1e\n`).join(''), stderr: '' });
|
|
223
|
+
const merge = await probeArtifact(1004513, { git: log([['3d50a6289', 'Merge pull request #1456 from octo/autobongos-1004513-978953']]) });
|
|
224
|
+
assert.equal(merge.onMain, true);
|
|
225
|
+
assert.equal(merge.sha, '3d50a6289');
|
|
226
|
+
const manual = await probeArtifact(1004440, { git: log([['a55b7707e', 'Merge pull request #1471 from octo/task-1004440']]) });
|
|
227
|
+
assert.equal(manual.onMain, true);
|
|
228
|
+
const body = await probeArtifact(1004513, { git: log([['301df048f', 'docs(lifecycle): a note', 'Fixes the goal close.\n\nRefs (task 1004513).']]) });
|
|
229
|
+
assert.equal(body.onMain, true, 'the `(task N)` convention still counts in a body');
|
|
230
|
+
// A body that merely DISCUSSES the task is another task's commit talking about
|
|
231
|
+
// it — not an artifact. (This task's own commits mention task 1004513.)
|
|
232
|
+
const talk = await probeArtifact(1004513, { git: log([['dddd', 'fix(verify): slow lands (task 1004538)', 'task 1004513 was flagged too early']]) });
|
|
233
|
+
assert.equal(talk.onMain, false);
|
|
234
|
+
// A longer id that merely STARTS with this one is not this task.
|
|
235
|
+
const prefix = await probeArtifact(1004513, { git: log([['aaaa', 'fix: thing (task 10045139)'], ['bbbb', 'Merge from x/task-10045130']]) });
|
|
236
|
+
assert.equal(prefix.onMain, false);
|
|
237
|
+
// Nor is a bare number with no `task` before it (a PR number, a row count).
|
|
238
|
+
const bare = await probeArtifact(1004513, { git: log([['cccc', 'chore: bump 1004513 rows']]) });
|
|
239
|
+
assert.equal(bare.onMain, false);
|
|
240
|
+
});
|
|
241
|
+
|
|
212
242
|
test('a failed fetch reports `checked: false`, never `onMain: false`', async () => {
|
|
213
243
|
const git = async (args) => (args[0] === 'fetch'
|
|
214
244
|
? { ok: false, stdout: '', stderr: 'fatal: could not read from remote' }
|
|
@@ -345,3 +375,209 @@ test('a thrown client error is a read failure, not a crash', async () => {
|
|
|
345
375
|
assert.equal(out.halt, true);
|
|
346
376
|
assert.match(out.read_error, /fetch failed/);
|
|
347
377
|
});
|
|
378
|
+
|
|
379
|
+
// ── a slow land is not a missing artifact (task 1004538) ───────────────────────
|
|
380
|
+
//
|
|
381
|
+
// Task 1004513 was flagged `shipped_no_artifact` at 22:24Z and its merge landed
|
|
382
|
+
// ~11 hours later; the owner got a false alarm (blocker 1000185). A shipped task
|
|
383
|
+
// with nothing on main is first `pending_land`. The clock is the LEDGER's
|
|
384
|
+
// `shipped_at`, so a runner restart cannot reset it.
|
|
385
|
+
|
|
386
|
+
const { LAND_GRACE_MS, recheckLands, pendingLandIds } = verify;
|
|
387
|
+
const shippedAgo = (ms) => task({ shipped_at: new Date(NOW - ms).toISOString(), updated_at: new Date(NOW - ms).toISOString() });
|
|
388
|
+
|
|
389
|
+
test('(1) inside the grace window, a missing artifact is pending_land and files NO blocker', async () => {
|
|
390
|
+
assert.ok(LAND_GRACE_MS >= 12 * 3600_000, 'the land pipeline can take hours on a red main — 1004513 took ~11');
|
|
391
|
+
const v = classifyLedger({ task: shippedAgo(11 * 3600_000), artifact: notOnMain, nowMs: NOW });
|
|
392
|
+
assert.equal(v.shape, 'pending_land');
|
|
393
|
+
assert.equal(v.verified, false, 'not yet a fact');
|
|
394
|
+
assert.equal(v.fileBlocker, false);
|
|
395
|
+
assert.equal(v.releaseClaim, false);
|
|
396
|
+
assert.equal(v.halt, false);
|
|
397
|
+
|
|
398
|
+
let filed = 0;
|
|
399
|
+
const out = await verifyShip({ taskId: 1003903 }, {
|
|
400
|
+
api: fakeApi(), task: shippedAgo(60_000), nowMs: NOW,
|
|
401
|
+
probeArtifact: async () => notOnMain,
|
|
402
|
+
fileBlocker: async () => { filed += 1; return { filed: true, blockerId: 9 }; },
|
|
403
|
+
});
|
|
404
|
+
assert.equal(out.shape, 'pending_land');
|
|
405
|
+
assert.equal(filed, 0, 'no owner blocker for a land that is merely slow');
|
|
406
|
+
assert.equal(out.blocker, null);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('(2) past the grace window, a still-missing artifact files the blocker', async () => {
|
|
410
|
+
const v = classifyLedger({ task: shippedAgo(LAND_GRACE_MS + 60_000), artifact: notOnMain, nowMs: NOW });
|
|
411
|
+
assert.equal(v.shape, 'shipped_no_artifact');
|
|
412
|
+
assert.equal(v.fileBlocker, true);
|
|
413
|
+
|
|
414
|
+
const rows = [];
|
|
415
|
+
const out = await verifyShip({ taskId: 1003903 }, {
|
|
416
|
+
api: fakeApi(), task: shippedAgo(LAND_GRACE_MS + 60_000), nowMs: NOW,
|
|
417
|
+
probeArtifact: async () => notOnMain,
|
|
418
|
+
fileBlocker: async (_a, row) => { rows.push(row); return { filed: true, blockerId: 9 }; },
|
|
419
|
+
});
|
|
420
|
+
assert.equal(out.shape, 'shipped_no_artifact');
|
|
421
|
+
assert.equal(rows.length, 1);
|
|
422
|
+
assert.equal(rows[0].source_ref, 'task-1003903-shipped_no_artifact', 'the dedupe key the resolve sweep matches on');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('the grace clock is shipped_at, and an unreadable clock does not hide a missing artifact', () => {
|
|
426
|
+
// updated_at moves on every touch; shipped_at is when the ship happened.
|
|
427
|
+
const t = task({ shipped_at: new Date(NOW - LAND_GRACE_MS - 1000).toISOString(), updated_at: new Date(NOW - 1000).toISOString() });
|
|
428
|
+
assert.equal(classifyLedger({ task: t, artifact: notOnMain, nowMs: NOW }).shape, 'shipped_no_artifact');
|
|
429
|
+
const undated = task({ shipped_at: null, updated_at: 'not a date' });
|
|
430
|
+
assert.equal(classifyLedger({ task: undated, artifact: notOnMain, nowMs: NOW }).shape, 'shipped_no_artifact',
|
|
431
|
+
'with no clock the grace cannot be measured, so this stays as strict as it was');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('a pending land that later appears on main verifies as shipped', () => {
|
|
435
|
+
assert.equal(classifyLedger({ task: shippedAgo(3600_000), artifact: onMain, nowMs: NOW }).shape, 'shipped');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ── re-probing across iterations, and across a restart ─────────────────────────
|
|
439
|
+
|
|
440
|
+
test('pendingLandIds reads the run log: the LATEST verdict per task decides', () => {
|
|
441
|
+
const rows = [
|
|
442
|
+
{ event: 'worked', task_id: '1', ledger_shape: 'pending_land' },
|
|
443
|
+
{ event: 'worked', task_id: '2', ledger_shape: 'pending_land' },
|
|
444
|
+
{ event: 'land_recheck', task_id: '2', ledger_shape: 'shipped' },
|
|
445
|
+
{ event: 'worked', task_id: '3', ledger_shape: 'shipped' },
|
|
446
|
+
{ event: 'land_recheck', task_id: '4', ledger_shape: 'pending_land' },
|
|
447
|
+
{ event: 'cadence', mode: 'full' },
|
|
448
|
+
{ event: 'worked', task_id: '5', ledger_shape: 'pending_land' },
|
|
449
|
+
{ event: 'land_recheck', task_id: '5', ledger_shape: 'shipped_no_artifact' },
|
|
450
|
+
null, 'garbage',
|
|
451
|
+
];
|
|
452
|
+
assert.deepEqual(pendingLandIds(rows).sort(), ['1', '4']);
|
|
453
|
+
assert.deepEqual(pendingLandIds([]), []);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const blockerApi = (open, resolved) => ({
|
|
457
|
+
tasks: { getTasksId: async ({ id }) => ({ ok: true, data: { task: { ...shippedAgo(3600_000), id: String(id) } } }) },
|
|
458
|
+
blockers: {
|
|
459
|
+
getBlockers: async () => ({ ok: true, data: { blockers: open } }),
|
|
460
|
+
postBlockersIdResolve: async ({ id, body }) => { resolved.push({ id: String(id), note: body.resolution_note }); return { ok: true, data: {} }; },
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('(3) an already-filed no-artifact blocker is resolved once main carries the commit', async () => {
|
|
465
|
+
const resolved = [];
|
|
466
|
+
const open = [
|
|
467
|
+
{ id: '1000185', source: 'autobongos', source_ref: 'task-1004513-shipped_no_artifact', status: 'open' },
|
|
468
|
+
{ id: '1000186', source: 'autobongos', source_ref: 'task-1004514-shipped_no_artifact', status: 'open' },
|
|
469
|
+
{ id: '1000187', source: 'autobongos', source_ref: 'task-1004515-still_held', status: 'open' },
|
|
470
|
+
{ id: '1000188', source: 'manual', source_ref: 'task-1004516-shipped_no_artifact', status: 'open' },
|
|
471
|
+
];
|
|
472
|
+
const probed = [];
|
|
473
|
+
const out = await recheckLands({ pendingIds: [] }, {
|
|
474
|
+
api: blockerApi(open, resolved), nowMs: NOW,
|
|
475
|
+
probeArtifact: async (id) => {
|
|
476
|
+
probed.push(String(id));
|
|
477
|
+
return String(id) === '1004513'
|
|
478
|
+
? { checked: true, onMain: true, sha: '3d50a6289abc', subject: 'Merge pull request #1456 from octo/autobongos-1004513-978953' }
|
|
479
|
+
: notOnMain;
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
assert.deepEqual(resolved.map((r) => r.id), ['1000185'], 'only the autobongos no-artifact blocker whose commit is now on main');
|
|
483
|
+
assert.match(resolved[0].note, /3d50a628/);
|
|
484
|
+
assert.match(resolved[0].note, /task 1004513/);
|
|
485
|
+
assert.deepEqual(probed.sort(), ['1004513', '1004514'], 'other shapes and other sources are not this sweep’s business');
|
|
486
|
+
assert.deepEqual(out.resolved.map((r) => r.blocker_id), ['1000185']);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('a probe that could not run never resolves a blocker', async () => {
|
|
490
|
+
const resolved = [];
|
|
491
|
+
await recheckLands({ pendingIds: [] }, {
|
|
492
|
+
api: blockerApi([{ id: '7', source: 'autobongos', source_ref: 'task-42-shipped_no_artifact' }], resolved), nowMs: NOW,
|
|
493
|
+
probeArtifact: async () => ({ checked: false, reason: 'no network' }),
|
|
494
|
+
});
|
|
495
|
+
assert.deepEqual(resolved, []);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('recheckLands re-verifies each pending task: quiet inside the window, a blocker after it', async () => {
|
|
499
|
+
const filed = [];
|
|
500
|
+
const deps = (ageMs) => ({
|
|
501
|
+
api: {
|
|
502
|
+
tasks: { getTasksId: async ({ id }) => ({ ok: true, data: { task: { ...shippedAgo(ageMs), id: String(id) } } }) },
|
|
503
|
+
blockers: { getBlockers: async () => ({ ok: true, data: { blockers: [] } }) },
|
|
504
|
+
},
|
|
505
|
+
nowMs: NOW, probeArtifact: async () => notOnMain,
|
|
506
|
+
fileBlocker: async (_a, row) => { filed.push(row.source_ref); return { filed: true, blockerId: 11 }; },
|
|
507
|
+
});
|
|
508
|
+
const early = await recheckLands({ pendingIds: ['42'] }, deps(3600_000));
|
|
509
|
+
assert.deepEqual(early.rechecked.map((r) => r.ledger_shape), ['pending_land']);
|
|
510
|
+
assert.deepEqual(filed, []);
|
|
511
|
+
const late = await recheckLands({ pendingIds: ['42'] }, deps(LAND_GRACE_MS + 1000));
|
|
512
|
+
assert.deepEqual(late.rechecked.map((r) => r.ledger_shape), ['shipped_no_artifact']);
|
|
513
|
+
assert.deepEqual(filed, ['task-42-shipped_no_artifact']);
|
|
514
|
+
assert.equal(late.rechecked[0].blocker_id, 11);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('one pass fetches main ONCE, however many tasks and blockers it probes', async () => {
|
|
518
|
+
const calls = [];
|
|
519
|
+
const git = async (args) => {
|
|
520
|
+
calls.push(args[0]);
|
|
521
|
+
if (args[0] === 'fetch') return { ok: true, stdout: '', stderr: '' };
|
|
522
|
+
const id = args.find((a) => a.startsWith('--grep=')).slice(7);
|
|
523
|
+
return { ok: true, stdout: `abc${id}\x1fMerge from x/task-${id}\x1f\x1e\n`, stderr: '' };
|
|
524
|
+
};
|
|
525
|
+
const resolved = [];
|
|
526
|
+
const api = blockerApi([
|
|
527
|
+
{ id: '1', source: 'autobongos', source_ref: 'task-201-shipped_no_artifact' },
|
|
528
|
+
{ id: '2', source: 'autobongos', source_ref: 'task-202-shipped_no_artifact' },
|
|
529
|
+
], resolved);
|
|
530
|
+
await recheckLands({ pendingIds: ['101', '102', '201'] }, { api, git, nowMs: NOW });
|
|
531
|
+
assert.equal(calls.filter((c) => c === 'fetch').length, 1, 'one fetch per pass, not one per id');
|
|
532
|
+
assert.equal(calls.filter((c) => c === 'log').length, 4, 'one log per DISTINCT id (201 is both pending and blocked)');
|
|
533
|
+
assert.deepEqual(resolved.map((r) => r.id).sort(), ['1', '2']);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('nothing to probe means no git at all, and a failed fetch resolves nothing', async () => {
|
|
537
|
+
let gitCalls = 0;
|
|
538
|
+
await recheckLands({ pendingIds: [], sweepBlockers: false }, {
|
|
539
|
+
api: { tasks: {}, blockers: {} }, git: async () => { gitCalls += 1; return { ok: true, stdout: '' }; }, nowMs: NOW,
|
|
540
|
+
});
|
|
541
|
+
assert.equal(gitCalls, 0);
|
|
542
|
+
|
|
543
|
+
const resolved = [];
|
|
544
|
+
const out = await recheckLands({ pendingIds: [] }, {
|
|
545
|
+
api: blockerApi([{ id: '9', source: 'autobongos', source_ref: 'task-301-shipped_no_artifact' }], resolved), nowMs: NOW,
|
|
546
|
+
git: async (args) => (args[0] === 'fetch' ? { ok: false, stderr: 'fatal: unreachable' } : { ok: true, stdout: 'x\x1fMerge from x/task-301\x1f\x1e' }),
|
|
547
|
+
});
|
|
548
|
+
assert.deepEqual(resolved, [], 'not looking is not evidence the commit landed either');
|
|
549
|
+
assert.deepEqual(out.resolved, []);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('the blocker list pages by the route’s total, and says so when it gives up', async () => {
|
|
553
|
+
let gets = 0;
|
|
554
|
+
const paged = {
|
|
555
|
+
tasks: {},
|
|
556
|
+
blockers: {
|
|
557
|
+
getBlockers: async ({ query }) => {
|
|
558
|
+
gets += 1;
|
|
559
|
+
const rows = query.offset === 0
|
|
560
|
+
? Array.from({ length: 200 }, (_, i) => ({ id: String(i), source: 'manual', source_ref: null }))
|
|
561
|
+
: [{ id: '900', source: 'autobongos', source_ref: 'task-55-shipped_no_artifact' }];
|
|
562
|
+
return { ok: true, data: { blockers: rows, page: { limit: 200, offset: query.offset, total: 201 } } };
|
|
563
|
+
},
|
|
564
|
+
postBlockersIdResolve: async () => ({ ok: true }),
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
const probed = [];
|
|
568
|
+
await recheckLands({ pendingIds: [] }, { api: paged, nowMs: NOW, probeArtifact: async (id) => { probed.push(id); return notOnMain; } });
|
|
569
|
+
assert.equal(gets, 2, 'page two holds the 201st row');
|
|
570
|
+
assert.deepEqual(probed, ['55']);
|
|
571
|
+
|
|
572
|
+
const endless = { tasks: {}, blockers: { getBlockers: async ({ query }) => ({ ok: true, data: { blockers: Array.from({ length: 200 }, (_, i) => ({ id: String(query.offset + i), source: 'manual' })), page: { total: 1e9 } } }) } };
|
|
573
|
+
const out = await recheckLands({ pendingIds: [] }, { api: endless, nowMs: NOW, probeArtifact: async () => notOnMain });
|
|
574
|
+
assert.match(out.errors.join(' '), /stopped paging/, 'a cap that is hit must say so, not read as "looked everywhere"');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('an unreadable blocker list is reported, not thrown', async () => {
|
|
578
|
+
const out = await recheckLands({ pendingIds: [] }, {
|
|
579
|
+
api: { tasks: {}, blockers: { getBlockers: async () => { throw new Error('fetch failed'); } } }, nowMs: NOW,
|
|
580
|
+
probeArtifact: async () => notOnMain,
|
|
581
|
+
});
|
|
582
|
+
assert.match(out.errors.join(' '), /fetch failed/);
|
|
583
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// tests/autonomy_config_idle.mjs — idle on a setting is not idle on no work
|
|
2
|
+
// (task 1004539).
|
|
3
|
+
//
|
|
4
|
+
// The incident: ten hours fenced with no_builder_scope while the hall said
|
|
5
|
+
// "alive, waiting for work". These pin the done-when: runner health reports a
|
|
6
|
+
// configuration idle as NOT ok with the fix, the owner's kill switch does not,
|
|
7
|
+
// and the Discord notice is said once per change rather than once per loop.
|
|
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 ci = require('../modules/autonomy/config-idle.js');
|
|
15
|
+
const { describeRunner } = require('../modules/autonomy/runner-health.js');
|
|
16
|
+
const runner = require('../scripts/gds/autobongos-run.js');
|
|
17
|
+
|
|
18
|
+
const NOW = Date.parse('2026-10-02T09:47:00.000Z');
|
|
19
|
+
const agoS = (s) => new Date(NOW - s * 1000).toISOString();
|
|
20
|
+
const hb = (over = {}) => ({ host: 'winbox', mode: 'full', last_seen_at: agoS(30), ...over });
|
|
21
|
+
|
|
22
|
+
// ── runner health: the hall's verdict ───────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
test('a runner idle on no_builder_scope is NOT ok, and the hall gets the fix', () => {
|
|
25
|
+
const d = describeRunner(hb({ last_event: 'config_idle:no_builder_scope' }), { nowMs: NOW });
|
|
26
|
+
assert.equal(d.state, 'idle_config');
|
|
27
|
+
assert.equal(d.ok, false, 'the incident: this read ok=true for ten hours');
|
|
28
|
+
assert.equal(d.idleCode, 'no_builder_scope');
|
|
29
|
+
assert.match(d.remedy, /Settings/);
|
|
30
|
+
assert.match(d.detail, /no goals are picked/);
|
|
31
|
+
assert.match(d.detail, /winbox/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('every configuration code reads not-ok with its own remedy', () => {
|
|
35
|
+
for (const code of Object.keys(ci.CONFIG_IDLE)) {
|
|
36
|
+
const d = describeRunner(hb({ last_event: `config_idle:${code}` }), { nowMs: NOW });
|
|
37
|
+
assert.equal(d.ok, false, code);
|
|
38
|
+
assert.equal(d.remedy, ci.CONFIG_IDLE[code].remedy, code);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a config-idle runner that goes quiet past the dead line is DEAD, not idle', () => {
|
|
43
|
+
// A dead runner cannot report its configuration; silence still wins.
|
|
44
|
+
const d = describeRunner(hb({ last_event: 'config_idle:no_builder_scope', last_seen_at: agoS(3 * 3600) }), { nowMs: NOW });
|
|
45
|
+
assert.equal(d.state, 'dead');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the kill switch, a plain wait and an unknown code stay ok', () => {
|
|
49
|
+
for (const last_event of ['fenced', 'waiting', 'nothing_claimable', 'hold', 'config_idle:kill_switch', 'config_idle:<b>x</b>']) {
|
|
50
|
+
const d = describeRunner(hb({ last_event }), { nowMs: NOW });
|
|
51
|
+
assert.equal(d.state, 'alive', last_event);
|
|
52
|
+
assert.equal(d.ok, true, last_event);
|
|
53
|
+
assert.equal(d.remedy, undefined, last_event);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── the runner side: which rows count, and the grace ────────────────────────
|
|
58
|
+
|
|
59
|
+
test('configIdleCode: configuration refusals count; deliberate stops and no-work do not', () => {
|
|
60
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'no_builder_scope' }), 'no_builder_scope');
|
|
61
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'no_allowlist' }), 'no_allowlist');
|
|
62
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'fence_unreadable' }), 'fence_unreadable');
|
|
63
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'goal_not_allowlisted', refused_goals: [7] }), 'goal_not_allowlisted');
|
|
64
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'goal_not_allowlisted', task_id: 5 }), null, 'one task outside the allowlist is the fence working');
|
|
65
|
+
assert.equal(ci.configIdleCode({ event: 'hold', unknown: true, reason: 'no gauge file' }), 'gauge_unknown');
|
|
66
|
+
for (const row of [
|
|
67
|
+
{ event: 'fenced', code: 'kill_switch' },
|
|
68
|
+
{ event: 'fenced', code: 'builder_paused' },
|
|
69
|
+
{ event: 'fenced', code: 'not_permitted' },
|
|
70
|
+
{ event: 'fenced', code: 'protected_path', task_id: 3 },
|
|
71
|
+
{ event: 'hold', unknown: false, sleep_until: 1 },
|
|
72
|
+
{ event: 'hold', unknown: false, usage_limit: true },
|
|
73
|
+
{ event: 'nothing_claimable' },
|
|
74
|
+
null,
|
|
75
|
+
]) assert.equal(ci.configIdleCode(row), null, JSON.stringify(row));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the grace: nothing is said until the stretch has held for it', () => {
|
|
79
|
+
const t0 = NOW;
|
|
80
|
+
let tr = ci.trackIdle(null, { event: 'fenced', code: 'no_builder_scope' }, t0);
|
|
81
|
+
assert.equal(ci.idleEvent(tr, t0 + (ci.GRACE_S - 1) * 1000), null);
|
|
82
|
+
tr = ci.trackIdle(tr, { event: 'fenced', code: 'no_builder_scope' }, t0 + 600_000);
|
|
83
|
+
assert.equal(tr.sinceMs, t0, 'the same code continues the stretch');
|
|
84
|
+
assert.equal(ci.idleEvent(tr, t0 + ci.GRACE_S * 1000), 'config_idle:no_builder_scope');
|
|
85
|
+
// A blip-prone code waits longer.
|
|
86
|
+
const fu = ci.trackIdle(null, { event: 'fenced', code: 'fence_unreadable' }, t0);
|
|
87
|
+
assert.equal(ci.idleEvent(fu, t0 + ci.GRACE_S * 1000), null);
|
|
88
|
+
assert.equal(ci.idleEvent(fu, t0 + ci.LONG_GRACE_S * 1000), 'config_idle:fence_unreadable');
|
|
89
|
+
// A different code restarts the clock; any other row ends it.
|
|
90
|
+
assert.equal(ci.trackIdle(tr, { event: 'fenced', code: 'no_allowlist' }, t0 + 1).sinceMs, t0 + 1);
|
|
91
|
+
assert.equal(ci.trackIdle(tr, { event: 'fenced', code: 'kill_switch' }, t0 + 1), null);
|
|
92
|
+
assert.equal(ci.trackIdle(tr, { event: 'nothing_claimable' }, t0 + 1), null);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('markBeat leaves a working, booting or exiting beat alone', () => {
|
|
96
|
+
const tr = { code: 'no_builder_scope', sinceMs: NOW - 3600_000 };
|
|
97
|
+
assert.equal(ci.markBeat({ last_event: 'waiting' }, tr, NOW).last_event, 'config_idle:no_builder_scope');
|
|
98
|
+
assert.equal(ci.markBeat({ last_event: 'loop', mode: 'full' }, tr, NOW).mode, 'full');
|
|
99
|
+
assert.equal(ci.markBeat({ last_event: 'worked', working_task_id: 9 }, tr, NOW).last_event, 'worked');
|
|
100
|
+
assert.equal(ci.markBeat({ last_event: 'boot' }, tr, NOW).last_event, 'boot');
|
|
101
|
+
assert.equal(ci.markBeat({ last_event: 'upgrade_exit' }, tr, NOW).last_event, 'upgrade_exit');
|
|
102
|
+
assert.equal(ci.markBeat({ last_event: 'waiting' }, null, NOW).last_event, 'waiting');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('THE LOOP: a runner fenced on no_builder_scope says so on its heartbeat once the grace has passed', async () => {
|
|
106
|
+
// Driven through the real forever(), so the beat override is wired, not just written.
|
|
107
|
+
let clock = NOW;
|
|
108
|
+
const beats = [];
|
|
109
|
+
const deps = {
|
|
110
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true, leftover: 0 }),
|
|
111
|
+
iteration: async () => ({ event: 'fenced', code: 'no_builder_scope', reason: 'r' }),
|
|
112
|
+
waitOrJump: async (waitS, d) => { clock += 10 * 60_000; await d.beat({ last_event: 'waiting' }); return { waited: waitS, jumped: false }; },
|
|
113
|
+
record: (r) => r,
|
|
114
|
+
emit: () => {},
|
|
115
|
+
writeHeartbeat: () => true,
|
|
116
|
+
postHeartbeat: async (fields) => { beats.push(fields.last_event); return true; },
|
|
117
|
+
codeDrifted: async () => false,
|
|
118
|
+
loadedHead: async () => null,
|
|
119
|
+
now: () => clock,
|
|
120
|
+
};
|
|
121
|
+
await runner.forever({ goals: [], maxTasks: 1, maxLoops: 4 }, deps);
|
|
122
|
+
assert.equal(beats[0], 'boot');
|
|
123
|
+
// The first loop is inside the grace: pending, which the hall reads as a fence wait.
|
|
124
|
+
assert.equal(beats[1], 'loop');
|
|
125
|
+
assert.equal(beats[2], 'config_pending:no_builder_scope');
|
|
126
|
+
assert.equal(describeRunner(hb({ last_event: beats[2] }), { nowMs: NOW }).ok, true, 'inside the grace is not an alert');
|
|
127
|
+
assert.ok(beats.includes('config_idle:no_builder_scope'), `beats: ${beats.join(',')}`);
|
|
128
|
+
assert.equal(beats[beats.length - 1], 'config_idle:no_builder_scope', 'and keeps saying it while it holds');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('THE LOOP: the kill switch never turns into a config_idle beat', async () => {
|
|
132
|
+
let clock = NOW;
|
|
133
|
+
const beats = [];
|
|
134
|
+
const deps = {
|
|
135
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true, leftover: 0 }),
|
|
136
|
+
iteration: async () => ({ event: 'fenced', code: 'kill_switch', reason: 'r' }),
|
|
137
|
+
waitOrJump: async (waitS, d) => { clock += 60 * 60_000; await d.beat({ last_event: 'waiting' }); return { waited: waitS, jumped: false }; },
|
|
138
|
+
record: (r) => r, emit: () => {},
|
|
139
|
+
writeHeartbeat: () => true,
|
|
140
|
+
postHeartbeat: async (fields) => { beats.push(fields.last_event); return true; },
|
|
141
|
+
codeDrifted: async () => false, loadedHead: async () => null, now: () => clock,
|
|
142
|
+
};
|
|
143
|
+
await runner.forever({ goals: [], maxTasks: 1, maxLoops: 4 }, deps);
|
|
144
|
+
assert.ok(!beats.some((b) => String(b).startsWith('config_idle:')), beats.join(','));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── the notice: once per change ─────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
test('the Discord notice is DE-DUPLICATED: once when it starts, once when it clears', async () => {
|
|
150
|
+
const posts = [];
|
|
151
|
+
const n = ci.createNotifier({ post: async (m) => { posts.push(m); return { posted: true }; } });
|
|
152
|
+
const beat = (lastEvent, extra = {}) => n.observe({ builderId: '3', host: 'WinBox', lastEvent, ...extra });
|
|
153
|
+
|
|
154
|
+
await beat('waiting');
|
|
155
|
+
assert.equal(posts.length, 0, 'quiet idle says nothing');
|
|
156
|
+
for (let i = 0; i < 30; i++) await beat('config_idle:no_builder_scope');
|
|
157
|
+
// A runner restart mid-stretch: boot, then the same setting pending again.
|
|
158
|
+
for (const e of ['loop', 'waiting', 'upgrade_exit', 'boot', 'loop', 'config_pending:no_builder_scope', 'waiting']) await beat(e);
|
|
159
|
+
await beat('config_idle:no_builder_scope');
|
|
160
|
+
assert.equal(posts.length, 1, 'thirty beats and a restart are one notice');
|
|
161
|
+
assert.match(posts[0].text, /no goals are picked/);
|
|
162
|
+
assert.match(posts[0].text, /Settings/);
|
|
163
|
+
assert.equal(posts[0].channel, 'ship_news');
|
|
164
|
+
|
|
165
|
+
await beat('config_idle:no_allowlist');
|
|
166
|
+
assert.equal(posts.length, 2, 'a different cause is a new notice');
|
|
167
|
+
await beat('nothing_claimable');
|
|
168
|
+
assert.equal(posts.length, 3, 'clearing is said once');
|
|
169
|
+
assert.match(posts[2].text, /no longer held/);
|
|
170
|
+
assert.match(posts[2].text, /no goal is allowed/, 'the clear names the setting in words');
|
|
171
|
+
for (const p of posts) assert.doesNotMatch(p.text, /no_allowlist|no_builder_scope/, 'never a raw code in a sentence a person reads');
|
|
172
|
+
await beat('nothing_claimable');
|
|
173
|
+
await beat('waiting');
|
|
174
|
+
assert.equal(posts.length, 3);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('the kill switch never posts, and a working beat clears a notice', async () => {
|
|
178
|
+
const posts = [];
|
|
179
|
+
const n = ci.createNotifier({ post: async (m) => { posts.push(m); return { posted: true }; } });
|
|
180
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'fenced' });
|
|
181
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:kill_switch' });
|
|
182
|
+
assert.equal(posts.length, 0);
|
|
183
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:gauge_unknown' });
|
|
184
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'loop', workingTaskId: 42 });
|
|
185
|
+
assert.equal(posts.length, 2);
|
|
186
|
+
// Separate runners are separate notices.
|
|
187
|
+
await n.observe({ builderId: 1, host: 'b', lastEvent: 'config_idle:gauge_unknown' });
|
|
188
|
+
await n.observe({ builderId: 2, host: 'a', lastEvent: 'config_idle:gauge_unknown' });
|
|
189
|
+
assert.equal(posts.length, 4);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a gauge notice clears when the runner moves to an ordinary capacity hold, or the owner throws the switch', async () => {
|
|
193
|
+
for (const after of ['hold', 'fenced']) {
|
|
194
|
+
const posts = [];
|
|
195
|
+
const n = ci.createNotifier({ post: async (m) => { posts.push(m); return { posted: true }; } });
|
|
196
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:gauge_unknown' });
|
|
197
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'waiting' });
|
|
198
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: after });
|
|
199
|
+
assert.equal(posts.length, 2, after);
|
|
200
|
+
assert.match(posts[1].text, /no longer held/, after);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the host lands in Discord as plain code, never a link or formatting', () => {
|
|
205
|
+
const t = ci.noticeText({ action: 'raise', code: 'no_allowlist', host: '[click](https://evil.example)*@everyone*' });
|
|
206
|
+
assert.doesNotMatch(t, /\]\(|https?:|\*|@/);
|
|
207
|
+
assert.match(t, /on `clickhttpsevil\.exampleeveryone`/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('no Discord on the instance: nothing posts and nothing is remembered', async () => {
|
|
211
|
+
const n = ci.createNotifier({ post: null });
|
|
212
|
+
const r = await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:no_allowlist' });
|
|
213
|
+
assert.deepEqual([r.posted, r.reason], [false, 'no_discord']);
|
|
214
|
+
assert.equal(n.notified.size, 0);
|
|
215
|
+
// A port that answers "no webhook for that channel" is the same.
|
|
216
|
+
const m = ci.createNotifier({ post: async () => ({ posted: false, reason: 'no_webhook_for_channel' }) });
|
|
217
|
+
await m.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:no_allowlist' });
|
|
218
|
+
assert.equal(m.notified.size, 0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('the hall draws idle_config as a warning with the fix, on the Gate page and in Settings', () => {
|
|
222
|
+
const fs = require('node:fs');
|
|
223
|
+
const gate = fs.readFileSync('modules/hall-ui/public/gate.js', 'utf8');
|
|
224
|
+
assert.match(gate, /idle_config: 'warn'/, 'the dot is a warning, not ok');
|
|
225
|
+
assert.match(gate, /h\.state === 'idle_config'[\s\S]{0,200}h\.remedy/, 'the checklist carries the remedy');
|
|
226
|
+
const panel = fs.readFileSync('modules/hall-ui/public/settings-autobongos.js', 'utf8');
|
|
227
|
+
assert.match(panel, /'GET', `\$\{API\}\/autonomy\/runners`/);
|
|
228
|
+
assert.match(panel, /h\.state !== 'idle_config'/);
|
|
229
|
+
assert.match(panel, /escapeHtml\(h\.remedy/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── the route: POST /autonomy/heartbeat posts through the discord.post port ──
|
|
233
|
+
|
|
234
|
+
test('POST /autonomy/heartbeat: thirty config-idle beats are ONE Discord post', async () => {
|
|
235
|
+
const path = require('node:path');
|
|
236
|
+
const express = require('express');
|
|
237
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
238
|
+
const api = require('../src/module-api.js');
|
|
239
|
+
const saved = { b: api.requireBuilder, p: api.requirePermission, r: api.resolveOptional, q: api.pool.query };
|
|
240
|
+
const posts = [];
|
|
241
|
+
api.requireBuilder = (r, _s, next) => { r.builder = { id: 7, rank: 'metic' }; next(); };
|
|
242
|
+
api.requirePermission = () => (_r, _s, next) => next();
|
|
243
|
+
api.resolveOptional = (name, dflt) => (name === 'discord.post' ? async (m) => { posts.push(m); return { posted: true }; } : dflt);
|
|
244
|
+
api.pool.query = async () => ({ rows: [{ host: 'winbox' }] });
|
|
245
|
+
const routePath = require.resolve(path.resolve('modules/autonomy/routes/autonomy.js'));
|
|
246
|
+
delete require.cache[routePath];
|
|
247
|
+
let server;
|
|
248
|
+
try {
|
|
249
|
+
const router = require(routePath)();
|
|
250
|
+
const app = express();
|
|
251
|
+
app.use(express.json()); app.use(attachFail); app.use(router);
|
|
252
|
+
server = app.listen(0);
|
|
253
|
+
await new Promise((r) => server.once('listening', r));
|
|
254
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
255
|
+
const beat = async (last_event) => {
|
|
256
|
+
const r = await fetch(`${base}/autonomy/heartbeat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ host: 'WinBox', last_event }) });
|
|
257
|
+
assert.equal(r.status, 200);
|
|
258
|
+
await new Promise((res) => setImmediate(res));
|
|
259
|
+
};
|
|
260
|
+
await beat('fenced');
|
|
261
|
+
for (let i = 0; i < 30; i++) await beat('config_idle:no_builder_scope');
|
|
262
|
+
await beat('waiting');
|
|
263
|
+
await beat('config_idle:no_builder_scope');
|
|
264
|
+
assert.equal(posts.length, 1);
|
|
265
|
+
assert.match(posts[0].text, /`WinBox`/);
|
|
266
|
+
assert.match(posts[0].text, /Settings/);
|
|
267
|
+
} finally {
|
|
268
|
+
if (server) server.close();
|
|
269
|
+
api.requireBuilder = saved.b; api.requirePermission = saved.p; api.resolveOptional = saved.r; api.pool.query = saved.q;
|
|
270
|
+
delete require.cache[routePath];
|
|
271
|
+
}
|
|
272
|
+
});
|
|
@@ -398,3 +398,32 @@ test('a trailing slash is still /deploy', async () => {
|
|
|
398
398
|
await new Promise((r) => setTimeout(r, 0));
|
|
399
399
|
assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/);
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
test('the banner asks with the project\'s stored update rule (task 1004535)', async () => {
|
|
403
|
+
const r2 = createRequire(import.meta.url);
|
|
404
|
+
const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
|
|
405
|
+
const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/core-update.js'));
|
|
406
|
+
const realAuth = r2(authPath);
|
|
407
|
+
const savedBuilder = realAuth.requireBuilder;
|
|
408
|
+
const savedPerm = realAuth.requirePermission;
|
|
409
|
+
realAuth.requireBuilder = (_req, _res, next) => next();
|
|
410
|
+
realAuth.requirePermission = () => (_req, _res, next) => next();
|
|
411
|
+
delete r2.cache[routePath];
|
|
412
|
+
let seen;
|
|
413
|
+
const express = r2('express');
|
|
414
|
+
const app = express();
|
|
415
|
+
app.use(r2(routePath)({ coreUpdate: async (a) => { seen = a; return { reading: 'ok', behind: true, channel: a.channel }; }, selfChannel: async () => 'minor' }));
|
|
416
|
+
const server = app.listen(0);
|
|
417
|
+
await new Promise((res) => server.once('listening', res));
|
|
418
|
+
try {
|
|
419
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/core-update`);
|
|
420
|
+
assert.equal(res.status, 200);
|
|
421
|
+
assert.equal(seen.channel, 'minor', 'the rule set on /deploy, not the platform default');
|
|
422
|
+
assert.equal((await res.json()).update.channel_source, 'deploy');
|
|
423
|
+
} finally {
|
|
424
|
+
server.close();
|
|
425
|
+
realAuth.requireBuilder = savedBuilder;
|
|
426
|
+
realAuth.requirePermission = savedPerm;
|
|
427
|
+
delete r2.cache[routePath];
|
|
428
|
+
}
|
|
429
|
+
});
|
|
@@ -866,10 +866,25 @@ await t('a box that reached the target by another road supersedes the failure',
|
|
|
866
866
|
|
|
867
867
|
await t('AN UNREADABLE REGISTRY LEAVES A FAILURE STANDING — not knowing is not evidence of recovery', () => {
|
|
868
868
|
const now = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'x' };
|
|
869
|
-
|
|
869
|
+
// An unreadable registry is what the snapshot writer stores when `npm view` fails: an
|
|
870
|
+
// EMPTY available list, and so no recommendation (provision-core-upgrade.js snapshotCoreVersions).
|
|
871
|
+
const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, available: [], recommended: null }, null, now);
|
|
870
872
|
assert.equal(p.last_move.superseded, false, 'the direction to fail in is showing an old failure, never hiding a live one');
|
|
871
873
|
});
|
|
872
874
|
|
|
875
|
+
await t('an UP-TO-DATE box supersedes an old failure: a read registry with nothing on offer is not "unknown" (task 1004534)', () => {
|
|
876
|
+
// The live shape, 2026-10-02: cloudbongos served 1.21.5, the registry answered (1168
|
|
877
|
+
// versions), nothing newer was within its rule, and the page still led with a day-old
|
|
878
|
+
// "waiting on a decision" about 1.20.73. recommended=null was being read as "the
|
|
879
|
+
// registry could not be read", so the failure could never become history.
|
|
880
|
+
const old = { action: 'core-upgrade', state: 'error', target_version: '1.20.73', resolved_at: new Date().toISOString(), last_error: 'x', failure_class: 'needs_decision', failure_reason: 'outside_channel' };
|
|
881
|
+
const p = CU.previewFromSnapshot(ACTIVE, { served_version: '1.21.5', available: ['1.20.73', '1.21.5'], recommended: null, observed_at: new Date().toISOString() }, null, old);
|
|
882
|
+
assert.equal(p.behind, false);
|
|
883
|
+
assert.equal(p.last_move.superseded, true, 'the box is current; a failure about another version is history');
|
|
884
|
+
assert.equal(CU.lastMoveSuperseded('1.20.73', '1.21.5', null, true), true);
|
|
885
|
+
assert.equal(CU.lastMoveSuperseded('1.20.73', '1.21.5', null, false), false, 'an unread registry still leaves it standing');
|
|
886
|
+
});
|
|
887
|
+
|
|
873
888
|
await t('lastMoveSuperseded computes no version ORDERING — only identity', () => {
|
|
874
889
|
// The page's standing rule is that no version comparison happens outside the runner's
|
|
875
890
|
// channel resolution. This helper answers "is this news" without ever asking which of
|
|
@@ -885,6 +900,21 @@ await t('lastMoveSuperseded computes no version ORDERING — only identity', ()
|
|
|
885
900
|
assert.equal(CU.lastMoveSuperseded('1.19.921', '1.19.900', '1.19.949'), true);
|
|
886
901
|
});
|
|
887
902
|
|
|
903
|
+
console.log('\nthe platform\'s own rule, for Settings and the banner (task 1004535):');
|
|
904
|
+
|
|
905
|
+
await t('selfUpdateChannel reads the CONTROL-PLANE row\'s rule', async () => {
|
|
906
|
+
const sqls = [];
|
|
907
|
+
const db = { query: async (sql) => { sqls.push(sql); return { rows: [{ update_channel: 'minor' }] }; } };
|
|
908
|
+
assert.equal(await CU.selfUpdateChannel(db), 'minor');
|
|
909
|
+
assert.match(sqls[0], /hosting_shape = 'control-plane'/, 'this hall\'s own row, never a tenant\'s');
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
await t('selfUpdateChannel answers null with no row, an unknown value, or a failed read', async () => {
|
|
913
|
+
assert.equal(await CU.selfUpdateChannel({ query: async () => ({ rows: [] }) }), null);
|
|
914
|
+
assert.equal(await CU.selfUpdateChannel({ query: async () => ({ rows: [{ update_channel: 'nightly' }] }) }), null);
|
|
915
|
+
assert.equal(await CU.selfUpdateChannel({ query: async () => { throw new Error('relation does not exist'); } }), null);
|
|
916
|
+
});
|
|
917
|
+
|
|
888
918
|
console.log('\nthe project\'s update rule (task 1004445):');
|
|
889
919
|
|
|
890
920
|
const channelHandler = (inst, calls) => updateChannelHandlerFor({
|
|
@@ -232,6 +232,26 @@ await t('the channel refusal names a step that EXISTS on /deploy, never "change
|
|
|
232
232
|
assert.match(major, /upgrading-the-core\.md/);
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
await t('a target the box ALREADY SERVES is not a channel refusal — the move is a no-op (task 1004534)', () => {
|
|
236
|
+
// 2026-10-01 16:32: the sweep and the door both went for 1.20.73; the sweep got there
|
|
237
|
+
// first, and the door's press was refused as "outside this project's 'minor' update
|
|
238
|
+
// channel … across a major version or onto a prerelease" — false on every count.
|
|
239
|
+
const channelAllows = require('../scripts/gds/update-channel.js').channelAllows;
|
|
240
|
+
const compare = require('../scripts/gds/update-channel.js').compareSemver;
|
|
241
|
+
assert.equal(R.targetFault('1.20.73', ['1.20.72', '1.20.73'], '1.20.73', 'minor', channelAllows, compare), null,
|
|
242
|
+
'no fault: the runner\'s own already-serving check answers it as a no-op');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await t('a target BELOW what the box serves says so, and is never called outside the update rule (task 1004534)', () => {
|
|
246
|
+
const channelAllows = require('../scripts/gds/update-channel.js').channelAllows;
|
|
247
|
+
const compare = require('../scripts/gds/update-channel.js').compareSemver;
|
|
248
|
+
const f = R.targetFault('1.20.73', ['1.20.73', '1.21.5'], '1.21.5', 'minor', channelAllows, compare);
|
|
249
|
+
assert.ok(f, 'still refused — a move never goes backwards on its own');
|
|
250
|
+
assert.doesNotMatch(f, /update channel|major version|prerelease/, 'none of the channel sentence is true here');
|
|
251
|
+
assert.match(f, /already runs core 1\.21\.5/);
|
|
252
|
+
assert.equal(require('../scripts/gds/upgrade-outcome.js').runnerReason(f), 'downgrade');
|
|
253
|
+
});
|
|
254
|
+
|
|
235
255
|
await t('a registry that could not be read is refused DIFFERENTLY from a version that does not exist', () => {
|
|
236
256
|
const empty = R.targetFault('1.19.760', [], '1.19.758', 'patch', () => true);
|
|
237
257
|
assert.match(empty, /could not read the published core versions/);
|