@bongos/core 1.19.923 → 1.19.925

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.
@@ -50,6 +50,19 @@
50
50
  // instance_id -> 'preview' | 'confirm'. Held per box so opening the confirm on one does
51
51
  // not arm it on another.
52
52
  const steps = new Map();
53
+ // Rows whose dry run is in flight — per box, like `steps`, because a page-wide flag
54
+ // would spin every other project's row on one project's check.
55
+ const checking = new Set();
56
+ // How long the page waits for the runner to answer. Read off `window` when present so
57
+ // a harness can drive the loop without sleeping through it — a test that injects the
58
+ // real timer spends 30s of wall-clock per non-converging case and keeps repainting a
59
+ // sandbox that has already been torn down.
60
+ // `|| 2000` would be a bug here: an override of 0 — the one a harness wants — is
61
+ // falsy, so the fallback would swallow it and the test would sleep the full interval
62
+ // anyway while appearing to be driven.
63
+ const num = (v, dflt) => (Number.isFinite(v) ? v : dflt);
64
+ const POLL_MS = typeof window !== 'undefined' ? num(window.__deployPollMs, 2000) : 2000;
65
+ const POLL_TRIES = typeof window !== 'undefined' ? num(window.__deployPollTries, 15) : 15;
53
66
 
54
67
  async function fetchJson(path) {
55
68
  const res = await api.request('GET', `${API}${path}`);
@@ -117,10 +130,94 @@
117
130
  // to that button closes over this same preview object — so the owner confirms the thing
118
131
  // they were shown rather than "the newest", which is a different act by the time it is
119
132
  // resolved.
133
+ // What the dry run said, from the runner's own tick (task 1004121). Before this, step
134
+ // one made NO network call — it redrew a fixed sentence and called that a preview. The
135
+ // owner pressed it four times on 2026-09-20, restarted four live boxes, and learned
136
+ // three different pre-flight refusals one production restart at a time. Every one of
137
+ // them was in this verdict already.
138
+ //
139
+ // A REFUSAL DOES NOT DISABLE THE BUTTON. The runner refuses on its own, and this
140
+ // reading can be minutes old; a control that blocked on a stale ✖ would be lying in
141
+ // the other direction. Say what will happen and let the owner decide.
142
+ function preflightHtml(p) {
143
+ const pf = p.preflight;
144
+ if (checking.has(String(p.instance_id)) && !(pf && pf.target === p.recommended_version)) {
145
+ return `<p class="ov-row__sub dp-preflight dp-preflight--checking">Checking what this would do…</p>`;
146
+ }
147
+ // No verdict is its own state, and it is NOT "fine". A silent gap here is how the
148
+ // page used to promise a preview it never performed.
149
+ if (!pf) {
150
+ return `<p class="ov-row__sub dp-preflight dp-preflight--none">Not checked yet — the runner takes this reading on its own tick.</p>`;
151
+ }
152
+ const when = pf && p.preflight_at ? ` <span class="dp-preflight__age">(checked ${escapeHtml(ago(p.preflight_at))})</span>` : '';
153
+ if (pf.ok) {
154
+ const lines = (pf.lines || []).map((l) => `<li>${escapeHtml(String(l))}</li>`).join('');
155
+ return `<div class="dp-preflight dp-preflight--ok">
156
+ <p class="ov-row__sub">Checked against ${escapeHtml(String(pf.target))} — nothing blocks this move.${when}</p>
157
+ ${lines ? `<ul class="dp-preflight__list">${lines}</ul>` : ''}
158
+ </div>`;
159
+ }
160
+ return `<div class="dp-preflight dp-preflight--blocked">
161
+ <p class="ov-row__sub"><strong>This would fail.</strong> The upgrade checked ${escapeHtml(String(pf.target))} and refused:${when}</p>
162
+ <pre class="dp-preflight__error">${escapeHtml(String(pf.error || 'no reason given'))}</pre>
163
+ <p class="ov-row__sub">You can still press Move — the runner will refuse it the same way, and the box is left where it is.</p>
164
+ </div>`;
165
+ }
166
+
167
+ // Arming ASKS for the check (task 1004121). The web tier has no shell — it queues the
168
+ // dry run as an intent and the runner answers, which is the same division of labour as
169
+ // every other fact on this page. Then it re-reads the preview until the verdict lands.
170
+ //
171
+ // Only when there isn't already a fresh one: a verdict for THIS target is the answer,
172
+ // and re-asking would spend a subprocess to reprint it.
173
+ const asked = new Set();
174
+ async function askPreflight(p) {
175
+ const id = String(p.instance_id);
176
+ if (p.preflight && p.preflight.target === p.recommended_version) return;
177
+ if (asked.has(id)) return;
178
+ asked.add(id);
179
+ checking.add(id);
180
+ repaint();
181
+ try {
182
+ await sendJson('POST', `/provisioning/instances/${id}/core-upgrade`, {
183
+ to: p.recommended_version, dry_run: true,
184
+ });
185
+ } catch (e) {
186
+ if (e && e.kind === '401') return;
187
+ // A refused REQUEST is not a refused upgrade, and must not be painted as one:
188
+ // fall back to "not checked", which is honest about what is known.
189
+ checking.delete(id); asked.delete(id); repaint();
190
+ return;
191
+ }
192
+ // The runner drains on its own tick, so this waits rather than assuming. Bounded:
193
+ // a check that never lands leaves the row saying it was never checked, which is
194
+ // true, instead of spinning for ever.
195
+ //
196
+ // AND IT STOPS WHEN NOBODY IS LOOKING. The loop is started from a click and not
197
+ // awaited by it, so without this it outlives whatever closed the row — cancelling,
198
+ // confirming, or a reload — and goes on repainting a step the owner has left. It
199
+ // also made every test that never converges sit through the full ~30s in real time.
200
+ for (let i = 0; i < POLL_TRIES; i++) {
201
+ await new Promise((r) => setTimeout(r, POLL_MS));
202
+ if (steps.get(p.instance_id) !== 'confirm') break; // the row was closed; stop.
203
+ try {
204
+ await refreshOne(p, { keepStep: true });
205
+ const now = (view.self && String(view.self.instance_id) === id)
206
+ ? view.self
207
+ : view.boxes.find((b) => String(b.instance_id) === id);
208
+ if (now && now.preflight && now.preflight.target === now.recommended_version) break;
209
+ } catch (e) { break; }
210
+ }
211
+ checking.delete(id);
212
+ asked.delete(id);
213
+ repaint();
214
+ }
215
+
120
216
  function stepTwoHtml(p, isSelf) {
121
217
  const id = escapeHtml(String(p.instance_id));
122
218
  return `
123
219
  <div class="ps-controls">
220
+ ${preflightHtml(p)}
124
221
  <p class="ov-row__sub">This restarts ${isSelf ? 'the platform' : escapeHtml(String(p.slug))} onto core
125
222
  <strong>${escapeHtml(String(p.recommended_version))}</strong>. It waits for the box to answer and
126
223
  puts itself back on ${escapeHtml(String(p.served_version))} if it does not.</p>
@@ -166,7 +263,7 @@
166
263
  function wire(p) {
167
264
  const id = String(p.instance_id);
168
265
  const preview = $(`dp-preview-${id}`);
169
- if (preview) preview.addEventListener('click', () => { steps.set(p.instance_id, 'confirm'); repaint(); });
266
+ if (preview) preview.addEventListener('click', () => { steps.set(p.instance_id, 'confirm'); repaint(); askPreflight(p); });
170
267
  const cancel = $(`dp-cancel-${id}`);
171
268
  if (cancel) cancel.addEventListener('click', () => { steps.set(p.instance_id, 'preview'); repaint(); });
172
269
  const confirm = $(`dp-confirm-${id}`);
@@ -223,14 +320,17 @@
223
320
  // far wider than the change — it also re-probes boxes the owner never touched. A
224
321
  // failure here falls back to the full read, because a stale row on a deploy page is
225
322
  // the one thing worse than an extra request.
226
- async function refreshOne(p) {
323
+ // `keepStep` is for the pre-flight poll (task 1004121): a refresh AFTER a move should
324
+ // return the row to step one, but a refresh DURING the wait must not — it would close
325
+ // the panel the owner is reading, under them, every two seconds.
326
+ async function refreshOne(p, { keepStep = false } = {}) {
227
327
  const id = String(p.instance_id);
228
328
  const isSelf = view.self && String(view.self.instance_id) === id;
229
329
  try {
230
330
  const d = await fetchJson(isSelf ? '/provisioning/control-plane' : `/provisioning/instances/${id}/core-upgrade`);
231
331
  if (isSelf) view.self = d.preview;
232
332
  else view.boxes = view.boxes.map((b) => (String(b.instance_id) === id ? d.preview : b));
233
- steps.set(p.instance_id, 'preview');
333
+ if (!keepStep) steps.set(p.instance_id, 'preview');
234
334
  repaint();
235
335
  } catch (e) {
236
336
  if (e && e.kind === '401') return;
@@ -88,3 +88,42 @@
88
88
  color: var(--ink-soft);
89
89
  }
90
90
  .ps-sibling strong { color: inherit; font-weight: 600; }
91
+
92
+ /* The deploy control's pre-flight verdict (task 1004121) — what the upgrade says it
93
+ would do, taken by the runner's dry run. Three states, and "not checked" is one of
94
+ them: the page used to show a confident sentence where it had performed no check at
95
+ all, so silence here must look like silence, not like a pass. */
96
+ .dp-preflight { margin: 0 0 0.6rem; }
97
+ .dp-preflight--none,
98
+ .dp-preflight--checking { color: var(--ink-soft); font-style: italic; }
99
+ /* The pass state earns a mark of its own. Without one, "nothing blocks this" and "not
100
+ checked" differ only in wording, and the three-state legibility this file claims is
101
+ two states and a sentence. */
102
+ .dp-preflight--ok {
103
+ border-left: 3px solid var(--ok);
104
+ padding-left: 0.7rem;
105
+ }
106
+ .dp-preflight__age { color: var(--ink-soft); font-weight: normal; }
107
+ .dp-preflight__list {
108
+ margin: 0.25rem 0 0;
109
+ padding-left: 1.1rem;
110
+ color: var(--ink-soft);
111
+ font-size: 0.86rem;
112
+ line-height: 1.5;
113
+ }
114
+ .dp-preflight--blocked {
115
+ border-left: 3px solid var(--danger);
116
+ padding-left: 0.7rem;
117
+ }
118
+ /* The refusal is upgrade.js's own words, multi-line and already indented to name the
119
+ fix — preserved rather than reflowed, because the wording IS the instruction. */
120
+ .dp-preflight__error {
121
+ margin: 0.35rem 0;
122
+ padding: 0.5rem 0.6rem;
123
+ background: var(--surface-2);
124
+ border-radius: 6px;
125
+ font-size: 0.82rem;
126
+ line-height: 1.45;
127
+ white-space: pre-wrap;
128
+ overflow-x: auto;
129
+ }
@@ -124,8 +124,8 @@ function coreUpgradeFault(inst, to, live = null) {
124
124
  * is. So a second request is a conflict the route names, including when the open
125
125
  * intent is itself a core-upgrade.
126
126
  */
127
- async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy) {
128
- const { intent, created } = await provisioning.enqueueIntent(db, instanceId, 'core-upgrade', requestedBy, to);
127
+ async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy, action = 'core-upgrade') {
128
+ const { intent, created } = await provisioning.enqueueIntent(db, instanceId, action, requestedBy, to);
129
129
  if (created) return { intent, created: true };
130
130
  return { intent, created: false, conflict: 'open_intent' };
131
131
  }
@@ -172,7 +172,13 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
172
172
  // core_upgrades ledger (ADR 0293 D5): a privileged action with an
173
173
  // unattributed row is not acceptable here.
174
174
  const actor = `api:archon:${req.builder.id}`;
175
- const r = await enqueueCoreUpgrade(provisioning, pool, id, to, actor);
175
+ // `dry_run: true` asks what the move WOULD do and moves nothing (task 1004121).
176
+ // Queued the same way as the move, through the same single-open-intent slot, so a
177
+ // preview cannot be requested on top of a move that is already running — and, just
178
+ // as deliberately, a preview occupies that slot only for as long as it takes.
179
+ const dryRun = req.body && req.body.dry_run === true;
180
+ const action = dryRun ? 'core-upgrade-preflight' : 'core-upgrade';
181
+ const r = await enqueueCoreUpgrade(provisioning, pool, id, to, actor, action);
176
182
  if (r.conflict) {
177
183
  return res.fail('intent_conflict', {
178
184
  status: 409,
@@ -181,9 +187,15 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
181
187
  });
182
188
  }
183
189
  await provisioning.recordEvent(pool, {
184
- instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'core-upgrade',
185
- detail: `core pin move to ${to} requested`, actor,
190
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: action,
191
+ detail: dryRun ? `pre-flight check for ${to} requested` : `core pin move to ${to} requested`, actor,
186
192
  }).catch(() => {});
193
+ if (dryRun) {
194
+ return res.json({
195
+ ok: true, queued: true, action, target_version: to,
196
+ message: `Checking what moving to ${to} would do. Nothing is changed by this — the answer appears here when the check finishes.`,
197
+ });
198
+ }
187
199
  // No duration is promised and no success is claimed: this says the request
188
200
  // was RECORDED, which is the only thing true at this moment (ADR 0190 §7).
189
201
  return res.json({
@@ -213,6 +225,7 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
213
225
  */
214
226
  function previewFromSnapshot(inst, snap, openIntent) {
215
227
  const served = (snap && snap.served_version) || null;
228
+ const pf = (snap && snap.preflight) || null;
216
229
  const available = (snap && Array.isArray(snap.available) ? snap.available : []);
217
230
  const recommended = (snap && snap.recommended) || null;
218
231
  // Only what a move could actually go TO: newer than what is running, and admitted by
@@ -230,6 +243,12 @@ function previewFromSnapshot(inst, snap, openIntent) {
230
243
  // Named so the UI cannot accidentally render "up to date" over a missing reading.
231
244
  reading: served ? 'ok' : 'no_answer',
232
245
  observed_at: (snap && snap.observed_at) || null,
246
+ // What the upgrade SAYS it would do, from the runner's dry run (task 1004121).
247
+ // Withheld unless it was taken against the move now on offer: a verdict about
248
+ // 1.19.920 tells the owner nothing true about pressing a button that says 1.19.921,
249
+ // and a stale pass is worse than no answer because it reads as a guarantee.
250
+ preflight: pf && recommended && pf.target === recommended ? pf : null,
251
+ preflight_at: pf && recommended && pf.target === recommended ? (snap.preflight_at || null) : null,
233
252
  open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null } : null,
234
253
  };
235
254
  }
@@ -0,0 +1,53 @@
1
+ -- provisioning_020_core_upgrade_preflight.sql — what the upgrade would say if you pressed it
2
+ -- (task 1004121, ADR 0293 D4).
3
+ --
4
+ -- WHY THIS EXISTS. Step one of the deploy control's two-step was labelled "see what would
5
+ -- change" and made no network call at all: it flipped a local flag and redrew one fixed
6
+ -- sentence naming the two versions. Nothing was computed and no precondition was checked.
7
+ --
8
+ -- What that cost, on 2026-09-20: the owner pressed Upgrade four times across four
9
+ -- instances. Every press restarted a live box, failed in pre-flight and rolled back, and
10
+ -- the four failures were three DIFFERENT causes — a root Postgres role, an unresolvable
11
+ -- database name (both task 1004119) and an unclean working tree (task 1004122). Each one
12
+ -- is a pre-flight check, and `upgrade.js --dry-run` runs the whole pre-flight before it
13
+ -- returns. Every one of them was reportable without restarting anything.
14
+ --
15
+ -- So the runner now performs that dry run on its own tick, beside the version probe it
16
+ -- already takes, and caches the verdict here. Same division of labour as the columns
17
+ -- next door (provisioning_019): facts the web tier may not go and look for itself, taken
18
+ -- by the process that has a shell and read by the process that does not.
19
+ --
20
+ -- KEYED BY THE TARGET IT WAS RUN AGAINST. A verdict is only about one move. `target` is
21
+ -- stored with it so the control can tell a live answer from one taken against a version
22
+ -- that has since been superseded, rather than showing a stale pass over a new release.
23
+
24
+ BEGIN;
25
+
26
+ ALTER TABLE provisioning_core_version_snapshot
27
+ -- { target, ok, lines[], error } as the dry run reported it, or NULL when there was
28
+ -- nothing to preview (the box is current, so no move exists to check).
29
+ ADD COLUMN IF NOT EXISTS preflight jsonb,
30
+ -- When the dry run ran, which is NOT observed_at: the version probe is taken every
31
+ -- tick and this is only re-taken when there is a move to check. A control that
32
+ -- rendered one timestamp for both would age-stamp the verdict with the probe's clock.
33
+ ADD COLUMN IF NOT EXISTS preflight_at timestamptz;
34
+
35
+ -- The intent vocabulary gains the ASKING action. The JS list (INTENT_ACTIONS) and this
36
+ -- CHECK are two spellings of one vocabulary, and a widening that lands in only one of
37
+ -- them fails at insert time, in production, on the first real use — which is exactly what
38
+ -- tests/core_upgrade_door.mjs asserts against, and what it caught here.
39
+ --
40
+ -- The target_version CHECK from provisioning_018 is re-stated, not just left alone: it
41
+ -- read `(action = 'core-upgrade') = (target_version IS NOT NULL)`, which would REFUSE a
42
+ -- preflight intent for naming the version it is about to check.
43
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
44
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
45
+ CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
46
+ 'settings-apply', 'restart', 'disconnect', 'core-upgrade',
47
+ 'core-upgrade-preflight'));
48
+
49
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_target_version_chk;
50
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_target_version_chk
51
+ CHECK ((action IN ('core-upgrade', 'core-upgrade-preflight')) = (target_version IS NOT NULL));
52
+
53
+ COMMIT;
@@ -61,7 +61,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
61
61
  // 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
62
62
  // 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
63
63
  // deploy door. The web tier only QUEUES it; the control-plane runner performs it.
64
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade'];
64
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight'];
65
65
  // Every state a teardown can be asked for — i.e. everything except the one
66
66
  // state where there is provably nothing left to release. DERIVED from
67
67
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.923",
3
+ "version": "1.19.925",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.923",
9
+ "version": "1.19.925",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.923",
3
+ "version": "1.19.925",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -30,7 +30,7 @@
30
30
 
31
31
  const path = require('path');
32
32
  const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
33
- const { APP_USER, dbName, instanceUser, standaloneRoot } = require('./provision-repo.js');
33
+ const { APP_USER, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
34
34
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
35
35
 
36
36
  // The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
@@ -96,7 +96,19 @@ function targetFault(to, available, installed, channel, channelAllows) {
96
96
  if (!available.includes(to)) {
97
97
  return `core ${to} is not published yet — nothing was changed. A release tag appears several minutes before the package itself does; try again once it lands.`;
98
98
  }
99
- if (installed && channelAllows && !channelAllows(installed, to, channel)) {
99
+ // AN UNREADABLE `installed` IS A FAULT, NOT A FREE PASS (task 1004123).
100
+ //
101
+ // This guard used to be written `if (installed && …)`, so a null installed version
102
+ // skipped the channel check entirely rather than failing it. That is backwards: not
103
+ // knowing where an instance is today is the one state in which "is this jump
104
+ // permitted" cannot be answered, and the channel exists precisely to refuse jumps
105
+ // nobody has looked at. It also hid the defect that motivated this: the control
106
+ // plane read its installed version from the wrong directory for weeks and got null
107
+ // every time, and every press sailed past this line.
108
+ if (!installed) {
109
+ return `could not read the core version installed at this instance — nothing was changed. An upgrade that cannot say where it is starting from cannot be bounded by the '${channel}' channel.`;
110
+ }
111
+ if (channelAllows && !channelAllows(installed, to, channel)) {
100
112
  return `core ${to} is outside this project's '${channel}' update channel — nothing was changed. Change the channel first if this is deliberate.`;
101
113
  }
102
114
  return null;
@@ -156,7 +168,8 @@ async function coreUpgradeInstance(inst, deps, intent) {
156
168
 
157
169
  // ---- 2. is the target real, and permitted -----------------------------------------
158
170
  const channelMod = deps.updateChannel || require('./update-channel.js');
159
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
171
+ const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
172
+ const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
160
173
  const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
161
174
  const installed = readInstalled(instanceDir);
162
175
  // THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
@@ -184,73 +197,24 @@ async function coreUpgradeInstance(inst, deps, intent) {
184
197
  // the two attribution flags (ADR 0293 D5). --commit-pin is NOT optional here: without
185
198
  // it the pin lives only in the working tree, and the next pull-deploy resets it away —
186
199
  // the silent thirteen-release revert upgrade.js's own header records.
187
- const healthUrl = `http://127.0.0.1:${inst.port}/healthz`;
188
200
  const versionUrl = `http://127.0.0.1:${inst.port}/version`;
189
- // WHO THE UPGRADE RUNS AS, AND WHICH DATABASE IT NAMES (task 1004119).
190
- //
191
- // A bare `sudo` runs as ROOT, and both halves of the upgrade resolve against the
192
- // CURRENT OS USER rather than a flag:
193
- //
194
- // • migrate.sh peer-auths over the local socket as whoever invoked it, and `root`
195
- // is almost never a Postgres role — pre-flight refuses with "Postgres has no
196
- // role root" before the pin moves (correctly; the pin must not move for a
197
- // migrate that cannot run).
198
- // • with no PGDATABASE, resolveInstanceDb() has to FIND the database by loading
199
- // node_modules/@bongos/core/src/bongos/pool from the instance root. An adopted
200
- // repo that installs its workspaces rather than its root has no core there, so
201
- // the name cannot be resolved at all.
202
- //
203
- // Both were live on 2026-09-20: four presses of the owner's Upgrade button, four
204
- // pre-flight refusals, no instance ever moved. The helpers to fix it already sat in
205
- // provision-repo.js — this leg simply never used them.
206
- //
207
- // The account is the instance's own (`bongos-<slug>`), which is ALSO the name of its
208
- // Postgres login role (instanceDbRole), so peer auth lands on a role that owns
209
- // exactly that instance's database. The control plane is the exception: it is not a
210
- // provisioned tenant and has no `bongos-` account — it runs as APP_USER, the operator
211
- // the recipe's gate-3 invocation names.
212
- const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
213
- const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
214
- // WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES (task 1004053).
215
- //
216
- // `scripts/gds/upgrade.js` ships in the CORE checkout. A provisioned instance root holds
217
- // no scripts/ directory at all — its core sits under node_modules/@bongos/core — so
218
- // running the CLI with cwd = the instance root resolved nothing, and EVERY standalone
219
- // upgrade died before it began:
220
- //
221
- // Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
222
- //
223
- // It worked only where instanceDir HAPPENED to be this checkout — the control-plane self
224
- // row and a co-tenant — which is why it survived to production: the shapes that are
225
- // upgraded by hand are the shapes the relative path fits. Every ACTIVE tenant is
226
- // standalone, so the deploy door could not move a single one of them.
227
- //
228
- // upgrade.js's own header states the contract this restores: "Runs from the consumer
229
- // instance root (default) or `--instance <dir>`. This script ships IN the core … against
230
- // `--instance <consumer>`." So: run it where it exists, and NAME the target.
231
- const runDir = REPO_ROOT;
232
- const targetArg = instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`;
233
- const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
234
201
  const requestedBy = intent.requested_by && /^api:archon:(\d+)$/.test(intent.requested_by)
235
202
  ? intent.requested_by.replace(/^api:archon:/, '')
236
203
  : '';
237
- const cmd = [
238
- `${sudoP}node ${cli}`,
239
- '--registry',
240
- `--to ${to}`,
241
- targetArg,
242
- `--service ${inst.slug}`,
243
- `--health-url ${healthUrl}`,
244
- `--version-url ${versionUrl}`,
245
- '--commit-pin',
246
- requestedBy ? `--requested-by ${requestedBy}` : '',
247
- requestedBy ? '--upgrade-source owner-control' : '',
248
- ].filter(Boolean).join(' ');
204
+ // Built by the SAME function the preview uses, so "what you were shown" and "what runs"
205
+ // cannot drift apart into two hand-maintained flag lists (task 1004121).
206
+ const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy, selfRoot });
249
207
 
250
208
  if (!apply) {
251
- log(` ✓ would run (in ${runDir}): ${cmd} (dry-run — nothing ran)`);
209
+ // A --dry-run runner tick used to only PRINT this command. It now runs the CLI's own
210
+ // dry run, so `provision.js --dry-run` reports what the upgrade would actually refuse
211
+ // rather than a command string the reader has to evaluate in their head.
212
+ log(` ✓ would run (in ${runDir}): ${cmd}`);
213
+ const verdict = (deps.preflightUpgrade || preflightUpgrade)(inst, to, deps);
214
+ for (const line of verdict.lines || []) log(` ✓ pre-flight: ${line}`);
215
+ if (!verdict.ok) log(` ✖ ${verdict.error}`);
252
216
  log(` ✓ would then read ${versionUrl} on the box and require it to say ${to}`);
253
- return { ok: true, served: null };
217
+ return { ok: true, served: null, preflight: verdict };
254
218
  }
255
219
  try {
256
220
  log(` [upgrade] (in ${runDir}) ${cmd}`);
@@ -290,6 +254,116 @@ async function coreUpgradeInstance(inst, deps, intent) {
290
254
  return { ok: true, served, confirmed: served === to };
291
255
  }
292
256
 
257
+ // The pre-flight lines a dry run emits, and the refusal when it has one. upgrade.js writes
258
+ // its pre-flight to stdout as ` ✓ pre-flight: …` and returns a `✖`-prefixed multi-line
259
+ // error on a refusal, so BOTH streams matter: a pass is only legible from stdout, and a
260
+ // refusal only from the failure text. PURE given its input.
261
+ function parsePreflight(out) {
262
+ const text = String(out || '');
263
+ const lines = text.split('\n').map((l) => l.trim());
264
+ const checks = lines.filter((l) => /^[✓✖]\s*pre-flight:/.test(l)).map((l) => l.replace(/^[✓✖]\s*/, ''));
265
+ // A refusal is whatever upgrade.js said after the ✖ — reported verbatim rather than
266
+ // re-worded, because its wording already names the fix ("Set PGDATABASE, or …").
267
+ const failAt = text.indexOf('✖');
268
+ const error = failAt >= 0 ? text.slice(failAt + 1).trim() : null;
269
+ return { checks, error };
270
+ }
271
+
272
+ // ONE BUILDER FOR BOTH LEGS (task 1004121).
273
+ //
274
+ // The preview and the move must be the same command or the preview is worthless — and
275
+ // two hand-copied builders are exactly how they stop being the same. The mode decides
276
+ // only the TAIL: a dry run stops after pre-flight, so the flags that describe what to do
277
+ // AFTER the pin moves (restart, health, version, pin-commit, attribution) would be
278
+ // inert on it. Everything that decides whether the upgrade is ALLOWED — the account, the
279
+ // database, the target, the instance — is shared, which is the half a preview is about.
280
+ //
281
+ // The slug rides into a shell command through instanceUser()/dbName(), so it is checked
282
+ // HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
283
+ // a call site whose safety depends on a helper's internals is one refactor away from a
284
+ // shell-injection path (and the apply leg's own guard sits at the route, not here).
285
+ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', selfRoot = selfInstanceRoot } = {}) {
286
+ if (!isValidSlug(inst && inst.slug)) {
287
+ throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
288
+ }
289
+ // WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES, TWICE OVER.
290
+ //
291
+ // `upgrade.js` ships in the CORE, and a provisioned instance root holds no scripts/
292
+ // directory at all — so the CLI runs from REPO_ROOT and NAMES its target (task
293
+ // 1004053, which fixed every standalone tenant).
294
+ //
295
+ // The second half is that REPO_ROOT is not the instance either (task 1004123). It
296
+ // names the copy of the core that is EXECUTING, which coincides with the instance
297
+ // only while the core is a checkout run in place. Once the control plane's runner
298
+ // moved into its vendored core the two diverged silently — REPO_ROOT still resolved,
299
+ // just to node_modules/@bongos/core — and the door aimed at the package instead of
300
+ // the project. So ask, rather than assume; in the legacy shape the answer is
301
+ // REPO_ROOT and the command below is byte-identical to what it always was.
302
+ const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
303
+ // The control plane is not a provisioned tenant and has no `bongos-` account; every
304
+ // other shape runs as its own, which is also its Postgres login role (task 1004119).
305
+ const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
306
+ const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
307
+ const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
308
+ const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
309
+ instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`]; // REPO_ROOT is where the CLI runs (runDir below)
310
+ const tail = dryRun
311
+ ? ['--dry-run']
312
+ : [`--service ${inst.slug}`,
313
+ `--health-url http://127.0.0.1:${inst.port}/healthz`,
314
+ `--version-url http://127.0.0.1:${inst.port}/version`,
315
+ '--commit-pin',
316
+ requestedBy ? `--requested-by ${requestedBy}` : '',
317
+ requestedBy ? '--upgrade-source owner-control' : ''];
318
+ return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs };
319
+ }
320
+
321
+ // Ask the upgrade CLI what it WOULD do, changing nothing. `allowFail` because a refusal
322
+ // is the interesting answer, not an exception: pre-flight refusing is what this is for.
323
+ function preflightUpgrade(inst, to, deps) {
324
+ const { exec, privileged } = deps;
325
+ const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
326
+ // A distinct local name: `deps.selfInstanceRoot || selfInstanceRoot` with a const of
327
+ // the same name would TDZ-shadow the import and crash every uninjected call.
328
+ const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
329
+ const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, dryRun: true, selfRoot });
330
+ const r = boxExec(cmd, { cwd: runDir, allowFail: true });
331
+ const out = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
332
+ const { checks, error } = parsePreflight(out);
333
+ // ok is the RUN's verdict, not the parser's: a command that exited non-zero without a
334
+ // recognisable ✖ is still a refusal, and reporting it as a pass is the failure mode
335
+ // this whole task exists to remove.
336
+ const ok = !!(r && !r.softFailed && r.ok !== false) && !error;
337
+ return { target: to, ok, lines: checks, error: error || (ok ? null : 'the dry run did not complete') };
338
+ }
339
+
340
+ // Run a dry run for one instance and cache its verdict — the ON-DEMAND leg, drained from
341
+ // a `core-upgrade-preflight` intent the owner's arming queues (task 1004121). The tick
342
+ // does not do this: a verdict that is only ever produced on a background schedule is
343
+ // missing at exactly the moment it is wanted (a fresh release, a new instance), and
344
+ // re-running it every tick for a box that has been behind for hours spends a subprocess
345
+ // to re-learn the same answer. Asked for, then cached, is both cheaper and timelier.
346
+ async function preflightCoreUpgrade(inst, deps, intent) {
347
+ const { db, log } = deps;
348
+ const to = intent && intent.target_version;
349
+ if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') {
350
+ return { ok: false, error: `cannot preview a ${inst.hosting_shape} instance` };
351
+ }
352
+ if (!to) return { ok: false, error: 'no target version to preview' };
353
+ const verdict = (deps.preflightUpgrade || preflightUpgrade)(inst, to, deps);
354
+ await db.query(
355
+ `INSERT INTO provisioning_core_version_snapshot (instance_id, preflight, preflight_at, observed_at)
356
+ VALUES ($1, $2::jsonb, now(), now())
357
+ ON CONFLICT (instance_id) DO UPDATE
358
+ SET preflight = EXCLUDED.preflight, preflight_at = now()`,
359
+ [inst.id, JSON.stringify(verdict)]);
360
+ log(` ${verdict.ok ? '✓' : '✖'} ${inst.slug}: pre-flight for ${to} — ${verdict.ok ? 'clear' : verdict.error}`);
361
+ // ok:true means THE PREVIEW RAN. A refusal is a successful preview of a refusal — the
362
+ // intent must not go to 'error', or the owner's next arming is blocked by a stale open
363
+ // intent describing a check that worked perfectly.
364
+ return { ok: true, preflight: verdict };
365
+ }
366
+
293
367
  /**
294
368
  * Observe one instance and cache what the deploy control needs, without acting.
295
369
  *
@@ -308,7 +382,8 @@ async function snapshotCoreVersions(inst, deps) {
308
382
  if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') return { ok: true, skipped: true };
309
383
  const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
310
384
  const channelMod = deps.updateChannel || require('./update-channel.js');
311
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
385
+ const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
386
+ const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
312
387
  try {
313
388
  const probe = boxExec(versionCmd(inst), { allowFail: true });
314
389
  const served = parseServedVersion(probe && !probe.softFailed ? (probe.stdout || '') : null);
@@ -323,14 +398,26 @@ async function snapshotCoreVersions(inst, deps) {
323
398
  const recommended = served
324
399
  ? channelMod.resolveChannelTarget({ installed: served, channel: channelMod.DEFAULT_CHANNEL, available })
325
400
  : null;
401
+ // THE TICK DOES NOT DRY-RUN. The verdict is produced on demand (preflightCoreUpgrade),
402
+ // because a check re-run every tick for a box that has been behind for hours spends a
403
+ // subprocess to re-learn the same answer, and is still missing in the one window that
404
+ // matters — the minutes after a release, which is when someone presses the button.
405
+ // The version probe below is cheap and genuinely per-tick; a dry run is not.
406
+ const preflight = null;
326
407
  await db.query(
327
- `INSERT INTO provisioning_core_version_snapshot (instance_id, served_version, available, recommended, observed_at)
328
- VALUES ($1, $2, $3::jsonb, $4, now())
408
+ `INSERT INTO provisioning_core_version_snapshot
409
+ (instance_id, served_version, available, recommended, observed_at, preflight, preflight_at)
410
+ VALUES ($1, $2, $3::jsonb, $4, now(), $5::jsonb, $6)
329
411
  ON CONFLICT (instance_id) DO UPDATE
330
412
  SET served_version = EXCLUDED.served_version, available = EXCLUDED.available,
331
- recommended = EXCLUDED.recommended, observed_at = now()`,
332
- [inst.id, served, JSON.stringify(available), recommended || null]);
333
- return { ok: true, served, available: available.length };
413
+ recommended = EXCLUDED.recommended, observed_at = now(),
414
+ -- A tick with nothing to preview must not ERASE the last verdict; it simply
415
+ -- has nothing newer to say. Only a real dry run overwrites one.
416
+ preflight = COALESCE(EXCLUDED.preflight, provisioning_core_version_snapshot.preflight),
417
+ preflight_at = COALESCE(EXCLUDED.preflight_at, provisioning_core_version_snapshot.preflight_at)`,
418
+ [inst.id, served, JSON.stringify(available), recommended || null,
419
+ preflight ? JSON.stringify(preflight) : null, preflight ? new Date() : null]);
420
+ return { ok: true, served, available: available.length, preflight };
334
421
  } catch (e) {
335
422
  log(` ~ core-version snapshot for ${inst.slug} skipped (${(e && e.message) || e})`);
336
423
  return { ok: true, skipped: true };
@@ -352,4 +439,4 @@ async function snapshotAllCoreVersions(deps) {
352
439
  return { seen };
353
440
  }
354
441
 
355
- module.exports = { UPGRADE_SHAPES, versionCmd, parseServedVersion, targetFault, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
442
+ module.exports = { UPGRADE_SHAPES, versionCmd, parseServedVersion, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };