@bongos/core 1.21.145 → 1.21.147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +98 -68
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +90 -7
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +17 -15
- package/docs/copy-registry.json +37 -19
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +85 -85
- package/modules/builder-settings/core-move-seen.js +41 -0
- package/modules/builder-settings/routes/settings.js +22 -0
- package/modules/builder-settings/settings.js +5 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/core-move-notice.js +70 -0
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-software-update.js +38 -5
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/shell.js +18 -6
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +11 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/provision-auto-upgrade.js +30 -7
- package/scripts/gds/provision-core-upgrade.js +9 -3
- package/src/bongos/core-moves.js +155 -0
- package/src/bongos/routes/core-update.js +55 -3
- package/src/bongos/upgrade-hold.js +96 -0
- package/src/module-api.js +1 -1
- package/tests/core_auto_upgrade.mjs +64 -3
- package/tests/core_move_notice.mjs +356 -0
- package/tests/core_upgrade_owner_door.mjs +25 -2
- package/tests/nav_permission_atoms.mjs +4 -2
- package/tests/upgrade_hold.mjs +207 -0
|
@@ -190,7 +190,7 @@
|
|
|
190
190
|
|
|
191
191
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
192
192
|
<script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
|
|
193
|
-
<script src="/builders/shell.js?v=2026-10-
|
|
193
|
+
<script src="/builders/shell.js?v=2026-10-18-core-move1"></script>
|
|
194
194
|
<script src="/builders/palette.js" defer></script>
|
|
195
195
|
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
196
196
|
<!-- The kit, for its ONE renderer: the full-size ship-time visual figure
|
|
@@ -1068,6 +1068,17 @@ html[data-theme="dark"] .gplate__light { display: none; }
|
|
|
1068
1068
|
/* With no action link the close button still sits hard right. */
|
|
1069
1069
|
.core-update-notice__action + .core-update-notice__close { margin-left: 0; }
|
|
1070
1070
|
.core-update-notice > span + .core-update-notice__close { margin-left: auto; }
|
|
1071
|
+
/* "Upgraded to core X on <date>" and what changed (task 1004731): the same strip, with the
|
|
1072
|
+
release-note lines under the sentence in the body ink; it wraps on a phone. */
|
|
1073
|
+
.core-move-notice { flex-wrap: wrap; }
|
|
1074
|
+
.core-move-notice__body { flex: 1 1 20rem; min-width: 0; }
|
|
1075
|
+
.core-move-notice__list {
|
|
1076
|
+
margin: 4px 0 0;
|
|
1077
|
+
padding-left: 1.15rem;
|
|
1078
|
+
max-width: var(--measure, 68ch);
|
|
1079
|
+
color: var(--ink-soft);
|
|
1080
|
+
}
|
|
1081
|
+
.core-move-notice__list li + li { margin-top: 2px; }
|
|
1071
1082
|
.topbar-menu {
|
|
1072
1083
|
display: none;
|
|
1073
1084
|
align-items: center;
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
time so the chrome exists before the deferred page scripts execute. -->
|
|
57
57
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
58
58
|
<script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
|
|
59
|
-
<script src="/builders/shell.js?v=2026-10-
|
|
59
|
+
<script src="/builders/shell.js?v=2026-10-18-core-move1"></script>
|
|
60
60
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
61
61
|
<script src="/builders/palette.js" defer></script>
|
|
62
62
|
<!-- shared frontend toolkit (window.OTB) — loaded FIRST so task.js can pull from it. -->
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
|
|
141
141
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
142
142
|
<script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
|
|
143
|
-
<script src="/builders/shell.js?v=2026-10-
|
|
143
|
+
<script src="/builders/shell.js?v=2026-10-18-core-move1"></script>
|
|
144
144
|
<script src="/builders/palette.js" defer></script>
|
|
145
145
|
|
|
146
146
|
<script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
|
|
145
145
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
146
146
|
<script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
|
|
147
|
-
<script src="/builders/shell.js?v=2026-10-
|
|
147
|
+
<script src="/builders/shell.js?v=2026-10-18-core-move1"></script>
|
|
148
148
|
<script src="/builders/palette.js" defer></script>
|
|
149
149
|
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
150
150
|
<script src="/builders/brand-holes.js?v=2026-09-29-aqfix"></script>
|
|
@@ -167,7 +167,7 @@
|
|
|
167
167
|
chrome exists before the page script runs. -->
|
|
168
168
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
169
169
|
<script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
|
|
170
|
-
<script src="/builders/shell.js?v=2026-10-
|
|
170
|
+
<script src="/builders/shell.js?v=2026-10-18-core-move1"></script>
|
|
171
171
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
172
172
|
<script src="/builders/palette.js" defer></script>
|
|
173
173
|
<!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
time so the chrome exists before the deferred page scripts execute. -->
|
|
174
174
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
175
175
|
<script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
|
|
176
|
-
<script src="/builders/shell.js?v=2026-10-
|
|
176
|
+
<script src="/builders/shell.js?v=2026-10-18-core-move1"></script>
|
|
177
177
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
178
178
|
<script src="/builders/palette.js" defer></script>
|
|
179
179
|
<!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.147",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.147",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.147",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9884,5 +9884,17 @@
|
|
|
9884
9884
|
"id": "1004348",
|
|
9885
9885
|
"text": "The Blockers page now uses the owner's own words, written on a Chromebook and brought back from Google Docs. The line under the page title is shorter and plainer, and the button reads \"+ File blocker\". Both rewritten lines wen"
|
|
9886
9886
|
}
|
|
9887
|
+
],
|
|
9888
|
+
"1.21.146": [
|
|
9889
|
+
{
|
|
9890
|
+
"id": "1004730",
|
|
9891
|
+
"text": "When an automatic core update fails on a hosted project and is rolled back, that version is not retried over and over, and the project's owner now sees in their own Settings and update banner which version was tried, that it w"
|
|
9892
|
+
}
|
|
9893
|
+
],
|
|
9894
|
+
"1.21.147": [
|
|
9895
|
+
{
|
|
9896
|
+
"id": "1004731",
|
|
9897
|
+
"text": "After a project's software is updated, its owner now sees a one-time note saying which version it moved to, when, and what changed, and the Software update page lists the recent updates, including any that failed and were undo"
|
|
9898
|
+
}
|
|
9887
9899
|
]
|
|
9888
9900
|
}
|
|
@@ -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 };
|
|
@@ -321,7 +321,10 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
321
321
|
// the one field task 1004121 exists to keep identical. Skipped when !apply, where the
|
|
322
322
|
// command is printed and never run — a plan should not spend a subprocess per tick.
|
|
323
323
|
const { runAs, migrateAs } = resolveRunAs(inst, deps, { probe: apply });
|
|
324
|
-
|
|
324
|
+
// A move the hub queued by itself (provision-auto-upgrade.js) says so in the project's
|
|
325
|
+
// own ledger, so its hall can tell its owner "upgraded automatically" (task 1004731).
|
|
326
|
+
const automatic = !!intent && intent.requested_by === 'runner:auto-upgrade';
|
|
327
|
+
const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy, automatic, selfRoot, runAs, migrateAs });
|
|
325
328
|
|
|
326
329
|
if (!apply) {
|
|
327
330
|
// A --dry-run runner tick used to only PRINT this command. It now runs the CLI's own
|
|
@@ -668,7 +671,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
|
|
|
668
671
|
// HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
|
|
669
672
|
// a call site whose safety depends on a helper's internals is one refactor away from a
|
|
670
673
|
// shell-injection path (and the apply leg's own guard sits at the route, not here).
|
|
671
|
-
function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', selfRoot = selfInstanceRoot, runAs = null, migrateAs = null } = {}) {
|
|
674
|
+
function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', automatic = false, selfRoot = selfInstanceRoot, runAs = null, migrateAs = null } = {}) {
|
|
672
675
|
if (!isValidSlug(inst && inst.slug)) {
|
|
673
676
|
throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
|
|
674
677
|
}
|
|
@@ -729,7 +732,10 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
729
732
|
// token; the upgrade process cannot (task 1004065, pushUpgradePin).
|
|
730
733
|
hubPushesPin(inst) ? '--pin-no-push' : '',
|
|
731
734
|
requestedBy ? `--requested-by ${requestedBy}` : '',
|
|
732
|
-
requestedBy ? '--upgrade-source owner-control' : ''
|
|
735
|
+
requestedBy ? '--upgrade-source owner-control' : '',
|
|
736
|
+
// The ledger's existing mark for an unattended move (the self-host sweep writes
|
|
737
|
+
// `auto-subscription (<rule>)`); one shell-safe word here (task 1004731).
|
|
738
|
+
automatic && !requestedBy ? '--note auto-subscription' : ''];
|
|
733
739
|
return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs: account, migrateAs: dbAccount };
|
|
734
740
|
}
|
|
735
741
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/core-moves.js — "you were upgraded, and this is what changed": what THIS
|
|
4
|
+
// project's own hall says after its core moved, and the short list of moves Settings →
|
|
5
|
+
// Software update keeps (task 1004731).
|
|
6
|
+
//
|
|
7
|
+
// WHY. Since task 1004729 the hub moves a hosted project's core on its own, by its update
|
|
8
|
+
// rule. The owner found out only by noticing a different number. The goal state is "a
|
|
9
|
+
// changelog is visible inside the update": the hall says once, after the move, which core
|
|
10
|
+
// it is on now, when it moved, and what the releases in between brought.
|
|
11
|
+
//
|
|
12
|
+
// NO NEW CHANNEL — the same reading task 1004730's hold makes (upgrade-hold.js). Every move,
|
|
13
|
+
// automatic or by hand, is `bongos upgrade` run against THIS project's database, and it
|
|
14
|
+
// writes one row to this project's own `core_upgrades` ledger: a landed move as from → to,
|
|
15
|
+
// a failed one as its `rolled_back:` row. And the release notes for every version up to
|
|
16
|
+
// the one now running ship INSIDE the running core (release-notes.json, task 1004218), so
|
|
17
|
+
// the entries between the old and the new version are read from this project's own disk.
|
|
18
|
+
// The hub is never asked.
|
|
19
|
+
//
|
|
20
|
+
// HOW IT WAS MOVED is only as precise as the ledger: `source = 'owner-control'` is the
|
|
21
|
+
// Update button (a person); a note carrying `auto-subscription` is an automatic move (the
|
|
22
|
+
// self-host sweep has always written it, and since this task the hub's automatic move
|
|
23
|
+
// passes it too, provision-core-upgrade.js); anything else was run on the server, and the
|
|
24
|
+
// ledger cannot say by whom, so it says "on the server" rather than guess. A failed
|
|
25
|
+
// automatic move keeps no note of its own (rollback() writes `rolled_back:` in the one note
|
|
26
|
+
// column), so it reads as "on the server" too.
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const { notesBetween, RELEASE_NOTES_FILE } = require('../../scripts/gds/release-notes.js');
|
|
31
|
+
|
|
32
|
+
const VERSION_RE = /^\d{1,4}\.\d{1,5}\.\d{1,6}$/;
|
|
33
|
+
const ROLLED_BACK_RE = /^rolled_back:/;
|
|
34
|
+
const DOWNGRADE_RE = /^downgrade\b/;
|
|
35
|
+
const AUTO_RE = /auto-subscription/;
|
|
36
|
+
|
|
37
|
+
// A notice is about a move that just happened. A project whose last move was months ago
|
|
38
|
+
// (the ledger outlives this feature) is not told about it as news.
|
|
39
|
+
const NOTICE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
40
|
+
// The notice is a banner, not the changelog: a few lines, then "and N more" with the way
|
|
41
|
+
// to the full list.
|
|
42
|
+
const NOTICE_ENTRIES_MAX = 6;
|
|
43
|
+
// "A short history of moves."
|
|
44
|
+
const HISTORY_MAX = 8;
|
|
45
|
+
|
|
46
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
|
|
47
|
+
'September', 'October', 'November', 'December'];
|
|
48
|
+
|
|
49
|
+
function isoOrNull(v) {
|
|
50
|
+
const d = v ? new Date(v) : null;
|
|
51
|
+
return d && Number.isFinite(d.getTime()) ? d.toISOString() : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// "15 October 2026", in UTC so the same move reads the same date for every reader and test.
|
|
55
|
+
function longDate(iso) {
|
|
56
|
+
const d = new Date(iso);
|
|
57
|
+
return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How a ledger row's move was started: 'automatic' | 'by_hand' | 'on_server'. PURE. */
|
|
61
|
+
function moveHow(row) {
|
|
62
|
+
if (row && row.source === 'owner-control') return 'by_hand';
|
|
63
|
+
if (row && typeof row.note === 'string' && AUTO_RE.test(row.note)) return 'automatic';
|
|
64
|
+
return 'on_server';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What a ledger row says happened: 'upgraded' | 'moved_back' | 'rolled_back'. PURE. */
|
|
68
|
+
function moveOutcome(row) {
|
|
69
|
+
const note = row && typeof row.note === 'string' ? row.note : '';
|
|
70
|
+
if (ROLLED_BACK_RE.test(note)) return 'rolled_back';
|
|
71
|
+
if (DOWNGRADE_RE.test(note)) return 'moved_back';
|
|
72
|
+
return 'upgraded';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The short history Settings → Software update shows, newest first, failed moves included.
|
|
77
|
+
* PURE over core_upgrades rows (newest first). `version` is the core the move went to, or
|
|
78
|
+
* for a rolled-back row the one it tried.
|
|
79
|
+
*/
|
|
80
|
+
function moveHistory(rows) {
|
|
81
|
+
if (!Array.isArray(rows)) return [];
|
|
82
|
+
return rows
|
|
83
|
+
.filter((r) => r && VERSION_RE.test(String(r.to_version || '')))
|
|
84
|
+
.slice(0, HISTORY_MAX)
|
|
85
|
+
.map((r) => ({
|
|
86
|
+
id: r.id == null ? null : String(r.id),
|
|
87
|
+
version: String(r.to_version),
|
|
88
|
+
from: VERSION_RE.test(String(r.from_version || '')) ? String(r.from_version) : null,
|
|
89
|
+
at: isoOrNull(r.applied_at),
|
|
90
|
+
how: moveHow(r),
|
|
91
|
+
outcome: moveOutcome(r),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The one-time notice after a move, or null. PURE.
|
|
97
|
+
*
|
|
98
|
+
* Shown only while it is true and new: the newest ledger row is a move that went in (not a
|
|
99
|
+
* rollback), this server runs the version it went to (so "upgraded to X" is a fact: this
|
|
100
|
+
* answer is served by X), it is recent, and this builder has not seen it (`seenId`, the
|
|
101
|
+
* ledger row id they last acknowledged).
|
|
102
|
+
*
|
|
103
|
+
* @param {object[]} rows core_upgrades rows, newest first
|
|
104
|
+
* @param {string} running the core this server runs
|
|
105
|
+
* @param {object?} notes the running core's release-notes.json (null if unreadable)
|
|
106
|
+
* @param {string?} seenId the ledger row id this builder has acknowledged
|
|
107
|
+
* @param {number} now ms clock
|
|
108
|
+
*/
|
|
109
|
+
function moveNotice({ rows, running, notes = null, seenId = null, now = Date.now() }) {
|
|
110
|
+
const last = Array.isArray(rows) ? rows[0] : null;
|
|
111
|
+
if (!last || last.id == null) return null;
|
|
112
|
+
const id = String(last.id);
|
|
113
|
+
const to = String(last.to_version || '');
|
|
114
|
+
if (!VERSION_RE.test(to) || to !== String(running || '')) return null;
|
|
115
|
+
const outcome = moveOutcome(last);
|
|
116
|
+
if (outcome === 'rolled_back') return null;
|
|
117
|
+
if (seenId != null && String(seenId) === id) return null;
|
|
118
|
+
const at = isoOrNull(last.applied_at);
|
|
119
|
+
if (!at || now - Date.parse(at) > NOTICE_MAX_AGE_MS) return null;
|
|
120
|
+
const from = VERSION_RE.test(String(last.from_version || '')) ? String(last.from_version) : null;
|
|
121
|
+
const how = moveHow(last);
|
|
122
|
+
|
|
123
|
+
// The release-note entries between the old and the new version, from this core's own file.
|
|
124
|
+
let entries = null;
|
|
125
|
+
let more = 0;
|
|
126
|
+
if (outcome === 'upgraded' && from && notes) {
|
|
127
|
+
const r = notesBetween(notes, from, to);
|
|
128
|
+
if (r.entries) {
|
|
129
|
+
entries = r.entries.slice(0, NOTICE_ENTRIES_MAX);
|
|
130
|
+
more = r.entries.length - entries.length;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const verb = outcome === 'moved_back' ? 'Moved back' : 'Upgraded';
|
|
134
|
+
return {
|
|
135
|
+
id,
|
|
136
|
+
version: to,
|
|
137
|
+
from,
|
|
138
|
+
at,
|
|
139
|
+
how,
|
|
140
|
+
outcome,
|
|
141
|
+
message: `${verb}${how === 'automatic' ? ' automatically' : ''} to core ${to} on ${longDate(at)}.`,
|
|
142
|
+
entries,
|
|
143
|
+
more,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The running core's own release notes, or null. Never throws. The file sits at the package
|
|
149
|
+
* root beside package.json (release-notes.js); a core checkout run in place has none.
|
|
150
|
+
*/
|
|
151
|
+
function readInstalledReleaseNotes({ fsImpl = fs, root = path.join(__dirname, '..', '..') } = {}) {
|
|
152
|
+
try { return JSON.parse(fsImpl.readFileSync(path.join(root, RELEASE_NOTES_FILE), 'utf8')); } catch { return null; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { moveHistory, moveNotice, readInstalledReleaseNotes };
|
|
@@ -34,6 +34,8 @@ 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');
|
|
38
|
+
const { moveNotice, moveHistory, readInstalledReleaseNotes } = require('../core-moves');
|
|
37
39
|
|
|
38
40
|
// WHERE A HOSTED PROJECT'S OWNER TAKES THE UPGRADE (task 1004174, ADR 0339): the hub's
|
|
39
41
|
// own deploy door, scoped to this project — `<hub>/deploy?project=<slug>`. The act stays
|
|
@@ -213,6 +215,45 @@ async function readAsk({ update, door, deps }) {
|
|
|
213
215
|
return upgradeRequest.askState({ ...base, stored });
|
|
214
216
|
}
|
|
215
217
|
|
|
218
|
+
// A core move this project's own ledger says was rolled back (task 1004730,
|
|
219
|
+
// src/bongos/upgrade-hold.js): which version was tried, why in plain words, and that this
|
|
220
|
+
// project is still on its old version and working. Both reads carry it, the Software update
|
|
221
|
+
// panel and the banner. Fail-soft: a hold that cannot be read is no hold, never a 500.
|
|
222
|
+
async function safeHold(read, args) {
|
|
223
|
+
try { return (await read(args)) || null; } catch { return null; }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// "Upgraded to core X on <date>, and this is what changed" (task 1004731,
|
|
227
|
+
// src/bongos/core-moves.js): the one-time notice after a move and the short history of
|
|
228
|
+
// moves, both read from this project's own core_upgrades ledger, the notes from the running
|
|
229
|
+
// core's own release-notes.json. Gated like the ledger read (GET /core-upgrades): when and to
|
|
230
|
+
// what this box moved is a vulnerability-window fact (ADR 0043).
|
|
231
|
+
//
|
|
232
|
+
// WHO HAS SEEN IT is per builder and server-side, in the builder-settings module's
|
|
233
|
+
// behavior_prefs (core-move-seen.js), reached through its port: core never requires a module.
|
|
234
|
+
// Without the module the hall remembers it in the browser instead ('browser').
|
|
235
|
+
async function defaultSeenMove(req) {
|
|
236
|
+
const port = seams.resolveOptional('builder-settings');
|
|
237
|
+
if (!port || typeof port.coreMoveSeen !== 'function' || typeof port.getBuilderBehaviorPrefs !== 'function') {
|
|
238
|
+
return { remember: 'browser', seen: null };
|
|
239
|
+
}
|
|
240
|
+
return { remember: 'server', seen: port.coreMoveSeen(await port.getBuilderBehaviorPrefs(req.builder && req.builder.id)) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The notice for this viewer, or null. Fail-soft: a ledger, notes file or seen state that
|
|
244
|
+
// cannot be read is no notice, never a failed banner read.
|
|
245
|
+
async function safeMoved({ req, running, upgrades, releaseNotes, seenMove }) {
|
|
246
|
+
try {
|
|
247
|
+
const [rows, seen] = await Promise.all([upgrades({ limit: 1 }), seenMove(req)]);
|
|
248
|
+
const notice = moveNotice({ rows, running, notes: releaseNotes(), seenId: seen && seen.seen });
|
|
249
|
+
return notice ? { ...notice, remember: seen && seen.remember === 'server' ? 'server' : 'browser' } : null;
|
|
250
|
+
} catch { return null; }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function safeHistory(upgrades) {
|
|
254
|
+
try { return moveHistory(await upgrades({ limit: 8 })); } catch { return null; }
|
|
255
|
+
}
|
|
256
|
+
|
|
216
257
|
function buildCoreUpdateRouter({
|
|
217
258
|
coreUpdate = readCoreUpdate,
|
|
218
259
|
selfChannel = storedSelfChannel,
|
|
@@ -221,6 +262,9 @@ function buildCoreUpdateRouter({
|
|
|
221
262
|
canUpdate = holdsCorePin,
|
|
222
263
|
doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig(), hubUpgradeUrl: safeHubUpgradeUrl() }),
|
|
223
264
|
askDeps = defaultAskDeps,
|
|
265
|
+
upgradeHold = readUpgradeHold,
|
|
266
|
+
releaseNotes = readInstalledReleaseNotes,
|
|
267
|
+
seenMove = defaultSeenMove,
|
|
224
268
|
} = {}) {
|
|
225
269
|
contributeCoreUpdateToDocket();
|
|
226
270
|
const router = express.Router();
|
|
@@ -237,7 +281,7 @@ function buildCoreUpdateRouter({
|
|
|
237
281
|
// grader caught here, and it is invisible to any test that does not drive a
|
|
238
282
|
// real request through the assembled router; tests/core_update_banner.mjs now
|
|
239
283
|
// does, and fails if this call is mis-shaped again.
|
|
240
|
-
asyncHandler('GET /core-update', async (
|
|
284
|
+
asyncHandler('GET /core-update', async (req, res) => {
|
|
241
285
|
// Fail-soft on the version read, exactly as GET /version does: an instance
|
|
242
286
|
// that cannot name its own core reports `no_answer` and renders no banner.
|
|
243
287
|
let running = null;
|
|
@@ -245,7 +289,12 @@ function buildCoreUpdateRouter({
|
|
|
245
289
|
// No `deploy_url` any more (task 1004296): the banner on a hall without a door of its
|
|
246
290
|
// own links to Settings → Software update, and the hand-off lives on GET /software-update.
|
|
247
291
|
const { channel, source } = await resolveChannel(selfChannel);
|
|
248
|
-
|
|
292
|
+
const update = await coreUpdate({ running, channel });
|
|
293
|
+
const [held, moved] = await Promise.all([
|
|
294
|
+
safeHold(upgradeHold, { running, offered: update && update.reading === 'ok' ? update.latest : null }),
|
|
295
|
+
safeMoved({ req, running, upgrades, releaseNotes, seenMove }),
|
|
296
|
+
]);
|
|
297
|
+
res.json({ ok: true, update: { ...update, channel_source: source }, held, moved });
|
|
249
298
|
}, { errorCode: 'core_update_failed', message: false }),
|
|
250
299
|
);
|
|
251
300
|
|
|
@@ -283,7 +332,10 @@ function buildCoreUpdateRouter({
|
|
|
283
332
|
]);
|
|
284
333
|
const door = updateDoor(doorInputs());
|
|
285
334
|
const ask = await readAsk({ update, door, deps: askDeps() });
|
|
286
|
-
|
|
335
|
+
const held = await safeHold(upgradeHold, { running, offered: update && update.reading === 'ok' ? update.offered : null });
|
|
336
|
+
// The history only for a viewer who holds core.pin.move (see safeMoved's gate note).
|
|
337
|
+
const history = can === true ? await safeHistory(upgrades) : null;
|
|
338
|
+
res.json({ ok: true, update, door, can_update: can === true, ask, held, history });
|
|
287
339
|
}, { errorCode: 'software_update_failed', message: false }),
|
|
288
340
|
);
|
|
289
341
|
|
|
@@ -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.147'; // 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');
|