@bongos/core 1.19.347 → 1.19.349

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.
@@ -130,6 +130,23 @@ function scrubCredential(s) {
130
130
  .replace(/(ghs_|ghp_|github_pat_)[A-Za-z0-9_]+/g, '$1***');
131
131
  }
132
132
 
133
+ // How much scrubbed stderr travels with a failure (task 1003222). Sized to hold a
134
+ // real git failure whole — the 'remote:' lines that name the cause arrive LAST, so a
135
+ // short cap deletes precisely the part worth reading. It is a payload bound, nothing
136
+ // more; when it bites, truncateLoudly says so rather than ending mid-word.
137
+ const STDERR_MAX = 8000;
138
+
139
+ // Truncate in a way the reader can SEE. A silent clip is worse than no message: it
140
+ // looks like the complete text, which is how 'error: failed' got mistaken for the
141
+ // whole story. Keeps the TAIL as well as the head, because git's useful line is last.
142
+ function truncateLoudly(text, max = STDERR_MAX) {
143
+ const s = String(text || '');
144
+ if (s.length <= max) return s;
145
+ const head = Math.floor(max * 0.4);
146
+ const tail = max - head;
147
+ return `${s.slice(0, head)}\n\n…[${s.length - max} characters omitted — full text in the server log]…\n\n${s.slice(-tail)}`;
148
+ }
149
+
133
150
  // Default git seam: execFile arg-array, shell:false, stdout captured. On failure
134
151
  // throws GIT_FAILED. We never echo the (possibly credentialed) argv, but DO attach
135
152
  // the credential-scrubbed stderr + the caller's step label (task 1547) so a real
@@ -149,9 +166,30 @@ async function defaultExec(args, opts = {}) {
149
166
  return stdout;
150
167
  } catch (e) {
151
168
  // Identify the failing step by the caller's label, never by echoing argv.
152
- // Surface the scrubbed stderr (truncated) so the cause is loud in the log.
153
- const stderr = scrubCredential(e && (e.stderr || e.message)).trim().slice(0, 500);
154
- throw err('GIT_FAILED', { git_step: step || (args && args[0]) || null, stderr });
169
+ //
170
+ // THE STDERR IS NOT CLIPPED SHORT ANY MORE (task 1003222). It used to be cut at
171
+ // 500 chars here and again at 200 on the way to the client, so a builder saw
172
+ // 'error: failed' — clipped mid-word, naming neither cause nor remedy — while the
173
+ // line that mattered ('remote: fatal error in commit_refs') and the exit code
174
+ // existed only server-side. That cost hours on the blocker-1000117 outage AND
175
+ // produced a WRONG first diagnosis, because the real message was never in the
176
+ // room. git puts the useful line LAST (the 'remote:' lines follow the progress
177
+ // chatter), which is exactly what a head-truncation throws away.
178
+ //
179
+ // The cap is a payload guard, not an editorial decision: generous enough that a
180
+ // real push failure fits whole, and when it does bite the text SAYS SO instead of
181
+ // ending mid-word and reading like the whole message.
182
+ const raw = scrubCredential(e && (e.stderr || e.message)).trim();
183
+ const stderr = truncateLoudly(raw, STDERR_MAX);
184
+ throw err('GIT_FAILED', {
185
+ git_step: step || (args && args[0]) || null,
186
+ stderr,
187
+ stderr_bytes: raw.length,
188
+ // The exit code distinguishes failure SHAPES that read identically in prose —
189
+ // 128 (git said no) vs 1 (a ref was rejected) vs null (killed by the timeout).
190
+ exit_code: (e && typeof e.code === 'number') ? e.code : null,
191
+ timed_out: !!(e && e.killed),
192
+ });
155
193
  }
156
194
  }
157
195
 
@@ -456,7 +494,14 @@ async function openOrFindPullRequest({ branch, title, body }, deps = {}) {
456
494
  });
457
495
  if (!createRes.ok) {
458
496
  const detail = await createRes.text().catch(() => '');
459
- throw err('PR_CREATE_FAILED', { status: createRes.status, detail: detail.slice(0, 300) });
497
+ // Same reasoning as GIT_FAILED's stderr (task 1003222): GitHub's refusal body
498
+ // names the actual rule that refused ('No commits between main and X', a
499
+ // protected-branch rule, a validation error naming the field) and a 300-char
500
+ // clip regularly cut it off. Scrubbed, because a body can echo a request header.
501
+ throw err('PR_CREATE_FAILED', {
502
+ status: createRes.status,
503
+ detail: truncateLoudly(scrubCredential(detail), STDERR_MAX),
504
+ });
460
505
  }
