@velaro/cli 1.4.95 → 1.4.97

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.
@@ -1,5 +1,6 @@
1
1
  import { getCredentials } from '../api.js';
2
2
  import { readConfig, ENVS } from '../config.js';
3
+ import { completeLogSearch, describeCompleteness, describeRawMeta, fetchJsonRetry } from '../log-search.js';
3
4
 
4
5
  // ── helpers ──────────────────────────────────────────────────────────────────
5
6
 
@@ -56,43 +57,65 @@ async function requireAdmin(creds) {
56
57
  // ── search handler ────────────────────────────────────────────────────────────
57
58
 
58
59
  async function searchLogs(argv) {
60
+ try { await searchLogsInner(argv); } catch (err) { console.error(err?.message ?? String(err)); process.exit(1); }
61
+ }
62
+
63
+ async function searchLogsInner(argv) {
59
64
  const creds = await getCredentials();
60
65
  await requireAdmin(creds);
61
66
  const msgBase = await getMsgBase(argv);
62
67
 
63
- const from = new Date(Date.now() - parseDuration(argv.last ?? '2h'));
64
- const params = new URLSearchParams({ from: from.toISOString(), take: String(argv.take ?? 50) });
65
- if (argv.query) params.set('q', argv.query);
66
- if (argv.level) params.set('level', argv.level.toUpperCase());
67
- if (argv.integration) params.set('integration', argv.integration);
68
- if (argv.site) params.set('siteId', String(argv.site));
69
- if (argv.source) params.set('source', argv.source);
70
- if (argv.platform) params.set('platform', argv.platform);
71
- if (argv.environment) params.set('environment', argv.environment);
72
- if (argv.correlationId) params.set('correlationId', argv.correlationId);
73
- if (argv.regex) params.set('regex', 'true');
74
-
75
- const res = await fetch(`${msgBase}/superadmin/logs/search?${params}`, {
76
- headers: {
77
- Authorization: `Bearer ${creds.velaroToken}`,
78
- 'X-Internal-SiteId': String(creds.siteId),
79
- },
80
- signal: AbortSignal.timeout(20000),
81
- });
82
-
83
- if (!res.ok) {
84
- console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
68
+ const to = argv.to ? new Date(argv.to) : new Date();
69
+ const from = argv.from ? new Date(argv.from) : new Date(to.getTime() - parseDuration(argv.last ?? '2h'));
70
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from) {
71
+ console.error('Invalid time window: --from/--to must be ISO timestamps with --to after --from.');
85
72
  process.exit(1);
86
73
  }
87
-
88
- const entries = extractLogEntries(await res.json());
89
- if (!entries.length) {
90
- console.log('No results.');
74
+ const headers = { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) };
75
+ // 40 s: the server's own scan budget is 12 s (+6 s AI search) per request, and a 429 (scan gate full) is retried with Retry-After.
76
+ const getJson = (query) => fetchJsonRetry(`${msgBase}/superadmin/logs/search?${query}`, () => ({ headers, signal: AbortSignal.timeout(40000) }));
77
+
78
+ // --raw: the server's own q= search (kept for parity). Proven 2026-10-14 to under-report (q=[HttpFallback]
79
+ // returned 0-2 rows while 136 existed), so it now always prints the server's searchPath/scannedRows/truncated.
80
+ if (argv.raw) {
81
+ const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: String(argv.take ?? 50) });
82
+ if (argv.query) params.set('q', argv.query);
83
+ if (argv.level) params.set('level', argv.level.toUpperCase());
84
+ if (argv.integration) params.set('integration', argv.integration);
85
+ if (argv.site) params.set('siteId', String(argv.site));
86
+ if (argv.source) params.set('source', argv.source);
87
+ if (argv.platform) params.set('platform', argv.platform);
88
+ if (argv.environment) params.set('environment', argv.environment);
89
+ if (argv.correlationId) params.set('correlationId', argv.correlationId);
90
+ if (argv.regex) params.set('regex', 'true');
91
+ const resp = await getJson(params.toString());
92
+ const entries = extractLogEntries(resp);
93
+ console.log(`${DIM}${describeRawMeta(resp)}${RESET}`);
94
+ if (!entries.length) { console.log('No results (raw server search; see the line above before trusting that).'); return; }
95
+ console.log(`\n${DIM}${entries.length} result(s) from ${from.toISOString().slice(0, 16)} UTC${RESET}\n`);
96
+ for (const e of entries) printEntry(e);
97
+ console.log('');
91
98
  return;
92
99
  }
93
100
 
94
- console.log(`\n${DIM}${entries.length} result(s) from ${from.toISOString().slice(0, 16)} UTC${RESET}\n`);
95
- for (const e of entries) printEntry(e);
101
+ // Default: COMPLETE read. Never sends q=: filters level/site/source/... server side, bisects any slice the
102
+ // server reports as capped (includeTotal), filters text client side, and says COMPLETE or INCOMPLETE.
103
+ const result = await completeLogSearch({
104
+ fetchJson: getJson,
105
+ from, to,
106
+ params: { level: argv.level, siteId: argv.site, source: argv.source, integration: argv.integration, platform: argv.platform, environment: argv.environment, correlationId: argv.correlationId },
107
+ contains: argv.regex ? undefined : argv.query,
108
+ regex: argv.regex ? argv.query : undefined,
109
+ logger: argv.logger,
110
+ maxRequests: argv.maxRequests ?? 200,
111
+ maxSeconds: argv.maxSeconds ?? 120,
112
+ wanted: argv.take ?? 50,
113
+ });
114
+ console.log(`${result.complete ? DIM : YEL}${describeCompleteness(result)}${RESET}`);
115
+ if (!result.entries.length) { console.log('No results.'); return; }
116
+ const shown = result.entries.slice(0, argv.take ?? 50);
117
+ console.log(`\n${DIM}showing ${shown.length} of ${result.entries.length} (newest first) from ${from.toISOString().slice(0, 16)} UTC${RESET}\n`);
118
+ for (const e of shown) printEntry(e);
96
119
  console.log('');
97
120
  }
98
121
 
