@bongos/core 1.21.125 → 1.21.127

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.
Files changed (55) hide show
  1. package/.bongos-core.json +106 -51
  2. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +6 -0
  3. package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +1 -1
  4. package/docs/api/openapi.json +4 -4
  5. package/docs/copy-inventory.md +128 -126
  6. package/docs/copy-registry.json +188 -170
  7. package/docs/module-api-changelog.md +5 -1
  8. package/docs/page-inventory.json +2 -1
  9. package/docs/page-readings.json +152 -143
  10. package/modules/hall-ui/public/goal-forest-lib.js +36 -0
  11. package/modules/hall-ui/public/goal-forest-render.js +50 -10
  12. package/modules/hall-ui/public/goals-page.js +19 -22
  13. package/modules/hall-ui/public/goals-render.js +6 -16
  14. package/modules/hall-ui/public/goals.css +13 -4
  15. package/modules/hall-ui/public/goals.html +22 -24
  16. package/modules/hall-ui/public/goals.states.json +19 -1
  17. package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
  18. package/modules/platform-identity/invite-authz.js +1 -1
  19. package/modules/provisioning/catalog-bridge.js +1 -1
  20. package/modules/provisioning/provisioning.js +1 -1
  21. package/modules/provisioning/route-helpers.js +50 -0
  22. package/modules/provisioning/routes/disconnect.js +6 -5
  23. package/modules/provisioning/routes/env-manifest.js +11 -27
  24. package/modules/provisioning/routes/provisioning.js +58 -75
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +24 -0
  28. package/scripts/gds/exec-path-guard.js +1 -1
  29. package/scripts/gds/fitness.js +1 -0
  30. package/scripts/gds/module-assess-tests.js +40 -11
  31. package/scripts/gds/module-assess-version.js +10 -2
  32. package/scripts/gds/module-error-report.js +148 -0
  33. package/scripts/gds/module-test-runner/README.md +44 -0
  34. package/scripts/gds/module-test-runner/module-tests.yml +87 -0
  35. package/scripts/gds/module-test-runner.js +358 -0
  36. package/scripts/gds/module.js +1 -0
  37. package/scripts/gds/provision-caps.js +63 -0
  38. package/scripts/gds/provision-process.js +3 -0
  39. package/scripts/gds/provision-units.js +3 -0
  40. package/scripts/gds/provision.js +3 -1
  41. package/src/module-api.js +1 -1
  42. package/src/module-loader/error-tally.js +188 -0
  43. package/src/module-loader/loader.js +52 -4
  44. package/src/platform-server.js +7 -0
  45. package/tests/goal_forest_lib.mjs +28 -0
  46. package/tests/goal_forest_page.mjs +106 -11
  47. package/tests/module_assess_price_parity.mjs +2 -2
  48. package/tests/module_assess_score.mjs +13 -3
  49. package/tests/module_error_counts.mjs +265 -0
  50. package/tests/module_test_runner.mjs +294 -0
  51. package/tests/project_catalog_routes.mjs +1 -1
  52. package/tests/provision.mjs +2 -0
  53. package/tests/provision_resource_caps.mjs +117 -0
  54. package/tests/provisioning_module_selection.mjs +1 -1
  55. package/tests/provisioning_project_owner_gate.mjs +271 -0
