@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.
Files changed (59) hide show
  1. package/.bongos-core.json +73 -63
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/api/openapi.json +8 -4
  4. package/docs/copy-inventory.md +23 -23
  5. package/docs/copy-registry.json +37 -37
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/page-readings.json +145 -142
  8. package/modules/hall-ui/public/atlas.html +1 -1
  9. package/modules/hall-ui/public/blockers.html +2 -2
  10. package/modules/hall-ui/public/blockers.js +1 -1
  11. package/modules/hall-ui/public/board-room.html +1 -1
  12. package/modules/hall-ui/public/city.html +1 -1
  13. package/modules/hall-ui/public/collab.html +1 -1
  14. package/modules/hall-ui/public/commands.html +1 -1
  15. package/modules/hall-ui/public/copy-desk.html +1 -1
  16. package/modules/hall-ui/public/deploy.html +1 -1
  17. package/modules/hall-ui/public/diagrams.html +1 -1
  18. package/modules/hall-ui/public/drachmae.html +1 -1
  19. package/modules/hall-ui/public/fleet.html +1 -1
  20. package/modules/hall-ui/public/gate.html +1 -1
  21. package/modules/hall-ui/public/goal-map.html +1 -1
  22. package/modules/hall-ui/public/goals.html +1 -1
  23. package/modules/hall-ui/public/government.html +1 -1
  24. package/modules/hall-ui/public/idea.html +1 -1
  25. package/modules/hall-ui/public/ideas.html +1 -1
  26. package/modules/hall-ui/public/index.html +1 -1
  27. package/modules/hall-ui/public/modules.html +1 -1
  28. package/modules/hall-ui/public/my-agents.html +1 -1
  29. package/modules/hall-ui/public/primer.html +1 -1
  30. package/modules/hall-ui/public/profile-charts.js +21 -1
  31. package/modules/hall-ui/public/profile.html +1 -1
  32. package/modules/hall-ui/public/project-settings.html +1 -1
  33. package/modules/hall-ui/public/ranks.html +1 -1
  34. package/modules/hall-ui/public/roadmap.html +1 -1
  35. package/modules/hall-ui/public/roster.html +1 -1
  36. package/modules/hall-ui/public/sessions.html +1 -1
  37. package/modules/hall-ui/public/settings-software-update.js +14 -3
  38. package/modules/hall-ui/public/settings.html +2 -2
  39. package/modules/hall-ui/public/shell.js +10 -6
  40. package/modules/hall-ui/public/street.html +1 -1
  41. package/modules/hall-ui/public/studio.html +1 -1
  42. package/modules/hall-ui/public/task.html +1 -1
  43. package/modules/hall-ui/public/thinking.html +1 -1
  44. package/modules/hall-ui/public/tweak-editor.html +1 -1
  45. package/modules/hall-ui/public/watch.html +1 -1
  46. package/modules/hall-ui/public/work.html +1 -1
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +16 -0
  50. package/scripts/gds/provision-auto-upgrade.js +30 -7
  51. package/src/bongos/routes/core-update.js +14 -2
  52. package/src/bongos/upgrade-hold.js +96 -0
  53. package/src/module-api.js +1 -1
  54. package/tests/blockers_page_ui.mjs +3 -2
  55. package/tests/core_auto_upgrade.mjs +64 -3
  56. package/tests/core_upgrade_owner_door.mjs +25 -2
  57. package/tests/hall_profile_world.mjs +19 -1
  58. package/tests/nav_permission_atoms.mjs +3 -2
  59. 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. The
27
- // richer hold — telling the project owner, releasing it — is task 1004730.
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
- res.json({ ok: true, update: { ...(await coreUpdate({ running, channel })), channel_source: source } });
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
- res.json({ ok: true, update, door, can_update: can === true, ask });
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.144'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
- assert.match(html, /Resolving one un-sticks every task waiting on it/, 'the static sub-line keeps the prose');
329
- assert.doesNotMatch(BLOCKERS_JS_SRC, /un-sticks|only they can make/, 'blockers.js must not duplicate the prose');
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, 2, 'a table twin under each time chart');
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
- const SHELL_SHA = 'd50a4cc5e6c954b5';
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);