@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 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.36",
6
- "core_contract": "1.20.36",
7
- "source_commit": "ef99e051581c54bf82390637612645c6843c1a89",
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:00:27.650Z",
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": 2673,
14
+ "functional_verbatim": 2675,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3261,
20
- "tree_sha256": "f5c36910af4994519b2992104681279da50a6e91bac57f5d05457f50d795153a",
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": "4149011e96acb427e32a55b0e48026ad0aa5a5ac2d3fa5054da9e5ef0e4f9a3f"
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": "c47bc356e4e0b51f670d88d07c5996ba6cf899572516ede0d76fa7048784f297"
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": "094e54a0001c11e30b43314ab7158d28a90e01307c158ce7349849c03794512d"
9000
+ "sha256": "071e1c2aa6ecec48b6aa07cfb8b548a0658b9812066a0f0bcb9f555310ce07ef"
9001
9001
  },
9002
9002
  {
9003
9003
  "path": "package.json",
9004
9004
  "mode": "0000644",
9005
- "sha256": "d8b6832d3d9283d028c516cb646cb0d8ef677913c45f1ac84f45675f84d1017e"
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": "60f91c3b0b103ee76210b8501b1b7bb1e1c3b250705628c79c8383354602a0b3"
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": "ac0d77b54920e50205546db4269957fd6a0fc2f1ba1aca9c097abc920fa04f2a"
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": "8d3d3bbb64ab3138e8d2f25e6bd6b1f6158c720d700d3d7388e63e5bf12276f5"
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": "91e92c46fe27a4382a762029418437642b038a77286de0977ac694397759a0b6"
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",
@@ -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.36",
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.36",
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.36",
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",
@@ -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
- fs.writeFileSync(p, JSON.stringify({ pid: process.pid, at: now, head: LOADED_HEAD }));
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 !== mine ? { from: mine.slice(0, 8), to: String(tip).slice(0, 8) } : false;
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
- try { beatFile(); } catch (_) { /* a runner that cannot write its file still runs */ }
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
- const drift = mayUpgrade
1070
+ let drift = mayUpgrade
997
1071
  ? (deps.codeDrifted ? await deps.codeDrifted(deps) : await codeDrifted(deps))
998
1072
  : false;
999
1073
  if (drift) {
1000
- log({ event: 'upgrade_exit', from: drift.from, to: drift.to,
1001
- reason: `main moved from ${drift.from} to ${drift.to} — exiting so the wrapper can relaunch on the new code` });
1002
- await beat({ mode: state.mode, last_event: 'upgrade_exit' });
1003
- return UPGRADE_EXIT_CODE;
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.36'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
+ });