@@ -0,0 +1,63 @@
1
+ // scripts/gds/provision-caps.js — the resource caps every hosted project's service carries
2
+ // (task 1004414). A cloud-host project runs its owner's code on the box that also serves the
3
+ // hub, and ADR 0354 lets any signed-in builder host one, so a runaway project must not be
4
+ // able to starve the others or the hub.
5
+ //
6
+ // The numbers are the owner's (2026-10-15), sized from ADR 0285's measurement: a project
7
+ // idles at ~45-70 MB and peaks at 118-134 MB at boot (page cache included), and uses a
8
+ // fraction of a percent of one core. MemoryHigh throttles and reclaims before MemoryMax
9
+ // kills; a killed project comes back through the unit's Restart=always. Too tight a cap
10
+ // takes a healthy project down, so change these only as a decision.
11
+ //
12
+ // A NEW unit renders them (serviceUnit). An EXISTING unit takes them with `systemctl
13
+ // set-property`, which applies live and persists a drop-in under /etc/systemd/system.control/,
14
+ // so nothing restarts and no unit file is rewritten — rewriting would move a project made
15
+ // before the per-instance accounts (task 1003369) onto an account that does not exist.
16
+ // systemd only: the supervisord image has no cgroups to cap.
17
+
18
+ 'use strict';
19
+
20
+ const RESOURCE_CAPS = Object.freeze({
21
+ MemoryHigh: '160M',
22
+ MemoryMax: '256M',
23
+ CPUQuota: '50%',
24
+ TasksMax: '256',
25
+ });
26
+
27
+ function capDirectives() {
28
+ return Object.entries(RESOURCE_CAPS).map(([key, value]) => `${key}=${value}`);
29
+ }
30
+
31
+ // The slug is interpolated into a root shell command, so it is held to the create route's
32
+ // charset here too rather than trusted from the row.
33
+ const SAFE_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
34
+
35
+ function setCapsCmd(inst, { sudoP = '' } = {}) {
36
+ if (!SAFE_SLUG.test(String(inst.slug))) throw new Error(`refusing to cap an unsafe slug: ${JSON.stringify(inst.slug)}`);
37
+ return `${sudoP}systemctl set-property ${inst.slug}.service ${capDirectives().join(' ')}`;
38
+ }
39
+
40
+ // `provision.js apply-caps [--apply]` — cap every running hosted project now. A cloud-host
41
+ // always runs on the control plane (capacity.placementBox), so this uses controlExec. One
42
+ // project that refuses never stops the rest; the result names it.
43
+ async function cmdApplyCaps(deps) {
44
+ const { db, log, privileged } = deps;
45
+ const exec = deps.controlExec || deps.exec;
46
+ const sudoP = privileged ? 'sudo ' : '';
47
+ const { rows } = await db.query(
48
+ `SELECT slug FROM provisioning_instances
49
+ WHERE hosting_shape = 'cloud-host' AND status = 'active'
50
+ ORDER BY slug`);
51
+ const capped = [], failed = [];
52
+ for (const { slug } of rows) {
53
+ // makeExec answers an allowFail'd failure ok:true + softFailed:true (softFailResult)
54
+ let r;
55
+ try { r = exec(setCapsCmd({ slug }, { sudoP }), { allowFail: true }); } catch { r = null; }
56
+ (!r || r.ok === false || r.softFailed ? failed : capped).push(slug);
57
+ }
58
+ log(`${deps.apply ? 'capped' : 'would cap (dry-run — add --apply)'} ${capped.length} of ${rows.length} active hosted project(s) at ${capDirectives().join(' ')}`);
59
+ if (failed.length) log(` ✖ could not cap: ${failed.join(', ')} — re-run once each unit is loaded`);
60
+ return { ok: failed.length === 0, capped, failed };
61
+ }
62
+
63
+ module.exports = { RESOURCE_CAPS, capDirectives, setCapsCmd, cmdApplyCaps };
@@ -59,6 +59,9 @@ const systemd = {
59
59
  exec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
60
60
  exec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
61
61
  exec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
62
+ // the caps drop-in `provision.js apply-caps` wrote (task 1004414, provision-caps.js)
63
+ exec(`${sudoP}rm -f /etc/systemd/system.control/${inst.slug}.service.d/50-*.conf`, { allowFail: true });
64
+ exec(`${sudoP}rmdir /etc/systemd/system.control/${inst.slug}.service.d`, { allowFail: true });
62
65
  exec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
63
66
  exec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
64
67
  },
@@ -12,6 +12,7 @@ const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
  const { CONFIG, REPO_ROOT } = require('./provision-config.js');
14
14
  const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
15
+ const { capDirectives } = require('./provision-caps.js');
15
16
 
16
17
  function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
17
18
  const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
@@ -61,6 +62,8 @@ ProtectSystem=strict
61
62
  ProtectHome=read-only
62
63
  PrivateTmp=true
63
64
  ReadWritePaths=${stateDir} -${workDir}/config
65
+ # Resource caps (task 1004414; the numbers and their reasons are in provision-caps.js).
66
+ ${capDirectives().join('\n')}
64
67
 
65
68
  [Install]
66
69
  WantedBy=multi-user.target
@@ -1348,6 +1348,7 @@ Commands:
1348
1348
  teardown <slug> tear down one instance by slug
1349
1349
  reconcile re-enqueue instances stuck mid-flight with no open intent
1350
1350
  status list provisioned instances + estimated run-rate
1351
+ apply-caps cap every active hosted project's memory/CPU/tasks live (task 1004414)
1351
1352
  dns-check <domain> PREFLIGHT: verify the control-plane Cloudflare token can reach
1352
1353
  the domain's zone BEFORE the address step (network-only, no DB)
1353
1354
 
@@ -1397,7 +1398,7 @@ async function main() {
1397
1398
  process.exit(1);
1398
1399
  }
1399
1400
 
1400
- const known = ['run-intents', 'provision', 'teardown', 'reconcile', 'status'];
1401
+ const known = ['run-intents', 'provision', 'teardown', 'reconcile', 'status', 'apply-caps'];
1401
1402
  if (!known.includes(cmd)) { console.error(`unknown command: ${cmd}\n`); usage(); process.exit(2); }
1402
1403
  const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
1403
1404
  if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
