@bongos/core 1.20.35 → 1.20.37
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 +33 -23
- package/docs/architecture.md +2 -0
- package/docs/copy-inventory.md +54 -43
- package/docs/copy-registry.json +189 -70
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +3 -3
- package/modules/hall-ui/public/deploy.js +231 -11
- package/modules/hall-ui/public/project-settings.css +28 -0
- package/modules/provisioning/core-upgrade.js +4 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/autobongos-run.js +107 -12
- package/scripts/gds/module-assess-tests.js +248 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_loop.mjs +205 -0
- package/tests/core_upgrade_door.mjs +8 -1
- package/tests/deploy_page_projects.mjs +165 -1
- package/tests/module_assess_tests.mjs +232 -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 };
|
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.37'; // 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');
|
|
@@ -1084,6 +1084,211 @@ test('a runner that just started will NOT upgrade-exit, so a failed fetch cannot
|
|
|
1084
1084
|
assert.equal(askedForDrift, false, 'and not even ask — the check costs a network call');
|
|
1085
1085
|
});
|
|
1086
1086
|
|
|
1087
|
+
// --- pull always, restart rarely (task 1004406) -----------------------------
|
|
1088
|
+
//
|
|
1089
|
+
// Owner-approved 2026-09-30: restart only when the supervisor's OWN code changes.
|
|
1090
|
+
// Every worker is a fresh process in its own tree, so it runs whatever is on disk
|
|
1091
|
+
// already; the only code a restart refreshes is what THIS process has loaded.
|
|
1092
|
+
// So on drift the checkout is updated in place, and the process exits for the
|
|
1093
|
+
// wrapper only when a change touches a file it holds in memory.
|
|
1094
|
+
|
|
1095
|
+
const loopDeps = (over = {}) => ({
|
|
1096
|
+
iteration: async () => ({ event: 'hold', reason: 'test' }),
|
|
1097
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
|
|
1098
|
+
waitOrJump: async () => ({ waited: 0, jumped: false }),
|
|
1099
|
+
writeHeartbeat: () => true,
|
|
1100
|
+
postHeartbeat: async () => true,
|
|
1101
|
+
emit: () => {},
|
|
1102
|
+
loadedHead: async () => 'a'.repeat(40),
|
|
1103
|
+
now: () => Date.now() + runner.MIN_UPTIME_BEFORE_UPGRADE_MS + 1000,
|
|
1104
|
+
codeDrifted: async () => ({ from: 'aaaaaaaa', to: 'cccccccc' }),
|
|
1105
|
+
loadedFiles: () => ['scripts/gds/autobongos-run.js', 'scripts/gds/sequence.js'],
|
|
1106
|
+
state: runner.newRunnerState(),
|
|
1107
|
+
...over,
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
test('main moving with none of the supervisor\'s code refreshes the checkout IN PLACE and does not restart', async () => {
|
|
1111
|
+
const rows = [];
|
|
1112
|
+
let refreshed = 0;
|
|
1113
|
+
const d = loopDeps({
|
|
1114
|
+
record: (r) => { rows.push(r); return r; },
|
|
1115
|
+
refreshCheckout: async () => { refreshed += 1; return { ok: true, to: 'c'.repeat(40), changed: ['docs/recipes/x.md', 'src/bongos/routes/tasks.js'] }; },
|
|
1116
|
+
});
|
|
1117
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, d);
|
|
1118
|
+
assert.equal(code, 0, 'no restart for code the supervisor does not run');
|
|
1119
|
+
assert.ok(refreshed >= 1, 'the checkout is brought current, so workers run the new code');
|
|
1120
|
+
assert.ok(rows.some((r) => r.event === 'code_refreshed'), 'and the refresh is logged');
|
|
1121
|
+
assert.ok(!rows.some((r) => r.event === 'upgrade_exit'));
|
|
1122
|
+
assert.equal(d.state.diskHead, 'c'.repeat(40), 'the on-disk commit is remembered apart from the loaded one');
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
test('a change to a file the supervisor has LOADED restarts, and says which file', async () => {
|
|
1126
|
+
const rows = [];
|
|
1127
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, loopDeps({
|
|
1128
|
+
record: (r) => { rows.push(r); return r; },
|
|
1129
|
+
refreshCheckout: async () => ({ ok: true, to: 'c'.repeat(40), changed: ['docs/x.md', 'scripts/gds/sequence.js'] }),
|
|
1130
|
+
}));
|
|
1131
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
1132
|
+
const exit = rows.find((r) => r.event === 'upgrade_exit');
|
|
1133
|
+
assert.match(exit.reason, /scripts\/gds\/sequence\.js/);
|
|
1134
|
+
assert.ok(!/docs\/x\.md/.test(exit.reason), 'only the files that force the restart are named');
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
test('a change under the supervisor\'s own paths restarts even before that file was loaded', async () => {
|
|
1138
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, loopDeps({
|
|
1139
|
+
record: (r) => r,
|
|
1140
|
+
loadedFiles: () => [],
|
|
1141
|
+
refreshCheckout: async () => ({ ok: true, to: 'c'.repeat(40), changed: ['modules/autonomy/cadence.js'] }),
|
|
1142
|
+
}));
|
|
1143
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
test('an in-place refresh that fails falls back to the restart, which re-runs the wrapper\'s refresh', async () => {
|
|
1147
|
+
const rows = [];
|
|
1148
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, loopDeps({
|
|
1149
|
+
record: (r) => { rows.push(r); return r; },
|
|
1150
|
+
refreshCheckout: async () => ({ ok: false, reason: 'fetch failed' }),
|
|
1151
|
+
}));
|
|
1152
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
1153
|
+
assert.match(rows.find((r) => r.event === 'upgrade_exit').reason, /fetch failed/);
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
test('the real --forever entry point is the one place that arms the in-place refresh', () => {
|
|
1157
|
+
// main() is the production call shape and is not driven by these tests (it
|
|
1158
|
+
// would run the real loop), so its one line is pinned by source. Without it the
|
|
1159
|
+
// refresh is dead code and every merge to main restarts the runner again.
|
|
1160
|
+
const src = readFileSync(new URL('../scripts/gds/autobongos-run.js', import.meta.url), 'utf8');
|
|
1161
|
+
const line = src.split('\n').find((l) => l.includes('if (opts.forever) return forever('));
|
|
1162
|
+
assert.ok(line, 'main() must still hand --forever to forever()');
|
|
1163
|
+
assert.ok(line.includes('refreshInPlace: true'), `and arm the refresh there: ${line}`);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
test('without refreshInPlace, forever never touches the checkout — tests and one-shot runs stay safe', async () => {
|
|
1167
|
+
// refreshCheckout runs `git reset --hard`. A suite that reached it would wipe
|
|
1168
|
+
// the tree it is testing, in CI or on a laptop, so only main() arms it.
|
|
1169
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, loopDeps({
|
|
1170
|
+
record: (r) => r,
|
|
1171
|
+
refreshCheckout: async () => { throw new Error('refreshed a checkout it was never told it owns'); },
|
|
1172
|
+
}));
|
|
1173
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
test('after a refresh, drift is measured from the REFRESHED commit, so it does not refresh every loop', async () => {
|
|
1177
|
+
const tip = 'c'.repeat(40);
|
|
1178
|
+
const run = async () => ({ ok: true, stdout: `${tip}\trefs/heads/main\n` });
|
|
1179
|
+
assert.equal(await runner.codeDrifted({ run, loadedHead: async () => 'a'.repeat(40), state: { diskHead: tip } }), false);
|
|
1180
|
+
const moved = await runner.codeDrifted({ run, loadedHead: async () => 'a'.repeat(40), state: { diskHead: 'b'.repeat(40) } });
|
|
1181
|
+
assert.deepEqual(moved, { from: 'bbbbbbbb', to: 'cccccccc' });
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
test('supervisorTouched: loaded files and the supervisor\'s own paths count; nothing else does', () => {
|
|
1185
|
+
const loaded = ['scripts/gds/sequence.js'];
|
|
1186
|
+
assert.deepEqual(runner.supervisorTouched(['docs/a.md', 'src/x.js'], loaded), []);
|
|
1187
|
+
assert.deepEqual(runner.supervisorTouched(['scripts/gds/sequence.js'], loaded), ['scripts/gds/sequence.js']);
|
|
1188
|
+
assert.deepEqual(runner.supervisorTouched(['scripts/gds/autobongos-verify.js'], []), ['scripts/gds/autobongos-verify.js']);
|
|
1189
|
+
assert.deepEqual(runner.supervisorTouched(['modules/autonomy/fence.js'], []), ['modules/autonomy/fence.js']);
|
|
1190
|
+
assert.deepEqual(runner.supervisorTouched(['scripts/gds/claim.js'], loaded), [], 'claim.js runs as a child process, so it is always fresh');
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
test('loadedFiles lists this process\'s own repo modules, repo-relative with forward slashes', () => {
|
|
1194
|
+
const files = runner.loadedFiles();
|
|
1195
|
+
assert.ok(files.includes('scripts/gds/autobongos-run.js'));
|
|
1196
|
+
assert.ok(files.every((f) => !f.includes('\\') && !f.startsWith('/') && !f.includes('node_modules/')));
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
test('refreshCheckout refuses to reset anything but the MAIN checkout', async () => {
|
|
1200
|
+
const calls = [];
|
|
1201
|
+
// Everything else answers as a healthy main checkout would, so the ONLY thing
|
|
1202
|
+
// that can stop the reset is the worktree check itself.
|
|
1203
|
+
const run = async (bin, args) => {
|
|
1204
|
+
calls.push(args.join(' '));
|
|
1205
|
+
if (args[1] === '--git-dir') return { ok: true, stdout: '/repo/.git/worktrees/some-claim\n' };
|
|
1206
|
+
if (args[1] === '--git-common-dir') return { ok: true, stdout: '/repo/.git\n' };
|
|
1207
|
+
if (args[0] === 'rev-parse' && args[1] === 'FETCH_HEAD') return { ok: true, stdout: `${'c'.repeat(40)}\n` };
|
|
1208
|
+
if (args[0] === 'diff') return { ok: true, stdout: 'docs/a.md\n' };
|
|
1209
|
+
return { ok: true, stdout: '' };
|
|
1210
|
+
};
|
|
1211
|
+
const r = await runner.refreshCheckout({ run, loadedHead: async () => 'a'.repeat(40) });
|
|
1212
|
+
assert.equal(r.ok, false);
|
|
1213
|
+
assert.ok(!calls.some((c) => c.startsWith('reset')), 'a linked worktree is somebody\'s claim — never reset it');
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
test('refreshCheckout in the main checkout fetches, diffs from the LOADED commit, then resets', async () => {
|
|
1217
|
+
const calls = [];
|
|
1218
|
+
const tip = 'c'.repeat(40);
|
|
1219
|
+
const run = async (bin, args) => {
|
|
1220
|
+
calls.push(args.join(' '));
|
|
1221
|
+
if (args[1] === '--git-dir' || args[1] === '--git-common-dir') return { ok: true, stdout: '.git\n' };
|
|
1222
|
+
if (args[0] === 'rev-parse' && args[1] === 'FETCH_HEAD') return { ok: true, stdout: `${tip}\n` };
|
|
1223
|
+
if (args[0] === 'diff') return { ok: true, stdout: 'docs/a.md\r\nscripts/gds/sequence.js\n' };
|
|
1224
|
+
return { ok: true, stdout: '' };
|
|
1225
|
+
};
|
|
1226
|
+
const r = await runner.refreshCheckout({ run, loadedHead: async () => 'a'.repeat(40) });
|
|
1227
|
+
assert.deepEqual(r, { ok: true, to: tip, changed: ['docs/a.md', 'scripts/gds/sequence.js'] });
|
|
1228
|
+
const order = ['fetch', 'diff', 'reset'].map((v) => calls.findIndex((c) => c.startsWith(v)));
|
|
1229
|
+
assert.ok(order.every((i) => i >= 0) && order[0] < order[1] && order[1] < order[2], `fetch → diff → reset, got ${calls.join(' | ')}`);
|
|
1230
|
+
assert.ok(calls.find((c) => c.startsWith('diff')).includes('a'.repeat(40)), 'the diff is from what the process LOADED, so a change that landed several refreshes ago still counts');
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// Each way the refresh can bail must bail BEFORE the reset: a half-done refresh
|
|
1234
|
+
// that resets to an unread or undiffed commit would lose the restart decision.
|
|
1235
|
+
for (const [name, failAt] of [
|
|
1236
|
+
['the running commit is unknown', 'loaded'],
|
|
1237
|
+
['fetch failed', 'fetch'],
|
|
1238
|
+
['could not read the fetched commit', 'FETCH_HEAD'],
|
|
1239
|
+
['could not diff the running commit against main', 'diff'],
|
|
1240
|
+
]) {
|
|
1241
|
+
test(`refreshCheckout bails without resetting when: ${name}`, async () => {
|
|
1242
|
+
const calls = [];
|
|
1243
|
+
const run = async (bin, args) => {
|
|
1244
|
+
calls.push(args.join(' '));
|
|
1245
|
+
if (args[1] === '--git-dir' || args[1] === '--git-common-dir') return { ok: true, stdout: '.git\n' };
|
|
1246
|
+
if (args[0] === 'fetch') return { ok: failAt !== 'fetch', stdout: '' };
|
|
1247
|
+
if (args[0] === 'rev-parse' && args[1] === 'FETCH_HEAD') return { ok: true, stdout: failAt === 'FETCH_HEAD' ? 'not-a-sha\n' : `${'c'.repeat(40)}\n` };
|
|
1248
|
+
if (args[0] === 'diff') return { ok: failAt !== 'diff', stdout: 'docs/a.md\n' };
|
|
1249
|
+
return { ok: true, stdout: '' };
|
|
1250
|
+
};
|
|
1251
|
+
const r = await runner.refreshCheckout({ run, loadedHead: async () => (failAt === 'loaded' ? null : 'a'.repeat(40)) });
|
|
1252
|
+
assert.deepEqual(r, { ok: false, reason: name });
|
|
1253
|
+
assert.ok(!calls.some((c) => c.startsWith('reset')), `no reset after "${name}"`);
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
test('a refresh in place reaches the heartbeat FILE through the real beat — disk_head end to end', async () => {
|
|
1258
|
+
// The grader's catch: every forever() test stubbed writeHeartbeat, so the
|
|
1259
|
+
// state never had to travel from the refresh to the file. This one does not.
|
|
1260
|
+
const dir = mkdtempSync(join(tmpdir(), 'ab-diskhead-e2e-'));
|
|
1261
|
+
const prev = process.env.AUTOBONGOS_HEARTBEAT_FILE;
|
|
1262
|
+
process.env.AUTOBONGOS_HEARTBEAT_FILE = join(dir, 'beat.json');
|
|
1263
|
+
try {
|
|
1264
|
+
const d = loopDeps({
|
|
1265
|
+
record: (r) => r,
|
|
1266
|
+
refreshCheckout: async () => ({ ok: true, to: 'e'.repeat(40), changed: ['docs/a.md'] }),
|
|
1267
|
+
});
|
|
1268
|
+
delete d.writeHeartbeat;
|
|
1269
|
+
await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, d);
|
|
1270
|
+
const beat = JSON.parse(readFileSync(process.env.AUTOBONGOS_HEARTBEAT_FILE, 'utf8'));
|
|
1271
|
+
assert.equal(beat.disk_head, 'e'.repeat(40));
|
|
1272
|
+
} finally {
|
|
1273
|
+
if (prev === undefined) delete process.env.AUTOBONGOS_HEARTBEAT_FILE; else process.env.AUTOBONGOS_HEARTBEAT_FILE = prev;
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
test('the heartbeat file names the on-disk commit when it differs from the running one', () => {
|
|
1278
|
+
const dir = mkdtempSync(join(tmpdir(), 'ab-diskhead-'));
|
|
1279
|
+
const prev = process.env.AUTOBONGOS_HEARTBEAT_FILE;
|
|
1280
|
+
process.env.AUTOBONGOS_HEARTBEAT_FILE = join(dir, 'beat.json');
|
|
1281
|
+
try {
|
|
1282
|
+
runner.writeHeartbeat(Date.now(), { diskHead: 'd'.repeat(40) });
|
|
1283
|
+
const beat = JSON.parse(readFileSync(process.env.AUTOBONGOS_HEARTBEAT_FILE, 'utf8'));
|
|
1284
|
+
assert.equal(beat.disk_head, 'd'.repeat(40), '"up to date on disk, old supervisor" must be visible');
|
|
1285
|
+
runner.writeHeartbeat(Date.now(), { diskHead: null });
|
|
1286
|
+
assert.equal(JSON.parse(readFileSync(process.env.AUTOBONGOS_HEARTBEAT_FILE, 'utf8')).disk_head, undefined);
|
|
1287
|
+
} finally {
|
|
1288
|
+
if (prev === undefined) delete process.env.AUTOBONGOS_HEARTBEAT_FILE; else process.env.AUTOBONGOS_HEARTBEAT_FILE = prev;
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1087
1292
|
test('past the uptime floor, a drift still exits', async () => {
|
|
1088
1293
|
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, {
|
|
1089
1294
|
iteration: async () => ({ event: 'hold', reason: 'test' }),
|
|
@@ -431,7 +431,7 @@ await t('schema_pending passes through as a count, and UNKNOWN stays null rather
|
|
|
431
431
|
await t('an open intent is surfaced with its target, so the page can say what is already running', () => {
|
|
432
432
|
const p = CU.previewFromSnapshot(previewInst, { served_version: '1.19.758', available: [], recommended: null },
|
|
433
433
|
{ action: 'core-upgrade', state: 'running', target_version: '1.19.762' });
|
|
434
|
-
assert.deepEqual(p.open_intent, { action: 'core-upgrade', state: 'running', target_version: '1.19.762', not_before: null, last_error: null });
|
|
434
|
+
assert.deepEqual(p.open_intent, { action: 'core-upgrade', state: 'running', target_version: '1.19.762', not_before: null, last_error: null, failure: null });
|
|
435
435
|
});
|
|
436
436
|
|
|
437
437
|
console.log('\nthe deploy page is gated on the same atom as its routes:');
|
|
@@ -1007,5 +1007,12 @@ await t('the preview says when a waiting move will try again, and why it is wait
|
|
|
1007
1007
|
'an ordinary pending intent shows no error');
|
|
1008
1008
|
});
|
|
1009
1009
|
|
|
1010
|
+
await t('a waiting move says what KIND of failure it is waiting out; a plain pending one says none (task 1004450)', () => {
|
|
1011
|
+
const p = CU.previewFromSnapshot(ACTIVE, null, waiting({ failure_class: 'known_wedge', failure_reason: 'dirty_tree' }), null);
|
|
1012
|
+
assert.deepEqual(p.open_intent.failure, { class: 'known_wedge', reason: 'dirty_tree' });
|
|
1013
|
+
assert.equal(CU.previewFromSnapshot(ACTIVE, null, waiting({ not_before: null, failure_class: 'transient' }), null).open_intent.failure, null);
|
|
1014
|
+
assert.equal(CU.previewFromSnapshot(ACTIVE, null, waiting(), null).open_intent.failure, null, 'a row from before the columns');
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1010
1017
|
console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
|
|
1011
1018
|
if (failed) process.exit(1);
|