@bongos/core 1.21.145 → 1.21.146
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 +69 -59
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +8 -4
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +12 -12
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +85 -85
- 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/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 +14 -3
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/shell.js +10 -6
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- 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 +6 -0
- package/scripts/gds/provision-auto-upgrade.js +30 -7
- package/src/bongos/routes/core-update.js +14 -2
- 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_upgrade_owner_door.mjs +25 -2
- package/tests/nav_permission_atoms.mjs +3 -2
- package/tests/upgrade_hold.mjs +207 -0
|
@@ -34,6 +34,7 @@ const { readSoftwareUpdate } = require('../software-update');
|
|
|
34
34
|
const seams = require('../../module-seams');
|
|
35
35
|
const { loadIdpConfig } = require('../auth-config');
|
|
36
36
|
const upgradeRequest = require('../upgrade-request');
|
|
37
|
+
const { readUpgradeHold } = require('../upgrade-hold');
|
|
37
38
|
|
|
38
39
|
// WHERE A HOSTED PROJECT'S OWNER TAKES THE UPGRADE (task 1004174, ADR 0339): the hub's
|
|
39
40
|
// own deploy door, scoped to this project — `<hub>/deploy?project=<slug>`. The act stays
|
|
@@ -213,6 +214,14 @@ async function readAsk({ update, door, deps }) {
|
|
|
213
214
|
return upgradeRequest.askState({ ...base, stored });
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
// A core move this project's own ledger says was rolled back (task 1004730,
|
|
218
|
+
// src/bongos/upgrade-hold.js): which version was tried, why in plain words, and that this
|
|
219
|
+
// project is still on its old version and working. Both reads carry it, the Software update
|
|
220
|
+
// panel and the banner. Fail-soft: a hold that cannot be read is no hold, never a 500.
|
|
221
|
+
async function safeHold(read, args) {
|
|
222
|
+
try { return (await read(args)) || null; } catch { return null; }
|
|
223
|
+
}
|
|
224
|
+
|
|
216
225
|
function buildCoreUpdateRouter({
|
|
217
226
|
coreUpdate = readCoreUpdate,
|
|
218
227
|
selfChannel = storedSelfChannel,
|
|
@@ -221,6 +230,7 @@ function buildCoreUpdateRouter({
|
|
|
221
230
|
canUpdate = holdsCorePin,
|
|
222
231
|
doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig(), hubUpgradeUrl: safeHubUpgradeUrl() }),
|
|
223
232
|
askDeps = defaultAskDeps,
|
|
233
|
+
upgradeHold = readUpgradeHold,
|
|
224
234
|
} = {}) {
|
|
225
235
|
contributeCoreUpdateToDocket();
|
|
226
236
|
const router = express.Router();
|
|
@@ -245,7 +255,8 @@ function buildCoreUpdateRouter({
|
|
|
245
255
|
// No `deploy_url` any more (task 1004296): the banner on a hall without a door of its
|
|
246
256
|
// own links to Settings → Software update, and the hand-off lives on GET /software-update.
|
|
247
257
|
const { channel, source } = await resolveChannel(selfChannel);
|
|
248
|
-
|
|
258
|
+
const update = await coreUpdate({ running, channel });
|
|
259
|
+
res.json({ ok: true, update: { ...update, channel_source: source }, held: await safeHold(upgradeHold, { running, offered: update && update.reading === 'ok' ? update.latest : null }) });
|
|
249
260
|
}, { errorCode: 'core_update_failed', message: false }),
|
|
250
261
|
);
|
|
251
262
|
|
|
@@ -283,7 +294,8 @@ function buildCoreUpdateRouter({
|
|
|
283
294
|
]);
|
|
284
295
|
const door = updateDoor(doorInputs());
|
|
285
296
|
const ask = await readAsk({ update, door, deps: askDeps() });
|
|
286
|
-
|
|
297
|
+
const held = await safeHold(upgradeHold, { running, offered: update && update.reading === 'ok' ? update.offered : null });
|
|
298
|
+
res.json({ ok: true, update, door, can_update: can === true, ask, held });
|
|
287
299
|
}, { errorCode: 'software_update_failed', message: false }),
|
|
288
300
|
);
|
|
289
301
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/upgrade-hold.js — "we tried that version, it was rolled back, you are fine":
|
|
4
|
+
// what THIS project's own hall says about a core move that failed (task 1004730).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Since task 1004729 the hub moves a hosted project's core on its own, by its update
|
|
7
|
+
// rule, and holds a target that failed so it is not retried every tick. The hub's /deploy
|
|
8
|
+
// page and its blockers said so; the project's OWNER, in their own hall, saw only "Update
|
|
9
|
+
// ready" for a version that had already been tried and undone on their project.
|
|
10
|
+
//
|
|
11
|
+
// NO NEW CHANNEL. The move itself is `bongos upgrade` (scripts/gds/upgrade.js), run with
|
|
12
|
+
// PGDATABASE set to THIS project's database, and its auto-rollback writes one row to this
|
|
13
|
+
// project's own `core_upgrades` ledger: `rolled_back: <why> (attempted A → B, restored A)`.
|
|
14
|
+
// So the fact is already here, in this hall's own database, written by the run that failed.
|
|
15
|
+
// This file reads it back; the hub is never asked and nothing crosses an instance.
|
|
16
|
+
//
|
|
17
|
+
// WHAT IT DOES NOT SEE. A pre-flight that refused, or a move refused before anything
|
|
18
|
+
// changed, wrote nothing here (nothing changed on this project). Those stay on the hub.
|
|
19
|
+
//
|
|
20
|
+
// A HOLD IS SHOWN ONLY WHILE IT IS TRUE:
|
|
21
|
+
// • the newest ledger row is that rollback (a later move that landed ends it);
|
|
22
|
+
// • this server runs the version the rollback restored (so "still on it, and working"
|
|
23
|
+
// is a fact: this answer is being served by it);
|
|
24
|
+
// • nothing newer than the tried version is offered (a newer release clears the hold —
|
|
25
|
+
// it may carry the fix, and the hub queues it).
|
|
26
|
+
|
|
27
|
+
const { compareSemver } = require('../../scripts/gds/update-channel.js');
|
|
28
|
+
const { upgradeReason, classifyFailure } = require('../../scripts/gds/upgrade-outcome.js');
|
|
29
|
+
|
|
30
|
+
const VERSION_RE = /^\d{1,4}\.\d{1,5}\.\d{1,6}$/;
|
|
31
|
+
// upgrade.js rollback()'s note, exactly: `rolled_back: ${reason} (attempted F → T, restored P)`.
|
|
32
|
+
const ROLLBACK_NOTE_RE = /^rolled_back: (.+) \(attempted \S+ → \S+, restored \S+\)$/;
|
|
33
|
+
|
|
34
|
+
// What each step that can fail after the pin moved means, for someone who does not run
|
|
35
|
+
// servers. Keyed by upgrade-outcome.js's reason codes; anything else is the general line.
|
|
36
|
+
const REASON_WORDS = Object.freeze({
|
|
37
|
+
npm_install: 'its files could not be downloaded and installed',
|
|
38
|
+
integrity_pin: 'its files did not match the ones that were published',
|
|
39
|
+
migrate: 'the database changes it brings could not be applied to this project',
|
|
40
|
+
migrate_check_violation: 'the database changes it brings do not fit data this project already has',
|
|
41
|
+
restart: 'the project did not start on it',
|
|
42
|
+
health: 'the project did not answer properly after starting on it',
|
|
43
|
+
disk_full: 'the server ran out of disk space while installing it',
|
|
44
|
+
});
|
|
45
|
+
const GENERAL_WORDS = 'it did not pass its checks';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The hold this project is under, or null. PURE.
|
|
49
|
+
* @param {object[]} rows core_upgrades rows, newest first
|
|
50
|
+
* @param {string} running the core this server runs
|
|
51
|
+
* @param {string?} offered the newest release this project's rule offers (null if none/unread)
|
|
52
|
+
*/
|
|
53
|
+
function upgradeHold({ rows, running, offered }) {
|
|
54
|
+
const last = Array.isArray(rows) ? rows[0] : null;
|
|
55
|
+
if (!last || typeof last.note !== 'string') return null;
|
|
56
|
+
const m = ROLLBACK_NOTE_RE.exec(last.note);
|
|
57
|
+
const tried = String(last.to_version || '');
|
|
58
|
+
const was = String(last.from_version || '');
|
|
59
|
+
if (!m || !VERSION_RE.test(tried) || !VERSION_RE.test(was)) return null;
|
|
60
|
+
if (String(running || '') !== was) return null;
|
|
61
|
+
if (offered && VERSION_RE.test(String(offered)) && compareSemver(offered, tried) > 0) return null;
|
|
62
|
+
const reason = upgradeReason({ error: m[1], rolledBack: true });
|
|
63
|
+
const transient = classifyFailure(reason).class === 'transient';
|
|
64
|
+
const why = REASON_WORDS[reason] || GENERAL_WORDS;
|
|
65
|
+
const at = last.applied_at ? new Date(last.applied_at) : null;
|
|
66
|
+
return {
|
|
67
|
+
version: tried,
|
|
68
|
+
still_on: was,
|
|
69
|
+
reason,
|
|
70
|
+
retry: transient ? 'later' : 'next_release',
|
|
71
|
+
at: at && Number.isFinite(at.getTime()) ? at.toISOString() : null,
|
|
72
|
+
message: `Version ${tried} was tried on this project and did not work: ${why}. `
|
|
73
|
+
+ `It was rolled back automatically, so this project is still on ${was} and working normally. `
|
|
74
|
+
+ (transient
|
|
75
|
+
? 'It may be tried again later.'
|
|
76
|
+
: `${tried} is on hold here, and the next release will be tried when it is out.`),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read this project's own ledger and answer upgradeHold. Never throws: a ledger that
|
|
82
|
+
* cannot be read is "no hold", never a failed Software update panel.
|
|
83
|
+
*/
|
|
84
|
+
async function readUpgradeHold({ running, offered, pool = null } = {}) {
|
|
85
|
+
try {
|
|
86
|
+
const db = pool || require('./pool').pool;
|
|
87
|
+
const { rows } = await db.query(
|
|
88
|
+
`SELECT from_version, to_version, note, applied_at FROM core_upgrades
|
|
89
|
+
ORDER BY applied_at DESC, id DESC LIMIT 1`);
|
|
90
|
+
return upgradeHold({ rows, running, offered });
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { upgradeHold, readUpgradeHold };
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.146'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -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);
|
|
@@ -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,9 @@ 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.
|
|
94
|
+
const SHELL_SHA = 'b4e4b7ae4fea6bc1';
|
|
94
95
|
|
|
95
96
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
96
97
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// tests/upgrade_hold.mjs — a failed core move is told to the project's OWN owner, in their
|
|
2
|
+
// own hall (task 1004730).
|
|
3
|
+
//
|
|
4
|
+
// The move is `bongos upgrade` run against THIS project's database; its auto-rollback
|
|
5
|
+
// writes `rolled_back: <why> (attempted A → B, restored A)` to this project's own
|
|
6
|
+
// core_upgrades ledger. src/bongos/upgrade-hold.js reads that row back, and GET
|
|
7
|
+
// /software-update (Settings → Software update) and GET /core-update (the banner) carry
|
|
8
|
+
// it as `held`: which version was tried, that it was rolled back, and that the project is
|
|
9
|
+
// still on its old version and working. A newer release clears it.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/upgrade_hold.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import vm from 'node:vm';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const { upgradeHold, readUpgradeHold } = require('../src/bongos/upgrade-hold.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
|
+
// The row upgrade.js rollback() writes, byte for byte in its note.
|
|
32
|
+
const rollbackRow = (why = 'migrate failed', from = '1.21.20', to = '1.21.115') => ({
|
|
33
|
+
from_version: from, to_version: to, applied_at: '2026-10-14T09:00:00.000Z',
|
|
34
|
+
note: `rolled_back: ${why} (attempted ${from} → ${to}, restored ${from})`,
|
|
35
|
+
});
|
|
36
|
+
const okRow = { from_version: '1.21.20', to_version: '1.21.69', applied_at: '2026-10-15T09:00:00.000Z', note: null };
|
|
37
|
+
|
|
38
|
+
console.log('upgrade hold, read by the project itself (task 1004730)');
|
|
39
|
+
|
|
40
|
+
await t('a rolled-back move is held, in plain words: tried, rolled back, still on the old one and working', () => {
|
|
41
|
+
const h = upgradeHold({ rows: [rollbackRow()], running: '1.21.20', offered: '1.21.115' });
|
|
42
|
+
assert.equal(h.version, '1.21.115');
|
|
43
|
+
assert.equal(h.still_on, '1.21.20');
|
|
44
|
+
assert.equal(h.reason, 'migrate');
|
|
45
|
+
assert.equal(h.retry, 'next_release');
|
|
46
|
+
assert.equal(h.at, '2026-10-14T09:00:00.000Z');
|
|
47
|
+
assert.match(h.message, /Version 1\.21\.115 was tried on this project/);
|
|
48
|
+
assert.match(h.message, /rolled back/);
|
|
49
|
+
assert.match(h.message, /still on 1\.21\.20 and working/);
|
|
50
|
+
assert.match(h.message, /database changes/, 'the reason, in words an owner reads');
|
|
51
|
+
assert.doesNotMatch(h.message, /[—–]/, 'voice rule: no dashes in copy');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await t('each step that can fail reads as its own plain reason; an unknown one as the general line', () => {
|
|
55
|
+
const cases = [['npm install failed', 'npm_install', /downloaded and installed/, 'later'],
|
|
56
|
+
['restart failed', 'restart', /did not start/, 'next_release'],
|
|
57
|
+
['health check failed after restart', 'health', /did not answer properly/, 'next_release'],
|
|
58
|
+
['served core version did not change (still 1.21.20, expected 1.21.115)', 'health', /did not answer properly/, 'next_release'],
|
|
59
|
+
['integrity pin mismatch', 'integrity_pin', /did not match/, 'next_release'],
|
|
60
|
+
['something nobody wrote down', 'unknown', /did not pass its checks/, 'next_release']];
|
|
61
|
+
for (const [why, reason, words, retry] of cases) {
|
|
62
|
+
const h = upgradeHold({ rows: [rollbackRow(why)], running: '1.21.20', offered: '1.21.115' });
|
|
63
|
+
assert.equal(h.reason, reason, why);
|
|
64
|
+
assert.match(h.message, words, why);
|
|
65
|
+
assert.equal(h.retry, retry, why);
|
|
66
|
+
}
|
|
67
|
+
assert.match(upgradeHold({ rows: [rollbackRow('npm install failed')], running: '1.21.20', offered: null }).message, /may be tried again later/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await t('a newer release clears the hold: it may carry the fix', () => {
|
|
71
|
+
assert.equal(upgradeHold({ rows: [rollbackRow()], running: '1.21.20', offered: '1.21.116' }), null);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await t('the hold stands when nothing, or nothing newer, is offered', () => {
|
|
75
|
+
assert.ok(upgradeHold({ rows: [rollbackRow()], running: '1.21.20', offered: null }));
|
|
76
|
+
assert.ok(upgradeHold({ rows: [rollbackRow()], running: '1.21.20', offered: '1.21.100' }));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t('a later move that landed ends it (only the newest ledger row counts)', () => {
|
|
80
|
+
assert.equal(upgradeHold({ rows: [okRow, rollbackRow()], running: '1.21.69', offered: null }), null);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await t('"still on it and working" is said only by the version the rollback restored', () => {
|
|
84
|
+
assert.equal(upgradeHold({ rows: [rollbackRow()], running: '1.21.30', offered: null }), null);
|
|
85
|
+
assert.equal(upgradeHold({ rows: [rollbackRow()], running: null, offered: null }), null);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await t('rows this file did not write are no hold', () => {
|
|
89
|
+
for (const rows of [[], null, [okRow], [{ ...rollbackRow(), note: 'rolled_back: whatever' }], [{ ...rollbackRow(), to_version: 'x' }]]) {
|
|
90
|
+
assert.equal(upgradeHold({ rows, running: '1.21.20', offered: null }), null, JSON.stringify(rows));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await t('readUpgradeHold reads the newest ledger row, and a failed read is no hold, never a throw', async () => {
|
|
95
|
+
let sql = '';
|
|
96
|
+
const pool = { query: async (s) => { sql = s; return { rows: [rollbackRow()] }; } };
|
|
97
|
+
const h = await readUpgradeHold({ running: '1.21.20', offered: '1.21.115', pool });
|
|
98
|
+
assert.equal(h.version, '1.21.115');
|
|
99
|
+
assert.match(sql, /FROM core_upgrades/);
|
|
100
|
+
assert.match(sql, /ORDER BY applied_at DESC/);
|
|
101
|
+
assert.equal(await readUpgradeHold({ running: '1.21.20', pool: { query: async () => { throw new Error('no table'); } } }), null);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---- the two reads the hall makes ----------------------------------------------------
|
|
105
|
+
|
|
106
|
+
async function drive(p, deps) {
|
|
107
|
+
const auth = require('../src/bongos/auth.js');
|
|
108
|
+
const saved = { b: auth.requireBuilder, p: auth.requirePermission };
|
|
109
|
+
auth.requireBuilder = (req, _res, next) => { req.builder = { id: '7' }; next(); };
|
|
110
|
+
auth.requirePermission = () => (_req, _res, next) => next();
|
|
111
|
+
const build = require('../src/bongos/routes/core-update.js');
|
|
112
|
+
const express = require('express');
|
|
113
|
+
const app = express();
|
|
114
|
+
try {
|
|
115
|
+
app.use(build({ askDeps: () => ({ hub: null, getSetting: async () => null }), canUpdate: async () => true,
|
|
116
|
+
doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: 'https://cloudbongos.com/deploy?project=toast' }), ...deps }));
|
|
117
|
+
} finally { auth.requireBuilder = saved.b; auth.requirePermission = saved.p; }
|
|
118
|
+
const server = app.listen(0);
|
|
119
|
+
await new Promise((r) => server.once('listening', r));
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`);
|
|
122
|
+
return { status: res.status, body: await res.json() };
|
|
123
|
+
} finally { server.close(); }
|
|
124
|
+
}
|
|
125
|
+
const ledger = { query: async () => ({ rows: [rollbackRow()] }) };
|
|
126
|
+
const realHold = (a) => readUpgradeHold({ ...a, pool: ledger });
|
|
127
|
+
|
|
128
|
+
await t('GET /software-update carries the hold and its plain reason (Settings → Software update)', async () => {
|
|
129
|
+
let asked = null;
|
|
130
|
+
const r = await drive('/software-update', {
|
|
131
|
+
softwareUpdate: async ({ running }) => ({ reading: 'ok', running, offered: '1.21.115' }),
|
|
132
|
+
upgradeHold: (a) => { asked = a; return realHold({ ...a, running: '1.21.20' }); },
|
|
133
|
+
});
|
|
134
|
+
assert.equal(r.status, 200);
|
|
135
|
+
assert.equal(asked.offered, '1.21.115', 'the hold is judged against what this project is offered');
|
|
136
|
+
assert.equal(r.body.held.version, '1.21.115');
|
|
137
|
+
assert.match(r.body.held.message, /still on 1\.21\.20 and working/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t('GET /software-update: a newer offer means no hold, and an unread registry still shows it', async () => {
|
|
141
|
+
const newer = await drive('/software-update', { softwareUpdate: async () => ({ reading: 'ok', offered: '1.21.116' }),
|
|
142
|
+
upgradeHold: (a) => realHold({ ...a, running: '1.21.20' }) });
|
|
143
|
+
assert.equal(newer.body.held, null);
|
|
144
|
+
const unread = await drive('/software-update', { softwareUpdate: async () => ({ reading: 'no_answer', offered: null }),
|
|
145
|
+
upgradeHold: (a) => realHold({ ...a, running: '1.21.20' }) });
|
|
146
|
+
assert.equal(unread.body.held.version, '1.21.115');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await t('GET /core-update carries it too, for the banner', async () => {
|
|
150
|
+
const r = await drive('/core-update', {
|
|
151
|
+
coreUpdate: async () => ({ reading: 'ok', running: '1.21.20', latest: '1.21.115', behind: true }),
|
|
152
|
+
selfChannel: async () => null,
|
|
153
|
+
upgradeHold: (a) => realHold({ ...a, running: '1.21.20' }),
|
|
154
|
+
});
|
|
155
|
+
assert.equal(r.status, 200);
|
|
156
|
+
assert.equal(r.body.held.version, '1.21.115');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await t('a hold read that throws never fails either read', async () => {
|
|
160
|
+
const boom = async () => { throw new Error('boom'); };
|
|
161
|
+
const a = await drive('/software-update', { softwareUpdate: async () => ({ reading: 'ok', offered: null }), upgradeHold: boom });
|
|
162
|
+
assert.equal(a.status, 200);
|
|
163
|
+
assert.equal(a.body.held, null);
|
|
164
|
+
const b = await drive('/core-update', { coreUpdate: async () => ({ reading: 'ok', behind: false }), selfChannel: async () => null, upgradeHold: boom });
|
|
165
|
+
assert.equal(b.status, 200);
|
|
166
|
+
assert.equal(b.body.held, null);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---- the Software update panel draws it ----------------------------------------------
|
|
170
|
+
|
|
171
|
+
const PANEL_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-software-update.js'), 'utf8');
|
|
172
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
173
|
+
|
|
174
|
+
async function panel(data) {
|
|
175
|
+
const el = () => ({ hidden: true, textContent: '', _html: '', dataset: {}, set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; }, querySelectorAll: () => [] });
|
|
176
|
+
const els = new Map([['software-update-scroll', el()], ['su-body', el()], ['su-note', el()], ['settings-groups', el()]]);
|
|
177
|
+
els.get('settings-groups').dataset.active = 'update';
|
|
178
|
+
const windowObj = {
|
|
179
|
+
BongosClient: { createClient: () => ({ request: async () => ({ ok: true, status: 200, data }) }) },
|
|
180
|
+
OTB: { escapeHtml }, location: { assign() {} },
|
|
181
|
+
};
|
|
182
|
+
const sandbox = { window: windowObj, document: { getElementById: (id) => els.get(id) || null, addEventListener() {} }, console, setTimeout, Date, Math, URL, Promise, String, Number };
|
|
183
|
+
vm.createContext(sandbox);
|
|
184
|
+
vm.runInContext(PANEL_SRC, sandbox, { filename: 'settings-software-update.js' });
|
|
185
|
+
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
186
|
+
return els.get('su-body').innerHTML;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await t('the panel says it in the owner\'s words, and does not offer the held version as "Update ready"', async () => {
|
|
190
|
+
const held = upgradeHold({ rows: [rollbackRow()], running: '1.21.20', offered: '1.21.115' });
|
|
191
|
+
const html = await panel({ ok: true, update: { reading: 'ok', running: '1.21.20', offered: '1.21.115', whats_new: null },
|
|
192
|
+
door: { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=toast' }, can_update: true, ask: {}, held });
|
|
193
|
+
assert.ok(html.includes(escapeHtml(held.message)), 'the plain message is drawn');
|
|
194
|
+
assert.match(html, /On hold/);
|
|
195
|
+
assert.doesNotMatch(html, /Update ready/);
|
|
196
|
+
assert.doesNotMatch(html, /id="su-update"/, 'no button to take the version that just failed');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await t('no hold, no change: the panel draws exactly what it did before', async () => {
|
|
200
|
+
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: {} };
|
|
201
|
+
const html = await panel({ ...data, held: null });
|
|
202
|
+
assert.match(html, /Update ready/);
|
|
203
|
+
assert.equal(html, await panel(data));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
207
|
+
process.exit(failed === 0 ? 0 : 1);
|