@bongos/core 1.21.119 → 1.21.121

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.
@@ -113,6 +113,32 @@
113
113
  return '<span class="fact-pill fact-pill--warn" title="Taken out of the store. Your copy keeps working, but it will get no more updates.">delisted · no more updates</span>';
114
114
  }
115
115
 
116
+ // A toggle this server has not acted on yet (task 1004463): routes mount and pollers
117
+ // start once, at boot, so the change lands at the next restart.
118
+ // The words are the server's (restartNote in src/bongos/routes/modules.js): 'routes' are
119
+ // its pages, 'pollers' its background jobs, and only what actually waits is named.
120
+ function restartFact(m) {
121
+ const waits = Array.isArray(m.restart_pending) ? m.restart_pending : [];
122
+ if (!waits.length) return '';
123
+ const parts = m.enabled
124
+ ? [waits.includes('routes') && 'its pages do not open', waits.includes('pollers') && 'its background jobs do not run']
125
+ : [waits.includes('routes') && 'its pages still open', waits.includes('pollers') && 'its background jobs still run'];
126
+ const title = `It turns ${m.enabled ? 'on' : 'off'} at the next restart of this server. Until then ${parts.filter(Boolean).join(' and ')}.`;
127
+ const pill = `<span class="fact-pill fact-pill--warn" title="${escapeHtml(title)}">waits for a restart</span>`;
128
+ const door = restartDoor();
129
+ return door ? pill + `<a class="fact-pill" href="${escapeHtml(door)}">restart it</a>` : pill;
130
+ }
131
+ // Where this server's Restart button is: only the platform's own hall has one, on its
132
+ // project page on the hub (task 1004463). Elsewhere whoever runs the server restarts it.
133
+ function restartDoor() {
134
+ const hub = window.OTB && typeof window.OTB.hubOrigin === 'function' ? window.OTB.hubOrigin() : null;
135
+ if (!hub) return null;
136
+ let hubHost = '';
137
+ try { hubHost = new URL(hub).hostname; } catch (err) { return null; }
138
+ const here = String(window.location.hostname || '').replace(/^builders\./, '');
139
+ return here === hubHost ? `${hub}/projects?view=mine` : null;
140
+ }
141
+
116
142
  // The lines the row's details disclosure opens onto: the facts the payload
117
143
  // carries that the card never rendered — what the module contributes, the
118
144
  // core it needs, who maintains it and what it hands off to.
@@ -159,7 +185,7 @@
159
185
  <div class="ov-row__main">
160
186
  <span class="ov-row__title">${escapeHtml(m.title || m.key)}<span class="ov-mono">${escapeHtml(m.key)}</span></span>
161
187
  ${desc ? `<p class="ov-row__sub mod-row__desc">${escapeHtml(desc)}</p>` : ''}
162
- <div class="ov-row__facts">${provenancePill(m)}${attributionFacts(m)}${upkeepFact(m)}${howtoFact(m)}${delistedFact(m)}</div>
188
+ <div class="ov-row__facts">${provenancePill(m)}${attributionFacts(m)}${upkeepFact(m)}${howtoFact(m)}${delistedFact(m)}${restartFact(m)}</div>
163
189
  ${lines ? `<details class="mod-more"${openKeys.has(m.key) ? ' open' : ''}><summary aria-label="Details for ${escapeHtml(m.title || m.key)}"></summary>${lines}</details>` : ''}
164
190
  </div>
165
191
  <div class="ov-row__right mod-row__right">${rightHtml(m)}</div>
@@ -289,13 +315,13 @@
289
315
  const body = res.data || {};
290
316
  if (!res.ok) throw new Error(body.message || `enable → ${res.status}`);
291
317
  const m = allModules.find((x) => x.key === key);
292
- if (m) m.enabled = next;
318
+ if (m) { m.enabled = next; if (Array.isArray(body.restart_pending)) m.restart_pending = body.restart_pending; }
293
319
  btn.setAttribute('aria-pressed', next ? 'true' : 'false');
294
320
  btn.textContent = next ? 'enabled' : 'disabled';
295
321
  const figs = $('#mod-figs');
296
322
  if (figs) figs.innerHTML = figsHTML();
297
323
  renderRows();
