@bongos/core 1.19.356 → 1.19.357

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.19.356",
6
- "core_contract": "1.19.356",
7
- "source_commit": "cb63704ad7571ab5d52fe949b6f265281f754679",
5
+ "core_version": "1.19.357",
6
+ "core_contract": "1.19.357",
7
+ "source_commit": "3c8f80201ef2dd9992ceea78af1081b7cd74524d",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-25T14:41:47.258Z",
9
+ "built_at": "2026-08-25T16:20:35.425Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 330,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1509,
14
+ "functional_verbatim": 1511,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1861,
20
- "tree_sha256": "983a13e5fa8e0ab31afc970fe2b7f6ebb7296e62bee4cf06257f51ce26658362",
19
+ "file_count": 1863,
20
+ "tree_sha256": "03a3fad69d2b334568f719ebc41a4cdde66d922efaae81ac1857f85baa39f760",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -4212,7 +4212,12 @@
4212
4212
  {
4213
4213
  "path": "modules/memory/module.json",
4214
4214
  "mode": "0000644",
4215
- "sha256": "ace721e3ae257aee7f9b13bfbb60b6959f9d72e11059c316d22701754db5b911"
4215
+ "sha256": "c697548692393d65c6beb8092f70f8864acb53d568548f123213023ca10032f8"
4216
+ },
4217
+ {
4218
+ "path": "modules/memory/pollers/search-index.js",
4219
+ "mode": "0000644",
4220
+ "sha256": "4950c01fc2b895a7eb8f5fc929826c1bea44d9764f5ffa4c8378de73be1446e9"
4216
4221
  },
