@bongos/core 1.20.31 → 1.20.33

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.
@@ -0,0 +1,191 @@
1
+ // tests/move_escalation.mjs — a core move nobody can heal files ONE blocker and ONE notice,
2
+ // and a later move that lands closes it (task 1004449, goal 1000090 deploy self-heal).
3
+ //
4
+ // WHAT IS AT RISK. Two opposite failures: a person never hears about a move that left a
5
+ // project down, or hears about it on every runner tick until they mute the channel. The
6
+ // blockers table here is a fake that enforces the real (source, source_ref) uniqueness, and
7
+ // the drain loop is the real one, so "one per failed move" is measured across retries and
8
+ // re-runs rather than assumed.
9
+ //
10
+ // Run: node tests/move_escalation.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+
15
+ process.env.PROVISION_APP_USER = process.env.PROVISION_APP_USER || 'lars'; // appUser() refuses a Windows account name
16
+ const require = createRequire(import.meta.url);
17
+ const E = require('../scripts/gds/move-escalation.js');
18
+ const P = require('../scripts/gds/provision.js');
19
+
20
+ let passed = 0;
21
+ let failed = 0;
22
+ const t = async (name, fn) => {
23
+ try { await fn(); passed += 1; console.log(` PASS ${name}`); }
24
+ catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
25
+ };
26
+
27
+ const inst = (over = {}) => ({ id: 7, slug: 'cloudbongos', db_name: 'cloudbongos', port: 3002, status: 'active', hosting_shape: 'control-plane', owner_builder_id: 1, ...over });
28
+ const unknownFail = { ok: false, error: 'core upgrade to 1.20.31 failed and was rolled back: health check failed after restart', failure: { class: 'unknown', reason: 'health' } };
29
+
30
+ // A blockers table with the real partial unique index on (source, source_ref).
31
+ function blockersTable() {
32
+ const rows = []; let next = 500;
33
+ const query = async (sql, params) => {
34
+ if (/INSERT INTO blockers/.test(sql)) {
35
+ const [title, body_md, source, source_ref] = params;
36
+ if (rows.some((r) => r.source === source && r.source_ref === source_ref)) return { rows: [] };
37
+ const r = { id: next++, title, body_md, source, source_ref, status: 'open' }; rows.push(r); return { rows: [{ id: r.id }] };
38
+ }
39
+ if (/UPDATE blockers/.test(sql)) {
40
+ const [source, like, note] = params; const prefix = like.replace(/%$/, '');
41
+ const hit = rows.filter((r) => r.status === 'open' && r.source === source && r.source_ref.startsWith(prefix));
42
+ for (const r of hit) { r.status = 'resolved'; r.resolution_note = note; }
43
+ return { rows: hit.map((r) => ({ id: r.id })) };
44
+ }
45
+ return { rows: [] };
46
+ };
47
+ return { rows, query };
48
+ }
49
+
50
+ console.log('\nwho needs a person (needsPerson):');
51
+
52
+ await t('unknown and rollback_failed escalate; every other class does not', () => {
53
+ const i = { action: 'core-upgrade' };
54
+ assert.equal(E.needsPerson({ intent: i, failure: { class: 'unknown', reason: 'health' } }), true);
55
+ assert.equal(E.needsPerson({ intent: i, failure: { class: 'unknown', reason: 'rollback_failed' }, terminal: true }), true);
56
+ assert.equal(E.needsPerson({ intent: i, failure: { class: 'unknown', reason: 'remedy_did_not_hold' } }), true, 'a fix that did not hold (task 1004448)');
57
+ for (const c of ['transient', 'known_wedge', 'needs_decision']) assert.equal(E.needsPerson({ intent: i, failure: { class: c, reason: 'x' } }), false, c);
58
+ assert.equal(E.needsPerson({ intent: { action: 'restart' }, failure: { class: 'unknown', reason: 'x' } }), false, 'core moves only');
59
+ });
60
+
61
+ console.log('\nwhat is said (blockerFor, noticeText):');
62
+
63
+ await t('the blocker names project, target, reason and what the move reported', () => {
64
+ const b = E.blockerFor({ inst: inst(), intent: { id: 11, target_version: '1.20.31' }, failure: unknownFail.failure, error: unknownFail.error });
65
+ assert.equal(b.source, 'provisioning');
66
+ assert.equal(b.sourceRef, 'core-move:7:11');
67
+ assert.match(b.title, /cloudbongos: the core move to 1\.20\.31 failed/);
68
+ assert.match(b.bodyMd, /`health`/);
69
+ assert.match(b.bodyMd, /health check failed after restart/);
70
+ assert.match(b.bodyMd, /closes by itself/);
71
+ });
72
+
73
+ await t('a failed rollback says the project may be down', () => {
74
+ const b = E.blockerFor({ inst: inst(), intent: { id: 11, target_version: '1.20.31' }, failure: { class: 'unknown', reason: 'rollback_failed' }, error: 'x' });
75
+ assert.match(b.title, /may be down/);
76
+ assert.match(E.noticeText({ kind: 'failed', name: 'x', down: true }), /may be down/);
77
+ });
78
+
79
+ await t('reported output cannot break out of its code block', () => {
80
+ const b = E.blockerFor({ inst: inst(), intent: { id: 1, target_version: '1' }, failure: unknownFail.failure, error: 'a```\n# injected' });
81
+ assert.equal((b.bodyMd.match(/```/g) || []).length, 2);
82
+ });
83
+
84
+ await t('Discord names only a PUBLIC project, and never carries the upgrade output', () => {
85
+ assert.match(E.noticeText({ kind: 'failed', name: null, to: '1.20.31' }), /^⚠️ A private project/);
86
+ assert.match(E.noticeText({ kind: 'failed', name: 'mercury', to: '1.20.31' }), /\*\*mercury\*\*/);
87
+ assert.doesNotMatch(E.noticeText({ kind: 'failed', name: 'm', to: '1' }), /health/);
88
+ });
89
+
90
+ console.log('\nonce per failed move, closed by success (afterRetire / afterSuccess):');
91
+
92
+ function deps(table, { visibility = 'private' } = {}) {
93
+ const posts = [];
94
+ return { posts, d: { apply: true, log: () => {}, db: { query: table.query }, postNotice: (text) => posts.push(text), provisioning: { effectiveSettings: () => ({ visibility }) } } };
95
+ }
96
+
97
+ await t('a retired unknown move files one blocker and one notice', async () => {
98
+ const table = blockersTable(); const { posts, d } = deps(table);
99
+ const r = await E.afterRetire({ intent: { id: 11, action: 'core-upgrade', target_version: '1.20.31' }, inst: inst(), result: unknownFail, deps: d });
100
+ assert.equal(r.filed, true);
101
+ assert.equal(table.rows.length, 1);
102
+ assert.equal(posts.length, 1);
103
+ });
104
+
105
+ await t('the same move retiring again files nothing and posts nothing', async () => {
106
+ const table = blockersTable(); const { posts, d } = deps(table);
107
+ const i = { id: 11, action: 'core-upgrade', target_version: '1.20.31' };
108
+ await E.afterRetire({ intent: i, inst: inst(), result: unknownFail, deps: d });
109
+ const again = await E.afterRetire({ intent: i, inst: inst(), result: unknownFail, deps: d });
110
+ assert.equal(again.filed, false);
111
+ assert.equal(table.rows.length, 1);
112
+ assert.equal(posts.length, 1, 'edge-triggered: the notice follows the insert');
113
+ });
114
+
115
+ await t('a later move that lands closes this project\'s blockers — and only this project\'s', async () => {
116
+ const table = blockersTable(); const { posts, d } = deps(table, { visibility: 'public' });
117
+ await E.afterRetire({ intent: { id: 11, action: 'core-upgrade', target_version: '1.20.31' }, inst: inst(), result: unknownFail, deps: d });
118
+ await E.afterRetire({ intent: { id: 12, action: 'core-upgrade', target_version: '1.20.31' }, inst: inst({ id: 70, slug: 'other' }), result: unknownFail, deps: d });
119
+ const r = await E.afterSuccess({ intent: { id: 13, action: 'core-upgrade', target_version: '1.20.32' }, inst: inst(), result: { ok: true, served: '1.20.32' }, deps: d });
120
+ assert.equal(r.closed, 1);
121
+ assert.deepEqual(table.rows.map((x) => x.status), ['resolved', 'open'], 'instance 70 is not instance 7 — a LIKE prefix must not over-match');
122
+ assert.match(table.rows[0].resolution_note, /later core move to 1\.20\.32 succeeded \(intent 13\)/);
123
+ assert.match(posts[posts.length - 1], /^✅ \*\*cloudbongos\*\* took its core update to 1\.20\.32/);
124
+ });
125
+
126
+ await t('a no-op or a failed result closes nothing; nothing open posts no all-clear', async () => {
127
+ const table = blockersTable(); const { posts, d } = deps(table);
128
+ await E.afterRetire({ intent: { id: 11, action: 'core-upgrade', target_version: '1' }, inst: inst(), result: unknownFail, deps: d });
129
+ assert.equal((await E.afterSuccess({ intent: { id: 13, action: 'core-upgrade' }, inst: inst(), result: { ok: true, noop: true }, deps: d })).closed, 0);
130
+ assert.equal((await E.afterSuccess({ intent: { id: 13, action: 'core-upgrade' }, inst: inst(), result: { ok: false }, deps: d })).closed, 0);
131
+ const empty = blockersTable(); const e2 = deps(empty);
132
+ await E.afterSuccess({ intent: { id: 13, action: 'core-upgrade' }, inst: inst(), result: { ok: true, served: '1' }, deps: e2.d });
133
+ assert.equal(e2.posts.length, 0);
134
+ });
135
+
136
+ await t('a database error never throws into the drain', async () => {
137
+ const { d } = deps({ query: async () => { throw new Error('boom'); } });
138
+ assert.deepEqual(await E.afterRetire({ intent: { id: 1, action: 'core-upgrade' }, inst: inst(), result: unknownFail, deps: d }), { filed: false });
139
+ assert.deepEqual(await E.afterSuccess({ intent: { id: 1, action: 'core-upgrade' }, inst: inst(), result: { ok: true, served: '1' }, deps: d }), { closed: 0 });
140
+ });
141
+
142
+ console.log('\nthe drain loop, end to end:');
143
+
144
+ // One move that fails `health` (rolled back, unknown), then — a later, separate move — lands.
145
+ async function drain({ table, row, upgradeFails }) {
146
+ const posts = []; const resolved = [];
147
+ const deps = {
148
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
149
+ postNotice: (text) => posts.push(text),
150
+ db: { query: async (sql, params) => {
151
+ if (/blockers/.test(sql)) return table.query(sql, params);
152
+ if (/SET state='pending'/.test(sql)) { row.state = 'pending'; row.not_before = new Date(Date.now() + params[params.length - 1]); }
153
+ return { rows: [] };
154
+ } },
155
+ provisioning: {
156
+ claimNextIntent: async () => { if (row.state !== 'pending' || (row.not_before && row.not_before > new Date())) return null; row.state = 'running'; row.attempts += 1; return { ...row }; },
157
+ getInstanceById: async () => inst(),
158
+ resolveIntent: async (_db, id, state) => { resolved.push(state); row.state = state; },
159
+ setInstanceStatus: async () => {}, recordEvent: async () => {}, clearErrorNote: async () => {},
160
+ effectiveSettings: () => ({ visibility: 'private' }),
161
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
162
+ },
163
+ exec: (cmd) => {
164
+ if (/upgrade\.js/.test(cmd) && upgradeFails) { const e = new Error('Command failed'); e.stderr = '✖ health check failed after restart\nBONGOS_UPGRADE_OUTCOME {"stage":"rolled_back","reason":"health"}\n'; throw e; }
165
+ return { ok: true, stdout: /\/version/.test(cmd) ? JSON.stringify({ coreVersion: upgradeFails ? '1.20.30' : row.target_version, schemaPending: 0 }) : '' };
166
+ },
167
+ writeFile: () => ({ ok: true }),
168
+ readInstalledCoreVersion: () => '1.20.30', selfInstanceRoot: () => '/srv/cb',
169
+ updateChannel: { DEFAULT_CHANNEL: 'patch', normalizeChannel: (c) => c || 'patch', channelAllows: () => true, listAvailableVersions: () => ({ ok: true, versions: ['1.20.30', '1.20.31', '1.20.32'] }) },
170
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }), checkCaddyWiring: () => null,
171
+ };
172
+ await P.cmdRunIntents(deps);
173
+ return { posts, resolved };
174
+ }
175
+
176
+ await t('a move that fails unknown files ONE blocker; a later move that lands closes it', async () => {
177
+ const table = blockersTable();
178
+ const first = await drain({ table, upgradeFails: true, row: { id: 11, instance_id: 7, action: 'core-upgrade', target_version: '1.20.31', attempts: 0, state: 'pending' } });
179
+ assert.deepEqual(first.resolved, ['error'], 'retired at once — unknown is not retried');
180
+ assert.equal(table.rows.length, 1);
181
+ assert.equal(table.rows[0].source_ref, 'core-move:7:11');
182
+ assert.equal(first.posts.length, 1);
183
+ const later = await drain({ table, upgradeFails: false, row: { id: 12, instance_id: 7, action: 'core-upgrade', target_version: '1.20.32', attempts: 0, state: 'pending' } });
184
+ assert.deepEqual(later.resolved, ['done']);
185
+ assert.equal(table.rows[0].status, 'resolved');
186
+ assert.equal(later.posts.length, 1);
187
+ assert.match(later.posts[0], /^✅ A private project took its core update to 1\.20\.32/);
188
+ });
189
+
190
+ console.log(`\nmove_escalation: ${passed} passed, ${failed} failed`);
191
+ if (failed) process.exit(1);
@@ -18,7 +18,7 @@ import * as shipLand from '../scripts/gds/ship-land.js';
18
18
 
19
19
  const require = createRequire(import.meta.url);
20
20
  const { JARGON_TERMS } = require('../modules/sessions/accessibility.js');
21
- const { formatClaimFailure } = require('../scripts/gds/claim.js');
21
+ const { formatClaimFailure, QUEUE_GATED_EXIT } = require('../scripts/gds/claim.js');
22
22
  const { formatGradeOutcome } = require('../scripts/gds/ship-grade-steps.js');
23
23
 
24
24
  let passed = 0;
@@ -130,7 +130,7 @@ test('a card interpolates nothing — it is static text, so it adds no injection
130
130
  test('a refused claim prints the engineer text first and the card after it', () => {
131
131
  const r = { status: 409, data: { error: { code: 'REBASE_REQUIRED', message: 'x', details: { rebaseTasks: [{ id: 7, reason: 'strand:pr_not_landed' }] } } } };
132
132
  const { lines, exit } = formatClaimFailure(r, 42);
133
- assert.equal(exit, 1);
133
+ assert.equal(exit, QUEUE_GATED_EXIT, 'the queue-wide gate keeps its own exit code (task 1004396)');
134
134
  assert.match(lines[0], /queue is gated/);
135
135
  const at = lines.findIndex((l) => l.includes(PLAIN_MARKER));
136
136
  assert.ok(at > 0, 'card follows the existing guidance');
@@ -0,0 +1,268 @@
1
+ // tests/wedge_remedy.mjs — a core move that failed on a KNOWN snag fixes the snag and tries
2
+ // once more, and never twice (task 1004448, goal 1000090 deploy self-heal).
3
+ //
4
+ // WHAT IS AT RISK. Each fix runs a command on a live project's box: a commit in the owner's
5
+ // checkout, a delete among database backups, a migration against a database. The cases pin
6
+ // WHAT each command is (and what it refuses to build), that it runs at most once per
7
+ // failure, and that a fix which did not hold reaches a person instead of looping. The
8
+ // disk-full prune is also run for real against a temp folder, because a `find` pattern is
9
+ // the kind of thing that reads right and deletes a sibling project's dumps.
10
+ //
11
+ // Run: node tests/wedge_remedy.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import { spawnSync } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ process.env.PROVISION_APP_USER = process.env.PROVISION_APP_USER || 'lars'; // appUser() refuses a Windows account name
21
+ const require = createRequire(import.meta.url);
22
+ const W = require('../scripts/gds/wedge-remedy.js');
23
+ const R = require('../scripts/gds/provision-core-upgrade.js');
24
+ const P = require('../scripts/gds/provision.js');
25
+ const { REASON_CLASS } = require('../scripts/gds/upgrade-outcome.js');
26
+ const { DEFAULT_RETRY_DELAY_MS } = require('../scripts/gds/intent-retry.js');
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+ const t = async (name, fn) => {
31
+ try { await fn(); passed += 1; console.log(` PASS ${name}`); }
32
+ catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
33
+ };
34
+
35
+ const inst = (over = {}) => ({ id: 7, slug: 'charter', db_name: 'charter', port: 3010, status: 'active', hosting_shape: 'co-tenant', owner_builder_id: 1, ...over });
36
+ const intent = (over = {}) => ({ id: 99, action: 'core-upgrade', target_version: '1.20.31', attempts: 1, remedied_reason: null, ...over });
37
+ const wedge = (reason) => ({ ok: false, error: `core upgrade to 1.20.31 was refused (${reason})`, failure: { class: 'known_wedge', reason } });
38
+ const selfRoot = () => '/srv/charter';
39
+
40
+ function harness({ execFails = false } = {}) {
41
+ const ran = []; const events = []; const queries = [];
42
+ const deps = {
43
+ apply: true, privileged: false, log: () => {}, selfInstanceRoot: selfRoot,
44
+ exec: (cmd, opts) => { ran.push({ cmd, ...opts }); return execFails ? { ok: false, softFailed: true, stderr: 'fatal: nope' } : { ok: true, stdout: '' }; },
45
+ db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
46
+ provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
47
+ migratePlan: () => ({ via: 'npm-script' }),
48
+ };
49
+ return { deps, ran, events, queries };
50
+ }
51
+
52
+ console.log('\nthe fix for each known snag (planRemedy):');
53
+
54
+ await t('every reason with a fix is a known_wedge in the classifier — and back', () => {
55
+ assert.deepEqual([...W.REMEDY_REASONS].sort(), ['dirty_tree', 'disk_full', 'schema_pending']);
56
+ for (const r of W.REMEDY_REASONS) assert.equal(REASON_CLASS[r], 'known_wedge', r);
57
+ for (const [r, c] of Object.entries(REASON_CLASS)) if (c === 'known_wedge') assert.ok(W.REMEDY_REASONS.includes(r), `${r} is known_wedge but has no fix — it would be retired with nothing done`);
58
+ assert.equal(REASON_CLASS.remedy_did_not_hold, 'unknown', 'a fix that did not hold reaches a person');
59
+ assert.equal(REASON_CLASS.remedy_failed, 'unknown');
60
+ });
61
+
62
+ await t('dirty tree: commits TRACKED changes inside the project, hooks off, as the runner', () => {
63
+ const p = W.planRemedy(inst(), 'dirty_tree', { selfRoot });
64
+ assert.match(p.cmd, /^git -C \/srv\/charter -c core\.hooksPath=\/dev\/null .* add -u && git -C \/srv\/charter .* commit -m /);
65
+ assert.doesNotMatch(p.cmd, /add -A|add \./, 'an untracked file is never swept into the owner\'s history on a guess');
66
+ assert.doesNotMatch(p.cmd, /sudo/, 'the runner owns the checkout (pushUpgradePin\'s shape)');
67
+ });
68
+
69
+ await t('a cloud-host project is fixed in ITS folder, not the control plane\'s', () => {
70
+ const p = W.planRemedy(inst({ hosting_shape: 'cloud-host' }), 'dirty_tree', { selfRoot });
71
+ assert.match(p.cmd, /git -C \/\S+\/charter /);
72
+ assert.doesNotMatch(p.cmd, /\/srv\/charter/);
73
+ });
74
+
75
+ await t('disk full: prunes only THIS database\'s dumps, keeping the newest week', () => {
76
+ const p = W.planRemedy(inst(), 'disk_full', { selfRoot, backupDir: '/var/backups/gds' });
77
+ assert.match(p.cmd, /^find \/var\/backups\/gds -maxdepth 1 -type f -name 'charter-\[0-9\]/);
78
+ assert.match(p.cmd, new RegExp(`head -n -${W.DUMP_KEEP_FLOOR} \\| xargs -r rm -f --$`));
79
+ assert.equal(W.DUMP_KEEP_FLOOR, 7);
80
+ });
81
+
82
+ await t('schema pending: migrates the project\'s OWN database, PGDATABASE always set', () => {
83
+ const plain = W.planRemedy(inst(), 'schema_pending', { selfRoot, migrate: () => ({ via: 'npm-script' }) });
84
+ assert.equal(plain.cmd, 'npm run migrate');
85
+ assert.equal(plain.cwd, '/srv/charter');
86
+ assert.deepEqual(plain.env, { PGDATABASE: 'charter' });
87
+ const adopted = W.planRemedy(inst(), 'schema_pending', { selfRoot, migrate: () => ({ via: 'core-script' }) });
88
+ assert.equal(adopted.cmd, 'bash node_modules/@bongos/core/scripts/migrate.sh');
89
+ assert.deepEqual(adopted.env, { PGDATABASE: 'charter', INIT_CWD: '/srv/charter' });
90
+ const priv = W.planRemedy(inst(), 'schema_pending', { selfRoot, privileged: true, runAs: 'bongos-charter', migrate: () => ({ via: 'npm-script' }) });
91
+ assert.equal(priv.cmd, 'sudo -u bongos-charter env PGDATABASE=charter npm run migrate', 'sudo resets env, so PGDATABASE rides the command');
92
+ assert.ok(W.planRemedy(inst(), 'schema_pending', { selfRoot, privileged: true, runAs: null }).refused);
93
+ });
94
+
95
+ await t('no command is built from a name that is not shell-safe', () => {
96
+ assert.ok(W.planRemedy(inst({ slug: 'a;rm -rf' }), 'dirty_tree', { selfRoot }).refused);
97
+ assert.ok(W.planRemedy(inst({ db_name: 'x; drop' }), 'disk_full', { selfRoot }).refused);
98
+ assert.ok(W.planRemedy(inst(), 'dirty_tree', { selfRoot: () => '/srv/a b' }).refused);
99
+ assert.ok(W.planRemedy(inst(), 'disk_full', { selfRoot, backupDir: '/tmp/$(x)' }).refused);
100
+ });
101
+
102
+ await t('a reason with no fix gets no command', () => {
103
+ for (const r of ['artist_gate', 'downgrade', 'health', 'not_published', 'nope']) assert.equal(W.planRemedy(inst(), r, { selfRoot }), null, r);
104
+ });
105
+
106
+ console.log('\nthe prune, run for real:');
107
+
108
+ const bash = spawnSync('bash', ['-c', 'echo ok'], { encoding: 'utf8' });
109
+ await t('keeps the newest 7 of this project\'s dumps and never touches a sibling\'s', () => {
110
+ if (bash.status !== 0) { console.log(' (skipped: no bash here)'); return; }
111
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wedge-prune-'));
112
+ try {
113
+ const mine = []; for (let d = 1; d <= 10; d++) mine.push(`charter-2026-09-${String(d).padStart(2, '0')}T030000Z.sql.gz`);
114
+ const others = ['charter-nk-2026-09-01T030000Z.sql.gz', 'test-2026-09-01T030000Z.sql.gz', 'charter-notes.txt'];
115
+ for (const f of [...mine, ...others]) fs.writeFileSync(path.join(dir, f), 'x');
116
+ const p = W.planRemedy(inst(), 'disk_full', { selfRoot, backupDir: '.' }); // run IN the folder: a temp path can hold a `~`
117
+ const r = spawnSync('bash', ['-c', p.cmd], { encoding: 'utf8', cwd: dir });
118
+ assert.equal(r.status, 0, r.stderr);
119
+ const left = fs.readdirSync(dir).sort();
120
+ assert.deepEqual(left, [...others, ...mine.slice(3)].sort());
121
+ // Nothing is deleted when the floor is not exceeded.
122
+ const again = spawnSync('bash', ['-c', p.cmd], { encoding: 'utf8', cwd: dir });
123
+ assert.equal(again.status, 0);
124
+ assert.equal(fs.readdirSync(dir).length, left.length);
125
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
126
+ });
127
+
128
+ console.log('\nonce per failure (remedyAfterFailure):');
129
+
130
+ for (const reason of W.REMEDY_REASONS) {
131
+ await t(`${reason}: runs the fix, records an event, re-pends the move with the reason stored`, async () => {
132
+ const h = harness();
133
+ const r = await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge(reason), deps: h.deps });
134
+ assert.deepEqual(r, { repended: true });
135
+ assert.equal(h.ran.length, 1);
136
+ assert.equal(h.events.length, 1);
137
+ assert.equal(h.events[0].event, W.REMEDY_EVENT);
138
+ assert.match(h.events[0].detail, /trying the move to 1\.20\.31 once more/);
139
+ const up = h.queries.find((q) => /SET state='pending'/.test(q.sql));
140
+ assert.ok(up, 're-pended');
141
+ assert.match(up.sql, /remedied_reason=\$3/);
142
+ assert.deepEqual(up.params, [99, wedge(reason).error, reason, 'known_wedge', reason, DEFAULT_RETRY_DELAY_MS]);
143
+ });
144
+ }
145
+
146
+ await t('the same reason again after a fix: no second fix — retired as unknown for a person', async () => {
147
+ const h = harness();
148
+ const r = await W.remedyAfterFailure({ intent: intent({ remedied_reason: 'dirty_tree' }), inst: inst(), result: wedge('dirty_tree'), deps: h.deps });
149
+ assert.equal(r.repended, false);
150
+ assert.deepEqual(r.failure, { class: 'unknown', reason: 'remedy_did_not_hold' });
151
+ assert.match(r.error, /already tried once/);
152
+ assert.equal(h.ran.length, 0, 'nothing ran');
153
+ assert.equal(h.queries.length, 0);
154
+ });
155
+
156
+ await t('a DIFFERENT known snag on the retry gets its own one fix', async () => {
157
+ const h = harness();
158
+ const r = await W.remedyAfterFailure({ intent: intent({ remedied_reason: 'dirty_tree' }), inst: inst(), result: wedge('disk_full'), deps: h.deps });
159
+ assert.equal(r.repended, true);
160
+ });
161
+
162
+ await t('a fix that fails is recorded and retired as unknown — never re-pended', async () => {
163
+ const h = harness({ execFails: true });
164
+ const r = await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge('dirty_tree'), deps: h.deps });
165
+ assert.equal(r.repended, false);
166
+ assert.deepEqual(r.failure, { class: 'unknown', reason: 'remedy_failed' });
167
+ assert.match(r.error, /did not work \(fatal: nope\)/);
168
+ assert.equal(h.events.length, 1);
169
+ assert.match(h.events[0].detail, /could not fix dirty_tree/);
170
+ assert.equal(h.queries.length, 0);
171
+ });
172
+
173
+ await t('leaves everything else alone: other classes, other actions, and a dry run', async () => {
174
+ for (const [i, res] of [[intent(), { ok: false, error: 'x', failure: { class: 'transient', reason: 'not_published' } }],
175
+ [intent(), { ok: false, error: 'x', failure: { class: 'needs_decision', reason: 'artist_gate' } }],
176
+ [intent(), { ok: false, error: 'x' }],
177
+ [intent({ action: 'restart' }), wedge('dirty_tree')]]) {
178
+ const h = harness();
179
+ const r = await W.remedyAfterFailure({ intent: i, inst: inst(), result: res, deps: h.deps });
180
+ assert.equal(r.repended, false);
181
+ assert.deepEqual(r.failure, res.failure);
182
+ assert.equal(h.ran.length, 0);
183
+ }
184
+ const h = harness(); h.deps.apply = false;
185
+ assert.equal((await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge('dirty_tree'), deps: h.deps })).repended, false);
186
+ assert.equal(h.ran.length, 0);
187
+ });
188
+
189
+ console.log('\nthe move refuses on a database behind its code:');
190
+
191
+ await t('schemaPending > 0 on the box refuses as schema_pending BEFORE the upgrade runs', async () => {
192
+ const ran = [];
193
+ const deps = {
194
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
195
+ exec: (cmd) => { ran.push(cmd); return { ok: true, stdout: /\/version/.test(cmd) ? JSON.stringify({ coreVersion: '1.20.30', schemaPending: 2 }) : '' }; },
196
+ db: { query: async () => ({ rows: [] }) }, provisioning: { recordEvent: async () => {} },
197
+ readInstalledCoreVersion: () => '1.20.30', selfInstanceRoot: () => '/srv/cb',
198
+ updateChannel: { DEFAULT_CHANNEL: 'patch', normalizeChannel: (c) => c || 'patch', channelAllows: () => true, listAvailableVersions: () => ({ ok: true, versions: ['1.20.30', '1.20.31'] }) },
199
+ };
200
+ const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos' }), deps, intent());
201
+ assert.equal(r.ok, false);
202
+ assert.deepEqual(r.failure, { class: 'known_wedge', reason: 'schema_pending' });
203
+ assert.match(r.error, /2 migration\(s\) behind/);
204
+ assert.ok(!ran.some((c) => /upgrade\.js/.test(c)), 'the pin never moved');
205
+ });
206
+
207
+ console.log('\nthe drain loop, end to end:');
208
+
209
+ // A stateful fake queue, the shape tests/provision.mjs uses: claimNextIntent honours
210
+ // not_before, so "fixed, then tried once more on a LATER tick" is measured.
211
+ async function drain({ row, reply }) {
212
+ const ran = []; const events = []; const resolved = []; let runs = 0;
213
+ const deps = {
214
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
215
+ db: { query: async (sql, params) => {
216
+ if (/SET state='pending'/.test(sql)) { row.state = 'pending'; row.not_before = new Date(Date.now() + params[params.length - 1]); if (/remedied_reason/.test(sql)) row.remedied_reason = params[2]; }
217
+ if (/SET failure_class/.test(sql)) { row.failure_class = params[1]; row.failure_reason = params[2]; }
218
+ return { rows: [] };
219
+ } },
220
+ provisioning: {
221
+ claimNextIntent: async () => {
222
+ if (row.state !== 'pending' || (row.not_before && row.not_before > new Date())) return null;
223
+ row.state = 'running'; row.attempts += 1; runs += 1; return { ...row };
224
+ },
225
+ getInstanceById: async () => inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', db_name: 'cloudbongos' }),
226
+ resolveIntent: async (_db, id, state, note) => { resolved.push({ id, state, note }); row.state = state; },
227
+ setInstanceStatus: async () => {}, recordEvent: async (_db, e) => { events.push(e); },
228
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
229
+ },
230
+ exec: (cmd, opts) => { ran.push(cmd); return reply(cmd, opts); },
231
+ writeFile: () => ({ ok: true }),
232
+ readInstalledCoreVersion: () => '1.20.30', selfInstanceRoot: () => '/srv/cb',
233
+ updateChannel: { DEFAULT_CHANNEL: 'patch', normalizeChannel: (c) => c || 'patch', channelAllows: () => true, listAvailableVersions: () => ({ ok: true, versions: ['1.20.30', '1.20.31'] }) },
234
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }), checkCaddyWiring: () => null,
235
+ };
236
+ await P.cmdRunIntents(deps);
237
+ return { ran, events, resolved, runs, row };
238
+ }
239
+ const dirtyUpgrade = (cmd) => {
240
+ if (/upgrade\.js/.test(cmd)) { const e = new Error('Command failed'); e.stderr = '✖ working tree not clean — commit/stash first\nBONGOS_UPGRADE_OUTCOME {"stage":"refused","reason":"dirty_tree"}\n'; throw e; }
241
+ return { ok: true, stdout: /\/version/.test(cmd) ? JSON.stringify({ coreVersion: '1.20.30', schemaPending: 0 }) : '' };
242
+ };
243
+
244
+ await t('a dirty tree is committed, the move waits for the next tick, and is NOT retired', async () => {
245
+ const row = { id: 11, instance_id: 7, action: 'core-upgrade', target_version: '1.20.31', attempts: 0, state: 'pending', not_before: null, remedied_reason: null };
246
+ const d = await drain({ row, reply: dirtyUpgrade });
247
+ assert.equal(d.runs, 1, 'the retry is not run in the same tick');
248
+ assert.equal(d.resolved.length, 0, 'not retired');
249
+ assert.equal(d.row.state, 'pending');
250
+ assert.equal(d.row.remedied_reason, 'dirty_tree');
251
+ assert.ok(d.ran.some((c) => /add -u && .* commit/.test(c)), 'the commit ran');
252
+ assert.ok(d.events.some((e) => e.event === W.REMEDY_EVENT));
253
+ });
254
+
255
+ await t('the retry fails dirty AGAIN: retired as unknown/remedy_did_not_hold, no second commit', async () => {
256
+ const row = { id: 11, instance_id: 7, action: 'core-upgrade', target_version: '1.20.31', attempts: 1, state: 'pending', not_before: null, remedied_reason: 'dirty_tree' };
257
+ const d = await drain({ row, reply: dirtyUpgrade });
258
+ assert.equal(d.runs, 1);
259
+ assert.equal(d.resolved.length, 1);
260
+ assert.equal(d.resolved[0].state, 'error');
261
+ assert.match(d.resolved[0].note, /already tried once/);
262
+ assert.equal(d.row.failure_class, 'unknown');
263
+ assert.equal(d.row.failure_reason, 'remedy_did_not_hold');
264
+ assert.ok(!d.ran.some((c) => /commit -m/.test(c)), 'no second fix');
265
+ });
266
+
267
+ console.log(`\nwedge_remedy: ${passed} passed, ${failed} failed`);
268
+ if (failed) process.exit(1);