@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 +21 -11
- package/modules/memory/module.json +11 -2
- package/modules/memory/pollers/search-index.js +201 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/search_index_poller.mjs +161 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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":
|
|
14
|
+
"functional_verbatim": 1511,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
4805
|
+
"sha256": "b1353851e7bd50f58b47d0191076eacc249bdca62553d0bb9a6ee767aed9c712"
|
|
4801
4806
|
},
|
|
4802
4807
|
{
|
|
4803
4808
|
"path": "package.json",
|
|
4804
4809
|
"mode": "0000644",
|
|
4805
|
-
"sha256": "
|
|
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": "
|
|
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": {
|
|
7
|
+
"maintenance": {
|
|
8
|
+
"status": "core-maintained"
|
|
9
|
+
},
|
|
8
10
|
"contributes": {
|
|
9
|
-
"routes": [
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|