@velaro/cli 1.4.96 → 1.4.98
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/bin/velaro.js +6 -0
- package/lib/commands/canned-learning.js +8 -5
- package/lib/commands/chat-window.js +47 -0
- package/lib/commands/db.js +1 -0
- package/lib/commands/deploy-state.js +26 -0
- package/lib/commands/logs.js +27 -21
- package/lib/commands/ops.js +32 -13
- package/lib/commands/safety.js +111 -0
- package/lib/deploy-state.js +408 -0
- package/lib/deploy-state.test.js +133 -0
- package/lib/log-search.js +159 -50
- package/lib/log-search.test.js +288 -43
- package/package.json +1 -1
package/lib/log-search.test.js
CHANGED
|
@@ -1,51 +1,86 @@
|
|
|
1
1
|
// Regression (2026-10-14): velaro-messaging's logs search under-reported text queries (q=[HttpFallback] returned 0-2 rows
|
|
2
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
|
|
4
|
-
//
|
|
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
5
|
import test from 'node:test';
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import { completeLogSearch, describeCompleteness, describeRawMeta } from './log-search.js';
|
|
10
|
+
import { completeLogSearch, describeCompleteness, describeRawMeta, escapeRegex, fetchJsonRetry } from './log-search.js';
|
|
11
11
|
|
|
12
12
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
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 } = {}) {
|
|
16
22
|
const calls = [];
|
|
17
23
|
const fetchJson = async (query) => {
|
|
18
24
|
const p = new URLSearchParams(query);
|
|
19
25
|
calls.push(p);
|
|
20
|
-
assert.equal(p.get('q'), null, 'the complete read must never send q=');
|
|
21
|
-
assert.equal(p.get('regex'), null);
|
|
22
26
|
const from = new Date(p.get('from')).getTime();
|
|
23
27
|
const to = new Date(p.get('to')).getTime();
|
|
24
28
|
const lvl = p.get('level');
|
|
25
29
|
const site = p.get('siteId');
|
|
26
|
-
|
|
30
|
+
const take = Number(p.get('take'));
|
|
27
31
|
let pool = rows.filter((r) => (!lvl || r.level === lvl) && (!site || String(r.siteId) === site));
|
|
28
32
|
pool = pool.filter((r) => new Date(r.timestamp).getTime() >= from && new Date(r.timestamp).getTime() <= to);
|
|
29
33
|
pool.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 };
|
|
40
75
|
};
|
|
41
76
|
return { fetchJson, calls };
|
|
42
77
|
}
|
|
43
78
|
|
|
44
|
-
function makeRows(n, needles) {
|
|
79
|
+
function makeRows(n, needles, level = 'WARN') {
|
|
45
80
|
const base = Date.UTC(2026, 9, 14, 13, 45, 0);
|
|
46
81
|
const rows = [];
|
|
47
82
|
for (let i = 0; i < n; i++) {
|
|
48
|
-
rows.push({ id: `r${String(i).padStart(7, '0')}`, timestamp: new Date(base - i * 1000).toISOString(), level
|
|
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}` });
|
|
49
84
|
}
|
|
50
85
|
return rows;
|
|
51
86
|
}
|
|
@@ -53,28 +88,58 @@ const W = (rows) => [new Date(rows[rows.length - 1].timestamp), new Date(rows[0]
|
|
|
53
88
|
|
|
54
89
|
test('the single raw call misses a needle behind 9,000 newer rows (the live bug shape)', async () => {
|
|
55
90
|
const rows = makeRows(12000, new Set([9000]));
|
|
56
|
-
const { fetchJson } = mockServer(rows,
|
|
91
|
+
const { fetchJson } = mockServer(rows, { legacy: true });
|
|
57
92
|
const [from, to] = W(rows);
|
|
58
93
|
const raw = await fetchJson(new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: '500', includeTotal: 'true' }).toString());
|
|
59
94
|
assert.equal(raw.totalCountCapped, true);
|
|
60
95
|
assert.equal(raw.entries.some((e) => e.message.includes('AsrsNegotiate')), false);
|
|
61
96
|
});
|
|
62
97
|
|
|
63
|
-
|
|
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 () => {
|
|
64
101
|
const rows = makeRows(12000, new Set([9000, 11000]));
|
|
65
|
-
const { fetchJson } = mockServer(rows, 5000);
|
|
102
|
+
const { fetchJson, calls } = mockServer(rows, { cap: 5000 });
|
|
66
103
|
const [from, to] = W(rows);
|
|
67
|
-
const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]'
|
|
104
|
+
const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]' });
|
|
105
|
+
assert.equal(r.mode, 'cursor');
|
|
68
106
|
assert.equal(r.entries.length, 2);
|
|
69
107
|
assert.equal(r.complete, true);
|
|
70
|
-
assert.ok(r.
|
|
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
|
|
71
110
|
assert.match(describeCompleteness(r), /COMPLETE/);
|
|
72
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');
|
|
73
116
|
});
|
|
74
117
|
|
|
75
|
-
test('
|
|
76
|
-
const rows = makeRows(
|
|
77
|
-
const { fetchJson } = mockServer(rows,
|
|
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 });
|
|
78
143
|
const [from, to] = W(rows);
|
|
79
144
|
const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]', maxRequests: 2 });
|
|
80
145
|
assert.equal(r.complete, false);
|
|
@@ -83,25 +148,73 @@ test('a request budget that runs out is INCOMPLETE, never a silent partial', asy
|
|
|
83
148
|
assert.match(describeCompleteness(r), /NOT proof of absence/);
|
|
84
149
|
});
|
|
85
150
|
|
|
86
|
-
test('
|
|
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 () => {
|
|
87
176
|
const rows = makeRows(300, new Set([10, 20]));
|
|
88
177
|
rows[5].logger = 'Other.Logger';
|
|
89
|
-
const { fetchJson, calls } = mockServer(rows, 5000);
|
|
178
|
+
const { fetchJson, calls } = mockServer(rows, { cap: 5000, legacy: true });
|
|
90
179
|
const [from, to] = W(rows);
|
|
91
180
|
const r = await completeLogSearch({ fetchJson, from, to, params: { level: 'warn', siteId: 20519 }, regex: 'Asrs.*Failed', logger: 'visitorlogging' });
|
|
92
181
|
assert.equal(r.entries.length, 2);
|
|
93
|
-
assert.equal(calls
|
|
94
|
-
assert.equal(calls
|
|
182
|
+
assert.equal(calls.at(-1).get('level'), 'WARN');
|
|
183
|
+
assert.equal(calls.at(-1).get('siteId'), '20519');
|
|
95
184
|
assert.equal(r.complete, true);
|
|
96
185
|
});
|
|
97
186
|
|
|
98
|
-
test('
|
|
99
|
-
const rows = makeRows(
|
|
100
|
-
const { fetchJson } = mockServer(rows, 5000);
|
|
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 });
|
|
101
190
|
const [from, to] = W(rows);
|
|
102
|
-
const r = await completeLogSearch({ fetchJson, from, to, contains: '
|
|
103
|
-
assert.equal(r.
|
|
104
|
-
assert.
|
|
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);
|
|
105
218
|
});
|
|
106
219
|
|
|
107
220
|
test('describeRawMeta surfaces searchPath/scannedRows/truncated and warns on old deploys', () => {
|
|
@@ -117,13 +230,145 @@ test('cli/lib/log-search.js and mcp/log-search.js are byte-identical (the npm pa
|
|
|
117
230
|
assert.equal(a, b, 'copy cli/lib/log-search.js to mcp/log-search.js (or the reverse)');
|
|
118
231
|
});
|
|
119
232
|
|
|
120
|
-
test('
|
|
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', () => {
|
|
121
242
|
const cli = fs.readFileSync(path.join(here, 'commands', 'logs.js'), 'utf8');
|
|
122
243
|
const mcp = fs.readFileSync(path.join(here, '..', '..', 'mcp', 'staff-server.js'), 'utf8');
|
|
123
|
-
for (const [name, src
|
|
124
|
-
const tail = src.slice(src.indexOf(
|
|
125
|
-
const handler = tail.slice(0,
|
|
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);
|
|
126
247
|
assert.ok(handler.includes('completeLogSearch('), `${name}: complete mode must call completeLogSearch`);
|
|
127
|
-
assert.ok(!/params\.set\('q'/.test(handler), `${name}: complete mode must not set q
|
|
248
|
+
assert.ok(!/params\.set\('q'/.test(handler), `${name}: complete mode must not set q= itself`);
|
|
128
249
|
}
|
|
129
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