@bongos/core 1.20.28 → 1.20.30

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.
@@ -38,6 +38,7 @@ const { appUser, dbName, instanceUser, parseTargetRef, resolveOwnerGithubToken,
38
38
  const { ensurePinWriteKey, hasOwnAccount, pinKeyPath, pinSshCommand } = require('./provision-pin-key.js');
39
39
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
40
40
  const { RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, parseChangeRecords, notesBetween } = require('./release-notes.js');
41
+ const outcome = require('./upgrade-outcome.js'); // how far a failed move got, as a class (task 1004446)
41
42
 
42
43
  // The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
43
44
  // web-tier half, and deliberately EXCLUDES 'dedicated': a dedicated droplet is stood up
@@ -141,6 +142,21 @@ function targetFault(to, available, installed, channel, channelAllows) {
141
142
  return null;
142
143
  }
143
144
 
145
+ // The update channel that bounds a move for THIS instance (task 1004445): the row's own
146
+ // `update_channel`, normalised by update-channel.js so anything unknown or absent (a row
147
+ // read before migration provisioning_029) falls back to DEFAULT_CHANNEL. PURE. One helper
148
+ // for the move and the snapshot, so the button's offer and the runner's refusal cannot
149
+ // disagree about which rule applies.
150
+ function instanceChannel(inst, channelMod) {
151
+ return channelMod.normalizeChannel(inst && inst.update_channel != null ? inst.update_channel : channelMod.DEFAULT_CHANNEL);
152
+ }
153
+
154
+ // A classified refusal (task 1004446): the drain loop stores `failure` beside last_error,
155
+ // so the rest of the self-heal chain acts on a class, not on a sentence. PURE.
156
+ function failed(reason, error, extra = {}) {
157
+ return { ok: false, error, failure: outcome.classifyFailure(reason), ...extra };
158
+ }
159
+
144
160
  // The builder id a queued move is attributed to, from the actor the web tier recorded.
145
161
  // PURE. Two doors reach the move (ADR 0339): an Archon (`api:archon:<id>`) and, since
146
162
  // task 1004174, the project's own owner (`api:owner:<id>`). Both carry the id, because
@@ -180,7 +196,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
180
196
  // anything. The guard's real job is the OTHER actions on the self row, enforced at
181
197
  // enqueueIntent where every intent passes.
182
198
  if (!UPGRADE_SHAPES.has(inst.hosting_shape)) {
183
- return { ok: false, error: `core-upgrade is not automated for a '${inst.hosting_shape}' instance — whoever runs that machine has to upgrade it there` };
199
+ return failed('shape_not_automated', `core-upgrade is not automated for a '${inst.hosting_shape}' instance — whoever runs that machine has to upgrade it there`);
184
200
  }
185
201
  if (inst.status !== 'active') {
186
202
  // Not an error: a teardown or a stand-up that raced the request must not burn the
@@ -189,7 +205,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
189
205
  return { ok: true, noop: true };
190
206
  }
191
207
  if (!to) {
192
- return { ok: false, error: 'core-upgrade intent carries no target_version — refusing to guess which version was meant' };
208
+ return failed('no_target', 'core-upgrade intent carries no target_version — refusing to guess which version was meant');
193
209
  }
194
210
  // THE SLUG IS INTERPOLATED INTO A SHELL COMMAND BELOW, so it is re-checked HERE
195
211
  // rather than trusted. `isValidSlug` is enforced on the create route, but the
@@ -200,7 +216,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
200
216
  // two interpolated values are already constrained — the version by the column's own
201
217
  // CHECK and by VERSION_RE, the actor by the digits-only extraction below.
202
218
  if (!isValidSlug(inst.slug)) {
203
- return { ok: false, error: `instance slug '${inst.slug}' is not a valid slug — refusing to build a command from it` };
219
+ return failed('bad_slug', `instance slug '${inst.slug}' is not a valid slug — refusing to build a command from it`);
204
220
  }
205
221
 
206
222
  // ---- 2. is the target real, and permitted -----------------------------------------
@@ -209,19 +225,16 @@ async function coreUpgradeInstance(inst, deps, intent) {
209
225
  const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
210
226
  const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
211
227
  const installed = readInstalled(instanceDir);
212
- // THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
213
- // otherwise. provisioning_instances carries no channel column; the subscription
214
- // roster update-channel.js reads is a control-plane file, not a per-row setting. So
215
- // this bounds every instance by the same policy (DEFAULT_CHANNEL: patch-only), which
216
- // is a real bound — it refuses a prerelease or a major jump — and is the honest one
217
- // available today. When a per-instance channel lands, this line is where it is read;
218
- // writing `inst.update_channel ||` now would only have created a guard that reads as
219
- // present and can never fire, which is the trap the web-tier half already documents.
220
- const channel = channelMod.normalizeChannel(channelMod.DEFAULT_CHANNEL);
228
+ // THIS PROJECT'S OWN UPDATE RULE (task 1004445, migration provisioning_029). Before it
229
+ // every instance was bounded by DEFAULT_CHANNEL (patch-only), so a step-up release
230
+ // could reach no project through the door and the refusal's "change the channel first"
231
+ // pointed at nothing. A row from before the column (undefined) still normalises to that
232
+ // default, so an old runner schema reads exactly as it always did.
233
+ const channel = instanceChannel(inst, channelMod);
221
234
  const listed = channelMod.listAvailableVersions({ cwd: instanceDir });
222
235
  const available = (listed && listed.versions) || [];
223
236
  const fault = targetFault(to, available, installed, channel, channelMod.channelAllows);
224
- if (fault) return { ok: false, error: fault };
237
+ if (fault) return failed(outcome.runnerReason(fault), fault);
225
238
  if (installed && installed === to) {
226
239
  // `bongos upgrade` would no-op on this too, but saying so here keeps a re-drained
227
240
  // intent from restarting a healthy service to land exactly where it already is.
@@ -259,10 +272,17 @@ async function coreUpgradeInstance(inst, deps, intent) {
259
272
  log(` [upgrade] (in ${runDir}) ${cmd}`);
260
273
  boxExec(cmd, { cwd: runDir });
261
274
  } catch (e) {
262
- // upgrade.js rolls itself back on a failed health check or an unchanged served
263
- // version, so by the time this throws the instance is back on the core it had.
264
- // ok:false, never a throw: the instance is healthy and must not be marked broken.
265
- return { ok: false, error: `core upgrade to ${to} failed and was rolled back: ${(e && e.message) || e}` };
275
+ // HOW FAR IT GOT, from upgrade.js's own outcome line (task 1004446) — never assumed.
276
+ // It used to say "failed and was rolled back" for every exit, including refusals that
277
+ // fire before the pin moves (nothing changed) and rollbacks that themselves failed
278
+ // (the project may be down). ok:false, never a throw: the drain loop's catch marks the
279
+ // INSTANCE broken, which is only ever right for the rollback_failed case, and that one
280
+ // is reported (and retired, never retried unattended) rather than guessed at here.
281
+ const text = `${(e && e.stderr) || ''}\n${(e && e.message) || e}`;
282
+ const o = outcome.parseOutcomeLine(text);
283
+ const said = (/✖ ([^\n]+)/.exec(text) || [])[1] || String((e && e.message) || e).split('\n')[0];
284
+ const reason = o ? o.reason : 'unknown';
285
+ return failed(reason, outcome.describeUpgradeFailure(to, o, said), { stage: o ? o.stage : null, terminal: reason === 'rollback_failed' });
266
286
  }
267
287
 
268
288
  // ---- 4. read back what the BOX serves (ADR 0293 D6) --------------------------------
@@ -281,7 +301,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
281
301
  if (served && served !== to) {
282
302
  // upgrade.js's own --version-url gate should have caught and reverted this, so
283
303
  // reaching here means the two disagree. Report what was SEEN rather than a verdict.
284
- return { ok: false, error: `the box is serving core ${served}, not the requested ${to} — the move did not take` };
304
+ return failed('served_mismatch', `the box is serving core ${served}, not the requested ${to} — the move did not take`);
285
305
  }
286
306
 
287
307
  // ---- 5. the pin reaches the project's own repo, pushed from HERE (task 1004065) ----
@@ -977,7 +997,7 @@ async function snapshotCoreVersions(inst, deps) {
977
997
  // admits nothing newer — "already current" and "nothing to take" are the same
978
998
  // answer here, and both correctly leave `recommended` NULL.
979
999
  const recommended = served
980
- ? channelMod.resolveChannelTarget({ installed: served, channel: channelMod.DEFAULT_CHANNEL, available })
1000
+ ? channelMod.resolveChannelTarget({ installed: served, channel: instanceChannel(inst, channelMod), available })
981
1001
  : null;
982
1002
  // THE TICK DOES NOT DRY-RUN. The verdict is produced on demand (preflightCoreUpgrade),
983
1003
  // because a check re-run every tick for a box that has been behind for hours spends a
@@ -1021,4 +1041,4 @@ async function snapshotAllCoreVersions(deps) {
1021
1041
  return { seen };
1022
1042
  }
1023
1043
 
1024
- module.exports = { pushUpgradePin, UPGRADE_SHAPES, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
1044
+ module.exports = { pushUpgradePin, UPGRADE_SHAPES, instanceChannel, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
@@ -1200,8 +1200,10 @@ async function cmdRunIntents(deps) {
1200
1200
  // instance is never marked broken over a domain hiccup, but the card
1201
1201
  // can say why the work stopped.
1202
1202
  await provisioning.setInstanceStatus(db, intent.instance_id, inst.status, { error_note: result.error || 'op failed', error_note_action: intent.action }).catch(() => {});
1203
+ if (result.failure) await recordFailureClass(db, intent.id, result.failure); // WHAT KIND of failure, beside the sentence (task 1004446)
1203
1204
  } else {
1204
1205
  await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
1206
+ if (result.failure) await recordFailureClass(db, intent.id, result.failure);
1205
1207
  }
1206
1208
  errors++;
1207
1209
  } else { await provisioning.resolveIntent(db, intent.id, 'done', (result && result.doneNote) || null); ok++; } // done WITH a note: it landed, and this is what it could not finish (task 1004065)
@@ -1293,6 +1295,15 @@ box (task 2074). Set PROVISION_CONTROL_PLANE_IP to this box's own public IP so a
1293
1295
  standalone instance's A record points HERE, not at the co-hosting box's PROVISION_PUBLIC_IP.`);
1294
1296
  }
1295
1297
 
1298
+ // Store an intent's failure class + reason code (migration provisioning_030, task 1004446),
1299
+ // only for a result that carries one. Best-effort: a runner on a schema without the columns
1300
+ // must still drain, so a failed write is swallowed — last_error still says it either way. A
1301
+ // class left on a row that later lands 'done' is harmless: readers ignore it on a done row.
1302
+ async function recordFailureClass(db, intentId, failure) {
1303
+ await db.query('UPDATE provisioning_intents SET failure_class = $2, failure_reason = $3 WHERE id = $1',
1304
+ [intentId, failure ? failure.class : null, failure ? failure.reason : null]).catch(() => {});
1305
+ }
1306
+
1296
1307
  async function main() {
1297
1308
  const args = process.argv.slice(2);
1298
1309
  const cmd = args[0];
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+ //
3
+ // scripts/gds/upgrade-outcome.js — what a failed core move actually WAS, as a class and a
4
+ // reason code the deploy runner can act on (task 1004446, goal 1000090 deploy self-heal).
5
+ //
6
+ // WHY. Every non-zero exit from `bongos upgrade` reached the owner as "failed and was rolled
7
+ // back", including the refusals that fire BEFORE the pin moves (a dirty tree, the artist
8
+ // gate, a downgrade, the database identity check) — nothing was changed, so nothing was
9
+ // rolled back. And a rollback that itself failed ("manual intervention needed") read exactly
10
+ // like one that worked. The runner could not tell a wait-and-retry from a decision only the
11
+ // owner can make from an outage, so it treated all of them the same: retry, then give up.
12
+ //
13
+ // TWO HALVES, ONE FILE, so the words cannot drift apart:
14
+ // • upgrade.js ends a failed run with ONE machine-readable stderr line (outcomeLine) saying
15
+ // how far it got: refused (nothing moved) · rolled_back (moved, restored) ·
16
+ // rollback_failed (moved, NOT restored). The runner invokes upgrade.js from its own
17
+ // checkout, so the two halves are always the same version.
18
+ // • the runner reads it back (parseOutcomeLine) and classifies (classifyFailure).
19
+ //
20
+ // THE FOUR CLASSES the rest of the self-heal chain keys on:
21
+ // transient — try again later and it may simply work (registry blip, not published yet)
22
+ // known_wedge — a known snag with a known fix the runner may apply itself (dirty tree, full disk)
23
+ // needs_decision — a rule the owner set is refusing it; only the owner can change that
24
+ // unknown — anything else, and every rollback that failed; a person should look
25
+ //
26
+ // PURE: no I/O, no requires. Reason codes are a fixed vocabulary — never builder text — so
27
+ // they are safe to store, index and show.
28
+
29
+ const OUTCOME_TAG = 'BONGOS_UPGRADE_OUTCOME';
30
+ const STAGES = Object.freeze(['refused', 'rolled_back', 'rollback_failed']);
31
+ const FAILURE_CLASSES = Object.freeze(['transient', 'known_wedge', 'needs_decision', 'unknown']);
32
+
33
+ // reason → class. Anything not listed is `unknown`, which is the safe direction: an unknown
34
+ // failure reaches a person; a mislabelled one could be retried or "healed" wrongly.
35
+ const REASON_CLASS = Object.freeze({
36
+ // the runner's own refusals (provision-core-upgrade.js)
37
+ registry_unreadable: 'transient',
38
+ not_published: 'transient',
39
+ outside_channel: 'needs_decision',
40
+ shape_not_automated: 'needs_decision',
41
+ no_target: 'unknown',
42
+ bad_slug: 'unknown',
43
+ installed_unreadable: 'unknown',
44
+ served_mismatch: 'unknown',
45
+ // upgrade.js refusals, before the pin moves
46
+ dirty_tree: 'known_wedge',
47
+ downgrade: 'needs_decision',
48
+ artist_gate: 'needs_decision',
49
+ module_incompatible: 'needs_decision',
50
+ already_on: 'unknown',
51
+ db_unresolvable: 'unknown',
52
+ db_identity: 'unknown',
53
+ health_url_missing: 'unknown',
54
+ core_source: 'unknown',
55
+ // after the pin moved (rolled back)
56
+ disk_full: 'known_wedge',
57
+ npm_install: 'transient',
58
+ integrity_pin: 'unknown',
59
+ migrate: 'unknown',
60
+ restart: 'unknown',
61
+ health: 'unknown',
62
+ // the one that must never be retried unattended
63
+ rollback_failed: 'unknown',
64
+ });
65
+
66
+ // upgrade.js error text → reason. Ordered; first match wins. Each pattern is the PREFIX of
67
+ // a message upgrade.js builds itself (runUpgrade), so these track that file, not the world.
68
+ const UPGRADE_REASONS = [
69
+ [/no space left on device|ENOSPC/i, 'disk_full'],
70
+ [/^refusing to DOWNGRADE/, 'downgrade'],
71
+ [/^already on /, 'already_on'],
72
+ [/^working tree not clean/, 'dirty_tree'],
73
+ [/^module pre-check failed/, 'module_incompatible'],
74
+ [/^cannot determine which database/, 'db_unresolvable'],
75
+ [/^post-restart health check is mandatory/, 'health_url_missing'],
76
+ [/^(--registry and --from|--from tarball not found|no core tarball at)/, 'core_source'],
77
+ [/^npm install failed/, 'npm_install'],
78
+ [/^integrity pin/, 'integrity_pin'],
79
+ [/^migrate failed/, 'migrate'],
80
+ [/^restart failed/, 'restart'],
81
+ [/^(health check failed after restart|served core version did not change)/, 'health'],
82
+ ];
83
+
84
+ /** The reason code for an upgrade.js failure result (runUpgrade's `{ ok:false, … }`). PURE. */
85
+ function upgradeReason(res) {
86
+ if (!res) return 'unknown';
87
+ if (res.downgrade) return 'downgrade';
88
+ if (res.artistGate) return 'artist_gate';
89
+ const text = String(res.error || '');
90
+ for (const [re, reason] of UPGRADE_REASONS) if (re.test(text)) return reason;
91
+ // A database-identity refusal carries preflightDbIdentity's own reason, which has no
92
+ // fixed prefix; it is the only pre-pin refusal left that names the database.
93
+ if (!res.rolledBack && /database|PGDATABASE/i.test(text)) return 'db_identity';
94
+ return 'unknown';
95
+ }
96
+
97
+ /** How far a failed run got. A rollback counts as failed if ANY of its steps did. PURE. */
98
+ function upgradeStage(res) {
99
+ if (!res || !res.rolledBack) return 'refused';
100
+ const rb = res.rollback || {};
101
+ const broken = rb.restoreOk === false || rb.reinstallOk === false || rb.restartOk === false || rb.healthOk === false;
102
+ return broken ? 'rollback_failed' : 'rolled_back';
103
+ }
104
+
105
+ /** The single stderr line upgrade.js ends a failed run with. PURE. */
106
+ function outcomeLine(res) {
107
+ const stage = upgradeStage(res);
108
+ const reason = stage === 'rollback_failed' ? 'rollback_failed' : upgradeReason(res);
109
+ return `${OUTCOME_TAG} ${JSON.stringify({ stage, reason })}\n`;
110
+ }
111
+
112
+ /** Read the outcome line back out of captured output, or null if there is none. PURE. */
113
+ function parseOutcomeLine(text) {
114
+ const lines = String(text || '').split(/\r?\n/).filter((l) => l.startsWith(`${OUTCOME_TAG} `));
115
+ if (!lines.length) return null;
116
+ try {
117
+ const o = JSON.parse(lines[lines.length - 1].slice(OUTCOME_TAG.length + 1));
118
+ if (!STAGES.includes(o.stage) || typeof o.reason !== 'string' || !/^[a-z_]{1,40}$/.test(o.reason)) return null;
119
+ return { stage: o.stage, reason: o.reason };
120
+ } catch { return null; }
121
+ }
122
+
123
+ /** `{ class, reason }` for a reason code; an unlisted code is `unknown`. PURE. */
124
+ function classifyFailure(reason) {
125
+ const r = typeof reason === 'string' && Object.prototype.hasOwnProperty.call(REASON_CLASS, reason) ? reason : 'unknown';
126
+ return { class: REASON_CLASS[r] || 'unknown', reason: r };
127
+ }
128
+
129
+ /** The runner's own refusals (targetFault's sentences) → reason code. PURE. */
130
+ function runnerReason(text) {
131
+ const s = String(text || '');
132
+ if (/^could not read the published core versions/.test(s)) return 'registry_unreadable';
133
+ if (/is not published yet/.test(s)) return 'not_published';
134
+ if (/could not read the core version installed/.test(s)) return 'installed_unreadable';
135
+ if (/update channel/.test(s)) return 'outside_channel';
136
+ return 'unknown';
137
+ }
138
+
139
+ /**
140
+ * The sentence the owner reads for a failed upgrade.js run. Says what actually happened,
141
+ * never more: "rolled back" only when a rollback ran and finished. PURE.
142
+ */
143
+ function describeUpgradeFailure(to, outcome, detail) {
144
+ const d = String(detail || '').trim();
145
+ const tail = d ? `: ${d}` : '';
146
+ if (!outcome) return `core upgrade to ${to} failed, and the upgrade did not report how far it got — check the project before retrying${tail}`;
147
+ if (outcome.stage === 'refused') return `core upgrade to ${to} was refused before anything changed — the project is still on its previous core${tail}`;
148
+ if (outcome.stage === 'rolled_back') return `core upgrade to ${to} failed and was rolled back — the project is back on its previous core${tail}`;
149
+ return `core upgrade to ${to} failed AND the rollback did not finish — the project may be down or on a half-installed core, and needs a person${tail}`;
150
+ }
151
+
152
+ module.exports = {
153
+ OUTCOME_TAG, STAGES, FAILURE_CLASSES, REASON_CLASS,
154
+ upgradeReason, upgradeStage, outcomeLine, parseOutcomeLine, classifyFailure, runnerReason, describeUpgradeFailure,
155
+ };
@@ -1480,7 +1480,7 @@ async function main(argv = process.argv.slice(2)) {
1480
1480
  return require('./module').cmdUpgrade(argv);
1481
1481
  }
1482
1482
  const res = await runUpgrade(parseArgs(argv));
1483
- if (!res.ok) { process.stderr.write(`\n✖ ${res.error || 'upgrade failed'}\n`); return 1; }
1483
+ if (!res.ok) { process.stderr.write(`\n✖ ${res.error || 'upgrade failed'}\n${require('./upgrade-outcome').outcomeLine(res)}`); return 1; } // the outcome line: how far it got, for the deploy runner (task 1004446)
1484
1484
  return 0;
1485
1485
  }
1486
1486
 
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.20.28'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.30'; // 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');
@@ -18,7 +18,7 @@ import { createRequire } from 'node:module';
18
18
  import { readFileSync, readdirSync } from 'node:fs';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { dirname, join } from 'node:path';
21
- import { parseRepoNote } from '../modules/provisioning/core-upgrade.js';
21
+ import { parseRepoNote, UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, makeUpdateChannelHandler as updateChannelHandlerFor } from '../modules/provisioning/core-upgrade.js';
22
22
 
23
23
  const require = createRequire(import.meta.url);
24
24
  const P = require('../modules/provisioning/provisioning.js');
@@ -885,5 +885,93 @@ await t('lastMoveSuperseded computes no version ORDERING — only identity', ()
885
885
  assert.equal(CU.lastMoveSuperseded('1.19.921', '1.19.900', '1.19.949'), true);
886
886
  });
887
887
 
888
+ console.log('\nthe project\'s update rule (task 1004445):');
889
+
890
+ const channelHandler = (inst, calls) => updateChannelHandlerFor({
891
+ provisioning: {
892
+ getInstanceById: async () => inst,
893
+ recordEvent: async (_db, e) => { calls.push({ event: e.event, detail: e.detail, actor: e.actor }); },
894
+ },
895
+ pool: { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [] }; } },
896
+ parseId: () => 7, validateOrRespond: () => false, log: { error() {} },
897
+ });
898
+ const runChannel = async (inst, body, calls = []) => {
899
+ let out = null;
900
+ const res = { json: (b) => { out = b; return res; }, fail: (code, o) => { out = { code, ...o }; return res; } };
901
+ await channelHandler(inst, calls)({ builder: { id: 42 }, params: { id: '7' }, body }, res);
902
+ return out;
903
+ };
904
+
905
+ await t('the web tier\'s channel list is the runner\'s, and the migration\'s CHECK carries the same values', () => {
906
+ const policy = require('../scripts/gds/update-channel.js');
907
+ assert.deepEqual([...UPDATE_CHANNELS], policy.VALID_CHANNELS, 're-stated in the module (ADR 0083) — so it must be equal');
908
+ assert.equal(DEFAULT_UPDATE_CHANNEL, policy.DEFAULT_CHANNEL);
909
+ const sql = read('modules', 'provisioning', 'migrations', 'provisioning_029_update_channel.sql');
910
+ assert.match(sql, /DEFAULT 'patch'/);
911
+ for (const c of policy.VALID_CHANNELS) assert.match(sql, new RegExp(`'${c}'`), `the CHECK admits ${c}`);
912
+ });
913
+
914
+ await t('updateChannelOf reads the row and falls back to patch for a pre-column row', () => {
915
+ assert.equal(updateChannelOf({ update_channel: 'minor' }), 'minor');
916
+ assert.equal(updateChannelOf({}), 'patch');
917
+ assert.equal(updateChannelOf({ update_channel: 'everything' }), 'patch');
918
+ });
919
+
920
+ await t('setting the rule writes the row and audits who changed it, from what to what', async () => {
921
+ const calls = [];
922
+ const out = await runChannel({ ...ACTIVE, update_channel: 'patch' }, { channel: 'minor' }, calls);
923
+ assert.equal(out.ok, true);
924
+ assert.equal(out.update_channel, 'minor');
925
+ assert.equal(out.changed, true);
926
+ const w = calls.find((c) => c.sql && /UPDATE provisioning_instances SET update_channel/.test(c.sql));
927
+ assert.ok(w, 'the row was written');
928
+ assert.deepEqual(w.params, [7, 'minor']);
929
+ const ev = calls.find((c) => c.event === 'update-channel-changed');
930
+ assert.ok(ev, 'and an event recorded');
931
+ assert.match(ev.detail, /from patch to minor/);
932
+ assert.equal(ev.actor, 'api:archon:42');
933
+ assert.ok(!calls.some((c) => c.sql && /provisioning_intents/.test(c.sql)), 'nothing is queued — a rule change moves no core');
934
+ });
935
+
936
+ await t('an unknown rule is a 400 naming the valid ones, and writes nothing', async () => {
937
+ const calls = [];
938
+ const out = await runChannel(ACTIVE, { channel: 'major' }, calls);
939
+ assert.equal(out.code, 'bad_update_channel');
940
+ assert.equal(out.status, 400);
941
+ assert.deepEqual(out.details.valid, ['pinned', 'patch', 'minor']);
942
+ assert.equal(calls.length, 0);
943
+ });
944
+
945
+ await t('re-sending the current rule changes nothing and says so', async () => {
946
+ const calls = [];
947
+ const out = await runChannel({ ...ACTIVE, update_channel: 'minor' }, { channel: 'MINOR' }, calls);
948
+ assert.equal(out.ok, true);
949
+ assert.equal(out.changed, false);
950
+ assert.equal(calls.length, 0, 'no write, no event');
951
+ });
952
+
953
+ await t('the preview carries the rule, so the page can say which rule shaped the offer', () => {
954
+ assert.equal(CU.previewFromSnapshot({ ...ACTIVE, update_channel: 'minor' }, null, null, null).update_channel, 'minor');
955
+ assert.equal(CU.previewFromSnapshot(ACTIVE, null, null, null).update_channel, 'patch');
956
+ });
957
+
958
+ await t('the route is gated like the move: the owner door OR core.pin.move', () => {
959
+ const src = read('modules', 'provisioning', 'routes', 'core-upgrade.js');
960
+ const i = src.indexOf("'/provisioning/instances/:id/update-channel'");
961
+ assert.ok(i > 0, 'the route exists');
962
+ const block = src.slice(i, src.indexOf(');', i));
963
+ assert.match(block, /api\.requireBuilder/);
964
+ assert.match(block, /requireOwnProjectOrPin\(\{ pinGate: api\.requirePermission\('core\.pin\.move'\)/);
965
+ });
966
+
967
+ await t('a failed move carries its class to the page; a move that worked carries none (task 1004446)', () => {
968
+ const failedMove = { target_version: '1.20.1', state: 'error', last_error: 'x', failure_class: 'needs_decision', failure_reason: 'outside_channel' };
969
+ assert.deepEqual(CU.previewFromSnapshot(ACTIVE, null, null, failedMove).last_move.failure, { class: 'needs_decision', reason: 'outside_channel' });
970
+ const done = { ...failedMove, state: 'done' };
971
+ assert.equal(CU.previewFromSnapshot(ACTIVE, null, null, done).last_move.failure, null);
972
+ const old = { target_version: '1.20.1', state: 'error', last_error: 'x' };
973
+ assert.equal(CU.previewFromSnapshot(ACTIVE, null, null, old).last_move.failure, null, 'a row from before the columns');
974
+ });
975
+
888
976
  console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
889
977
  if (failed) process.exit(1);
@@ -31,7 +31,7 @@ import { createRequire } from 'node:module';
31
31
  import fs from 'node:fs';
32
32
  import path from 'node:path';
33
33
  // An ESM named import, not R.parseSchemaPending: knip credits a CJS export only when a test imports it this way.
34
- import { parseSchemaPending, repoNote, REPO_NOTES } from '../scripts/gds/provision-core-upgrade.js';
34
+ import { parseSchemaPending, repoNote, REPO_NOTES, instanceChannel } from '../scripts/gds/provision-core-upgrade.js';
35
35
  import { parseRepoNote } from '../modules/provisioning/core-upgrade.js';
36
36
 
37
37
  const require = createRequire(import.meta.url);
@@ -1396,5 +1396,124 @@ await t('the DRAIN LOOP resolves a done-with-a-note move WITH its note, still as
1396
1396
  assert.equal(statusWrites.length, 0, 'a healthy instance is not marked anything');
1397
1397
  });
1398
1398
 
1399
+ console.log('\nthe project\'s own update rule (task 1004445):');
1400
+
1401
+ // The REAL policy module, not the stub makeDeps hands every other case: these cases are
1402
+ // about which rule the runner applies, so a stub that admits everything would prove nothing.
1403
+ const realChannel = require('../scripts/gds/update-channel.js');
1404
+ const withRealChannel = (over) => {
1405
+ const d = makeDeps(over);
1406
+ d.updateChannel = { ...realChannel, listAvailableVersions: () => ({ ok: true, versions: over.available }) };
1407
+ return d;
1408
+ };
1409
+
1410
+ await t('instanceChannel reads the row, and a row from before the column falls back to patch', () => {
1411
+ assert.equal(instanceChannel({ update_channel: 'minor' }, realChannel), 'minor');
1412
+ assert.equal(instanceChannel({ update_channel: 'pinned' }, realChannel), 'pinned');
1413
+ assert.equal(instanceChannel({}, realChannel), 'patch', 'no column yet → the old default');
1414
+ assert.equal(instanceChannel({ update_channel: 'bogus' }, realChannel), 'patch', 'unknown → the default, never a wider rule');
1415
+ });
1416
+
1417
+ await t('a project on PATCH still refuses a step-up release (1.19 → 1.20)', async () => {
1418
+ const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1081', available: ['1.19.1081', '1.20.1'] });
1419
+ const r = await R.coreUpgradeInstance(inst({ update_channel: 'patch' }), d, intent({ target_version: '1.20.1' }));
1420
+ assert.equal(r.ok, false);
1421
+ assert.match(r.error, /outside this project's 'patch' update channel/);
1422
+ assert.ok(!d.ran.some((c) => /upgrade\.js/.test(c)), 'and nothing was run');
1423
+ });
1424
+
1425
+ await t('a project on MINOR takes that same step-up release', async () => {
1426
+ const d = withRealChannel({ installed: '1.19.1081', served: '1.20.1', available: ['1.19.1081', '1.20.1'] });
1427
+ const r = await R.coreUpgradeInstance(inst({ update_channel: 'minor' }), d, intent({ target_version: '1.20.1' }));
1428
+ assert.ok(!(r.error && /update channel/.test(r.error)), `the channel must not refuse it: ${r.error}`);
1429
+ assert.ok(d.ran.some((c) => /upgrade\.js/.test(c) && /--to 1\.20\.1/.test(c)), 'the upgrade was invoked for 1.20.1');
1430
+ });
1431
+
1432
+ await t('MINOR still refuses a MAJOR jump — no rule makes 2.0 automatic', async () => {
1433
+ const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1081', available: ['1.19.1081', '2.0.0'] });
1434
+ const r = await R.coreUpgradeInstance(inst({ update_channel: 'minor' }), d, intent({ target_version: '2.0.0' }));
1435
+ assert.equal(r.ok, false);
1436
+ assert.match(r.error, /outside this project's 'minor' update channel/);
1437
+ });
1438
+
1439
+ await t('the snapshot OFFERS what the project\'s rule admits — the button and the runner agree', async () => {
1440
+ const available = ['1.19.1081', '1.19.1082', '1.20.1', '1.20.11'];
1441
+ const offered = async (update_channel) => {
1442
+ const queries = [];
1443
+ await R.snapshotCoreVersions(inst({ update_channel }), withRealChannel({ served: '1.19.1081', available, queries }));
1444
+ return queries.find((q) => /provisioning_core_version_snapshot/.test(q.sql)).params[3];
1445
+ };
1446
+ assert.equal(await offered('patch'), '1.19.1082', 'patch offers the newest fix on the same line');
1447
+ assert.equal(await offered('minor'), '1.20.11', 'minor offers the step up');
1448
+ assert.equal(await offered('pinned'), null, 'pinned offers nothing');
1449
+ assert.equal(await offered(undefined), '1.19.1082', 'a pre-column row behaves exactly as before');
1450
+ });
1451
+
1452
+ console.log('\nwhat a failed upgrade.js run is reported as (task 1004446):');
1453
+
1454
+ // execSync's error: message "Command failed: <cmd>\n<stderr>", stderr a Buffer.
1455
+ const upgradeThrows = (stderr) => (d) => {
1456
+ const base = d.exec;
1457
+ d.exec = (cmd, opts) => {
1458
+ if (/upgrade\.js/.test(cmd)) { const e = new Error(`Command failed: ${cmd}\n${stderr}`); e.stderr = Buffer.from(stderr); e.status = 1; throw e; }
1459
+ return base(cmd, opts);
1460
+ };
1461
+ return d;
1462
+ };
1463
+
1464
+ await t('a refusal before the pin moved is classified, and never called "rolled back"', async () => {
1465
+ const d = upgradeThrows('\n✖ working tree not clean — commit/stash first\nBONGOS_UPGRADE_OUTCOME {"stage":"refused","reason":"dirty_tree"}\n')(makeDeps());
1466
+ const r = await R.coreUpgradeInstance(inst(), d, intent());
1467
+ assert.equal(r.ok, false);
1468
+ assert.deepEqual(r.failure, { class: 'known_wedge', reason: 'dirty_tree' });
1469
+ assert.equal(r.stage, 'refused');
1470
+ assert.doesNotMatch(r.error, /rolled back/);
1471
+ assert.match(r.error, /working tree not clean/, 'the upgrade\'s own sentence is kept');
1472
+ assert.ok(!r.terminal);
1473
+ });
1474
+
1475
+ await t('a real rollback says rolled back', async () => {
1476
+ const d = upgradeThrows('\n✖ migrate failed (exit 3)\nBONGOS_UPGRADE_OUTCOME {"stage":"rolled_back","reason":"migrate"}\n')(makeDeps());
1477
+ const r = await R.coreUpgradeInstance(inst(), d, intent());
1478
+ assert.deepEqual(r.failure, { class: 'unknown', reason: 'migrate' });
1479
+ assert.match(r.error, /failed and was rolled back/);
1480
+ });
1481
+
1482
+ await t('a FAILED rollback is its own outcome, and terminal — never retried unattended', async () => {
1483
+ const d = upgradeThrows('\n✖ health check failed after restart\nBONGOS_UPGRADE_OUTCOME {"stage":"rollback_failed","reason":"rollback_failed"}\n')(makeDeps());
1484
+ const r = await R.coreUpgradeInstance(inst(), d, intent());
1485
+ assert.deepEqual(r.failure, { class: 'unknown', reason: 'rollback_failed' });
1486
+ assert.equal(r.stage, 'rollback_failed');
1487
+ assert.equal(r.terminal, true);
1488
+ assert.match(r.error, /rollback did not finish/);
1489
+ });
1490
+
1491
+ await t('an exit with NO outcome line (sudo refused before node ran) is unknown and claims no rollback', async () => {
1492
+ const d = upgradeThrows('sudo: unknown user bongos-test4\n')(makeDeps());
1493
+ const r = await R.coreUpgradeInstance(inst(), d, intent());
1494
+ assert.deepEqual(r.failure, { class: 'unknown', reason: 'unknown' });
1495
+ assert.doesNotMatch(r.error, /rolled back/);
1496
+ });
1497
+
1498
+ await t('the runner\'s own refusals are classified too', async () => {
1499
+ const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1081', available: ['1.19.1081', '1.20.1'] });
1500
+ const r = await R.coreUpgradeInstance(inst({ update_channel: 'patch' }), d, intent({ target_version: '1.20.1' }));
1501
+ assert.deepEqual(r.failure, { class: 'needs_decision', reason: 'outside_channel' });
1502
+ const n = await R.coreUpgradeInstance(inst(), makeDeps({ available: ['1.19.760'] }), intent({ target_version: '1.19.999' }));
1503
+ assert.deepEqual(n.failure, { class: 'transient', reason: 'not_published' });
1504
+ const s = await R.coreUpgradeInstance(inst({ hosting_shape: 'dedicated' }), makeDeps(), intent());
1505
+ assert.deepEqual(s.failure, { class: 'needs_decision', reason: 'shape_not_automated' });
1506
+ });
1507
+
1508
+ await t('upgrade.js ends a failed run with the outcome line', () => {
1509
+ const src = fs.readFileSync(new URL('../scripts/gds/upgrade.js', import.meta.url), 'utf8');
1510
+ assert.match(src, /if \(!res\.ok\) \{ process\.stderr\.write\([^\n]*outcomeLine\(res\)/);
1511
+ });
1512
+
1513
+ await t('the drain loop writes the class beside the sentence (behaviour: tests/provision.mjs)', () => {
1514
+ const src = fs.readFileSync(new URL('../scripts/gds/provision.js', import.meta.url), 'utf8');
1515
+ assert.match(src, /SET failure_class = \$2, failure_reason = \$3/);
1516
+ });
1517
+
1399
1518
  console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
1400
1519
  if (failed) process.exit(1);
@@ -2564,6 +2564,40 @@ await ta('F19: a NON-final failure re-pends the intent and writes nothing on the
2564
2564
  assert.equal(statusWrites.length, 0, 'no instance write until the intent actually retires');
2565
2565
  });
2566
2566
 
2567
+ // A failed core move stores WHAT KIND of failure beside its sentence (task 1004446).
2568
+ const classifiedRun = async (attempts) => {
2569
+ let claims = 0; const resolved = []; const classWrites = [];
2570
+ const deps = {
2571
+ apply: true, log: () => {},
2572
+ db: { query: async (sql, params) => { if (/SET failure_class/.test(sql)) classWrites.push(params); return { rows: [] }; } },
2573
+ provisioning: {
2574
+ claimNextIntent: async () => (claims++ === 0
2575
+ ? { id: 11, instance_id: 18, action: 'core-upgrade', target_version: '1.20.1', attempts } : null),
2576
+ // 'dedicated' is a shape the runner cannot move — a classified refusal, no exec needed.
2577
+ getInstanceById: async () => ({ ...activeStandalone, hosting_shape: 'dedicated' }),
2578
+ resolveIntent: async (_db, id, state) => { resolved.push({ id, state }); },
2579
+ setInstanceStatus: async () => {}, recordEvent: async () => {},
2580
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
2581
+ },
2582
+ exec: () => ({ ok: true }), writeFile: () => ({ ok: true }),
2583
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }), checkCaddyWiring: () => null,
2584
+ };
2585
+ await P.cmdRunIntents(deps);
2586
+ return { resolved, classWrites };
2587
+ };
2588
+
2589
+ await ta('a re-pended core move records its failure class and reason', async () => {
2590
+ const { resolved, classWrites } = await classifiedRun(1);
2591
+ assert.equal(resolved.length, 0);
2592
+ assert.deepEqual(classWrites, [[11, 'needs_decision', 'shape_not_automated']]);
2593
+ });
2594
+
2595
+ await ta('a RETIRED core move records its failure class and reason too', async () => {
2596
+ const { resolved, classWrites } = await classifiedRun(3); // = MAX_INTENT_ATTEMPTS: the last real attempt, retired after it runs
2597
+ assert.equal(resolved[0].state, 'error');
2598
+ assert.deepEqual(classWrites, [[11, 'needs_decision', 'shape_not_automated']]);
2599
+ });
2600
+
2567
2601
  console.log('\nreview-panel hardening (task 1002720 — the loopback/latch/birth-note seams):');
2568
2602
 
2569
2603
  await ta('a PASSING loopback probe on a DOMAIN-bearing instance records NO verdict — the address claim belongs to the sweep', async () => {