461
506
  const pr = await createRes.json();
462
507
  return { number: pr.number, url: pr.html_url, node_id: pr.node_id };
@@ -1360,6 +1405,8 @@ module.exports = {
1360
1405
  prReferencesTask,
1361
1406
  findTaskPullRequest,
1362
1407
  scrubCredential,
1408
+ truncateLoudly, // task 1003222 — exported so its behaviour is testable, not just its effect
1409
+ STDERR_MAX,
1363
1410
  isAncestorOfDefaultBranch,
1364
1411
  resolveToken,
1365
1412
  getInstallationToken,
@@ -979,7 +979,12 @@ module.exports = function buildTasksRouter() {
979
979
  if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
980
980
  if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
981
981
  if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
982
- if (e.code === 'PR_CREATE_FAILED') return res.fail('pr_create_failed', 502, { detail: e.detail });
982
+ // The GitHub HTTP status travels WITH the body (task 1003222). 422 ('no commits
983
+ // between'), 403 (a protected-branch rule) and 404 (the App cannot see the repo)
984
+ // are three different remedies, and the body alone does not always separate them.
985
+ if (e.code === 'PR_CREATE_FAILED') {
986
+ return res.fail('pr_create_failed', 502, { detail: e.detail, github_status: e.status ?? null });
987
+ }
983
988
  // task 1547: GIT_FAILED now carries WHICH git step failed + the credential-
984
989
  // scrubbed stderr (github-push.defaultExec). Log both so the next push outage
985
990
  // is a one-line diagnosis, not a multi-hour opaque-`publish_failed` mystery
@@ -990,7 +995,22 @@ module.exports = function buildTasksRouter() {
990
995
  e.git_step ? `step=${e.git_step}` : '',
991
996
  e.stderr ? `stderr=${e.stderr}` : ''
992
997
  );
993
- return res.fail('publish_failed', 500, { ...(e.git_step ? { step: e.git_step } : {}), ...(e.stderr ? { reason: String(e.stderr).slice(0, 200) } : {}) });
998
+ // NO SECOND TRUNCATION (task 1003222). This line used to clip the already-
999
+ // shortened stderr to 200 chars, so the builder got 'error: failed' — cut
1000
+ // mid-word, naming neither cause nor remedy — while the real remote message sat
1001
+ // in a log they could not reach. A publish failure must be diagnosable from
1002
+ // where the builder is STANDING; that is the whole point of the task.
1003
+ //
1004
+ // `reason` keeps its name and position so existing readers (ship.js prints it)
1005
+ // keep working; it is simply no longer amputated. Everything here is already
1006
+ // credential-scrubbed at the throw site by github-push.scrubCredential.
1007
+ return res.fail('publish_failed', 500, {
1008
+ ...(e.git_step ? { step: e.git_step } : {}),
1009
+ ...(e.stderr ? { reason: e.stderr } : {}),
1010
+ ...(e.stderr_bytes != null ? { reason_bytes: e.stderr_bytes } : {}),
1011
+ ...(e.exit_code != null ? { git_exit_code: e.exit_code } : {}),
1012
+ ...(e.timed_out ? { timed_out: true } : {}),
1013
+ });
994
1014
  } finally {
995
1015
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
996
1016
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.347",
3
+ "version": "1.19.349",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.347",
9
+ "version": "1.19.349",
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.347",
3
+ "version": "1.19.349",
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",
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/client-baseurl-guard.js — a web page must not send its API calls to
3
+ // the site root (task 1003263).
4
+ //
5
+ // THE BUG THIS EXISTS TO STOP. The generated client (clients/bongos-client) exposes
6
+ // two call styles, and mixing them is silent and fatal:
7
+ //
8
+ // NAMESPACED api.inbox.getInboxTemplate({}) → path template '/inbox/template',
9
+ // API-RELATIVE, needs the API base
10
+ // ESCAPE HATCH api.request('GET', `${API}/me`) → caller supplies the FULL path
11
+ //
12
+ // createClient documents `baseUrl: ''` as "paths are already absolute" and passes
13
+ // them through unchanged. So a page that sets baseUrl:'' AND calls a namespaced
14
+ // method requests '/inbox/template' — the SITE ROOT, where nothing is mounted — and
15
+ // 404s on every call. The router mounts at '/api/bongos/v1' (api-prefix.js
16
+ // VERSIONED_API_PREFIX), which is exactly the client's own default.
17
+ //
18
+ // WHY A FITNESS CHECK AND NOT A TEST. Three pages shipped this defect — ideas.js and
19
+ // idea.js were dead in production from the day they shipped, and collab.js was caught
20
+ // in review one commit before it would have been. Every page test stubs the client
21
+ // method directly, so URL construction is never exercised; tests/idea_hall_composer.mjs
22
+ // even asserted with a regex against the source text, which passes happily on a broken
23
+ // URL. 54 green tests over pages that 404 on every call. The defect is invisible to the
24
+ // test style this repo uses for hall pages, so it needs a guard at a different altitude.
25
+ //
26
+ // STATIC AND CONSERVATIVE. It flags only the unambiguous combination — an explicit
27
+ // empty baseUrl in a createClient call, in a file that also makes a namespaced call.
28
+ // A page using the escape hatch with baseUrl:'' is CORRECT and stays silent (work.js,
29
+ // copy-desk.js, goal-inbox.js and most of the hall do exactly that).
30
+
31
+ 'use strict';
32
+
33
+ const fs = require('node:fs');
34
+ const path = require('node:path');
35
+ const { execFileSync } = require('node:child_process');
36
+
37
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
38
+ const NAME = 'a web page sends its API calls to the API, not the site root (task 1003263)';
39
+
40
+ // createClient({ ... baseUrl: '' ... }) — an EXPLICIT empty base. Omitting baseUrl is
41
+ // the correct fix and must not match; only the literal empty string is the defect.
42
+ const EMPTY_BASEURL = /createClient\(\s*\{[^}]*baseUrl\s*:\s*(''|""|`\s*`)/;
43
+
44
+ // api.<namespace>.<method>( — the generated namespaced style. `api.request(` is the
45
+ // escape hatch and is deliberately NOT matched: with it, baseUrl:'' is correct.
46
+ const NAMESPACED_CALL = /\bapi\s*\.\s*(?!request\b)[A-Za-z_$][\w$]*\s*\.\s*[A-Za-z_$][\w$]*\s*\(/;
47
+
48
+ // Every tracked browser-served script. Server code never calls createClient this way.
49
+ function trackedPageScripts() {
50
+ try {
51
+ const out = execFileSync('git', ['ls-files', 'modules/*/public/*.js', 'public/*.js'],
52
+ { cwd: REPO_ROOT, encoding: 'utf8' });
53
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ function scanPages(files, read) {
60
+ const violations = [];
61
+ let scanned = 0;
62
+ let usingClient = 0;
63
+ for (const rel of files) {
64
+ const src = read(rel);
65
+ if (src == null) continue;
66
+ scanned += 1;
67
+ if (!/createClient\(/.test(src)) continue;
68
+ usingClient += 1;
69
+ if (!EMPTY_BASEURL.test(src)) continue;
70
+ if (!NAMESPACED_CALL.test(src)) continue; // escape-hatch page — baseUrl:'' is right
71
+ const m = NAMESPACED_CALL.exec(src);
72
+ violations.push(
73
+ `${rel} sets baseUrl:'' AND calls the generated client (${m[0].replace(/\s+/g, '')}…). `
74
+ + "Those paths are API-relative, so baseUrl:'' sends them to the site root and every "
75
+ + 'call 404s. Drop the baseUrl override, or switch every call to '
76
+ + "api.request('METHOD', `${API}/…`) with a full path.",
77
+ );
78
+ }
79
+ return { violations, scanned, usingClient };
80
+ }
81
+
82
+ function checkClientBaseUrl({ files, read } = {}) {
83
+ const list = files || trackedPageScripts();
84
+ if (!list.length) {
85
+ return {
86
+ name: NAME, ok: false, hardFail: true,
87
+ violations: ['scan defect — enumerated 0 tracked page scripts; a broken enumeration, not a clean result.'],
88
+ warnings: [], note: 'static scan of browser-served scripts for a root-bound API client.',
89
+ };
90
+ }
91
+ const reader = read || ((rel) => {
92
+ try { return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { return null; }
93
+ });
94
+ const { violations, scanned, usingClient } = scanPages(list, reader);
95
+ return {
96
+ name: NAME,
97
+ ok: violations.length === 0,
98
+ hardFail: violations.length > 0,
99
+ violations,
100
+ warnings: [],
101
+ note: `${scanned} browser-served script(s) scanned, ${usingClient} building a client: none may combine `
102
+ + "baseUrl:'' with a generated namespaced call (api.<ns>.<method>()) — that pairing 404s every request. "
103
+ + "baseUrl:'' with api.request() and a full path is the correct escape-hatch style and is not flagged.",
104
+ };
105
+ }
106
+
107
+ function main() {
108
+ const r = checkClientBaseUrl();
109
+ for (const v of r.violations) console.error(`✗ ${v}`);
110
+ console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
111
+ process.exit(r.hardFail ? 1 : 0);
112
+ }
113
+
114
+ if (require.main === module) main();
115
+
116
+ module.exports = { checkClientBaseUrl, scanPages, trackedPageScripts, EMPTY_BASEURL, NAMESPACED_CALL };
@@ -160,6 +160,28 @@ async function fetchLive(base, timeoutMs = DEFAULT_TIMEOUT_MS) {
160
160
  }
161
161
  }
162
162
 
163
+ // Is the automation that should have PREVENTED this divergence itself alive? (task 1003158)
164
+ //
165
+ // This script detects the SYMPTOM — live is behind main — and only ever at a moment a human is
166
+ // already looking. What went unwatched is the auto-upgrade subscription itself: when it stopped,
167
+ // nothing said so, and cloudbongos.com sat 36 releases behind for days. Reading the roster here means
168
+ // a dead upgrader is reported in the same breath as the staleness it was supposed to prevent.
169
+ //
170
+ // Speaks ONLY when a subscription manifest is actually present. An ordinary core checkout has none by
171
+ // design (ADR 0136 — it ships empty), and alarming about that every session is exactly the noise that
172
+ // gets a real warning scrolled past. Present-but-broken, or present-and-subscribing-nothing, is the
173
+ // signal worth having: someone meant to enroll instances here and none are being upgraded.
174
+ function subscriptionHealth(dir = REPO_ROOT) {
175
+ try {
176
+ const uc = require('./update-channel');
177
+ const roster = uc.loadSubscriptions({ instanceDir: dir });
178
+ if (!roster.present) return null;
179
+ return uc.describeRoster(roster, { controlPlane: true });
180
+ } catch {
181
+ return null; // never let the health probe break the divergence check it rides along with
182
+ }
183
+ }
184
+
163
185
  async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
164
186
  const target = base || arg('--url') || API_BASE;
165
187
  const local = coreVersionAt(null);
@@ -173,7 +195,9 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
173
195
  const unreleased = unreleasedSince(mainRef);
174
196
  const live = await fetchLive(target, timeoutMs);
175
197
 
176
- if (live.error) return { ok: false, undetermined: true, reason: `could not read ${live.url}: ${live.error}`, target, local, onMain, unreleased };
198
+ const subscriptions = subscriptionHealth();
199
+
200
+ if (live.error) return { ok: false, undetermined: true, reason: `could not read ${live.url}: ${live.error}`, target, local, onMain, unreleased, subscriptions };
177
201
 
178
202
  const behind = cmpSemver(live.coreVersion, onMain) < 0;
179
203
  const ahead = cmpSemver(live.coreVersion, onMain) > 0;
@@ -181,7 +205,7 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
181
205
 
182
206
  return {
183
207
  ok: !behind && !hasUnreleased,
184
- target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased,
208
+ target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased, subscriptions,
185
209
  };
186
210
  }
187
211
 
@@ -232,6 +256,13 @@ function report(r, { quiet = false } = {}) {
232
256
  if (r.ahead) {
233
257
  say(` note: live (${r.live.coreVersion}) is AHEAD of ${r.mainRef} (${r.onMain}) — this checkout is stale; fetch.`);
234
258
  }
259
+ // The watcher's watcher: if this checkout carries a subscription roster and that roster is not
260
+ // healthy, the unattended upgrade is not going to close the gap above on its own.
261
+ if (r.subscriptions && r.subscriptions.level !== 'ok') {
262
+ say('');
263
+ say(` ${r.subscriptions.level === 'alarm' ? '⚠ AUTO-UPGRADE' : 'note: auto-upgrade'} — ${r.subscriptions.message}`);
264
+ if (r.behind) say(' So the gap above will NOT close by itself.');
265
+ }
235
266
  say('');
236
267
  say(' Merging to main deploys nothing by design. "shipped" means "merged" —');
237
268
  say(' it does not mean users can see it.');
@@ -317,4 +348,4 @@ if (require.main === module) {
317
348
  });
318
349
  }
319
350
 
320
- module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver };
351
+ module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver, subscriptionHealth };
@@ -1307,6 +1307,7 @@ const CHECKS = [
1307
1307
  require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
1308
1308
  require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
1309
1309
  require('./baseline-staleness.js').checkBaselineFloors, // Check 26 — task 1003210: a shrink-only baseline that never shrinks is unmanaged debt; a floor exempting one must say why
1310
+ require('./client-baseurl-guard.js').checkClientBaseUrl, // Check 27 — task 1003263: a page must not combine baseUrl:'' with a generated namespaced call (that pairing 404s every request; three pages shipped it, two of them dead in prod)
1310
1311
  ];
1311
1312
 
1312
1313
  function runAll() {
@@ -89,12 +89,23 @@ function formatPublishFailure(data, status, branch, opts = {}) {
89
89
  const envelopeDetails = (data && data.error && typeof data.error === 'object' && data.error.details) || null;
90
90
  const details = envelopeDetails || data || {};
91
91
  const label = message || code || (data && data.message) || `HTTP ${status}`;
92
- // git stderr is multi-line; this is embedded mid-sentence in one log line.
92
+ // Flattened ONLY for the matchers below and the one-line summary. The readable
93
+ // copy is printed verbatim underneath (task 1003222): the server no longer clips
94
+ // stderr, and git's useful line is the LAST one, so collapsing a 20-line push
95
+ // failure into a single run-on sentence hides the very thing that was un-hidden.
93
96
  const reason = details.reason ? String(details.reason).replace(/\s+/g, ' ').trim() : '';
94
97
  const parts = [];
95
98
  if (details.step) parts.push(`step: ${details.step}`);
96
- if (reason) parts.push(`reason: ${reason}`);
99
+ if (details.git_exit_code != null) parts.push(`git exit ${details.git_exit_code}`);
100
+ if (details.github_status != null) parts.push(`GitHub HTTP ${details.github_status}`);
101
+ if (details.timed_out) parts.push('TIMED OUT');
102
+ // The one-line summary stays short and scannable; the full text follows it.
103
+ if (reason) parts.push(`reason: ${reason.length > 200 ? `${reason.slice(0, 200)}…` : reason}`);
97
104
  let out = parts.length ? `${label} (${parts.join('; ')})` : label;
105
+
106
+ // ORDER MATTERS: summary, then the REMEDY, then the verbatim block last. The
107
+ // remedy is the actionable sentence and must not end up stranded below twenty
108
+ // lines of git chatter where it reads as a footnote to the quote.
98
109
  if (NON_FAST_FORWARD_RE.test(reason)) {
99
110
  out += opts.localHistoryRewritten === true
100
111
  ? ` — your LOCAL history was rewritten (a rebase or amend), so ${branch} is no longer a `
@@ -104,6 +115,17 @@ function formatPublishFailure(data, status, branch, opts = {}) {
104
115
  : ` — the server already moved ${branch}: fetch origin, reset onto origin/${branch}, `
105
116
  + 're-run the generators, and re-ship so the push fast-forwards.';
106
117
  }
118
+
119
+ // The untruncated remote message, verbatim and indented so it reads as a quotation
120
+ // rather than as more ship output. Only when it is multi-line — a single-line
121
+ // stderr is already fully present in the summary above, and repeating it is noise.
122
+ const full = details.reason ? String(details.reason).trimEnd() : '';
123
+ if (full && /\n/.test(full)) {
124
+ out += `\n\n --- what the remote actually said ---\n${full.split('\n').map((l) => ` ${l}`).join('\n')}\n --- end ---`;
125
+ if (details.reason_bytes != null && details.reason_bytes > full.length) {
126
+ out += `\n (${details.reason_bytes} bytes at the server; the middle was omitted here)`;
127
+ }
128
+ }
107
129
  return out;
108
130
  }
109
131
 
@@ -32,6 +32,23 @@ const DEFAULT_CHANNEL = 'patch';
32
32
  // core template — a fresh instance subscribes nothing until the operator lists a target here.
33
33
  const SUBSCRIPTIONS_REL = path.join('config', 'update-subscriptions.json');
34
34
 
35
+ // Why a roster came back the way it did. The routine reports this EVERY run: an upgrader whose only
36
+ // job is keeping instances current must never be indistinguishable from one that has stopped. Before
37
+ // task 1003158 an absent manifest, a malformed manifest and a deliberately empty roster all produced
38
+ // the same silent `instances: []`, which is exactly how cloudbongos.com sat 36 releases behind for
39
+ // days with nothing complaining (the same failure SHAPE as blocker 1000097 / task 1002521).
40
+ const ROSTER_OK = 'ok'; // at least one usable entry
41
+ const ROSTER_ABSENT = 'manifest-absent'; // no file at all
42
+ const ROSTER_MALFORMED = 'manifest-malformed';// file present, JSON unparseable
43
+ const ROSTER_EMPTY = 'roster-empty'; // file parsed, nothing subscribable in it
44
+ const ROSTER_REASONS = [ROSTER_OK, ROSTER_ABSENT, ROSTER_MALFORMED, ROSTER_EMPTY];
45
+
46
+ // How many releases a subscribed instance may fall behind before "the upgrader stopped" is the only
47
+ // remaining explanation. A successful sweep takes the HIGHEST permitted version in one jump, so a
48
+ // healthy roster returns to 0 behind after every run — a backlog that keeps growing means no run is
49
+ // landing. 5 leaves room for a busy publish-on-merge day between two daily sweeps without crying wolf.
50
+ const DEFAULT_STALENESS_THRESHOLD = 5;
51
+
35
52
  // ---- semver (stable x.y.z, optional prerelease tag) -------------------------
36
53
  // A deliberately small parser — the repo avoids an npm-semver dependency in the CLI path (see
37
54
  // upgrade.js versionFromDep). We only need compare + the major/minor/patch fields.
@@ -143,16 +160,23 @@ function parseNpmViewVersions(stdout) {
143
160
  // this control plane keeps up to date. Never throws: a missing/malformed file ⇒ an empty roster
144
161
  // (subscribe nothing), because an unreadable manifest must not upgrade anything. Each entry is
145
162
  // normalized to { slug, dir, service, healthUrl, channel } with the channel validated + defaulted.
163
+ //
164
+ // Still fails CLOSED, no longer fails QUIET (task 1003158): every return carries a `reason` from
165
+ // ROSTER_REASONS and a `dropped` count of entries that were unusable, so a caller can say WHY it is
166
+ // about to do nothing. Pair it with describeRoster() for the line to print.
146
167
  function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SUBSCRIPTIONS_REL } = {}) {
147
168
  const abs = path.isAbsolute(file) ? file : path.join(instanceDir, file);
148
169
  let raw;
149
- try { raw = fsImpl.readFileSync(abs, 'utf8'); } catch (_) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: false }; }
170
+ try { raw = fsImpl.readFileSync(abs, 'utf8'); } catch (_) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: false, dropped: 0, reason: ROSTER_ABSENT }; }
150
171
  let json;
151
- try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, error: `malformed JSON: ${e.message}` }; }
172
+ try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, dropped: 0, error: `malformed JSON: ${e.message}`, reason: ROSTER_MALFORMED }; }
152
173
  const defaultChannel = normalizeChannel(json.defaultChannel);
