@bongos/core 1.20.36 → 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 +25 -15
- package/docs/architecture.md +2 -0
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -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/module_assess_tests.mjs +232 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.20.
|
|
6
|
-
"core_contract": "1.20.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.20.37",
|
|
6
|
+
"core_contract": "1.20.37",
|
|
7
|
+
"source_commit": "f1e5bf27f6d26ab0ac34690a4dc1d8d77bf664b3",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-30T23:
|
|
9
|
+
"built_at": "2026-09-30T23:18:11.929Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 562,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2675,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3263,
|
|
20
|
+
"tree_sha256": "8c656cd221547b1969984338b6271ddd489b2aee573810eb5bb563c2db9e4f11",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2272,7 +2272,7 @@
|
|
|
2272
2272
|
{
|
|
2273
2273
|
"path": "docs/architecture.md",
|
|
2274
2274
|
"mode": "0000644",
|
|
2275
|
-
"sha256": "
|
|
2275
|
+
"sha256": "e73b5883090da09febf6fcbb3b23ccc6582623eb864b385b759687c061ea4cd3"
|
|
2276
2276
|
},
|
|
2277
2277
|
{
|
|
2278
2278
|
"path": "docs/branding-contract.md",
|
|
@@ -2802,7 +2802,7 @@
|
|
|
2802
2802
|
{
|
|
2803
2803
|
"path": "docs/module-api-changelog.md",
|
|
2804
2804
|
"mode": "0000644",
|
|
2805
|
-
"sha256": "
|
|
2805
|
+
"sha256": "8473af383b4c8cc856d7bfd61404ea7d0c76953de98cdb2838a06094ba775587"
|
|
2806
2806
|
},
|
|
2807
2807
|
{
|
|
2808
2808
|
"path": "docs/modules-contract.md",
|
|
@@ -8997,12 +8997,12 @@
|
|
|
8997
8997
|
{
|
|
8998
8998
|
"path": "package-lock.json",
|
|
8999
8999
|
"mode": "0000644",
|
|
9000
|
-
"sha256": "
|
|
9000
|
+
"sha256": "071e1c2aa6ecec48b6aa07cfb8b548a0658b9812066a0f0bcb9f555310ce07ef"
|
|
9001
9001
|
},
|
|
9002
9002
|
{
|
|
9003
9003
|
"path": "package.json",
|
|
9004
9004
|
"mode": "0000644",
|
|
9005
|
-
"sha256": "
|
|
9005
|
+
"sha256": "8ada5651cfc648a3cafd4f3571e4cf3e30d3f58a82d0f9a4ca3febd32d6f1638"
|
|
9006
9006
|
},
|
|
9007
9007
|
{
|
|
9008
9008
|
"path": "public-docs/index.html",
|
|
@@ -9022,7 +9022,7 @@
|
|
|
9022
9022
|
{
|
|
9023
9023
|
"path": "release-notes.json",
|
|
9024
9024
|
"mode": "0000644",
|
|
9025
|
-
"sha256": "
|
|
9025
|
+
"sha256": "5b7e70fb99324b9f0568c77f388646d11b897f6886b1d67e4536ce3a871df971"
|
|
9026
9026
|
},
|
|
9027
9027
|
{
|
|
9028
9028
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -9182,7 +9182,7 @@
|
|
|
9182
9182
|
{
|
|
9183
9183
|
"path": "scripts/gds/autobongos-run.js",
|
|
9184
9184
|
"mode": "0000644",
|
|
9185
|
-
"sha256": "
|
|
9185
|
+
"sha256": "bb633909f9547e67809d474f4198ce1146ae2044d70e2c84424870fa179c0a96"
|
|
9186
9186
|
},
|
|
9187
9187
|
{
|
|
9188
9188
|
"path": "scripts/gds/autobongos-service.cmd",
|
|
@@ -9864,6 +9864,11 @@
|
|
|
9864
9864
|
"mode": "0000644",
|
|
9865
9865
|
"sha256": "09211dcd5bae4b935b5f80a2c021a1c10a376236b1c7ea7ebc1f53b84d1ead40"
|
|
9866
9866
|
},
|
|
9867
|
+
{
|
|
9868
|
+
"path": "scripts/gds/module-assess-tests.js",
|
|
9869
|
+
"mode": "0000644",
|
|
9870
|
+
"sha256": "b86d6b3b98534c91b2dffe67f6c5335a09d2ef7c571c70e3a0871ab4929bc490"
|
|
9871
|
+
},
|
|
9867
9872
|
{
|
|
9868
9873
|
"path": "scripts/gds/module.js",
|
|
9869
9874
|
"mode": "0000644",
|
|
@@ -11162,7 +11167,7 @@
|
|
|
11162
11167
|
{
|
|
11163
11168
|
"path": "src/module-api.js",
|
|
11164
11169
|
"mode": "0000644",
|
|
11165
|
-
"sha256": "
|
|
11170
|
+
"sha256": "e36b04da83d53169550b5897dead018db39ac197d25c2b0f034db6faf05b2715"
|
|
11166
11171
|
},
|
|
11167
11172
|
{
|
|
11168
11173
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11622,7 +11627,7 @@
|
|
|
11622
11627
|
{
|
|
11623
11628
|
"path": "tests/autobongos_loop.mjs",
|
|
11624
11629
|
"mode": "0000644",
|
|
11625
|
-
"sha256": "
|
|
11630
|
+
"sha256": "c7f0131e2182c967d90b3df6aef74121f1ea8746fcb7e8e848cb6747f1a4d965"
|
|
11626
11631
|
},
|
|
11627
11632
|
{
|
|
11628
11633
|
"path": "tests/autobongos_service_ps1.mjs",
|
|
@@ -14129,6 +14134,11 @@
|
|
|
14129
14134
|
"mode": "0000644",
|
|
14130
14135
|
"sha256": "9183d78e4d195428afbee8c71746e12db2455f8529ac1223d690a7b8f7ded538"
|
|
14131
14136
|
},
|
|
14137
|
+
{
|
|
14138
|
+
"path": "tests/module_assess_tests.mjs",
|
|
14139
|
+
"mode": "0000644",
|
|
14140
|
+
"sha256": "eb2c6167ee1dc57274ace741a75852140ff2614d9b35234918f45d1b41414d6f"
|
|
14141
|
+
},
|
|
14132
14142
|
{
|
|
14133
14143
|
"path": "tests/module_assessments_migration.mjs",
|
|
14134
14144
|
"mode": "0000644",
|
package/docs/architecture.md
CHANGED
|
@@ -272,6 +272,8 @@ module_assessment_scores(id, version_id, kind IN (computed|override), overall 0-
|
|
|
272
272
|
-- and is a new row on the computed history, never an edit (D6)
|
|
273
273
|
```
|
|
274
274
|
|
|
275
|
+
Signals that fill it: **Tests** — `scripts/gds/module-assess-tests.js <version id>` (task 1003791) unpacks the published tarball to `<core root>/.module-assess-<run>/<key>/` (gitignored, removed after), runs each `tests/*.mjs` in its own node process with a timeout and a credential-free environment — regardless of `isModuleEnabled`, which skips every default-off catalog module in the unit gate — and appends one `tests` row: `scored` = % of test files passing (sample_size = files), `no_data` = declares no tests, `not_scored` = tarball unreadable. The store path **refuses unless `MODULE_TEST_SANDBOX=1`** — a published module's tests run only in a separate testing environment with no secrets on disk, never on the control plane (owner, 2026-09-30). `--dir modules/<key>` is a DB-free dry run on your own checkout. Nothing calls it on publish yet (task 1003794 wires that).
|
|
276
|
+
|
|
275
277
|
Code: `src/bongos/module-entitlements.js` (`grantEntitlement`, `recordAcquired`, `revokeEntitlement`, `checkEntitlement`, `listEntitlements`). Read routes (own-scoped, `requireBuilder`): `GET /store/entitlements`, `GET /store/modules/:key/entitlement`. Install (task 1003785) grants a free module through `POST /store/modules/:key/acquire`; the buy action (area 8) will grant a paid one.
|
|
276
278
|
|
|
277
279
|
Bongos views: `claimable_tasks` (rebuilt in 006 to use SELECT t.*; rebuilt again in 014 so the new `discipline` column flows through), `version_progress` (rebuilt in 006 to count only leaf tasks for forward-compat with task hierarchy), `tasks_session_fit` (new in 006: derives `fits_session_modes[]` and `credits_per_minute`; rebuilt in 014 for the same SELECT t.* refresh), `task_grades_with_context` (new in 016: joins task_grades × tasks × builders × cost_log so the V4 optimizer has quality-per-dollar in one read).
|
|
@@ -2729,5 +2729,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2729
2729
|
landed since 1.20.34 with no explicit bump. run 36787635391. (task 1002620)
|
|
2730
2730
|
1.20.36 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2731
2731
|
landed since 1.20.35 with no explicit bump. run 36788696388. (task 1002620)
|
|
2732
|
+
1.20.37 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2733
|
+
landed since 1.20.36 with no explicit bump. run 36790298715. (task 1002620)
|
|
2732
2734
|
---------------------------------------------------------------------------
|
|
2733
2735
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.37",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.37",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.37",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8303,5 +8303,15 @@
|
|
|
8303
8303
|
"id": "1004450",
|
|
8304
8304
|
"text": "The deploy page now explains why an update stopped in plain words, says when it will try again, and offers a one-click fix when the only thing in the way is your own update setting. You can also see and change how far a projec"
|
|
8305
8305
|
}
|
|
8306
|
+
],
|
|
8307
|
+
"1.20.37": [
|
|
8308
|
+
{
|
|
8309
|
+
"id": "1003791",
|
|
8310
|
+
"text": "A module's own tests can now be run and scored: the store records what share of them pass, and a module with no tests shows as 'no data yet' instead of failing."
|
|
8311
|
+
},
|
|
8312
|
+
{
|
|
8313
|
+
"id": "1004406",
|
|
8314
|
+
"text": "The unattended runner stops restarting every time anyone merges: it now updates its code in place and only restarts when its own code has changed."
|
|
8315
|
+
}
|
|
8306
8316
|
]
|
|
8307
8317
|
}
|
|
@@ -359,7 +359,7 @@ const MAX_CLAIM_ATTEMPTS = 5;
|
|
|
359
359
|
const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
|
|
360
360
|
|
|
361
361
|
function newRunnerState() {
|
|
362
|
-
return { setAside: new Map(), queueGate: null, limitUntil: null };
|
|
362
|
+
return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null };
|
|
363
363
|
}
|
|
364
364
|
const RUNNER_STATE = newRunnerState();
|
|
365
365
|
|
|
@@ -666,13 +666,16 @@ function heartbeatPath() {
|
|
|
666
666
|
// output, reddening `unit` on main and blocking every PR. Use a regular FILE as the
|
|
667
667
|
// parent directory instead: mkdir under it is ENOTDIR immediately, everywhere, and it
|
|
668
668
|
// does not depend on the caller's permissions.
|
|
669
|
-
function writeHeartbeat(now = Date.now()) {
|
|
669
|
+
function writeHeartbeat(now = Date.now(), runnerState = RUNNER_STATE) {
|
|
670
670
|
try {
|
|
671
671
|
const p = heartbeatPath();
|
|
672
672
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
673
673
|
// `head` is what this process is RUNNING, not what is on disk (task 1004374).
|
|
674
674
|
// Alive and alive-on-stale-code look identical from a check-in age alone.
|
|
675
|
-
|
|
675
|
+
// `disk_head` appears only when the checkout was refreshed in place past what
|
|
676
|
+
// is running (task 1004406): "up to date on disk, old supervisor" is visible.
|
|
677
|
+
const disk = runnerState && runnerState.diskHead && runnerState.diskHead !== LOADED_HEAD ? runnerState.diskHead : undefined;
|
|
678
|
+
fs.writeFileSync(p, JSON.stringify({ pid: process.pid, at: now, head: LOADED_HEAD, ...(disk ? { disk_head: disk } : {}) }));
|
|
676
679
|
return true;
|
|
677
680
|
} catch (_) { return false; }
|
|
678
681
|
}
|
|
@@ -851,10 +854,79 @@ async function codeDrifted(deps = {}) {
|
|
|
851
854
|
// the comparison untestable in isolation.
|
|
852
855
|
const mine = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
|
|
853
856
|
if (!mine) return null;
|
|
857
|
+
// Measured from what is on DISK once the checkout has been refreshed in place
|
|
858
|
+
// (task 1004406), or every loop after a refresh would see the same drift again.
|
|
859
|
+
const base = ((deps.state || RUNNER_STATE).diskHead) || mine;
|
|
854
860
|
let tip = null;
|
|
855
861
|
try { tip = await runFetchHead(deps); } catch (_) { return null; }
|
|
856
862
|
if (!tip) return null;
|
|
857
|
-
return tip !==
|
|
863
|
+
return tip !== base ? { from: base.slice(0, 8), to: String(tip).slice(0, 8) } : false;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ── pull always, restart rarely (task 1004406) ──────────────────────────────
|
|
867
|
+
//
|
|
868
|
+
// Owner-approved 2026-09-30. Main moves every time ANY session lands, and a
|
|
869
|
+
// relaunch per merge meant a busy day of restarts (four in fourteen minutes on
|
|
870
|
+
// 2026-09-29). But a restart only refreshes code THIS process holds in memory:
|
|
871
|
+
// every worker, and every claim.js / worktree.js / release.js the loop shells
|
|
872
|
+
// out to, is a fresh process that runs whatever is on disk. So on drift the
|
|
873
|
+
// checkout is updated in place, and the process exits for the wrapper only when
|
|
874
|
+
// a change touches code it has actually loaded.
|
|
875
|
+
//
|
|
876
|
+
// SUPERVISOR_PATH_RES catch the supervisor's own files even before they are
|
|
877
|
+
// required (autobongos-verify.js is loaded lazily on the first ship), so a change
|
|
878
|
+
// to a file the loop is ABOUT to load cannot mix old callers with a new callee.
|
|
879
|
+
const SUPERVISOR_PATH_RES = [/^scripts\/gds\/autobongos-[^/]+$/, /^modules\/autonomy\//];
|
|
880
|
+
|
|
881
|
+
// The repo files this process has loaded, repo-relative with forward slashes.
|
|
882
|
+
function loadedFiles() {
|
|
883
|
+
const root = REPO_ROOT + path.sep;
|
|
884
|
+
const nm = path.sep + 'node_modules' + path.sep;
|
|
885
|
+
return Object.keys(require.cache)
|
|
886
|
+
.filter((p) => p.startsWith(root) && !p.includes(nm))
|
|
887
|
+
.map((p) => path.relative(REPO_ROOT, p).split(path.sep).join('/'));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// The changed files that force a restart: ones this process holds, or ones under
|
|
891
|
+
// the supervisor's own paths. Everything else is picked up by the next child.
|
|
892
|
+
function supervisorTouched(changed = [], loaded = loadedFiles()) {
|
|
893
|
+
const held = new Set(loaded);
|
|
894
|
+
return changed.filter((f) => held.has(f) || SUPERVISOR_PATH_RES.some((re) => re.test(f)));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// refreshCheckout — the wrapper's fetch + reset, done from inside the process.
|
|
898
|
+
//
|
|
899
|
+
// THE MAIN CHECKOUT ONLY. It runs `git reset --hard`, and a linked worktree is a
|
|
900
|
+
// claim somebody is building in; resetting one would destroy their work. The
|
|
901
|
+
// check compares git-dir to git-common-dir, which are equal only in the main
|
|
902
|
+
// checkout. Armed only by main() (`opts.refreshInPlace`), so a test driving
|
|
903
|
+
// forever() can never reach it — in CI that reset would wipe the code under test.
|
|
904
|
+
//
|
|
905
|
+
// The diff is from the LOADED commit, not the last refresh, so a supervisor
|
|
906
|
+
// change that landed several refreshes ago still forces the restart it needs.
|
|
907
|
+
async function refreshCheckout(deps = {}) {
|
|
908
|
+
const runCmd = deps.run || run;
|
|
909
|
+
const git = (args) => runCmd('git', args, { cwd: REPO_ROOT });
|
|
910
|
+
try {
|
|
911
|
+
const [gd, cd] = await Promise.all([git(['rev-parse', '--git-dir']), git(['rev-parse', '--git-common-dir'])]);
|
|
912
|
+
if (!gd.ok || !cd.ok) return { ok: false, reason: 'could not tell which checkout this is' };
|
|
913
|
+
if (path.resolve(REPO_ROOT, String(gd.stdout).trim()) !== path.resolve(REPO_ROOT, String(cd.stdout).trim())) {
|
|
914
|
+
return { ok: false, reason: 'not the main checkout — refusing to reset a linked worktree' };
|
|
915
|
+
}
|
|
916
|
+
const loaded = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
|
|
917
|
+
if (!loaded) return { ok: false, reason: 'the running commit is unknown' };
|
|
918
|
+
if (!(await git(['fetch', '--quiet', 'origin', 'main'])).ok) return { ok: false, reason: 'fetch failed' };
|
|
919
|
+
const fh = await git(['rev-parse', 'FETCH_HEAD']);
|
|
920
|
+
const to = fh.ok ? String(fh.stdout).trim() : '';
|
|
921
|
+
if (!/^[0-9a-f]{40}$/.test(to)) return { ok: false, reason: 'could not read the fetched commit' };
|
|
922
|
+
const diff = await git(['diff', '--name-only', loaded, to]);
|
|
923
|
+
if (!diff.ok) return { ok: false, reason: 'could not diff the running commit against main' };
|
|
924
|
+
if (!(await git(['reset', '--quiet', '--hard', to])).ok) return { ok: false, reason: 'reset failed' };
|
|
925
|
+
const changed = String(diff.stdout).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
926
|
+
return { ok: true, to, changed };
|
|
927
|
+
} catch (e) {
|
|
928
|
+
return { ok: false, reason: e && e.message ? e.message : String(e) };
|
|
929
|
+
}
|
|
858
930
|
}
|
|
859
931
|
|
|
860
932
|
// The head of origin/main, or null. `git ls-remote` rather than `fetch`: it moves
|
|
@@ -932,7 +1004,9 @@ async function forever(opts, deps = {}) {
|
|
|
932
1004
|
// reports itself anyway: the hall reads the AGE of the last check-in, so a
|
|
933
1005
|
// failed beat shows up as silence, which is the signal.
|
|
934
1006
|
const beat = async (fields) => {
|
|
935
|
-
|
|
1007
|
+
// The runner state is passed, not defaulted: the file names `disk_head` from
|
|
1008
|
+
// it (task 1004406), and an injected state must reach the beat that reports it.
|
|
1009
|
+
try { beatFile(Date.now(), deps.state || RUNNER_STATE); } catch (_) { /* a runner that cannot write its file still runs */ }
|
|
936
1010
|
try { await beatServer(fields, deps); } catch (_) { /* and one that cannot reach the instance still runs */ }
|
|
937
1011
|
};
|
|
938
1012
|
// The running commit, read ONCE, BEFORE the first beat (task 1004397). It used
|
|
@@ -993,14 +1067,35 @@ async function forever(opts, deps = {}) {
|
|
|
993
1067
|
// spends nearly all of its time and an upgrade should not sit behind it.
|
|
994
1068
|
const upMs = (deps.now ? deps.now() : Date.now()) - Date.parse(RUNNER_STARTED_AT);
|
|
995
1069
|
const mayUpgrade = upMs >= MIN_UPTIME_BEFORE_UPGRADE_MS;
|
|
996
|
-
|
|
1070
|
+
let drift = mayUpgrade
|
|
997
1071
|
? (deps.codeDrifted ? await deps.codeDrifted(deps) : await codeDrifted(deps))
|
|
998
1072
|
: false;
|
|
999
1073
|
if (drift) {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1074
|
+
// Pull always, restart rarely (task 1004406). Only main() arms the refresh.
|
|
1075
|
+
let why = 'exiting so the wrapper can relaunch on the new code';
|
|
1076
|
+
if (opts.refreshInPlace) {
|
|
1077
|
+
const refreshed = await (deps.refreshCheckout || refreshCheckout)(deps);
|
|
1078
|
+
if (refreshed && refreshed.ok) {
|
|
1079
|
+
const touched = supervisorTouched(refreshed.changed, deps.loadedFiles ? deps.loadedFiles() : loadedFiles());
|
|
1080
|
+
if (!touched.length) {
|
|
1081
|
+
(deps.state || RUNNER_STATE).diskHead = refreshed.to;
|
|
1082
|
+
log({ event: 'code_refreshed', from: drift.from, to: refreshed.to.slice(0, 8), changed: refreshed.changed.length,
|
|
1083
|
+
reason: `main moved from ${drift.from} to ${refreshed.to.slice(0, 8)} — checkout updated in place; none of the ${refreshed.changed.length} changed file(s) is code this process runs, so no restart` });
|
|
1084
|
+
await beat({ mode: state.mode, last_event: 'code_refreshed' });
|
|
1085
|
+
drift = null;
|
|
1086
|
+
} else {
|
|
1087
|
+
const more = touched.length > 5 ? `, +${touched.length - 5} more` : '';
|
|
1088
|
+
why = `it changed code this process runs (${touched.slice(0, 5).join(', ')}${more}) — exiting so the wrapper can relaunch on it`;
|
|
1089
|
+
}
|
|
1090
|
+
} else {
|
|
1091
|
+
why = `the in-place refresh did not run (${(refreshed && refreshed.reason) || 'no result'}) — exiting so the wrapper's own refresh can`;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (drift) {
|
|
1095
|
+
log({ event: 'upgrade_exit', from: drift.from, to: drift.to, reason: `main moved from ${drift.from} to ${drift.to} — ${why}` });
|
|
1096
|
+
await beat({ mode: state.mode, last_event: 'upgrade_exit' });
|
|
1097
|
+
return UPGRADE_EXIT_CODE;
|
|
1098
|
+
}
|
|
1004
1099
|
}
|
|
1005
1100
|
|
|
1006
1101
|
const { waited, jumped } = await wait(state.waitS, { ...deps, beat }, opts.cadence);
|
|
@@ -1015,7 +1110,7 @@ async function main(argv) {
|
|
|
1015
1110
|
// it lived on the machine being fenced; the database owns that now, and an empty
|
|
1016
1111
|
// `--goal` means "every allowlisted goal" rather than "anything claimable". The
|
|
1017
1112
|
// refusal did not disappear — it moved somewhere the runner host cannot edit.
|
|
1018
|
-
if (opts.forever) return forever(opts);
|
|
1113
|
+
if (opts.forever) return forever({ ...opts, refreshInPlace: true });
|
|
1019
1114
|
|
|
1020
1115
|
// The one-shot path, kept for an operator running a single task by hand. It
|
|
1021
1116
|
// still reconciles first: a leftover claim is a leftover claim whoever is
|
|
@@ -1043,5 +1138,5 @@ module.exports = {
|
|
|
1043
1138
|
worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
|
|
1044
1139
|
heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
|
|
1045
1140
|
isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT, failureReason,
|
|
1046
|
-
newRunnerState, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
|
|
1141
|
+
newRunnerState, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
|
|
1047
1142
|
};
|
|
@@ -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' }),
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// tests/module_assess_tests.mjs — the Tests assessment signal (task 1003791, ADR 0343).
|
|
2
|
+
// No database: the module's tests really run (real node child processes, from a REAL
|
|
3
|
+
// packed tarball verified by module-artifact.js), and the signal row goes to a fake
|
|
4
|
+
// pg client, so what is pinned is the outcome vocabulary core_265 stores:
|
|
5
|
+
// no tests -> no_data, never a fail
|
|
6
|
+
// tests ran -> scored, round(100 x passing files / files), sample_size = files
|
|
7
|
+
// unreadable -> not_scored
|
|
8
|
+
// plus the two safety properties: the sandbox is always removed, and a module's test
|
|
9
|
+
// process never inherits the parent's credentials. And it runs regardless of whether
|
|
10
|
+
// the module is enabled — nothing here consults isModuleEnabled.
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/module_assess_tests.mjs
|
|
13
|
+
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const { discoverTestFiles, assessTests, recordTestsSignal, insertSignal, runRefusal, testEnv, SANDBOX_PREFIX } = require('../scripts/gds/module-assess-tests.js');
|
|
23
|
+
const { packModule, verifyModuleArtifact } = require('../scripts/gds/module-artifact.js');
|
|
24
|
+
const { relativeArtifactPath } = require('../src/bongos/module-store.js');
|
|
25
|
+
|
|
26
|
+
const PASS = "import assert from 'node:assert'; assert.equal(1 + 1, 2); console.log('1 passed, 0 failed');\n";
|
|
27
|
+
const FAIL = "import assert from 'node:assert'; assert.equal(1 + 1, 3);\n";
|
|
28
|
+
const file = (p, text) => ({ path: p, buf: Buffer.from(text) });
|
|
29
|
+
const tmpRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'mod-assess-root-'));
|
|
30
|
+
const sandboxesIn = (root) => fs.readdirSync(root).filter((n) => n.startsWith(SANDBOX_PREFIX));
|
|
31
|
+
|
|
32
|
+
test('only tests/*.mjs at the top of tests/ count, as in the unit gate', () => {
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
discoverTestFiles(['module.json', 'tests/b.mjs', 'tests/a.mjs', 'tests/fixtures/x.mjs', 'tests/helper.js', 'lib/tests/c.mjs']),
|
|
35
|
+
['tests/a.mjs', 'tests/b.mjs']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a module that declares no tests is no_data, not a fail', async () => {
|
|
39
|
+
const root = tmpRoot();
|
|
40
|
+
const s = await assessTests('weather', [file('module.json', '{}'), file('index.js', '')], { trusted: true, root });
|
|
41
|
+
assert.equal(s.outcome, 'no_data');
|
|
42
|
+
assert.equal(s.score, null, 'no_data carries no number (core_265: score exactly when scored)');
|
|
43
|
+
assert.equal(s.sample_size, 0);
|
|
44
|
+
assert.match(s.reason, /declares no tests/);
|
|
45
|
+
assert.deepEqual(sandboxesIn(root), [], 'nothing is unpacked when there is nothing to run');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('all passing scores 100; one of two failing scores 50', async () => {
|
|
49
|
+
const root = tmpRoot();
|
|
50
|
+
const all = await assessTests('weather', [file('tests/a.mjs', PASS), file('tests/b.mjs', PASS)], { trusted: true, root });
|
|
51
|
+
assert.equal(all.outcome, 'scored');
|
|
52
|
+
assert.equal(all.score, 100);
|
|
53
|
+
assert.equal(all.sample_size, 2);
|
|
54
|
+
|
|
55
|
+
const half = await assessTests('weather', [file('tests/a.mjs', PASS), file('tests/b.mjs', FAIL)], { trusted: true, root });
|
|
56
|
+
assert.equal(half.outcome, 'scored');
|
|
57
|
+
assert.equal(half.score, 50);
|
|
58
|
+
assert.equal(half.sample_size, 2);
|
|
59
|
+
assert.match(half.reason, /1 of 2/);
|
|
60
|
+
const failed = half.detail.files.find((f) => f.file === 'tests/b.mjs');
|
|
61
|
+
assert.equal(failed.passed, false);
|
|
62
|
+
assert.match(failed.tail, /AssertionError|assert/i, 'a failing file keeps a bounded tail of why');
|
|
63
|
+
assert.deepEqual(sandboxesIn(root), [], 'the sandbox is removed after every run');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('every failing still scores 0 — distinct from no_data', async () => {
|
|
67
|
+
const s = await assessTests('weather', [file('tests/a.mjs', FAIL)], { trusted: true, root: tmpRoot() });
|
|
68
|
+
assert.equal(s.outcome, 'scored');
|
|
69
|
+
assert.equal(s.score, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a module test reaches its own files by relative path, at the modules/<key>/ depth', async () => {
|
|
73
|
+
const s = await assessTests('weather', [
|
|
74
|
+
file('lib/add.js', 'module.exports = (a, b) => a + b;\n'),
|
|
75
|
+
file('tests/a.mjs', "import { createRequire } from 'node:module'; import assert from 'node:assert';\nconst add = createRequire(import.meta.url)('../lib/add.js'); assert.equal(add(2, 2), 4);\n"),
|
|
76
|
+
], { trusted: true, root: tmpRoot() });
|
|
77
|
+
assert.equal(s.score, 100, JSON.stringify(s.detail));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a module test process never inherits the parent\'s credentials', async () => {
|
|
81
|
+
process.env.DATABASE_URL = 'postgres://secret@host/db';
|
|
82
|
+
process.env.BONGOS_TOKEN = 'secret-token';
|
|
83
|
+
try {
|
|
84
|
+
const s = await assessTests('weather', [file('tests/env.mjs',
|
|
85
|
+
"import assert from 'node:assert'; assert.equal(process.env.DATABASE_URL, undefined); assert.equal(process.env.BONGOS_TOKEN, undefined); assert.equal(process.env.NODE_ENV, 'test');\n")],
|
|
86
|
+
{ trusted: true, root: tmpRoot() });
|
|
87
|
+
assert.equal(s.score, 100, JSON.stringify(s.detail));
|
|
88
|
+
} finally {
|
|
89
|
+
delete process.env.DATABASE_URL;
|
|
90
|
+
delete process.env.BONGOS_TOKEN;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a hung test file is killed at the timeout and counted as failed', async () => {
|
|
95
|
+
const s = await assessTests('weather', [file('tests/hang.mjs', 'setInterval(() => {}, 1000);\n'), file('tests/ok.mjs', PASS)],
|
|
96
|
+
{ trusted: true, root: tmpRoot(), timeoutMs: 1500 });
|
|
97
|
+
assert.equal(s.score, 50);
|
|
98
|
+
assert.equal(s.detail.files.find((f) => f.file === 'tests/hang.mjs').timed_out, true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---- recordTestsSignal: tarball in, one appended row out ----
|
|
102
|
+
|
|
103
|
+
const SANDBOX_ENV = { MODULE_TEST_SANDBOX: '1', PATH: process.env.PATH, SystemRoot: process.env.SystemRoot };
|
|
104
|
+
|
|
105
|
+
test('outside the separate testing environment, a published module is never run', async () => {
|
|
106
|
+
let ran = false;
|
|
107
|
+
const db = { async query() { ran = true; return { rows: [] }; } };
|
|
108
|
+
for (const env of [{}, { MODULE_TEST_SANDBOX: 'true' }, { MODULE_TEST_SANDBOX: '0' }]) {
|
|
109
|
+
const res = await recordTestsSignal(42, { db, env, root: tmpRoot() });
|
|
110
|
+
assert.equal(res.ok, false);
|
|
111
|
+
assert.equal(res.code, 'not_in_test_sandbox');
|
|
112
|
+
}
|
|
113
|
+
assert.equal(ran, false, 'refused before reading the store or the tarball');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('the test environment keeps no credential and pins HOME to the sandbox', () => {
|
|
117
|
+
process.env.SOME_API_KEY = 'x';
|
|
118
|
+
try {
|
|
119
|
+
const env = testEnv('/sb');
|
|
120
|
+
assert.equal(env.SOME_API_KEY, undefined);
|
|
121
|
+
assert.equal(env.HOME, '/sb');
|
|
122
|
+
assert.equal(env.NODE_ENV, 'test');
|
|
123
|
+
} finally { delete process.env.SOME_API_KEY; }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function fakeDb(versionRow) {
|
|
127
|
+
const inserts = [];
|
|
128
|
+
return {
|
|
129
|
+
inserts,
|
|
130
|
+
async query(sql, params) {
|
|
131
|
+
if (/FROM store_module_versions/.test(sql)) return { rows: versionRow ? [versionRow] : [] };
|
|
132
|
+
if (/INSERT INTO module_assessment_signals/.test(sql)) {
|
|
133
|
+
assert.doesNotMatch(sql, /UPDATE/i, 'append-only');
|
|
134
|
+
inserts.push(params);
|
|
135
|
+
const [version_id, outcome, score, sample_size, reason] = params;
|
|
136
|
+
return { rows: [{ id: 7, version_id, part: 'tests', outcome, score, sample_size, reason, measured_at: new Date() }] };
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function publishedFixture(extraFiles) {
|
|
144
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-assess-src-'));
|
|
145
|
+
const dir = path.join(src, 'weather');
|
|
146
|
+
fs.mkdirSync(path.join(dir, 'tests'), { recursive: true });
|
|
147
|
+
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ key: 'weather', title: 'Weather', description: 'x', version: '1.2.0', coreVersion: '^1.0.0', contributes: {} }));
|
|
148
|
+
for (const [p, text] of Object.entries(extraFiles)) fs.writeFileSync(path.join(dir, ...p.split('/')), text);
|
|
149
|
+
const { tgz } = packModule('weather', { modulesDir: src, now: () => new Date('2026-09-30T00:00:00Z'), modeOf: () => '644' });
|
|
150
|
+
const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-assess-store-'));
|
|
151
|
+
const final = path.join(storeDir, 'weather', 'weather-1.2.0.tgz');
|
|
152
|
+
fs.mkdirSync(path.dirname(final), { recursive: true });
|
|
153
|
+
fs.writeFileSync(final, tgz);
|
|
154
|
+
return { storeDir, final, row: { id: 42, module_key: 'weather', version: '1.2.0', artifact_path: relativeArtifactPath(final, { dir: storeDir }) } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test('a published version is tested from its tarball and one tests row is appended', async () => {
|
|
158
|
+
const { storeDir, row } = publishedFixture({ 'tests/a.mjs': PASS, 'tests/b.mjs': FAIL });
|
|
159
|
+
const db = fakeDb(row);
|
|
160
|
+
const res = await recordTestsSignal(42, { db, storeDir, root: tmpRoot(), env: SANDBOX_ENV });
|
|
161
|
+
assert.equal(res.ok, true);
|
|
162
|
+
assert.equal(db.inserts.length, 1);
|
|
163
|
+
const [versionId, outcome, score, sample, , detail] = db.inserts[0];
|
|
164
|
+
assert.equal(versionId, 42);
|
|
165
|
+
assert.equal(outcome, 'scored');
|
|
166
|
+
assert.equal(score, 50);
|
|
167
|
+
assert.equal(sample, 2);
|
|
168
|
+
assert.equal(JSON.parse(detail).files.length, 2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a version whose tarball is missing or tampered is not_scored, not failed', async () => {
|
|
172
|
+
const { storeDir, final, row } = publishedFixture({ 'tests/a.mjs': PASS });
|
|
173
|
+
fs.writeFileSync(final, Buffer.from('not a tarball'));
|
|
174
|
+
const db = fakeDb(row);
|
|
175
|
+
const res = await recordTestsSignal(42, { db, storeDir, root: tmpRoot(), env: SANDBOX_ENV });
|
|
176
|
+
assert.equal(res.ok, true);
|
|
177
|
+
const [, outcome, score] = db.inserts[0];
|
|
178
|
+
assert.equal(outcome, 'not_scored');
|
|
179
|
+
assert.equal(score, null);
|
|
180
|
+
|
|
181
|
+
fs.rmSync(final);
|
|
182
|
+
const db2 = fakeDb(row);
|
|
183
|
+
await recordTestsSignal(42, { db: db2, storeDir, root: tmpRoot(), env: SANDBOX_ENV });
|
|
184
|
+
assert.equal(db2.inserts[0][1], 'not_scored');
|
|
185
|
+
assert.doesNotMatch(db2.inserts[0][4], /[\/]/, 'the stored reason carries an error code, never a local path');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('an unknown version writes nothing', async () => {
|
|
189
|
+
const db = fakeDb(null);
|
|
190
|
+
const res = await recordTestsSignal(99, { db, root: tmpRoot(), env: SANDBOX_ENV });
|
|
191
|
+
assert.equal(res.ok, false);
|
|
192
|
+
assert.equal(res.code, 'version_not_found');
|
|
193
|
+
assert.equal(db.inserts.length, 0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('the runner never consults whether the module is enabled', () => {
|
|
197
|
+
const src = fs.readFileSync(new URL('../scripts/gds/module-assess-tests.js', import.meta.url), 'utf8').replace(/\/\/.*$/gm, '');
|
|
198
|
+
assert.doesNotMatch(src, /isModuleEnabled/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('the sandbox dir is gitignored in case a run is killed mid-way', () => {
|
|
202
|
+
const gi = fs.readFileSync(new URL('../.gitignore', import.meta.url), 'utf8');
|
|
203
|
+
assert.match(gi, /^\/\.module-assess-\*\/$/m);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('the gate lives in the runner itself: assessTests refuses untrusted code outside the testing environment', async () => {
|
|
207
|
+
const root = tmpRoot();
|
|
208
|
+
await assert.rejects(assessTests('weather', [file('tests/a.mjs', PASS)], { root, env: {} }), { code: 'not_in_test_sandbox' });
|
|
209
|
+
assert.deepEqual(sandboxesIn(root), [], 'refused before anything is unpacked');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('a mis-set flag on a machine that holds secrets is still refused', async () => {
|
|
213
|
+
const root = tmpRoot();
|
|
214
|
+
for (const k of ['DATABASE_URL', 'BONGOS_TOKEN', 'STRIPE_SECRET', 'OPENAI_API_KEY']) {
|
|
215
|
+
assert.equal(runRefusal({ env: { ...SANDBOX_ENV, [k]: 'x' }, root }).code, 'secrets_present', k);
|
|
216
|
+
}
|
|
217
|
+
assert.equal(runRefusal({ env: SANDBOX_ENV, root }), null, 'a clean testing environment may run');
|
|
218
|
+
fs.writeFileSync(path.join(root, '.env'), 'DATABASE_URL=x\n');
|
|
219
|
+
assert.equal(runRefusal({ env: SANDBOX_ENV, root }).code, 'secrets_present', 'a .env at the core root counts');
|
|
220
|
+
await assert.rejects(assessTests('weather', [file('tests/a.mjs', PASS)], { root, env: SANDBOX_ENV }), { code: 'secrets_present' });
|
|
221
|
+
assert.equal(runRefusal({ trusted: true, env: {}, root }), null, 'your own checkout (--dir) is trusted');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('insertSignal appends exactly one tests row with the signal fields', async () => {
|
|
225
|
+
const seen = [];
|
|
226
|
+
const db = { async query(sql, params) { seen.push({ sql, params }); return { rows: [{ id: 1 }] }; } };
|
|
227
|
+
await insertSignal(5, { outcome: 'scored', score: 80, sample_size: 5, reason: 'r', detail: { files: [] } }, { db });
|
|
228
|
+
assert.equal(seen.length, 1);
|
|
229
|
+
assert.match(seen[0].sql, /INSERT INTO module_assessment_signals[\s\S]*'tests'/);
|
|
230
|
+
assert.doesNotMatch(seen[0].sql, /UPDATE/i);
|
|
231
|
+
assert.deepEqual(seen[0].params, [5, 'scored', 80, 5, 'r', '{"files":[]}']);
|
|
232
|
+
});
|