@bongos/core 1.21.145 → 1.21.147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +98 -68
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +90 -7
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +17 -15
- package/docs/copy-registry.json +37 -19
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +85 -85
- package/modules/builder-settings/core-move-seen.js +41 -0
- package/modules/builder-settings/routes/settings.js +22 -0
- package/modules/builder-settings/settings.js +5 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/core-move-notice.js +70 -0
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-software-update.js +38 -5
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/shell.js +18 -6
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +11 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/provision-auto-upgrade.js +30 -7
- package/scripts/gds/provision-core-upgrade.js +9 -3
- package/src/bongos/core-moves.js +155 -0
- package/src/bongos/routes/core-update.js +55 -3
- package/src/bongos/upgrade-hold.js +96 -0
- package/src/module-api.js +1 -1
- package/tests/core_auto_upgrade.mjs +64 -3
- package/tests/core_move_notice.mjs +356 -0
- package/tests/core_upgrade_owner_door.mjs +25 -2
- package/tests/nav_permission_atoms.mjs +4 -2
- package/tests/upgrade_hold.mjs +207 -0
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Run: node tests/core_auto_upgrade.mjs
|
|
14
14
|
|
|
15
15
|
import { strict as assert } from 'node:assert';
|
|
16
|
-
import { autoUpgradeTick, AUTO_MOVES_PER_TICK, autoUpgradeEnabled } from '../scripts/gds/provision-auto-upgrade.js';
|
|
16
|
+
import { autoUpgradeTick, AUTO_MOVES_PER_TICK, autoUpgradeEnabled, TRANSIENT_HOLD_MS } from '../scripts/gds/provision-auto-upgrade.js';
|
|
17
17
|
|
|
18
18
|
let passed = 0;
|
|
19
19
|
let failed = 0;
|
|
@@ -32,7 +32,7 @@ function row(over = {}) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// A fake world: `rows` are candidates, `intents` the provisioning_intents history.
|
|
35
|
-
function makeDeps({ rows = [row()], intents = [], env = {} } = {}) {
|
|
35
|
+
function makeDeps({ rows = [row()], intents = [], env = {}, now } = {}) {
|
|
36
36
|
const queued = [];
|
|
37
37
|
const events = [];
|
|
38
38
|
const db = {
|
|
@@ -53,7 +53,7 @@ function makeDeps({ rows = [row()], intents = [], env = {} } = {}) {
|
|
|
53
53
|
},
|
|
54
54
|
async recordEvent(_db, e) { events.push(e); },
|
|
55
55
|
};
|
|
56
|
-
return { db, provisioning, env, log: () => {}, queued, events };
|
|
56
|
+
return { db, provisioning, env, now, log: () => {}, queued, events };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
console.log('core auto-upgrade tick (task 1004729)');
|
|
@@ -185,5 +185,66 @@ await t('a refusal from enqueueIntent (e.g. the control-plane guard) never fails
|
|
|
185
185
|
assert.equal(r.queued, 0);
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
// ---- the hold, by failure class (task 1004730) ------------------------------------------
|
|
189
|
+
// A failure classed `transient` (upgrade-outcome.js: a registry blip, a release still
|
|
190
|
+
// publishing, an install that could not download) already waited out its own 15m/1h/6h
|
|
191
|
+
// backoff inside the intent (intent-retry.js) before it was retired. The automatic path
|
|
192
|
+
// may try that same target once more after TRANSIENT_HOLD_MS; every other class (a known
|
|
193
|
+
// wedge whose fix did not hold, a rule refusing it, an unknown, a failed rollback) stays
|
|
194
|
+
// held until a newer release.
|
|
195
|
+
const T0 = Date.parse('2026-10-14T12:00:00Z');
|
|
196
|
+
const hoursAgo = (h) => new Date(T0 - h * 3600 * 1000).toISOString();
|
|
197
|
+
|
|
198
|
+
await t('a target retired as TRANSIENT is held for a day, then tried again', async () => {
|
|
199
|
+
assert.equal(TRANSIENT_HOLD_MS, 24 * 3600 * 1000);
|
|
200
|
+
const failedAt = (h) => [{ instance_id: 52, action: 'core-upgrade', state: 'error', target_version: '1.21.69', failure_class: 'transient', failure_reason: 'npm_install', updated_at: hoursAgo(h) }];
|
|
201
|
+
const fresh = makeDeps({ intents: failedAt(2), now: () => T0 });
|
|
202
|
+
await autoUpgradeTick(fresh);
|
|
203
|
+
assert.equal(fresh.queued.length, 0, 'held inside the day');
|
|
204
|
+
const later = makeDeps({ intents: failedAt(25), now: () => T0 });
|
|
205
|
+
await autoUpgradeTick(later);
|
|
206
|
+
assert.equal(later.queued.length, 1, 'tried again after the day');
|
|
207
|
+
assert.equal(later.queued[0].target_version, '1.21.69');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await t('a transient PREFLIGHT error is also released after a day', async () => {
|
|
211
|
+
const d = makeDeps({ now: () => T0, intents: [{ instance_id: 52, action: 'core-upgrade-preflight', state: 'error', target_version: '1.21.69', failure_class: 'transient', updated_at: hoursAgo(30) }] });
|
|
212
|
+
await autoUpgradeTick(d);
|
|
213
|
+
assert.equal(d.queued.length, 1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await t('every other class stays held however long ago it failed', async () => {
|
|
217
|
+
for (const cls of ['unknown', 'known_wedge', 'needs_decision', null]) {
|
|
218
|
+
const d = makeDeps({ now: () => T0, intents: [{ instance_id: 52, action: 'core-upgrade', state: 'error', target_version: '1.21.69', failure_class: cls, updated_at: hoursAgo(24 * 30) }] });
|
|
219
|
+
await autoUpgradeTick(d);
|
|
220
|
+
assert.equal(d.queued.length, 0, String(cls));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await t('a move that finished but left the box behind stays held, whatever its age', async () => {
|
|
225
|
+
const d = makeDeps({ now: () => T0, rows: [row({ preflight: { target: '1.21.69', ok: true } })],
|
|
226
|
+
intents: [{ instance_id: 52, action: 'core-upgrade', state: 'done', target_version: '1.21.69', failure_class: null, updated_at: hoursAgo(24 * 30) }] });
|
|
227
|
+
await autoUpgradeTick(d);
|
|
228
|
+
assert.equal(d.queued.length, 0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await t('one held failure of the target wins over an older transient one', async () => {
|
|
232
|
+
const d = makeDeps({ now: () => T0, intents: [
|
|
233
|
+
{ instance_id: 52, action: 'core-upgrade', state: 'error', target_version: '1.21.69', failure_class: 'transient', updated_at: hoursAgo(72) },
|
|
234
|
+
{ instance_id: 52, action: 'core-upgrade', state: 'error', target_version: '1.21.69', failure_class: 'unknown', updated_at: hoursAgo(48) }] });
|
|
235
|
+
await autoUpgradeTick(d);
|
|
236
|
+
assert.equal(d.queued.length, 0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await t('the intents read carries the failure class and when it failed', async () => {
|
|
240
|
+
const d = makeDeps();
|
|
241
|
+
let sql = '';
|
|
242
|
+
const q = d.db.query;
|
|
243
|
+
d.db.query = async (s, p) => { if (/FROM provisioning_intents/.test(s)) sql = s; return q(s, p); };
|
|
244
|
+
await autoUpgradeTick(d);
|
|
245
|
+
assert.match(sql, /failure_class/);
|
|
246
|
+
assert.match(sql, /updated_at/);
|
|
247
|
+
});
|
|
248
|
+
|
|
188
249
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
189
250
|
if (failed) process.exit(1);
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// tests/core_move_notice.mjs — after the project's core moves, its own hall says so once,
|
|
2
|
+
// with what changed, and Settings → Software update keeps a short history (task 1004731).
|
|
3
|
+
//
|
|
4
|
+
// The move writes one row to THIS project's own core_upgrades ledger (scripts/gds/upgrade.js);
|
|
5
|
+
// the running core carries the release notes for every version up to itself
|
|
6
|
+
// (release-notes.json). src/bongos/core-moves.js reads both back: GET /core-update carries
|
|
7
|
+
// the one-time notice (`moved`) for the banner, GET /software-update the history. The
|
|
8
|
+
// builder's "seen" is server-side, in the builder-settings module's behavior_prefs.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/core_move_notice.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import vm from 'node:vm';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const { moveNotice, moveHistory, readInstalledReleaseNotes } = require('../src/bongos/core-moves.js');
|
|
22
|
+
const seen = require('../modules/builder-settings/core-move-seen.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
const t = async (name, fn) => {
|
|
27
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
28
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const NOW = Date.parse('2026-10-16T12:00:00.000Z');
|
|
32
|
+
const NOTES = {
|
|
33
|
+
'1.21.20': [{ id: '1', text: 'Old work, already on the project.' }],
|
|
34
|
+
'1.21.21': [{ id: '1004700', text: 'The board shows who is reviewing.' }],
|
|
35
|
+
'1.21.22': [],
|
|
36
|
+
'1.21.23': [{ text: 'Settings remembers your filters.' }, { id: '1004702', text: 'Faster task pages.' }],
|
|
37
|
+
'1.21.24': [{ id: '9', text: 'Beyond this move.' }],
|
|
38
|
+
};
|
|
39
|
+
// The rows upgrade.js writes: a landed move, an automatic one, a door one, a rollback.
|
|
40
|
+
const moved = (over = {}) => ({ id: '41', from_version: '1.21.20', to_version: '1.21.23', note: null, source: null,
|
|
41
|
+
applied_at: '2026-10-15T09:00:00.000Z', ...over });
|
|
42
|
+
const rollback = { id: '40', from_version: '1.21.20', to_version: '1.21.22', source: null, applied_at: '2026-10-14T09:00:00.000Z',
|
|
43
|
+
note: 'rolled_back: migrate failed (attempted 1.21.20 → 1.21.22, restored 1.21.20)' };
|
|
44
|
+
|
|
45
|
+
console.log('the notice after a core move, and the history of moves (task 1004731)');
|
|
46
|
+
|
|
47
|
+
await t('a recorded move renders as a notice: version, date, and the release-note entries between old and new', () => {
|
|
48
|
+
const n = moveNotice({ rows: [moved()], running: '1.21.23', notes: NOTES, now: NOW });
|
|
49
|
+
assert.equal(n.id, '41');
|
|
50
|
+
assert.equal(n.version, '1.21.23');
|
|
51
|
+
assert.equal(n.from, '1.21.20');
|
|
52
|
+
assert.equal(n.message, 'Upgraded to core 1.21.23 on 15 October 2026.');
|
|
53
|
+
assert.deepEqual(n.entries.map((e) => e.text),
|
|
54
|
+
['Settings remembers your filters.', 'Faster task pages.', 'The board shows who is reviewing.'],
|
|
55
|
+
'newest first, only the versions after the old one and up to the new one');
|
|
56
|
+
assert.equal(n.more, 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await t('an automatic move says so; a move by hand reads the same, without the word', () => {
|
|
60
|
+
assert.equal(moveNotice({ rows: [moved({ note: 'auto-subscription' })], running: '1.21.23', notes: NOTES, now: NOW }).message,
|
|
61
|
+
'Upgraded automatically to core 1.21.23 on 15 October 2026.');
|
|
62
|
+
const door = moveNotice({ rows: [moved({ source: 'owner-control' })], running: '1.21.23', notes: NOTES, now: NOW });
|
|
63
|
+
assert.equal(door.how, 'by_hand');
|
|
64
|
+
assert.equal(door.message, 'Upgraded to core 1.21.23 on 15 October 2026.');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await t('the notice clears once this builder has seen that move, and the next move is news again', () => {
|
|
68
|
+
assert.equal(moveNotice({ rows: [moved()], running: '1.21.23', notes: NOTES, seenId: '41', now: NOW }), null);
|
|
69
|
+
assert.ok(moveNotice({ rows: [moved({ id: '42' })], running: '1.21.23', notes: NOTES, seenId: '41', now: NOW }));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await t('no notice for a rollback, a version this server does not run, or a move months old', () => {
|
|
73
|
+
assert.equal(moveNotice({ rows: [rollback, moved()], running: '1.21.20', notes: NOTES, now: NOW }), null);
|
|
74
|
+
assert.equal(moveNotice({ rows: [moved()], running: '1.21.20', notes: NOTES, now: NOW }), null);
|
|
75
|
+
assert.equal(moveNotice({ rows: [moved()], running: '1.21.23', notes: NOTES, now: NOW + 40 * 86400000 }), null);
|
|
76
|
+
assert.equal(moveNotice({ rows: [], running: '1.21.23', notes: NOTES, now: NOW }), null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t('notes that cannot be read, or do not cover the move, still give the notice, with no entries', () => {
|
|
80
|
+
const none = moveNotice({ rows: [moved()], running: '1.21.23', notes: null, now: NOW });
|
|
81
|
+
assert.equal(none.entries, null);
|
|
82
|
+
assert.match(none.message, /1\.21\.23/);
|
|
83
|
+
assert.equal(moveNotice({ rows: [moved()], running: '1.21.23', notes: { '1.21.22': [] }, now: NOW }).entries, null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await t('a long range shows a few entries and counts the rest', () => {
|
|
87
|
+
const many = { '1.21.20': [] };
|
|
88
|
+
for (let i = 21; i <= 30; i++) many[`1.21.${i}`] = [{ text: `Change ${i}.` }];
|
|
89
|
+
const n = moveNotice({ rows: [moved({ to_version: '1.21.30' })], running: '1.21.30', notes: many, now: NOW });
|
|
90
|
+
assert.equal(n.entries.length, 6);
|
|
91
|
+
assert.equal(n.more, 4);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await t('the history lists every move, newest first, failed ones included, with how and outcome', () => {
|
|
95
|
+
const h = moveHistory([moved({ note: 'auto-subscription' }), rollback,
|
|
96
|
+
{ id: '39', from_version: '1.21.19', to_version: '1.21.20', source: 'owner-control', note: null, applied_at: '2026-10-10T09:00:00.000Z' },
|
|
97
|
+
{ id: '38', from_version: '1.21.20', to_version: '1.21.19', source: null, note: 'downgrade (--allow-downgrade)', applied_at: '2026-10-09T09:00:00.000Z' }]);
|
|
98
|
+
assert.deepEqual(h.map((m) => [m.version, m.how, m.outcome]), [
|
|
99
|
+
['1.21.23', 'automatic', 'upgraded'],
|
|
100
|
+
['1.21.22', 'on_server', 'rolled_back'],
|
|
101
|
+
['1.21.20', 'by_hand', 'upgraded'],
|
|
102
|
+
['1.21.19', 'on_server', 'moved_back'],
|
|
103
|
+
]);
|
|
104
|
+
assert.equal(h[0].at, '2026-10-15T09:00:00.000Z');
|
|
105
|
+
assert.equal(moveHistory(Array.from({ length: 20 }, (_, i) => moved({ id: String(i) }))).length, 8, 'a short history');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await t('the running core\'s own release notes are read from its package root; none is null, never a throw', () => {
|
|
109
|
+
const fake = { readFileSync: () => JSON.stringify(NOTES) };
|
|
110
|
+
assert.deepEqual(readInstalledReleaseNotes({ fsImpl: fake, root: '/x' }), NOTES);
|
|
111
|
+
assert.equal(readInstalledReleaseNotes({ fsImpl: { readFileSync: () => { throw new Error('ENOENT'); } }, root: '/x' }), null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---- the per-builder seen state (builder-settings module) -------------------------------
|
|
115
|
+
|
|
116
|
+
await t('seen is one ledger id per builder, kept beside the other prefs', () => {
|
|
117
|
+
assert.equal(seen.seenId({}), null);
|
|
118
|
+
assert.equal(seen.seenId(seen.applySeen({ wandering: 'low' }, '41')), '41');
|
|
119
|
+
assert.equal(seen.applySeen({ wandering: 'low' }, '41').wandering, 'low');
|
|
120
|
+
assert.equal(seen.validatePatch({ id: '41' }).id, '41');
|
|
121
|
+
assert.equal(seen.validatePatch({ id: 'x' }).ok, false);
|
|
122
|
+
assert.equal(seen.validatePatch({ id: '41', other: 1 }).ok, false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await t('PATCH /me/core-move-seen stores it for the signed-in builder only, and the port reads it back', async () => {
|
|
126
|
+
const rows = new Map([[1, { wandering: 'low' }]]);
|
|
127
|
+
const db = require('../modules/builder-settings/db.js');
|
|
128
|
+
db.getBuilderBehaviorPrefs = async (id) => rows.get(id) || {};
|
|
129
|
+
db.setBuilderBehaviorPrefs = async (id, prefs) => { rows.set(id, prefs); return prefs; };
|
|
130
|
+
const router = require('../modules/builder-settings/routes/settings.js')();
|
|
131
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/me/core-move-seen' && l.route.methods.patch);
|
|
132
|
+
assert.ok(layer, 'PATCH /me/core-move-seen is registered');
|
|
133
|
+
const out = { status: 200, body: null };
|
|
134
|
+
const res = { status(s) { out.status = s; return this; }, json(b) { out.body = b; return this; },
|
|
135
|
+
fail(code, a, b) { out.status = typeof a === 'number' ? a : (a && a.status) || 400; out.body = { error: code, ...(b || {}) }; return this; } };
|
|
136
|
+
await layer.route.stack[layer.route.stack.length - 1].handle({ builder: { id: 1 }, body: { id: '41' } }, res);
|
|
137
|
+
assert.equal(out.status, 200);
|
|
138
|
+
assert.deepEqual(out.body, { ok: true, seen: '41' });
|
|
139
|
+
assert.equal(rows.get(1).wandering, 'low', 'sibling prefs survive');
|
|
140
|
+
assert.equal(rows.has(2), false);
|
|
141
|
+
const port = require('../modules/builder-settings/settings.js');
|
|
142
|
+
assert.equal(port.coreMoveSeen(rows.get(1)), '41');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---- the two reads the hall makes ------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
const { CORE_VERSION } = require('../src/module-api.js');
|
|
148
|
+
const nowMove = (over = {}) => moved({ to_version: CORE_VERSION, from_version: '0.0.1', applied_at: new Date().toISOString(), ...over });
|
|
149
|
+
|
|
150
|
+
async function drive(p, deps) {
|
|
151
|
+
const auth = require('../src/bongos/auth.js');
|
|
152
|
+
const saved = { b: auth.requireBuilder, p: auth.requirePermission };
|
|
153
|
+
auth.requireBuilder = (req, _res, next) => { req.builder = { id: '7' }; next(); };
|
|
154
|
+
auth.requirePermission = () => (_req, _res, next) => next();
|
|
155
|
+
const build = require('../src/bongos/routes/core-update.js');
|
|
156
|
+
const express = require('express');
|
|
157
|
+
const app = express();
|
|
158
|
+
try {
|
|
159
|
+
app.use(build({ askDeps: () => ({ hub: null, getSetting: async () => null }), canUpdate: async () => true,
|
|
160
|
+
doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: null }), upgradeHold: async () => null,
|
|
161
|
+
releaseNotes: () => ({ '0.0.1': [], [CORE_VERSION]: [{ text: 'Shown after the move.' }] }), ...deps }));
|
|
162
|
+
} finally { auth.requireBuilder = saved.b; auth.requirePermission = saved.p; }
|
|
163
|
+
const server = app.listen(0);
|
|
164
|
+
await new Promise((r) => server.once('listening', r));
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`);
|
|
167
|
+
return { status: res.status, body: await res.json() };
|
|
168
|
+
} finally { server.close(); }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await t('GET /core-update carries the notice for this builder, and none once they have seen it', async () => {
|
|
172
|
+
const r = await drive('/core-update', {
|
|
173
|
+
coreUpdate: async () => ({ reading: 'ok', behind: false }), selfChannel: async () => null,
|
|
174
|
+
upgrades: async () => [nowMove()], seenMove: async () => ({ remember: 'server', seen: null }),
|
|
175
|
+
});
|
|
176
|
+
assert.equal(r.status, 200);
|
|
177
|
+
assert.equal(r.body.moved.version, CORE_VERSION);
|
|
178
|
+
assert.equal(r.body.moved.remember, 'server');
|
|
179
|
+
assert.deepEqual(r.body.moved.entries.map((e) => e.text), ['Shown after the move.']);
|
|
180
|
+
const after = await drive('/core-update', {
|
|
181
|
+
coreUpdate: async () => ({ reading: 'ok', behind: false }), selfChannel: async () => null,
|
|
182
|
+
upgrades: async () => [nowMove()], seenMove: async () => ({ remember: 'server', seen: '41' }),
|
|
183
|
+
});
|
|
184
|
+
assert.equal(after.body.moved, null);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await t('a ledger that cannot be read is no notice, never a failed banner read', async () => {
|
|
188
|
+
const r = await drive('/core-update', { coreUpdate: async () => ({ reading: 'ok', behind: false }), selfChannel: async () => null,
|
|
189
|
+
upgrades: async () => { throw new Error('no table'); }, seenMove: async () => ({ remember: 'server', seen: null }) });
|
|
190
|
+
assert.equal(r.status, 200);
|
|
191
|
+
assert.equal(r.body.moved, null);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await t('GET /software-update carries the history for those who may update, and not for anyone else', async () => {
|
|
195
|
+
const deps = { softwareUpdate: async ({ running }) => ({ reading: 'ok', running, offered: null }),
|
|
196
|
+
upgrades: async () => [nowMove({ note: 'auto-subscription' }), rollback] };
|
|
197
|
+
const r = await drive('/software-update', deps);
|
|
198
|
+
assert.deepEqual(r.body.history.map((m) => [m.how, m.outcome]), [['automatic', 'upgraded'], ['on_server', 'rolled_back']]);
|
|
199
|
+
const other = await drive('/software-update', { ...deps, canUpdate: async () => false });
|
|
200
|
+
assert.equal(other.body.history, null, 'when and to what the core moved is a vulnerability-window fact (ADR 0043)');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ---- the hall draws the notice, and it clears after it is seen --------------------------
|
|
204
|
+
|
|
205
|
+
const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
206
|
+
// shell.js loads the notice's own file only when there is a notice; the fake <head> runs it.
|
|
207
|
+
const NOTICE = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/core-move-notice.js'), 'utf8');
|
|
208
|
+
|
|
209
|
+
function boot({ data, permissions = { 'core.pin.move': true }, stored = {} } = {}) {
|
|
210
|
+
const inserted = [];
|
|
211
|
+
const requested = [];
|
|
212
|
+
const loaded = [];
|
|
213
|
+
const els = new Map();
|
|
214
|
+
let sandbox = null;
|
|
215
|
+
const makeEl = () => ({
|
|
216
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '', _on: {},
|
|
217
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
218
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
219
|
+
addEventListener(ev, fn) { this._on[ev] = fn; },
|
|
220
|
+
querySelector(sel) { const self = this; return { addEventListener: (e, f) => { (self._q ||= {})[sel] = f; } }; },
|
|
221
|
+
querySelectorAll() { return []; },
|
|
222
|
+
appendChild() {}, focus() {}, click() {}, scrollIntoView() {}, remove() { this._removed = true; },
|
|
223
|
+
insertAdjacentElement(_pos, el) { inserted.push(el); },
|
|
224
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
225
|
+
});
|
|
226
|
+
const documentObj = {
|
|
227
|
+
body: { dataset: { page: 'index' } },
|
|
228
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
|
|
229
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
230
|
+
querySelectorAll() { return []; }, createElement() { return makeEl(); },
|
|
231
|
+
addEventListener() {}, dispatchEvent() {}, get cookie() { return ''; }, set cookie(_v) {},
|
|
232
|
+
head: { appendChild(tag) { loaded.push(tag.src); vm.runInContext(NOTICE, sandbox, { filename: 'core-move-notice.js' }); tag.onload(); } },
|
|
233
|
+
};
|
|
234
|
+
const location = { hostname: 'h', origin: 'https://h', pathname: '/', hash: '', assign() {}, replace() {} };
|
|
235
|
+
const windowObj = {
|
|
236
|
+
__BRANDING__: {}, __MODULES__: { enabled: { provisioning: false } },
|
|
237
|
+
BongosClient: { createClient: () => ({ request: (method, p, opts) => {
|
|
238
|
+
requested.push({ method, p, body: opts && opts.body });
|
|
239
|
+
if (p === '/api/bongos/core-update') return Promise.resolve({ status: 200, data });
|
|
240
|
+
if (p === '/api/bongos/me/core-move-seen') return Promise.resolve({ status: 200, ok: true, data: { ok: true } });
|
|
241
|
+
return new Promise(() => {});
|
|
242
|
+
} }) },
|
|
243
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
244
|
+
localStorage: { getItem: (k) => (k in stored ? stored[k] : null), setItem: (k, v) => { stored[k] = v; } },
|
|
245
|
+
requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
|
|
246
|
+
};
|
|
247
|
+
sandbox = { window: windowObj, document: documentObj, location, console, setTimeout, clearTimeout, queueMicrotask,
|
|
248
|
+
CustomEvent: class { constructor(tp, i) { this.type = tp; Object.assign(this, i); } } };
|
|
249
|
+
sandbox.globalThis = sandbox;
|
|
250
|
+
vm.createContext(sandbox);
|
|
251
|
+
vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
|
|
252
|
+
windowObj.OTBShell.applyAccess({ authed: true, rank: 'archon', permissions });
|
|
253
|
+
return { inserted, requested, stored, loaded, notice: () => inserted.find((e) => String(e._html).includes('core-move-notice__list') || String(e._html).includes('Upgraded')) };
|
|
254
|
+
}
|
|
255
|
+
const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); };
|
|
256
|
+
|
|
257
|
+
const recorded = moveNotice({ rows: [moved({ note: 'auto-subscription' })], running: '1.21.23', notes: NOTES, now: NOW });
|
|
258
|
+
|
|
259
|
+
await t('the hall renders the notice from a recorded move, with its release-note entries', async () => {
|
|
260
|
+
const b = boot({ data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: { ...recorded, remember: 'server' } } });
|
|
261
|
+
await settle();
|
|
262
|
+
const el = b.notice();
|
|
263
|
+
assert.ok(el, 'the notice is drawn');
|
|
264
|
+
assert.match(el.innerHTML, /Upgraded automatically to core 1\.21\.23 on 15 October 2026\./);
|
|
265
|
+
assert.match(el.innerHTML, /Settings remembers your filters\./);
|
|
266
|
+
assert.match(el.innerHTML, /Faster task pages\./);
|
|
267
|
+
assert.match(el.innerHTML, /href="\/settings#software-update"/);
|
|
268
|
+
assert.equal(b.loaded.length, 1, 'its file is loaded once, only because there is a notice');
|
|
269
|
+
assert.match(b.loaded[0], /^\/builders\/core-move-notice\.js/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await t('no notice, no extra file: a page with nothing to say loads nothing', async () => {
|
|
273
|
+
const b = boot({ data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: null } });
|
|
274
|
+
await settle();
|
|
275
|
+
assert.equal(b.loaded.length, 0);
|
|
276
|
+
assert.equal(b.notice(), undefined);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await t('the notice clears once seen: closing it tells the server, for this move, and removes it', async () => {
|
|
280
|
+
const b = boot({ data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: { ...recorded, remember: 'server' } } });
|
|
281
|
+
await settle();
|
|
282
|
+
const el = b.notice();
|
|
283
|
+
el._q['.core-move-notice__close']();
|
|
284
|
+
await settle();
|
|
285
|
+
assert.equal(el._removed, true);
|
|
286
|
+
const patch = b.requested.find((r) => r.method === 'PATCH');
|
|
287
|
+
assert.equal(patch && patch.p, '/api/bongos/me/core-move-seen');
|
|
288
|
+
assert.equal(JSON.stringify(patch.body), JSON.stringify({ id: '41' }));
|
|
289
|
+
// The next page load: the server answers moved:null for this builder.
|
|
290
|
+
const next = boot({ data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: null } });
|
|
291
|
+
await settle();
|
|
292
|
+
assert.equal(next.notice(), undefined);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await t('without the server-side store the browser remembers it instead', async () => {
|
|
296
|
+
const stored = {};
|
|
297
|
+
const b = boot({ stored, data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: { ...recorded, remember: 'browser' } } });
|
|
298
|
+
await settle();
|
|
299
|
+
b.notice()._q['.core-move-notice__close']();
|
|
300
|
+
await settle();
|
|
301
|
+
assert.equal(stored['bongos.coreMove.seen'], '41');
|
|
302
|
+
assert.ok(!b.requested.some((r) => r.method === 'PATCH'));
|
|
303
|
+
const again = boot({ stored, data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: { ...recorded, remember: 'browser' } } });
|
|
304
|
+
await settle();
|
|
305
|
+
assert.equal(again.notice(), undefined);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---- Settings → Software update draws the history ---------------------------------------
|
|
309
|
+
|
|
310
|
+
const PANEL_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-software-update.js'), 'utf8');
|
|
311
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
312
|
+
async function panel(data) {
|
|
313
|
+
const el = () => ({ hidden: true, textContent: '', _html: '', dataset: {}, set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; }, querySelectorAll: () => [] });
|
|
314
|
+
const els = new Map([['software-update-scroll', el()], ['su-body', el()], ['su-note', el()], ['settings-groups', el()]]);
|
|
315
|
+
els.get('settings-groups').dataset.active = 'update';
|
|
316
|
+
const windowObj = { BongosClient: { createClient: () => ({ request: async () => ({ ok: true, status: 200, data }) }) }, OTB: { escapeHtml }, location: { assign() {} } };
|
|
317
|
+
const sandbox = { window: windowObj, document: { getElementById: (id) => els.get(id) || null, addEventListener() {} }, console, setTimeout, Date, Math, URL, Promise, String, Number };
|
|
318
|
+
vm.createContext(sandbox);
|
|
319
|
+
vm.runInContext(PANEL_SRC, sandbox, { filename: 'settings-software-update.js' });
|
|
320
|
+
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
321
|
+
return els.get('su-body').innerHTML;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await t('Settings → Software update lists the moves: version, date, automatic or by hand, outcome', async () => {
|
|
325
|
+
const history = moveHistory([moved({ note: 'auto-subscription' }), rollback,
|
|
326
|
+
{ id: '39', from_version: '1.21.19', to_version: '1.21.20', source: 'owner-control', note: null, applied_at: '2026-10-10T09:00:00.000Z' }]);
|
|
327
|
+
const html = await panel({ ok: true, update: { reading: 'ok', running: '1.21.23', offered: null }, door: { kind: 'cli' }, can_update: true, ask: {}, held: null, history });
|
|
328
|
+
assert.match(html, /Recent updates/);
|
|
329
|
+
assert.match(html, /1\.21\.23/);
|
|
330
|
+
assert.match(html, /15 October 2026/);
|
|
331
|
+
assert.match(html, /Automatic/);
|
|
332
|
+
assert.match(html, /By hand/);
|
|
333
|
+
assert.match(html, /Rolled back/);
|
|
334
|
+
assert.match(html, /Installed/);
|
|
335
|
+
assert.doesNotMatch(html.slice(html.indexOf('Recent updates')), /[—–]/, 'voice rule: no dashes in new copy');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await t('no history, no change: the panel draws exactly what it did before', async () => {
|
|
339
|
+
const data = { ok: true, update: { reading: 'ok', running: '1.21.20', offered: '1.21.116', whats_new: null }, door: { kind: 'cli', url: null }, can_update: true, ask: {}, held: null };
|
|
340
|
+
assert.equal(await panel({ ...data, history: null }), await panel(data));
|
|
341
|
+
assert.equal(await panel({ ...data, history: [] }), await panel(data));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ---- the hub's automatic move marks itself in the project's ledger -------------------------
|
|
345
|
+
|
|
346
|
+
await t('the hub\'s automatic move passes the auto-subscription note; a door move and a preview do not', () => {
|
|
347
|
+
const R = require('../scripts/gds/provision-core-upgrade.js');
|
|
348
|
+
const box = { slug: 'toast', port: 4100, hosting_shape: 'cloud-host' };
|
|
349
|
+
const as = { runAs: 'bongos', migrateAs: 'bongos' };
|
|
350
|
+
assert.match(R.upgradeInvocation(box, '1.21.23', { ...as, automatic: true }).cmd, / --note auto-subscription(\s|$)/);
|
|
351
|
+
assert.doesNotMatch(R.upgradeInvocation(box, '1.21.23', { ...as, requestedBy: '42' }).cmd, /--note/);
|
|
352
|
+
assert.doesNotMatch(R.upgradeInvocation(box, '1.21.23', { ...as, automatic: true, dryRun: true }).cmd, /--note/);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
356
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -344,7 +344,7 @@ const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js')
|
|
|
344
344
|
// The real shell.js in a DOM stub (the tests/core_update_banner.mjs harness), with the
|
|
345
345
|
// /core-update answer optionally carrying a deploy_url — an older server still sends one —
|
|
346
346
|
// and elements that remember their attributes.
|
|
347
|
-
function boot({ provisioning, deployUrl, pathname = '/settings', hash = '' }) {
|
|
347
|
+
function boot({ provisioning, deployUrl, pathname = '/settings', hash = '', held }) {
|
|
348
348
|
const els = new Map();
|
|
349
349
|
const inserted = [];
|
|
350
350
|
const makeEl = () => ({
|
|
@@ -380,7 +380,7 @@ function boot({ provisioning, deployUrl, pathname = '/settings', hash = '' }) {
|
|
|
380
380
|
BongosClient: {
|
|
381
381
|
createClient: () => ({
|
|
382
382
|
request: (_method, p) => (p === '/api/bongos/core-update'
|
|
383
|
-
? Promise.resolve({ status: 200, data: { ok: true, update, deploy_url: deployUrl } })
|
|
383
|
+
? Promise.resolve({ status: 200, data: { ok: true, update, deploy_url: deployUrl, held } })
|
|
384
384
|
: new Promise(() => {})),
|
|
385
385
|
}),
|
|
386
386
|
},
|
|
@@ -396,6 +396,7 @@ function boot({ provisioning, deployUrl, pathname = '/settings', hash = '' }) {
|
|
|
396
396
|
windowObj.OTBShell.applyAccess({ authed: true, rank: 'archon', permissions: { 'core.pin.move': true } });
|
|
397
397
|
return {
|
|
398
398
|
banner: () => inserted.find((e) => String(e._html).includes('is available')),
|
|
399
|
+
notice: () => inserted.find((e) => e.id === 'core-update-notice'),
|
|
399
400
|
deployItem: () => els.get('nav-deploy'),
|
|
400
401
|
};
|
|
401
402
|
}
|
|
@@ -449,5 +450,27 @@ await t('on the platform, the local door wins: the banner links /deploy and the
|
|
|
449
450
|
assert.equal(item.dataset.module, 'provisioning', 'and its module gate, which the platform satisfies');
|
|
450
451
|
});
|
|
451
452
|
|
|
453
|
+
// Task 1004730: a version this project already tried and rolled back is not announced as
|
|
454
|
+
// "available" with an Upgrade button. The banner says what happened instead, in the owner's
|
|
455
|
+
// words, and points at Software update for the rest.
|
|
456
|
+
await t('a held version: the banner says it was tried and rolled back, and the project is still fine', async () => {
|
|
457
|
+
const held = { version: '1.19.1033', still_on: '1.19.871', message: 'long form' };
|
|
458
|
+
const b = boot({ provisioning: false, held });
|
|
459
|
+
await settle();
|
|
460
|
+
const n = b.notice();
|
|
461
|
+
assert.ok(n, 'the banner renders');
|
|
462
|
+
assert.doesNotMatch(n.innerHTML, /is available/);
|
|
463
|
+
assert.match(n.innerHTML, /1\.19\.1033<\/strong> was tried on this project and rolled back/);
|
|
464
|
+
assert.match(n.innerHTML, /still on 1\.19\.871 and working/);
|
|
465
|
+
assert.match(n.innerHTML, /href="\/settings#software-update">Details</);
|
|
466
|
+
assert.doesNotMatch(n.innerHTML, />Upgrade</, 'no button to take the version that just failed');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
await t('a hold on an OLDER version than the one now out leaves the ordinary banner', async () => {
|
|
470
|
+
const b = boot({ provisioning: false, held: { version: '1.19.900', still_on: '1.19.871', message: 'x' } });
|
|
471
|
+
await settle();
|
|
472
|
+
assert.ok(b.banner(), 'the newer release is announced as usual');
|
|
473
|
+
});
|
|
474
|
+
|
|
452
475
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
453
476
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -89,8 +89,10 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
89
89
|
|
|
90
90
|
// task 1004685 (the living-city prototype): the held-seat nav and the My agents tab,
|
|
91
91
|
// then the reads it shares with the governor pages (window.__hallReads); task 1004713:
|
|
92
|
-
// the seats read from /me's server-resolved positions block
|
|
93
|
-
|
|
92
|
+
// the seats read from /me's server-resolved positions block; task 1004730: the banner
|
|
93
|
+
// says a held core version was tried and rolled back; task 1004731: the one-time
|
|
94
|
+
// "Upgraded to core X" notice after a move.
|
|
95
|
+
const SHELL_SHA = 'eb9e85b9c1784f01';
|
|
94
96
|
|
|
95
97
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
96
98
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|