153
174
  const rows = Array.isArray(json.instances) ? json.instances : [];
154
- const instances = rows
155
- .filter((r) => r && typeof r === 'object' && r.dir) // an entry without a dir can't be upgraded — drop it
175
+ // An entry without a dir can't be upgraded — drop it, but COUNT the drops. Silently discarding an
176
+ // operator's malformed row is how an instance believes it is enrolled while nothing ever touches it.
177
+ const usable = rows.filter((r) => r && typeof r === 'object' && r.dir);
178
+ const dropped = rows.length - usable.length;
179
+ const instances = usable
156
180
  .map((r) => ({
157
181
  slug: String(r.slug || r.dir),
158
182
  dir: String(r.dir),
@@ -178,7 +202,90 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
178
202
  ? Object.fromEntries(Object.entries(r.env).filter(([, v]) => typeof v === 'string'))
179
203
  : {},
180
204
  }));
181
- return { defaultChannel, instances, source: abs, present: true };
205
+ return {
206
+ defaultChannel, instances, source: abs, present: true, dropped,
207
+ reason: instances.length ? ROSTER_OK : ROSTER_EMPTY,
208
+ };
209
+ }
210
+
211
+ // ---- reporting: make "nothing happened" audible -----------------------------
212
+
213
+ // Turn a loadSubscriptions() result into the line a routine prints EVERY run, plus a level a caller
214
+ // can act on ('ok' | 'warn' | 'alarm'). The judgement that matters is `controlPlane`: a fresh
215
+ // instance subscribing nothing is the shipped default (ADR 0136 — the manifest ships empty), but a
216
+ // CONTROL PLANE that subscribes nothing has silently stopped doing its only job, and that is an alarm.
217
+ // A malformed manifest is an alarm everywhere — an operator wrote something and it does not parse.
218
+ function describeRoster(roster, { controlPlane = false } = {}) {
219
+ const r = roster || {};
220
+ const reason = ROSTER_REASONS.includes(r.reason) ? r.reason : ROSTER_EMPTY;
221
+ const where = r.source || SUBSCRIPTIONS_REL;
222
+ const dropped = Number(r.dropped) || 0;
223
+ const n = Array.isArray(r.instances) ? r.instances.length : 0;
224
+
225
+ if (reason === ROSTER_MALFORMED) {
226
+ return { level: 'alarm', reason, count: n, dropped,
227
+ message: `update-subscriptions: MALFORMED manifest at ${where} (${r.error || 'unparseable'}) — subscribing NOTHING. No instance will be upgraded until this parses.` };
228
+ }
229
+ if (reason === ROSTER_ABSENT) {
230
+ return { level: controlPlane ? 'alarm' : 'warn', reason, count: n, dropped,
231
+ message: `update-subscriptions: no manifest at ${where} — subscribing NOTHING.${controlPlane ? ' This is a CONTROL PLANE: it is supposed to keep instances current and currently keeps none.' : ' That is the shipped default; add an instance to enroll it.'}` };
232
+ }
233
+ if (reason === ROSTER_EMPTY) {
234
+ return { level: controlPlane ? 'alarm' : 'warn', reason, count: n, dropped,
235
+ message: `update-subscriptions: manifest at ${where} lists no upgradable instance${dropped ? ` (${dropped} entr${dropped === 1 ? 'y' : 'ies'} dropped for a missing \`dir\`)` : ''} — subscribing NOTHING.${controlPlane ? ' This is a CONTROL PLANE: it is supposed to keep instances current and currently keeps none.' : ''}` };
236
+ }
237
+ // Usable entries — still surface partial loss, which is the quiet half of an enrolled-but-untouched
238
+ // instance: the sweep looks healthy while the operator's broken row is never upgraded.
239
+ if (dropped > 0) {
240
+ return { level: 'warn', reason, count: n, dropped,
241
+ message: `update-subscriptions: ${n} instance${n === 1 ? '' : 's'} subscribed, but ${dropped} entr${dropped === 1 ? 'y was' : 'ies were'} DROPPED for a missing \`dir\` in ${where} — those instances are NOT being upgraded.` };
242
+ }
243
+ return { level: 'ok', reason, count: n, dropped,
244
+ message: `update-subscriptions: ${n} instance${n === 1 ? '' : 's'} subscribed from ${where}.` };
245
+ }
246
+
247
+ // ---- watching the watcher ---------------------------------------------------
248
+
249
+ // How far behind is this instance in releases its OWN channel is entitled to take? Not raw version
250
+ // distance: majors, prereleases and (on the patch channel) minors are releases the subscription is
251
+ // correctly declining, and counting them would alarm on instances that are behaving exactly as
252
+ // configured. `behind` is therefore "stable releases this channel WOULD have taken and has not".
253
+ //
254
+ // This is the check that was missing (task 1003158). deploy-divergence.js detects the SYMPTOM — live
255
+ // is behind main — at ship time, when a human is already looking. Nothing watched whether the
256
+ // upgrader itself was still alive, so its death was only ever discovered by someone noticing stale UI
257
+ // days later. A healthy sweep drives `behind` to 0 every run, so a persistent backlog IS the alarm.
258
+ //
259
+ // A `pinned` instance is never stale: channelAllows declines everything, so behind === 0 by
260
+ // construction. That is correct — a pinned instance is not neglected, it is pinned.
261
+ function subscriptionStaleness({ installed, channel, available, threshold = DEFAULT_STALENESS_THRESHOLD } = {}) {
262
+ const ch = normalizeChannel(channel);
263
+ const list = Array.isArray(available) ? available : [];
264
+ // `installed` is parsed and the channel re-normalized ONCE, not per candidate:
265
+ // channelAllows re-does both on every element, and this filter is the only place
266
+ // that calls it in a loop. Cheap either way at registry scale — hoisted because
267
+ // the invariant genuinely is invariant, not because the arithmetic hurts.
268
+ const from = parseSemver(installed);
269
+ const behind = (ch === 'pinned' || !from) ? 0
270
+ : list.filter((v) => channelAllows(installed, v, ch)).length;
271
+ const t = Number(threshold);
272
+ const limit = Number.isFinite(t) && t >= 0 ? t : DEFAULT_STALENESS_THRESHOLD;
273
+ return {
274
+ channel: ch,
275
+ installed: installed == null ? null : String(installed),
276
+ target: resolveChannelTarget({ installed, channel: ch, available: list }),
277
+ behind,
278
+ threshold: limit,
279
+ stale: behind > limit,
280
+ };
281
+ }
282
+
283
+ // The alarm line for one subscribed instance, or null when it is healthy (nothing to say).
284
+ function describeStaleness(st, { slug = 'instance' } = {}) {
285
+ if (!st || !st.stale) return null;
286
+ return `update-subscriptions: ${slug} is ${st.behind} ${st.channel} release${st.behind === 1 ? '' : 's'} behind `
287
+ + `(installed ${st.installed || 'unknown'}, entitled to ${st.target}) — over the ${st.threshold} threshold. `
288
+ + 'A healthy sweep clears this to zero every run, so the upgrader has most likely stopped.';
182
289
  }
