@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.
- package/.bongos-core.json +47 -27
- 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 +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +49 -1
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/core-upgrade.js +75 -1
- package/modules/provisioning/migrations/provisioning_029_update_channel.sql +24 -0
- package/modules/provisioning/migrations/provisioning_030_intent_failure_class.sql +28 -0
- package/modules/provisioning/routes/core-upgrade.js +21 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/provision-core-upgrade.js +40 -20
- package/scripts/gds/provision.js +11 -0
- package/scripts/gds/upgrade-outcome.js +155 -0
- package/scripts/gds/upgrade.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +89 -1
- package/tests/core_upgrade_runner.mjs +120 -1
- package/tests/provision.mjs +34 -0
- package/tests/upgrade_outcome.mjs +129 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
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
|
|
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
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
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
|
|
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
|
|
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 };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -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
|
+
};
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -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.
|
|
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);
|
package/tests/provision.mjs
CHANGED
|
@@ -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 () => {
|