@bongos/core 1.20.61 → 1.20.62

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.
Files changed (38) hide show
  1. package/.bongos-core.json +80 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +2 -0
  8. package/docs/api/openapi.json +96 -3
  9. package/docs/api-reference.md +4 -3
  10. package/docs/copy-inventory.md +372 -324
  11. package/docs/copy-registry.json +881 -417
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-inventory.json +4 -1
  14. package/docs/page-readings.json +544 -481
  15. package/docs/recipes/upgrading-the-core.md +30 -1
  16. package/modules/provisioning/demo.js +176 -0
  17. package/modules/provisioning/migrations/provisioning_035_demo.sql +26 -0
  18. package/modules/provisioning/module.json +5 -3
  19. package/modules/provisioning/pollers/demo-archive.js +61 -0
  20. package/modules/provisioning/provisioning.js +2 -2
  21. package/modules/provisioning/routes/demo.js +54 -0
  22. package/modules/provisioning/routes/provisioning.js +7 -4
  23. package/modules/public-landing/public/projects-demo.states.json +53 -0
  24. package/modules/public-landing/public/projects.html +300 -23
  25. package/modules/public-landing/public/projects.probes.json +3 -3
  26. package/modules/public-landing/public/projects.states.json +2 -1
  27. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +114 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/release-notes.json +14 -0
  31. package/scripts/gds/update-channel.js +11 -2
  32. package/scripts/gds/update-sweep.js +717 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/update_channel_db.mjs +13 -6
  35. package/tests/update_subscription_engine.mjs +5 -5
  36. package/tests/update_sweep_home.mjs +100 -0
  37. package/tests/wizard_demo.mjs +264 -0
  38. package/tests/wizard_front_door.mjs +16 -20