183
290
 
184
291
  module.exports = {
@@ -195,4 +302,13 @@ module.exports = {
195
302
  listAvailableVersions,
196
303
  parseNpmViewVersions,
197
304
  loadSubscriptions,
305
+ ROSTER_OK,
306
+ ROSTER_ABSENT,
307
+ ROSTER_MALFORMED,
308
+ ROSTER_EMPTY,
309
+ ROSTER_REASONS,
310
+ DEFAULT_STALENESS_THRESHOLD,
311
+ describeRoster,
312
+ subscriptionStaleness,
313
+ describeStaleness,
198
314
  };
package/src/module-api.js CHANGED
@@ -1124,8 +1124,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1124
1124
  // landed since 1.19.345 with no explicit bump. run 32801147433. (task 1002620)
1125
1125
  // 1.19.347 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1126
1126
  // landed since 1.19.346 with no explicit bump. run 32802265337. (task 1002620)
1127
+ // 1.19.348 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1128
+ // landed since 1.19.347 with no explicit bump. run 32802746363. (task 1002620)
1129
+ // 1.19.349 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1130
+ // landed since 1.19.348 with no explicit bump. run 32803276871. (task 1002620)
1127
1131
  // ---------------------------------------------------------------------------
1128
- const CORE_VERSION = '1.19.347'; // CI auto-patch carrier (ADR 0161); see changelog above
1132
+ const CORE_VERSION = '1.19.349'; // CI auto-patch carrier (ADR 0161); see changelog above
1129
1133
 
1130
1134
  // A namespaced logger so a module's log lines are attributable + consistent.
1131
1135
  // Usage: const log = api.logger('dev-box'); log.info('mounted');