@@ -1432,6 +1433,7 @@ async function main() {
1432
1433
  else if (cmd === 'teardown') { const inst = await provisioning.getInstanceBySlug(pool, slug); if (!inst) { console.error(`no instance with slug '${slug}'`); await pool.end().catch(() => {}); process.exit(1); } result = await teardownInstance(inst, deps); }
1433
1434
  else if (cmd === 'reconcile') result = await cmdReconcile(deps);
1434
1435
  else if (cmd === 'status') result = await cmdStatus(deps);
1436
+ else if (cmd === 'apply-caps') result = await require('./provision-caps.js').cmdApplyCaps(deps);
1435
1437
  } catch (e) {
1436
1438
  console.error('fatal:', e && e.message ? e.message : e);
1437
1439
  await pool.end().catch(() => {});
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.21.125'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.127'; // 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');
@@ -0,0 +1,188 @@
1
+ // src/module-loader/error-tally.js — count each mounted module's runtime errors
2
+ // per day, so a store module's Reliability score has real installs behind it
3
+ // (task 1004637, follow-up to task 1003793; ADR 0343 D1).
4
+ //
5
+ // WHY. The store scores Reliability from a daily runtime-error count per module
6
+ // version, but nothing on an instance counted one: the loader console.errored a
7
+ // poller that failed to load and attributed nothing else to a module at all. This
8
+ // file is the instance-side tally the loader feeds; scripts/gds/module-error-report.js
9
+ // is the sender that turns it into reports.
10
+ //
11
+ // WHAT COUNTS. A request a module's router took and answered 500 (a thrown error or
12
+ // an unhandled failure — not a 4xx, which is the caller's mistake, and not a 503,
13
+ // which modules use to say "not set up here"); a poller that failed to load, threw
14
+ // or rejected on start; and anything a poller hands to the `reportError` the loader
15
+ // gives it. Only a module whose manifest carries an exact X.Y.Z version is counted,
16
+ // because the store files counts against an exact version.
17
+ //
18
+ // COUNTS ONLY (owner's privacy posture, 2026-10-01). The tally holds module key,
19
+ // version, day and a whole number — never an error's text, a path or a request.
20
+ //
21
+ // WHO SENDS (owner, 2026-10-15). A builder's sign-in on the box: the store's report
22
+ // routes take a builder, and no project-to-store credential exists (parked as
23
+ // task 1004740). So this file never talks to the network. It writes the tally to the
24
+ // instance config dir, and startDailySend() runs the sender as a CHILD process —
25
+ // cli-lib's session plumbing must never load in the server process.
26
+ //
27
+ // Counting is always in memory; nothing touches disk until start() is called, and
28
+ // only the server entrypoint calls it — so a test or tool that mounts modules never
29
+ // writes a file.
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const FILE_NAME = 'module-error-counts.json';
35
+ // Days kept on disk. One more than the store accepts (ERROR_DAY_WINDOW = 30), so a
36
+ // day the sender has not reached yet is never pruned before it can go.
37
+ const KEEP_DAYS = 31;
38
+ const FLUSH_DELAY_MS = 5_000;
39
+ const ROLL_CHECK_MS = 60 * 60 * 1000;
40
+ const SEND_EVERY_MS = 6 * 60 * 60 * 1000;
41
+ const FIRST_SEND_DELAY_MS = 10 * 60 * 1000;
42
+ const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
43
+ // The sender, rooted at this file so what the server runs is answerable by reading
44
+ // this line (exec-path-guard, task 1003371).
45
+ const SENDER = path.join(__dirname, '..', '..', 'scripts', 'gds', 'module-error-report.js');
46
+
47
+ function utcDay(ms = Date.now()) { return new Date(ms).toISOString().slice(0, 10); }
48
+
49
+ let _now = Date.now;
50
+ let _days = {}; // day → { "key@version": count }
51
+ let _mounted = new Map(); // key → version, for every module seeded today and each new day
52
+ let _file = null; // set by start(); null = nothing written to disk
53
+ let _flushTimer = null;
54
+ let _timers = [];
55
+
56
+ function entryOf(key, version) { return `${key}@${version}`; }
57
+
58
+ // Make sure today has a 0 for every mounted module — a clean day is exactly what
59
+ // Reliability measures, so a module that ran and threw nothing must still appear.
60
+ function seedToday() {
61
+ const today = utcDay(_now());
62
+ const row = (_days[today] ||= {});
63
+ for (const [key, version] of _mounted) {
64
+ const e = entryOf(key, version);
65
+ if (!(e in row)) row[e] = 0;
66
+ }
67
+ return row;
68
+ }
69
+
70
+ // Register the modules this process runs. Call once the enabled set is known; a
71
+ // module without an exact version is ignored. Returns how many were registered.
72
+ function seed(modules) {
73
+ let n = 0;
74
+ for (const { key, manifest } of modules) {
75
+ const v = manifest && manifest.version;
76
+ if (typeof v !== 'string' || !EXACT_VERSION.test(v)) continue;
77
+ _mounted.set(key, v);
78
+ n += 1;
79
+ }
80
+ seedToday();
81
+ scheduleFlush();
82
+ return n;
83
+ }
84
+
85
+ // Count one runtime error against a mounted module. Unknown or unversioned keys
86
+ // are ignored, so a stray call can never invent a module.
87
+ function record(key) {
88
+ const version = _mounted.get(key);
89
+ if (!version) return false;
90
+ const row = seedToday();
91
+ row[entryOf(key, version)] += 1;
92
+ scheduleFlush();
93
+ return true;
94
+ }
95
+
96
+ function prune() {
97
+ const cutoff = utcDay(_now() - KEEP_DAYS * 86_400_000);
98
+ for (const d of Object.keys(_days)) if (d < cutoff) delete _days[d];
99
+ }
100
+
101
+ function scheduleFlush() {
102
+ if (!_file || _flushTimer) return;
103
+ _flushTimer = setTimeout(() => { _flushTimer = null; flush(); }, FLUSH_DELAY_MS);
104
+ if (_flushTimer.unref) _flushTimer.unref();
105
+ }
106
+
107
+ // Write the tally atomically (temp file, then rename), so the sender never reads
108
+ // half a file. Best-effort: a failed write costs a count, never the server.
109
+ function flush() {
110
+ if (!_file) return false;
111
+ prune();
112
+ try {
113
+ fs.mkdirSync(path.dirname(_file), { recursive: true });
114
+ const tmp = `${_file}.${process.pid}.tmp`;
115
+ fs.writeFileSync(tmp, JSON.stringify({ days: _days }, null, 2));
116
+ fs.renameSync(tmp, _file);
117
+ return true;
118
+ } catch (e) {
119
+ process.emitWarning(`module error tally: could not write ${_file}: ${e && e.message ? e.message : e}`);
120
+ return false;
121
+ }
122
+ }
123
+
124
+ // Read a tally file. Never throws: a missing or unreadable file is an empty tally.
125
+ function readTally(file) {
126
+ try {
127
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
128
+ return parsed && typeof parsed.days === 'object' && parsed.days ? parsed.days : {};
129
+ } catch { return {}; }
130
+ }
131
+
132
+ function defaultFile() {
133
+ return require('../instance-config').configPath(FILE_NAME);
134
+ }
135
+
136
+ // Persist from now on. Merges what is already on disk (a restart mid-day keeps the
137
+ // morning's count: the higher of the two wins per entry), seeds today, and checks
138
+ // hourly for a new day so a server that runs for a week still files a 0 each day.
139
+ function start({ file = defaultFile(), now } = {}) {
140
+ if (now) _now = now;
141
+ _file = file;
142
+ const onDisk = readTally(file);
143
+ for (const [day, row] of Object.entries(onDisk)) {
144
+ const mine = (_days[day] ||= {});
145
+ for (const [e, n] of Object.entries(row || {})) {
146
+ if (Number.isInteger(n) && n >= 0) mine[e] = Math.max(mine[e] || 0, n);
147
+ }
148
+ }
149
+ seedToday();
150
+ flush();
151
+ const roll = setInterval(() => { seedToday(); flush(); }, ROLL_CHECK_MS);
152
+ if (roll.unref) roll.unref();
153
+ _timers.push(roll);
154
+ return file;
155
+ }
156
+
157
+ // Run the sender now and then (scripts/gds/module-error-report.js) as a child
158
+ // process. It sends nothing without a builder sign-in on this box, and only days
159
+ // before today, so running it often is cheap and safe. Returns the timers.
160
+ function startDailySend({ spawnImpl } = {}) {
161
+ const { spawn } = require('node:child_process');
162
+ const run = spawnImpl || (() => {
163
+ const child = spawn(process.execPath, [SENDER, '--quiet'], { stdio: 'ignore', detached: false });
164
+ child.on('error', () => {});
165
+ if (child.unref) child.unref();
166
+ });
167
+ if (!fs.existsSync(SENDER)) return [];
168
+ const go = () => { try { run(SENDER); } catch { /* best-effort */ } };
169
+ const first = setTimeout(go, FIRST_SEND_DELAY_MS);
170
+ const every = setInterval(go, SEND_EVERY_MS);
171
+ for (const t of [first, every]) if (t.unref) t.unref();
172
+ _timers.push(first, every);
173
+ return [first, every];
174
+ }
175
+
176
+ // The in-memory tally, for tests and a /watch tile.
177
+ function snapshot() { return JSON.parse(JSON.stringify(_days)); }
178
+
179
+ function _reset() {
180
+ for (const t of _timers) { clearTimeout(t); clearInterval(t); }
181
+ if (_flushTimer) clearTimeout(_flushTimer);
182
+ _timers = []; _flushTimer = null; _days = {}; _mounted = new Map(); _file = null; _now = Date.now;
183
+ }
184
+
185
+ module.exports = {
186
+ FILE_NAME,
187
+ utcDay, seed, record, readTally, start, startDailySend, snapshot, _reset,
188
+ };
@@ -112,7 +112,7 @@ function discoverModules({ roots = DEFAULT_ROOTS, core = coreVersion() } = {}) {
112
112
  // Cached singleton for app code (lazy, like src/modules.js's modules()).
113
113
  let _cache = null;
114
114
  function discovered(opts) { return (_cache ||= discoverModules(opts)); }
115
- function _reset() { _cache = null; _startedPollers = []; _boot = { routes: null, pollers: null }; _bootShape = null; }
115
+ function _reset() { resetErrorTally(); _cache = null; _startedPollers = []; _boot = { routes: null, pollers: null }; _bootShape = null; }
116
116
 
117
117
  // The discovered manifests as MODULE_REGISTRY-shaped entries, so src/modules.js
118
118
  // can merge them with its built-in registry. Empty until a module exists.
@@ -197,10 +197,13 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
197
197
  for (const rk of routeKeys) {
198
198
  const factory = require(path.join(dir, 'routes', `${rk}.js`));
199
199
  const sub = typeof factory === 'function' ? factory() : factory;
200
- router.use(sub);
200
+ router.use(attributeErrors(key, sub));
201
201
  mounted += 1;
202
202
  }
203
203
  }
204
+ // Every enabled module gets a 0 for today, routes or not: a clean day is what the
205
+ // store's Reliability score measures (task 1004637).
206
+ seedErrorTally(enabled);
204
207
  return mounted;
205
208
  }
