@bongos/core 1.20.37 → 1.20.39
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 +119 -64
- package/.claude/skills/backlog-review/SKILL.md +1 -1
- package/.claude/skills/bongos-feedback/SKILL.md +66 -0
- package/.claude/skills/collab-review/SKILL.md +1 -1
- package/.claude/skills/priority-session/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/status/SKILL.md +1 -1
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
- package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +188 -2
- package/docs/api-reference.md +12 -4
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +18 -18
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +253 -250
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/autonomy/db.js +39 -8
- package/modules/autonomy/fence.js +26 -4
- package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
- package/modules/autonomy/routes/autonomy.js +28 -1
- package/modules/hall-ui/public/gate.js +33 -4
- package/modules/ideas/routing.js +8 -1
- package/modules/npm-release/preview/childlog.js +95 -0
- package/modules/npm-release/preview/commands.js +46 -5
- package/modules/npm-release/preview/env.js +18 -7
- package/modules/npm-release/preview/runtime.js +43 -9
- package/modules/npm-release/preview/supervisor.js +170 -31
- package/modules/npm-release/public/work.css +3 -0
- package/modules/npm-release/public/work.js +6 -1
- package/modules/npm-release/routes/preview.js +1 -0
- package/modules/platform-identity/instance-feedback.js +142 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso.js +62 -0
- package/modules/provisioning/render-standup.js +13 -1
- package/modules/public-landing/public/projects.html +22 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +30 -0
- package/scripts/gds/autobongos-run.js +14 -4
- package/scripts/gds/feedback-send.js +131 -0
- package/scripts/gds/provision-render.js +13 -1
- package/scripts/gds/render-payload.js +6 -5
- package/scripts/gds/update-channel.js +82 -0
- package/src/bongos/routes/feedback.js +110 -0
- package/src/bongos/routes.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_fence.mjs +62 -0
- package/tests/autobongos_loop.mjs +76 -0
- package/tests/autonomy_fence_priority.mjs +211 -0
- package/tests/bongos_cli.mjs +7 -1
- package/tests/filing_abuse_matrix.mjs +7 -2
- package/tests/idea_routing.mjs +1 -1
- package/tests/instance_feedback.mjs +327 -0
- package/tests/npm_release_preview_commands.mjs +1 -1
- package/tests/npm_release_preview_diagnosis.mjs +376 -0
- package/tests/npm_release_preview_supervisor.mjs +4 -4
- package/tests/projects_hub_app_step.mjs +49 -0
- package/tests/projects_hub_render_connect.mjs +2 -1
- package/tests/provision_render.mjs +23 -2
- package/tests/update_channel_db.mjs +124 -0
|
@@ -22,6 +22,7 @@ const REAP_EVERY_MS = 60 * 1000;
|
|
|
22
22
|
const PROGRAMS = Object.freeze({
|
|
23
23
|
npm: (args, o) => spawn('npm', args, o),
|
|
24
24
|
dropdb: (args, o) => spawn('dropdb', args, o),
|
|
25
|
+
psql: (args, o) => spawn('psql', args, o),
|
|
25
26
|
createdb: (args, o) => spawn('createdb', args, o),
|
|
26
27
|
pg_dump: (args, o) => spawn('pg_dump', args, o),
|
|
27
28
|
pg_restore: (args, o) => spawn('pg_restore', args, o),
|
|
@@ -49,27 +50,60 @@ function runCommand({ cmd, args, env, cwd }) {
|
|
|
49
50
|
});
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/**
|
|
53
|
-
function
|
|
53
|
+
/** GET one loopback path: { status, body } (body capped), or { error } when nothing answered. */
|
|
54
|
+
function getLoopback(port, urlPath) {
|
|
54
55
|
return new Promise((resolve) => {
|
|
55
|
-
const req = http.get({ host: '127.0.0.1', port, path:
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
const req = http.get({ host: '127.0.0.1', port, path: urlPath, timeout: 3000, headers: { accept: 'application/json' } }, (res) => {
|
|
57
|
+
let body = '';
|
|
58
|
+
res.on('data', (d) => { if (body.length < 16384) body += d; });
|
|
59
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
60
|
+
res.on('error', (e) => resolve({ error: e.message }));
|
|
58
61
|
});
|
|
59
|
-
req.on('error', () => resolve(
|
|
60
|
-
req.on('timeout', () => { req.destroy(); resolve(
|
|
62
|
+
req.on('error', (e) => resolve({ error: e.message }));
|
|
63
|
+
req.on('timeout', () => { req.destroy(); resolve({ error: 'timed out' }); });
|
|
61
64
|
});
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Can the preview SERVE DATA? Not just "does something answer": /healthz touches no database,
|
|
69
|
+
* and on the first live run a preview that answered it 200 returned 500 on every page.
|
|
70
|
+
*
|
|
71
|
+
* 1. /healthz answers 200 (the process is up).
|
|
72
|
+
* 2. /version's schemaPending is not null. It is computed from a read of schema_migrations,
|
|
73
|
+
* and is null exactly when that read fails — so this proves the copy is reachable. A
|
|
74
|
+
* version too old to carry the field at all is passed on to step 3 instead.
|
|
75
|
+
* 3. a database-backed route is not a server error: /api/bongos/me answers 401 or 200 when
|
|
76
|
+
* the database and auth path work, and 500 when they do not.
|
|
77
|
+
*
|
|
78
|
+
* Returns { ok, reason }; the reason is shown to the owner, so it names the failing step.
|
|
79
|
+
*/
|
|
80
|
+
async function probeHealth(port, get = getLoopback) {
|
|
81
|
+
const h = await get(port, '/healthz');
|
|
82
|
+
if (h.error || h.status !== 200) return { ok: false, reason: h.error ? `/healthz: ${h.error}` : `/healthz answered ${h.status}` };
|
|
83
|
+
const v = await get(port, '/version');
|
|
84
|
+
if (v.error || v.status !== 200) return { ok: false, reason: v.error ? `/version: ${v.error}` : `/version answered ${v.status}` };
|
|
85
|
+
let info = null;
|
|
86
|
+
try { info = JSON.parse(v.body); } catch { /* not JSON: handled below */ }
|
|
87
|
+
if (!info || typeof info !== 'object') return { ok: false, reason: '/version did not answer with JSON' };
|
|
88
|
+
if ('schemaPending' in info && info.schemaPending === null) {
|
|
89
|
+
return { ok: false, reason: '/version reports schemaPending null: the database copy could not be read' };
|
|
90
|
+
}
|
|
91
|
+
const me = await get(port, '/api/bongos/me');
|
|
92
|
+
if (me.error || me.status >= 500) {
|
|
93
|
+
return { ok: false, reason: me.error ? `/api/bongos/me: ${me.error}` : `a database-backed read (/api/bongos/me) answered ${me.status}` };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
64
98
|
/**
|
|
65
99
|
* Build the production supervisor: the port from NPM_RELEASE_PREVIEW_PORT (default 3190,
|
|
66
100
|
* loopback only), installs cached under ~/.cache/bongos-preview, and a timer that stops
|
|
67
101
|
* an idle preview and a hook that stops it when this process does.
|
|
68
102
|
*/
|
|
69
|
-
function createRuntime({ liveDb, log, env = process.env } = {}) {
|
|
103
|
+
function createRuntime({ liveDb, log, env = process.env, instanceRoot = null } = {}) {
|
|
70
104
|
const port = Number(env.NPM_RELEASE_PREVIEW_PORT) || DEFAULT_PORT;
|
|
71
105
|
const supervisor = createSupervisor({
|
|
72
|
-
run: runCommand, spawn, probe: probeHealth, liveDb, log, port, env,
|
|
106
|
+
run: runCommand, spawn, probe: probeHealth, liveDb, log, port, env, instanceRoot,
|
|
73
107
|
cacheRoot: path.join(os.homedir(), '.cache', 'bongos-preview'),
|
|
74
108
|
});
|
|
75
109
|
const timer = setInterval(() => {
|
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// modules/npm-release/preview/supervisor.js — starts, watches and stops the ONE version
|
|
4
|
-
// preview this hall may run (task 1004300, ADR 0349).
|
|
4
|
+
// preview this hall may run (task 1004300, ADR 0349; hardened by task 1004465).
|
|
5
5
|
//
|
|
6
6
|
// A preview is a second Bongos process, running an installed copy of an older core against
|
|
7
7
|
// a COPY of the live database, reached through the live hall's own address (divert.js). This
|
|
8
8
|
// file owns its lifecycle and nothing else: install → copy the database → migrate → launch →
|
|
9
|
-
// wait until it
|
|
10
|
-
// command, spawn for the long-lived child, probe for the health check, sleep, now), which is
|
|
9
|
+
// wait until it can serve data. Every outward step goes through an injected function (run for
|
|
10
|
+
// a command, spawn for the long-lived child, probe for the health check, sleep, now), which is
|
|
11
11
|
// what lets the whole lifecycle be tested without a process, a database or a network.
|
|
12
12
|
//
|
|
13
13
|
// ONE AT A TIME, STOPPED WHEN IDLE. A second preview is refused, not queued; a running one
|
|
14
14
|
// that nothing has touched for IDLE_MS is stopped and its copy of the database dropped. That
|
|
15
15
|
// bound is what keeps a preview from becoming a permanent second tenant of the droplet.
|
|
16
|
+
//
|
|
17
|
+
// THREE THINGS task 1004465 ADDED, each from the first live run:
|
|
18
|
+
// * the child's output is kept (redacted, capped) and shown when a start fails;
|
|
19
|
+
// * "running" means the child can serve DATA, not merely that it answers /healthz;
|
|
20
|
+
// * a stop waits for the child to exit and really drops the copy, or says it could not.
|
|
16
21
|
|
|
17
22
|
const os = require('node:os');
|
|
18
23
|
const fs = require('node:fs');
|
|
19
24
|
const path = require('node:path');
|
|
20
|
-
const { buildPreviewEnv } = require('./env');
|
|
25
|
+
const { buildPreviewEnv, PREVIEW_DB } = require('./env');
|
|
21
26
|
const cmds = require('./commands');
|
|
27
|
+
const { createChildLog, redact, secretValues } = require('./childlog');
|
|
22
28
|
|
|
23
29
|
const IDLE_MS = 20 * 60 * 1000;
|
|
24
30
|
const DEFAULT_PORT = 3190;
|
|
25
31
|
const HEALTH_TRIES = 40;
|
|
26
32
|
const HEALTH_INTERVAL_MS = 1500;
|
|
33
|
+
// How long a stopping child gets to exit on SIGTERM before SIGKILL, and how long after that.
|
|
34
|
+
const KILL_WAIT_MS = 5000;
|
|
35
|
+
const KILL_FINAL_WAIT_MS = 2000;
|
|
36
|
+
|
|
37
|
+
// The ONLY files of the live instance's config/ copied into the preview's scratch instance:
|
|
38
|
+
// the identity and switch packs a hall reads at boot. An allow-list, like the environment
|
|
39
|
+
// (env.js): a deny-list forgets the next credential file someone adds, an allow-list cannot.
|
|
40
|
+
// Anything else in config/ (go-live.json, key files, whatever comes next) stays behind.
|
|
41
|
+
const CONFIG_COPY = /^(branding|modules|government|ideas|design-tokens)(\.neutral)?\.json$/;
|
|
27
42
|
|
|
28
43
|
// How far behind the running version a preview may reach, counted in PUBLISHED versions.
|
|
29
44
|
const WINDOW = 30;
|
|
@@ -52,43 +67,123 @@ function previewable(version, { published, running, window = WINDOW } = {}) {
|
|
|
52
67
|
return { ok: true };
|
|
53
68
|
}
|
|
54
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Copy the live instance's host content the child needs to boot as an instance rather than
|
|
72
|
+
* as a bare install: config/ (branding, module switches, government) and migrations/instance/.
|
|
73
|
+
* A source that is absent is skipped; any other failure fails the start. Only the allow-listed
|
|
74
|
+
* pack files of config/ are copied (CONFIG_COPY).
|
|
75
|
+
*/
|
|
76
|
+
function copyInstance({ fsImpl, from, to }) {
|
|
77
|
+
const parts = [
|
|
78
|
+
// The directory itself always passes the filter; files are copied only when allow-listed.
|
|
79
|
+
['config', (src) => path.basename(src) === 'config' || CONFIG_COPY.test(path.basename(src))],
|
|
80
|
+
[path.join('migrations', 'instance'), () => true],
|
|
81
|
+
];
|
|
82
|
+
for (const [rel, filter] of parts) {
|
|
83
|
+
const src = path.join(from, rel);
|
|
84
|
+
try {
|
|
85
|
+
if (!fsImpl.existsSync(src)) continue;
|
|
86
|
+
fsImpl.mkdirSync(path.dirname(path.join(to, rel)), { recursive: true });
|
|
87
|
+
fsImpl.cpSync(src, path.join(to, rel), { recursive: true, filter });
|
|
88
|
+
} catch (e) {
|
|
89
|
+
throw new Error(`could not copy the instance ${rel} into the preview: ${e && e.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
55
94
|
/**
|
|
56
95
|
* @param {object} o
|
|
57
96
|
* @param {Function} o.run (command) => Promise of { code, stderr } — runs one command to the end
|
|
58
97
|
* @param {Function} o.spawn node:child_process spawn (the long-lived child)
|
|
59
|
-
* @param {Function} o.probe (port) => Promise of boolean —
|
|
98
|
+
* @param {Function} o.probe (port) => Promise of boolean | { ok, reason } — can the preview serve data
|
|
60
99
|
* @param {string} o.liveDb the live database name (the source of the copy)
|
|
61
100
|
* @param {string} o.cacheRoot where versions are installed
|
|
101
|
+
* @param {string} [o.instanceRoot] the live instance's root; its config/ and migrations/instance/
|
|
102
|
+
* are copied into the preview's scratch instance directory
|
|
62
103
|
* @param {object} [o.env] the live environment (read by buildPreviewEnv only)
|
|
63
104
|
*/
|
|
64
105
|
function createSupervisor({
|
|
65
|
-
run, spawn, probe, liveDb, cacheRoot,
|
|
66
|
-
env = process.env, port = DEFAULT_PORT,
|
|
106
|
+
run, spawn, probe, liveDb, cacheRoot, instanceRoot = null,
|
|
107
|
+
env = process.env, port = DEFAULT_PORT, killWaitMs = KILL_WAIT_MS,
|
|
67
108
|
now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
68
109
|
fsImpl = fs, tmpdir = os.tmpdir(), idleMs = IDLE_MS,
|
|
69
110
|
log = { info() {}, warn() {}, error() {} },
|
|
111
|
+
copy = copyInstance,
|
|
70
112
|
} = {}) {
|
|
113
|
+
// The one database a drop may ever name is the fixed copy. If the live database WERE that
|
|
114
|
+
// name, every stop would drop the live hall's data, so refuse to be built at all.
|
|
115
|
+
if (liveDb === PREVIEW_DB) throw new Error(`the live database cannot be named ${PREVIEW_DB}: that name is the preview's copy and is dropped on every stop`);
|
|
116
|
+
|
|
71
117
|
let state = { state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null };
|
|
72
118
|
let child = null;
|
|
73
119
|
let workDir = null;
|
|
120
|
+
let childLog = null; // the running or just-stopped child's output
|
|
121
|
+
let keptTail = []; // its tail, kept after the scratch directory is gone
|
|
122
|
+
let secrets = secretValues(env);
|
|
123
|
+
let exitInfo = null; // { code } once the child has exited
|
|
124
|
+
let exited = Promise.resolve();
|
|
74
125
|
|
|
75
126
|
const set = (patch) => { state = { ...state, ...patch }; };
|
|
76
|
-
const status = () => ({ ...state, port, idle_ms: idleMs });
|
|
127
|
+
const status = () => ({ ...state, port, idle_ms: idleMs, log_tail: childLog ? childLog.tail() : keptTail });
|
|
77
128
|
|
|
78
129
|
async function step(stage, command) {
|
|
79
130
|
set({ stage });
|
|
80
131
|
const r = await run(command);
|
|
81
132
|
if (!r || r.code !== 0) {
|
|
82
|
-
const tail = r && r.stderr ? `: ${String(r.stderr).trim().slice(-300)}` : '';
|
|
133
|
+
const tail = r && r.stderr ? `: ${redact(String(r.stderr).trim().slice(-300), secrets)}` : '';
|
|
83
134
|
throw new Error(`${stage} failed (exit ${r ? r.code : '?'})${tail}`);
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
|
|
138
|
+
// Run a command only if it can touch nothing but the copy. Never throws for a failed command.
|
|
139
|
+
async function runDrop(command) {
|
|
140
|
+
if (!cmds.targetsOnlyPreviewDb(command)) throw new Error(`refusing a database command that does not name only ${PREVIEW_DB}`);
|
|
141
|
+
try { return (await run(command)) || { code: -1, stderr: 'no result' }; } catch (e) { return { code: -1, stderr: e && e.message }; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Drop the copy, really. dropdb --force first (it ends stray connections itself); if that
|
|
146
|
+
* is refused, end the copy's connections — the copy's alone — and drop plainly. Returns
|
|
147
|
+
* null when the copy is gone, else a sentence saying it is not.
|
|
148
|
+
*/
|
|
149
|
+
async function dropCopy() {
|
|
150
|
+
const first = await runDrop(cmds.dropDbCommand());
|
|
151
|
+
if (first.code === 0) return null;
|
|
152
|
+
await runDrop(cmds.terminateBackendsCommand());
|
|
153
|
+
const second = await runDrop(cmds.dropDbPlainCommand());
|
|
154
|
+
if (second.code === 0) return null;
|
|
155
|
+
const why = redact(String(second.stderr || first.stderr || '').trim().slice(-200), secrets);
|
|
156
|
+
return `the copy database ${PREVIEW_DB} could not be dropped (exit ${second.code})${why ? `: ${why}` : ''}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Ask the child to stop and WAIT for it, then insist. The drop that follows needs the
|
|
160
|
+
// child's database connections gone; killing without waiting is what left the copy behind.
|
|
161
|
+
async function stopChild(c) {
|
|
162
|
+
if (exitInfo) return;
|
|
163
|
+
try { c.kill('SIGTERM'); } catch (e) { log.warn(`preview child would not stop: ${e && e.message}`); }
|
|
164
|
+
await Promise.race([exited, sleep(killWaitMs)]);
|
|
165
|
+
if (exitInfo) return;
|
|
166
|
+
try { c.kill('SIGKILL'); } catch { /* already gone */ }
|
|
167
|
+
await Promise.race([exited, sleep(KILL_FINAL_WAIT_MS)]);
|
|
168
|
+
}
|
|
169
|
+
|
|
87
170
|
// Everything a start or a stop leaves behind: the child, the copy, the scratch directory.
|
|
171
|
+
// Returns null when all of it is gone, else the sentence saying the copy is not.
|
|
88
172
|
async function cleanup() {
|
|
89
|
-
|
|
90
|
-
|
|
173
|
+
const c = child;
|
|
174
|
+
child = null;
|
|
175
|
+
if (c) await stopChild(c);
|
|
176
|
+
if (childLog) { keptTail = childLog.tail(); childLog.close(); childLog = null; }
|
|
177
|
+
const dropError = await dropCopy();
|
|
178
|
+
if (dropError) log.error(`preview: ${dropError}`);
|
|
91
179
|
if (workDir) { try { fsImpl.rmSync(workDir, { recursive: true, force: true }); } catch { /* best effort */ } workDir = null; }
|
|
180
|
+
return dropError;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A few of the child's last lines, to sit in the error message itself.
|
|
184
|
+
function withTail(message, lg) {
|
|
185
|
+
const lines = lg ? lg.tail(5) : [];
|
|
186
|
+
return lines.length ? `${message}. Its last output: ${lines.join(' | ')}`.slice(0, 900) : message;
|
|
92
187
|
}
|
|
93
188
|
|
|
94
189
|
async function launch(version) {
|
|
@@ -97,41 +192,78 @@ function createSupervisor({
|
|
|
97
192
|
const home = path.join(workDir, 'home');
|
|
98
193
|
fsImpl.mkdirSync(home, { recursive: true });
|
|
99
194
|
const dumpFile = path.join(workDir, 'live.dump');
|
|
100
|
-
|
|
195
|
+
|
|
196
|
+
// The child's working directory is a scratch INSTANCE: the live instance's config/ and
|
|
197
|
+
// migrations/instance/, copied. Run from the bare install directory it had no config/
|
|
198
|
+
// at all (no branding, no module switches) and was not shaped like a hall.
|
|
199
|
+
let instanceDir;
|
|
200
|
+
let prefix;
|
|
201
|
+
if (instanceRoot) {
|
|
202
|
+
instanceDir = path.join(workDir, 'instance');
|
|
203
|
+
fsImpl.mkdirSync(instanceDir, { recursive: true });
|
|
204
|
+
copy({ fsImpl, from: instanceRoot, to: instanceDir });
|
|
205
|
+
try { prefix = JSON.parse(fsImpl.readFileSync(path.join(instanceDir, 'config', 'branding.json'), 'utf8')).envPrefix; } catch { /* no branding pack: the default prefix */ }
|
|
206
|
+
}
|
|
207
|
+
const childEnv = buildPreviewEnv({ env, port, scratchHome: home, instanceRoot: instanceDir, envPrefix: prefix });
|
|
208
|
+
secrets = secretValues(env, childEnv);
|
|
209
|
+
keptTail = [];
|
|
210
|
+
childLog = createChildLog({ secrets, fsImpl, file: path.join(workDir, 'preview.log') });
|
|
211
|
+
exitInfo = null;
|
|
101
212
|
|
|
102
213
|
await step('install', { ...cmds.installCommand({ cacheRoot, version }), env: childEnv });
|
|
103
|
-
|
|
214
|
+
set({ stage: 'drop-old-copy' });
|
|
215
|
+
const leftover = await dropCopy();
|
|
216
|
+
if (leftover) throw new Error(leftover);
|
|
104
217
|
await step('create-copy', cmds.createDbCommand());
|
|
105
218
|
await step('dump', cmds.dumpCommand({ liveDb, dumpFile }));
|
|
106
219
|
await step('restore', cmds.restoreCommand({ dumpFile }));
|
|
107
|
-
await step('migrate', cmds.migrateCommand({ cacheRoot, version, env: childEnv }));
|
|
220
|
+
await step('migrate', cmds.migrateCommand({ cacheRoot, version, env: childEnv, instanceDir }));
|
|
108
221
|
|
|
109
222
|
set({ stage: 'launch' });
|
|
110
|
-
child
|
|
111
|
-
|
|
223
|
+
// The child's output is kept (redacted, capped): its errors used to go nowhere.
|
|
224
|
+
const mine = childLog;
|
|
225
|
+
const spawned = spawn(process.execPath, [cmds.entryPath(cacheRoot, version)], {
|
|
226
|
+
cwd: instanceDir || cmds.versionDir(cacheRoot, version), env: childEnv, stdio: ['ignore', 'pipe', 'pipe'],
|
|
112
227
|
});
|
|
113
|
-
child
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
228
|
+
child = spawned;
|
|
229
|
+
exited = new Promise((resolve) => {
|
|
230
|
+
spawned.on('exit', (code) => {
|
|
231
|
+
exitInfo = { code };
|
|
232
|
+
resolve();
|
|
233
|
+
// A child that dies on its own must not leave a copy behind or a page waiting on it.
|
|
234
|
+
if (child === spawned && state.state === 'running') {
|
|
235
|
+
log.warn(`preview of ${version} exited (${code})`);
|
|
236
|
+
set({ state: 'failed', stage: 'exited', error: withTail(`the preview process exited (${code})`, mine) });
|
|
237
|
+
cleanup().then((err) => { if (err) set({ error: `${state.error}; ${err}` }); });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
121
240
|
});
|
|
241
|
+
for (const stream of [spawned.stdout, spawned.stderr]) {
|
|
242
|
+
if (stream && typeof stream.on === 'function') stream.on('data', (d) => mine.append(String(d)));
|
|
243
|
+
}
|
|
122
244
|
|
|
123
245
|
set({ stage: 'health' });
|
|
246
|
+
// READY MEANS IT CAN SERVE DATA. The probe must see the database and the auth path work,
|
|
247
|
+
// not just the process answer (probeHealth in runtime.js); a preview that cannot is
|
|
248
|
+
// failed here, with why, rather than reported running.
|
|
124
249
|
let up = false;
|
|
250
|
+
let why = 'it did not answer';
|
|
125
251
|
for (let i = 0; i < HEALTH_TRIES && !up; i++) {
|
|
126
|
-
|
|
127
|
-
|
|
252
|
+
if (exitInfo) throw new Error(withTail(`the preview process exited (${exitInfo.code}) before it could serve data`, mine));
|
|
253
|
+
const r = await probe(port);
|
|
254
|
+
up = r === true || !!(r && r.ok);
|
|
255
|
+
if (!up) {
|
|
256
|
+
if (r && r.reason) why = r.reason;
|
|
257
|
+
await sleep(HEALTH_INTERVAL_MS);
|
|
258
|
+
}
|
|
128
259
|
}
|
|
129
|
-
if (!up) throw new Error(
|
|
260
|
+
if (!up) throw new Error(withTail(`the preview started but could not serve data (${why})`, mine));
|
|
130
261
|
set({ state: 'running', stage: null, startedAt: now(), lastTouch: now() });
|
|
131
262
|
} catch (e) {
|
|
132
263
|
log.error(`preview of ${version} failed: ${e && e.message}`);
|
|
133
|
-
set({ state: 'failed', error: e && e.message ? e.message : String(e) });
|
|
134
|
-
await cleanup();
|
|
264
|
+
set({ state: 'failed', error: redact(e && e.message ? e.message : String(e), secrets) });
|
|
265
|
+
const dropError = await cleanup();
|
|
266
|
+
if (dropError) set({ error: `${state.error}; ${dropError}` });
|
|
135
267
|
}
|
|
136
268
|
}
|
|
137
269
|
|
|
@@ -146,13 +278,20 @@ function createSupervisor({
|
|
|
146
278
|
if (state.version === version) return { ok: true, already: true, status: status(), done: Promise.resolve() };
|
|
147
279
|
return { ok: false, code: 'preview_busy', status: status() };
|
|
148
280
|
}
|
|
281
|
+
keptTail = [];
|
|
149
282
|
set({ state: 'starting', version, stage: 'queued', error: null, startedAt: null, lastTouch: now() });
|
|
150
283
|
return { ok: true, status: status(), done: launch(version) };
|
|
151
284
|
}
|
|
152
285
|
|
|
153
286
|
/** Stop the preview and drop its copy. Safe to call at any point. */
|
|
154
287
|
async function stop() {
|
|
155
|
-
await cleanup();
|
|
288
|
+
const dropError = await cleanup();
|
|
289
|
+
// A copy that is still there is NOT idle: say so, where the deploy page will show it.
|
|
290
|
+
if (dropError) {
|
|
291
|
+
set({ state: 'failed', stage: 'drop', error: dropError, startedAt: null, lastTouch: null });
|
|
292
|
+
return status();
|
|
293
|
+
}
|
|
294
|
+
keptTail = [];
|
|
156
295
|
set({ state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null });
|
|
157
296
|
return status();
|
|
158
297
|
}
|
|
@@ -172,4 +311,4 @@ function createSupervisor({
|
|
|
172
311
|
return { start, stop, status, touch, reapIdle };
|
|
173
312
|
}
|
|
174
313
|
|
|
175
|
-
module.exports = { createSupervisor, previewable, IDLE_MS, DEFAULT_PORT, WINDOW };
|
|
314
|
+
module.exports = { createSupervisor, previewable, copyInstance, IDLE_MS, DEFAULT_PORT, WINDOW };
|
|
@@ -6,3 +6,6 @@
|
|
|
6
6
|
/* A stage's sentence sits under its head (work.js says why) and needs air before the first
|
|
7
7
|
row, which drops its own top rule and padding in oversight.css. */
|
|
8
8
|
#nr-work .ov-group > .ov-row__sub { margin: 8px 0 6px; }
|
|
9
|
+
|
|
10
|
+
/* the preview process output shown under a failed preview (task 1004465) */
|
|
11
|
+
.ov-log { max-height: 16rem; overflow: auto; margin: 0.5rem 0 0; padding: 0.5rem 0.75rem; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; background: rgba(127, 127, 127, 0.12); border-radius: 6px; }
|
|
@@ -192,7 +192,12 @@
|
|
|
192
192
|
return `<div class="ov-note"><p><strong>Preparing a preview of ${v}</strong> — ${escapeHtml(PREVIEW_STEP[preview.stage] || 'working')}. This can take a few minutes.</p></div>`;
|
|
193
193
|
}
|
|
194
194
|
if (preview.state === 'failed') {
|
|
195
|
-
|
|
195
|
+
// What the preview process itself printed (secrets already removed on the server), so a
|
|
196
|
+
// failed preview can be diagnosed here without a shell on the box (task 1004465).
|
|
197
|
+
const lines = Array.isArray(preview.log_tail) ? preview.log_tail : [];
|
|
198
|
+
const output = lines.length ? `<details open><summary>What the preview printed</summary><pre class="ov-log">${escapeHtml(lines.join('\n'))}</pre></details>` : '';
|
|
199
|
+
const headline = preview.stage === 'drop' ? `The copy of the data for ${v} could not be removed.` : `The preview of ${v} did not start.`;
|
|
200
|
+
return `<div class="ov-note ov-note--warn"><p><strong>${headline}</strong> ${escapeHtml(preview.error || '')}</p>${output}<button type="button" class="ov-toggle" data-preview-stop>${preview.stage === 'drop' ? 'Try again' : 'Clear'}</button></div>`;
|
|
196
201
|
}
|
|
197
202
|
return `<div class="ov-note"><p><strong>Preview of ${v} is running</strong> on a copy of this hall's data. Nothing done there reaches the live hall. It stops by itself after ${Math.round((preview.idle_ms || 0) / 60000)} minutes unused.</p>
|
|
198
203
|
<a class="ov-toggle" href="${API}/npm-release/preview/enter?v=${encodeURIComponent(preview.version)}">Open the preview</a>
|
|
@@ -55,6 +55,7 @@ module.exports = function buildNpmReleasePreviewRouter({
|
|
|
55
55
|
const router = express.Router();
|
|
56
56
|
const sup = supervisor || lazySupervisor(() => require('../preview/runtime').createRuntime({
|
|
57
57
|
liveDb: process.env.PGDATABASE || api.instanceDbName(),
|
|
58
|
+
instanceRoot: api.resolveInstanceRoot(),
|
|
58
59
|
log,
|
|
59
60
|
}));
|
|
60
61
|
const mayPreview = api.requirePermission('core.pin.move');
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/platform-identity/instance-feedback.js — feedback about Bongos ITSELF,
|
|
4
|
+
// relayed up from a federated project to the hub (task 1004462).
|
|
5
|
+
//
|
|
6
|
+
// A builder on a hosted project runs `bongos feedback "…"`. Their OWN instance
|
|
7
|
+
// stamps who sent it and which core it runs, and relays it here over the
|
|
8
|
+
// hub-federation client credential (ADR 0141) — the same server-to-server channel
|
|
9
|
+
// as the activity rollup. This file turns that relay into a filing on the hub:
|
|
10
|
+
//
|
|
11
|
+
// kind 'bug' → a task in the building version's MAINTENANCE goal, at 'backlog'
|
|
12
|
+
// (ADR 0262's homeless-bug home, reached through the
|
|
13
|
+
// 'instance-feedback' routing vector).
|
|
14
|
+
// kind 'idea' → an idea_inbox row for /idea-triage, exactly as any goal-less
|
|
15
|
+
// idea lands — a suggestion is not a defect, and the maintenance
|
|
16
|
+
// goal is for defects.
|
|
17
|
+
//
|
|
18
|
+
// ALWAYS 'backlog', never 'ready' — the owner's decision (2026-09-30). Anyone on
|
|
19
|
+
// any tenant project can send this, so an approval stands between a report and
|
|
20
|
+
// paid agent work. That is enforced by passing NO filer rank: decideLanding reads
|
|
21
|
+
// an absent rank as untrusted, so even the owner's own feedback waits for the nod
|
|
22
|
+
// rather than inheriting a hub rank the instance never vouched for.
|
|
23
|
+
//
|
|
24
|
+
// THE ATTRIBUTION IS THE HUB'S, NOT THE INSTANCE'S. Client credentials prove WHICH
|
|
25
|
+
// PROJECT is calling and nothing about WHO sent it, so the sender must hold a
|
|
26
|
+
// membership on that project carrying the hub's own sign-in witness
|
|
27
|
+
// (membershipBacksRollup — the ADR 0205 rule the rollup uses). The login printed
|
|
28
|
+
// on the task is then read from the hub's account row, never from the body.
|
|
29
|
+
|
|
30
|
+
const FEEDBACK_KINDS = Object.freeze({ bug: 'bug', idea: 'feature' });
|
|
31
|
+
const MESSAGE_MAX = 4000;
|
|
32
|
+
const TITLE_MAX = 120;
|
|
33
|
+
|
|
34
|
+
// Per-hour caps, counted in memory on this hub process. Two, because they stop
|
|
35
|
+
// different things: one noisy builder, and one project relaying for many.
|
|
36
|
+
const FEEDBACK_WINDOW_MS = 60 * 60 * 1000;
|
|
37
|
+
const PER_SENDER_LIMIT = 10;
|
|
38
|
+
const PER_PROJECT_LIMIT = 30;
|
|
39
|
+
|
|
40
|
+
// makeFeedbackLimiter — a sliding-window counter keyed by project and sender.
|
|
41
|
+
// `take` records a filing only when BOTH caps have room, so a refusal on one cap
|
|
42
|
+
// never spends the other.
|
|
43
|
+
function makeFeedbackLimiter({ now = Date.now } = {}) {
|
|
44
|
+
const buckets = new Map(); // key -> timestamp[]
|
|
45
|
+
function prune(key, t) {
|
|
46
|
+
const arr = buckets.get(key);
|
|
47
|
+
if (!arr) return [];
|
|
48
|
+
while (arr.length && arr[0] <= t - FEEDBACK_WINDOW_MS) arr.shift();
|
|
49
|
+
if (arr.length === 0) buckets.delete(key);
|
|
50
|
+
return arr;
|
|
51
|
+
}
|
|
52
|
+
function take(clientId, githubId) {
|
|
53
|
+
const t = now();
|
|
54
|
+
const checks = [
|
|
55
|
+
[`p:${clientId}`, PER_PROJECT_LIMIT, 'project'],
|
|
56
|
+
[`s:${clientId}:${githubId}`, PER_SENDER_LIMIT, 'sender'],
|
|
57
|
+
];
|
|
58
|
+
for (const [key, limit, scope] of checks) {
|
|
59
|
+
const arr = prune(key, t);
|
|
60
|
+
if (arr.length >= limit) {
|
|
61
|
+
const retryAfter = Math.max(1, Math.ceil((arr[0] + FEEDBACK_WINDOW_MS - t) / 1000));
|
|
62
|
+
return { ok: false, scope, retryAfter };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const [key] of checks) {
|
|
66
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
67
|
+
buckets.get(key).push(t);
|
|
68
|
+
}
|
|
69
|
+
return { ok: true };
|
|
70
|
+
}
|
|
71
|
+
return { take, _buckets: buckets };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A version stamp from the instance: short, and only the characters a version
|
|
75
|
+
// string or an OS name uses, so it cannot carry markdown or links into the task.
|
|
76
|
+
function cleanStamp(v) {
|
|
77
|
+
if (typeof v !== 'string') return null;
|
|
78
|
+
const s = v.trim().slice(0, 40);
|
|
79
|
+
return /^[\w.+\- ]+$/.test(s) ? s : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The task title: the first non-empty line, trimmed to fit.
|
|
83
|
+
function feedbackTitle(message) {
|
|
84
|
+
const first = String(message).split('\n').map((l) => l.trim()).find(Boolean) || 'Feedback';
|
|
85
|
+
const title = first.length > TITLE_MAX ? `${first.slice(0, TITLE_MAX - 1)}…` : first;
|
|
86
|
+
return `Feedback: ${title}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function feedbackBody({ message, client, account, githubId, context = {} }) {
|
|
90
|
+
const who = account && account.github_login ? `@${account.github_login}` : `GitHub user ${githubId}`;
|
|
91
|
+
const project = client.name ? `${client.name} (${client.origin || client.client_id})` : (client.origin || client.client_id);
|
|
92
|
+
const stamps = [
|
|
93
|
+
['Core', cleanStamp(context.core_version)],
|
|
94
|
+
['CLI', cleanStamp(context.cli_version)],
|
|
95
|
+
['OS', cleanStamp(context.os)],
|
|
96
|
+
].filter(([, v]) => v).map(([k, v]) => `${k} ${v}`).join(' · ');
|
|
97
|
+
// The hub's own attribution comes FIRST and the sender's words follow as a
|
|
98
|
+
// quote, so text they type can never pose as the footer a reviewer trusts.
|
|
99
|
+
const quoted = String(message).trim().split('\n').map((l) => `> ${l}`).join('\n');
|
|
100
|
+
return [
|
|
101
|
+
'Sent from a managed project with `bongos feedback`.',
|
|
102
|
+
`- Project: ${project}`,
|
|
103
|
+
`- Sent by: ${who}`,
|
|
104
|
+
...(stamps ? [`- Running: ${stamps}`] : []),
|
|
105
|
+
'',
|
|
106
|
+
quoted,
|
|
107
|
+
].join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// fileInstanceFeedback — file one relayed report. `deps.capture` is the
|
|
111
|
+
// `ideas.capture` port; `deps.getAccount` and `deps.hubBuilderId` read the hub's
|
|
112
|
+
// own rows for the sender. Returns the landing the CLI prints.
|
|
113
|
+
async function fileInstanceFeedback({ client, githubId, message, kind, context }, deps) {
|
|
114
|
+
const account = await deps.getAccount(githubId).catch(() => null);
|
|
115
|
+
const capturedBy = await deps.hubBuilderId(githubId).catch(() => null);
|
|
116
|
+
const idea = await deps.capture({
|
|
117
|
+
title: feedbackTitle(message),
|
|
118
|
+
bodyMd: feedbackBody({ message, client, account, githubId, context }),
|
|
119
|
+
kind: FEEDBACK_KINDS[kind],
|
|
120
|
+
capturedBy,
|
|
121
|
+
filerRank: null, // untrusted by design — see the header
|
|
122
|
+
filedVia: 'api',
|
|
123
|
+
vector: 'instance-feedback',
|
|
124
|
+
suggestedPriority: 3,
|
|
125
|
+
});
|
|
126
|
+
const routing = (idea && idea.routing) || {};
|
|
127
|
+
return routing.routed
|
|
128
|
+
? { landed: 'task', task_id: String(routing.task_id), goal_id: String(routing.goal_id), status: routing.status, idea_id: String(idea.id) }
|
|
129
|
+
: { landed: 'inbox', idea_id: String(idea.id) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
FEEDBACK_KINDS,
|
|
134
|
+
MESSAGE_MAX,
|
|
135
|
+
PER_SENDER_LIMIT,
|
|
136
|
+
PER_PROJECT_LIMIT,
|
|
137
|
+
FEEDBACK_WINDOW_MS,
|
|
138
|
+
makeFeedbackLimiter,
|
|
139
|
+
feedbackTitle,
|
|
140
|
+
feedbackBody,
|
|
141
|
+
fileInstanceFeedback,
|
|
142
|
+
};
|
|
@@ -26,6 +26,10 @@ const hubDevice = require('../hub-device');
|
|
|
26
26
|
// The D7 applicant-profile read (task 1002972) and the echo that gates it.
|
|
27
27
|
const applicantProfile = require('../applicant-profile');
|
|
28
28
|
const applicationEcho = require('../application-echo');
|
|
29
|
+
const instanceFeedback = require('../instance-feedback');
|
|
30
|
+
|
|
31
|
+
// One limiter per hub process for POST /sso/feedback (task 1004462).
|
|
32
|
+
const feedbackLimiter = instanceFeedback.makeFeedbackLimiter();
|
|
29
33
|
|
|
30
34
|
// Per-IP cap for POST /sso/applicant-profile — DEFENCE IN DEPTH behind the echo
|
|
31
35
|
// gate, not the gate itself (task 1002972 grade advisory). The echo already
|
|
@@ -692,5 +696,63 @@ module.exports = function ssoRoutes() {
|
|
|
692
696
|
}
|
|
693
697
|
});
|
|
694
698
|
|
|
699
|
+
// POST /sso/feedback — a federated project relays one builder's feedback about
|
|
700
|
+
// Bongos itself (task 1004462). A bug becomes a 'backlog' task in the building
|
|
701
|
+
// version's maintenance goal; an idea becomes an inbox row. Why always backlog,
|
|
702
|
+
// and why the sender must be a witnessed member: ../instance-feedback.js.
|
|
703
|
+
//
|
|
704
|
+
// rank: public — authenticated by client_id + client_secret, not a builder rank.
|
|
705
|
+
// The per-project and per-sender caps run after the client check, so an
|
|
706
|
+
// unauthenticated caller cannot spend a real project's allowance.
|
|
707
|
+
router.post('/sso/feedback', async (req, res) => {
|
|
708
|
+
if (validateOrRespond(req, res, {
|
|
709
|
+
client_id: { required: true, type: 'string', maxLength: 200 },
|
|
710
|
+
client_secret: { required: true, type: 'string', maxLength: 512 },
|
|
711
|
+
github_id: { required: true },
|
|
712
|
+
message: { required: true, type: 'string', maxLength: instanceFeedback.MESSAGE_MAX },
|
|
713
|
+
kind: { type: 'string', enum: Object.keys(instanceFeedback.FEEDBACK_KINDS) },
|
|
714
|
+
context: {},
|
|
715
|
+
})) return;
|
|
716
|
+
const b = req.body || {};
|
|
717
|
+
try {
|
|
718
|
+
const client = await pi.getClient(b.client_id, { pool });
|
|
719
|
+
if (!client || client.status !== 'active' || !pi.clientSecretValid(client, b.client_secret)) {
|
|
720
|
+
return res.fail('invalid_client', 401);
|
|
721
|
+
}
|
|
722
|
+
if (!pi.isNumericGithubId(b.github_id)) return res.fail('invalid_github_id', 400);
|
|
723
|
+
if (!String(b.message).trim()) return res.fail('message_required', 400);
|
|
724
|
+
const membership = await pi.getMembership(client.client_id, b.github_id, { pool });
|
|
725
|
+
if (!pi.membershipBacksRollup(membership)) return res.fail('membership_required', 409);
|
|
726
|
+
const allowed = feedbackLimiter.take(client.client_id, String(b.github_id));
|
|
727
|
+
if (!allowed.ok) {
|
|
728
|
+
res.set('Retry-After', String(allowed.retryAfter));
|
|
729
|
+
return res.fail('rate_limited', 429, { scope: `feedback-${allowed.scope}`, retry_after_seconds: allowed.retryAfter });
|
|
730
|
+
}
|
|
731
|
+
const landing = await instanceFeedback.fileInstanceFeedback({
|
|
732
|
+
client,
|
|
733
|
+
githubId: String(b.github_id),
|
|
734
|
+
message: b.message,
|
|
735
|
+
kind: b.kind || 'bug',
|
|
736
|
+
context: b.context && typeof b.context === 'object' ? b.context : {},
|
|
737
|
+
}, {
|
|
738
|
+
capture: (...a) => api.resolve('ideas.capture')(...a),
|
|
739
|
+
getAccount: (id) => pi.getAccount(id, { pool }),
|
|
740
|
+
hubBuilderId: async (id) => {
|
|
741
|
+
const { rows } = await pool.query('SELECT id FROM builders WHERE github_id = $1 LIMIT 1', [id]);
|
|
742
|
+
return rows[0] ? rows[0].id : null;
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
res.status(201).json({ ok: true, ...landing });
|
|
746
|
+
} catch (err) {
|
|
747
|
+
// No building version / no maintenance goal: the hub cannot take a bug right
|
|
748
|
+
// now. Say so rather than 500, so the CLI can tell the builder to retry later.
|
|
749
|
+
if (err && (err.code === 'NO_BUILDING_VERSION' || err.code === 'NO_MAINTENANCE_GOAL')) {
|
|
750
|
+
return res.fail('feedback_unavailable', 503, { reason: err.code });
|
|
751
|
+
}
|
|
752
|
+
log.error('[platform-identity] /sso/feedback', err);
|
|
753
|
+
res.fail('feedback_failed', { status: 500, message: 'internal error' });
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
695
757
|
return router;
|
|
696
758
|
};
|