querysub 0.459.0 → 0.461.0

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.
Files changed (35) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/package.json +2 -2
  3. package/src/-b-authorities/dnsAuthority.ts +23 -15
  4. package/src/-g-core-values/NodeCapabilities.ts +3 -0
  5. package/src/-h-path-value-serialize/PathValueSerializer.ts +11 -3
  6. package/src/0-path-value-core/PathRouter.ts +6 -0
  7. package/src/0-path-value-core/PathWatcher.ts +1 -1
  8. package/src/0-path-value-core/pathValueCore.ts +4 -7
  9. package/src/1-path-client/RemoteWatcher.ts +8 -3
  10. package/src/1-path-client/pathValueClientWatcher.ts +3 -0
  11. package/src/2-proxy/PathValueProxyWatcher.ts +1 -1
  12. package/src/2-proxy/TransactionDelayer.ts +1 -1
  13. package/src/3-path-functions/PathFunctionHelpers.ts +13 -8
  14. package/src/3-path-functions/PathFunctionRunner.ts +2 -0
  15. package/src/4-querysub/Querysub.ts +0 -1
  16. package/src/4-querysub/QuerysubController.ts +1 -7
  17. package/src/config.ts +9 -0
  18. package/src/config2.ts +7 -1
  19. package/src/deployManager/components/MachinePicker.tsx +40 -0
  20. package/src/deployManager/components/ServiceDetailPage.tsx +2 -5
  21. package/src/deployManager/components/ServicesListPage.tsx +2 -0
  22. package/src/deployManager/components/Tools.tsx +165 -0
  23. package/src/deployManager/setupMachineMain.ts +74 -23
  24. package/src/diagnostics/charts/Chart.tsx +240 -0
  25. package/src/diagnostics/grossStats/GrossStatsPage.tsx +48 -83
  26. package/src/diagnostics/logs/IndexedLogs/BufferIndex.ts +22 -35
  27. package/src/diagnostics/logs/IndexedLogs/BufferUnitIndex.ts +39 -47
  28. package/src/diagnostics/logs/IndexedLogs/IndexedLogs.ts +3 -3
  29. package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogs.ts +18 -3
  30. package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts +1 -0
  31. package/src/diagnostics/managementPages.tsx +58 -58
  32. package/src/diagnostics/misc-pages/DNSPage.tsx +344 -0
  33. package/test.ts +46 -70
  34. package/src/diagnostics/AuditLogPage.tsx +0 -147
  35. package/src/diagnostics/NodeConnectionsPage.tsx +0 -167
@@ -3,9 +3,8 @@ import { qreact } from "../../4-dom/qreact";
3
3
  import { css } from "../../4-dom/css";
4
4
  import { Querysub } from "../../4-querysub/QuerysubController";
5
5
  import { SocketFunction } from "socket-function/SocketFunction";
6
- import { formatNumber } from "socket-function/src/formatting/format";
6
+ import { formatDateTime, formatNumber } from "socket-function/src/formatting/format";
7
7
  import { timeInHour, timeInMinute } from "socket-function/src/misc";
8
- import { cacheArgsEqual } from "socket-function/src/caching";
9
8
  import { t } from "../../2-proxy/schema2";
10
9
  import { getSyncedController } from "../../library-components/SyncedController";
11
10
  import { GrossStatsController } from "./GrossStatsController";
@@ -14,12 +13,12 @@ import {
14
13
  GrossStatsField,
15
14
  GrossStatsBucket,
16
15
  } from "../../5-diagnostics/gross-stats/grossStats";
16
+ import { ChartBar, ChartData } from "../charts/Chart";
17
17
  export { GrossStatsController } from "./GrossStatsController";
18
18
 
19
19
  module.hotreload = true;
20
20
 
21
21
  const REFRESH_INTERVAL_MS = 5 * timeInMinute;
22
- const CHART_WIDTH = 1200;
23
22
  const CHART_HEIGHT = 300;
24
23
 