206
209
 
@@ -352,19 +355,26 @@ function startModulePollers(deps = {}, { isEnabled } = {}, opts) {
352
355
  // here. A file that EXISTS but throws is a real error (the catch below).
353
356
  if (e && e.code === 'MODULE_NOT_FOUND' && String(e.message).includes(pollerPath)) continue;
354
357
  console.error(`[module-loader] poller ${key}/${pk} failed to load: ${e && e.message ? e.message : e}`);
358
+ recordModuleError(key);
355
359
  continue;
356
360
  }
357
361
  try {
358
362
  if (poller && typeof poller.start === 'function') {
359
- const r = poller.start(deps);
363
+ // reportError: how a poller counts a failure in its own runs against its
364
+ // module (task 1004637) — counts only, it takes no message.
365
+ const r = poller.start({ ...deps, reportError: () => recordModuleError(key) });
360
366
  if (r && typeof r.then === 'function') {
361
- r.catch((e) => console.error(`[module-loader] poller ${key}/${pk} start error: ${e && e.message ? e.message : e}`));
367
+ r.catch((e) => {
368
+ console.error(`[module-loader] poller ${key}/${pk} start error: ${e && e.message ? e.message : e}`);
369
+ recordModuleError(key);
370
+ });
362
371
  }
363
372
  started.push({ key, poller: pk, handle: poller });
364
373
  _boot.pollers.add(key);
365
374
  }
366
375
  } catch (e) {
367
376
  console.error(`[module-loader] poller ${key}/${pk} start threw: ${e && e.message ? e.message : e}`);
377
+ recordModuleError(key);
368
378
  }
