@bongos/core 1.19.268 → 1.19.270
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 +28 -18
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -1
- package/modules/memory/routes/search.js +19 -1
- package/modules/memory/search-chunker.js +28 -2
- package/modules/memory/search.js +170 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/recall.js +41 -3
- package/scripts/gds/search-ingest-db.js +52 -10
- package/scripts/gds/search-ingest-md.js +59 -16
- package/src/module-api.js +5 -1
- package/tests/recall_corpus_health.mjs +194 -0
- package/tests/search_ingest_nul.mjs +193 -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.270",
|
|
6
|
+
"core_contract": "1.19.270",
|
|
7
|
+
"source_commit": "b552261cf04dbe95edbfa682bdeb797004935248",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-20T23:27:09.964Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 318,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1369,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1709,
|
|
20
|
+
"tree_sha256": "fd8774b261076703d5db5da8703522555606efc1b3463642d9ded0253791b414",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "96bbd222f3ccbc2d57bce28e0ac89584252b0e3213bc79f5c8aad54ce10cc845"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1402,7 +1402,7 @@
|
|
|
1402
1402
|
{
|
|
1403
1403
|
"path": "docs/api/openapi.json",
|
|
1404
1404
|
"mode": "0000644",
|
|
1405
|
-
"sha256": "
|
|
1405
|
+
"sha256": "2f14ac090adc4b7de69f20948172cee0cf002e1224262d5a51ddcada95ad9489"
|
|
1406
1406
|
},
|
|
1407
1407
|
{
|
|
1408
1408
|
"path": "docs/architecture.md",
|
|
@@ -3907,17 +3907,17 @@
|
|
|
3907
3907
|
{
|
|
3908
3908
|
"path": "modules/memory/routes/search.js",
|
|
3909
3909
|
"mode": "0000644",
|
|
3910
|
-
"sha256": "
|
|
3910
|
+
"sha256": "a79f61f7906393fcb0bffb67f224c47b7129223e3a6806357de091a9339fa42e"
|
|
3911
3911
|
},
|
|
3912
3912
|
{
|
|
3913
3913
|
"path": "modules/memory/search-chunker.js",
|
|
3914
3914
|
"mode": "0000644",
|
|
3915
|
-
"sha256": "
|
|
3915
|
+
"sha256": "b25ec0c42b9ea0ac856837e9a4282fd9ea4cb79a1c9258ee18bdf81e757c001e"
|
|
3916
3916
|
},
|
|
3917
3917
|
{
|
|
3918
3918
|
"path": "modules/memory/search.js",
|
|
3919
3919
|
"mode": "0000644",
|
|
3920
|
-
"sha256": "
|
|
3920
|
+
"sha256": "a9b900bcbdbb55b27f15dafe3ee45f57cd0e4dfda0b32cb38b29139b24f4ef68"
|
|
3921
3921
|
},
|
|
3922
3922
|
{
|
|
3923
3923
|
"path": "modules/onboarding/CLAUDE.md",
|
|
@@ -4472,12 +4472,12 @@
|
|
|
4472
4472
|
{
|
|
4473
4473
|
"path": "package-lock.json",
|
|
4474
4474
|
"mode": "0000644",
|
|
4475
|
-
"sha256": "
|
|
4475
|
+
"sha256": "3da63688ee1642c534fe9f8c177822c8de4335ed57c30878366e4a59f6a94a31"
|
|
4476
4476
|
},
|
|
4477
4477
|
{
|
|
4478
4478
|
"path": "package.json",
|
|
4479
4479
|
"mode": "0000644",
|
|
4480
|
-
"sha256": "
|
|
4480
|
+
"sha256": "1b3cda6b70ab6ff9c4034e5a459a3ea8b6bdeb39ad6368e0627d0a35015ad8c2"
|
|
4481
4481
|
},
|
|
4482
4482
|
{
|
|
4483
4483
|
"path": "public-docs/index.html",
|
|
@@ -5447,7 +5447,7 @@
|
|
|
5447
5447
|
{
|
|
5448
5448
|
"path": "scripts/gds/recall.js",
|
|
5449
5449
|
"mode": "0000644",
|
|
5450
|
-
"sha256": "
|
|
5450
|
+
"sha256": "191b81d68114cbb920d2aed23398d581d2e1d51f6bc92e243ae7541f454d4b1e"
|
|
5451
5451
|
},
|
|
5452
5452
|
{
|
|
5453
5453
|
"path": "scripts/gds/redteam-patrol.js",
|
|
@@ -5497,12 +5497,12 @@
|
|
|
5497
5497
|
{
|
|
5498
5498
|
"path": "scripts/gds/search-ingest-db.js",
|
|
5499
5499
|
"mode": "0000644",
|
|
5500
|
-
"sha256": "
|
|
5500
|
+
"sha256": "0723d48df92d78696b86adf0591584800f77da590afc80bcc5e3f99dcf614979"
|
|
5501
5501
|
},
|
|
5502
5502
|
{
|
|
5503
5503
|
"path": "scripts/gds/search-ingest-md.js",
|
|
5504
5504
|
"mode": "0000644",
|
|
5505
|
-
"sha256": "
|
|
5505
|
+
"sha256": "802d16a48f6a47bf41b1768fff3da0ae1b18a4d73147b3559a781f18ea47c75c"
|
|
5506
5506
|
},
|
|
5507
5507
|
{
|
|
5508
5508
|
"path": "scripts/gds/security-baseline-check.sh",
|
|
@@ -6122,7 +6122,7 @@
|
|
|
6122
6122
|
{
|
|
6123
6123
|
"path": "src/module-api.js",
|
|
6124
6124
|
"mode": "0000644",
|
|
6125
|
-
"sha256": "
|
|
6125
|
+
"sha256": "a70173a565935a8a93c133dd94bde27e0000836e6ec9b043499ab0d27b4f29c2"
|
|
6126
6126
|
},
|
|
6127
6127
|
{
|
|
6128
6128
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7984,6 +7984,11 @@
|
|
|
7984
7984
|
"mode": "0000644",
|
|
7985
7985
|
"sha256": "dedec6f449696a7fefa2cec1aeb290dc91386c4450e8feaf28a49e9b46a5e7ed"
|
|
7986
7986
|
},
|
|
7987
|
+
{
|
|
7988
|
+
"path": "tests/recall_corpus_health.mjs",
|
|
7989
|
+
"mode": "0000644",
|
|
7990
|
+
"sha256": "ec1ebb9e317221df08c6009d1abd8816da47eda20740ae928cf9f829b8da4951"
|
|
7991
|
+
},
|
|
7987
7992
|
{
|
|
7988
7993
|
"path": "tests/recall_hints.mjs",
|
|
7989
7994
|
"mode": "0000644",
|
|
@@ -8099,6 +8104,11 @@
|
|
|
8099
8104
|
"mode": "0000644",
|
|
8100
8105
|
"sha256": "c80af8bfbb83f0ebdc44c196b8d127caf228a5e102382f254f4e55e1c1d96eaf"
|
|
8101
8106
|
},
|
|
8107
|
+
{
|
|
8108
|
+
"path": "tests/search_ingest_nul.mjs",
|
|
8109
|
+
"mode": "0000644",
|
|
8110
|
+
"sha256": "a8687493e50f83430b337df76c77e2ecb9924df5012180ba1c853141e0646f88"
|
|
8111
|
+
},
|
|
8102
8112
|
{
|
|
8103
8113
|
"path": "tests/search_isolation.mjs",
|
|
8104
8114
|
"mode": "0000644",
|
|
@@ -278,7 +278,7 @@ export interface PostProvisioningInstancesIdTeardownResponse { ok: boolean; queu
|
|
|
278
278
|
export interface PostProvisioningInstancesRequest { slug: string; target_ref?: string; hosting_shape?: string; domain?: string; no_address?: boolean; tier?: string; onboard_mode?: string; type?: string }
|
|
279
279
|
export interface PostSearchMarkRequest { chunk_id?: StringifiableId; useful?: unknown }
|
|
280
280
|
export interface PostSearchRequest { expand?: unknown; q?: string; source_kind?: string; limit?: unknown }
|
|
281
|
-
export interface PostSearchResponse { expand?: unknown; chunk?: unknown; query?: unknown; count?: unknown; total_full_tokens?: unknown; results?: unknown }
|
|
281
|
+
export interface PostSearchResponse { expand?: unknown; chunk?: unknown; query?: unknown; count?: unknown; total_full_tokens?: unknown; results?: unknown; corpus?: unknown }
|
|
282
282
|
export interface PostSecurityDocsRequest { slug: string; title: string; content_md: string }
|
|
283
283
|
export interface PostSecurityDocsResponse { ok: boolean; doc: unknown }
|
|
284
284
|
export interface PostSecurityReportsIdConfirmRequest { drachmae_awarded?: unknown }
|
package/docs/api/openapi.json
CHANGED
|
@@ -17187,7 +17187,8 @@
|
|
|
17187
17187
|
"query": {},
|
|
17188
17188
|
"count": {},
|
|
17189
17189
|
"total_full_tokens": {},
|
|
17190
|
-
"results": {}
|
|
17190
|
+
"results": {},
|
|
17191
|
+
"corpus": {}
|
|
17191
17192
|
}
|
|
17192
17193
|
},
|
|
17193
17194
|
"PostSecurityDocsRequest": {
|
|
@@ -128,7 +128,25 @@ module.exports = function buildSearchRouter() {
|
|
|
128
128
|
// total_full_tokens: the cost of expanding EVERY hit — the headline an
|
|
129
129
|
// agent reads to decide whether to expand selectively (ADR 0095 C1).
|
|
130
130
|
const totalFullTokens = results.reduce((sum, r) => sum + (r.full_tokens || 0), 0);
|
|
131
|
-
|
|
131
|
+
// task 1003097: a zero-result search is ambiguous on the wire — a DEAD
|
|
132
|
+
// INDEX and a genuine miss are the same 200-with-an-empty-array, which is
|
|
133
|
+
// how /recall stayed broken for weeks with nothing reporting a fault. On
|
|
134
|
+
// the zero path only (so a healthy search pays nothing), ask the corpus why
|
|
135
|
+
// and say which of the three it was. Best-effort: a diagnostic must never
|
|
136
|
+
// turn a working search into a 500.
|
|
137
|
+
// corpusHealth absorbs its own failures (enrichment must never break a
|
|
138
|
+
// working search), so no try/catch here. `corpus` stays undefined on a
|
|
139
|
+
// hit, and JSON.stringify drops an undefined key — so a successful search
|
|
140
|
+
// is byte-identical on the wire, and the response literal stays STATIC so
|
|
141
|
+
// gen-api-docs can still infer PostSearchResponse from it.
|
|
142
|
+
let corpus;
|
|
143
|
+
if (results.length === 0) {
|
|
144
|
+
const health = await search.corpusHealth({ builderId: req.builder.id, builderRank: req.builder.rank });
|
|
145
|
+
// ONLY the classifier's output — it returns the SAFE subset. Spreading
|
|
146
|
+
// `health` here would put the unscoped existence probe back on the wire.
|
|
147
|
+
corpus = search.classifyEmptyResult(health);
|
|
148
|
+
}
|
|
149
|
+
return res.json({ query: q, count: results.length, total_full_tokens: totalFullTokens, results, corpus });
|
|
132
150
|
} catch (err) {
|
|
133
151
|
console.error('[gds] POST /search', { builderId: req.builder.id, err: err && err.message });
|
|
134
152
|
return res.fail('search_failed', { status: 500, message: 'search could not be completed' });
|
|
@@ -168,11 +168,37 @@ function splitLongBody(body, maxTokens) {
|
|
|
168
168
|
// a typed ref like 'task:431' for DB prose); it's only used for the breadcrumb
|
|
169
169
|
// root + returned for the caller's source_ref. Returns chunks in document order
|
|
170
170
|
// with 0-based chunk_index.
|
|
171
|
+
// task 1002859: strip characters Postgres rejects in a text column. U+0000 is the
|
|
172
|
+
// one that matters: PG raises 'invalid byte sequence for encoding "UTF8": 0x00'
|
|
173
|
+
// on it, and BOTH ingesters (search-ingest-md.js for the tree, search-ingest-db.js
|
|
174
|
+
// for DB prose) hand chunk bodies straight to an INSERT. A NUL therefore aborted
|
|
175
|
+
// the whole ingest and /recall silently stopped re-indexing (task 1003097).
|
|
176
|
+
//
|
|
177
|
+
// Sanitizing HERE rather than in either ingester is deliberate: this is the one
|
|
178
|
+
// funnel both go through, so the guard cannot be added to one path and forgotten
|
|
179
|
+
// on the other. That is exactly what happened the first time this bug class was
|
|
180
|
+
// fixed — commit 75742cb (task 1002507) stripped NUL from ship.js snippetOf after
|
|
181
|
+
// a UTF-16LE `wsl --list` read voided a session digest, and left every other path
|
|
182
|
+
// carrying the same hazard.
|
|
183
|
+
//
|
|
184
|
+
// NUL is meaningless in prose, so dropping it loses nothing a reader wants. Lone
|
|
185
|
+
// surrogates go too: they are unpaired UTF-16 halves that cannot be encoded as
|
|
186
|
+
// valid UTF-8 — the same class of failure from the same class of cause (a file
|
|
187
|
+
// read in the wrong encoding).
|
|
188
|
+
const PG_INVALID_TEXT_RE = /\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
189
|
+
|
|
190
|
+
function sanitizeForText(text) {
|
|
191
|
+
return typeof text === 'string' ? text.replace(PG_INVALID_TEXT_RE, '') : text;
|
|
192
|
+
}
|
|
193
|
+
|
|
171
194
|
function chunkMarkdown({ path, content, maxTokens = DEFAULT_MAX_TOKENS, targetTokens = DEFAULT_TARGET_TOKENS } = {}) {
|
|
172
195
|
// ADR 0095 C4: strip <private>-fenced content BEFORE chunking so it never
|
|
173
196
|
// enters the recall index. Done first so front-matter parsing + heading
|
|
174
197
|
// splitting see only the public text.
|
|
175
|
-
|
|
198
|
+
// Sanitize FIRST, before any parsing: a NUL inside a heading or a front-matter
|
|
199
|
+
// value would otherwise ride out on `title`/`heading_path`, which are also
|
|
200
|
+
// Postgres text columns.
|
|
201
|
+
const { frontmatter, body } = parseFrontMatter(stripPrivate(sanitizeForText(content)));
|
|
176
202
|
const root = fileLabel(path);
|
|
177
203
|
|
|
178
204
|
// Title: explicit front-matter title/name → first H1 → file label.
|
|
@@ -249,4 +275,4 @@ function chunkMarkdown({ path, content, maxTokens = DEFAULT_MAX_TOKENS, targetTo
|
|
|
249
275
|
return { path: path || null, title, frontmatter: frontmatter || null, chunks };
|
|
250
276
|
}
|
|
251
277
|
|
|
252
|
-
module.exports = { chunkMarkdown, parseFrontMatter, estTokens, fileLabel, stripPrivate, DEFAULT_TARGET_TOKENS, DEFAULT_MAX_TOKENS };
|
|
278
|
+
module.exports = { chunkMarkdown, parseFrontMatter, estTokens, fileLabel, stripPrivate, sanitizeForText, PG_INVALID_TEXT_RE, DEFAULT_TARGET_TOKENS, DEFAULT_MAX_TOKENS };
|
package/modules/memory/search.js
CHANGED
|
@@ -304,8 +304,178 @@ async function markChunkUsefulness(
|
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
// task 1003097: WHY A ZERO-RESULT SEARCH IS NOT SELF-EXPLANATORY.
|
|
308
|
+
//
|
|
309
|
+
// /recall returned zero matches for EVERY query on live for weeks and nothing
|
|
310
|
+
// reported a fault, because a dead index and a genuine no-match are the same
|
|
311
|
+
// 200-with-an-empty-array on the wire. That is the most expensive possible wrong
|
|
312
|
+
// answer: it reads as "we have never touched this", so sessions re-derive work
|
|
313
|
+
// that already exists. (The cause was task 1002859 — one NUL byte rolled back
|
|
314
|
+
// the whole ingest, silently, on every deploy.)
|
|
315
|
+
//
|
|
316
|
+
// So when a search finds nothing, ask the corpus WHY and say which of three
|
|
317
|
+
// things happened. One cheap aggregate, run ONLY on the zero-result path, so a
|
|
318
|
+
// healthy search pays nothing:
|
|
319
|
+
//
|
|
320
|
+
// index_empty — there are no chunks at all. The ingest has never
|
|
321
|
+
// succeeded, or the table was wiped and never rebuilt.
|
|
322
|
+
// This is an OUTAGE, not an answer.
|
|
323
|
+
// nothing_visible — chunks exist, but none are visible at this caller's rank
|
|
324
|
+
// or ownership. Scoping, not absence — and worth naming,
|
|
325
|
+
// because the scope join is the other candidate cause a
|
|
326
|
+
// diagnosis has to rule out.
|
|
327
|
+
// no_match — the corpus is populated and visible, and the query
|
|
328
|
+
// genuinely matched nothing. The only one of the three that
|
|
329
|
+
// is a legitimate "no results".
|
|
330
|
+
//
|
|
331
|
+
// NOTE for anyone reading task 1003097's brief: it points at "migration 127
|
|
332
|
+
// search_ingest_runs" as an ingest ledger to consult. No such table exists in
|
|
333
|
+
// this repo (the search schema is migration 103, search_chunks alone), so
|
|
334
|
+
// freshness here is derived from MAX(updated_at) on the chunks themselves.
|
|
335
|
+
// Who may be told about content they cannot read. Metic+ is the boundary this
|
|
336
|
+
// repo already uses for the permission/methodology core (ADR 0043), and
|
|
337
|
+
// distinguishing "the index is empty" from "everything is above your rank"
|
|
338
|
+
// necessarily reveals that unreadable content exists.
|
|
339
|
+
const PRIVILEGED_DIAGNOSTIC_RANKS = ['metic', 'archon'];
|
|
340
|
+
|
|
341
|
+
// task 1003097: why a zero-result search is not self-explanatory.
|
|
342
|
+
//
|
|
343
|
+
// /recall returned zero for EVERY query on live for weeks and nothing reported a
|
|
344
|
+
// fault, because a dead index and a genuine no-match are the same
|
|
345
|
+
// 200-with-an-empty-array. That reads as "we have never touched this", so
|
|
346
|
+
// sessions re-derive work that already exists. (Cause: task 1002859 — one NUL
|
|
347
|
+
// byte rolled back the whole ingest, silently, every deploy.)
|
|
348
|
+
//
|
|
349
|
+
// Two panel findings shaped this into its current form, and both are worth
|
|
350
|
+
// keeping in view:
|
|
351
|
+
//
|
|
352
|
+
// SCOPING (ADR 0016). The first cut returned unfiltered total/doc/db counts and
|
|
353
|
+
// a global last_indexed_at to every caller — an oracle for the size,
|
|
354
|
+
// composition and freshness of content the caller may not read. This route
|
|
355
|
+
// family deliberately avoids that (fetchChunkBody answers one 404 for both
|
|
356
|
+
// "missing" and "not yours"). Now: the caller's own view is scoped with the
|
|
357
|
+
// SAME predicate as the search, and the unscoped probe is only consulted for a
|
|
358
|
+
// Metic+ caller.
|
|
359
|
+
//
|
|
360
|
+
// COST. It also ran a full-table COUNT(*) — and during an actual outage EVERY
|
|
361
|
+
// search is a zero-result search, so that scan would run on every request from
|
|
362
|
+
// every builder for the duration. But nothing here needs a COUNT: the states
|
|
363
|
+
// turn on whether ANY row exists, not how many. EXISTS short-circuits at the
|
|
364
|
+
// first matching row, so this is two index-assisted probes rather than a scan,
|
|
365
|
+
// and it stays flat as the corpus grows. Dropping the exact numbers also
|
|
366
|
+
// removes the last of the oracle.
|
|
367
|
+
async function corpusHealth({ builderId, builderRank } = {}, { pool: p = pool, client = null } = {}) {
|
|
368
|
+
const allowedRanks = ranksAtOrBelow(builderRank);
|
|
369
|
+
const ownerId = Number.isInteger(builderId) ? builderId : (Number(builderId) || null);
|
|
370
|
+
const privileged = PRIVILEGED_DIAGNOSTIC_RANKS.includes(String(builderRank || '').toLowerCase());
|
|
371
|
+
const sql = `
|
|
372
|
+
SELECT EXISTS (
|
|
373
|
+
SELECT 1 FROM search_chunks
|
|
374
|
+
WHERE min_rank = ANY($1::text[])
|
|
375
|
+
AND (owner_builder_id IS NULL OR owner_builder_id = $2::bigint)
|
|
376
|
+
) AS any_visible,
|
|
377
|
+
-- Unscoped existence. Consulted ONLY for a privileged caller (the
|
|
378
|
+
-- route passes it straight to classifyEmptyResult, which strips it
|
|
379
|
+
-- for everyone else), and cheap either way because EXISTS stops at
|
|
380
|
+
-- the first row.
|
|
381
|
+
EXISTS (SELECT 1 FROM search_chunks) AS any_at_all,
|
|
382
|
+
(SELECT MAX(updated_at) FROM search_chunks
|
|
383
|
+
WHERE min_rank = ANY($1::text[])
|
|
384
|
+
AND (owner_builder_id IS NULL OR owner_builder_id = $2::bigint)
|
|
385
|
+
) AS last_indexed_at
|
|
386
|
+
`;
|
|
387
|
+
const runner = client || p;
|
|
388
|
+
let rows;
|
|
389
|
+
try {
|
|
390
|
+
({ rows } = await runner.query(sql, [allowedRanks, ownerId]));
|
|
391
|
+
} catch (_) {
|
|
392
|
+
// ENRICHMENT ONLY — it runs to EXPLAIN a zero result, so it must never turn
|
|
393
|
+
// a working search into an error. Nulls fail closed through
|
|
394
|
+
// classifyEmptyResult, which never reports health it cannot prove.
|
|
395
|
+
return { any_visible: null, any_at_all: null, last_indexed_at: null, privileged };
|
|
396
|
+
}
|
|
397
|
+
const r = rows[0] || {};
|
|
398
|
+
return {
|
|
399
|
+
any_visible: r.any_visible === true || r.any_visible === false ? r.any_visible : null,
|
|
400
|
+
any_at_all: r.any_at_all === true || r.any_at_all === false ? r.any_at_all : null,
|
|
401
|
+
last_indexed_at: r.last_indexed_at || null,
|
|
402
|
+
privileged,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// PURE. The state, the sentence to show, and only the facts this caller may
|
|
407
|
+
// have. Separated from the query so the truth table AND the scoping are
|
|
408
|
+
// unit-tested without a DB.
|
|
409
|
+
function classifyEmptyResult(health) {
|
|
410
|
+
const anyVisible = health && typeof health.any_visible === 'boolean' ? health.any_visible : null;
|
|
411
|
+
const privileged = !!(health && health.privileged);
|
|
412
|
+
// Never echo the unscoped probe. last_indexed_at is already scoped to the
|
|
413
|
+
// caller's own view by the query.
|
|
414
|
+
const safe = { last_indexed_at: (health && health.last_indexed_at) || null };
|
|
415
|
+
|
|
416
|
+
if (anyVisible === null) {
|
|
417
|
+
return {
|
|
418
|
+
...safe,
|
|
419
|
+
state: 'unknown',
|
|
420
|
+
healthy: false,
|
|
421
|
+
message: 'The index could not be inspected, so this zero cannot be interpreted — it may be an '
|
|
422
|
+
+ 'empty corpus, a scoping wall, or a real miss. Treat it as UNKNOWN rather than as "nothing '
|
|
423
|
+
+ 'exists", and check the server log for the search-corpus probe.',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (anyVisible) {
|
|
428
|
+
return {
|
|
429
|
+
...safe,
|
|
430
|
+
state: 'no_match',
|
|
431
|
+
healthy: true,
|
|
432
|
+
message: 'No match. The index is populated and visible to you, and the query ran, so this is a '
|
|
433
|
+
+ 'genuine miss — try fewer or different terms.',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Nothing visible. WHETHER that is an empty corpus or a scoping wall is a fact
|
|
438
|
+
// about content this caller cannot read, so only Metic+ is told which.
|
|
439
|
+
if (!privileged) {
|
|
440
|
+
return {
|
|
441
|
+
...safe,
|
|
442
|
+
state: 'nothing_visible_to_you',
|
|
443
|
+
healthy: false,
|
|
444
|
+
message: 'NOTHING IS VISIBLE TO YOU HERE — either the index is empty or everything in it is '
|
|
445
|
+
+ 'scoped above your rank. This answer does not distinguish them, so do NOT read it as "the '
|
|
446
|
+
+ 'project has never touched this subject". Ask a Metic+ to check whether the index needs '
|
|
447
|
+
+ 'rebuilding.',
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (health.any_at_all === false) {
|
|
452
|
+
return {
|
|
453
|
+
...safe,
|
|
454
|
+
state: 'index_empty',
|
|
455
|
+
healthy: false,
|
|
456
|
+
message: 'THE SEARCH INDEX IS EMPTY — this is an outage, not an answer. No chunk has ever been '
|
|
457
|
+
+ 'written, or the table was wiped and never rebuilt. Nothing can be found until an ingest '
|
|
458
|
+
+ 'succeeds: run `node scripts/gds/search-ingest-md.js` (repo docs) and '
|
|
459
|
+
+ '`node scripts/gds/search-ingest-db.js` (DB prose), and check their exit codes — they run '
|
|
460
|
+
+ 'best-effort from the deploy and a failure there is easy to miss (task 1002859).',
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
...safe,
|
|
465
|
+
state: 'nothing_visible',
|
|
466
|
+
healthy: false,
|
|
467
|
+
message: 'The index is NOT empty, but nothing in it is visible to you — every chunk is scoped '
|
|
468
|
+
+ 'above your rank or owned by another builder. This is a scoping result, not an empty corpus, '
|
|
469
|
+
+ 'so a zero here says nothing about whether the subject is documented.',
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
307
473
|
module.exports = {
|
|
308
474
|
searchChunks,
|
|
475
|
+
// task 1003097: a zero-result search must say WHICH of three things happened —
|
|
476
|
+
// a dead index, a scoping wall, or a genuine miss.
|
|
477
|
+
corpusHealth,
|
|
478
|
+
classifyEmptyResult,
|
|
309
479
|
fetchChunkBody,
|
|
310
480
|
markChunkUsefulness,
|
|
311
481
|
ranksAtOrBelow,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.270",
|
|
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.270",
|
|
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.270",
|
|
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/scripts/gds/recall.js
CHANGED
|
@@ -73,6 +73,41 @@ async function runExpand(args) {
|
|
|
73
73
|
console.log(c.body);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
|
|
77
|
+
// task 1003097: the zero-result message, as a PURE function of (query, corpus)
|
|
78
|
+
// so the three outcomes are unit-tested instead of eyeballed.
|
|
79
|
+
//
|
|
80
|
+
// The old single line hedged — "or it may not be indexed yet" — which reads
|
|
81
|
+
// identically whether the index is fine or completely dead. /recall returned zero
|
|
82
|
+
// for EVERY query for weeks and this sentence was the only thing a builder saw,
|
|
83
|
+
// so it read as "we have never touched this" and sessions re-derived work that
|
|
84
|
+
// already existed. Returns { stream, lines, exitCode }: an OUTAGE goes to stderr
|
|
85
|
+
// with a non-zero exit, because a scheduled routine scripting /recall has to be
|
|
86
|
+
// able to tell a broken index from an honest miss.
|
|
87
|
+
function renderNoMatch({ q, corpus }) {
|
|
88
|
+
if (!corpus || !corpus.message) {
|
|
89
|
+
// Older server with no corpus diagnostic — previous wording, unchanged.
|
|
90
|
+
return {
|
|
91
|
+
stream: 'out',
|
|
92
|
+
exitCode: 0,
|
|
93
|
+
lines: [`No matches for "${q}". The index covers repo docs + (nightly) DB prose; try fewer / different terms, or it may not be indexed yet.`],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const stamp = corpus.last_indexed_at ? ` Last indexed: ${corpus.last_indexed_at}.` : '';
|
|
97
|
+
if (corpus.healthy) {
|
|
98
|
+
return { stream: 'out', exitCode: 0, lines: [`No matches for "${q}". ${corpus.message}${stamp}`] };
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
stream: 'err',
|
|
102
|
+
exitCode: 1,
|
|
103
|
+
lines: [
|
|
104
|
+
`\n ⚠ RECALL IS NOT ANSWERING — "${q}" returned nothing, and the reason is not your query.\n`,
|
|
105
|
+
` ${corpus.message}${stamp}\n`,
|
|
106
|
+
' Do NOT read this as "the project has never touched this subject".\n',
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
async function main() {
|
|
77
112
|
const args = parseArgs(process.argv.slice(2));
|
|
78
113
|
|
|
@@ -102,9 +137,12 @@ async function main() {
|
|
|
102
137
|
return;
|
|
103
138
|
}
|
|
104
139
|
|
|
105
|
-
const { count, results, total_full_tokens } = r.data;
|
|
140
|
+
const { count, results, total_full_tokens, corpus } = r.data;
|
|
106
141
|
if (!count) {
|
|
107
|
-
|
|
142
|
+
const out = renderNoMatch({ q: args.q, corpus });
|
|
143
|
+
const sink = out.stream === 'err' ? console.error : console.log;
|
|
144
|
+
for (const line of out.lines) sink(line);
|
|
145
|
+
if (out.exitCode) process.exitCode = out.exitCode;
|
|
108
146
|
return;
|
|
109
147
|
}
|
|
110
148
|
const totalNote = Number.isFinite(total_full_tokens)
|
|
@@ -132,4 +170,4 @@ if (require.main === module) {
|
|
|
132
170
|
main().catch((e) => { console.error('recall: error', e && e.message ? e.message : e); process.exit(1); });
|
|
133
171
|
}
|
|
134
172
|
|
|
135
|
-
module.exports = { parseArgs, truncate };
|
|
173
|
+
module.exports = { parseArgs, truncate, renderNoMatch };
|
|
@@ -143,11 +143,26 @@ async function ingestRows(client, rows, sourceRef) {
|
|
|
143
143
|
return stats;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// task 1002859 (panel finding): bound the driver's error text before it reaches a
|
|
147
|
+
// log. A Postgres error message is unbounded upstream text and CAN embed row
|
|
148
|
+
// content (a constraint violation quotes the offending key), and these logs are
|
|
149
|
+
// deploy/cron sinks that get shipped around. One line, capped, is enough to
|
|
150
|
+
// identify the fault; the source_ref beside it is what actually locates it. The
|
|
151
|
+
// DB corpus makes this matter more than it looks — its prose is session-log and
|
|
152
|
+
// memory-derived text the file elsewhere treats as scrub-sensitive.
|
|
153
|
+
const ERR_LOG_MAX = 300;
|
|
154
|
+
function errLine(e) {
|
|
155
|
+
const raw = e && e.message ? String(e.message) : String(e);
|
|
156
|
+
const flat = raw.replace(/\s+/g, ' ').trim();
|
|
157
|
+
return flat.length > ERR_LOG_MAX ? `${flat.slice(0, ERR_LOG_MAX)}… (truncated)` : flat;
|
|
158
|
+
}
|
|
159
|
+
|
|
146
160
|
async function main() {
|
|
147
161
|
const dryRun = process.argv.includes('--dry-run');
|
|
148
162
|
const pool = requirePool();
|
|
149
163
|
const client = await pool.connect();
|
|
150
164
|
const totals = { inserted: 0, updated: 0, unchanged: 0, pruned: 0, deleted: 0, sources: 0 };
|
|
165
|
+
const failures = []; // task 1002859 — per-source casualties, named at the end
|
|
151
166
|
const presentRefs = [];
|
|
152
167
|
try {
|
|
153
168
|
// Gather every source's rows first (read-only), grouped by source_ref.
|
|
@@ -177,19 +192,39 @@ async function main() {
|
|
|
177
192
|
return;
|
|
178
193
|
}
|
|
179
194
|
|
|
180
|
-
|
|
195
|
+
// task 1002859: ONE TRANSACTION PER SOURCE, not one for the whole run — the
|
|
196
|
+
// same all-or-nothing shape that made a single bad byte a total recall
|
|
197
|
+
// outage on the markdown side. DB prose is the MORE exposed of the two: it
|
|
198
|
+
// carries session-log and transcript-derived text, which is exactly where a
|
|
199
|
+
// wrong-encoding read introduces a NUL in the first place.
|
|
181
200
|
for (const [ref, rows] of byRef) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
201
|
+
try {
|
|
202
|
+
await client.query('BEGIN');
|
|
203
|
+
const s = await ingestRows(client, rows, ref);
|
|
204
|
+
await client.query('COMMIT');
|
|
205
|
+
totals.inserted += s.inserted; totals.updated += s.updated;
|
|
206
|
+
totals.unchanged += s.unchanged; totals.pruned += s.pruned;
|
|
207
|
+
} catch (e) {
|
|
208
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
209
|
+
failures.push({ sourceRef: ref, message: errLine(e) });
|
|
210
|
+
console.error(`search-ingest-db: SKIPPED ${ref} — ${errLine(e)}`);
|
|
211
|
+
}
|
|
185
212
|
}
|
|
186
213
|
// Delete db chunks whose source no longer exists (a deleted task/idea/etc.).
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
|
|
214
|
+
// Own transaction, and SKIPPED after any failure: presentRefs is what we
|
|
215
|
+
// meant to write, so pruning against it on a partial run could delete a
|
|
216
|
+
// still-live source's chunks.
|
|
217
|
+
if (failures.length === 0) {
|
|
218
|
+
await client.query('BEGIN');
|
|
219
|
+
const del = await client.query(
|
|
220
|
+
`DELETE FROM search_chunks WHERE source_kind='db' AND NOT (source_ref = ANY($1::text[]))`,
|
|
221
|
+
[presentRefs]
|
|
222
|
+
);
|
|
223
|
+
totals.deleted = del.rowCount || 0;
|
|
224
|
+
await client.query('COMMIT');
|
|
225
|
+
} else {
|
|
226
|
+
console.error(`search-ingest-db: vanished-source prune SKIPPED — ${failures.length} source(s) failed, so presentRefs is incomplete`);
|
|
227
|
+
}
|
|
193
228
|
} catch (e) {
|
|
194
229
|
await client.query('ROLLBACK').catch(() => {});
|
|
195
230
|
console.error('search-ingest-db: FAILED, rolled back:', e.message);
|
|
@@ -200,6 +235,13 @@ async function main() {
|
|
|
200
235
|
client.release();
|
|
201
236
|
await pool.end().catch(() => {});
|
|
202
237
|
console.log(`search-ingest-db: inserted=${totals.inserted} updated=${totals.updated} unchanged=${totals.unchanged} pruned=${totals.pruned} deleted=${totals.deleted} across ${totals.sources} sources`);
|
|
238
|
+
if (failures.length > 0) {
|
|
239
|
+
// NON-SILENT: this runs best-effort from a scheduled job, so a non-zero exit
|
|
240
|
+
// is the only signal that reaches a log a human reads. Name every casualty.
|
|
241
|
+
console.error(`search-ingest-db: ${failures.length} source(s) FAILED and were skipped:`);
|
|
242
|
+
for (const f of failures) console.error(` ${f.sourceRef}: ${f.message}`);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
}
|
|
203
245
|
}
|
|
204
246
|
|
|
205
247
|
if (require.main === module) {
|
|
@@ -163,6 +163,20 @@ async function ingestFile(client, rows, sourceRef) {
|
|
|
163
163
|
return stats;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// task 1002859 (panel finding): bound the driver's error text before it reaches a
|
|
167
|
+
// log. A Postgres error message is unbounded upstream text and CAN embed row
|
|
168
|
+
// content (a constraint violation quotes the offending key), and these logs are
|
|
169
|
+
// deploy/cron sinks that get shipped around. One line, capped, is enough to
|
|
170
|
+
// identify the fault; the source_ref beside it is what actually locates it. The
|
|
171
|
+
// DB corpus makes this matter more than it looks — its prose is session-log and
|
|
172
|
+
// memory-derived text the file elsewhere treats as scrub-sensitive.
|
|
173
|
+
const ERR_LOG_MAX = 300;
|
|
174
|
+
function errLine(e) {
|
|
175
|
+
const raw = e && e.message ? String(e.message) : String(e);
|
|
176
|
+
const flat = raw.replace(/\s+/g, ' ').trim();
|
|
177
|
+
return flat.length > ERR_LOG_MAX ? `${flat.slice(0, ERR_LOG_MAX)}… (truncated)` : flat;
|
|
178
|
+
}
|
|
179
|
+
|
|
166
180
|
async function main() {
|
|
167
181
|
const args = process.argv.slice(2);
|
|
168
182
|
const dryRun = args.includes('--dry-run');
|
|
@@ -191,32 +205,61 @@ async function main() {
|
|
|
191
205
|
const pool = requirePool();
|
|
192
206
|
const client = await pool.connect();
|
|
193
207
|
const totals = { inserted: 0, updated: 0, unchanged: 0, pruned: 0, deleted: 0 };
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
208
|
+
// task 1002859: ONE TRANSACTION PER FILE, not one for the whole run. The old
|
|
209
|
+
// shape opened a single BEGIN before this loop, so ANY failure on ANY file
|
|
210
|
+
// rolled back the ENTIRE index update — that is the "rolls back the entire
|
|
211
|
+
// re-index" in this task's title, and it is what turned a one-character data
|
|
212
|
+
// problem into a total, permanent recall outage. Per-file scope makes the
|
|
213
|
+
// blast radius one file: the other ~900 still land, and the failure is
|
|
214
|
+
// reported by name instead of vanishing.
|
|
215
|
+
const failures = [];
|
|
216
|
+
for (const f of allRows) {
|
|
217
|
+
try {
|
|
218
|
+
await client.query('BEGIN');
|
|
197
219
|
const s = await ingestFile(client, f.rows, f.sourceRef);
|
|
220
|
+
await client.query('COMMIT');
|
|
198
221
|
totals.inserted += s.inserted; totals.updated += s.updated;
|
|
199
222
|
totals.unchanged += s.unchanged; totals.pruned += s.pruned;
|
|
223
|
+
} catch (e) {
|
|
224
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
225
|
+
failures.push({ sourceRef: f.sourceRef, message: errLine(e) });
|
|
226
|
+
console.error(`search-ingest-md: SKIPPED ${f.sourceRef} — ${errLine(e)}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Delete chunks for doc sources that no longer exist in the tree. Its own
|
|
230
|
+
// transaction, and deliberately SKIPPED when any file failed: `present` is the
|
|
231
|
+
// list of sources we intended to write, so pruning against it after a partial
|
|
232
|
+
// run could delete chunks for a file that only failed transiently.
|
|
233
|
+
try {
|
|
234
|
+
if (failures.length === 0) {
|
|
235
|
+
await client.query('BEGIN');
|
|
236
|
+
const present = allRows.map((f) => f.sourceRef);
|
|
237
|
+
const del = await client.query(
|
|
238
|
+
`DELETE FROM search_chunks WHERE source_kind='doc' AND NOT (source_ref = ANY($1::text[]))`,
|
|
239
|
+
[present]
|
|
240
|
+
);
|
|
241
|
+
totals.deleted = del.rowCount || 0;
|
|
242
|
+
await client.query('COMMIT');
|
|
243
|
+
} else {
|
|
244
|
+
console.error(`search-ingest-md: vanished-source prune SKIPPED — ${failures.length} file(s) failed, so the present-sources list is incomplete`);
|
|
200
245
|
}
|
|
201
|
-
// Delete chunks for doc sources that no longer exist in the tree.
|
|
202
|
-
const present = allRows.map((f) => f.sourceRef);
|
|
203
|
-
const del = await client.query(
|
|
204
|
-
`DELETE FROM search_chunks WHERE source_kind='doc' AND NOT (source_ref = ANY($1::text[]))`,
|
|
205
|
-
[present]
|
|
206
|
-
);
|
|
207
|
-
totals.deleted = del.rowCount || 0;
|
|
208
|
-
await client.query('COMMIT');
|
|
209
246
|
} catch (e) {
|
|
210
247
|
await client.query('ROLLBACK').catch(() => {});
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
client.release();
|
|
214
|
-
await pool.end().catch(() => {});
|
|
215
|
-
return;
|
|
248
|
+
failures.push({ sourceRef: '(vanished-source prune)', message: errLine(e) });
|
|
249
|
+
console.error('search-ingest-md: vanished-source prune FAILED:', errLine(e));
|
|
216
250
|
}
|
|
217
251
|
client.release();
|
|
218
252
|
await pool.end().catch(() => {});
|
|
219
253
|
console.log(`search-ingest-md: inserted=${totals.inserted} updated=${totals.updated} unchanged=${totals.unchanged} pruned=${totals.pruned} deleted_sources_chunks=${totals.deleted}`);
|
|
254
|
+
if (failures.length > 0) {
|
|
255
|
+
// NON-SILENT (task 1003097's sibling half): the deploy step runs this
|
|
256
|
+
// best-effort, so a non-zero exit is the only signal that reaches a log a
|
|
257
|
+
// human reads. Name every casualty — "some files were skipped" is what let
|
|
258
|
+
// this hide.
|
|
259
|
+
console.error(`search-ingest-md: ${failures.length} file(s) FAILED and were skipped:`);
|
|
260
|
+
for (const f of failures) console.error(` ${f.sourceRef}: ${f.message}`);
|
|
261
|
+
process.exitCode = 1;
|
|
262
|
+
}
|
|
220
263
|
}
|
|
221
264
|
|
|
222
265
|
if (require.main === module) {
|
package/src/module-api.js
CHANGED
|
@@ -963,8 +963,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
963
963
|
// landed since 1.19.266 with no explicit bump. run 32404853216. (task 1002620)
|
|
964
964
|
// 1.19.268 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
965
965
|
// landed since 1.19.267 with no explicit bump. run 32407127474. (task 1002620)
|
|
966
|
+
// 1.19.269 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
967
|
+
// landed since 1.19.268 with no explicit bump. run 32427178123. (task 1002620)
|
|
968
|
+
// 1.19.270 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
969
|
+
// landed since 1.19.269 with no explicit bump. run 32428659648. (task 1002620)
|
|
966
970
|
// ---------------------------------------------------------------------------
|
|
967
|
-
const CORE_VERSION = '1.19.
|
|
971
|
+
const CORE_VERSION = '1.19.270'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
968
972
|
|
|
969
973
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
970
974
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// tests/recall_corpus_health.mjs — task 1003097.
|
|
2
|
+
//
|
|
3
|
+
// THE OUTAGE THIS PREVENTS RECURRING: /recall returned zero matches for EVERY
|
|
4
|
+
// query on live for weeks, and nothing anywhere reported a fault, because a DEAD
|
|
5
|
+
// INDEX and a genuine no-match are the same 200-with-an-empty-array on the wire.
|
|
6
|
+
// The CLI's one line even hedged toward the wrong reading — "or it may not be
|
|
7
|
+
// indexed yet" — so a total outage looked like "the project has never touched
|
|
8
|
+
// this subject", which is the most expensive possible wrong answer: sessions then
|
|
9
|
+
// re-derive work that already exists. (Cause: task 1002859.)
|
|
10
|
+
//
|
|
11
|
+
// The fix is that a zero result now says WHICH of three things happened. What we
|
|
12
|
+
// prove here (pure — no DB, no network):
|
|
13
|
+
// 1. The truth table: no chunks at all -> index_empty (an outage);
|
|
14
|
+
// chunks exist but none visible -> nothing_visible (scoping, and the OTHER
|
|
15
|
+
// candidate cause a diagnosis must rule out); populated + visible -> the one
|
|
16
|
+
// legitimate no_match.
|
|
17
|
+
// 2. Only no_match is marked healthy — so nothing downstream can mistake an
|
|
18
|
+
// outage for an answer.
|
|
19
|
+
// 3. The CLI renders an outage to STDERR with a NON-ZERO EXIT (a scheduled
|
|
20
|
+
// routine scripting /recall must be able to tell a broken index from an
|
|
21
|
+
// honest miss), a genuine miss to stdout with exit 0, and stays
|
|
22
|
+
// backward-compatible with a server that sends no diagnostic at all.
|
|
23
|
+
// 4. The outage text tells the reader what to run, and explicitly contradicts
|
|
24
|
+
// the wrong inference.
|
|
25
|
+
//
|
|
26
|
+
// Run: node tests/recall_corpus_health.mjs
|
|
27
|
+
|
|
28
|
+
import { strict as assert } from 'node:assert';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
const search = require('../modules/memory/search.js');
|
|
37
|
+
const { renderNoMatch } = require('../scripts/gds/recall.js');
|
|
38
|
+
const { classifyEmptyResult } = search;
|
|
39
|
+
|
|
40
|
+
let passed = 0;
|
|
41
|
+
let failed = 0;
|
|
42
|
+
function test(name, fn) {
|
|
43
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
44
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- 1/2. the truth table, and the scoping ----
|
|
48
|
+
|
|
49
|
+
test('nothing visible + index HAS content: a sub-Metic is not told which', () => {
|
|
50
|
+
// Panel finding (ADR 0016): distinguishing "empty" from "above your rank" is a
|
|
51
|
+
// fact ABOUT CONTENT THE CALLER CANNOT READ. This route family already answers
|
|
52
|
+
// one 404 for both "missing" and "not yours"; the diagnostic must not become
|
|
53
|
+
// the oracle that surface refuses to be.
|
|
54
|
+
const c = classifyEmptyResult({ any_visible: false, any_at_all: true, privileged: false });
|
|
55
|
+
assert.equal(c.state, 'nothing_visible_to_you');
|
|
56
|
+
assert.equal(c.healthy, false);
|
|
57
|
+
assert.match(c.message, /does not distinguish them/, 'says plainly that it is not telling');
|
|
58
|
+
assert.match(c.message, /Ask a Metic\+/, 'and routes the caller to someone who can find out');
|
|
59
|
+
assert.match(c.message, /do NOT read it as "the project has never touched this subject"/i);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('the SAFE subset never carries the unscoped probe, at any rank', () => {
|
|
63
|
+
for (const privileged of [false, true]) {
|
|
64
|
+
for (const any_at_all of [true, false]) {
|
|
65
|
+
const c = classifyEmptyResult({ any_visible: false, any_at_all, privileged });
|
|
66
|
+
assert.ok(!('any_at_all' in c), 'the unscoped existence probe must never reach the caller');
|
|
67
|
+
assert.ok(!('total_chunks' in c), 'and no global count either');
|
|
68
|
+
assert.ok(!('privileged' in c), 'nor the rank decision itself');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a Metic+ IS told which — empty index vs scoping wall', () => {
|
|
74
|
+
const empty = classifyEmptyResult({ any_visible: false, any_at_all: false, privileged: true });
|
|
75
|
+
assert.equal(empty.state, 'index_empty');
|
|
76
|
+
assert.equal(empty.healthy, false);
|
|
77
|
+
assert.match(empty.message, /outage, not an answer/);
|
|
78
|
+
assert.match(empty.message, /search-ingest-md\.js/, 'names what to run');
|
|
79
|
+
assert.match(empty.message, /search-ingest-db\.js/);
|
|
80
|
+
assert.match(empty.message, /exit codes/, 'and why the failure was missable');
|
|
81
|
+
|
|
82
|
+
const walled = classifyEmptyResult({ any_visible: false, any_at_all: true, privileged: true });
|
|
83
|
+
assert.equal(walled.state, 'nothing_visible');
|
|
84
|
+
assert.equal(walled.healthy, false);
|
|
85
|
+
assert.match(walled.message, /scoping result, not an empty corpus/);
|
|
86
|
+
assert.doesNotMatch(walled.message, /\d+ chunk/, 'no exact count — that was the oracle');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('anything visible → no_match, the ONLY healthy zero', () => {
|
|
90
|
+
for (const privileged of [false, true]) {
|
|
91
|
+
const c = classifyEmptyResult({ any_visible: true, any_at_all: true, privileged });
|
|
92
|
+
assert.equal(c.state, 'no_match');
|
|
93
|
+
assert.equal(c.healthy, true);
|
|
94
|
+
assert.match(c.message, /genuine miss/);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a probe that could not be READ is "unknown", never reported as empty', () => {
|
|
99
|
+
// corpusHealth absorbs its own errors, so a failed probe yields nulls.
|
|
100
|
+
// Reporting that as "the index is empty, run the ingest" would be a confident
|
|
101
|
+
// wrong answer — the exact failure mode this task exists to remove.
|
|
102
|
+
for (const input of [undefined, null, {}, { any_visible: null, privileged: true }]) {
|
|
103
|
+
const c = classifyEmptyResult(input);
|
|
104
|
+
assert.equal(c.state, 'unknown', `handled ${JSON.stringify(input)}`);
|
|
105
|
+
assert.equal(c.healthy, false, 'never claim health we cannot prove');
|
|
106
|
+
assert.doesNotMatch(c.message, /search-ingest-md/, 'must NOT prescribe a fix for a cause it has not established');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---- 3/4. what the builder actually sees ----
|
|
111
|
+
|
|
112
|
+
test('an outage goes to STDERR with a non-zero exit', () => {
|
|
113
|
+
const corpus = classifyEmptyResult({ any_visible: false, any_at_all: false, privileged: true });
|
|
114
|
+
const out = renderNoMatch({ q: 'board room ratification', corpus });
|
|
115
|
+
assert.equal(out.stream, 'err', 'an outage is not normal output');
|
|
116
|
+
assert.equal(out.exitCode, 1, 'a scripted caller must be able to detect it');
|
|
117
|
+
const text = out.lines.join('\n');
|
|
118
|
+
assert.match(text, /RECALL IS NOT ANSWERING/);
|
|
119
|
+
assert.match(text, /the reason is not your query/);
|
|
120
|
+
assert.match(text, /Do NOT read this as "the project has never touched this subject"/,
|
|
121
|
+
'the wrong inference must be contradicted explicitly — it is the whole cost of this bug');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a genuine miss stays ordinary stdout with exit 0', () => {
|
|
125
|
+
const corpus = { ...classifyEmptyResult({ any_visible: true, any_at_all: true, privileged: false }), last_indexed_at: '2026-08-20T00:00:00.000Z' };
|
|
126
|
+
const out = renderNoMatch({ q: 'nonexistent gibberish', corpus });
|
|
127
|
+
assert.equal(out.stream, 'out');
|
|
128
|
+
assert.equal(out.exitCode, 0);
|
|
129
|
+
const text = out.lines.join('\n');
|
|
130
|
+
assert.doesNotMatch(text, /RECALL IS NOT ANSWERING/, 'never cry outage on an honest miss');
|
|
131
|
+
assert.match(text, /Last indexed: 2026-08-20/, 'freshness is still surfaced');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a server that sends no diagnostic keeps the previous behaviour', () => {
|
|
135
|
+
// Backward compatibility matters here in particular: the fix ships before the
|
|
136
|
+
// pin moves, so a new CLI will talk to an OLD server for a while.
|
|
137
|
+
const out = renderNoMatch({ q: 'anything', corpus: undefined });
|
|
138
|
+
assert.equal(out.stream, 'out');
|
|
139
|
+
assert.equal(out.exitCode, 0);
|
|
140
|
+
assert.match(out.lines.join('\n'), /No matches for "anything"/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---- wiring ----
|
|
144
|
+
|
|
145
|
+
test('the route asks for the diagnostic ONLY on the zero-result path', () => {
|
|
146
|
+
// A healthy search must not pay for an extra aggregate.
|
|
147
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/memory/routes/search.js'), 'utf8');
|
|
148
|
+
const idx = src.indexOf('search.corpusHealth');
|
|
149
|
+
assert.ok(idx > 0, 'the route consults corpusHealth');
|
|
150
|
+
const before = src.slice(Math.max(0, idx - 300), idx);
|
|
151
|
+
assert.match(before, /if \(results\.length === 0\)/, 'gated on the zero path');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the response literal stays STATIC so the OpenAPI schema survives', () => {
|
|
155
|
+
// A conditional spread in res.json() defeated gen-api-docs's literal parser
|
|
156
|
+
// and silently DELETED the whole PostSearchResponse schema from the spec.
|
|
157
|
+
// `corpus` is always a key (undefined on a hit — JSON.stringify drops it), so
|
|
158
|
+
// the wire shape of a successful search is unchanged and the schema holds.
|
|
159
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/memory/routes/search.js'), 'utf8');
|
|
160
|
+
assert.match(src, /res\.json\(\{ query: q, count: results\.length, total_full_tokens: totalFullTokens, results, corpus \}\)/,
|
|
161
|
+
'the search response must be a flat, static object literal');
|
|
162
|
+
assert.doesNotMatch(src, /\.\.\.\(corpus \?/, 'no conditional spread in the response literal');
|
|
163
|
+
const spec = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs/api/openapi.json'), 'utf8'));
|
|
164
|
+
const schema = spec.components.schemas.PostSearchResponse;
|
|
165
|
+
assert.ok(schema, 'PostSearchResponse must still exist in the spec');
|
|
166
|
+
assert.ok('corpus' in schema.properties, 'and now document the corpus diagnostic');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('corpusHealth probes EXISTENCE, scoped with the same predicate as the search', () => {
|
|
170
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/memory/search.js'), 'utf8');
|
|
171
|
+
const fn = src.slice(src.indexOf('async function corpusHealth'));
|
|
172
|
+
const body = fn.slice(0, fn.indexOf('\n}'));
|
|
173
|
+
// EXISTS, not COUNT: during an outage EVERY search is a zero-result search, so
|
|
174
|
+
// a full-table scan here would run on every request from every builder for the
|
|
175
|
+
// duration. EXISTS short-circuits at the first row and stays flat as the
|
|
176
|
+
// corpus grows.
|
|
177
|
+
assert.doesNotMatch(body, /COUNT\(\*\)/, 'no full-table count on the zero path');
|
|
178
|
+
assert.match(body, /EXISTS \(/, 'existence probes instead');
|
|
179
|
+
assert.match(body, /min_rank = ANY/, 'the visible probe is rank-scoped');
|
|
180
|
+
assert.match(body, /owner_builder_id IS NULL OR owner_builder_id/, 'with the search\'s own ownership predicate');
|
|
181
|
+
assert.match(body, /MAX\(updated_at\)/, 'freshness, also scoped');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('the route hands the caller ONLY the classifier output', () => {
|
|
185
|
+
// Spreading `health` into the response would put the unscoped probe back on
|
|
186
|
+
// the wire — the regression this ordering exists to prevent.
|
|
187
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/memory/routes/search.js'), 'utf8');
|
|
188
|
+
assert.match(src, /corpus = search\.classifyEmptyResult\(health\);/);
|
|
189
|
+
assert.doesNotMatch(src, /\.\.\.health/, 'never spread the raw health object into the response');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
console.log('');
|
|
193
|
+
console.log(`recall_corpus_health: ${passed} passed, ${failed} failed`);
|
|
194
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// tests/search_ingest_nul.mjs — task 1002859.
|
|
2
|
+
//
|
|
3
|
+
// THE OUTAGE: /recall returned zero matches for EVERY query on live (task
|
|
4
|
+
// 1003097), because the doc index was never written. Postgres rejects U+0000 in
|
|
5
|
+
// a text column ('invalid byte sequence for encoding "UTF8": 0x00'), both
|
|
6
|
+
// ingesters handed chunk bodies straight to an INSERT, and both wrapped the
|
|
7
|
+
// WHOLE run in ONE transaction. So one NUL byte in one chunk of one file rolled
|
|
8
|
+
// back the entire index update — and the deploy's search-index step logs
|
|
9
|
+
// non-fatally, so nothing surfaced. A corpus that starts empty (a restore leaves
|
|
10
|
+
// the table empty and expects the ingest to rebuild it) then stays empty
|
|
11
|
+
// forever, and the route answers a legitimate-looking 200 with no results.
|
|
12
|
+
//
|
|
13
|
+
// Same bug class had already been fixed once on a different path: commit 75742cb
|
|
14
|
+
// (task 1002507) stripped NUL from ship.js snippetOf after a UTF-16LE `wsl
|
|
15
|
+
// --list` read voided a session digest — and left every other path exposed.
|
|
16
|
+
//
|
|
17
|
+
// What we prove here (pure — no DB):
|
|
18
|
+
// 1. The sanitizer strips U+0000, and strips lone surrogates too (the same
|
|
19
|
+
// wrong-encoding cause, also unencodable as UTF-8).
|
|
20
|
+
// 2. It runs at the SHARED funnel (search-chunker), so BOTH ingesters are
|
|
21
|
+
// covered and the guard cannot be added to one path and forgotten on the
|
|
22
|
+
// other — which is exactly how this survived the first fix.
|
|
23
|
+
// 3. Nothing a reader wants is lost: sanitizing is a no-op on clean prose.
|
|
24
|
+
// 4. A NUL in a HEADING or a front-matter title is stripped too — title and
|
|
25
|
+
// heading_path are Postgres text columns as well, so sanitizing only the
|
|
26
|
+
// body would have left two live vectors.
|
|
27
|
+
// 5. Both ingesters now scope a transaction PER SOURCE, and report failures by
|
|
28
|
+
// name with a non-zero exit — the blast radius of a bad byte is one file,
|
|
29
|
+
// not the whole corpus, and it is no longer silent.
|
|
30
|
+
//
|
|
31
|
+
// Run: node tests/search_ingest_nul.mjs
|
|
32
|
+
|
|
33
|
+
import { strict as assert } from 'node:assert';
|
|
34
|
+
import { createRequire } from 'node:module';
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
const chunker = require('../modules/memory/search-chunker.js');
|
|
42
|
+
const { chunkMarkdown, sanitizeForText } = chunker;
|
|
43
|
+
|
|
44
|
+
// Never write a literal control character into a source file — build it.
|
|
45
|
+
const NUL = String.fromCharCode(0);
|
|
46
|
+
|
|
47
|
+
let passed = 0;
|
|
48
|
+
let failed = 0;
|
|
49
|
+
function test(name, fn) {
|
|
50
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
51
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- 1. the sanitizer ----
|
|
55
|
+
|
|
56
|
+
test('sanitizeForText strips U+0000', () => {
|
|
57
|
+
assert.equal(sanitizeForText(`a${NUL}b`), 'ab');
|
|
58
|
+
assert.equal(sanitizeForText(`${NUL}${NUL}lead`), 'lead');
|
|
59
|
+
assert.equal(sanitizeForText(`trail${NUL}`), 'trail');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('sanitizeForText strips LONE surrogates but keeps valid pairs', () => {
|
|
63
|
+
// Unpaired halves cannot be encoded as UTF-8 — same cause (a wrong-encoding
|
|
64
|
+
// read), same Postgres rejection.
|
|
65
|
+
assert.equal(sanitizeForText('a\uD800b'), 'ab', 'lone high surrogate');
|
|
66
|
+
assert.equal(sanitizeForText('a\uDC00b'), 'ab', 'lone low surrogate');
|
|
67
|
+
// A real astral character is a VALID pair and must survive untouched.
|
|
68
|
+
assert.equal(sanitizeForText('emoji \u{1F600} ok'), 'emoji \u{1F600} ok');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('sanitizeForText is a no-op on clean prose and non-strings', () => {
|
|
72
|
+
const clean = '# Title\n\nSome prose with `code`, an emoji \u{1F680}, and accents: éàü.\n';
|
|
73
|
+
assert.equal(sanitizeForText(clean), clean);
|
|
74
|
+
assert.equal(sanitizeForText(null), null);
|
|
75
|
+
assert.equal(sanitizeForText(undefined), undefined);
|
|
76
|
+
assert.equal(sanitizeForText(42), 42);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ---- 2/4. it runs at the shared funnel, and covers title + heading too ----
|
|
80
|
+
|
|
81
|
+
test('chunkMarkdown strips NUL from body, title AND heading_path', () => {
|
|
82
|
+
const content = `# Ti${NUL}tle\n\n## Sec${NUL}tion\n\nbo${NUL}dy prose here, long enough to be a chunk.\n`;
|
|
83
|
+
const { title, chunks } = chunkMarkdown({ path: 'docs/x.md', content });
|
|
84
|
+
assert.ok(chunks.length > 0, 'produced at least one chunk');
|
|
85
|
+
assert.equal(title.includes(NUL), false, `title still carries NUL: ${JSON.stringify(title)}`);
|
|
86
|
+
for (const c of chunks) {
|
|
87
|
+
assert.equal(c.body.includes(NUL), false, 'body still carries NUL');
|
|
88
|
+
assert.equal(String(c.heading_path || '').includes(NUL), false, 'heading_path still carries NUL');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a front-matter title carrying NUL is sanitized', () => {
|
|
93
|
+
const content = `---\ntitle: Fr${NUL}ont\n---\n\nsome body prose here for a chunk.\n`;
|
|
94
|
+
const { title } = chunkMarkdown({ path: 'docs/y.md', content });
|
|
95
|
+
assert.equal(title.includes(NUL), false);
|
|
96
|
+
assert.equal(title, 'Front');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the guard lives at the SHARED funnel, so both ingesters inherit it', () => {
|
|
100
|
+
// The first fix of this bug class (task 1002507) was local to ship.js and left
|
|
101
|
+
// every other path exposed. Assert structurally that neither ingester relies
|
|
102
|
+
// on its own private guard: both must reach the sanitizer through chunkMarkdown.
|
|
103
|
+
for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
|
|
104
|
+
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
105
|
+
assert.match(src, /chunkMarkdown\(/, `${rel} must chunk through the shared funnel`);
|
|
106
|
+
}
|
|
107
|
+
const chunkerSrc = fs.readFileSync(path.join(ROOT, 'modules/memory/search-chunker.js'), 'utf8');
|
|
108
|
+
assert.match(chunkerSrc, /sanitizeForText\(content\)/, 'the funnel must sanitize its INPUT, before any parsing');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('chunkFile (the md ingester\'s row builder) emits NUL-free rows', () => {
|
|
112
|
+
const ingest = require('../scripts/gds/search-ingest-md.js');
|
|
113
|
+
const tmp = fs.mkdtempSync(path.join(ROOT, '.tmp-nul-'));
|
|
114
|
+
try {
|
|
115
|
+
const f = path.join(tmp, 'poisoned.md');
|
|
116
|
+
fs.writeFileSync(f, `# He${NUL}ad\n\nbo${NUL}dy text long enough to survive chunking.\n`);
|
|
117
|
+
const rows = ingest.chunkFile(f);
|
|
118
|
+
assert.ok(rows.length > 0);
|
|
119
|
+
for (const r of rows) {
|
|
120
|
+
assert.equal(r.body.includes(NUL), false, 'row body must be clean before the INSERT');
|
|
121
|
+
assert.equal(String(r.title || '').includes(NUL), false);
|
|
122
|
+
assert.equal(String(r.heading_path || '').includes(NUL), false);
|
|
123
|
+
assert.match(r.content_hash, /^[0-9a-f]{64}$/, 'hash is of the SANITIZED body');
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---- 5. blast radius + non-silence ----
|
|
131
|
+
|
|
132
|
+
test('both ingesters scope a transaction PER SOURCE, not one per run', () => {
|
|
133
|
+
// This is the "rolls back the entire re-index" half of the bug. A single BEGIN
|
|
134
|
+
// before the loop is what turned one bad byte into a total outage.
|
|
135
|
+
for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
|
|
136
|
+
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
137
|
+
// Anchor on the loop that actually WRITES (the one calling ingestFile /
|
|
138
|
+
// ingestRows), not the first `for (const f of ...)` in the file — the
|
|
139
|
+
// dry-run sample listing uses that shape too.
|
|
140
|
+
const writeIdx = src.search(/await ingest(File|Rows)\(client/); // the CALL, not the definition
|
|
141
|
+
assert.ok(writeIdx > 0, `${rel}: found the write call`);
|
|
142
|
+
const loopIdx = src.lastIndexOf('for (', writeIdx);
|
|
143
|
+
assert.ok(loopIdx > 0 && writeIdx - loopIdx < 400, `${rel}: the write is inside a loop`);
|
|
144
|
+
const loopBody = src.slice(loopIdx, writeIdx + 700);
|
|
145
|
+
assert.match(loopBody, /BEGIN/, `${rel}: BEGIN must be INSIDE the per-source loop`);
|
|
146
|
+
assert.match(loopBody, /COMMIT/, `${rel}: COMMIT must be inside the loop too`);
|
|
147
|
+
assert.match(loopBody, /ROLLBACK/, `${rel}: a failing source rolls back only itself`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('a skipped source is named and forces a non-zero exit', () => {
|
|
152
|
+
// The deploy/cron step runs these best-effort, so the exit code is the only
|
|
153
|
+
// signal that reaches a log a human reads. "Some files were skipped" is what
|
|
154
|
+
// let this hide for weeks — every casualty must be named.
|
|
155
|
+
for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
|
|
156
|
+
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
157
|
+
assert.match(src, /failures\.push\(/, `${rel}: collects casualties`);
|
|
158
|
+
assert.match(src, /FAILED and were skipped/, `${rel}: reports them as a group`);
|
|
159
|
+
assert.match(src, /for \(const f of failures\) console\.error/, `${rel}: names each one`);
|
|
160
|
+
assert.match(src, /process\.exitCode = 1/, `${rel}: exits non-zero so the deploy log shows it`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('the vanished-source prune is SKIPPED after any failure', () => {
|
|
165
|
+
// present/presentRefs is the list we MEANT to write. Pruning against it on a
|
|
166
|
+
// partial run would delete chunks for a source that only failed transiently —
|
|
167
|
+
// turning a recoverable skip into data loss.
|
|
168
|
+
for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
|
|
169
|
+
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
170
|
+
assert.match(src, /failures\.length === 0/, `${rel}: prune is gated on a clean run`);
|
|
171
|
+
assert.match(src, /prune SKIPPED/, `${rel}: and says so when it skips`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('the driver error text reaching a log is bounded and single-line', () => {
|
|
176
|
+
// Panel finding: a Postgres error message is unbounded upstream text and CAN
|
|
177
|
+
// embed row content (a constraint violation quotes the offending key), and
|
|
178
|
+
// these are deploy/cron log sinks. The DB corpus makes it matter more — its
|
|
179
|
+
// prose is session-log/memory-derived text the file treats as scrub-sensitive.
|
|
180
|
+
for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
|
|
181
|
+
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
182
|
+
assert.match(src, /const ERR_LOG_MAX = \d+;/, `${rel}: declares a cap`);
|
|
183
|
+
assert.match(src, /function errLine\(e\)/, `${rel}: has the single formatter`);
|
|
184
|
+
assert.match(src, /truncated/, `${rel}: says when it truncated`);
|
|
185
|
+
// And nothing may still interpolate the raw driver message directly.
|
|
186
|
+
assert.doesNotMatch(src, /e && e\.message \? e\.message : (e|String\(e\))/,
|
|
187
|
+
`${rel}: every error path must go through errLine`);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
console.log('');
|
|
192
|
+
console.log(`search_ingest_nul: ${passed} passed, ${failed} failed`);
|
|
193
|
+
process.exit(failed === 0 ? 0 : 1);
|