@@ -215,21 +238,26 @@ async function diagnoseLogs(argv) {
215
238
 
216
239
  console.log(`\n${BOLD}── Bot Diagnostic — last ${window} ──────────────────────────────${RESET2}`);
217
240
 
218
- // Run error search and perf/routing search in parallel
219
- async function search(q) {
220
- const p = new URLSearchParams({ q, from: from.toISOString(), take: '50' });
221
- const r = await fetch(`${msgBase}/superadmin/logs/search?${p}`, {
241
+ // Run error search and perf/routing search in parallel. FIXED 2026-10-14: the old calls searched the TEXT "level:ERROR" and the
242
+ // TEXT "WF-AI-ROUTE OR PERF-SLOW", which no log line contains, so this printed "No errors" no matter what. Errors are now the
243
+ // server-side level filter and the perf/routing pair is a regex; the server's scan metadata is surfaced so an unverified
244
+ // empty answer is never shown as a clean bill of health.
245
+ const notes = [];
246
+ async function search(label, p0) {
247
+ const p = new URLSearchParams({ from: from.toISOString(), take: '50', ...p0 });
248
+ const resp = await fetchJsonRetry(`${msgBase}/superadmin/logs/search?${p}`, () => ({
222
249
  headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
223
- signal: AbortSignal.timeout(15000),
224
- });
225
- if (!r.ok) return [];
226
- return extractLogEntries(await r.json());
250
+ signal: AbortSignal.timeout(40000),
251
+ })).catch((err) => { notes.push(`${label}: search failed (${err.message})`); return null; });
252
+ if (resp && !Array.isArray(resp) && resp.truncated !== false) notes.push(`${label}: ${describeRawMeta(resp)}`);
253
+ else if (Array.isArray(resp)) notes.push(`${label}: ${describeRawMeta(resp)}`);
254
+ return resp ? extractLogEntries(resp) : [];
227
255
  }
228
256
 
229
- const [errors, perfAndRouting] = await Promise.all([
230
- search('level:ERROR'),
231
- search('WF-AI-ROUTE OR PERF-SLOW'),
232
- ]);
257
+ // One at a time: the server runs at most 2 exact scans per instance.
258
+ const errors = await search('errors', { level: 'ERROR' });
259
+ const perfAndRouting = await search('perf/routing', { q: 'WF-AI-ROUTE|PERF-SLOW', regex: 'true' });
260
+ for (const n of notes) console.log(` \x1b[33m!\x1b[0m ${n}`);
233
261
 
234
262
  // ── Errors ──
235
263
  console.log(`\n${BOLD}Errors${RESET2}`);
@@ -696,7 +724,13 @@ export const logsCommand = {
696
724
  .option('correlationId', { describe: 'Exact correlation/trace ID filter', type: 'string' })
697
725
  .option('last', { describe: 'Time window: 30m, 2h, 1d', default: '2h', type: 'string' })
698
726
  .option('regex', { describe: 'Treat query as regex', boolean: true, default: false })
699
- .option('take', { describe: 'Max results (max 500)', default: 50, type: 'number' })
727
+ .option('logger', { describe: 'Logger-name substring (client side)', type: 'string' })
728
+ .option('from', { describe: 'ISO start time (overrides --last)', type: 'string' })
729
+ .option('to', { describe: 'ISO end time (default now)', type: 'string' })
730
+ .option('raw', { describe: 'Use the server q= search (can under-report; prints searchPath/scannedRows/truncated)', boolean: true, default: false })
731
+ .option('max-requests', { describe: 'Request budget for the complete read', default: 200, type: 'number' })
732
+ .option('max-seconds', { describe: 'Wall-clock budget for the complete read', default: 120, type: 'number' })
733
+ .option('take', { describe: 'Max lines to print (complete read) / server page size (--raw, max 500)', default: 50, type: 'number' })
700
734
  .option('env', { describe: 'staging or prod', type: 'string' }),
701
735
  handler: searchLogs,
702
736
  })
@@ -1,3 +1,4 @@
1
+ import { describeRawMeta, fetchRetry429 } from '../log-search.js';
1
2
  import { get, del, post, getCredentials } from '../api.js';
2
3
  import { readConfig, ENVS } from '../config.js';
3
4
 
@@ -236,19 +237,25 @@ export const opsCommand = {
236
237
  const stagingMsgBaseLocal = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
237
238
  const from1h = new Date(Date.now() - 3600000).toISOString();
238
239
 
239
- const [errRes, perfRes] = await Promise.all([
240
- fetch(`${stagingMsgBaseLocal}/superadmin/logs/search?q=level%3AERROR&from=${from1h}&take=5`, {
241
- headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
242
- signal: AbortSignal.timeout(12000),
243
- }),
244
- fetch(`${stagingMsgBaseLocal}/superadmin/logs/search?q=WF-AI-ROUTE+OR+PERF-SLOW&from=${from1h}&take=20`, {
245
- headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
246
- signal: AbortSignal.timeout(12000),
247
- }),
248
- ]);
249
-
250
- const errors = errRes.ok ? extractLogEntries(await errRes.json().catch(() => [])) : [];
251
- const perfLogs = perfRes.ok ? extractLogEntries(await perfRes.json().catch(() => [])) : [];
240
+ // One search at a time: the server runs at most 2 exact scans per instance. 429 (gate full) is retried honouring Retry-After.
241
+ const errRes = await fetchRetry429(`${stagingMsgBaseLocal}/superadmin/logs/search?level=ERROR&from=${from1h}&take=5`, () => ({
242
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
243
+ signal: AbortSignal.timeout(40000),
244
+ }));
245
+ const perfRes = await fetchRetry429(`${stagingMsgBaseLocal}/superadmin/logs/search?q=${encodeURIComponent('WF-AI-ROUTE|PERF-SLOW')}&regex=true&from=${from1h}&take=20`, () => ({
246
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
247
+ signal: AbortSignal.timeout(40000),
248
+ }));
249
+
250
+ // FIXED 2026-10-14: both searches used to look for the literal TEXT "level:ERROR" / "WF-AI-ROUTE OR PERF-SLOW" (never in any log
251
+ // line), so this check always said "0 errors". Errors are now the server-side level filter, perf/routing a regex, and an
252
+ // unverified or failed scan is reported instead of being counted as a clean result.
253
+ const errBody = errRes.ok ? await errRes.json().catch(() => null) : null;
254
+ const perfBody = perfRes.ok ? await perfRes.json().catch(() => null) : null;
255
+ const errors = errBody ? extractLogEntries(errBody) : [];
256
+ const perfLogs = perfBody ? extractLogEntries(perfBody) : [];
257
+ const scanNote = (label, body, ok) => (!ok || !body) ? `${label}: search failed or timed out (HTTP ${ok ? 'ok' : 'error'}), NOT verified` : (body.truncated === false ? null : `${label}: ${describeRawMeta(body)}`);
258
+ for (const n of [scanNote('errors', errBody, errRes.ok), scanNote('perf/routing', perfBody, perfRes.ok)].filter(Boolean)) console.log(` ${WARN} ${n}`);
252
259
 
253
260
  const perfCount = perfLogs.filter(e => (e.Message ?? e.message ?? '').includes('PERF-SLOW')).length;
254
261
  const routeCount = perfLogs.filter(e => (e.Message ?? e.message ?? '').includes('WF-AI-ROUTE')).length;
@@ -0,0 +1,209 @@
1
+ // Complete Velaro Logger reads (2026-10-14).
2
+ //
3
+ // WHY: velaro-messaging's /superadmin/logs/search and /internal/logs/search silently under-reported text queries.
4
+ // Proven live that day: q=[HttpFallback] over 3.9 h returned 0-2 rows while 136 existed, and q=AsrsNegotiateFailed
5
+ // (no level) returned 0 while 4 existed. The AI-search path only saw the newest 1,000 candidates and its answer was
6
+ // final; the exact path only saw the newest 5,000 rows of the window before filtering. Neither said so.
7
+ //
8
+ // TWO MODES, picked automatically from the first response:
9
+ // * CURSOR mode (server with velaro-messaging PR #2510, response carries `truncated`/`scannedRows`): the server scans the
10
+ // window newest-first applying level/site AND text (as an escaped regex, which also forces the exact path) while it
11
+ // scans, reports `truncated`, and returns `nextCursor` whenever more rows may exist. We follow the cursor until it is
12
+ // null, so a 2 h window is a handful of requests instead of hundreds.
13
+ // * BISECT mode (older server): never send q=; push level/site/etc server side; includeTotal=true reports
14
+ // totalCountCapped for a slice; a capped slice is bisected until none is capped; text is filtered client side.
15
+ // Either way the result says COMPLETE or INCOMPLETE, so a 0 is never ambiguous. Hard caps: maxRequests and maxSeconds.
16
+ //
17
+ // KEEP IN SYNC: this file is duplicated byte for byte in mcp/log-search.js (the npm packages ship separately);
18
+ // cli/lib/log-search.test.js fails if the two copies drift.
19
+
20
+ const MIN_SLICE_MS = 2000;
21
+
22
+ export const escapeRegex = (s) => String(s).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
23
+
24
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
+ const tsOf = (e) => { const t = Date.parse(e.timestamp ?? e.LogTimestamp ?? ''); return Number.isNaN(t) ? 0 : t; };
26
+
27
+ /**
28
+ * fetch that retries ONLY 429 (honouring Retry-After, seconds, capped at 30 s) for up to `retries` attempts and returns the final Response.
29
+ * `init` may be a function so each attempt gets a fresh AbortSignal.
30
+ */
31
+ export async function fetchRetry429(url, init = {}, { retries = 3, fetchImpl = fetch, sleepImpl = sleep } = {}) {
32
+ for (let attempt = 0; ; attempt++) {
33
+ const res = await fetchImpl(url, typeof init === 'function' ? init() : init);
34
+ if (res.status === 429 && attempt < retries) {
35
+ // Number(null) is 0, which would turn a 429 without Retry-After into an immediate retry: a missing header means the 5 s default.
36
+ const raw = res.headers?.get?.('retry-after');
37
+ const ra = raw == null || raw === '' ? NaN : Number(raw);
38
+ await sleepImpl(Math.min(Number.isFinite(ra) && ra >= 0 ? ra : 5, 30) * 1000);
39
+ continue;
40
+ }
41
+ return res;
42
+ }
43
+ }
44
+
45
+ /** fetchRetry429 + JSON. Any non-2xx throws an Error carrying `.status` and the response text. */
46
+ export async function fetchJsonRetry(url, init = {}, opts = {}) {
47
+ const res = await fetchRetry429(url, init, opts);
48
+ if (!res.ok) {
49
+ const err = new Error(`HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 400)}`);
50
+ err.status = res.status;
51
+ throw err;
52
+ }
53
+ return res.json();
54
+ }
55
+
56
+ /**
57
+ * @param {object} o
58
+ * @param {(query: string) => Promise<any>} o.fetchJson GET `...search?<query>` and return parsed JSON (caller adds auth/timeout)
59
+ * @param {Date} o.from
60
+ * @param {Date} o.to
61
+ * @param {object} [o.params] server-side filters: level, siteId, source, integration, platform, environment, correlationId
62
+ * @param {string} [o.contains] case-insensitive substring of the message
63
+ * @param {string} [o.regex] regex on the message
64
+ * @param {string} [o.logger] substring of the logger name
65
+ * @param {number} [o.maxRequests=200]
66
+ * @param {number} [o.maxSeconds=120] wall-clock budget for the whole read
67
+ * @param {number} [o.wanted=Infinity] CURSOR mode: stop once this many matches were collected (newest first); the result is then `satisfied`
68
+ */
69
+ export async function completeLogSearch({ fetchJson, from, to, params = {}, contains, regex, logger, maxRequests = 200, maxSeconds = 120, wanted = Infinity }) {
70
+ const server = {};
71
+ for (const k of ['level', 'siteId', 'source', 'integration', 'platform', 'environment', 'correlationId']) {
72
+ if (params[k] !== undefined && params[k] !== null && params[k] !== '') server[k] = String(k === 'level' ? String(params[k]).toUpperCase() : params[k]);
73
+ }
74
+ const byId = new Map();
75
+ const unresolved = [];
76
+ const started = Date.now();
77
+ let requests = 0;
78
+ let slices = 0;
79
+ let scanned = 0;
80
+ let budgetExhausted = false;
81
+ let timeExhausted = false;
82
+
83
+ const canRequest = () => {
84
+ if (requests >= maxRequests) { budgetExhausted = true; return false; }
85
+ if ((Date.now() - started) / 1000 >= maxSeconds && requests > 0) { timeExhausted = true; return false; }
86
+ return true;
87
+ };
88
+ const norm = (r) => (Array.isArray(r) ? { entries: r } : (r || {}));
89
+ const add = (e) => byId.set(e.id ?? `${e.timestamp}|${e.message}`, e);
90
+
91
+ // ---- text handling shared by both modes ----
92
+ const textQ = regex ?? (contains ? escapeRegex(contains) : undefined);
93
+ const textLogger = logger ? escapeRegex(logger) : undefined;
94
+
95
+ // ---- probe: first CURSOR-mode page, doubles as the new-server detector ----
96
+ const cursorQuery = (cursor) => {
97
+ const q = new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: '500', ...server });
98
+ if (textQ) q.set('q', textQ);
99
+ if (textLogger) q.set('logger', textLogger);
100
+ if (textQ || textLogger) q.set('regex', 'true'); // regex also forces the exact path (the AI-search path is never consulted)
101
+ q.set('fallthrough', 'false');
102
+ if (cursor) q.set('cursor', cursor);
103
+ return q.toString();
104
+ };
105
+
106
+ let mode = 'bisect';
107
+ let satisfied = false;
108
+ let cursorDone = false;
109
+
110
+ if (canRequest()) {
111
+ requests++;
112
+ const probe = norm(await fetchJson(cursorQuery(null)));
113
+ if (typeof probe.truncated === 'boolean') {
114
+ mode = 'cursor';
115
+ scanned += Number(probe.scannedRows ?? 0);
116
+ for (const e of probe.entries ?? []) add(e);
117
+ let page = probe;
118
+ let cursor = probe.nextCursor || null;
119
+ for (;;) {
120
+ // The ONLY way a cursor read is COMPLETE: the last page said truncated === false and carried no cursor. `truncated: true` with no
121
+ // cursor means the server stopped scanning (deadline/cap) and could not say where to continue: INCOMPLETE, never COMPLETE.
122
+ if (!cursor) {
123
+ if (page.truncated === false) cursorDone = true;
124
+ else unresolved.push('the server stopped scanning (truncated) without a cursor to continue from');
125
+ break;
126
+ }
127
+ if (byId.size >= wanted) { satisfied = true; break; }
128
+ if (!canRequest()) { unresolved.push(`cursor ${cursor} (${timeExhausted ? 'time budget' : 'request budget'})`); break; }
129
+ requests++;
130
+ const next = norm(await fetchJson(cursorQuery(cursor)));
131
+ scanned += Number(next.scannedRows ?? 0);
132
+ for (const e of next.entries ?? []) add(e);
133
+ if (next.nextCursor && next.nextCursor === cursor) {
134
+ unresolved.push('no progress: the server returned the cursor it was given (its scan budget ended before reaching any row)');
135
+ break;
136
+ }
137
+ page = next;
138
+ cursor = next.nextCursor || null;
139
+ }
140
+ slices = 1;
141
+ }
142
+ }
143
+
144
+ // ---- BISECT mode (older server) ----
145
+ if (mode === 'bisect' && !budgetExhausted) {
146
+ const call = async (a, b, cursor, total) => {
147
+ if (!canRequest()) return null;
148
+ requests++;
149
+ const q = new URLSearchParams({ from: a.toISOString(), to: b.toISOString(), take: '500', ...server });
150
+ if (total) q.set('includeTotal', 'true');
151
+ if (cursor) q.set('cursor', cursor);
152
+ return norm(await fetchJson(q.toString()));
153
+ };
154
+ const walk = async (a, b) => {
155
+ const r = await call(a, b, null, true);
156
+ if (!r) { unresolved.push(`${a.toISOString()}..${b.toISOString()} (${timeExhausted ? 'time' : 'request'} budget)`); return; }
157
+ if (r.totalCountCapped === true && (b - a) > MIN_SLICE_MS) {
158
+ const mid = new Date(a.getTime() + Math.floor((b - a) / 2));
159
+ await walk(a, mid);
160
+ await walk(mid, b);
161
+ return;
162
+ }
163
+ if (r.totalCountCapped === true) unresolved.push(`${a.toISOString()}..${b.toISOString()} (still capped at <=2s: a log storm; add level/site)`);
164
+ slices++;
165
+ scanned += Number(r.totalCount ?? (r.entries?.length ?? 0));
166
+ for (const e of r.entries ?? []) add(e);
167
+ let cursor = r.nextCursor;
168
+ while (cursor) {
169
+ const r2 = await call(a, b, cursor, false);
170
+ if (!r2) { unresolved.push(`${a.toISOString()}..${b.toISOString()} (budget while paging)`); break; }
171
+ for (const e of r2.entries ?? []) add(e);
172
+ cursor = r2.nextCursor;
173
+ }
174
+ };
175
+ await walk(from, to);
176
+ }
177
+
178
+ let rows = [...byId.values()];
179
+ const msg = (e) => String(e.message ?? e.Message ?? '');
180
+ if (mode === 'bisect') {
181
+ if (contains) { const c = contains.toLowerCase(); rows = rows.filter((e) => msg(e).toLowerCase().includes(c)); }
182
+ if (regex) { const re = new RegExp(regex, 'i'); rows = rows.filter((e) => re.test(msg(e))); }
183
+ if (logger) { const l = logger.toLowerCase(); rows = rows.filter((e) => String(e.logger ?? e.Logger ?? '').toLowerCase().includes(l)); }
184
+ }
185
+ rows.sort((x, y) => tsOf(y) - tsOf(x)); // compare as instants: fractional-second digit counts differ between rows
186
+
187
+ const complete = mode === 'cursor' ? (cursorDone && unresolved.length === 0) : (unresolved.length === 0 && !budgetExhausted && !timeExhausted);
188
+ return { entries: rows, fetched: byId.size, scanned, requests, slices, mode, complete, satisfied, unresolved, budgetExhausted, timeExhausted };
189
+ }
190
+
191
+ /** One line a human or an LLM can trust: says COMPLETE or INCOMPLETE and how much the SERVER actually scanned. */
192
+ export function describeCompleteness(r) {
193
+ const base = `${r.entries.length} matched (${r.fetched} fetched; server scanned ${r.scanned} rows) in ${r.requests} request(s), ${r.mode} mode`;
194
+ if (r.satisfied) return `${base}; showing the newest matches, more may exist beyond what was asked for (stopped early on purpose).`;
195
+ return r.complete
196
+ ? `${base}; COMPLETE (the whole window was covered), so 0 matches means nothing was logged.`
197
+ : `${base}; INCOMPLETE (${r.unresolved.length} part(s) not covered${r.budgetExhausted ? ', request budget exhausted' : ''}${r.timeExhausted ? ', time budget exhausted' : ''}): narrow the window or add level/site; a short or empty result is NOT proof of absence.`;
198
+ }
199
+
200
+ /** For raw (server-side q=) responses: surfaces searchPath/scannedRows/truncated so a 0 is never ambiguous. */
201
+ export function describeRawMeta(resp) {
202
+ if (!resp || Array.isArray(resp) || resp.scannedRows === undefined) {
203
+ return 'server did not report scan metadata (older deploy): treat an empty or short result as unverified, use the complete mode.';
204
+ }
205
+ const t = resp.truncated === true;
206
+ // exact=false: the answer involves the AI-search index, which is best-effort (written behind, can lag, misses text that starts mid-word).
207
+ const bestEffort = resp.exact === false ? ' exact=false (answered from the best-effort search index: it can lag and misses text that starts mid-word, so treat counts as a floor, not exact)' : '';
208
+ return `searchPath=${resp.searchPath} scannedRows=${resp.scannedRows} truncated=${t}${t ? ' (NOT proof of absence; continue with nextCursor or narrow the window)' : ''}${bestEffort}${resp.nextCursor ? ' nextCursor=present' : ''}`;
209
+ }
@@ -0,0 +1,374 @@
1
+ // Regression (2026-10-14): velaro-messaging's logs search under-reported text queries (q=[HttpFallback] returned 0-2 rows
2
+ // while 136 existed; q=AsrsNegotiateFailed with no level returned 0 while 4 existed) because each scan only examined the
3
+ // newest N rows of the window and never said so. completeLogSearch must find rows behind an arbitrary pile of newer rows,
4
+ // say COMPLETE only when the whole window was covered, honour its request/time budgets, and (older servers) never send q=.
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { completeLogSearch, describeCompleteness, describeRawMeta, escapeRegex, fetchJsonRetry } from './log-search.js';
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ /**
15
+ * Mock of the messaging search endpoint.
16
+ * legacy=true : OLD server: newest-first fetch capped at `cap` rows BEFORE any non-OData filter, no truncated/scannedRows,
17
+ * includeTotal reports totalCountCapped.
18
+ * legacy=false: NEW server (#2510): server-side text filtering while scanning (q as regex when regex=true), scan stops after
19
+ * `take` matches or `cap` examined rows, returns truncated/scannedRows and a nextCursor whenever more may exist.
20
+ */
21
+ function mockServer(rows, { cap = 5000, legacy = false } = {}) {
22
+ const calls = [];
23
+ const fetchJson = async (query) => {
24
+ const p = new URLSearchParams(query);
25
+ calls.push(p);
26
+ const from = new Date(p.get('from')).getTime();
27
+ const to = new Date(p.get('to')).getTime();
28
+ const lvl = p.get('level');
29
+ const site = p.get('siteId');
30
+ const take = Number(p.get('take'));
31
+ let pool = rows.filter((r) => (!lvl || r.level === lvl) && (!site || String(r.siteId) === site));
32
+ pool = pool.filter((r) => new Date(r.timestamp).getTime() >= from && new Date(r.timestamp).getTime() <= to);
33
+ pool.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
34
+
35
+ if (legacy) {
36
+ // The only request allowed to carry q is the first (the new-server probe), and then only with regex=true, which forces the exact
37
+ // path: the AI-search path (the one that under-reports) must never be reachable. Every bisect request after it has no q at all.
38
+ if (p.get('q') !== null) {
39
+ assert.equal(calls.length, 1, 'only the probe may carry q=; bisect requests must not');
40
+ assert.equal(p.get('regex'), 'true', 'a q= sent to an old server must force the exact path with regex=true');
41
+ }
42
+ const capped = pool.length >= cap;
43
+ const fetched = pool.slice(0, cap);
44
+ let start = 0;
45
+ if (p.get('cursor')) start = fetched.findIndex((r) => r.id === p.get('cursor')) + 1;
46
+ const page = fetched.slice(start, start + take);
47
+ const out = { entries: page, nextCursor: start + take < fetched.length ? page[page.length - 1].id : null };
48
+ if (p.get('includeTotal') === 'true') { out.totalCount = fetched.length; out.totalCountCapped = capped; }
49
+ return out;
50
+ }
51
+
52
+ const re = p.get('q') ? new RegExp(p.get('q'), 'i') : null;
53
+ const lre = p.get('logger') ? new RegExp(p.get('logger'), 'i') : null;
54
+ let startIdx = 0;
55
+ if (p.get('cursor')) startIdx = pool.findIndex((r) => r.id === p.get('cursor')) + 1;
56
+ const matches = [];
57
+ let examined = 0;
58
+ let more = false;
59
+ let capHit = false;
60
+ let lastId = null;
61
+ for (let i = startIdx; i < pool.length; i++) {
62
+ if (examined >= cap) { capHit = true; break; }
63
+ examined++;
64
+ lastId = pool[i].id;
65
+ const r = pool[i];
66
+ if ((!re || re.test(r.message)) && (!lre || lre.test(r.logger))) {
67
+ if (matches.length >= take) { more = true; break; }
68
+ matches.push(r);
69
+ }
70
+ }
71
+ let nextCursor = null;
72
+ if (more) nextCursor = matches[matches.length - 1].id;
73
+ else if (capHit) nextCursor = lastId;
74
+ return { entries: matches, nextCursor, searchPath: 'table-storage', scannedRows: examined, truncated: capHit };
75
+ };
76
+ return { fetchJson, calls };
77
+ }
78
+
79
+ function makeRows(n, needles, level = 'WARN') {
80
+ const base = Date.UTC(2026, 9, 14, 13, 45, 0);
81
+ const rows = [];
82
+ for (let i = 0; i < n; i++) {
83
+ rows.push({ id: `r${String(i).padStart(7, '0')}`, timestamp: new Date(base - i * 1000).toISOString(), level, siteId: 20519, logger: 'VisitorLoggingService', message: needles.has(i) ? '[AsrsNegotiateFailed] site=20715' : `noise ${i}` });
84
+ }
85
+ return rows;
86
+ }
87
+ const W = (rows) => [new Date(rows[rows.length - 1].timestamp), new Date(rows[0].timestamp)];
88
+
89
+ test('the single raw call misses a needle behind 9,000 newer rows (the live bug shape)', async () => {
90
+ const rows = makeRows(12000, new Set([9000]));
91
+ const { fetchJson } = mockServer(rows, { legacy: true });
92
+ const [from, to] = W(rows);
93
+ const raw = await fetchJson(new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: '500', includeTotal: 'true' }).toString());
94
+ assert.equal(raw.totalCountCapped, true);
95
+ assert.equal(raw.entries.some((e) => e.message.includes('AsrsNegotiate')), false);
96
+ });
97
+
98
+ // ---------------- CURSOR mode (new server) ----------------
99
+
100
+ test('CURSOR mode: finds a needle behind 9,000 newer rows via server-side text filtering and cursor paging, COMPLETE, few requests', async () => {
101
+ const rows = makeRows(12000, new Set([9000, 11000]));
102
+ const { fetchJson, calls } = mockServer(rows, { cap: 5000 });
103
+ const [from, to] = W(rows);
104
+ const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]' });
105
+ assert.equal(r.mode, 'cursor');
106
+ assert.equal(r.entries.length, 2);
107
+ assert.equal(r.complete, true);
108
+ assert.ok(r.requests <= 4, `expected a handful of requests, got ${r.requests}`); // 12,000 rows / 5,000-row scans, not hundreds
109
+ assert.equal(r.scanned, 12000); // the REAL server scannedRows, summed
110
+ assert.match(describeCompleteness(r), /COMPLETE/);
111
+ assert.doesNotMatch(describeCompleteness(r), /INCOMPLETE/);
112
+ // text is sent as an ESCAPED regex with regex=true (forces the exact path), and the AI-search fall-through is disabled
113
+ assert.equal(calls[0].get('regex'), 'true');
114
+ assert.equal(calls[0].get('q'), escapeRegex('[AsrsNegotiateFailed]'));
115
+ assert.equal(calls[0].get('fallthrough'), 'false');
116
+ });
117
+
118
+ test('CURSOR mode: an UNFILTERED default search is a couple of requests, not hundreds (wanted=50 stops after the first page)', async () => {
119
+ const rows = makeRows(84000, new Set());
120
+ const { fetchJson } = mockServer(rows, { cap: 60000 });
121
+ const [from, to] = W(rows);
122
+ const r = await completeLogSearch({ fetchJson, from, to, wanted: 50 });
123
+ assert.equal(r.mode, 'cursor');
124
+ assert.equal(r.requests, 1);
125
+ assert.equal(r.satisfied, true);
126
+ assert.ok(r.entries.length >= 50);
127
+ assert.match(describeCompleteness(r), /more may exist/);
128
+ });
129
+
130
+ test('CURSOR mode: a genuine 0 follows the cursor to the end and says COMPLETE', async () => {
131
+ const rows = makeRows(14000, new Set());
132
+ const { fetchJson } = mockServer(rows, { cap: 5000 });
133
+ const [from, to] = W(rows);
134
+ const r = await completeLogSearch({ fetchJson, from, to, contains: 'nope' });
135
+ assert.equal(r.entries.length, 0);
136
+ assert.equal(r.complete, true);
137
+ assert.match(describeCompleteness(r), /0 matches means nothing was logged/);
138
+ });
139
+
140
+ test('CURSOR mode: the request budget ending mid-cursor is INCOMPLETE, never a silent partial', async () => {
141
+ const rows = makeRows(20000, new Set([19000]));
142
+ const { fetchJson } = mockServer(rows, { cap: 5000 });
143
+ const [from, to] = W(rows);
144
+ const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]', maxRequests: 2 });
145
+ assert.equal(r.complete, false);
146
+ assert.equal(r.budgetExhausted, true);
147
+ assert.match(describeCompleteness(r), /INCOMPLETE/);
148
+ assert.match(describeCompleteness(r), /NOT proof of absence/);
149
+ });
150
+
151
+ test('CURSOR mode: the wall-clock budget ending mid-cursor is INCOMPLETE', async () => {
152
+ const rows = makeRows(20000, new Set([19000]));
153
+ const { fetchJson } = mockServer(rows, { cap: 5000 });
154
+ const slow = async (q) => { await new Promise((r) => setTimeout(r, 30)); return fetchJson(q); };
155
+ const [from, to] = W(rows);
156
+ const r = await completeLogSearch({ fetchJson: slow, from, to, contains: '[AsrsNegotiateFailed]', maxSeconds: 0.01 });
157
+ assert.equal(r.complete, false);
158
+ assert.equal(r.timeExhausted, true);
159
+ assert.match(describeCompleteness(r), /time budget exhausted/);
160
+ });
161
+
162
+ // ---------------- BISECT mode (older server) ----------------
163
+
164
+ test('BISECT mode (old server): finds the needle behind 9,000 newer rows, never sends q=, reports COMPLETE', async () => {
165
+ const rows = makeRows(12000, new Set([9000, 11000]));
166
+ const { fetchJson } = mockServer(rows, { cap: 5000, legacy: true });
167
+ const [from, to] = W(rows);
168
+ const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]', maxRequests: 200 });
169
+ assert.equal(r.mode, 'bisect');
170
+ assert.equal(r.entries.length, 2);
171
+ assert.equal(r.complete, true);
172
+ assert.ok(r.slices > 1, 'capped slices must have been bisected');
173
+ });
174
+
175
+ test('BISECT mode: level and siteId are pushed server side; contains/regex/logger filter client side', async () => {
176
+ const rows = makeRows(300, new Set([10, 20]));
177
+ rows[5].logger = 'Other.Logger';
178
+ const { fetchJson, calls } = mockServer(rows, { cap: 5000, legacy: true });
179
+ const [from, to] = W(rows);
180
+ const r = await completeLogSearch({ fetchJson, from, to, params: { level: 'warn', siteId: 20519 }, regex: 'Asrs.*Failed', logger: 'visitorlogging' });
181
+ assert.equal(r.entries.length, 2);
182
+ assert.equal(calls.at(-1).get('level'), 'WARN');
183
+ assert.equal(calls.at(-1).get('siteId'), '20519');
184
+ assert.equal(r.complete, true);
185
+ });
186
+
187
+ test('BISECT mode: a request budget that runs out is INCOMPLETE', async () => {
188
+ const rows = makeRows(12000, new Set([9000]));
189
+ const { fetchJson } = mockServer(rows, { cap: 5000, legacy: true });
190
+ const [from, to] = W(rows);
191
+ const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]', maxRequests: 2 });
192
+ assert.equal(r.complete, false);
193
+ assert.equal(r.budgetExhausted, true);
194
+ });
195
+
196
+ // ---------------- shared behaviour ----------------
197
+
198
+ test('results are ordered by instant, not by string: mixed fractional-second digit counts', async () => {
199
+ const mk = (id, ts) => ({ id, timestamp: ts, level: 'WARN', siteId: 1, logger: 'x', message: 'm' });
200
+ // as strings "…:10Z" > "…:10.5Z" (because 'Z' > '.') although 10.5 s is LATER than 10 s
201
+ const rows = [mk('a', '2026-10-14T13:00:10Z'), mk('b', '2026-10-14T13:00:10.5Z'), mk('c', '2026-10-14T13:00:10.25Z')];
202
+ const fetchJson = async () => ({ entries: rows, nextCursor: null, searchPath: 'table-storage', scannedRows: 3, truncated: false });
203
+ const r = await completeLogSearch({ fetchJson, from: new Date('2026-10-14T13:00:00Z'), to: new Date('2026-10-14T13:01:00Z') });
204
+ assert.deepEqual(r.entries.map((e) => e.id), ['b', 'c', 'a']);
205
+ });
206
+
207
+ test('fetchJsonRetry honours 429 Retry-After, then succeeds; gives up after the retry budget', async () => {
208
+ const waits = [];
209
+ const mkRes = (status, retryAfter, body) => ({ status, ok: status >= 200 && status < 300, headers: { get: () => retryAfter }, json: async () => body, text: async () => 'busy' });
210
+ let n = 0;
211
+ const ok = await fetchJsonRetry('u', {}, { fetchImpl: async () => (++n < 3 ? mkRes(429, '2', null) : mkRes(200, null, { done: true })), sleepImpl: async (ms) => { waits.push(ms); } });
212
+ assert.deepEqual(ok, { done: true });
213
+ assert.deepEqual(waits, [2000, 2000]);
214
+
215
+ n = 0;
216
+ await assert.rejects(() => fetchJsonRetry('u', {}, { retries: 2, fetchImpl: async () => { n++; return mkRes(429, '1', null); }, sleepImpl: async () => {} }), (e) => e.status === 429);
217
+ assert.equal(n, 3);
218
+ });
219
+
220
+ test('describeRawMeta surfaces searchPath/scannedRows/truncated and warns on old deploys', () => {
221
+ assert.match(describeRawMeta({ entries: [], searchPath: 'ai-search', scannedRows: 1000, truncated: true }), /truncated=true.*NOT proof of absence/);
222
+ assert.match(describeRawMeta({ entries: [], searchPath: 'table-storage', scannedRows: 911, truncated: false }), /scannedRows=911 truncated=false/);
223
+ assert.match(describeRawMeta([]), /older deploy/);
224
+ assert.match(describeRawMeta({ entries: [] }), /older deploy/);
225
+ });
226
+
227
+ test('cli/lib/log-search.js and mcp/log-search.js are byte-identical (the npm packages ship separately)', () => {
228
+ const a = fs.readFileSync(path.join(here, 'log-search.js'), 'utf8');
229
+ const b = fs.readFileSync(path.join(here, '..', '..', 'mcp', 'log-search.js'), 'utf8');
230
+ assert.equal(a, b, 'copy cli/lib/log-search.js to mcp/log-search.js (or the reverse)');
231
+ });
232
+
233
+ test('no staff log check searches the literal TEXT "level:ERROR" or "X OR Y" (they always matched nothing and printed "no errors")', () => {
234
+ for (const rel of ['commands/logs.js', 'commands/ops.js', '../../mcp/staff-server.js', '../../mcp/server.js']) {
235
+ const src = fs.readFileSync(path.join(here, rel), 'utf8');
236
+ assert.ok(!/q=level%3AERROR/.test(src) && !/search\('level:ERROR'\)/.test(src), `${rel}: still searches the text "level:ERROR"`);
237
+ assert.ok(!/q=WF-AI-ROUTE\+OR\+PERF-SLOW/.test(src) && !/search\('WF-AI-ROUTE OR PERF-SLOW'\)/.test(src), `${rel}: still searches the text "WF-AI-ROUTE OR PERF-SLOW"`);
238
+ }
239
+ });
240
+
241
+ test('the complete-mode handlers use completeLogSearch and never set q= themselves', () => {
242
+ const cli = fs.readFileSync(path.join(here, 'commands', 'logs.js'), 'utf8');
243
+ const mcp = fs.readFileSync(path.join(here, '..', '..', 'mcp', 'staff-server.js'), 'utf8');
244
+ for (const [name, src] of [['cli', cli], ['mcp', mcp]]) {
245
+ const tail = src.slice(src.indexOf('Default: COMPLETE read'));
246
+ const handler = tail.slice(0, 1400);
247
+ assert.ok(handler.includes('completeLogSearch('), `${name}: complete mode must call completeLogSearch`);
248
+ assert.ok(!/params\.set\('q'/.test(handler), `${name}: complete mode must not set q= itself`);
249
+ }
250
+ });
251
+
252
+ test('timeouts: log searches allow the server scan budget (12 s + 6 s AI search), not the 20 s default', () => {
253
+ for (const rel of ['commands/logs.js', '../../mcp/staff-server.js']) {
254
+ assert.match(fs.readFileSync(path.join(here, rel), 'utf8'), /AbortSignal\.timeout\(40000\)/, `${rel}: log search timeout must be 40 s`);
255
+ }
256
+ });
257
+
258
+ // ---------------- Opus final check (2026-10-14): the cursor protocol must never turn a truncated scan into COMPLETE ----------------
259
+
260
+ test('CURSOR mode: truncated=true with NO cursor is INCOMPLETE, never COMPLETE (the server deadline fired before any row)', async () => {
261
+ const fetchJson = async () => ({ entries: [], nextCursor: null, searchPath: 'table-storage', scannedRows: 0, truncated: true });
262
+ const r = await completeLogSearch({ fetchJson, from: new Date('2026-10-14T13:00:00Z'), to: new Date('2026-10-14T13:45:00Z'), contains: 'x' });
263
+ assert.equal(r.mode, 'cursor');
264
+ assert.equal(r.complete, false);
265
+ assert.match(describeCompleteness(r), /INCOMPLETE/);
266
+ assert.match(r.unresolved.join(' '), /without a cursor/);
267
+ });
268
+
269
+ test('CURSOR mode: a LATER page that is truncated without a cursor is INCOMPLETE too (it needs truncated===false on the last page)', async () => {
270
+ let n = 0;
271
+ const fetchJson = async () => (++n === 1
272
+ ? { entries: [{ id: 'a', timestamp: '2026-10-14T13:00:00Z', message: 'm' }], nextCursor: 'c1', scannedRows: 10, truncated: true }
273
+ : { entries: [], nextCursor: null, scannedRows: 0, truncated: true });
274
+ const r = await completeLogSearch({ fetchJson, from: new Date('2026-10-14T13:00:00Z'), to: new Date('2026-10-14T13:45:00Z'), contains: 'm' });
275
+ assert.equal(r.complete, false);
276
+ assert.equal(r.requests, 2);
277
+ });
278
+
279
+ test('CURSOR mode: a last page that does not confirm truncated===false is not COMPLETE', async () => {
280
+ let n = 0;
281
+ const fetchJson = async () => (++n === 1
282
+ ? { entries: [], nextCursor: 'c1', scannedRows: 10, truncated: true }
283
+ : { entries: [], nextCursor: null, scannedRows: 10 }); // truncated missing: unknown, so not proof
284
+ const r = await completeLogSearch({ fetchJson, from: new Date('2026-10-14T13:00:00Z'), to: new Date('2026-10-14T13:45:00Z'), contains: 'm' });
285
+ assert.equal(r.complete, false);
286
+ });
287
+
288
+ test('CURSOR mode: the server handing back the SAME cursor means no progress: INCOMPLETE, and the loop stops', async () => {
289
+ let n = 0;
290
+ const fetchJson = async () => { n++; return { entries: [], nextCursor: 'stuck', scannedRows: 0, truncated: true }; };
291
+ const r = await completeLogSearch({ fetchJson, from: new Date('2026-10-14T13:00:00Z'), to: new Date('2026-10-14T13:45:00Z'), contains: 'm', maxRequests: 50 });
292
+ assert.equal(r.complete, false);
293
+ assert.match(r.unresolved.join(' '), /no progress/);
294
+ assert.ok(n <= 3, `must stop at the first repeated cursor, made ${n} requests`);
295
+ });
296
+
297
+ test('CURSOR mode: a page chain that ends with truncated===false and no cursor IS complete', async () => {
298
+ let n = 0;
299
+ const fetchJson = async () => (++n === 1
300
+ ? { entries: [], nextCursor: 'c1', scannedRows: 5000, truncated: true }
301
+ : { entries: [{ id: 'a', timestamp: '2026-10-14T13:00:00Z', message: 'm' }], nextCursor: null, scannedRows: 4000, truncated: false });
302
+ const r = await completeLogSearch({ fetchJson, from: new Date('2026-10-14T13:00:00Z'), to: new Date('2026-10-14T13:45:00Z'), contains: 'm' });
303
+ assert.equal(r.complete, true);
304
+ assert.equal(r.scanned, 9000);
305
+ assert.equal(r.entries.length, 1);
306
+ });
307
+
308
+ test('fetchRetry429 retries only 429 and returns the final response after the retry budget', async () => {
309
+ const { fetchRetry429 } = await import('./log-search.js');
310
+ let n = 0;
311
+ const waits = [];
312
+ const mk = (status) => ({ status, ok: status < 300, headers: { get: () => '1' } });
313
+ const res = await fetchRetry429('u', {}, { retries: 2, fetchImpl: async () => { n++; return mk(429); }, sleepImpl: async (ms) => { waits.push(ms); } });
314
+ assert.equal(res.status, 429);
315
+ assert.equal(n, 3);
316
+ assert.deepEqual(waits, [1000, 1000]);
317
+ n = 0;
318
+ const ok = await fetchRetry429('u', {}, { fetchImpl: async () => { n++; return mk(500); }, sleepImpl: async () => {} });
319
+ assert.equal(ok.status, 500);
320
+ assert.equal(n, 1); // a 500 is not retried
321
+ });
322
+
323
+ test('bot_full_diagnostic: errors use the server-side level=ERROR filter, patterns are a separate read; neither is the old text regex', () => {
324
+ for (const rel of ['../../mcp/staff-server.js', '../../mcp/server.js']) {
325
+ const src = fs.readFileSync(path.join(here, rel), 'utf8');
326
+ const a = src.indexOf('const velaroLoggerP = safeJson');
327
+ const body = src.slice(a, a + 2600);
328
+ assert.ok(body.includes('level=ERROR'), `${rel}: errors must use level=ERROR`);
329
+ assert.ok(body.includes("WF-AI-ROUTE|PERF-SLOW"), `${rel}: patterns are their own read`);
330
+ assert.ok(!body.includes("'ERROR|WF-AI-ROUTE|PERF-SLOW'"), `${rel}: still runs the regex ERROR|... over message text`);
331
+ assert.ok(/errRes[\s\S]*patRes/.test(body), `${rel}: two reads expected`);
332
+ }
333
+ });
334
+
335
+ test('both MCP servers retry 429 on their messaging calls (GETs)', () => {
336
+ for (const rel of ['../../mcp/staff-server.js', '../../mcp/server.js']) {
337
+ const src = fs.readFileSync(path.join(here, rel), 'utf8');
338
+ assert.ok(src.includes('fetchRetry429('), `${rel}: messagingApi must retry 429`);
339
+ }
340
+ });
341
+
342
+ test('describeRawMeta surfaces the best-effort caveat when exact=false (an AI-search-index answer is a floor, never exact)', () => {
343
+ const m = describeRawMeta({ entries: [], searchPath: 'ai-search', scannedRows: 400, truncated: false, exact: false });
344
+ assert.match(m, /exact=false/);
345
+ assert.match(m, /best-effort search index/);
346
+ assert.match(m, /floor, not exact/);
347
+ // an authoritative table answer carries no caveat
348
+ assert.doesNotMatch(describeRawMeta({ entries: [], searchPath: 'table-storage', scannedRows: 400, truncated: false, exact: true }), /exact=false/);
349
+ // an older server that does not send exact at all is not accused of being best-effort
350
+ assert.doesNotMatch(describeRawMeta({ entries: [], searchPath: 'table-storage', scannedRows: 400, truncated: false }), /exact=false/);
351
+ });
352
+
353
+ test('ops health and the diagnostics run their log searches ONE AT A TIME and retry 429 (the server runs 2 exact scans per instance)', () => {
354
+ const parallelSearch = /await Promise\.all\(\[\s*(search\(|messagingApi\(|fetch\()/;
355
+ for (const rel of ['commands/ops.js', 'commands/logs.js', '../../mcp/staff-server.js', '../../mcp/server.js']) {
356
+ const src = fs.readFileSync(path.join(here, rel), 'utf8');
357
+ assert.ok(!parallelSearch.test(src), rel + ': log searches must not be fired in parallel');
358
+ }
359
+ assert.ok(fs.readFileSync(path.join(here, 'commands', 'ops.js'), 'utf8').includes('fetchRetry429('));
360
+ });
361
+
362
+ test('fetchRetry429: a 429 with NO Retry-After waits the 5 s default (Number(null) is 0: it must not become an immediate retry)', async () => {
363
+ const { fetchRetry429 } = await import('./log-search.js');
364
+ const waits = [];
365
+ let n = 0;
366
+ const noHeader = (status) => ({ status, ok: status < 300, headers: { get: () => null } });
367
+ await fetchRetry429('u', {}, { fetchImpl: async () => (++n < 3 ? noHeader(429) : noHeader(200)), sleepImpl: async (ms) => { waits.push(ms); } });
368
+ assert.deepEqual(waits, [5000, 5000]);
369
+ // an explicit Retry-After: 0 is honoured as 0 (retry now), it is not mistaken for "missing"
370
+ const zero = [];
371
+ n = 0;
372
+ await fetchRetry429('u', {}, { fetchImpl: async () => (++n < 2 ? { status: 429, ok: false, headers: { get: () => '0' } } : noHeader(200)), sleepImpl: async (ms) => { zero.push(ms); } });
373
+ assert.deepEqual(zero, [0]);
374
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.95",
3
+ "version": "1.4.97",
4
4
  "description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
5
5
  "type": "module",
6
6
  "bin": {