4217
4222
  {
4218
4223
  "path": "modules/memory/routes/learnings.js",
@@ -4797,12 +4802,12 @@
4797
4802
  {
4798
4803
  "path": "package-lock.json",
4799
4804
  "mode": "0000644",
4800
- "sha256": "d5f53ae25273ca753b122eca1de770bb20d9a1ed1b9a7b0b105710403670e7ec"
4805
+ "sha256": "b1353851e7bd50f58b47d0191076eacc249bdca62553d0bb9a6ee767aed9c712"
4801
4806
  },
4802
4807
  {
4803
4808
  "path": "package.json",
4804
4809
  "mode": "0000644",
4805
- "sha256": "881f7f9d99198d118f9bbcec082d6637b586dbc6acb850cc654cac192b0cab55"
4810
+ "sha256": "632ea5fa7541ae2eb15f7afdc97f5414b65e1cadcf627e03ed7aee1f1443a0c0"
4806
4811
  },
4807
4812
  {
4808
4813
  "path": "public-docs/index.html",
@@ -6662,7 +6667,7 @@
6662
6667
  {
6663
6668
  "path": "src/module-api.js",
6664
6669
  "mode": "0000644",
6665
- "sha256": "0b23db16d6f47afb30b4f5ed970cd1a2d0aac607feda9ce4ebf9be09c0a3dc47"
6670
+ "sha256": "2376b2c1c18430faa5dc61183b3dace18e5d991515aa508fcb3d739ba9342701"
6666
6671
  },
6667
6672
  {
6668
6673
  "path": "src/module-loader/catalog.js",
@@ -8804,6 +8809,11 @@
8804
8809
  "mode": "0000644",
8805
8810
  "sha256": "1034ab278e7eb005ba0286b8bcecd7fafdec390418ae94e3cbacf81ffb9fc88c"
8806
8811
  },
8812
+ {
8813
+ "path": "tests/search_index_poller.mjs",
8814
+ "mode": "0000644",
8815
+ "sha256": "f665d4f0a9ccfb5b92564dfda39d13d6d8ed3146cde3fe2e05bb9f9aec029558"
8816
+ },
8807
8817
  {
8808
8818
  "path": "tests/search_ingest.mjs",
8809
8819
  "mode": "0000644",
@@ -4,9 +4,18 @@
4
4
  "description": "Per-builder session memory, the lexical recall/search retrieval layer (ADR 0060), and durable learnings. A core-domain module carved out of the GDS core in BV1.R72 (ADR 0091); default-on because every instance needs the build system's memory.",
5
5
  "coreVersion": "^1.5.0",
6
6
  "default": true,
7
- "maintenance": { "status": "core-maintained" },
7
+ "maintenance": {
8
+ "status": "core-maintained"
9
+ },
8
10
  "contributes": {
9
- "routes": ["memory", "search", "learnings"]
11
+ "routes": [
12
+ "memory",
13
+ "search",
14
+ "learnings"
15
+ ],
16
+ "pollers": [
17
+ "search-index"
18
+ ]
10
19
  },
11
20
  "provides": [],
12
21
  "consumes": []
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ // modules/memory/pollers/search-index.js — the recall index keeps itself, and says
4
+ // so out loud (task 1003162, goal 1000027).
5
+ //
6
+ // THE INCIDENT. `/recall` on live returned the `index_empty` outage state for words
7
+ // that demonstrably exist in the corpus — an entire goal's worth of ADRs, session
8
+ // logs and task rows. The index genuinely held nothing, and had held nothing for as
9
+ // long as anyone could tell, because NOTHING RAN THE INGESTERS.
10
+ //
11
+ // ADR 0060 says repo markdown is ingested by "a deploy-hook step" and DB prose by
12
+ // "a nightly timer". Both are BOX-SIDE artifacts: `grep -rn search-ingest` across
13
+ // the core tree finds the two ingesters, some prose about them, and nothing that
14
+ // executes either. Whether the box's deploy.sh ever called them is unknowable from
15
+ // here — and that unknowability IS the bug. An index that depends on an unwatched
16
+ // host script fails exactly the way this one did: silently, indefinitely, and only
17
+ // noticed when a human happens to search for something they know is there.
18
+ //
19
+ // WHY IN-PROCESS, and not a cron or a CI workflow. The ingesters need the database.
20
+ // A GitHub Actions schedule cannot reach it; the box's own timer is the thing that
21
+ // stopped. The web process already holds a pool and already runs this exact shape
22
+ // of work (the publish reconciler, the board-expiry sweep), so putting the sweep
23
+ // here makes it start whenever the app starts and stop only when the app stops.
24
+ // There is no separate thing left to forget to arm.
25
+ //
26
+ // THE SHAPE follows the publish reconciler, the in-tree precedent:
27
+ // * a DISPOSITION LINE every sweep — the reconciler's lesson is that a sweep
28
+ // which only speaks on failure is indistinguishable from a sweep that is not
29
+ // running. "held 4,812 chunks, nothing to do" is the sentence whose ABSENCE is
30
+ // the alarm.
31
+ // * failure isolation — the md ingest failing must not skip the db ingest.
32
+ // * bounded and self-gating — SEARCH_INDEX_SWEEP_DISABLED turns it off, an unref'd
33
+ // timer never holds the process open, and a sweep already running is not
34
+ // re-entered.
35
+ //
36
+ // AND IT CHECKS EXIT CODES, which is the half the task named explicitly. Task
37
+ // 1002859 made a partial ingest exit non-zero and NAME every skipped source; that
38
+ // only helps if someone reads the code. Spawning the CLIs (rather than importing
39
+ // their main()) is what preserves it — the exit code is the contract, and running
40
+ // them the documented way keeps this poller honest about what an operator would see.
41
+
42
+ const path = require('node:path');
43
+ const { execFile } = require('node:child_process');
44
+
45
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
46
+ const MD_INGESTER = path.join(REPO_ROOT, 'scripts', 'gds', 'search-ingest-md.js');
47
+ const DB_INGESTER = path.join(REPO_ROOT, 'scripts', 'gds', 'search-ingest-db.js');
48
+
49
+ // Nightly-ish. DB prose changes slowly and the md corpus only moves on deploy, so
50
+ // a long period is right; the value is that it recurs at all, not that it is tight.
51
+ const INTERVAL_MS = Number(process.env.SEARCH_INDEX_SWEEP_MS) || 6 * 60 * 60 * 1000;
52
+ // An EMPTY index is an outage, not a slow day: recall answers nothing at all until
53
+ // it is fixed. Sweep early after boot so a deploy that lands on an empty table
54
+ // repairs it in minutes rather than at the next six-hour tick.
55
+ const FIRST_SWEEP_DELAY_MS = Number(process.env.SEARCH_INDEX_FIRST_MS) || 90_000;
56
+ // The md ingest walks a few hundred files; the db ingest reads prose tables. Both
57
+ // are minutes at worst. The cap exists so a wedged child can never pin the sweep.
58
+ const INGEST_TIMEOUT_MS = Number(process.env.SEARCH_INDEX_INGEST_TIMEOUT_MS) || 10 * 60 * 1000;
59
+
60
+ let intervalTimer = null;
61
+ let firstTimer = null;
62
+ let sweeping = false;
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Pure decisions — exported and unit-tested. No DB, no spawn, no clock.
66
+ // ---------------------------------------------------------------------------
67
+
68
+ // What does a chunk count MEAN? Separated from the doing so the judgement can be
69
+ // tested without a database, and so the wording lives in one place.
70
+ //
71
+ // `empty` is deliberately its own state rather than "very stale": it is the only
72
+ // one where recall is not degraded but DEAD, it is rank-independent (so it can
73
+ // never be "nothing visible to YOU"), and it is the one that justifies spending an
74
+ // ingest immediately on boot.
75
+ function describeIndex({ chunks, lastIngestedAt = null, now = Date.now() } = {}) {
76
+ // typeof FIRST: Number(null) is 0 and Number('') is 0, so a missing column or an
77
+ // absent row would coerce into a confident "the index is empty" — a wrong
78
+ // diagnosis that also spends an ingest. Only an actual number can be a count.
79
+ const n = typeof chunks === 'number' ? chunks : NaN;
80
+ if (!Number.isFinite(n) || n < 0) {
81
+ return { level: 'alarm', state: 'unreadable', chunks: null,
82
+ message: 'search-index: could not read search_chunks — recall health is UNKNOWN, which is not the same as healthy.' };
83
+ }
84
+ if (n === 0) {
85
+ return { level: 'alarm', state: 'empty', chunks: 0,
86
+ message: 'search-index: search_chunks is EMPTY — /recall can return nothing at all, for any query, at any rank. Ingesting now.' };
87
+ }
88
+ const ageMs = lastIngestedAt ? Math.max(0, now - new Date(lastIngestedAt).getTime()) : null;
89
+ const ageDays = ageMs == null ? null : Math.floor(ageMs / 86_400_000);
90
+ if (ageDays != null && ageDays >= 7) {
91
+ return { level: 'warn', state: 'stale', chunks: n, age_days: ageDays,
92
+ message: `search-index: ${n} chunk(s), but the newest is ${ageDays} day(s) old — the sweep may have stopped.` };
93
+ }
94
+ return { level: 'ok', state: 'ok', chunks: n, age_days: ageDays,
95
+ message: `search-index: ${n} chunk(s) indexed${ageDays == null ? '' : `, newest ${ageDays} day(s) old`}.` };
96
+ }
97
+
98
+ // One ingester's outcome, in the words an operator needs. A non-zero exit is the
99
+ // signal task 1002859 built and nothing was reading.
100
+ function describeIngest({ name, code, signal = null, stderr = '' } = {}) {
101
+ if (signal) {
102
+ return { level: 'alarm', ok: false,
103
+ message: `search-index: ${name} was KILLED by ${signal} (timeout or OOM) — the index is partially written at best.` };
104
+ }
105
+ if (code === 0) return { level: 'ok', ok: true, message: `search-index: ${name} ok.` };
106
+ // The tail, not the head: these CLIs name what they skipped at the END.
107
+ const tail = String(stderr || '').trim().split('\n').slice(-6).join('\n');
108
+ return { level: 'alarm', ok: false,
109
+ message: `search-index: ${name} exited ${code} — it names every source it skipped:\n${tail || '(no stderr captured)'}` };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Doing
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function runIngester(file, { execFileImpl = execFile } = {}) {
117
+ return new Promise((resolve) => {
118
+ execFileImpl(process.execPath, [file], {
119
+ cwd: REPO_ROOT, timeout: INGEST_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024,
120
+ env: process.env, shell: false,
121
+ }, (err, _stdout, stderr) => {
122
+ resolve({
123
+ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
124
+ signal: (err && err.signal) || null,
125
+ stderr: stderr || (err && err.message) || '',
126
+ });
127
+ });
128
+ });
129
+ }
130
+
131
+ async function readChunkStats(pool) {
132
+ const r = await pool.query(
133
+ 'SELECT count(*)::int AS chunks, max(updated_at) AS last FROM search_chunks'
134
+ );
135
+ const row = (r && r.rows && r.rows[0]) || {};
136
+ return { chunks: row.chunks, lastIngestedAt: row.last || null };
137
+ }
138
+
139
+ // One sweep. Never throws — a poller that can take the process down is worse than
140
+ // the outage it watches.
141
+ async function sweep({ pool, log = console, execFileImpl = execFile, now = Date.now() } = {}) {
142
+ let health;
143
+ try {
144
+ health = describeIndex({ ...(await readChunkStats(pool)), now });
145
+ } catch (e) {
146
+ health = describeIndex({ chunks: NaN, now });
147
+ health.error = (e && e.message) || String(e);
148
+ }
149
+ (health.level === 'ok' ? log.log : log.error).call(log, health.message);
150
+
151
+ // Ingest when the index is empty or unreadable-but-recoverable; a populated
152
+ // index is refreshed on the ordinary tick, not re-walked because it is stale by
153
+ // an hour. `empty` is the case that cannot wait.
154
+ const results = [];
155
+ for (const [name, file] of [['search-ingest-md', MD_INGESTER], ['search-ingest-db', DB_INGESTER]]) {
156
+ const r = await runIngester(file, { execFileImpl });
157
+ const d = describeIngest({ name, ...r });
158
+ results.push(d);
159
+ (d.ok ? log.log : log.error).call(log, d.message);
160
+ }
161
+
162
+ // The after-picture. Without it a run that exited 0 while writing nothing still
163
+ // reads as success — the exact shape of failure this task exists to end.
164
+ let after = null;
165
+ try {
166
+ after = describeIndex({ ...(await readChunkStats(pool)), now });
167
+ (after.level === 'ok' ? log.log : log.error).call(log, `search-index: after sweep — ${after.message}`);
168
+ } catch (_) { /* the before-picture already reported; never throw from a sweep */ }
169
+
170
+ return { health, results, after };
171
+ }
172
+
173
+ function start(deps = {}) {
174
+ // Same injectable-log shape as sweep() above (default console), so the two
175
+ // halves of this poller log through one seam and neither hard-codes console.
176
+ const log = deps.log || console;
177
+ if (process.env.SEARCH_INDEX_SWEEP_DISABLED === 'true') {
178
+ log.log('search-index: sweep disabled by SEARCH_INDEX_SWEEP_DISABLED.');
179
+ return { stop };
180
+ }
181
+ const pool = deps.pool || require('../../../src/module-api.js').pool;
182
+ const tick = async () => {
183
+ if (sweeping) return; // never re-enter; a long ingest must not stack
184
+ sweeping = true;
185
+ try { await sweep({ pool, log }); }
186
+ catch (e) { log.error('search-index: sweep failed:', (e && e.message) || e); }
187
+ finally { sweeping = false; }
188
+ };
189
+ firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
190
+ intervalTimer = setInterval(tick, INTERVAL_MS);
191
+ if (firstTimer.unref) firstTimer.unref();
192
+ if (intervalTimer.unref) intervalTimer.unref();
193
+ return { stop };
194
+ }
195
+
196
+ function stop() {
197
+ if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
198
+ if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null; }
199
+ }
200
+
201
+ module.exports = { start, stop, sweep, describeIndex, describeIngest, INTERVAL_MS, FIRST_SWEEP_DELAY_MS };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.356",
3
+ "version": "1.19.357",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.356",
9
+ "version": "1.19.357",
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.19.356",
3
+ "version": "1.19.357",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -1142,8 +1142,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1142
1142
  // landed since 1.19.354 with no explicit bump. run 32853063015. (task 1002620)
1143
1143
  // 1.19.356 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1144
1144
  // landed since 1.19.355 with no explicit bump. run 32861072779. (task 1002620)
1145
+ // 1.19.357 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1146
+ // landed since 1.19.356 with no explicit bump. run 32871335939. (task 1002620)
1145
1147
  // ---------------------------------------------------------------------------
1146
- const CORE_VERSION = '1.19.356'; // CI auto-patch carrier (ADR 0161); see changelog above
1148
+ const CORE_VERSION = '1.19.357'; // CI auto-patch carrier (ADR 0161); see changelog above
1147
1149
 
1148
1150
  // A namespaced logger so a module's log lines are attributable + consistent.
1149
1151
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,161 @@
1
+ // tests/search_index_poller.mjs — the recall index keeps itself, and says so
2
+ // (task 1003162).
3
+ //
4
+ // The outage this guards: /recall returned `index_empty` on live for words that
5
+ // demonstrably existed in the corpus, because NOTHING ran the ingesters. ADR 0060
6
+ // claims a deploy hook and a nightly timer; both are box-side, and neither could be
7
+ // shown to have ever run.
8
+ //
9
+ // Everything here is pure or dependency-injected — no database, no child process,
10
+ // no clock. The sweep is driven with a fake pool and a fake execFile so the
11
+ // DECISIONS are tested, not the plumbing.
12
+ //
13
+ // Run: node tests/search_index_poller.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const poller = require('../modules/memory/pollers/search-index.js');
20
+ const { describeIndex, describeIngest, sweep } = poller;
21
+
22
+ let passed = 0;
23
+ let failed = 0;
24
+ async function test(name, fn) {
25
+ try { await fn(); passed++; console.log(` ok ${name}`); }
26
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
27
+ }
28
+
29
+ const NOW = Date.parse('2026-08-25T12:00:00Z');
30
+ const daysAgo = (d) => new Date(NOW - d * 86400000).toISOString();
31
+ const quietLog = { log() {}, error() {} };
32
+ const okExec = (_p, _a, _o, cb) => cb(null, '', '');
33
+
34
+ // --- READING THE INDEX ------------------------------------------------------
35
+
36
+ await test('an EMPTY index is an alarm, and says recall returns nothing AT ANY RANK', async () => {
37
+ const d = describeIndex({ chunks: 0, now: NOW });
38
+ assert.equal(d.level, 'alarm');
39
+ assert.equal(d.state, 'empty');
40
+ // Rank-independence is the whole reason `index_empty` is diagnostic: it rules
41
+ // out "nothing visible to YOU", which is what made the live report trustworthy.
42
+ assert.match(d.message, /any rank/i);
43
+ });
44
+
45
+ await test('an UNREADABLE count is an alarm — unknown is not healthy', async () => {
46
+ for (const bad of [NaN, undefined, null, -1, 'lots']) {
47
+ const d = describeIndex({ chunks: bad, now: NOW });
48
+ assert.equal(d.level, 'alarm', `${bad} must not read as healthy`);
49
+ assert.equal(d.state, 'unreadable');
50
+ }
51
+ });
52
+
53
+ await test('a populated, fresh index is ok and says how much it holds', async () => {
54
+ const d = describeIndex({ chunks: 4812, lastIngestedAt: daysAgo(1), now: NOW });
55
+ assert.equal(d.level, 'ok');
56
+ assert.match(d.message, /4812/);
57
+ });
58
+
59
+ await test('a populated but WEEK-OLD index warns — the sweep may have stopped', async () => {
60
+ const d = describeIndex({ chunks: 4812, lastIngestedAt: daysAgo(9), now: NOW });
61
+ assert.equal(d.level, 'warn');
62
+ assert.equal(d.state, 'stale');
63
+ assert.equal(d.age_days, 9);
64
+ });
65
+
66
+ await test('staleness is not guessed when nothing says when it last ran', async () => {
67
+ const d = describeIndex({ chunks: 10, lastIngestedAt: null, now: NOW });
68
+ assert.equal(d.level, 'ok', 'an unknown age must not be reported as stale');
69
+ assert.equal(d.age_days, null);
70
+ });
71
+
72
+ // --- READING THE INGESTERS' EXIT CODES --------------------------------------
73
+
74
+ await test('a NON-ZERO exit is an alarm carrying the TAIL of stderr', async () => {
75
+ // Task 1002859 made a partial ingest exit non-zero and NAME every skipped
76
+ // source — at the end of its output. A head-clip would drop exactly that.
77
+ const d = describeIngest({
78
+ name: 'search-ingest-md', code: 1,
79
+ stderr: ['noise', 'noise', 'noise', 'noise', 'noise', 'noise', 'skipped: docs/adr/0060.md (NUL byte)'].join('\n'),
80
+ });
81
+ assert.equal(d.ok, false);
82
+ assert.equal(d.level, 'alarm');
83
+ assert.match(d.message, /skipped: docs\/adr\/0060\.md/, 'the last line is the one that matters');
84
+ });
85
+
86
+ await test('a KILLED ingester is reported as killed, not as a clean failure', async () => {
87
+ const d = describeIngest({ name: 'search-ingest-db', code: null, signal: 'SIGTERM' });
88
+ assert.equal(d.ok, false);
89
+ assert.match(d.message, /KILLED by SIGTERM/);
90
+ assert.match(d.message, /partially written/, 'a half-written index is the real risk');
91
+ });
92
+
93
+ await test('exit 0 is ok', async () => {
94
+ assert.equal(describeIngest({ name: 'x', code: 0 }).ok, true);
95
+ });
96
+
97
+ // --- THE SWEEP --------------------------------------------------------------
98
+
99
+ function fakePool(seq) {
100
+ let i = 0;
101
+ return { query: async () => ({ rows: [seq[Math.min(i++, seq.length - 1)]] }) };
102
+ }
103
+
104
+ await test('the sweep runs BOTH ingesters — one failing must not skip the other', async () => {
105
+ const called = [];
106
+ const exec = (_p, args, _o, cb) => {
107
+ called.push(args[0].split('/').pop());
108
+ cb(args[0].includes('md') ? Object.assign(new Error('x'), { code: 1 }) : null, '', 'boom');
109
+ };
110
+ const r = await sweep({ pool: fakePool([{ chunks: 0, last: null }]), log: quietLog, execFileImpl: exec, now: NOW });
111
+ assert.deepEqual(called, ['search-ingest-md.js', 'search-ingest-db.js']);
112
+ assert.equal(r.results.length, 2);
113
+ assert.equal(r.results[0].ok, false);
114
+ assert.equal(r.results[1].ok, true, 'the db ingest still ran after the md ingest failed');
115
+ });
116
+
117
+ await test('the sweep reports the AFTER count — exit 0 that wrote nothing is a failure shape', async () => {
118
+ // The precise trap: an ingester can exit 0 and leave the table empty. Without
119
+ // an after-picture that reads as success.
120
+ const r = await sweep({
121
+ pool: fakePool([{ chunks: 0, last: null }, { chunks: 0, last: null }]),
122
+ log: quietLog, execFileImpl: okExec, now: NOW,
123
+ });
124
+ assert.equal(r.after.state, 'empty', 'still empty after a "successful" ingest');
125
+ assert.equal(r.after.level, 'alarm');
126
+ });
127
+
128
+ await test('a healthy sweep still SPEAKS — silence must not be the healthy state', async () => {
129
+ // The reconciler's lesson: a sweep that only logs on failure is
130
+ // indistinguishable from a sweep that is not running at all.
131
+ const lines = [];
132
+ await sweep({
133
+ pool: fakePool([{ chunks: 4812, last: daysAgo(1) }]),
134
+ log: { log: (m) => lines.push(m), error: (m) => lines.push(m) },
135
+ execFileImpl: okExec, now: NOW,
136
+ });
137
+ assert.ok(lines.length >= 2, 'a healthy sweep leaves a disposition line');
138
+ assert.ok(lines.some((l) => /4812/.test(l)), 'and says what it found');
139
+ });
140
+
141
+ await test('a DB read failure never throws out of the sweep', async () => {
142
+ const r = await sweep({
143
+ pool: { query: async () => { throw new Error('connection refused'); } },
144
+ log: quietLog, execFileImpl: okExec, now: NOW,
145
+ });
146
+ assert.equal(r.health.state, 'unreadable');
147
+ assert.equal(r.health.level, 'alarm');
148
+ });
149
+
150
+ await test('the poller is declared in the memory manifest — a file nothing loads is dormant', async () => {
151
+ // The exact failure goal 1000076 hit five times: a surface that exists, is
152
+ // documented, and is never wired.
153
+ const manifest = require('../modules/memory/module.json');
154
+ assert.ok(
155
+ (manifest.contributes.pollers || []).includes('search-index'),
156
+ 'modules/memory/module.json must declare the poller for the loader to start it',
157
+ );
158
+ });
159
+
160
+ console.log(`\nsearch_index_poller: ${passed} passed, ${failed} failed`);
161
+ process.exit(failed ? 1 : 0);