@bongos/core 1.21.126 → 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.
@@ -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
@@ -0,0 +1,265 @@
1
+ // tests/module_error_counts.mjs — instances count each store module's runtime
2
+ // errors per day and send the daily count with the builder sign-in on the box
3
+ // (task 1004637): the loader's attribution, the on-disk tally, and the sender.
4
+ // Hermetic — temp dirs, a fake store client, an in-process express app.
5
+ import assert from 'node:assert/strict';
6
+ import { test } from 'node:test';
7
+ import { createRequire } from 'node:module';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import http from 'node:http';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const loader = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
17
+ const { seed, record, readTally, start, startDailySend, _reset } = require(path.join(ROOT, 'src', 'module-loader', 'error-tally.js'));
18
+ // the in-memory tally is read the way tooling reads it: through the loader's snapshot
19
+ const tally = { seed, record, readTally, start, startDailySend, _reset, snapshot: () => loader.loaderSnapshot().errorCounts };
20
+ const sender = require(path.join(ROOT, 'scripts', 'gds', 'module-error-report.js'));
21
+ const express = require('express');
22
+
23
+ function tmpDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); }
24
+
25
+ function fixtureRoot(spec) {
26
+ const root = tmpDir('bongos-errmods-');
27
+ for (const [key, def] of Object.entries(spec)) {
28
+ const d = path.join(root, key);
29
+ fs.mkdirSync(d, { recursive: true });
30
+ fs.writeFileSync(path.join(d, 'module.json'), JSON.stringify({
31
+ key, title: key, description: 'd', version: def.version === undefined ? '1.2.0' : def.version,
32
+ coreVersion: '^1.0.0', contributes: def.contributes || {},
33
+ }));
34
+ for (const [rel, content] of Object.entries(def.files || {})) {
35
+ const fp = path.join(d, rel);
36
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
37
+ fs.writeFileSync(fp, content);
38
+ }
39
+ }
40
+ return root;
41
+ }
42
+
43
+ const TODAY = new Date().toISOString().slice(0, 10);
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // The tally
47
+ // ---------------------------------------------------------------------------
48
+
49
+ test('seed gives every exactly-versioned module a 0 today; record counts only those', () => {
50
+ tally._reset();
51
+ const n = tally.seed([
52
+ { key: 'alpha', manifest: { version: '1.2.0' } },
53
+ { key: 'loose', manifest: { version: '^1.0.0' } },
54
+ { key: 'none', manifest: {} },
55
+ ]);
56
+ assert.equal(n, 1);
57
+ assert.deepEqual(tally.snapshot()[TODAY], { 'alpha@1.2.0': 0 }, 'a clean day is a 0, not a missing row');
58
+ assert.equal(tally.record('alpha'), true);
59
+ assert.equal(tally.record('alpha'), true);
60
+ assert.equal(tally.record('loose'), false, 'no exact version, nothing to file it against');
61
+ assert.equal(tally.record('ghost'), false, 'a stray key never invents a module');
62
+ assert.deepEqual(tally.snapshot()[TODAY], { 'alpha@1.2.0': 2 });
63
+ tally._reset();
64
+ });
65
+
66
+ test('nothing touches disk until start(); start merges the file (higher count wins) and prunes old days', () => {
67
+ tally._reset();
68
+ const dir = tmpDir('bongos-tally-');
69
+ const file = path.join(dir, 'module-error-counts.json');
70
+ tally.seed([{ key: 'alpha', manifest: { version: '1.2.0' } }]);
71
+ tally.record('alpha');
72
+ assert.equal(fs.existsSync(file), false);
73
+ fs.writeFileSync(file, JSON.stringify({ days: {
74
+ [TODAY]: { 'alpha@1.2.0': 5 },
75
+ '2000-01-01': { 'alpha@1.0.0': 9 },
76
+ } }));
77
+ tally.start({ file });
78
+ const onDisk = tally.readTally(file);
79
+ assert.equal(onDisk[TODAY]['alpha@1.2.0'], 5, 'a restart mid-day keeps the morning count');
80
+ assert.equal(onDisk['2000-01-01'], undefined, 'days past the window are pruned');
81
+ tally._reset();
82
+ });
83
+
84
+ test('the tally on disk holds counts only — key@version and a number, nothing else', () => {
85
+ tally._reset();
86
+ const file = path.join(tmpDir('bongos-tally-'), 'c.json');
87
+ tally.seed([{ key: 'alpha', manifest: { version: '1.2.0' } }]);
88
+ tally.record('alpha');
89
+ tally.start({ file });
90
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
91
+ assert.deepEqual(Object.keys(raw), ['days']);
92
+ for (const row of Object.values(raw.days)) {
93
+ for (const [k, v] of Object.entries(row)) {
94
+ assert.match(k, /^[a-z0-9-]+@\d+\.\d+\.\d+$/);
95
+ assert.ok(Number.isInteger(v));
96
+ }
97
+ }
98
+ tally._reset();
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // The loader attributes a 500 to the module that answered it
103
+ // ---------------------------------------------------------------------------
104
+
105
+ async function withServer(app, fn) {
106
+ const server = http.createServer(app);
107
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
108
+ const base = `http://127.0.0.1:${server.address().port}`;
109
+ try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
110
+ }
111
+
112
+ test('a module route that throws or answers 500 counts against it; a 4xx, a 503 and a fall-through do not', async () => {
113
+ loader._reset();
114
+ const root = fixtureRoot({
115
+ alpha: {
116
+ contributes: { routes: ['main'] },
117
+ files: { 'routes/main.js': `
118
+ const express = require(${JSON.stringify(require.resolve('express'))});
119
+ module.exports = () => {
120
+ const r = express.Router();
121
+ r.get('/alpha/boom', () => { throw new Error('boom'); });
122
+ r.get('/alpha/fail', (req, res) => res.status(500).json({ error: 'x' }));
123
+ r.get('/alpha/bad', (req, res) => res.status(400).json({ error: 'x' }));
124
+ r.get('/alpha/off', (req, res) => res.status(503).json({ error: 'not set up' }));
125
+ r.get('/alpha/pass', (req, res, next) => next(new Error('handed on')));
126
+ return r;
127
+ };` },
128
+ },
129
+ beta: { contributes: {} },
130
+ });
131
+ const app = express();
132
+ const router = express.Router();
133
+ loader.mountModuleRoutes(router, { isEnabled: () => true }, { roots: [root], core: '1.0.0' });
134
+ router.get('/kernel/boom', () => { throw new Error('kernel'); });
135
+ app.use(router);
136
+ app.use((err, req, res, next) => res.status(500).json({ error: 'internal' })); // eslint-disable-line no-unused-vars
137
+
138
+ await withServer(app, async (base) => {
139
+ for (const p of ['/alpha/boom', '/alpha/fail', '/alpha/bad', '/alpha/off', '/alpha/pass', '/kernel/boom']) {
140
+ await (await fetch(base + p)).text();
141
+ }
142
+ });
143
+ await new Promise((r) => setImmediate(r));
144
+ const day = tally.snapshot()[TODAY];
145
+ assert.equal(day['alpha@1.2.0'], 3, 'boom + fail + next(err) — not the 400, not the 503, not the kernel route');
146
+ assert.equal(day['beta@1.2.0'], 0, 'an enabled module with no routes still files its clean day');
147
+ loader._reset();
148
+ });
149
+
150
+ test('the wrapped router still exposes the module’s own routes to tooling', () => {
151
+ loader._reset();
152
+ const root = fixtureRoot({
153
+ alpha: {
154
+ contributes: { routes: ['main'] },
155
+ files: { 'routes/main.js': `
156
+ const express = require(${JSON.stringify(require.resolve('express'))});
157
+ module.exports = () => { const r = express.Router(); r.get('/alpha/x', (q, s) => s.end()); return r; };` },
158
+ },
159
+ });
160
+ const used = [];
161
+ loader.mountModuleRoutes({ use: (r) => used.push(r) }, { isEnabled: () => true }, { roots: [root], core: '1.0.0' });
162
+ assert.equal(used.length, 1);
163
+ const paths = (used[0].stack || []).filter((l) => l.route).map((l) => l.route.path);
164
+ assert.deepEqual(paths, ['/alpha/x']);
165
+ loader._reset();
166
+ });
167
+
168
+ test('a poller that throws on start counts, and a running poller can report through reportError', async () => {
169
+ loader._reset();
170
+ const root = fixtureRoot({
171
+ broken: { contributes: { pollers: ['p'] }, files: { 'pollers/p.js': 'module.exports = { start() { throw new Error("x"); } };' } },
172
+ rejecting: { contributes: { pollers: ['p'] }, files: { 'pollers/p.js': 'module.exports = { start() { return Promise.reject(new Error("x")); } };' } },
173
+ reporting: { contributes: { pollers: ['p'] }, files: { 'pollers/p.js': 'module.exports = { start(deps) { deps.reportError(); deps.reportError(); } };' } },
174
+ });
175
+ const opts = { roots: [root], core: '1.0.0' };
176
+ loader.mountModuleRoutes({ use: () => {} }, { isEnabled: () => true }, opts);
177
+ const origErr = console.error;
178
+ console.error = () => {};
179
+ try {
180
+ loader.startModulePollers({}, { isEnabled: () => true }, opts);
181
+ await new Promise((r) => setImmediate(r));
182
+ } finally { console.error = origErr; }
183
+ const day = tally.snapshot()[TODAY];
184
+ assert.equal(day['broken@1.2.0'], 1);
185
+ assert.equal(day['rejecting@1.2.0'], 1);
186
+ assert.equal(day['reporting@1.2.0'], 2);
187
+ loader._reset();
188
+ });
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // The sender
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function fakeStore({ enabled = true, known = ['alpha'], post = () => ({ ok: true, status: 200 }) } = {}) {
195
+ const calls = [];
196
+ const call = async (method, p, opts = {}) => {
197
+ calls.push({ method, p, json: opts.json });
198
+ if (p === '/store/usage-reporting') return { ok: true, status: 200, data: { enabled } };
199
+ if (p.startsWith('/store/modules/latest')) {
200
+ return { ok: true, status: 200, data: { latest: Object.fromEntries(known.map((k) => [k, { version: '1.2.0' }])) } };
201
+ }
202
+ return post(p, opts.json);
203
+ };
204
+ return { call, calls };
205
+ }
206
+
207
+ test('pendingDays: finished, unsent days inside the store window, oldest first', () => {
208
+ const days = { '2026-10-14': {}, '2026-10-15': {}, '2026-10-12': {}, '2026-09-01': {} };
209
+ assert.deepEqual(sender.pendingDays(days, { '2026-10-12': true }, '2026-10-15'), ['2026-10-14']);
210
+ });
211
+
212
+ test('sendPending sends every count for a store module, zeros included, and skips modules the store does not know', async () => {
213
+ const { call, calls } = fakeStore();
214
+ const days = { '2026-10-14': { 'alpha@1.2.0': 0, 'core-thing@3.0.0': 4 } };
215
+ const out = await sender.sendPending({ days, today: '2026-10-15', call });
216
+ const posts = calls.filter((c) => c.method === 'POST');
217
+ assert.equal(posts.length, 1);
218
+ assert.equal(posts[0].p, '/store/modules/alpha/versions/1.2.0/error-count');
219
+ assert.deepEqual(posts[0].json, { day: '2026-10-14', count: 0 }, 'a clean day goes as a 0');
220
+ assert.deepEqual(out.sentDays, ['2026-10-14']);
221
+ assert.equal(out.skipped, 1);
222
+ });
223
+
224
+ test('an opted-out builder sends nothing, and the days are marked done', async () => {
225
+ const { call, calls } = fakeStore({ enabled: false });
226
+ const out = await sender.sendPending({ days: { '2026-10-14': { 'alpha@1.2.0': 2 } }, today: '2026-10-15', call });
227
+ assert.equal(calls.filter((c) => c.method === 'POST').length, 0);
228
+ assert.equal(out.optedOut, true);
229
+ assert.deepEqual(out.sentDays, ['2026-10-14']);
230
+ });
231
+
232
+ test('a refusal for good (4xx) is done; a server error leaves the day for the next run', async () => {
233
+ const refused = fakeStore({ post: () => ({ ok: false, status: 403 }) });
234
+ const a = await sender.sendPending({ days: { '2026-10-14': { 'alpha@1.2.0': 1 } }, today: '2026-10-15', call: refused.call });
235
+ assert.deepEqual(a.sentDays, ['2026-10-14']);
236
+ const down = fakeStore({ post: () => ({ ok: false, status: 502 }) });
237
+ const b = await sender.sendPending({ days: { '2026-10-14': { 'alpha@1.2.0': 1 } }, today: '2026-10-15', call: down.call });
238
+ assert.deepEqual(b.sentDays, []);
239
+ assert.equal(b.retryLater, true);
240
+ });
241
+
242
+ test('main records the days it sent, and a second run sends nothing again', async () => {
243
+ const dir = tmpDir('bongos-sender-');
244
+ const tallyFile = path.join(dir, 'counts.json');
245
+ const sentFile = path.join(dir, 'sent.json');
246
+ fs.writeFileSync(tallyFile, JSON.stringify({ days: { '2026-10-14': { 'alpha@1.2.0': 3 }, '2026-10-15': { 'alpha@1.2.0': 1 } } }));
247
+ const first = fakeStore();
248
+ const log = () => {};
249
+ assert.equal(await sender.main([], { log, request: first.call, tallyFile, sentFile, today: '2026-10-15' }), 0);
250
+ assert.equal(first.calls.filter((c) => c.method === 'POST').length, 1, 'today is not finished, so only yesterday goes');
251
+ assert.deepEqual(JSON.parse(fs.readFileSync(sentFile, 'utf8')).sent, { '2026-10-14': true });
252
+ const second = fakeStore();
253
+ assert.equal(await sender.main([], { log, request: second.call, tallyFile, sentFile, today: '2026-10-15' }), 0);
254
+ assert.equal(second.calls.length, 0);
255
+ });
256
+
257
+ test('the server runs the sender as a child process, never in its own process', () => {
258
+ tally._reset();
259
+ const ran = [];
260
+ const timers = tally.startDailySend({ spawnImpl: (s) => ran.push(s) });
261
+ assert.equal(timers.length, 2, 'a first run soon after boot, then every few hours');
262
+ tally._reset();
263
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'module-loader', 'error-tally.js'), 'utf8');
264
+ assert.doesNotMatch(src, /require\([^)]*cli-lib/, 'the session plumbing never loads in the server');
265
+ });
@@ -637,6 +637,8 @@ t('systemd manager: emits byte-identical commands + unit paths to the inline str
637
637
  'sudo rm -f /etc/systemd/system/demo.service [allowFail]',
638
638
  'sudo systemctl disable --now demo-db-backup.timer [allowFail]',
639
639
  'sudo rm -f /etc/systemd/system/demo-db-backup.service /etc/systemd/system/demo-db-backup.timer [allowFail]',
640
+ 'sudo rm -f /etc/systemd/system.control/demo.service.d/50-*.conf [allowFail]', // the caps drop-in (task 1004414)
641
+ 'sudo rmdir /etc/systemd/system.control/demo.service.d [allowFail]',
640
642
  'sudo systemctl daemon-reload [allowFail]',
641
643
  'sudo systemctl reset-failed demo.service demo-db-backup.service [allowFail]',
642
644
  ], 'including reset-failed — a removed unit\'s FAILED state outlives its file (task 1003940)');