369
379
  }
370
380
  }
@@ -397,9 +407,47 @@ function loaderSnapshot(opts) {
397
407
  loaded: d.loaded.map((m) => m.key),
398
408
  skipped: d.skipped,
399
409
  errors: d.errors,
410
+ // per-day runtime-error counts per module@version, kept for the store (task 1004637)
411
+ errorCounts: snapshotErrorTally(),
400
412
  };
401
413
  }
402
414
 
415
+ // Which module took this request, and whether its 500 has been hooked yet.
416
+ const OWNER = Symbol('moduleOwner');
417
+ const HOOKED = Symbol('moduleErrorHooked');
418
+
419
+ // Wrap a module's router so a request it answers with a 500 counts against that
420
+ // module (task 1004637). The module is the owner from the moment the request enters
421
+ // its router until it falls through (the router calls next() with no error): a request
422
+ // the module never matched is not its fault, and the next module or the kernel takes
423
+ // it from there. An error the module passes on (next(err)) keeps it the owner, so the
424
+ // kernel's error handler answering 500 for it still counts here. Only a 500 counts:
425
+ // a 4xx is the caller's mistake and a 503 is a module saying it is not set up.
426
+ //
427
+ // A plain function rather than a nested express Router, so nothing about mounting
428
+ // changes for the kernel; `stack` is passed through so tooling that reads a mounted
429
+ // router's routes still sees the module's own.
430
+ function attributeErrors(key, sub) {
431
+ const scoped = function moduleScoped(req, res, next) {
432
+ const prev = req[OWNER];
433
+ req[OWNER] = key;
434
+ if (!req[HOOKED] && res && typeof res.on === 'function') {
435
+ req[HOOKED] = true;
436
+ res.on('finish', () => { if (res.statusCode === 500 && req[OWNER]) recordModuleError(req[OWNER]); });
437
+ }
438
+ sub(req, res, (err) => {
439
+ if (!err || typeof err === 'string') req[OWNER] = prev; // fell through ('route'/'router' too)
440
+ next(err);
441
+ });
442
+ };
443
+ if (sub && sub.stack) scoped.stack = sub.stack;
444
+ return scoped;
445
+ }
446
+
447
+ // Per-module runtime-error counts (task 1004637). Pure fs + timers, no network and no
448
+ // core requires at load, so it is cycle-safe here.
449
+ const { seed: seedErrorTally, record: recordModuleError, _reset: resetErrorTally, snapshot: snapshotErrorTally } = require('./error-tally');
450
+
403
451
  module.exports = {
404
452
  discoverModules,
405
453
  discovered,
@@ -47,6 +47,7 @@ const express = require('express');
47
47
  const { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGlobal } = require('./bongos/serve-internal');
48
48
  const { isApiShapedPath, apiNotFoundBody } = require('./bongos/api-path-404');
49
49
  const moduleLoader = require('./module-loader/loader');
50
+ const { start: startErrorTally, startDailySend: startErrorReports } = require('./module-loader/error-tally');
50
51
  const { isModuleEnabled } = require('./modules');
51
52
  // Hoisted out of an inline require inside the boot IIFE below (task 1004052 strand walk).
52
53
  // knip does not follow an inline require(), so every export of module-overrides.js read as
@@ -413,6 +414,12 @@ if (require.main === module) {
413
414
  // process starts, and /version carries the count within one query of a restart.
414
415
  schemaPending();
415
416
  startBackgroundServices();
417
+ // Store modules' daily runtime-error counts (task 1004637): keep the tally on
418
+ // disk, and now and then run the sender, which goes out only with a builder
419
+ // sign-in on this box. Here, not in startBackgroundServices(), so a test that
420
+ // starts the pollers never writes the tally or spawns the sender.
421
+ try { startErrorTally(); startErrorReports(); }
422
+ catch (e) { logger.warn({ err: e.message }, 'module error tally did not start; module error counts are not kept'); }
416
423
  });