25
24
  const TIME_RANGES: { label: string; ms: number }[] = [
@@ -58,65 +57,33 @@ function shortNodeId(nodeId: string): string {
58
57
 
59
58
  let grossStatsController = getSyncedController(GrossStatsController);
60
59
 
61
- // Render a stacked bar chart to a PNG data URL. Cached by argument identity:
62
- // selectedField/rangeMs/width/height are primitives; nodeIds and bucketsArrays are
63
- // expected to be stable references (the bucketsArrays come from syncedController and
64
- // only change when refreshed).
65
- const renderChartPNG = cacheArgsEqual((
60
+ type ChartRow = { time: number; value: number };
61
+
62
+ function buildChartRows(
66
63
  selectedField: GrossStatsField,
67
64
  rangeMs: number,
68
- width: number,
69
- height: number,
70
- nodeIds: readonly string[],
71
- bucketsArrays: readonly GrossStatsBucket[][],
72
- ): { pngUrl: string; maxTotal: number } => {
73
- let canvas = document.createElement("canvas");
74
- canvas.width = width;
75
- canvas.height = height;
76
- let ctx = canvas.getContext("2d")!;
77
- ctx.fillStyle = "hsl(0, 0%, 97%)";
78
- ctx.fillRect(0, 0, width, height);
79
-
65
+ bucketsArrays: GrossStatsBucket[][],
66
+ ): { rows: ChartRow[]; peak: number } {
80
67
  let now = Date.now();
81
68
  let windowStart = now - rangeMs;
82
-
83
- let perPixel: { perNode: number[]; total: number; }[] = [];
84
- for (let x = 0; x < width; x++) {
85
- perPixel.push({ perNode: new Array(nodeIds.length).fill(0), total: 0 });
86
- }
87
- let maxTotal = 0;
88
- for (let i = 0; i < nodeIds.length; i++) {
89
- let buckets = bucketsArrays[i];
69
+ // Sum across selected nodes, keyed by bucket time (each bucket is one minute).
70
+ let sumByTime = new Map<number, number>();
71
+ for (let buckets of bucketsArrays) {
90
72
  if (!buckets) continue;
91
73
  for (let bucket of buckets) {
92
74
  if (bucket.time < windowStart) continue;
93
- let xFloat = (bucket.time - windowStart) / rangeMs * width;
94
- let x = Math.min(width - 1, Math.max(0, Math.floor(xFloat)));
95
- let cell = perPixel[x];
96
- let val = bucket.deltas[selectedField] || 0;
97
- cell.perNode[i] += val;
98
- cell.total += val;
99
- if (cell.total > maxTotal) maxTotal = cell.total;
75
+ let v = bucket.deltas[selectedField] || 0;
76
+ sumByTime.set(bucket.time, (sumByTime.get(bucket.time) || 0) + v);
100
77
  }
101
78
  }
102
- if (maxTotal <= 0) maxTotal = 1;
103
-
104
- for (let x = 0; x < width; x++) {
105
- let cell = perPixel[x];
106
- if (cell.total === 0) continue;
107
- let y = height;
108
- for (let i = 0; i < nodeIds.length; i++) {
109
- let val = cell.perNode[i];
110
- if (!val) continue;
111
- let segHeight = (val / maxTotal) * height;
112
- ctx.fillStyle = colorForNode(i);
113
- ctx.fillRect(x, y - segHeight, 1, segHeight);
114
- y -= segHeight;
115
- }
79
+ let times = Array.from(sumByTime.keys()).sort((a, b) => a - b);
80
+ let rows: ChartRow[] = times.map(t => ({ time: t, value: sumByTime.get(t)! }));
81
+ let peak = 0;
82
+ for (let r of rows) {
83
+ if (r.value > peak) peak = r.value;
116
84
  }
117
-
118
- return { pngUrl: canvas.toDataURL(), maxTotal };
119
- }, 5);
85
+ return { rows, peak };
86
+ }
120
87
 
121
88
  export class GrossStatsPage extends qreact.Component {
122
89
  refreshTimer: ReturnType<typeof setInterval> | undefined;
@@ -137,7 +104,7 @@ export class GrossStatsPage extends qreact.Component {
137
104
  <span>Range:</span>
138
105
  {TIME_RANGES.map(r =>
139
106
  <button
140
- className={css.pad2(8, 4) + (state.rangeMs === r.ms ? " " + css.hsl(210, 70, 60).hslcolor(0, 0, 100).bold : "")}
107
+ className={css.pad2(8, 4) + (state.rangeMs === r.ms ? " " + css.hsl(210, 70, 60).hslcolor(0, 0, 100).boldStyle : "")}
141
108
  onClick={() => Querysub.commit(() => { state.rangeMs = r.ms; })}
142
109
  >{r.label}</button>
143
110
  )}
@@ -146,7 +113,7 @@ export class GrossStatsPage extends qreact.Component {
146
113
  <span>Field:</span>
147
114
  {GROSS_STATS_FIELDS.map(f =>
148
115
  <button
149
- className={css.pad2(8, 4) + (state.selectedField === f ? " " + css.hsl(140, 60, 50).hslcolor(0, 0, 100).bold : "")}
116
+ className={css.pad2(8, 4) + (state.selectedField === f ? " " + css.hsl(140, 60, 50).hslcolor(0, 0, 100).boldStyle : "")}
150
117
  onClick={() => Querysub.commit(() => { state.selectedField = f; })}
151
118
  >{f}</button>
152
119
  )}
@@ -254,44 +221,42 @@ export class GrossStatsPage extends qreact.Component {
254
221
  let bucketsArrays: GrossStatsBucket[][] = selectedNodeIds.map(n => bucketsByNode.get(n) ?? []);
255
222
  let anyLoading = !result;
256
223
 
257
- let chart = renderChartPNG(
258
- state.selectedField,
259
- state.rangeMs,
260
- CHART_WIDTH,
261
- CHART_HEIGHT,
262
- selectedNodeIds,
263
- bucketsArrays,
264
- );
265
- let now = Date.now();
266
- let windowStart = now - state.rangeMs;
224
+ let { rows, peak } = buildChartRows(state.selectedField, state.rangeMs, bucketsArrays);
225
+
226
+ let chartData: ChartData<ChartRow> = {
227
+ rows,
228
+ columns: {
229
+ time: {
230
+ title: "Time",
231
+ isDateType: true,
232
+ formatter: (v) => formatDateTime(Number(v)),
233
+ },
234
+ value: {
235
+ title: state.selectedField,
236
+ formatter: (v) => formatNumber(Number(v)),
237
+ },
238
+ },
239
+ };
267
240
 
268
241
  return <div className={css.vbox(8).pad2(8).fillWidth}>
269
242
  <h2>Cluster Stats</h2>
270
243
  {this.renderControls(allNodeIds)}
271
- <div className={css.vbox(4)}>
272
- <div className={css.hbox(8).bold}>
273
- <span>peak: {formatNumber(chart.maxTotal)} {state.selectedField} / minute</span>
244
+ <div className={css.vbox(4).fillWidth}>
245
+ <div className={css.hbox(8).boldStyle}>
246
+ <span>peak: {formatNumber(peak)} {state.selectedField} / minute</span>
274
247
  </div>
275
- <img
276
- src={chart.pngUrl}
277
- width={CHART_WIDTH}
278
- height={CHART_HEIGHT}
279
- className={css.hsl(0, 0, 100)}
248
+ <div
249
+ className={css.fillWidth.height(CHART_HEIGHT).hsl(0, 0, 100)}
280
250
  style={anyLoading ? { opacity: 0.5 } : undefined}
281
- />
282
- <div className={css.hbox(0).fillWidth}>
283
- <span className={css.flexShrink0}>{formatLocalTime(windowStart)}</span>
284
- <span className={css.fillBoth}></span>
285
- <span className={css.flexShrink0}>{formatLocalTime(now)}</span>
251
+ >
252
+ <ChartBar<ChartRow>
253
+ data={chartData}
254
+ xAxis="time"
255
+ yAxis="value"
256
+ />
286
257
  </div>
287
258
  </div>
288
259
  {this.renderTable(selectedNodeIds, bucketsArrays)}
289
260
  </div>;
290
261
  }
291
262
  }
292
-
293
- function formatLocalTime(ms: number): string {
294
- let d = new Date(ms);
295
- let pad = (n: number) => String(n).padStart(2, "0");
296
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
297
- }
@@ -300,9 +300,6 @@ export class BufferIndex {
300
300
  dataReader: Reader;
301
301
  params: SearchParams;
302
302
  keepIterating: () => boolean;
303
- // Returns true iff the caller actually retained the value. We use that
304
- // to drive the per-file matchCount cap below: see the note at the
305
- // `matchesPattern(buffer)` call for why we can't blindly count emits.
306
303
  onResult: (match: Buffer) => boolean;
307
304
  results: IndexedLogResults;
308
305
  allSearchUnits: Unit[][];
@@ -331,7 +328,20 @@ export class BufferIndex {
331
328
  }, `BufferIndex|readLocalBlocks`);
332
329
  });
333
330
 
334
- let matchCount = 0;
331
+ // NOTE: The per-file matchCount cap (commented out below in both the
332
+ // block loop and the inner buffer loop) is intentionally disabled.
333
+ // We tested (see test.ts) and confirmed that blocks within a file
334
+ // aren't time-ordered — the move-to-public pipeline can leave a
335
+ // late-index block holding earlier-time entries than earlier-index
336
+ // blocks, and buffers within a single block aren't time-ordered
337
+ // either. The old `matchCount >= params.limit` short-circuit assumed
338
+ // ordering and was silently dropping blocks whose entries would have
339
+ // survived the top-K trim (broad queries lost their earliest
340
+ // matches). Without the cap we scan every candidate block per file,
341
+ // but the index pre-filter bounds the work and it isn't measurably
342
+ // slower in practice. If blocks/buffers ever get written in
343
+ // guaranteed time order this code can be re-enabled.
344
+ // let matchCount = 0;
335
345
  let blockSearchTimeStart = Date.now();
336
346
 
337
347
  results.totalBlockCount += indexEntries.length;
@@ -344,7 +354,8 @@ export class BufferIndex {
344
354
  const step = iterateForward ? 1 : -1;
345
355
 
346
356
  for (let i = startIdx; iterateForward ? i < endIdx : i > endIdx; i += step) {
347
- if (matchCount >= params.limit || !config.keepIterating()) break;
357
+ // if (matchCount >= params.limit || !config.keepIterating()) break;
358
+ if (!config.keepIterating()) break;
348
359
  await config.results.limitGroup?.wait();
349
360
  const blockIndex = i;
350
361
 
@@ -393,38 +404,16 @@ export class BufferIndex {
393
404
  const bufferStep = iterateForward ? 1 : -1;
394
405
 
395
406
  for (let bufferIndex = bufferStartIdx; iterateForward ? bufferIndex < bufferEndIdx : bufferIndex > bufferEndIdx; bufferIndex += bufferStep) {
396
- // No `matchCount >= params.limit` cap inside the block.
397
- // Buffer order within a block is not guaranteed to follow
398
- // the search direction (blocks are time-ordered, buffers
399
- // inside them are not), so stopping mid-block on a match
400
- // count would drop earlier-time buffers we haven't reached
401
- // yet. The block-level cap above is the only safe stop;
402
- // here we only honor cross-file `keepIterating` (which
403
- // applies to the whole file at once, so it's safe at any
404
- // granularity).
407
+ // See the note above the outer loop for why the
408
+ // matchCount-based stop is gone.
409
+ // if (matchCount >= params.limit || !config.keepIterating()) break;
405
410
  if (!config.keepIterating()) break;
406
411
  await config.results.limitGroup?.wait();
407
412
 
408
413
  let buffer = buffers[bufferIndex];
409
414
  if (matchesPattern(buffer)) {
410
- // Only count matches the caller actually kept. `onResult`
411
- // routes through `FindProgressTracker.addResult`, which
412
- // can reject for reasons we don't see from here — most
413
- // notably time-range filtering (an entry whose time is
414
- // outside the search window matched the text pattern
415
- // but isn't a real hit). Counting rejected emits would
416
- // let a stretch of out-of-window matches at the start
417
- // of a file blow the per-file cap and short-circuit the
418
- // scan before we reach the in-window region.
419
- //
420
- // The cost is that we keep matching and calling onResult
421
- // through those out-of-window blocks (mild inefficiency).
422
- // We can't skip ahead — buffers are scanned linearly and
423
- // we don't know up front which entries the caller will
424
- // reject — so this is the best we can do here.
425
- if (config.onResult(buffer)) {
426
- matchCount++;
427
- }
415
+ config.onResult(buffer);
416
+ // matchCount++;
428
417
  }
429
418
  }
430
419
  } catch (e: any) {
@@ -559,9 +548,7 @@ export class BufferIndex {
559
548
  params: SearchParams;
560
549
 
561
550
  keepIterating: () => boolean;
562
- // See the note on `findLocal.onResult` — return value drives the
563
- // per-file matchCount cap so out-of-window emits don't short-circuit
564
- // the scan.
551
+ // Return value is unused — see `findLocal.onResult`.
565
552
  onResult: (match: Buffer) => boolean;
566
553
  results: IndexedLogResults;
567
554
  }): Promise<{
@@ -505,9 +505,6 @@ export class BufferUnitIndex {
505
505
  params: SearchParams;
506
506
  allSearchUnits: Unit[][];
507
507
  keepIterating: () => boolean;
508
- // Returns true iff the caller actually retained the value. Drives the
509
- // `matchCounts` cap below — see the comment at the `matchesPattern`
510
- // call for why we can't blindly count emits.
511
508
  onResult: (match: Buffer) => boolean;
512
509
  index: Buffer;
513
510
  reader: Reader;
@@ -534,26 +531,42 @@ export class BufferUnitIndex {
534
531
  // Read blocks and search for matches
535
532
  let blockSearchTimeStart = Date.now();
536
533
  await measureBlock(async () => {
537
- let matchCount = 0;
538
- let matchCounts = list(blockCount).fill(0);
534
+ // NOTE: The matchCount / matchCounts tracking and the
535
+ // `stopIterating` cap below are commented out, not deleted.
536
+ // We tested (see test.ts) and confirmed that blocks within a
537
+ // file aren't actually time-ordered — the move-to-public
538
+ // pipeline can leave a late-index block holding earlier-time
539
+ // entries than earlier-index blocks. The old `relevantCount
540
+ // >= params.limit` short-circuit assumed time-ordered blocks
541
+ // and was silently skipping blocks whose entries would have
542
+ // survived the top-K trim (broad queries lost their earliest
543
+ // matches). The same applied to the inner-buffer cap (buffers
544
+ // within a block aren't time-ordered either). Removing both
545
+ // caps means we scan every candidate block per file, but the
546
+ // index pre-filter bounds the work and it isn't measurably
547
+ // slower in practice. If blocks ever get written in
548
+ // guaranteed time order, this code can be re-enabled.
549
+ // let matchCount = 0;
550
+ // let matchCounts = list(blockCount).fill(0);
539
551
 
540
552
  const searchBlock = async (blockIndex: number) => {
541
553
  if (!candidateBlocksSet.has(blockIndex)) return;
542
- // Check if we should stop iterating based on match counts and direction
543
- let stopIterating = () => {
544
- let relevantCount = 0;
545
- if (params.searchFromStart) {
546
- for (let i = 0; i <= blockIndex; i++) {
547
- relevantCount += matchCounts[i];
548
- }
549
- } else {
550
- for (let i = blockIndex; i < blockCount; i++) {
551
- relevantCount += matchCounts[i];
552
- }
553
- }
554
- return relevantCount >= params.limit || !keepIterating();
555
- };
556
- if (stopIterating()) return;
554
+ // // Check if we should stop iterating based on match counts and direction
555
+ // let stopIterating = () => {
556
+ // let relevantCount = 0;
557
+ // if (params.searchFromStart) {
558
+ // for (let i = 0; i <= blockIndex; i++) {
559
+ // relevantCount += matchCounts[i];
560
+ // }
561
+ // } else {
562
+ // for (let i = blockIndex; i < blockCount; i++) {
563
+ // relevantCount += matchCounts[i];
564
+ // }
565
+ // }
566
+ // return relevantCount >= params.limit || !keepIterating();
567
+ // };
568
+ // if (stopIterating()) return;
569
+ if (!keepIterating()) return;
557
570
 
558
571
  let debugOffsets = {
559
572
  startOffset: 0,
@@ -580,38 +593,17 @@ export class BufferUnitIndex {
580
593
  const step = iterateForward ? 1 : -1;
581
594
 
582
595
  for (let i = startIdx; iterateForward ? i < endIdx : i > endIdx; i += step) {
583
- // No matchCount-based cap inside the block. Buffer
584
- // order within a block is not guaranteed to follow the
585
- // search direction (blocks are time-ordered, buffers
586
- // inside them are not), so a mid-block stop on
587
- // `relevantCount >= limit` would drop earlier-time
588
- // buffers we haven't reached yet. Block-level
589
- // `stopIterating` is the safe granularity; here we
590
- // only honor cross-file `keepIterating`, which applies
591
- // to the whole file at once.
596
+ // See the note at the top of this function for why
597
+ // the matchCount-based stop is gone (commented out).
598
+ // if (stopIterating()) break;
592
599
  if (!keepIterating()) break;
593
600
  await results.limitGroup?.wait();
594
601
 
595
602
  const buffer = await this.getBufferFromBlock(blockReader, i);
596
603
  if (matchesPattern(buffer)) {
597
- // Only count matches the caller actually kept.
598
- // `onResult` routes through
599
- // `FindProgressTracker.addResult`, which can reject
600
- // for reasons opaque to us — most notably
601
- // time-range filtering. Counting rejected emits
602
- // would let a stretch of out-of-window matches at
603
- // the start of a file blow the per-file cap and
604
- // short-circuit the scan before we reach the
605
- // in-window region. The cost is that we keep
606
- // matching and calling onResult through those
607
- // out-of-window blocks (mild inefficiency); we
608
- // can't skip ahead because buffers are scanned
609
- // linearly and we don't know up front which
610
- // entries the caller will reject.
611
- if (config.onResult(buffer)) {
612
- matchCount++;
613
- matchCounts[blockIndex]++;
614
- }
604
+ config.onResult(buffer);
605
+ // matchCount++;
606
+ // matchCounts[blockIndex]++;
615
607
  }
616
608
  }
617
609
  } catch (e: any) {
@@ -286,11 +286,11 @@ export class IndexedLogs<T> {
286
286
  let added = false;
287
287
  const tryAdd = async (nodeId: string, preferredOnly = false) => {
288
288
  if (added) return;
289
- let hasLogger = await timeoutToUndefinedSilent(2500, IndexedLogShimController.nodes[nodeId].hasLogger(this.config.name));
289
+ let hasLogger = await timeoutToUndefinedSilent(10_000, IndexedLogShimController.nodes[nodeId].hasLogger(this.config.name));
290
290
  if (!hasLogger) return false;
291
291
  // NOTE: Prefer to do the searching on the move logs service. However, if it's not available, any service can do searching. It just might lag that server...
292
292
  if (preferredOnly) {
293
- let metadata = await timeoutToUndefinedSilent(2500, NodeCapabilitiesController.nodes[nodeId].getMetadata());
293
+ let metadata = await timeoutToUndefinedSilent(10_000, NodeCapabilitiesController.nodes[nodeId].getMetadata());
294
294
  if (!metadata?.entryPoint.includes("movelogs")) return false;
295
295
  }
296
296
  added = true;
@@ -400,13 +400,13 @@ export class IndexedLogs<T> {
400
400
  onResult: (match: T) => void;
401
401
  onResults?: (results: IndexedLogResults) => Promise<boolean>;
402
402
  }): Promise<IndexedLogResults> {
403
-
404
403
  let { params } = config;
405
404
  if (params.pathOverrides && config.params.only === "public") {
406
405
  // Fine, if they provided path overrides, and they are all public, just read them, as they'll resolve the same no matter where we read from
407
406
  } else if (config.params.forceReadProduction && !isPublic()) {
408
407
  let machineNodes = await this.getMachineNodes();
409
408
  if (machineNodes.length === 0) throw new Error(`Cannot find any public nodes to read from`);
409
+ console.log(`Picking machine node ${machineNodes[0]} to read from`);
410
410
  return await this.clientFind({
411
411
  ...config,
412
412
  nodeId: machineNodes[0],
@@ -157,6 +157,7 @@ export class MCPIndexedLogs {
157
157
  direction: Direction;
158
158
  columns: string[];
159
159
  limit?: number;
160
+ forceRefresh?: boolean;
160
161
  }): Promise<SearchResult> {
161
162
  let limit = config.limit ?? 100;
162
163
  let startTime = normalizeTime(config.startTime, "startTime");
@@ -176,7 +177,7 @@ export class MCPIndexedLogs {
176
177
  let machineId = config.machine === "local" ? getOwnMachineId() : config.machine;
177
178
 
178
179
  let moveStart = Date.now();
179
- let moveOutcome = await this.ensureMovedThrough(machineId, endTime);
180
+ let moveOutcome = await this.ensureMovedThrough(machineId, endTime, config.forceRefresh);
180
181
  console.log(`[search] ensureMovedThrough ${moveOutcome} in ${formatTime(Date.now() - moveStart)}`);
181
182
 
182
183
  let loggers = await getLoggers2Async();
@@ -203,6 +204,7 @@ export class MCPIndexedLogs {
203
204
  loggerName,
204
205
  startTime,
205
206
  endTime,
207
+ forceRefresh: config.forceRefresh,
206
208
  });
207
209
  totalPathsSeen += paths.length;
208
210
 
@@ -479,9 +481,9 @@ export class MCPIndexedLogs {
479
481
  // (e.g. older versions still running). Records moved-through up to
480
482
  // now - MOVE_GRACE so we skip this on subsequent calls covering the same
481
483
  // window.
482
- private async ensureMovedThrough(machineId: string, endTime: number): Promise<"cached" | "no-node" | "moved"> {
484
+ private async ensureMovedThrough(machineId: string, endTime: number, forceRefresh?: boolean): Promise<"cached" | "no-node" | "moved"> {
483
485
  let lastMoved = this.movedThroughByMachine.get(machineId) ?? 0;
484
- if (lastMoved >= endTime) return "cached";
486
+ if (!forceRefresh && lastMoved >= endTime) return "cached";
485
487
 
486
488
  let nodeIds = await this.findRemoteNodesOnMachine(machineId);
487
489
  if (nodeIds.length === 0) {
@@ -493,6 +495,14 @@ export class MCPIndexedLogs {
493
495
  let answered = false;
494
496
  for (let nodeId of nodeIds) {
495
497
  try {
498
+ if (forceRefresh) {
499
+ console.log(`MCPIndexedLogs: forceRefresh — flushing ${loggerName} on ${nodeId} unconditionally`);
500
+ await IndexedLogShimController.nodes[nodeId].forceMoveLogsToPublic({
501
+ indexedLogsName: loggerName,
502
+ });
503
+ answered = true;
504
+ break;
505
+ }
496
506
  let hasPending = await timeoutToUndefinedSilent(
497
507
  5000,
498
508
  IndexedLogShimController.nodes[nodeId].hasPendingInRange({
@@ -564,6 +574,7 @@ export class MCPIndexedLogs {
564
574
  loggerName: LoggerName;
565
575
  startTime: number;
566
576
  endTime: number;
577
+ forceRefresh?: boolean;
567
578
  }): Promise<TimeFilePath[]> {
568
579
  let bucketStart = Math.floor(config.startTime / timeInHour) * timeInHour;
569
580
  let bucketEnd = Math.ceil(config.endTime / timeInHour) * timeInHour;
@@ -575,6 +586,10 @@ export class MCPIndexedLogs {
575
586
  if (now - v.time > PATHS_CACHE_TTL) this.pathsCache.delete(k);
576
587
  }
577
588
 
589
+ if (config.forceRefresh) {
590
+ this.pathsCache.delete(key);
591
+ }
592
+
578
593
  let cached = this.pathsCache.get(key);
579
594
  if (cached && now - cached.time <= PATHS_CACHE_TTL) {
580
595
  return cached.paths;
@@ -56,6 +56,7 @@ Note: each segment between operators ideally has at least 4 contiguous character
56
56
  columns: { type: "array", items: { type: "string" }, description: "Which fields to project onto each row. Use [] to get just metadata; use allColumns from a prior result to pick more." },
57
57
  limit: { type: "number", default: 100 },
58
58
  logTypes: { type: "string", description: "Optional pipe-separated list restricting which log streams to scan. Allowed values: log, info, warn, error. Examples: \"warn|error\", \"log\". Omit (default) to search all four." },
59
+ forceRefresh: { type: "boolean", description: "If true, bypass the path-cache (TimeFileTree.findAllPaths) and re-walk the archive folders. Use this when recent log files appear missing because the cache is stale." },
59
60
  },
60
61
  required: ["query", "machine", "startTime", "endTime", "direction", "columns"],
61
62
  },