@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.
- package/.bongos-core.json +46 -36
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0199-restart-an-unreachable-active-project.md +4 -0
- package/docs/api/openapi.json +2 -0
- package/docs/copy-inventory.md +13 -11
- package/docs/copy-registry.json +30 -12
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +3 -3
- package/modules/hall-ui/public/modules.js +29 -3
- package/modules/provisioning/github-setup.js +40 -7
- package/modules/provisioning/pollers/liveness-sweep.js +34 -2
- package/modules/provisioning/provisioning.js +5 -3
- package/modules/provisioning/tests/liveness.mjs +1 -1
- package/modules/public-landing/public/projects.html +11 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/exec-path-guard.js +2 -2
- package/scripts/gds/provision.js +4 -2
- package/src/bongos/routes/modules.js +22 -3
- package/src/module-api.js +1 -1
- package/src/module-loader/loader.js +42 -2
- package/tests/github_one_trip.mjs +1 -1
- package/tests/github_sign_in_seen.mjs +148 -0
- package/tests/hall_catalog_world.mjs +1 -1
- package/tests/module_loader.mjs +31 -0
- package/tests/module_toggle_restart.mjs +73 -0
- package/tests/projects_hub_pre_uat.mjs +4 -1
- package/tests/provision_restart.mjs +3 -2
- package/tests/provisioning_restart.mjs +6 -3
- package/tests/provisioning_settings_apply.mjs +2 -2
- package/tests/setup_status_facts.mjs +33 -0
|
@@ -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
|
|
117
|
-
const
|
|
118
|
-
const
|
|
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
|
-
//
|
|
128
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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:
|
|
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:
|
|
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.',
|
package/scripts/gds/provision.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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.
|
|
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
|
-
|
|
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);
|