417
424
  })().catch((err) => {
418
425
  // THE AWAIT ABOVE MUST NOT SOFTEN A FATAL BOOT (task 1002857, caught by
@@ -141,6 +141,34 @@ await test('describe states the numbers plainly, and an empty forest says so', (
141
141
  assert.equal(L.describe(L.analyse({})), 'No task in this goal depends on another task in this goal.');
142
142
  });
143
143
 
144
+ // ---- the band card's summary (task 1004738) ---------------------------------------
145
+
146
+ await test('brief is the short form of describe, and falls back to it for an empty forest', () => {
147
+ const a = L.analyse({ nodes: [1, 2, 3, 4, 5].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(5, 4)] });
148
+ assert.equal(L.brief(a), '5 tasks linked by dependencies, in 2 chains.');
149
+ assert.equal(L.brief(L.analyse({})), L.describe(L.analyse({})));
150
+ });
151
+
152
+ await test('shipFirst ranks the open tasks nothing open blocks by how many open tasks wait on them, down the chain', () => {
153
+ // 1 ← 2 ← 3 and 1 ← 4: task 1 unblocks three. 5 ← 6: task 5 unblocks one.
154
+ const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(4, 1), edge(6, 5)] });
155
+ assert.deepEqual(L.shipFirst(a, 3).map((r) => [r.id, r.unblocks]), [['1', 3], ['5', 1]],
156
+ 'a task still waiting on an open one is never "first"; one that unblocks nothing is left off');
157
+ });
158
+
159
+ await test('shipFirst: a shipped dependency frees what waits on it, and closed work is never listed or counted', () => {
160
+ const a = L.analyse({
161
+ nodes: [node(1, { status: 'shipped' }), node(2), node(3), node(4, { status: 'abandoned' })],
162
+ edges: [edge(2, 1), edge(3, 2), edge(4, 2)],
163
+ });
164
+ assert.deepEqual(L.shipFirst(a, 3).map((r) => [r.id, r.unblocks]), [['2', 1]], 'task 2 is up next and unblocks only the open task 3');
165
+ });
166
+
167
+ await test('shipFirst stops at the limit and breaks ties by task id', () => {
168
+ const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6, 7, 8].map((i) => node(i)), edges: [edge(5, 4), edge(6, 3), edge(7, 2), edge(8, 1)] });
169
+ assert.deepEqual(L.shipFirst(a, 3).map((r) => r.id), ['1', '2', '3']);
170
+ });
171
+
144
172
  // ---- purity -----------------------------------------------------------------------
145
173
 