298
- toast(body.note || (next ? `${key} enabled.` : `${key} disabled.`), { duration: 3200 });
324
+ toast(body.note || (next ? `${key} enabled.` : `${key} disabled.`), { duration: body.restart_pending && body.restart_pending.length ? 7000 : 3200 });
299
325
  } catch (err) {
300
326
  console.error('[modules] toggle failed', err);
301
327
  toast(err.message || 'Could not change that module.', { duration: 3200 });
@@ -19,7 +19,11 @@
19
19
  // docs warn it can be spoofed, so that redirect only records that the founder
20
20
  // SAYS they installed it ('github-app-install-reported'), which makes the
21
21
  // runner look again.
22
- // sign_in — the runner saw the project report auth_configured (the manifest row 'done').
22
+ // sign_in — the runner saw the project report auth_configured (the manifest row 'done'),
23
+ // OR the liveness sweep did ('github-sign-in-seen', task 1004410). The second
24
+ // is what keeps the step honest when sign-in was wired some other way (a manual
25
+ // OAuth app, or a GitHub tab that never came back): the project's own answer
26
+ // is the fact the step names, so it ticks whatever the attempt row says.
23
27
  // device — the runner started a device-flow sign-in with the app's client id and GitHub
24
28
  // did not refuse it with device_flow_disabled ('github-app-device-flow').
25
29
  // GitHub offers no manifest field and no API to switch device flow on (the
@@ -43,6 +47,9 @@ const SETUP_EVENTS = Object.freeze({
43
47
  // the founder pressed "Pick up where it stopped" or "Check again" (task 1004652). Like an
44
48
  // install report it re-opens the runner's checking window; it claims nothing itself.
45
49
  checkRequested: 'github-app-check-requested',
50
+ // the liveness sweep saw the project's own /healthz answer auth_configured: true (task
51
+ // 1004410). Recorded once per attempt (recordSignInSeen); no detail.
52
+ signInSeen: 'github-sign-in-seen',
46
53
  });
47
54
 
48
55
  const { effectiveManifestStatus } = require('./manifest-truth');
@@ -99,7 +106,7 @@ async function githubSetupFacts(db, instanceId, since) {
99
106
  const org = find(SETUP_EVENTS.appOrg);
100
107
  return {
101
108
  installReportedAt: at(SETUP_EVENTS.installReported), installedAt: at(SETUP_EVENTS.installed), deviceFlowAt: at(SETUP_EVENTS.deviceFlow),
102
- checkRequestedAt: at(SETUP_EVENTS.checkRequested),
109
+ checkRequestedAt: at(SETUP_EVENTS.checkRequested), signInSeenAt: at(SETUP_EVENTS.signInSeen),
103
110
  appOrg: org && validGithubLogin(org.detail) ? org.detail : null,
104
111
  };
105
112
  }
@@ -108,22 +115,42 @@ async function githubSetupFacts(db, instanceId, since) {
108
115
  // vocabulary) + the facts above. A closed vocabulary the page turns into words:
109
116
  // 'done' · 'now' (waiting on GitHub, the founder or the platform) · 'todo' (not reached)
110
117
  // · 'stopped' (this attempt failed at this step) · 'later' (device flow only: optional, off).
118
+ // A project that answers sign-in works with no app of ours recorded was wired some other
119
+ // way: approve and install name steps it never needed, so they read done and
120
+ // `wired_elsewhere` says why (task 1004410). With an app recorded, they keep their own facts.
111
121
  // PURE.
112
122
  function githubChecklist(status, appSlug, facts = {}) {
113
123
  const s = String(status || '');
114
124
  const inFlight = s === 'pending' || s === 'code_received' || s === 'converting';
115
125
  const failed = s === 'error' || s === 'expired';
116
- const approve = appSlug ? 'done' : inFlight ? 'now' : failed ? 'stopped' : 'todo';
117
- const install = facts.installedAt ? 'done' : appSlug ? 'now' : 'todo';
118
- const signIn = s === 'done' ? 'done' : appSlug && failed ? 'stopped' : appSlug ? 'now' : 'todo';
126
+ const seen = !!facts.signInSeenAt;
127
+ const elsewhere = seen && !appSlug;
128
+ const approve = appSlug || elsewhere ? 'done' : inFlight ? 'now' : failed ? 'stopped' : 'todo';
129
+ const install = facts.installedAt || elsewhere ? 'done' : appSlug ? 'now' : 'todo';
130
+ const signIn = s === 'done' || seen ? 'done' : appSlug && failed ? 'stopped' : appSlug ? 'now' : 'todo';
119
131
  const device = facts.deviceFlowAt ? 'done' : s === 'done' ? 'later' : 'todo';
120
132
  return {
121
133
  approve, install, sign_in: signIn, device_flow: device,
122
134
  // what "is alive" waits for — device flow is optional by design
123
135
  connected: approve === 'done' && install === 'done' && signIn === 'done',
136
+ wired_elsewhere: elsewhere,
124
137
  };
125
138
  }
126
139
 
140
+ // The liveness sweep saw this project answer auth_configured: true (task 1004410). One row
141
+ // per attempt: nothing is written when one already stands since the latest attempt began
142
+ // (or ever, for a project with no attempt), so a five-minute sweep never grows the ledger.
143
+ async function recordSignInSeen(db, instanceId) {
144
+ await db.query(
145
+ `INSERT INTO provisioning_events (instance_id, event, actor)
146
+ SELECT $1, $2, 'liveness'
147
+ WHERE NOT EXISTS (
148
+ SELECT 1 FROM provisioning_events e
149
+ WHERE e.instance_id = $1 AND e.event = $2
150
+ AND e.created_at >= COALESCE((SELECT max(m.created_at) FROM provisioning_oauth_manifests m WHERE m.instance_id = $1), 'epoch'::timestamptz))`,
151
+ [instanceId, SETUP_EVENTS.signInSeen]);
152
+ }
153
+
127
154
  // Is the runner still checking this attempt? While it is, an install or a 'later'
128
155
  // device-flow step can still tick itself, so the page keeps polling. PURE.
129
156
  function stillVerifying(row, facts = {}, now = Date.now()) {
@@ -147,6 +174,8 @@ function stillVerifying(row, facts = {}, now = Date.now()) {
147
174
  // PURE.
148
175
  function pickUpFor(row, status, checklist, verifying, now = Date.now()) {
149
176
  if (!row) return null;
177
+ // sign-in works without our app (task 1004410): there is nothing left to pick up
178
+ if (checklist && checklist.wired_elsewhere) return null;
150
179
  if (status === 'pending') return 'reopen';
151
180
  if (status === 'expired') return 'fresh';
152
181
  if (row.app_slug || row.app_id) {
@@ -195,8 +224,12 @@ async function requeueConversion(db, id) {
195
224
  // lands on the setup page, which sends the founder back to their projects.
196
225
  // A failed facts read is "nothing verified yet" — the honest 'now' states, never a 500.
197
226
  async function githubSetupRead(db, inst, row, status, { now = Date.now(), withState = true } = {}) {
198
- if (!row) return { checklist: githubChecklist(null, null), install_url: null, repo: targetRepo(inst), verifying: false, app_org: null, pick_up: null };
199
227
  let facts = {};
228
+ if (!row) {
229
+ // no attempt: only the project's own answer can tick anything (task 1004410)
230
+ try { facts = (inst && inst.id != null) ? await module.exports.githubSetupFacts(db, inst.id, null) : {}; } catch (_) { facts = {}; }
231
+ return { checklist: githubChecklist(null, null, facts), install_url: null, repo: targetRepo(inst), verifying: false, app_org: null, pick_up: null };
232
+ }
200
233
  // through module.exports so a route test can stub the one DB read on the cached module
201
234
  try { facts = await module.exports.githubSetupFacts(db, row.instance_id, row.created_at); } catch (_) { facts = {}; }
202
235
  const checklist = githubChecklist(status, row.app_slug, facts);
@@ -216,5 +249,5 @@ async function githubSetupRead(db, inst, row, status, { now = Date.now(), withSt
216
249
 
217
250
  module.exports = {
218
251
  SETUP_EVENTS, VERIFY_WINDOW_MS, installUrlFor, targetRepo, githubSetupFacts, githubChecklist, stillVerifying, githubSetupRead,
219
- validGithubLogin, pickUpFor, mintGuard, requeueConversion,
252
+ validGithubLogin, pickUpFor, mintGuard, requeueConversion, recordSignInSeen,
220
253
  };
@@ -27,6 +27,7 @@ const dns = require('node:dns').promises;
27
27
  const { Agent: UndiciAgent } = require('undici');
28
28
  const api = require('../../../src/module-api');
29
29
  const provisioning = require('../provisioning');
30
+ const githubSetup = require('../github-setup');
30
31
  const { planNotices, postNotice } = require('./liveness-notify');
31
32
  // task 1004000: the runner-drift check rides this tick rather than installing a timer of
32
33
  // its own — the sweep already runs every ~5 min on the one host that can see the systemd
@@ -163,8 +164,35 @@ function probeReason(err) {
163
164
  return err.message ? String(err.message).slice(0, 40) : 'error';
164
165
  }
165
166
 
167
+ // The /healthz answer is tiny; read no more than this of a body a builder's host chose.
168
+ const BODY_CAP_BYTES = 4096;
169
+
170
+ // Did the answer say auth_configured: true (task 1004410)? Reads at most BODY_CAP_BYTES
171
+ // and answers false for anything else — a big, slow or non-JSON body is never "configured".
172
+ async function readAuthConfigured(res) {
173
+ try {
174
+ if (!res.body || typeof res.body.getReader !== 'function') {
175
+ return typeof res.json === 'function' ? (await res.json()).auth_configured === true : false;
176
+ }
177
+ const reader = res.body.getReader();
178
+ const chunks = [];
179
+ let size = 0;
180
+ for (;;) {
181
+ const { done, value } = await reader.read();
182
+ if (done) break;
183
+ size += value.length;
184
+ if (size > BODY_CAP_BYTES) { reader.cancel().catch(() => {}); return false; }
185
+ chunks.push(Buffer.from(value));
186
+ }
187
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')).auth_configured === true;
188
+ } catch (_) {
189
+ return false;
190
+ }
191
+ }
192
+
166
193
  // Knock once. Any 2xx = up; everything else (non-2xx, refused, DNS, TLS, timeout)
167
- // = down with a reason. Never throws — a probe result is the whole point.
194
+ // = down with a reason. Never throws — a probe result is the whole point. An up answer
195
+ // also carries whether the project says its sign-in is configured (task 1004410).
168
196
  async function probeHealthz(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl, pin } = {}) {
169
197
  const doFetch = fetchImpl || globalThis.fetch;
170
198
  const dispatcher = pin && !fetchImpl ? pinnedDispatcher(pin, timeoutMs) : null;
@@ -178,7 +206,7 @@ async function probeHealthz(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl, pin
178
206
  headers: { accept: 'application/json, text/plain;q=0.9, */*;q=0.8' },
179
207
  ...(dispatcher ? { dispatcher } : {}),
180
208
  });
181
- if (res.status >= 200 && res.status < 300) return { up: true };
209
+ if (res.status >= 200 && res.status < 300) return { up: true, authConfigured: await readAuthConfigured(res) };
182
210
  return { up: false, reason: `http ${res.status}` };
183
211
  } catch (e) {
184
212
  return { up: false, reason: probeReason(e) };
@@ -229,6 +257,10 @@ async function sweepOnce(deps = {}) {
229
257
  if (row.domain && !row.dns_resolved_at && (pin || probe.up)) {
230
258
  await provisioning.recordDnsResolved(db, row.id).catch((e) => log(`[liveness] dns record failed (non-blocking): ${e && e.message ? e.message : e}`));
231
259
  }
260
+ // The project's own word that sign-in works ticks its setup checklist (task 1004410).
261
+ if (probe.up && probe.authConfigured) {
262
+ await githubSetup.recordSignInSeen(db, row.id).catch((e) => log(`[liveness] sign-in record failed (non-blocking): ${e && e.message ? e.message : e}`));
263
+ }
232
264
  return { row, probed: true, prevUp: rec ? rec.prev_up : null, ...probe };
233
265
  }));
234
266
 
@@ -123,9 +123,11 @@ const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'cloud-host', 'control-plane
123
123
  // SETTINGS_APPLY_FROM: each action declaring its own gate is this module's
124
124
  // convention, and the two answer different questions.
125
125
  const RESTART_FROM = new Set(['active']);
126
- // Same reachability rule as the settings push (the runner can only restart what it runs),
127
- // minus 'control-plane': task 1004334 gave the self row a settings push, not a restart door.
128
- const RESTART_SHAPES = new Set(['co-tenant', 'cloud-host']);
126
+ // Same reachability rule as the settings push (the runner can only restart what it runs).
127
+ // 'control-plane' joined in task 1004463: a module toggled in its hall takes effect only at
128
+ // the next restart, the guard already allows 'restart' on the self row, and the owner's only
129
+ // other way was a shell on the box.
130
+ const RESTART_SHAPES = new Set(['co-tenant', 'cloud-host', 'control-plane']);
129
131
 
130
132
  // Per-project settings (task 1003040, goal 1000058): the known keys + their
131
133
  // vocabularies, enforced at the API layer — the jsonb `settings` column
@@ -330,7 +330,7 @@ await ta('the pin is load-bearing on the REAL client: same URL, the pinned addre
330
330
  try {
331
331
  // 'pinned.invalid' resolves nowhere; only the pin can make this connect.
332
332
  const hit = await liveness.probeHealthz(url, { timeoutMs: 3000, pin: { address: '127.0.0.1', family: 4 } });
333
- assert.deepEqual(hit, { up: true }, 'the pinned address is what gets connected');
333
+ assert.deepEqual(hit, { up: true, authConfigured: false }, 'the pinned address is what gets connected');
334
334
  const miss = await liveness.probeHealthz(url, { timeoutMs: 3000, pin: { address: '127.0.0.2', family: 4 } });
335
335
  assert.equal(miss.up, false, 'a different pin cannot reach the same server — the pin, not DNS, decides');
336
336
  } finally {
@@ -6987,7 +6987,11 @@ summary{min-height:24px;padding:3px 0;}
6987
6987
  including when a LATER step stopped (sign-in unconfirmed) */
6988
6988
  var appMade = (ghStep('approve') === 'done' || live.appSlug) && live.manifest !== 'expired' &&
6989
6989
  (live.manifest !== 'error' || live.manifestFailure === 'sign_in_unconfirmed');
6990
- if (appMade) { approve.state = 'done'; approve.sub = name + ' app on ' + (appOrgLogin() || 'your account'); }
6990
+ /* sign-in works with no app of ours: it was wired another way, and the project's own
6991
+ answer is the fact (task 1004410) — never "waiting on GitHub" for a step it skipped */
6992
+ var elsewhere = !!ghStep('wired_elsewhere');
6993
+ if (elsewhere) { approve.state = 'done'; approve.sub = 'Not needed. Sign-in was set up another way.'; }
6994
+ else if (appMade) { approve.state = 'done'; approve.sub = name + ' app on ' + (appOrgLogin() || 'your account'); }
6991
6995
  else if (!dom) { approve.state = 'todo'; approve.sub = 'Opens once it has an address.'; }
6992
6996
  /* GitHub refuses app manifests whose hook URL it cannot reach (owner walk, 2026-08-12) */
6993
6997
  else if (!apexBase()) { approve.state = 'todo'; approve.sub = 'GitHub can’t reach a platform on a local address, so this works only on a public one.'; }
@@ -7010,7 +7014,8 @@ summary{min-height:24px;padding:3px 0;}
7010
7014
 
7011
7015
  var install = { key: 'install', title: 'Install it on your repo', sub: repo || '' };
7012
7016
  var installUrl = live.installUrl || (live.appSlug ? 'https://github.com/apps/' + encodeURIComponent(live.appSlug) + '/installations/new' : null);
7013
- if (ghStep('install') === 'done') install.state = 'done';
7017
+ if (elsewhere) { install.state = 'done'; install.sub = 'Not needed. Sign-in was set up another way.'; }
7018
+ else if (ghStep('install') === 'done') install.state = 'done';
7014
7019
  else if (!live.appSlug) install.state = 'todo';
7015
7020
  /* the runner stopped checking and GitHub never confirmed the install: the link opens the
7016
7021
  SAME app's install page, and the click asks the runner to look again */
@@ -8536,9 +8541,11 @@ summary{min-height:24px;padding:3px 0;}
8536
8541
  last_verified_up === false is the verdict; null means never probed and is
8537
8542
  deliberately NOT the same claim (publicInstance keeps the two apart), so a
8538
8543
  brand-new project never grows a Restart button it has no use for. A door on
8539
- every healthy card would only invite needless downtime. */
8544
+ every healthy card would only invite needless downtime. The ONE exception is the
8545
+ platform's own row (task 1004463): a module switched on or off in its hall waits for
8546
+ a restart, so its owner always has the door. */
8540
8547
  function canRestart(inst) {
8541
- return inst.status === 'active' && inst.last_verified_up === false && !inst.open_intent;
8548
+ return inst.status === 'active' && !inst.open_intent && (inst.last_verified_up === false || inst.hosting_shape === 'control-plane');
8542
8549
  }
8543
8550
  /* keep watching while the platform is doing something to this instance */
8544
8551
  function shouldWatch(inst) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.119",
3
+ "version": "1.21.121",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.119",
9
+ "version": "1.21.121",
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.21.119",
3
+ "version": "1.21.121",
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",
@@ -9696,5 +9696,17 @@
9696
9696
  "id": "1004664",
9697
9697
  "text": "A profile privacy test that had been switched off because it failed on a fresh database is fixed and switched back on, so it guards the public profile page again."
9698
9698
  }
9699
+ ],
9700
+ "1.21.120": [
9701
+ {
9702
+ "id": "1004410",
9703
+ "text": "The new-project page no longer says GitHub sign-in is unfinished when it already works: the platform now checks the project itself and ticks the step."
9704
+ }
9705
+ ],
9706
+ "1.21.121": [
9707
+ {
9708
+ "id": "1004463",
9709
+ "text": "Turning a module on or off now says honestly when it takes effect at the next restart, and the platform's own server can be restarted from its project page instead of needing a login to the machine."
9710
+ }
9699
9711
  ]
9700
9712
  }
@@ -91,7 +91,7 @@ const EXEMPTIONS = [
91
91
  + 're-opens for review.',
92
92
  },
93
93
  {
94
- at: 'src/module-loader/loader.js:196',
94
+ at: 'src/module-loader/loader.js:198',
95
95
  why: 'The plugin loader, and the second site the audit named (B50 "motivating sites"). It '
96
96
  + 'require()s a discovered module\'s route file, so the specifier is a runtime path BY '
97
97
  + 'DESIGN — a plugin system whose module paths are compile-time constants is not a plugin '
@@ -103,7 +103,7 @@ const EXEMPTIONS = [
103
103
  + 'absorbed into this check.',
104
104
  },
105
105
  {
106
- at: 'src/module-loader/loader.js:309',
106
+ at: 'src/module-loader/loader.js:347',
107
107
  why: 'The poller twin of the route require at :196 — same DEFAULT_ROOTS bound, same plugin-by-'
108
108
  + 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
109
109
  + 'change to either call re-opens only that one.',
@@ -927,8 +927,10 @@ async function settingsApplyInstance(inst, deps) {
927
927
  // ---------------------------------------------------------------------------
928
928
  async function restartInstance(inst, deps) {
929
929
  const { db, provisioning, exec, log, apply, privileged } = deps;
930
- // Same box routing as settingsApplyInstance: a standalone runs control-plane-local.
931
- const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
930
+ // Same box routing as settingsApplyInstance: a standalone runs control-plane-local, and the
931
+ // control plane's own row IS this box (task 1004463).
932
+ const onControlBox = inst.hosting_shape === 'cloud-host' || inst.hosting_shape === 'control-plane';
933
+ const boxExec = onControlBox ? (deps.controlExec || exec) : exec;
932
934
  const sudoP = privileged ? 'sudo ' : '';
933
935
  log(`restart ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
934
936
 
@@ -77,6 +77,8 @@ const express = require('express');
77
77
  const auth = require('../auth');
78
78
  const { asyncHandler, validateOrRespond, LIMITS } = require('./_helpers');
79
79
  const loader = require('../../module-loader/loader');
80
+ // named, not loader.restartPendingFor: knip sees a named require, never a member read
81
+ const { restartPendingFor } = require('../../module-loader/loader');
80
82
  const catalog = require('../../module-loader/catalog');
81
83
  const modulesLib = require('../../modules');
82
84
  // scripts/gds/ is a sibling CORE-package dir (both resolve under
@@ -110,6 +112,15 @@ const log = require('../logger').logger.child({ src: 'modules' });
110
112
  const { listOverrides, setOverride } = require('../module-overrides');
111
113
  const { listCatalog } = require('../module-listing');
112
114
 
115
+ // The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
116
+ // module's pages, 'pollers' its background jobs. PURE.
117
+ function restartNote(waits, enabled) {
118
+ const parts = enabled
119
+ ? [waits.includes('routes') && 'its pages do not open', waits.includes('pollers') && 'its background jobs do not run']
120
+ : [waits.includes('routes') && 'its pages still open', waits.includes('pollers') && 'its background jobs still run'];
121
+ return `It turns ${enabled ? 'on' : 'off'} at the next restart of this server. Until then ${parts.filter(Boolean).join(' and ')}.`;
122
+ }
123
+
113
124
  // A packaged submission embeds every file's content (base64) — cap the total
114
125
  // so a runaway/misbuilt module can't bloat the review-queue table. Generous
115
126
  // headroom over what a real feature module carries (routes + migrations + UI
@@ -258,6 +269,8 @@ module.exports = function buildModulesRouter() {
258
269
  ? `/builders/modules?howto=${encodeURIComponent(m.key)}&version=${encodeURIComponent(String(m.version))}`
259
270
  : null;
260
271
  m.store_delisted = m.source === 'instance' && delistedKeys.has(m.key);
272
+ // what of this module still waits for a restart to match its toggle (task 1004463)
273
+ m.restart_pending = restartPendingFor(m.key, m.enabled === true);
261
274
  }
262
275
  res.json({
263
276
  modules: all,
@@ -297,6 +310,9 @@ module.exports = function buildModulesRouter() {
297
310
  // the write path deliberately is not.
298
311
  const resolvedSet = await setOverride(key, enabled, { builderId: req.builder && req.builder.id });
299
312
  const effective = resolvedSet ? resolvedSet[key] === true : enabled;
313
+ // Routes mount and pollers start once, at boot (task 1004463): say so rather than
314
+ // "live now" when this change waits for the next restart of this server.
315
+ const waits = restartPendingFor(key, effective);
300
316
 
301
317
  console.log(`[gds] module "${key}" ${enabled ? 'enabled' : 'disabled'} by builder ${req.builder && req.builder.id}`);
302
318
  res.json({
@@ -308,9 +324,12 @@ module.exports = function buildModulesRouter() {
308
324
  // who is not told so will re-click the toggle and blame the feature.
309
325
  effective,
310
326
  source: (resolvedSet && resolvedSet.sources && resolvedSet.sources[key]) || 'override',
311
- note: effective === enabled
312
- ? 'Live on this box now, and durable — it survives the next deploy.'
313
- : `Recorded, but "${key}" still resolves to ${effective}: a higher-precedence layer (env, or the spend guard) is winning.`,
327
+ restart_pending: waits,
328
+ note: effective !== enabled
329
+ ? `Recorded, but "${key}" still resolves to ${effective}: a higher-precedence layer (env, or the spend guard) is winning.`
330
+ : waits.length
331
+ ? `Saved, and it survives the next deploy. ${restartNote(waits, enabled)}`
332
+ : 'Live on this box now, and durable — it survives the next deploy.',
314
333
  });
315
334
  }));
316
335
 
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.119'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.121'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -112,7 +112,7 @@ function discoverModules({ roots = DEFAULT_ROOTS, core = coreVersion() } = {}) {
112
112
  // Cached singleton for app code (lazy, like src/modules.js's modules()).
113
113
  let _cache = null;
114
114
  function discovered(opts) { return (_cache ||= discoverModules(opts)); }
115
- function _reset() { _cache = null; _startedPollers = []; }
115
+ function _reset() { _cache = null; _startedPollers = []; _boot = { routes: null, pollers: null }; _bootShape = null; }
116
116
 
117
117
  // The discovered manifests as MODULE_REGISTRY-shaped entries, so src/modules.js
118
118
  // can merge them with its built-in registry. Empty until a module exists.
@@ -190,8 +190,10 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
190
190
  const enabled = enabledModules({ isEnabled }, opts);
191
191
  verifyEnabledPorts(enabled);
192
192
  let mounted = 0;
193
- for (const { dir, manifest } of enabled) {
193
+ _boot.routes = new Set();
194
+ for (const { key, dir, manifest } of enabled) {
194
195
  const routeKeys = (manifest.contributes && manifest.contributes.routes) || [];
196
+ if (routeKeys.length) _boot.routes.add(key);
195
197
  for (const rk of routeKeys) {
196
198
  const factory = require(path.join(dir, 'routes', `${rk}.js`));
197
199
  const sub = typeof factory === 'function' ? factory() : factory;
@@ -284,6 +286,41 @@ function moduleWebSurfaces({ isEnabled } = {}, opts) {
284
286
  return out;
285
287
  }
286
288
 
289
+ // What this process mounted at boot (task 1004463): the keys whose routes were mounted
290
+ // and whose pollers started. null until that boot step has run in this process — so a
291
+ // script or test that never booted is never told a restart is pending.
292
+ let _boot = { routes: null, pollers: null };
293
+ // key → { routes, pollers }: whether each discovered module mounts routes / runs a poller in
294
+ // this process. Module files do not change under a running process, so it is worked out
295
+ // once, on first ask, rather than stat'ing disk on every GET /modules.
296
+ let _bootShape = null;
297
+ function bootShape(opts) {
298
+ if (_bootShape) return _bootShape;
299
+ _bootShape = new Map();
300
+ for (const { key, dir, manifest } of discovered(opts).loaded) {
301
+ const c = manifest.contributes || {};
302
+ _bootShape.set(key, {
303
+ routes: (c.routes || []).length > 0,
304
+ // the same rule startModulePollers applies: a poller with no file here runs elsewhere
305
+ pollers: (c.pollers || []).some((pk) => fs.existsSync(path.join(dir, 'pollers', `${pk}.js`))),
306
+ });
307
+ }
308
+ return _bootShape;
309
+ }
310
+
311
+ // Does turning module `key` to `enabled` wait for a restart in THIS process (task
312
+ // 1004463)? Routes mount and pollers start once, at boot, so a module switched on after
313
+ // boot has no pages yet, and one switched off keeps answering until the restart. Returns
314
+ // what waits — [] when the change is live now. PURE over the boot record and the manifest.
315
+ function restartPendingFor(key, enabled, opts) {
316
+ const shape = bootShape(opts).get(key);
317
+ if (!shape) return [];
318
+ const waits = [];
319
+ if (_boot.routes && shape.routes && _boot.routes.has(key) !== !!enabled) waits.push('routes');
320
+ if (_boot.pollers && shape.pollers && _boot.pollers.has(key) !== !!enabled) waits.push('pollers');
321
+ return waits;
322
+ }
323
+
287
324
  // Started poller handles (key, name, module), so stopModulePollers stops exactly
288
325
  // what startModulePollers started — and a test can introspect them.
289
326
  let _startedPollers = [];
@@ -299,6 +336,7 @@ let _startedPollers = [];
299
336
  // No-op when nothing is discovered/enabled. Returns the started-handle list.
300
337
  function startModulePollers(deps = {}, { isEnabled } = {}, opts) {
301
338
  const started = [];
339
+ _boot.pollers = new Set();
302
340
  for (const { key, dir, manifest } of discovered(opts).loaded) {
303
341
  if (typeof isEnabled === 'function' && !isEnabled(key)) continue;
304
342
  const pollerKeys = (manifest.contributes && manifest.contributes.pollers) || [];
@@ -323,6 +361,7 @@ function startModulePollers(deps = {}, { isEnabled } = {}, opts) {
323
361
  r.catch((e) => console.error(`[module-loader] poller ${key}/${pk} start error: ${e && e.message ? e.message : e}`));
324
362
  }
325
363
  started.push({ key, poller: pk, handle: poller });
364
+ _boot.pollers.add(key);
326
365
  }
327
366
  } catch (e) {
328
367
  console.error(`[module-loader] poller ${key}/${pk} start threw: ${e && e.message ? e.message : e}`);
@@ -370,6 +409,7 @@ module.exports = {
370
409
  moduleWebSurfaces,
371
410
  startModulePollers,
372
411
  stopModulePollers,
412
+ restartPendingFor,
373
413
  loaderSnapshot,
374
414
  coreVersion,
375
415
  dedupeRoots,
@@ -35,7 +35,7 @@ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>
35
35
 
36
36
  test('each step reads only what the server has seen — the honest "not yet" states included', () => {
37
37
  const none = GS.githubChecklist(null, null);
38
- assert.deepEqual(none, { approve: 'todo', install: 'todo', sign_in: 'todo', device_flow: 'todo', connected: false }, 'never started');
38
+ assert.deepEqual(none, { approve: 'todo', install: 'todo', sign_in: 'todo', device_flow: 'todo', connected: false, wired_elsewhere: false }, 'never started');
39
39
  assert.equal(GS.githubChecklist('pending', null).approve, 'now', 'waiting for the approval on GitHub');
40
40
  for (const s of ['code_received', 'converting']) {
41
41
  const c = GS.githubChecklist(s, null);