@bongos/core 1.20.36 → 1.20.38
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 +104 -49
- 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/architecture.md +2 -0
- package/docs/copy-inventory.md +9 -9
- package/docs/copy-registry.json +9 -9
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +2 -2
- 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/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/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +30 -0
- package/scripts/gds/autobongos-run.js +121 -16
- package/scripts/gds/feedback-send.js +131 -0
- package/scripts/gds/module-assess-tests.js +248 -0
- 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 +281 -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/module_assess_tests.mjs +232 -0
- package/tests/update_channel_db.mjs +124 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/module-assess-tests.js — the Tests part of a module's assessment:
|
|
3
|
+
// run a published module version's OWN test suite and record the pass rate as a
|
|
4
|
+
// module_assessment_signals row (task 1003791, ADR 0343; table from core_265).
|
|
5
|
+
//
|
|
6
|
+
// Why this cannot reuse the unit gate. run-unit-tests.js discovers
|
|
7
|
+
// modules/<key>/tests/*.mjs but runs them only when isModuleEnabled(key) — and a
|
|
8
|
+
// catalog module ships default:false (ADR 0107 §6), so every store module's tests
|
|
9
|
+
// are skipped there by construction (the trap task 1003630 documented). This runs
|
|
10
|
+
// the tests straight from the published TARBALL, with no enabled/disabled check at
|
|
11
|
+
// all, so what is measured is exactly the build a buyer would install.
|
|
12
|
+
//
|
|
13
|
+
// Outcomes (core_265's vocabulary — a missing part is never a zero):
|
|
14
|
+
// scored the module has tests and they ran: score = round(100 × files that
|
|
15
|
+
// passed / files run), sample_size = files run. The unit is a test
|
|
16
|
+
// FILE (one process, its exit code), the same unit the core gate uses.
|
|
17
|
+
// no_data the module declares no tests — NOT a fail (the task says the two
|
|
18
|
+
// must not score the same).
|
|
19
|
+
// not_scored the tarball could not be read or verified, so nothing could run.
|
|
20
|
+
//
|
|
21
|
+
// Where the tests run. A module's tests reach into the core by relative path
|
|
22
|
+
// (`../../../docs/…`) and resolve packages from the core's node_modules, so the
|
|
23
|
+
// module is unpacked at the same depth modules/<key>/ sits at:
|
|
24
|
+
// <core root>/.module-assess-<run>/<key>/ — node's walk-up resolution then finds
|
|
25
|
+
// the core with no junctions to create (and so none a cleanup could follow; see
|
|
26
|
+
// worktree.js). Each file runs in its own node process, serially, with a timeout
|
|
27
|
+
// and a stripped environment: no DATABASE_URL, tokens or keys are handed to
|
|
28
|
+
// third-party code. That is a floor, not a sandbox: a test process can still read
|
|
29
|
+
// any file the OS user can (the core's own .env sits two directories up) and reach
|
|
30
|
+
// the network.
|
|
31
|
+
//
|
|
32
|
+
// SO A PUBLISHED MODULE'S TESTS RUN ONLY IN A SEPARATE TESTING ENVIRONMENT
|
|
33
|
+
// (owner, 2026-09-30): never on the control plane, where the secrets live. The
|
|
34
|
+
// store path (recordTestsSignal) refuses unless the process declares it is that
|
|
35
|
+
// environment with MODULE_TEST_SANDBOX=1 — set only on the isolated test box, no
|
|
36
|
+
// secrets on disk, which is its own task. `--dir` stays open: it runs a module
|
|
37
|
+
// from your own checkout, i.e. code you already chose to run.
|
|
38
|
+
//
|
|
39
|
+
// node scripts/gds/module-assess-tests.js <store_module_versions.id> run + record
|
|
40
|
+
// node scripts/gds/module-assess-tests.js --dir modules/<key> dry run on a
|
|
41
|
+
// local module
|
|
42
|
+
// (prints; no DB)
|
|
43
|
+
|
|
44
|
+
const fs = require('node:fs');
|
|
45
|
+
const path = require('node:path');
|
|
46
|
+
const crypto = require('node:crypto');
|
|
47
|
+
const { execFile } = require('node:child_process');
|
|
48
|
+
const { isCleanTeardownAbort } = require('./run-unit-tests');
|
|
49
|
+
|
|
50
|
+
const CORE_ROOT = path.resolve(__dirname, '..', '..');
|
|
51
|
+
const PER_FILE_TIMEOUT_MS = 90_000; // the unit gate's own per-file cap
|
|
52
|
+
const SANDBOX_PREFIX = '.module-assess-';
|
|
53
|
+
// The only variables a module's test process inherits. PATH to find tools; the
|
|
54
|
+
// Windows ones node needs to start at all; nothing that names a credential.
|
|
55
|
+
const ENV_KEEP = ['PATH', 'Path', 'SystemRoot', 'SYSTEMROOT', 'windir', 'TEMP', 'TMP', 'TMPDIR', 'COMSPEC', 'PATHEXT'];
|
|
56
|
+
|
|
57
|
+
// The module's test files: tests/*.mjs at the top of tests/, exactly what
|
|
58
|
+
// run-unit-tests.js discovers for a module on disk. `files` is module-relative paths.
|
|
59
|
+
function discoverTestFiles(paths) {
|
|
60
|
+
return paths.filter((p) => /^tests\/[^/]+\.mjs$/.test(p)).sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function testEnv(sandbox) {
|
|
64
|
+
const env = {};
|
|
65
|
+
for (const k of ENV_KEEP) if (process.env[k] !== undefined) env[k] = process.env[k];
|
|
66
|
+
env.NODE_ENV = 'test';
|
|
67
|
+
env.HOME = sandbox;
|
|
68
|
+
env.USERPROFILE = sandbox;
|
|
69
|
+
return env;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Run one file; resolve { file, passed, timedOut, code, tail }. Never rejects.
|
|
73
|
+
function runFile(file, { cwd, env, timeoutMs, exec = execFile }) {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
exec(process.execPath, [file], { cwd, env, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
|
|
76
|
+
(err, stdout, stderr) => {
|
|
77
|
+
const out = String(stdout || '');
|
|
78
|
+
const errText = String(stderr || '');
|
|
79
|
+
// A suite that passed but aborted in libuv teardown on Windows passed (task 1003845).
|
|
80
|
+
const passed = !err || isCleanTeardownAbort(out, errText);
|
|
81
|
+
const timedOut = !!(err && err.killed);
|
|
82
|
+
// Why it failed: the suite's last stdout lines (its verdict) and the HEAD of
|
|
83
|
+
// stderr, where node puts the thrown error — its tail is only the stack and version.
|
|
84
|
+
const lines = (t, n, fromEnd) => { const ls = t.split(/\r?\n/).filter((l) => l.trim()); return (fromEnd ? ls.slice(-n) : ls.slice(0, n)).join('\n'); };
|
|
85
|
+
const tail = passed ? '' : [lines(out, 3, true), lines(errText, 4, false)].filter(Boolean).join('\n').slice(0, 600);
|
|
86
|
+
resolve({ passed, timedOut, code: err ? (err.code ?? null) : 0, tail });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Remove a sandbox this file made. It holds only real files written by placeFiles
|
|
92
|
+
// (never a link), and the name check means a bad argument cannot aim this anywhere else.
|
|
93
|
+
function removeSandbox(dir, root) {
|
|
94
|
+
if (!path.basename(dir).startsWith(SANDBOX_PREFIX) || path.dirname(dir) !== root) {
|
|
95
|
+
throw new Error(`refusing to remove ${dir}: not a module-assess sandbox`);
|
|
96
|
+
}
|
|
97
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function placeFiles(files, target) {
|
|
101
|
+
for (const f of files) {
|
|
102
|
+
const dest = path.join(target, ...f.path.split('/'));
|
|
103
|
+
if (!dest.startsWith(target + path.sep)) throw new Error(`"${f.path}" would land outside the module`);
|
|
104
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
105
|
+
fs.writeFileSync(dest, f.buf);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The gate in front of running module code. `trusted` is only for code you
|
|
110
|
+
// already chose to run (a module in your own checkout, `--dir`). Anything else — a
|
|
111
|
+
// published, third-party module — runs only where the process declares itself the
|
|
112
|
+
// separate testing environment (MODULE_TEST_SANDBOX=1) AND nothing secret is
|
|
113
|
+
// visible: no credential-looking variable in the environment, no .env at the core
|
|
114
|
+
// root. The second half is what stops a mis-set flag on a real server from
|
|
115
|
+
// quietly reopening the hole. Returns null (go) or { code, message }.
|
|
116
|
+
const SECRET_ENV_RE = /(^DATABASE_URL$|^PG(PASSWORD|PASSFILE)$|TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|_KEY$)/i;
|
|
117
|
+
function inTestSandbox(env = process.env) {
|
|
118
|
+
return env.MODULE_TEST_SANDBOX === '1';
|
|
119
|
+
}
|
|
120
|
+
function runRefusal({ trusted = false, env = process.env, root = CORE_ROOT } = {}) {
|
|
121
|
+
if (trusted) return null;
|
|
122
|
+
if (!inTestSandbox(env)) {
|
|
123
|
+
return { code: 'not_in_test_sandbox', message: "refusing to run a published module's tests here: they run only in the separate testing environment (MODULE_TEST_SANDBOX=1), never on a server holding secrets" };
|
|
124
|
+
}
|
|
125
|
+
const leaked = Object.keys(env).filter((k) => SECRET_ENV_RE.test(k) && env[k]);
|
|
126
|
+
if (leaked.length || fs.existsSync(path.join(root, '.env'))) {
|
|
127
|
+
return { code: 'secrets_present', message: `refusing: MODULE_TEST_SANDBOX=1 is set but secrets are visible here (${leaked.length ? leaked.sort().join(', ') : '.env at the core root'}) — the testing environment must hold none` };
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Run a module's own tests from its files ([{ path, buf }], module-relative) and
|
|
133
|
+
// return the signal fields: { outcome, score, sample_size, reason, detail }.
|
|
134
|
+
// Pure apart from the sandbox it writes and removes; `exec` is injectable for tests.
|
|
135
|
+
async function assessTests(key, files, { root = CORE_ROOT, timeoutMs = PER_FILE_TIMEOUT_MS, exec, trusted = false, env = process.env } = {}) {
|
|
136
|
+
// Checked here too, not only in recordTestsSignal, so no caller can reach the
|
|
137
|
+
// code that runs a module without passing the gate.
|
|
138
|
+
const refused = runRefusal({ trusted, env, root });
|
|
139
|
+
if (refused) throw Object.assign(new Error(refused.message), { code: refused.code });
|
|
140
|
+
const tests = discoverTestFiles(files.map((f) => f.path));
|
|
141
|
+
if (tests.length === 0) {
|
|
142
|
+
return { outcome: 'no_data', score: null, sample_size: 0, reason: 'the module declares no tests (no tests/*.mjs)', detail: { files: [] } };
|
|
143
|
+
}
|
|
144
|
+
const sandbox = path.join(root, `${SANDBOX_PREFIX}${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
145
|
+
const moduleDir = path.join(sandbox, key);
|
|
146
|
+
try {
|
|
147
|
+
placeFiles(files, moduleDir);
|
|
148
|
+
const env = testEnv(sandbox);
|
|
149
|
+
const results = [];
|
|
150
|
+
for (const t of tests) {
|
|
151
|
+
// Serial on purpose, like the unit gate: tests share the core tree around them.
|
|
152
|
+
const r = await runFile(path.join(moduleDir, ...t.split('/')), { cwd: moduleDir, env, timeoutMs, exec });
|
|
153
|
+
results.push({ file: t, ...r });
|
|
154
|
+
}
|
|
155
|
+
const passed = results.filter((r) => r.passed).length;
|
|
156
|
+
const failed = results.filter((r) => !r.passed);
|
|
157
|
+
return {
|
|
158
|
+
outcome: 'scored',
|
|
159
|
+
score: Math.round((100 * passed) / results.length),
|
|
160
|
+
sample_size: results.length,
|
|
161
|
+
reason: failed.length ? `${failed.length} of ${results.length} test file(s) failed` : `all ${results.length} test file(s) passed`,
|
|
162
|
+
detail: { files: results.map(({ file, passed: ok, timedOut, tail }) => ({ file, passed: ok, ...(timedOut ? { timed_out: true } : {}), ...(tail ? { tail } : {}) })) },
|
|
163
|
+
};
|
|
164
|
+
} finally {
|
|
165
|
+
removeSandbox(sandbox, root);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Append one Tests signal row for a store version. Never UPDATEs (core_265 is
|
|
170
|
+
// append-only: the current result is the newest row per (version, part)).
|
|
171
|
+
async function insertSignal(versionId, s, { db }) {
|
|
172
|
+
const { rows: [row] } = await db.query(
|
|
173
|
+
`INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
|
|
174
|
+
VALUES ($1, 'tests', $2, $3, $4, $5, $6)
|
|
175
|
+
RETURNING id, version_id, part, outcome, score, sample_size, reason, measured_at`,
|
|
176
|
+
[versionId, s.outcome, s.score, s.sample_size, s.reason, JSON.stringify(s.detail || {})]);
|
|
177
|
+
return row;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Read, verify and test one published version, then record the result. A tarball
|
|
181
|
+
// that is missing or fails verification is recorded as not_scored — the part could
|
|
182
|
+
// not be measured, which is not the same as the module failing its tests.
|
|
183
|
+
async function recordTestsSignal(versionId, { db, storeDir, root, timeoutMs, exec, env = process.env } = {}) {
|
|
184
|
+
const refused = runRefusal({ env, root });
|
|
185
|
+
if (refused) return { ok: false, ...refused };
|
|
186
|
+
const pool = db || require('../../src/bongos/pool').pool;
|
|
187
|
+
const { versionArtifactFile } = require('../../src/bongos/module-store');
|
|
188
|
+
const { verifyModuleArtifact } = require('./module-artifact');
|
|
189
|
+
const { rows: [ver] } = await pool.query(
|
|
190
|
+
'SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [versionId]);
|
|
191
|
+
if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
|
|
192
|
+
|
|
193
|
+
let signal;
|
|
194
|
+
try {
|
|
195
|
+
const file = versionArtifactFile(ver, storeDir ? { dir: storeDir } : {});
|
|
196
|
+
const tgz = await fs.promises.readFile(file);
|
|
197
|
+
const v = await verifyModuleArtifact(tgz, { key: ver.module_key, includeFiles: true });
|
|
198
|
+
signal = v.ok
|
|
199
|
+
? await assessTests(ver.module_key, v.files, { root, timeoutMs, exec, env })
|
|
200
|
+
: { outcome: 'not_scored', score: null, sample_size: null, reason: `the tarball failed verification (${v.code}): ${v.message}`, detail: {} };
|
|
201
|
+
} catch (e) {
|
|
202
|
+
signal = { outcome: 'not_scored', score: null, sample_size: null, reason: `the tests could not run (${e.code || 'error'})`, detail: {} };
|
|
203
|
+
}
|
|
204
|
+
const row = await insertSignal(ver.id, signal, { db: pool });
|
|
205
|
+
return { ok: true, version: ver, signal: row };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Local dry run: read modules/<key>/ from disk and print what the signal would be.
|
|
209
|
+
function readModuleDir(dir) {
|
|
210
|
+
const out = [];
|
|
211
|
+
const walk = (rel) => {
|
|
212
|
+
for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
|
|
213
|
+
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
214
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
215
|
+
if (e.isDirectory()) walk(r);
|
|
216
|
+
else if (e.isFile()) out.push({ path: r, buf: fs.readFileSync(path.join(dir, r)) });
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
walk('');
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function main(argv) {
|
|
224
|
+
const i = argv.indexOf('--dir');
|
|
225
|
+
if (i !== -1) {
|
|
226
|
+
const dir = path.resolve(argv[i + 1] || '');
|
|
227
|
+
const key = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8')).key;
|
|
228
|
+
const s = await assessTests(key, readModuleDir(dir), { trusted: true });
|
|
229
|
+
console.log(JSON.stringify(s, null, 2));
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
const id = argv[0];
|
|
233
|
+
if (!/^\d+$/.test(String(id || ''))) {
|
|
234
|
+
console.error('usage: node scripts/gds/module-assess-tests.js <store_module_versions.id> | --dir modules/<key>');
|
|
235
|
+
return 2;
|
|
236
|
+
}
|
|
237
|
+
const res = await recordTestsSignal(id);
|
|
238
|
+
if (!res.ok) { console.error(res.message); return 1; }
|
|
239
|
+
const s = res.signal;
|
|
240
|
+
console.log(`${res.version.module_key} ${res.version.version}: tests ${s.outcome}${s.score !== null ? ` ${s.score}/100` : ''} — ${s.reason} (signal ${s.id})`);
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (require.main === module) {
|
|
245
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(e.stack || e.message); process.exit(1); });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { discoverTestFiles, assessTests, recordTestsSignal, insertSignal, runRefusal, testEnv, SANDBOX_PREFIX };
|
|
@@ -228,6 +228,82 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
|
|
|
228
228
|
};
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// ---- the per-project rule set on /deploy (task 1004468, ADR 0357) -----------
|
|
232
|
+
// The owner sets each project's update rule on /deploy, which writes
|
|
233
|
+
// provisioning_instances.update_channel (migration provisioning_029, task 1004445). That column
|
|
234
|
+
// is the rule; the roster's `channel` is only the fallback for an entry no project row matches,
|
|
235
|
+
// or for a sweep that cannot reach the database. Before this, the button followed the column and
|
|
236
|
+
// the sweep followed the roster, so a project set to `minor` on /deploy was still swept as `patch`.
|
|
237
|
+
//
|
|
238
|
+
// Matched by slug — the one stable key the table has (it records no directory). Read over psql,
|
|
239
|
+
// which is how upgrade.js recordLedger reaches a PGDATABASE (a bare name over the unix socket +
|
|
240
|
+
// peer auth, the self-host shape). recordLedger uses the pg driver for DATABASE_URL; this passes
|
|
241
|
+
// the URL to `psql -d` instead, because the sweep is synchronous and pg is not. With no psql the
|
|
242
|
+
// sweep falls back to the roster and says so.
|
|
243
|
+
//
|
|
244
|
+
// A failure's reason is a fixed class, never psql's own text: stderr can name a DB user or host,
|
|
245
|
+
// and the reason is printed into the sweep's log.
|
|
246
|
+
const CHANNEL_SOURCE_DEPLOY = 'deploy';
|
|
247
|
+
const CHANNEL_SOURCE_ROSTER = 'roster';
|
|
248
|
+
const DB_CHANNELS_SQL = 'SELECT slug, update_channel FROM provisioning_instances';
|
|
249
|
+
const DB_CHANNELS_TIMEOUT_MS = 15000;
|
|
250
|
+
|
|
251
|
+
// PURE. psql's stderr reduced to a class that is safe to log.
|
|
252
|
+
function psqlFailureClass(stderr) {
|
|
253
|
+
const s = String(stderr || '');
|
|
254
|
+
if (/relation .*provisioning_instances.* does not exist/i.test(s)) return 'no provisioning_instances table (provisioning module off?)';
|
|
255
|
+
if (/column .*update_channel.* does not exist/i.test(s)) return 'no update_channel column (migration provisioning_029 not applied)';
|
|
256
|
+
if (/authentication failed|role .* does not exist|permission denied/i.test(s)) return 'the sweep\'s database login was refused';
|
|
257
|
+
if (/could not connect|connection refused|no such file or directory|could not translate host/i.test(s)) return 'database unreachable';
|
|
258
|
+
if (/database .* does not exist/i.test(s)) return 'the named database does not exist';
|
|
259
|
+
return 'query failed';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The line a sweep prints for one entry's channel. PURE.
|
|
263
|
+
function channelLine(inst) {
|
|
264
|
+
return ` • ${inst.slug}: channel ${inst.channel}, ${inst.channelNote}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Read every project's update rule. Never throws: { ok:true, channels: Map<slug, channel> }, or
|
|
268
|
+
// { ok:false, reason } when there is no database to ask, psql is missing, or the table is absent
|
|
269
|
+
// (an instance with the provisioning module off). The caller falls back to the roster and says why.
|
|
270
|
+
function readDbChannels({ env = process.env, run = spawnSync } = {}) {
|
|
271
|
+
const target = env.DATABASE_URL || env.PGDATABASE;
|
|
272
|
+
if (!target) return { ok: false, reason: 'no DATABASE_URL or PGDATABASE set for the sweep' };
|
|
273
|
+
const r = run('psql', ['-d', target, '-At', '-F', '\t', '-v', 'ON_ERROR_STOP=1', '--no-psqlrc', '-c', DB_CHANNELS_SQL],
|
|
274
|
+
{ encoding: 'utf8', timeout: DB_CHANNELS_TIMEOUT_MS });
|
|
275
|
+
if (!r || r.error) {
|
|
276
|
+
const code = r && r.error && r.error.code;
|
|
277
|
+
return { ok: false, reason: code === 'ENOENT' ? 'psql not found' : code === 'ETIMEDOUT' ? 'psql timed out' : 'psql could not be started' };
|
|
278
|
+
}
|
|
279
|
+
if (r.status !== 0) return { ok: false, reason: `psql exit ${r.status}: ${psqlFailureClass(r.stderr)}` };
|
|
280
|
+
const channels = new Map();
|
|
281
|
+
for (const line of String(r.stdout || '').split('\n')) {
|
|
282
|
+
const [slug, ch] = line.split('\t');
|
|
283
|
+
if (slug && ch) channels.set(slug.trim(), ch.trim());
|
|
284
|
+
}
|
|
285
|
+
return { ok: true, channels };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// PURE. Give each roster entry the channel it is actually under, and say where it came from:
|
|
289
|
+
// `channelSource` is 'deploy' (the project row) or 'roster', `channelNote` the one-line reason a
|
|
290
|
+
// sweep log prints. The roster's own value is kept as `rosterChannel`, so a disagreement stays
|
|
291
|
+
// visible in the log instead of silently vanishing.
|
|
292
|
+
function applyDbChannels(instances, db) {
|
|
293
|
+
return instances.map((inst) => {
|
|
294
|
+
if (db && db.ok && db.channels.has(inst.slug)) {
|
|
295
|
+
const ch = normalizeChannel(db.channels.get(inst.slug));
|
|
296
|
+
const differs = ch !== inst.channel;
|
|
297
|
+
return {
|
|
298
|
+
...inst, channel: ch, channelSource: CHANNEL_SOURCE_DEPLOY, rosterChannel: inst.channel,
|
|
299
|
+
channelNote: `set on /deploy${differs ? ` (roster says ${inst.channel}, ignored)` : ''}`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const why = db && db.ok ? 'no project row for this slug' : (db && db.reason) || 'database not read';
|
|
303
|
+
return { ...inst, channelSource: CHANNEL_SOURCE_ROSTER, rosterChannel: inst.channel, channelNote: `from the roster — ${why}` };
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
231
307
|
// ---- reporting: make "nothing happened" audible -----------------------------
|
|
232
308
|
|
|
233
309
|
// Turn a loadSubscriptions() result into the line a routine prints EVERY run, plus a level a caller
|
|
@@ -322,6 +398,12 @@ module.exports = {
|
|
|
322
398
|
listAvailableVersions,
|
|
323
399
|
parseNpmViewVersions,
|
|
324
400
|
loadSubscriptions,
|
|
401
|
+
readDbChannels,
|
|
402
|
+
applyDbChannels,
|
|
403
|
+
channelLine,
|
|
404
|
+
psqlFailureClass,
|
|
405
|
+
CHANNEL_SOURCE_DEPLOY,
|
|
406
|
+
CHANNEL_SOURCE_ROSTER,
|
|
325
407
|
ROSTER_OK,
|
|
326
408
|
ROSTER_ABSENT,
|
|
327
409
|
ROSTER_MALFORMED,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/routes/feedback.js — POST /api/bongos/feedback (task 1004462).
|
|
4
|
+
//
|
|
5
|
+
// `bongos feedback "…"` lands here, on the builder's OWN project, with the session
|
|
6
|
+
// they already hold. This route stamps who sent it and which core this project
|
|
7
|
+
// runs, then relays it to the hub's POST /sso/feedback over the hub-federation
|
|
8
|
+
// client credential (ADR 0141) — the channel the activity rollup already uses. So
|
|
9
|
+
// a builder never signs in to cloudbongos.com to report something about Bongos.
|
|
10
|
+
//
|
|
11
|
+
// Only the text the builder typed and version stamps travel. No code, transcript,
|
|
12
|
+
// task or file content from this project is attached.
|
|
13
|
+
//
|
|
14
|
+
// In the CORE, not a module, for the reason core-update.js gives: the hub link is
|
|
15
|
+
// kernel (src/bongos/auth-config.js), and every hosted project must have this door
|
|
16
|
+
// whatever modules it runs. A project with no hub link (self-hosted, or the hub
|
|
17
|
+
// itself) answers 409 not_hub_connected — told, not silently dropped.
|
|
18
|
+
|
|
19
|
+
const express = require('express');
|
|
20
|
+
const { asyncHandler, validateOrRespond } = require('./_helpers');
|
|
21
|
+
const auth = require('../auth');
|
|
22
|
+
const { loadIdpConfig } = require('../auth-config');
|
|
23
|
+
const { userAgent } = require('../../branding');
|
|
24
|
+
|
|
25
|
+
const MESSAGE_MAX = 4000;
|
|
26
|
+
const HUB_TIMEOUT_MS = 15000;
|
|
27
|
+
|
|
28
|
+
function safeIdpConfig() {
|
|
29
|
+
try { return loadIdpConfig(); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runningCore() {
|
|
33
|
+
try { return require('../../module-api').CORE_VERSION || null; } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Hub refusals the builder can act on are passed through by code; anything else
|
|
37
|
+
// is one generic failure, so the hub's internals never leak through this door.
|
|
38
|
+
const PASS_THROUGH = new Set(['membership_required', 'rate_limited', 'feedback_unavailable', 'validation_failed']);
|
|
39
|
+
|
|
40
|
+
async function relayToHub({ idp, builder, message, kind, context, fetchImpl = fetch }) {
|
|
41
|
+
const res = await fetchImpl(`${idp.origin.replace(/\/+$/, '')}/api/bongos/sso/feedback`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
client_id: idp.clientId,
|
|
46
|
+
client_secret: idp.clientSecret,
|
|
47
|
+
github_id: builder.github_id,
|
|
48
|
+
message,
|
|
49
|
+
kind,
|
|
50
|
+
context,
|
|
51
|
+
}),
|
|
52
|
+
signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
|
|
53
|
+
});
|
|
54
|
+
let body = null;
|
|
55
|
+
try { body = await res.json(); } catch { /* non-JSON answer */ }
|
|
56
|
+
return { status: res.status, body };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildFeedbackRouter({ idpConfig = safeIdpConfig, fetchImpl = fetch } = {}) {
|
|
60
|
+
const router = express.Router();
|
|
61
|
+
|
|
62
|
+
// rank: any signed-in builder — reporting a problem with Bongos grants nothing
|
|
63
|
+
// here, and the hub files every report at 'backlog' behind a Metic+ nod.
|
|
64
|
+
router.post(
|
|
65
|
+
'/feedback',
|
|
66
|
+
auth.requireBuilder,
|
|
67
|
+
asyncHandler('POST /feedback', async (req, res) => {
|
|
68
|
+
if (validateOrRespond(req, res, {
|
|
69
|
+
message: { required: true, type: 'string', maxLength: MESSAGE_MAX },
|
|
70
|
+
kind: { type: 'string', enum: ['bug', 'idea'] },
|
|
71
|
+
context: {},
|
|
72
|
+
})) return;
|
|
73
|
+
const b = req.body || {};
|
|
74
|
+
const message = b.message.trim();
|
|
75
|
+
if (!message) return res.fail('message_required', 400);
|
|
76
|
+
const kind = b.kind || 'bug';
|
|
77
|
+
|
|
78
|
+
const idp = idpConfig();
|
|
79
|
+
if (!idp) return res.fail('not_hub_connected', 409);
|
|
80
|
+
|
|
81
|
+
const ctx = b.context && typeof b.context === 'object' ? b.context : {};
|
|
82
|
+
const context = {
|
|
83
|
+
core_version: runningCore(),
|
|
84
|
+
cli_version: typeof ctx.cli_version === 'string' ? ctx.cli_version.slice(0, 40) : null,
|
|
85
|
+
os: typeof ctx.os === 'string' ? ctx.os.slice(0, 40) : null,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let hub;
|
|
89
|
+
try {
|
|
90
|
+
hub = await relayToHub({ idp, builder: req.builder, message, kind, context, fetchImpl });
|
|
91
|
+
} catch {
|
|
92
|
+
return res.fail('hub_unreachable', 502);
|
|
93
|
+
}
|
|
94
|
+
if (hub.status === 201 && hub.body && hub.body.ok) {
|
|
95
|
+
const { ok: _ok, ...landing } = hub.body;
|
|
96
|
+
return res.status(201).json({ ok: true, hub_origin: idp.origin, ...landing });
|
|
97
|
+
}
|
|
98
|
+
const err = hub.body && hub.body.error && typeof hub.body.error === 'object' ? hub.body.error : null;
|
|
99
|
+
if (err && PASS_THROUGH.has(err.code)) {
|
|
100
|
+
const retry = err.details && err.details.retry_after_seconds;
|
|
101
|
+
return res.fail(err.code, hub.status, retry ? { retry_after_seconds: retry } : undefined);
|
|
102
|
+
}
|
|
103
|
+
return res.fail('hub_refused', 502, { hub_status: hub.status });
|
|
104
|
+
}, { errorCode: 'feedback_failed', message: false }),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return router;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = buildFeedbackRouter;
|
package/src/bongos/routes.js
CHANGED
|
@@ -20,6 +20,7 @@ const express = require('express');
|
|
|
20
20
|
const buildHealthzRouter = require('./routes/healthz');
|
|
21
21
|
const buildInstanceRouter = require('./routes/instance');
|
|
22
22
|
const buildCoreUpdateRouter = require('./routes/core-update');
|
|
23
|
+
const buildFeedbackRouter = require('./routes/feedback');
|
|
23
24
|
const buildAuthRouter = require('./routes/auth');
|
|
24
25
|
const buildGithubRouter = require('./routes/github');
|
|
25
26
|
const buildMeRouter = require('./routes/me');
|
|
@@ -144,6 +145,7 @@ function buildGdsRouter() {
|
|
|
144
145
|
router.use(buildHealthzRouter());
|
|
145
146
|
router.use(buildInstanceRouter());
|
|
146
147
|
router.use(buildCoreUpdateRouter());
|
|
148
|
+
router.use(buildFeedbackRouter());
|
|
147
149
|
router.use(buildAuthRouter());
|
|
148
150
|
router.use(buildGithubRouter());
|
|
149
151
|
router.use(buildMeRouter());
|
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.20.
|
|
78
|
+
const CORE_VERSION = '1.20.38'; // 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');
|
|
@@ -260,3 +260,65 @@ test('no request means every allowlisted goal', () => {
|
|
|
260
260
|
assert.deepEqual(allowedGoals(f, []).goals, [1, 2]);
|
|
261
261
|
assert.deepEqual(allowedGoals(f).goals, [1, 2]);
|
|
262
262
|
});
|
|
263
|
+
|
|
264
|
+
// ── the priority goal (task 1004453) ────────────────────────────────────────────
|
|
265
|
+
//
|
|
266
|
+
// Owner ruling: whoever starts the runner names the goal it works FIRST, as well as
|
|
267
|
+
// the set it may touch. The priority ORDERS the allowlist; it must never widen it.
|
|
268
|
+
|
|
269
|
+
test('the priority goal is read off the fence and moved to the front', () => {
|
|
270
|
+
const f = open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 });
|
|
271
|
+
assert.equal(normaliseFence(f).priorityGoal, 3);
|
|
272
|
+
const d = decideRun({ fence: f, ...GRADER_OK });
|
|
273
|
+
assert.equal(d.go, true);
|
|
274
|
+
assert.deepEqual(d.goals, [3, 1, 2], 'the runner walks goals in this order, so the priority goes first');
|
|
275
|
+
assert.equal(d.priorityGoal, 3);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('a priority that is not on the allowlist is ignored, never added', () => {
|
|
279
|
+
// The database refuses this with a foreign key; the pure half refuses it again,
|
|
280
|
+
// because a priority that could add a goal would be a second, unguarded allowlist.
|
|
281
|
+
const f = open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: 99 });
|
|
282
|
+
assert.equal(normaliseFence(f).priorityGoal, null);
|
|
283
|
+
const d = decideRun({ fence: f, ...GRADER_OK });
|
|
284
|
+
assert.deepEqual(d.goals, [1, 2]);
|
|
285
|
+
assert.equal(d.priorityGoal, null);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('a malformed or missing priority reads as "no priority", not as a refusal', () => {
|
|
289
|
+
for (const p of [undefined, null, '', 0, -4, 'abc', 1.5, {}]) {
|
|
290
|
+
const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: p }), ...GRADER_OK });
|
|
291
|
+
assert.equal(d.go, true, `priority ${JSON.stringify(p)} must not stop the run`);
|
|
292
|
+
assert.deepEqual(d.goals, [1, 2], `priority ${JSON.stringify(p)} must not reorder`);
|
|
293
|
+
assert.equal(d.priorityGoal, null);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('a stringified priority id (a bigint from the database) still counts', () => {
|
|
298
|
+
const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: '2' }), ...GRADER_OK });
|
|
299
|
+
assert.deepEqual(d.goals, [2, 1]);
|
|
300
|
+
assert.equal(d.priorityGoal, 2);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('narrowing on the command line keeps the priority first, and can drop it', () => {
|
|
304
|
+
const f = normaliseFence(open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 }));
|
|
305
|
+
assert.deepEqual(allowedGoals(f, [1, 3]).goals, [3, 1]);
|
|
306
|
+
// An operator narrowing to goals that leave the priority out gets no priority —
|
|
307
|
+
// it is not quietly re-added behind their back.
|
|
308
|
+
assert.deepEqual(allowedGoals(f, [1, 2]).goals, [1, 2]);
|
|
309
|
+
const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 }), requestedGoals: [1, 2], ...GRADER_OK });
|
|
310
|
+
assert.equal(d.priorityGoal, null, 'a row must not claim a pick came from a priority this run excluded');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('prioritise only reorders: same members, same count', () => {
|
|
314
|
+
assert.deepEqual(fence.prioritise([5, 6, 7], 6), [6, 5, 7]);
|
|
315
|
+
assert.deepEqual(fence.prioritise([5, 6, 7], 8), [5, 6, 7]);
|
|
316
|
+
assert.deepEqual(fence.prioritise([5, 6, 7], null), [5, 6, 7]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('a closed fence still refuses whatever the priority says', () => {
|
|
320
|
+
const d = decideRun({ fence: open({ enabled: false, priority_goal_id: 1000119 }), ...GRADER_OK });
|
|
321
|
+
assert.equal(d.go, false);
|
|
322
|
+
assert.equal(d.code, 'kill_switch');
|
|
323
|
+
assert.deepEqual(d.goals, []);
|
|
324
|
+
});
|