146
174
  await test('the lib is pure: no DOM, no fetch, no cross-goal input', () => {
@@ -36,10 +36,11 @@ const settle = async () => { for (let i = 0; i < 20; i++) await tick(); };
36
36
  const t = (id, extra = {}) => ({ task_id: String(id), title: `Task ${id}`, status: 'ready', priority: 3, ...extra });
37
37
  const e = (from, to) => ({ from: String(from), to: String(to) });
38
38
 
39
- async function boot({ status = 200, fail = false, payload = { nodes: [], edges: [] } } = {}) {
39
+ async function boot({ status = 200, fail = false, payload = { nodes: [], edges: [] }, conflictPairs = null, view = 'dependencies' } = {}) {
40
40
  const root = makeEl('div');
41
41
  const reg = (id) => { const el = makeEl('div', { id }); root.appendChild(el); return el; };
42
- ['goal-deps-h', 'goal-deps-count', 'goal-deps-lede', 'goal-deps-body'].forEach(reg);
42
+ ['goal-deps-h', 'goal-deps-count', 'goal-deps-lede', 'goal-deps-body', 'goal-deps-back',
43
+ 'goal-deps-card-count', 'goal-deps-card-lede', 'goal-deps-card-body', 'goal-deps-card-foot'].forEach(reg);
43
44
 
44
45
  const documentObj = {
45
46
  getElementById: (id) => walk(root, (el) => (el.id === id ? el : null)),
@@ -83,7 +84,7 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
83
84
  vm.runInContext(read('bulk-toolbar.js'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
84
85
  vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
85
86
 
86
- const state = { current: 7, forest: null, forestError: false };
87
+ const state = { current: 7, view, forest: null, forestError: false, conflictPairs };
87
88
  const ctx = {
88
89
  API: '/api/bongos',
89
90
  state,
@@ -96,7 +97,10 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
96
97
  },
97
98
  };
98
99
  const renderers = windowObj.OTBGoalsRender(ctx);
99
- return { $: documentObj.getElementById, requests, state, renderers };
100
+ // The band card's renderer is the forest module's own export — goals-render.js
101
+ // reaches it through its private `forestRender`, so the test makes its own.
102
+ const card = windowObj.OTBGoalForestRender(ctx);
103
+ return { $: documentObj.getElementById, requests, state, renderers, card };
100
104
  }
101
105
 
102
106
  // ---- the honest states (ADR 0227) ---------------------------------------------
@@ -217,14 +221,105 @@ await test('renderDetail calls loadDependencyForest, beside loadGoalTasks, for t
217
221
  assert.match(RENDER_JS, /loadGoalTasks\(g\.id\);\s*\n\s*loadDependencyForest\(g\.id\);/);
218
222
  });
219
223
 
220
- await test('the section is its OWN full-width block, not squeezed into the three-column band', () => {
224
+ // ---- the band card + the full view (task 1004738) ----------------------------
225
+
226
+ const sectionOf = (html, id) => {
227
+ const start = html.indexOf(`<section id="${id}"`);
228
+ return start < 0 ? '' : html.slice(start, html.indexOf('</section>', start));
229
+ };
230
+
231
+ await test('the band\'s third column is the compact Dependencies card, where Conflicts was — no long block before Tasks', () => {
221
232
  const html = fs.readFileSync(path.join(HALL, 'goals.html'), 'utf8');
222
- const bandAt = html.indexOf('class="scroll goal-band"');
223
- const depsAt = html.indexOf('id="goal-deps-scroll"');
224
- const tasksAt = html.indexOf('<h2 class="scroll__h">Tasks</h2>');
225
- assert.ok(bandAt > -1 && depsAt > -1 && tasksAt > -1);
226
- assert.ok(bandAt < depsAt && depsAt < tasksAt, 'band, then dependencies, then tasks');
227
- assert.equal(/id="goal-deps-scroll"[\s\S]{0,400}goal-band__col/.test(html), false, 'not inside a band column');
233
+ const detail = sectionOf(html, 'goal-detail-view');
234
+ const cols = detail.split('class="goal-band__col"').slice(1);
235
+ assert.equal(cols.length, 3, 'still three columns');
236
+ assert.match(cols[2], /^\s*id="goal-deps-card"/, 'Dependencies takes the third slot');
237
+ assert.equal(/>Conflicts</.test(cols.join('')), false, 'no Conflicts column left in the band');
238
+ assert.equal(detail.includes('id="goal-deps-scroll"'), false, 'the forest is off the goal page');
239
+ });
240
+
241
+ await test('the full picture is #/goal/N/dependencies: the forest, then the whole conflict set; the old /conflicts lands there', () => {
242
+ const html = fs.readFileSync(path.join(HALL, 'goals.html'), 'utf8');
243
+ const view = sectionOf(html, 'goal-dependencies-view');
244
+ const depsAt = view.indexOf('id="goal-deps-scroll"');
245
+ const conflictsAt = view.indexOf('id="goal-conflicts-full"');
246
+ assert.ok(view.includes('id="goal-deps-back"') && depsAt > -1 && depsAt < conflictsAt, 'back link, forest, then conflicts');
247
+ assert.equal(sectionOf(html, 'goal-conflicts-view'), '', 'no separate conflicts view any more');
248
+ const page = read('goals-page.js');
249
+ assert.match(page, /dependencies: '#goal-dependencies-view'/);
250
+ assert.match(page, /\(dependencies\|conflicts\|members\|completion\)/);
251
+ assert.match(page, /mSub\[2\] === 'conflicts' \? 'dependencies'/, 'the inbox\'s /conflicts links keep working');
252
+ assert.match(RENDER_JS, /function renderConflicts\(\) \{[\s\S]*?forestRender\.renderDependencyCard\(\);/, 'the card re-renders when the conflicts land');
253
+ });
254
+
255
+ await test('the card states the shape, lists what to ship first, counts conflicts, and links to the full view', async () => {
256
+ const h = await boot({
257
+ payload: { nodes: [t(1, { title: 'Base' }), t(2), t(3), t(4, { title: 'Other root' }), t(5)], edges: [e(2, 1), e(3, 2), e(5, 4)] },
258
+ conflictPairs: [{ a: 2, b: 5 }, { a: 3, b: 5 }],
259
+ });
260
+ await h.renderers.loadDependencyForest(7);
261
+ assert.equal(h.$('goal-deps-card-count').hidden, false);
262
+ assert.equal(h.$('goal-deps-card-count').textContent, '5');
263
+ assert.equal(h.$('goal-deps-card-lede').textContent, '5 tasks linked by dependencies, in 2 chains.');
264
+ const body = h.$('goal-deps-card-body');
265
+ const rows = body.querySelectorAll('.gf-brief__row');
266
+ assert.deepEqual(rows.map((r) => r.querySelector('a').getAttribute('href')), ['#/task/1', '#/task/4']);
267
+ assert.deepEqual(rows.map((r) => r.querySelector('.gf-brief__n').textContent), ['unblocks 2', 'unblocks 1']);
268
+ const line = body.querySelector('.gf-brief__conflicts');
269
+ assert.equal(line.textContent, '2 file conflicts between open tasks.');
270
+ assert.match(line.className, /gf-brief__conflicts--live/);
271
+ const foot = h.$('goal-deps-card-foot');
272
+ assert.equal(foot.hidden, false);
273
+ assert.equal(foot.querySelector('a').getAttribute('href'), '#/goal/7/dependencies');
274
+ assert.equal(h.$('goal-deps-back').getAttribute('href'), '#/goal/7');
275
+ });
276
+
277
+ await test('on the goal page only the card is drawn — the forest waits for its own view, where its titles can be measured', async () => {
278
+ const h = await boot({ view: 'detail', payload: { nodes: [t(1), t(2)], edges: [e(2, 1)] } });
279
+ await h.renderers.loadDependencyForest(7);
280
+ assert.equal(h.$('goal-deps-body').querySelector('svg'), null, 'no forest drawn into the hidden view');
281
+ assert.equal(h.$('goal-deps-body').innerHTML, '');
282
+ assert.equal(h.$('goal-deps-card-lede').textContent, '2 tasks linked by dependencies, in 1 chain.');
283
+ h.state.view = 'dependencies';
284
+ h.renderers.renderDependencyForest();
285
+ assert.ok(h.$('goal-deps-body').querySelector('svg'), 'opening the view draws it');
286
+ });
287
+
288
+ await test('the card: no conflicts says so, unknown conflicts says nothing, and an empty goal has no door to an empty page', async () => {
289
+ const none = await boot({ payload: { nodes: [], edges: [] }, conflictPairs: [] });
290
+ await none.renderers.loadDependencyForest(7);
291
+ assert.match(none.$('goal-deps-card-lede').textContent, /No task in this goal depends on another/);
292
+ assert.equal(none.$('goal-deps-card-count').hidden, true);
293
+ assert.equal(none.$('goal-deps-card-body').querySelector('.gf-brief__conflicts').textContent, 'No file conflicts between open tasks.');
294
+ assert.equal(none.$('goal-deps-card-foot').hidden, true);
295
+
296
+ const unknown = await boot({ payload: { nodes: [], edges: [] }, conflictPairs: null });
297
+ await unknown.renderers.loadDependencyForest(7);
298
+ assert.equal(unknown.$('goal-deps-card-body').querySelector('.gf-brief__conflicts'), null, 'a failed or pending conflicts read draws no line, not a zero');
299
+
300
+ const onlyConflicts = await boot({ payload: { nodes: [], edges: [] }, conflictPairs: [{ a: 1, b: 2 }] });
301
+ await onlyConflicts.renderers.loadDependencyForest(7);
302
+ assert.equal(onlyConflicts.$('goal-deps-card-foot').hidden, false, 'conflicts alone still earn the full view');
303
+ });
304
+
305
+ await test('the card: while reading, and after a failed read, it says so — and a late conflicts list re-renders it', async () => {
306
+ const h = await boot({ fail: true });
307
+ const p = h.renderers.loadDependencyForest(7);
308
+ assert.equal(h.$('goal-deps-card-lede').textContent, 'Reading…');
309
+ await p;
310
+ assert.match(h.$('goal-deps-card-lede').textContent, /Couldn.t load this goal.s task dependencies/);
311
+ h.state.conflictPairs = [{ a: 1, b: 2 }];
312
+ h.card.renderDependencyCard();
313
+ assert.equal(h.$('goal-deps-card-body').querySelector('.gf-brief__conflicts').textContent, '1 file conflict between open tasks.');
314
+ });
315
+
316
+ await test('a "ship first" title is builder-authored text and never becomes markup in the card either', async () => {
317
+ const evil = '<img src=x onerror=alert(1)>';
318
+ const h = await boot({ payload: { nodes: [t(1, { title: evil }), t(2)], edges: [e(2, 1)] } });
319
+ await h.renderers.loadDependencyForest(7);
320
+ const body = h.$('goal-deps-card-body');
321
+ assert.equal(body.querySelector('img'), null);
322
+ assert.ok(body.textContent.includes(evil));
228
323
  });
229
324
 
230
325
  summary();
@@ -87,7 +87,7 @@ function fakeDb() {
87
87
  async query(sql, params = []) {
88
88
  sqlSeen.push(sql);
89
89
  if (PRICE_WORD.test(sql)) throw new Error(`the assessment read price: ${sql.slice(0, 120)}`);
90
- if (/^SELECT id, module_key, version FROM store_module_versions WHERE id/.test(sql)) {
90
+ if (/^SELECT id, module_key, version(, artifact_path)? FROM store_module_versions WHERE id/.test(sql)) {
91
91
  const v = CASES.find((c) => String(c.id) === String(params[0]));
92
92
  return { rows: v ? [{ id: v.id, module_key: v.module_key, version: v.version }] : [] };
93
93
  }
@@ -162,7 +162,7 @@ test('assessVersion on publish runs the same steps for every case, and never rea
162
162
  const recordDocs = async (id, { db: d }) => { steps.push(['docs', id]); return { ok: true, signal: d.add(id, 'docs', 'scored', 50) }; };
163
163
  const out = [];
164
164
  for (const c of CASES) {
165
- const res = await assessVersion(c.id, { db, recordSecurity, recordDocs });
165
+ const res = await assessVersion(c.id, { db, dispatchTests: async () => null, recordSecurity, recordDocs });
166
166
  assert.equal(res.ok, true, c.module_key);
167
167
  out.push(shape(res.score));
168
168
  }