@@ -0,0 +1,717 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // scripts/gds/update-sweep.js — the update-subscription sweep (task 2150, ADR 0136).
4
+ //
5
+ // WHERE IT LIVES (task 1004307). This was .claude/scheduled-tasks/core-update-subscription/subscribe.js,
6
+ // and the core package ships no .claude/scheduled-tasks/. So a control plane ran whatever copy its
7
+ // own checkout held: cloudbongos.com's sweep ran a checkout frozen at 2026-08-25, and every lane fix
8
+ // after that never ran there. Under scripts/gds/ it ships in every release. A unit runs it straight
9
+ // from the INSTALLED core (node <instance>/node_modules/@bongos/core/scripts/gds/update-sweep.js
10
+ // --apply --instance <roster root>), and each upgrade it performs brings the next run current code.
11
+ // It therefore requires its core siblings by relative path, which is right in both layouts. The one
12
+ // thing it still resolves across layouts is the TARGET instance's own core (coreModulePathFrom below).
13
+ // subscribe.js remains as a shim, so the routine's step and a hand run keep working.
14
+ //
15
+ // For each instance the operator has opted into config/update-subscriptions.json, ask the registry
16
+ // what @bongos/core versions are published, resolve the newest one this instance's UPDATE CHANNEL
17
+ // permits (patch-only by default; a major is never automatic), and — if that's newer than what's
18
+ // installed — run `bongos upgrade --to <v> --registry` for it. Auto-rollback is ON by default (task
19
+ // 2149), so a patch that fails install/migrate/health reverts itself before this returns.
20
+ //
21
+ // DRY-RUN by default (the box-sweep convention): it prints the plan and changes nothing. The
22
+ // scheduled step passes --apply to actually upgrade. Double-gated in production: the routine is
23
+ // requiresAutonomy:true + default OFF (config/scheduled-routines.json) AND the roster ships empty, so
24
+ // nothing upgrades until an operator both arms autonomy and lists a target.
25
+ //
26
+ // This is the body of a `mode: deterministic` routine — it runs as plain node with NO model session.
27
+ //
28
+ // ── THE PIN (task 1003212) ───────────────────────────────────────────────────────────────────────
29
+ // A successful upgrade rewrites package.json + package-lock.json. If those are left UNCOMMITTED the
30
+ // lane sabotages itself two ways, and did so from the day it was armed:
31
+ // 1. upgrade.js's clean-tree preflight is a bare `git status --porcelain`, so the NEXT sweep dies
32
+ // "✖ working tree not clean" and exits 1 — a systemd failure nobody watches. Net effect: the
33
+ // lane applies AT MOST ONE upgrade per manual pin commit.
34
+ // 2. a pull-deploy `git reset --hard origin/main` REVERTS the bump and npm ci reinstalls the older
35
+ // core. Live silently goes backwards, and every health check still passes.
36
+ // Passing --commit-pin is necessary but NOT sufficient: this routine used to spawn the CONTROL
37
+ // PLANE's vendored upgrade.js, which can be many releases behind (it was on core 1.19.2, ~305 back)
38
+ // and whose parseArgs is a pure allowlist that SILENTLY IGNORES an unknown flag. Adding the flag
39
+ // there would have changed nothing and still reported success — the same silent shape as the bug.
40
+ // So this file now: (a) spawns the TARGET INSTANCE's own upgrade.js, which is by definition current,
41
+ // (b) DETECTS --commit-pin support instead of assuming it, and (c) VERIFIES the end state and heals
42
+ // it, because the only trustworthy signal is a clean tree — not an exit code.
43
+ //
44
+ // ── WHAT THE LANE ITSELF WROTE (task 1003968) ────────────────────────────────────────────────────
45
+ // Every bump also re-materializes .claude/ and regenerates the API artifacts into the instance tree.
46
+ // The guard below used to sort a dirty worktree two ways — "the pin" and "a human's work, halt" — and
47
+ // the lane's own output has never been either. So on any run that does not commit it (pinMode
48
+ // leave-dirty, whose whole premise is that the tree may stay dirty; or a core too old for the flags
49
+ // that commit it) the sweep halted on its own exhaust, and halted there forever. pinState now sorts
50
+ // THREE ways, and the third bucket is not a glob of paths that look generated: it is what the
51
+ // instance's OWN core says this upgrade rewrites (laneGeneratedPaths). Anything outside that set is
52
+ // still a human's, and still stops us.
53
+ //
54
+ // Usage:
55
+ // node scripts/gds/update-sweep.js # dry-run (plan only)
56
+ // node scripts/gds/update-sweep.js --apply # actually upgrade
57
+ // …--only <slug> limit to one subscribed instance
58
+ // …--instance <dir> where to read config/update-subscriptions.json (default: the working directory)
59
+ //
60
+ // Exit codes: 0 = clean (upgraded, or nothing to do), 1 = at least one instance's upgrade failed OR
61
+ // ended with an uncommitted pin (an unhealed pin is a FAILURE — it wedges the next run).
62
+
63
+ const fs = require('node:fs');
64
+ const path = require('node:path');
65
+ const { spawnSync } = require('node:child_process');
66
+ const channel = require('./update-channel.js');
67
+ const { readInstalledCoreVersion } = require('./upgrade.js');
68
+
69
+ // Where a core file lives for a given instance: at its root (a monolith checkout), else inside each
70
+ // installed core package. The same order and names as .claude/hooks/_core-resolve.js, which the core
71
+ // package does not ship (it is topology-excluded), so this file cannot require it.
72
+ // tests/update_sweep_home.mjs holds the two lists equal.
73
+ const CORE_PKG_NAMES = ['@cloudbongos/core', '@bongos/core', 'cloudbongos'];
74
+ function coreModulePathFrom(instanceRoot, relpath) {
75
+ const bases = [instanceRoot, ...CORE_PKG_NAMES.map((n) => path.join(instanceRoot, 'node_modules', ...n.split('/')))];
76
+ for (const base of bases) {
77
+ try { return require.resolve(path.join(base, relpath)); } catch (_) { /* not in this base */ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ // Tiny argv helpers — kept inline so this cron companion pulls in no heavier CLI surface than it needs.
83
+ const argv = process.argv.slice(2);
84
+ const hasFlag = (name) => argv.includes(name);
85
+ const argOf = (name) => { const i = argv.indexOf(name); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; };
86
+
87
+ // The two files a core bump rewrites. Nothing else may be auto-committed by this lane — a sweep that
88
+ // commits whatever it happens to find is far more dangerous than one that stalls.
89
+ const PIN_FILES = ['package.json', 'package-lock.json'];
90
+
91
+ // ── pin plumbing ─────────────────────────────────────────────────────────────────────────────────
92
+
93
+ function git(dir, args, run = spawnSync) {
94
+ const r = run('git', ['-C', dir, ...args], { encoding: 'utf8' });
95
+ const ok = !!r && !r.error && r.status === 0;
96
+ return { ok, stdout: (r && r.stdout) || '', stderr: (r && r.stderr) || '', status: r ? r.status : null };
97
+ }
98
+
99
+ // The instance-relative paths THIS upgrade writes into the instance's own tree — the provenance
100
+ // answer pinState's third bucket is sorted by.
101
+ //
102
+ // Asked of the TARGET INSTANCE's core, never the control plane's, for the reason resolveUpgradeScript
103
+ // documents: the instance being upgraded is current by definition, while the control plane's vendored
104
+ // copy is the plane nobody bumps (it sat ~305 releases behind, and that staleness is what silently
105
+ // disabled --commit-pin for the whole lane). Two sources, each already the core's own answer to "what
106
+ // does a bump rewrite here":
107
+ // • upgrade.js API_ARTIFACT_FILES — the OpenAPI spec + typed client regenerated from the new core's
108
+ // routes (task 1003926).
109
+ // • claude-materialize.js materializeWillRewrite — a DRY RUN of the materialize step, so the set is
110
+ // derived from the core actually installed here rather than guessed from a glob (task 1004122).
111
+ // It is the same function upgrade.js's own clean-tree pre-flight waives dirt by, so the lane and
112
+ // the bump it spawns agree on what "this upgrade rewrote" means.
113
+ //
114
+ // FAIL-SOFT, and the direction is deliberate. An instance whose core predates either export, or whose
115
+ // materialize throws, yields nothing here: every dirty path stays in `other` and the guard is exactly
116
+ // as strict as it was before this function existed. The failure mode is "still wedged", never
117
+ // "committed a stranger's work".
118
+ function laneGeneratedPaths(instDir, { resolveFrom = coreModulePathFrom, load = require } = {}) {
119
+ const out = new Set();
120
+ try {
121
+ const p = resolveFrom(instDir, 'scripts/gds/upgrade.js');
122
+ if (p) for (const files of Object.values(load(p).API_ARTIFACT_FILES || {})) for (const f of files) out.add(f);
123
+ } catch (_) { /* an older core, or one that will not load here — stay strict */ }
124
+ try {
125
+ const p = resolveFrom(instDir, 'scripts/gds/claude-materialize.js');
126
+ const willRewrite = p && load(p).materializeWillRewrite;
127
+ if (willRewrite) {
128
+ // coreRoot is derived from where the module RESOLVED (<core>/scripts/gds/x.js → <core>), not
129
+ // from a package-name guess: an instance may carry the core under any of its published names,
130
+ // and in the monolith layout the core root IS the instance root (where materialize no-ops).
131
+ const coreRoot = path.resolve(path.dirname(p), '..', '..');
132
+ for (const f of willRewrite({ instanceDir: instDir, coreRoot }, () => {}) || []) out.add(f);
133
+ }
134
+ } catch (_) { /* same — a set we could not derive is an empty one, never a guessed one */ }
135
+ return [...out];
136
+ }
137
+
138
+ // Split a dirty worktree into "the pin", "what this upgrade generated", and "everything else". The
139
+ // distinction is the whole safety story, and it is THREE-way for a reason (task 1003968).
140
+ //
141
+ // Two buckets could not express what a bump actually leaves behind. Every upgrade re-materializes
142
+ // .claude/ and regenerates the API artifacts, so on any run that does not commit them the lane's OWN
143
+ // output landed in `other` — and `other` means "a human's work, halt". That wedged pinMode
144
+ // leave-dirty against its own definition (a mode whose point is that the tree may stay dirty, stopped
145
+ // by dirt), and wedged any instance on a core too old for the flags that commit it.
146
+ //
147
+ // `generated` is not an allow-list of paths that LOOK generated; it is what the instance's own core
148
+ // says this upgrade rewrites. A path outside that set — an owner's edit, a hand-written skill, a
149
+ // half-finished migration — is still `other` and still stops us, which is the property task 1003957
150
+ // pinned and this must not spend.
151
+ function pinState(dir, run = spawnSync, generated = []) {
152
+ // -uall, not the default -unormal. A wholly-untracked DIRECTORY is collapsed to one entry —
153
+ // '?? .claude/skills/new-in-785/' — so a skill the new core adds would never match a path in
154
+ // `generated`, would land in `other`, and would wedge the lane in exactly the way this sorting
155
+ // exists to prevent. Listing untracked files individually is what makes the buckets comparable.
156
+ const r = git(dir, ['status', '--porcelain', '-uall'], run);
157
+ if (!r.ok) return { repo: false };
158
+ const ours = new Set(generated);
159
+ const entries = r.stdout.split('\n').map((l) => l.trim()).filter(Boolean)
160
+ .map((l) => ({ raw: l, file: l.slice(2).trim() }));
161
+ const pin = entries.filter((e) => PIN_FILES.includes(e.file));
162
+ const rest = entries.filter((e) => !PIN_FILES.includes(e.file));
163
+ return {
164
+ repo: true,
165
+ clean: entries.length === 0,
166
+ pin,
167
+ generated: rest.filter((e) => ours.has(e.file)),
168
+ other: rest.filter((e) => !ours.has(e.file)),
169
+ };
170
+ }
171
+
172
+ // Commit + push ONLY the pin files. Returns a verdict rather than throwing: a sweep must survive one
173
+ // instance's git trouble and keep going, but it must never call the result clean.
174
+ //
175
+ // `push: false` commits and STOPS — go-live's `local-commit` pin mode (task 1003521). On a
176
+ // provisioned co-tenant the remote is the CUSTOMER's own repo: pushing there is wrong, and in
177
+ // practice uncredentialed. Nothing reverts the pin either, because a co-tenant has no
178
+ // pull-deploy timer, so a local commit is durable on its own.
179
+ //
180
+ // `generated` (task 1003968) is the paths THIS upgrade wrote — laneGeneratedPaths' answer. They are
181
+ // committed alongside the pin and are NOT grounds to refuse: the upgrade overwrites them
182
+ // unconditionally on every bump, so refusing on them protects nothing and stops every future sweep.
183
+ // The refusal itself stays exactly where it was, keyed on everything else.
184
+ function commitPin({ dir, message, remote = 'origin', push = true, generated = [] }, run = spawnSync, log = console.log, err = console.error) {
185
+ const st = pinState(dir, run, generated);
186
+ if (!st.repo) return { ok: true, action: 'not-a-repo' };
187
+ if (st.other.length) {
188
+ return {
189
+ ok: false, action: 'refused',
190
+ reason: `worktree carries changes that are neither the pin nor this upgrade's own output (${st.other.slice(0, 5).map((e) => e.file).join(', ')}) — refusing to auto-commit a human's work`,
191
+ };
192
+ }
193
+ if (!st.pin.length && !st.generated.length) return { ok: true, action: 'already-clean' };
194
+
195
+ // Stage the pin plus whatever of the upgrade's OWN output is dirty — never "everything dirty".
196
+ // The refusal above is the guard that keeps an owner's edit out of an unattended commit, and it
197
+ // stays keyed on a set the instance's core produced rather than on one this file invented.
198
+ const add = git(dir, ['add', '--', ...PIN_FILES, ...st.generated.map((e) => e.file)], run);
199
+ if (!add.ok) return { ok: false, action: 'add-failed', reason: add.stderr.trim().split('\n').pop() };
200
+
201
+ const commit = git(dir, ['commit', '-m', message], run);
202
+ if (!commit.ok) return { ok: false, action: 'commit-failed', reason: commit.stderr.trim().split('\n').pop() };
203
+
204
+ const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'], run);
205
+ const target = branch.ok ? branch.stdout.trim() : 'main';
206
+
207
+ if (!push) {
208
+ log(' ✓ pin committed locally, not pushed (pinMode local-commit — the remote is not ours)');
209
+ return { ok: true, action: 'committed-local', committed: true, branch: target };
210
+ }
211
+
212
+ const pushed = git(dir, ['push', remote, `HEAD:${target}`], run);
213
+ if (!pushed.ok) {
214
+ // Committed but not pushed is STILL doomed — deploy.sh resets to the REMOTE.
215
+ return { ok: false, action: 'push-failed', reason: pushed.stderr.trim().split('\n').pop(), committed: true };
216
+ }
217
+ log(` ✓ pin committed + pushed to ${remote}/${target} — a pull-deploy reset can no longer revert it`);
218
+ return { ok: true, action: 'committed', committed: true, branch: target };
219
+ }
220
+
221
+ // ── the deploy engine: go-live.js when the instance can support it ───────────────────────────────
222
+
223
+ // Fields go-live.js REQUIRES to build a valid target. `ssh` is deliberately absent: the
224
+ // routine runs ON the box, so the target is LOCAL and go-live drives a local shell.
225
+ // `runAs` is absent too — this routine already runs as the instance owner, so no sudo
226
+ // hop is needed and no secret has to be handed across a privilege boundary.
227
+ // This list MIRRORS go-live.js's own REQUIRED_FIELDS — a second copy of someone else's
228
+ // contract, so it drifts silently when that contract moves. It did: co-tenant mode
229
+ // (task 1003521) made `deployTimer` optional there, and this copy kept requiring it, so
230
+ // every provisioned co-tenant was told its roster entry "lacks deployTimer" and dropped to
231
+ // the weaker direct-upgrade path forever.
232
+ const GO_LIVE_REQUIRED = ['instanceDir', 'service', 'healthUrl', 'versionUrl'];
233
+
234
+ // Read the roster entry STRAIGHT FROM DISK for the deploy-topology fields.
235
+ //
236
+ // loadSubscriptions() normalizes entries, and it comes from the CONTROL PLANE's core —
237
+ // which is a separate plane nobody bumps (it sat on 1.19.2, ~305 releases behind). A
238
+ // normalizer that predates a field silently drops it, so an instance can name
239
+ // versionUrl + deployTimer and still be told "roster entry lacks versionUrl +
240
+ // deployTimer" forever. That is the same stale-vendored-core trap that disabled
241
+ // --commit-pin (task 1003212), wearing a different hat: do not let the oldest thing on
242
+ // the box decide which features the newest thing may use.
243
+ //
244
+ // So the topology is read directly. Everything ELSE still comes from the normalizer —
245
+ // this is a targeted supplement, not a second parser.
246
+ function rawTopologyFor(instanceRoot, slug, { readFile = fs.readFileSync } = {}) {
247
+ try {
248
+ const raw = JSON.parse(readFile(path.join(instanceRoot, 'config', 'update-subscriptions.json'), 'utf8'));
249
+ const row = (raw.instances || []).find((r) => r && (r.slug === slug || r.dir === slug));
250
+ if (!row) return {};
251
+ const out = {};
252
+ for (const k of ['versionUrl', 'deployTimer', 'deployService', 'backupDb', 'backupDir', 'registryPackage', 'pinMode']) {
253
+ if (typeof row[k] === 'string' && row[k]) out[k] = row[k];
254
+ }
255
+ return out;
256
+ } catch (_) { return {}; }
257
+ }
258
+
259
+ // Map a roster entry onto a go-live target. Returns what is MISSING rather than a
260
+ // half-built target: an invalid target would be rejected by go-live anyway, and the
261
+ // point of checking here is to fall back cleanly instead of failing the sweep.
262
+ function goLiveTargetFor(inst, extra = {}) {
263
+ const e = { ...inst, ...extra };
264
+ const t = {
265
+ instanceDir: inst.dir,
266
+ service: inst.service,
267
+ healthUrl: inst.healthUrl,
268
+ versionUrl: e.versionUrl,
269
+ };
270
+ // deployTimer belongs HERE, not above. The normalizer yields `null` for an absent field,
271
+ // and an explicit `"deployTimer": null` survives JSON.stringify into --target-json, where
272
+ // go-live's validateTarget runs isSafeToken(null) on it and rejects the whole target. The
273
+ // optional loop drops falsy values, so a co-tenant simply omits the key — which is what
274
+ // "no pull-deploy timer to stop" actually means.
275
+ for (const [k, v] of Object.entries({
276
+ deployTimer: e.deployTimer,
277
+ deployService: e.deployService,
278
+ backupDb: e.backupDb,
279
+ backupDir: e.backupDir,
280
+ registryPackage: e.registryPackage,
281
+ pinMode: e.pinMode,
282
+ env: e.env,
283
+ })) if (v) t[k] = v;
284
+
285
+ const missing = GO_LIVE_REQUIRED.filter((k) => !t[k]);
286
+ return missing.length ? { ok: false, missing } : { ok: true, target: t };
287
+ }
288
+
289
+ // Does this target need CO-TENANT support from go-live.js? Two shapes do, and both are
290
+ // rejected outright by a go-live.js predating task 1003521: a target with no `deployTimer`
291
+ // fails its REQUIRED_FIELDS check, and one naming `pinMode` fails its unknown-field check.
292
+ // Either way the run dies on a validation error instead of falling back — the failure mode
293
+ // resolveGoLive exists to prevent.
294
+ function needsCoTenantMode(target) {
295
+ return !!target && (!target.deployTimer || !!target.pinMode);
296
+ }
297
+
298
+ // go-live's PIN_MODES, mirrored for the same reason GO_LIVE_REQUIRED is — the lane acts on the pin
299
+ // both BEFORE go-live is called and AFTER it returns, so an absent or unknown mode has to mean the
300
+ // same thing on both sides of that boundary.
301
+ const PIN_MODES = ['push', 'local-commit', 'leave-dirty'];
302
+ const DEFAULT_PIN_MODE = 'push';
303
+
304
+ // The pin policy is read from the ROSTER ENTRY, which is the operator's DECLARED INTENT — it does
305
+ // not change with which engine happens to run. Deriving it from the go-live target instead would
306
+ // silently revert a co-tenant to `push` on exactly the runs that fall back to a direct upgrade,
307
+ // which is the customer's own repo and the one place a push must never land.
308
+ function pinModeOf(source) {
309
+ const mode = (source && source.pinMode) || DEFAULT_PIN_MODE;
310
+ return { mode, valid: PIN_MODES.includes(mode) };
311
+ }
312
+
313
+ // What to do about a dirty pin found BEFORE the upgrade runs.
314
+ //
315
+ // Extracted because this is the THIRD place the lane writes the instance's pin, and all three were
316
+ // wrong in the same way: each decided whether to push without asking what mode it was in. A named
317
+ // decision can be tested; an `if` inline in a 250-line loop cannot, which is why this one shipped
318
+ // broken past a green suite.
319
+ //
320
+ // halt — dirty with changes that are neither the pin nor this upgrade's own output: a
321
+ // human's work, never guess
322
+ // by-design — `leave-dirty`, where an uncommitted pin IS the steady state, not a leftover
323
+ // commit — an actual leftover; `push` says whether it may leave this machine
324
+ //
325
+ // The order — dirt BEFORE mode — is deliberate and stays that way (task 1003968). A human's edit must
326
+ // halt even under `leave-dirty`, which licenses the LANE's dirt, not a stranger's. What made
327
+ // `by-design` unreachable was never this order: it was `other` swallowing the lane's own output, so
328
+ // every mode saw a human's work after every upgrade. pinState's third bucket is the fix, and with it
329
+ // this reads the way it always claimed to.
330
+ function healDecision(pre, pinMode) {
331
+ if (!pre || !pre.repo || pre.clean) return { action: 'nothing' };
332
+ if (pre.other && pre.other.length) return { action: 'halt' };
333
+ if (pinMode === 'leave-dirty') return { action: 'by-design' };
334
+ return { action: 'commit', push: pinMode !== 'local-commit' };
335
+ }
336
+
337
+ // Resolve go-live.js the same way upgrade.js is resolved — the INSTANCE's own core
338
+ // first, because that one is current by definition. go-live.js only exists from core
339
+ // 1.19.310, so its ABSENCE is normal on an older instance and must fall back rather
340
+ // than break the lane (the fail-closed detection rule from task 1003212).
341
+ function resolveGoLive(instDir, { resolveFrom = coreModulePathFrom, readFile = fs.readFileSync, needsCoTenant = false } = {}) {
342
+ let p = null;
343
+ try { p = resolveFrom(instDir, 'scripts/gds/go-live.js'); } catch (_) { return null; }
344
+ if (!p) return null;
345
+ // EXISTING IS NOT ENOUGH. go-live.js arrived in 1.19.310 but could only drive a
346
+ // REMOTE box over ssh; the local mode this routine needs (--target-json + a target
347
+ // with no ssh host) came later. Calling the older one from here would fail on an
348
+ // unrecognised flag — so check for the capability, not the file. Same fail-closed
349
+ // rule as supportsCommitPin, for the same reason.
350
+ //
351
+ // Co-tenant mode is a SECOND capability on the same file, arriving later still (task
352
+ // 1003521), and it is probed ONLY when the target actually needs it — so an instance on a
353
+ // mid-generation core keeps the go-live path it already has for ordinary targets.
354
+ let src;
355
+ try { src = readFile(p, 'utf8'); } catch (_) { return null; }
356
+ if (!src.includes('--target-json')) return null;
357
+ if (needsCoTenant && !src.includes('pinMode')) return null;
358
+ return p;
359
+ }
360
+
361
+ // Pick the deploy engine for ONE instance, and say why when it is not go-live.
362
+ //
363
+ // Both callers — the dry run and the apply path — must agree, or the preview promises an
364
+ // engine the real sweep will not use. They each re-derived it; the co-tenant probe gave them
365
+ // a third thing to keep in step, so the decision lives here once. `raw` is returned because
366
+ // the fallback branch reads the roster's versionUrl straight from it.
367
+ function goLiveEngineFor(inst, instanceRoot, opts = {}) {
368
+ const raw = rawTopologyFor(instanceRoot, inst.slug);
369
+ const mapped = goLiveTargetFor(inst, raw);
370
+ if (!mapped.ok) return { script: null, raw, mapped, coTenant: false, why: `roster entry lacks ${mapped.missing.join(' + ')}` };
371
+ const coTenant = needsCoTenantMode(mapped.target);
372
+ const script = resolveGoLive(inst.dir, { ...opts, needsCoTenant: coTenant });
373
+ if (script) return { script, raw, mapped, coTenant, why: null };
374
+ return {
375
+ script: null, raw, mapped, coTenant,
376
+ why: coTenant
377
+ ? "this instance's core has no go-live.js with co-tenant support yet"
378
+ : "this instance's core has no go-live.js with local-target support yet",
379
+ };
380
+ }
381
+
382
+ // ── which upgrade.js to run ──────────────────────────────────────────────────────────────────────
383
+
384
+ // Prefer the TARGET INSTANCE's own core over this control plane's vendored copy.
385
+ //
386
+ // The instance being upgraded always carries a current core (that is what "upgrade" means), whereas
387
+ // the control plane's own pin is a separate plane that nobody bumps — it sat 305 releases behind and
388
+ // that staleness silently disabled --commit-pin for the whole lane. Running the instance's own script
389
+ // is also exactly what the manual runbook and scripts/gds/go-live.js do, so all three paths agree.
390
+ function resolveUpgradeScript(instDir, { resolveFrom = coreModulePathFrom, fallback = null } = {}) {
391
+ let own = null;
392
+ try { own = resolveFrom(instDir, 'scripts/gds/upgrade.js'); } catch (_) { own = null; }
393
+ if (own) return { script: own, source: 'instance' };
394
+ if (fallback) return { script: fallback, source: 'control-plane' };
395
+ return { script: null, source: 'none' };
396
+ }
397
+
398
+ // Detect the flag rather than assuming it. An unsupported flag is SILENTLY IGNORED here (parseArgs is
399
+ // an allowlist reader), and "silently ignored" is precisely the failure being fixed — so assuming
400
+ // support would reintroduce the bug on any instance whose core predates the flag.
401
+ function supportsCommitPin(scriptPath, readFile = fs.readFileSync) {
402
+ if (!scriptPath) return false;
403
+ try { return readFile(scriptPath, 'utf8').includes('--commit-pin'); }
404
+ catch (_) { return false; }
405
+ }
406
+
407
+ // The SECOND capability on the same file, probed separately and for a sharper reason than
408
+ // supportsCommitPin's. On a co-tenant `--commit-pin` alone means commit AND PUSH, and the push
409
+ // lands in the CUSTOMER's own repo — so a core old enough to have `--commit-pin` but not
410
+ // `--pin-no-push` must not be handed the pair. An unsupported flag is silently ignored by
411
+ // parseArgs, which would turn "commit locally" into exactly the push this mode forbids.
412
+ function supportsPinNoPush(scriptPath, readFile = fs.readFileSync) {
413
+ if (!scriptPath) return false;
414
+ try { return readFile(scriptPath, 'utf8').includes('--pin-no-push'); }
415
+ catch (_) { return false; }
416
+ }
417
+
418
+ // Which pin flags the direct-upgrade fallback hands `bongos upgrade` — the THIRD place the lane
419
+ // decides its pin policy, named for the same reason healDecision is: an `if` inline in a 250-line
420
+ // loop cannot be tested, and this one shipped broken past a green suite.
421
+ //
422
+ // THE BUG IT WAS (task 1004122). The branch withheld --commit-pin on every mode but `push`, on the
423
+ // reasoning that --commit-pin also PUSHES and a co-tenant's remote is the customer's own repo. But
424
+ // `--pin-no-push` is exactly the "commit, do not push" it wanted, go-live's upgradeCommand() had
425
+ // been sending that pair for `local-commit` all along, and the comment here already CLAIMED parity
426
+ // with go-live. Withholding the flag left the `.claude/` that upgrade.js materializes on every bump
427
+ // uncommitted, and the lane's own net can stage nothing but PIN_FILES — so its post-upgrade
428
+ // commitPin() refused the tree as "a human's work" and every later sweep halted on the same files.
429
+ //
430
+ // Mirrors go-live.js upgradeCommand() mode for mode; tests/update_subscription_engine.mjs pins the
431
+ // two against each other. `--pin-no-push` is PROBED, never assumed: parseArgs silently ignores an
432
+ // unknown flag, so handing the pair to a core that predates it would turn "commit locally" into the
433
+ // one push this mode exists to forbid.
434
+ function pinFlagsFor({ pinMode, canCommitPin, canPinNoPush }) {
435
+ const noPush = pinMode !== 'push';
436
+ // Both non-default modes turn on --pin-no-push, and a core that would silently ignore it must
437
+ // be handed NOTHING — the pair degrades to a bare --commit-pin, which is the push into the
438
+ // customer's repo this whole function exists to prevent.
439
+ if (noPush && !canPinNoPush) return [];
440
+ return [
441
+ // leave-dirty commits nothing; --pin-no-push alone is what downgrades upgrade.js's
442
+ // "PIN NOT COMMITTED" alarm to a note, which is go-live's reason for sending it there.
443
+ ...(canCommitPin && pinMode !== 'leave-dirty' ? ['--commit-pin'] : []),
444
+ ...(noPush ? ['--pin-no-push'] : []),
445
+ ];
446
+ }
447
+
448
+ // The direct-upgrade fallback's CLEAN-TREE policy, which is a pin-mode decision like the flags above.
449
+ //
450
+ // Under `leave-dirty` the pin is left in the working tree BY DESIGN, so the second bump meets
451
+ // upgrade.js's clean-tree pre-flight reading the first bump's own package.json as uncommitted work,
452
+ // and refuses — the one-upgrade wedge again, from the other end of the lane. (The materialize waiver
453
+ // that pre-flight gained in task 1004122 does not cover the pin files, and cannot: on every other
454
+ // mode a dirty pin really is the thing that must stop a bump.) go-live.js's upgradeCommand() passes
455
+ // --force on every mode for exactly this reason; the direct path needs it only where a dirty tree is
456
+ // the operator's declared steady state, so that is the only place it goes.
457
+ //
458
+ // --force waives the clean-tree check and NOTHING else — module compatibility, the DB-identity
459
+ // pre-check, the mandatory health check and auto-rollback all still run. The human-work guard is
460
+ // upstream of this anyway: healDecision halts the instance before an upgrade is ever spawned when the
461
+ // dirt is not the lane's.
462
+ //
463
+ // NOT probed, unlike --commit-pin and --pin-no-push, and the asymmetry is the point. parseArgs
464
+ // silently ignores an unknown flag, so what matters is which way that silence fails: ignoring
465
+ // --pin-no-push turns "commit locally" into a push into the customer's repo (a wrong ACTION, so it
466
+ // must be probed), while ignoring --force leaves the bump refusing a dirty tree (a REFUSAL — loud,
467
+ // and no worse than the status quo). Fail-closed means checking the flags whose absence would do
468
+ // something, not every flag.
469
+ function preflightFlagsFor(pinMode) {
470
+ return pinMode === 'leave-dirty' ? ['--force'] : [];
471
+ }
472
+
473
+ // ── the sweep ────────────────────────────────────────────────────────────────────────────────────
474
+
475
+ // `instanceRoot` is the default roster root when no --instance is given: the subscribe.js shim passes
476
+ // its own instance root; run directly, it is the working directory.
477
+ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
478
+ const apply = hasFlag('--apply');
479
+ const only = argOf('--only');
480
+ const instanceRoot = path.resolve(argOf('--instance') || defaultRoot);
481
+ const sweepingCoreUpgradeScript = path.join(__dirname, 'upgrade.js'); // fallback only: the upgrade.js of the core running this sweep
482
+ // Says which core is doing the sweeping, every run, so "is the box running current code?" is
483
+ // one journal line instead of an archaeology session (the gap task 1004307 was filed for).
484
+ let selfVersion = 'unknown';
485
+ try { selfVersion = require(path.join(__dirname, '..', '..', 'src', 'module-api.js')).CORE_VERSION || selfVersion; } catch (_) { /* a core without the doorway */ }
486
+ console.log(`update-sweep · running from ${path.resolve(__dirname, '..', '..')} (core ${selfVersion}) · roster ${instanceRoot}`);
487
+
488
+ const loaded = channel.loadSubscriptions({ instanceDir: instanceRoot });
489
+ const { source, present, error } = loaded;
490
+ if (error) console.error(` ! ${path.relative(instanceRoot, source) || source}: ${error} — treating roster as empty.`);
491
+
492
+ // The rule set on /deploy wins over the roster's `channel` (task 1004468, ADR 0357). Read from the
493
+ // CONTROL PLANE's database — this process's own env, never an entry's `env`, which points at that
494
+ // instance's database. A core too old to carry the reader keeps the roster, as before.
495
+ const db = typeof channel.readDbChannels === 'function' ? channel.readDbChannels({ env: process.env }) : null;
496
+ const instances = db ? channel.applyDbChannels(loaded.instances, db) : loaded.instances;
497
+ if (db && !db.ok && instances.length) console.log(` • update rules: read from the roster — ${db.reason}`);
498
+
499
+ const roster = only ? instances.filter((i) => i.slug === only || i.dir === only) : instances;
500
+ const mode = apply ? 'apply' : 'dry-run';
501
+ console.log(`core-update-subscription (${mode}) — ${roster.length} subscribed instance(s)${present ? '' : ' [no roster file — nothing subscribed]'}`);
502
+ for (const inst of roster) if (inst.channelNote) console.log(channel.channelLine(inst));
503
+ if (only && roster.length === 0) console.error(` ! --only ${only} matched no subscribed instance`);
504
+
505
+ let upgraded = 0, skipped = 0, failed = 0, upToDate = 0, healed = 0;
506
+
507
+ for (const inst of roster) {
508
+ const label = inst.slug;
509
+ if (inst.channel === 'pinned') { console.log(` • ${label}: pinned — skipped`); skipped++; continue; }
510
+
511
+ const installed = readInstalledCoreVersion(inst.dir);
512
+ if (!installed) { console.log(` • ${label}: installed core version unknown (no node_modules/@bongos/core at ${inst.dir}) — skipped`); skipped++; continue; }
513
+
514
+ // Resolve the engine and the pin policy BEFORE anything touches the pin. Both are cheap and
515
+ // pure (a disk read of the roster plus a source probe), and the HEAL step below is itself a
516
+ // write to the instance's repo — so it has to know the policy it is writing under.
517
+ const engine = goLiveEngineFor(inst, instanceRoot);
518
+ const rawTopo = engine.raw;
519
+ const pin = pinModeOf(rawTopo);
520
+ if (!pin.valid) {
521
+ console.error(` ! ${label}: roster entry names an unknown pinMode ${JSON.stringify(pin.mode)} (expected ${PIN_MODES.join(' | ')}) — skipped`);
522
+ console.error(' Refusing rather than defaulting: every unknown value would fall through to the PUSH branch, and on a co-tenant that push lands in the customer\'s own repo.');
523
+ skipped++; continue;
524
+ }
525
+ const pinMode = pin.mode;
526
+
527
+ // HEAL FIRST. A pin left uncommitted by an earlier sweep would make upgrade.js's clean-tree
528
+ // preflight abort this instance forever. Committing it here is what turns a permanent wedge into
529
+ // a self-correcting lane. Anything OTHER than the pin dirty is a human's work — halt, don't guess.
530
+ //
531
+ // EXCEPT under `leave-dirty`, where a dirty pin is not a leftover at all: it is the STEADY STATE
532
+ // this mode asks for. Healing it would commit and push the customer's own repo on the sweep after
533
+ // every successful upgrade — reliably, not just after a crash. go-live's dirty-checkout halt reads
534
+ // the CORE checkout, not the instance, so leaving it dirty wedges nothing.
535
+ //
536
+ // What THIS upgrade rewrites in this instance's tree, resolved once and threaded through every
537
+ // pin decision below — the guard, the heal and the post-upgrade net must not disagree about which
538
+ // paths the lane produced, which is exactly how they disagreed before (task 1003968).
539
+ const generated = laneGeneratedPaths(inst.dir);
540
+ const pre = pinState(inst.dir, spawnSync, generated);
541
+ const heal = healDecision(pre, pinMode);
542
+ if (heal.action === 'halt') {
543
+ console.error(` ! ${label}: worktree not clean, and the changes are neither the pin nor this upgrade's own output (${pre.other.slice(0, 3).map((e) => e.file).join(', ')}) — skipped, a human needs to look`);
544
+ skipped++; continue;
545
+ }
546
+ if (heal.action === 'by-design') {
547
+ console.log(` • ${label}: pin is uncommitted by design (pinMode leave-dirty) — nothing to heal`);
548
+ } else if (heal.action === 'commit') {
549
+ if (!apply) {
550
+ const leftover = [...pre.pin, ...pre.generated].map((e) => e.file);
551
+ console.log(` → ${label}: would first commit a leftover uncommitted pin (${leftover.slice(0, 4).join(', ')}${leftover.length > 4 ? `, +${leftover.length - 4} more` : ''})`);
552
+ } else {
553
+ const r = commitPin({
554
+ dir: inst.dir,
555
+ message: `pin @bongos/core ${installed} (committed by the update lane — leftover from an earlier sweep)`,
556
+ push: heal.push,
557
+ generated,
558
+ });
559
+ if (!r.ok) {
560
+ console.error(` ✖ ${label}: could not commit the leftover pin (${r.action}: ${r.reason}) — skipped; the next sweep will stall here too`);
561
+ failed++; continue;
562
+ }
563
+ if (r.committed) { healed++; console.log(` ↺ ${label}: healed a leftover uncommitted pin from an earlier sweep`); }
564
+ }
565
+ }
566
+
567
+ // Ask the registry. NO CREDENTIAL IS INVOLVED: @bongos/core is public (ADR 0283), and
568
+ // fcea51ca deleted the env-fed .npmrc this line used to describe. Do not reintroduce one —
569
+ // the private era is exactly how this sweep died silently on 2026-08-25, reporting failed=0
570
+ // for three weeks because the unit passed no NPM_TOKEN, the .npmrc expanded to empty, and a
571
+ // skip is not a failure in the accounting below. Still run from the instance dir, which now
572
+ // only decides which registry config applies. Fail-soft: a registry error skips this
573
+ // instance, not the sweep.
574
+ const listed = channel.listAvailableVersions({ cwd: inst.dir, env: { ...process.env, ...inst.env } });
575
+ if (!listed.ok) { console.error(` ! ${label}: could not list registry versions (${listed.error}) — skipped`); skipped++; continue; }
576
+
577
+ const target = channel.resolveChannelTarget({ installed, channel: inst.channel, available: listed.versions });
578
+ if (!target) { console.log(` ✓ ${label}: up to date on ${installed} (channel: ${inst.channel})`); upToDate++; continue; }
579
+
580
+ const { script: upgradeScript, source: scriptSource } = resolveUpgradeScript(inst.dir, { fallback: sweepingCoreUpgradeScript });
581
+ if (!upgradeScript) { console.error(` ! ${label}: no upgrade.js resolvable for this instance — skipped`); skipped++; continue; }
582
+ const canCommitPin = supportsCommitPin(upgradeScript);
583
+ const canPinNoPush = supportsPinNoPush(upgradeScript);
584
+
585
+ if (!apply) {
586
+ const dryEngine = engine.script
587
+ ? `go-live.js (backup + ${engine.coTenant ? 'co-tenant pin mode' : 'deploy-timer guard'} + box-side read-back)`
588
+ : `direct bongos upgrade — ${engine.why}`;
589
+ // The pin flags come from the SAME function the apply path spreads, so the preview cannot
590
+ // promise a policy the real sweep will not use (task 1004122). It read `canCommitPin` alone
591
+ // before, and so said "--commit-pin" for a co-tenant that was never going to get it.
592
+ const dryPinFlags = [...pinFlagsFor({ pinMode, canCommitPin, canPinNoPush }), ...preflightFlagsFor(pinMode)];
593
+ console.log(` → ${label}: would upgrade ${installed} → ${target} (channel: ${inst.channel}, registry, via the ${scriptSource} core${dryPinFlags.length ? `, ${dryPinFlags.join(' ')}` : ''})`);
594
+ console.log(` engine: ${dryEngine}`);
595
+ continue;
596
+ }
597
+
598
+ console.log(` ↑ ${label}: upgrading ${installed} → ${target} (channel: ${inst.channel}) …`);
599
+
600
+ // PREFER go-live.js. Running `bongos upgrade` directly gets the bump but none of the
601
+ // surrounding rigour: no pg_dump and no guard around the pull-deploy timer (which fires
602
+ // every ~2 min doing `git reset --hard` and can revert a pin mid-flight). The machine
603
+ // deploying unattended every night should not have weaker checks than a human deploying
604
+ // by hand.
605
+ // The read-back gap is CLOSED as of task 1002884: upgrade.js no longer treats a failed
606
+ // restart as a warning, and it reads the live process's coreVersion back from /version
607
+ // rather than trusting a health poll that the still-running OLD core would also answer
608
+ // (idea 1000682). go-live is still preferred for the backup + timer guard.
609
+ //
610
+ // The CHANNEL decision stays here: go-live is told exactly which version to take, so
611
+ // the patch-only policy still bounds what may be applied.
612
+ let res;
613
+ if (engine.script) {
614
+ const glArgs = [engine.script, '--target-json', JSON.stringify(engine.mapped.target), '--to', target, '--apply', '--no-divergence'];
615
+ res = spawnSync(process.execPath, glArgs, { stdio: 'inherit', env: { ...process.env, ...inst.env } });
616
+ } else {
617
+ console.log(` • falling back to a direct upgrade — ${engine.why}. No backup and no deploy-timer guard (the served-version read-back does run — task 1002884).`);
618
+ const upArgs = [upgradeScript, '--to', target, '--registry', '--instance', inst.dir, '--note', `auto-subscription (${inst.channel})`];
619
+ if (inst.service) upArgs.push('--service', inst.service);
620
+ if (inst.healthUrl) upArgs.push('--health-url', inst.healthUrl);
621
+ // task 1002884: hand the bump the same independent read-back go-live uses. upgrade.js now
622
+ // derives /version from the health URL on its own, but the roster already carries the exact
623
+ // endpoint — passing it makes the check STRICT (an unreadable endpoint fails the bump rather
624
+ // than warning), which is the right posture for a lane nobody is watching.
625
+ // Read it from rawTopo, NOT from the normalized `inst`. This branch is reached precisely
626
+ // WHEN the normalizer came up short (`mapped.ok === false`, often for versionUrl itself), so
627
+ // trusting `inst.versionUrl` here would drop strict mode in exactly the stale-control-plane
628
+ // case the comment above rawTopologyFor() documents — the silent degradation this task exists
629
+ // to remove. `inst` is kept as a fallback for a roster whose entry is keyed unusually.
630
+ const versionUrl = rawTopo.versionUrl || inst.versionUrl;
631
+ if (versionUrl) upArgs.push('--version-url', versionUrl);
632
+ upArgs.push(...pinFlagsFor({ pinMode, canCommitPin, canPinNoPush }));
633
+ // …and the clean-tree half of the same wiring: under leave-dirty the previous bump's pin is
634
+ // still in the tree by design, and without this the second bump is refused by upgrade.js's
635
+ // pre-flight. go-live.js sends --force on every mode; here it goes only where dirt is declared.
636
+ upArgs.push(...preflightFlagsFor(pinMode));
637
+ // Auto-rollback stays ON (do NOT pass --no-rollback-on-failure): a bad patch reverts itself.
638
+ res = spawnSync(process.execPath, upArgs, { stdio: 'inherit', env: { ...process.env, ...inst.env } });
639
+ }
640
+
641
+ // RE-PROBE, rather than reusing the set from before the bump. The pre-upgrade answer came from
642
+ // the core that was installed THEN, so a skill the NEW core adds — or one it withdraws — is absent
643
+ // from it, would land in `other`, and would wedge the lane on the very next sweep: the same defect
644
+ // one release later. After the bump the instance carries the core that actually wrote these files,
645
+ // which is the only thing that can name them. (On a rolled-back bump the OLD core is back, and its
646
+ // answer is the right one for the tree a rollback leaves.)
647
+ //
648
+ // What re-probing gives is the FILE SET, which materializeClaude walks off coreRoot at call time
649
+ // — so this really is the newly-installed core's `.claude/`. What it does not give is the new
650
+ // core's materialize CODE: node caches a module by resolved path, and the path did not change.
651
+ // A materialize algorithm that changed shape mid-lane is a second-order gap, and closing it would
652
+ // mean either a half-purged module graph (new file, old transitive deps) or a child process per
653
+ // instance per sweep. The primary path is upgrade.js's own --commit-pin, which uses the real
654
+ // post-install materialize result; this is the backstop under it.
655
+ const generatedAfter = laneGeneratedPaths(inst.dir);
656
+
657
+ if (res.status !== 0) {
658
+ console.error(` ✖ ${label}: upgrade to ${target} exited ${res.status == null ? '(signal)' : res.status} — see output above (auto-rollback runs on install/migrate/health failure)`);
659
+ // A rollback restores the OLD pin, which can itself leave the tree dirty. Tidy it so one bad
660
+ // patch does not wedge every future sweep.
661
+ const after = pinState(inst.dir, spawnSync, generatedAfter);
662
+ if (after.repo && !after.clean && !after.other.length && pinMode !== 'leave-dirty') {
663
+ commitPin({
664
+ dir: inst.dir,
665
+ message: `pin @bongos/core ${installed} (restored after a failed auto-upgrade to ${target})`,
666
+ push: pinMode !== 'local-commit',
667
+ generated: generatedAfter,
668
+ });
669
+ }
670
+ failed++; continue;
671
+ }
672
+
673
+ // VERIFY THE END STATE. The exit code says the upgrade ran; only a clean tree says the bump will
674
+ // SURVIVE. If --commit-pin did its job this is a no-op; if it silently did not, this is the net.
675
+ //
676
+ // But the net must not FIGHT the target's own pin policy (task 1003843). `leave-dirty` means the
677
+ // bump lives in the working tree by design, and committing it here would be the net undoing the
678
+ // very thing pinMode was added to express; `local-commit` means the pin may be committed but must
679
+ // never reach the customer's own remote. Only the default `push` mode wants the full treatment —
680
+ // and it is the only one with a pull-deploy timer waiting to revert an unpushed pin.
681
+ if (pinMode === 'leave-dirty') {
682
+ console.log(` ✓ ${label}: upgraded to ${target} (pin left in the working tree — pinMode leave-dirty)`);
683
+ upgraded++; continue;
684
+ }
685
+
686
+ const post = commitPin({
687
+ dir: inst.dir,
688
+ message: `pin @bongos/core ${target} (was ${installed})`,
689
+ push: pinMode !== 'local-commit',
690
+ generated: generatedAfter,
691
+ });
692
+ if (!post.ok) {
693
+ console.error(` ✖ ${label}: upgraded to ${target} BUT THE PIN IS NOT DURABLE (${post.action}: ${post.reason}).`);
694
+ if (pinMode === 'local-commit') {
695
+ console.error(' The bump lives only in this instance\'s working tree, and the next sweep will stall on it.');
696
+ console.error(` Fix by hand: git -C ${inst.dir} add ${PIN_FILES.join(' ')} && git -C ${inst.dir} commit -m "pin @bongos/core ${target}"`);
697
+ } else {
698
+ console.error(' The next pull-deploy reset will revert this bump, and the next sweep will stall on the dirty tree.');
699
+ console.error(` Fix by hand: git -C ${inst.dir} add ${PIN_FILES.join(' ')} && git -C ${inst.dir} commit -m "pin @bongos/core ${target}" && git -C ${inst.dir} push`);
700
+ }
701
+ failed++; continue;
702
+ }
703
+
704
+ console.log(` ✓ ${label}: upgraded to ${target}${post.committed ? ' (pin committed by the lane)' : ''}`);
705
+ upgraded++;
706
+ }
707
+
708
+ console.log(`\ncore-update-subscription: upgraded=${upgraded} up-to-date=${upToDate} skipped=${skipped} failed=${failed}${healed ? ` healed=${healed}` : ''} (${mode})`);
709
+ return failed > 0 ? 1 : 0;
710
+ }
711
+
712
+ if (require.main === module) {
713
+ try { process.exit(main()); }
714
+ catch (e) { console.error(`core-update-subscription: fatal — ${e && e.message ? e.message : e}`); process.exit(1); }
715
+ }
716
+
717
+ module.exports = { main, coreModulePathFrom, CORE_PKG_NAMES, pinState, laneGeneratedPaths, commitPin, preflightFlagsFor, resolveUpgradeScript, supportsCommitPin, supportsPinNoPush, pinFlagsFor, goLiveTargetFor, resolveGoLive, goLiveEngineFor, needsCoTenantMode, pinModeOf, healDecision, rawTopologyFor, PIN_FILES, PIN_MODES, GO_LIVE_REQUIRED };