@bongos/core 1.21.144 → 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 +73 -63
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +8 -4
- package/docs/copy-inventory.md +23 -23
- package/docs/copy-registry.json +37 -37
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +145 -142
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +2 -2
- package/modules/hall-ui/public/blockers.js +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-charts.js +21 -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 +16 -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/blockers_page_ui.mjs +3 -2
- package/tests/core_auto_upgrade.mjs +64 -3
- package/tests/core_upgrade_owner_door.mjs +25 -2
- package/tests/hall_profile_world.mjs +19 -1
- package/tests/nav_permission_atoms.mjs +3 -2
- package/tests/upgrade_hold.mjs +207 -0
|
@@ -23,8 +23,16 @@
|
|
|
23
23
|
// re-queue a target that already failed, every tick, forever. A target is held for an
|
|
24
24
|
// instance when it already has, FOR THAT TARGET: a core-upgrade that ended (error, or
|
|
25
25
|
// done but the box is still behind), a preflight intent that errored, or a stored
|
|
26
|
-
// preflight verdict that refused. A newer release is a new target and is not held.
|
|
27
|
-
//
|
|
26
|
+
// preflight verdict that refused. A newer release is a new target and is not held.
|
|
27
|
+
//
|
|
28
|
+
// BY FAILURE CLASS (task 1004730). A failed intent records WHAT KIND of failure it was
|
|
29
|
+
// (upgrade-outcome.js classifyFailure, stored by the drain loop). A `transient` one (a
|
|
30
|
+
// registry blip, an install that could not download) already waited out its own
|
|
31
|
+
// 15m/1h/6h backoff inside the intent (intent-retry.js) before it was retired, so this
|
|
32
|
+
// path may try that same target once more after TRANSIENT_HOLD_MS. Every other class (a
|
|
33
|
+
// known wedge whose one fix did not hold, a rule refusing it, an unknown, a failed
|
|
34
|
+
// rollback, which move-escalation.js already took to a person) stays held until a newer
|
|
35
|
+
// release. The project's own hall tells its owner: src/bongos/upgrade-hold.js.
|
|
28
36
|
//
|
|
29
37
|
// Carved into its own file because provision.js and provision-core-upgrade.js sit at
|
|
30
38
|
// the size ratchet. Best-effort: nothing here may fail the tick.
|
|
@@ -35,6 +43,11 @@ const { resolveEnv } = require('../../src/instance-config');
|
|
|
35
43
|
// restarts every hosted project at once. The tick runs every few minutes; one is plenty.
|
|
36
44
|
const AUTO_MOVES_PER_TICK = 1;
|
|
37
45
|
|
|
46
|
+
// How long a target whose failure was classed transient waits before this path tries it
|
|
47
|
+
// again (task 1004730): a day, the hold the self-host sweep gives a failed version
|
|
48
|
+
// (update-sweep.js REFUSAL_HOLD_MS).
|
|
49
|
+
const TRANSIENT_HOLD_MS = 24 * 60 * 60 * 1000;
|
|
50
|
+
|
|
38
51
|
const REQUESTED_BY = 'runner:auto-upgrade';
|
|
39
52
|
const OPEN_STATES = new Set(['pending', 'running']);
|
|
40
53
|
const OFF_VALUES = new Set(['off', '0', 'false', 'no', 'disabled']);
|
|
@@ -57,6 +70,15 @@ function parseVerdict(p) {
|
|
|
57
70
|
return p;
|
|
58
71
|
}
|
|
59
72
|
|
|
73
|
+
// Does this failed intent still hold its target at `now`? A transient error holds for
|
|
74
|
+
// TRANSIENT_HOLD_MS from when it was retired; anything else holds until a newer release
|
|
75
|
+
// (task 1004730). An unreadable time reads as "just now", the safe direction. PURE.
|
|
76
|
+
function stillHolds(i, now) {
|
|
77
|
+
if (i.failure_class !== 'transient' || i.state !== 'error') return true;
|
|
78
|
+
const at = Date.parse(i.updated_at);
|
|
79
|
+
return !Number.isFinite(at) || now - at < TRANSIENT_HOLD_MS;
|
|
80
|
+
}
|
|
81
|
+
|
|
60
82
|
/**
|
|
61
83
|
* What, if anything, to queue for one project. PURE.
|
|
62
84
|
* @param {object} inst provisioning_instances row joined with its core-version snapshot
|
|
@@ -64,7 +86,7 @@ function parseVerdict(p) {
|
|
|
64
86
|
* @param {object} channelMod update-channel.js
|
|
65
87
|
* @returns {{ action: string, to: string } | { skip: string }}
|
|
66
88
|
*/
|
|
67
|
-
function decideAutoMove(inst, intents, channelMod) {
|
|
89
|
+
function decideAutoMove(inst, intents, channelMod, now = Date.now()) {
|
|
68
90
|
if (inst.hosting_shape !== 'cloud-host') return { skip: 'not a hosted project' }; // never the control-plane row
|
|
69
91
|
if (inst.status !== 'active') return { skip: 'not active' };
|
|
70
92
|
if (channelMod.normalizeChannel(inst.update_channel) === 'pinned') return { skip: 'pinned' };
|
|
@@ -75,7 +97,7 @@ function decideAutoMove(inst, intents, channelMod) {
|
|
|
75
97
|
const same = (i) => String(i.target_version || '') === String(to);
|
|
76
98
|
const held = intents.some((i) => same(i) && (
|
|
77
99
|
(i.action === 'core-upgrade' && (i.state === 'error' || i.state === 'done'))
|
|
78
|
-
|| (i.action === 'core-upgrade-preflight' && i.state === 'error')));
|
|
100
|
+
|| (i.action === 'core-upgrade-preflight' && i.state === 'error')) && stillHolds(i, now));
|
|
79
101
|
const verdict = parseVerdict(inst.preflight);
|
|
80
102
|
const verdictForTarget = verdict && String(verdict.target || '') === String(to) ? verdict : null;
|
|
81
103
|
if (held || (verdictForTarget && verdictForTarget.ok !== true)) return { skip: `held: ${to} already failed here` };
|
|
@@ -95,6 +117,7 @@ async function autoUpgradeTick(deps) {
|
|
|
95
117
|
}
|
|
96
118
|
const channelMod = deps.updateChannel || require('./update-channel.js');
|
|
97
119
|
let queued = 0;
|
|
120
|
+
const now = (deps.now || Date.now)();
|
|
98
121
|
try {
|
|
99
122
|
const { rows } = await db.query(
|
|
100
123
|
`SELECT i.id, i.slug, i.owner_builder_id, i.hosting_shape, i.status, i.update_channel,
|
|
@@ -105,7 +128,7 @@ async function autoUpgradeTick(deps) {
|
|
|
105
128
|
ORDER BY i.id`);
|
|
106
129
|
if (!rows.length) return { queued };
|
|
107
130
|
const { rows: intents } = await db.query(
|
|
108
|
-
`SELECT instance_id, action, state, target_version FROM provisioning_intents
|
|
131
|
+
`SELECT instance_id, action, state, target_version, failure_class, updated_at FROM provisioning_intents
|
|
109
132
|
WHERE instance_id = ANY($1)
|
|
110
133
|
AND (state IN ('pending', 'running')
|
|
111
134
|
OR (action IN ('core-upgrade', 'core-upgrade-preflight') AND target_version IS NOT NULL))`,
|
|
@@ -113,7 +136,7 @@ async function autoUpgradeTick(deps) {
|
|
|
113
136
|
for (const inst of rows) {
|
|
114
137
|
if (queued >= AUTO_MOVES_PER_TICK) break;
|
|
115
138
|
const mine = intents.filter((i) => String(i.instance_id) === String(inst.id));
|
|
116
|
-
const d = decideAutoMove(inst, mine, channelMod);
|
|
139
|
+
const d = decideAutoMove(inst, mine, channelMod, now);
|
|
117
140
|
if (d.skip) { if (d.skip.startsWith('held')) log(` auto core-upgrade: ${inst.slug} ${d.skip}`); continue; }
|
|
118
141
|
try {
|
|
119
142
|
const r = await provisioning.enqueueIntent(db, inst.id, d.action, REQUESTED_BY, d.to);
|
|
@@ -136,4 +159,4 @@ async function autoUpgradeTick(deps) {
|
|
|
136
159
|
return { queued };
|
|
137
160
|
}
|
|
138
161
|
|
|
139
|
-
module.exports = { AUTO_MOVES_PER_TICK, autoUpgradeEnabled, autoUpgradeTick };
|
|
162
|
+
module.exports = { AUTO_MOVES_PER_TICK, TRANSIENT_HOLD_MS, autoUpgradeEnabled, autoUpgradeTick };
|
|
@@ -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');
|
|
@@ -325,8 +325,9 @@ await test('resolving through the shared #/blocker/N overlay (otb:blocker-resolv
|
|
|
325
325
|
|
|
326
326
|
await test('the sub-line reason prose has ONE source (blockers.html); JS never carries a copy (task 1003985)', () => {
|
|
327
327
|
const html = fs.readFileSync(path.join(HALL, 'blockers.html'), 'utf8');
|
|
328
|
-
|
|
329
|
-
assert.
|
|
328
|
+
// Wording is the artist's (page tweak, task 1004348); the invariant is the single source.
|
|
329
|
+
assert.match(html, /Any tasks awaiting the same block will unblock automatically on resolution/, 'the static sub-line keeps the prose');
|
|
330
|
+
assert.doesNotMatch(BLOCKERS_JS_SRC, /unblock automatically on resolution|gated at the owner level/, 'blockers.js must not duplicate the prose');
|
|
330
331
|
});
|
|
331
332
|
|
|
332
333
|
summary();
|
|
@@ -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);
|
|
@@ -338,12 +338,30 @@ test('render draws three public figures with a table twin per time chart, and no
|
|
|
338
338
|
assert.ok(out.includes('<td>2026-08-20</td><td class="ledger__num">50 drachmae</td>'), 'the drachmae twin reads the ledger, not the 30 of per-task awards');
|
|
339
339
|
assert.ok(!out.includes('id="chart-rate"'), 'no pace chart on someone else\'s profile');
|
|
340
340
|
assert.ok(!b.requests.some((u) => u.includes('/analytics/')), 'and /analytics was never asked for');
|
|
341
|
-
assert.equal((out.match(/<details class="pchart__twin">/g) || []).length,
|
|
341
|
+
assert.equal((out.match(/<details class="pchart__twin">/g) || []).length, 3, 'a table twin under each time chart and the weekly columns');
|
|
342
342
|
assert.ok(out.includes('<td>2026-08-01</td><td class="ledger__num">2 works</td>'), 'the twin holds the plotted day and its value');
|
|
343
343
|
assert.ok(out.includes('>3 works</text>'), 'only the endpoint is direct-labelled');
|
|
344
344
|
assert.ok(out.includes('Drachmae earned'), 'the currency label comes from branding at render');
|
|
345
345
|
});
|
|
346
346
|
|
|
347
|
+
test('task 1004756: any profile draws works shipped PER WEEK from the record, each week present', async () => {
|
|
348
|
+
const b = bootRender({
|
|
349
|
+
rows: [ship('2026-08-01'), ship('2026-08-01'), ship('2026-08-20', { discipline: 'ui' })],
|
|
350
|
+
earnings: [pay('2026-08-01', 20)],
|
|
351
|
+
});
|
|
352
|
+
await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
|
|
353
|
+
const out = b.mount.innerHTML;
|
|
354
|
+
assert.ok(out.includes('id="chart-weekly"'), 'the weekly figure draws for another builder');
|
|
355
|
+
assert.ok(out.includes('Works shipped, by week'));
|
|
356
|
+
assert.ok(!b.requests.some((u) => u.includes('/analytics/')), 'from the record already read, no extra request');
|
|
357
|
+
const w = b.C.weeklyShipped([ship('2026-08-01'), ship('2026-08-01'), ship('2026-08-20')], null, NOW);
|
|
358
|
+
assert.equal(w.buckets.reduce((s, x) => s + x.n, 0), 3, 'every ship counted once');
|
|
359
|
+
assert.ok(w.buckets.some((x) => x.n === 2), 'two ships in one week are one column of 2');
|
|
360
|
+
assert.ok(w.buckets.some((x) => x.n === 0), 'quiet weeks stay on the axis as zero');
|
|
361
|
+
assert.ok(w.days <= 365, 'all time is capped');
|
|
362
|
+
assert.equal(b.C.weeklyShipped([], 30, NOW).buckets.length, 5, '30 days is five week columns');
|
|
363
|
+
});
|
|
364
|
+
|
|
347
365
|
test('render asks for the pace chart on the viewer\'s own profile only', async () => {
|
|
348
366
|
const analytics = { window_days: 28, ship_rate: [{ day: '2026-09-01', n: 2 }] };
|
|
349
367
|
const own = bootRender({ rows: [ship('2026-08-01')], analytics });
|
|
@@ -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);
|