@linxiraos/pi-stats 1.1.20 → 1.1.22

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 (193) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/README.md +13 -11
  3. package/THIRD-PARTY-NOTICES.txt +3 -40
  4. package/build.ts +5 -49
  5. package/dist/client/index.css +1 -1
  6. package/dist/client/index.html +2 -2
  7. package/dist/client/index.js +105 -126
  8. package/dist/types/aggregator.d.ts +25 -22
  9. package/dist/types/client/api.d.ts +11 -7
  10. package/dist/types/client/app/LiveChip.d.ts +2 -0
  11. package/dist/types/client/app/Shell.d.ts +16 -0
  12. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  13. package/dist/types/client/app/nav.d.ts +18 -0
  14. package/dist/types/client/charts/BarList.d.ts +17 -0
  15. package/dist/types/client/charts/Chart.d.ts +49 -0
  16. package/dist/types/client/charts/Legend.d.ts +16 -0
  17. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  18. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  19. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  20. package/dist/types/client/charts/index.d.ts +7 -0
  21. package/dist/types/client/charts/types.d.ts +25 -0
  22. package/dist/types/client/charts/useWidth.d.ts +3 -0
  23. package/dist/types/client/data/colors.d.ts +25 -0
  24. package/dist/types/client/data/formatters.d.ts +9 -1
  25. package/dist/types/client/data/live.d.ts +29 -0
  26. package/dist/types/client/data/query.d.ts +35 -0
  27. package/dist/types/client/data/range.d.ts +31 -0
  28. package/dist/types/client/data/series.d.ts +28 -0
  29. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  30. package/dist/types/client/data/view-models.d.ts +111 -28
  31. package/dist/types/client/index.d.ts +0 -1
  32. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  34. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  35. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  36. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  37. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  38. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  39. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  40. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  41. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  42. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  43. package/dist/types/client/routes/index.d.ts +1 -1
  44. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  45. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  46. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  47. package/dist/types/client/ui/Badge.d.ts +19 -0
  48. package/dist/types/client/ui/Card.d.ts +25 -0
  49. package/dist/types/client/ui/Drawer.d.ts +25 -0
  50. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  51. package/dist/types/client/ui/Modal.d.ts +23 -0
  52. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  53. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  54. package/dist/types/client/ui/Segmented.d.ts +15 -0
  55. package/dist/types/client/ui/Stat.d.ts +36 -0
  56. package/dist/types/client/ui/States.d.ts +41 -0
  57. package/dist/types/client/ui/Table.d.ts +48 -0
  58. package/dist/types/client/ui/index.d.ts +9 -10
  59. package/dist/types/db.d.ts +90 -106
  60. package/dist/types/frustration.d.ts +70 -0
  61. package/dist/types/index.d.ts +4 -2
  62. package/dist/types/live.d.ts +30 -0
  63. package/dist/types/rollup.d.ts +115 -0
  64. package/dist/types/server.d.ts +6 -1
  65. package/dist/types/shared-types.d.ts +116 -56
  66. package/dist/types/types.d.ts +4 -0
  67. package/dist/types/user-metrics.d.ts +8 -0
  68. package/package.json +7 -13
  69. package/src/aggregator.ts +278 -216
  70. package/src/client/App.tsx +38 -80
  71. package/src/client/api.ts +65 -7
  72. package/src/client/app/LiveChip.tsx +97 -0
  73. package/src/client/app/Shell.tsx +181 -0
  74. package/src/client/app/ThemeToggle.tsx +5 -2
  75. package/src/client/app/nav.ts +78 -0
  76. package/src/client/charts/BarList.tsx +49 -0
  77. package/src/client/charts/Chart.tsx +388 -0
  78. package/src/client/charts/Legend.tsx +61 -0
  79. package/src/client/charts/ShareBar.tsx +26 -0
  80. package/src/client/charts/Sparkline.tsx +38 -0
  81. package/src/client/charts/TimeChart.tsx +22 -0
  82. package/src/client/charts/index.ts +7 -0
  83. package/src/client/charts/types.ts +27 -0
  84. package/src/client/charts/useWidth.ts +19 -0
  85. package/src/client/data/colors.ts +45 -0
  86. package/src/client/data/formatters.ts +40 -12
  87. package/src/client/data/live.tsx +85 -0
  88. package/src/client/data/query.ts +166 -0
  89. package/src/client/data/range.ts +82 -0
  90. package/src/client/data/series.ts +85 -0
  91. package/src/client/data/useHashRoute.ts +41 -83
  92. package/src/client/data/view-models.ts +347 -140
  93. package/src/client/index.tsx +3 -6
  94. package/src/client/routes/CostsRoute.tsx +394 -222
  95. package/src/client/routes/ErrorsRoute.tsx +401 -98
  96. package/src/client/routes/FrustrationRoute.tsx +924 -0
  97. package/src/client/routes/GainRoute.tsx +209 -206
  98. package/src/client/routes/ModelsRoute.tsx +540 -431
  99. package/src/client/routes/OverviewRoute.tsx +283 -311
  100. package/src/client/routes/ProjectsRoute.tsx +298 -156
  101. package/src/client/routes/ProvidersRoute.tsx +948 -416
  102. package/src/client/routes/RequestsRoute.tsx +295 -107
  103. package/src/client/routes/ToolsRoute.tsx +495 -398
  104. package/src/client/routes/TracesRoute.tsx +136 -117
  105. package/src/client/routes/costs.css +24 -0
  106. package/src/client/routes/errors.css +60 -0
  107. package/src/client/routes/frustration.css +123 -0
  108. package/src/client/routes/index.ts +1 -1
  109. package/src/client/routes/models.css +43 -0
  110. package/src/client/routes/projects.css +6 -0
  111. package/src/client/routes/providers.css +28 -0
  112. package/src/client/routes/tools.css +14 -0
  113. package/src/client/styles.css +1396 -1301
  114. package/src/client/traces/AggregatesPanel.tsx +55 -74
  115. package/src/client/traces/Minimap.tsx +6 -15
  116. package/src/client/traces/SpanDrawer.tsx +128 -194
  117. package/src/client/traces/SummaryStrip.tsx +21 -24
  118. package/src/client/traces/TimelineCanvas.tsx +29 -46
  119. package/src/client/traces/TraceView.tsx +175 -130
  120. package/src/client/traces/TranscriptList.tsx +13 -41
  121. package/src/client/traces/trace-colors.ts +60 -53
  122. package/src/client/traces/traces.css +279 -0
  123. package/src/client/ui/Badge.tsx +29 -0
  124. package/src/client/ui/Card.tsx +61 -0
  125. package/src/client/ui/Drawer.tsx +93 -0
  126. package/src/client/ui/JsonBlock.tsx +26 -44
  127. package/src/client/ui/Modal.tsx +106 -0
  128. package/src/client/ui/RequestDrawer.tsx +163 -188
  129. package/src/client/ui/SearchInput.tsx +25 -0
  130. package/src/client/ui/Segmented.tsx +61 -0
  131. package/src/client/ui/Stat.tsx +104 -0
  132. package/src/client/ui/States.tsx +81 -0
  133. package/src/client/ui/Table.tsx +230 -0
  134. package/src/client/ui/index.ts +9 -10
  135. package/src/client/ui/modal.css +54 -0
  136. package/src/client/ui/request-drawer.css +40 -0
  137. package/src/db.ts +379 -983
  138. package/src/frustration.ts +473 -0
  139. package/src/index.ts +23 -29
  140. package/src/live.ts +259 -0
  141. package/src/parser.ts +55 -66
  142. package/src/rollup.ts +951 -0
  143. package/src/server.ts +143 -87
  144. package/src/shared-types.ts +120 -56
  145. package/src/trace.ts +42 -22
  146. package/src/types.ts +4 -0
  147. package/src/user-metrics.ts +13 -0
  148. package/dist/client/styles.css +0 -1976
  149. package/dist/types/client/app/AppLayout.d.ts +0 -16
  150. package/dist/types/client/app/NavRail.d.ts +0 -7
  151. package/dist/types/client/app/RangeControl.d.ts +0 -7
  152. package/dist/types/client/app/SyncButton.d.ts +0 -14
  153. package/dist/types/client/app/TopBar.d.ts +0 -15
  154. package/dist/types/client/app/routes.d.ts +0 -12
  155. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  156. package/dist/types/client/components/chart-shared.d.ts +0 -178
  157. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  158. package/dist/types/client/components/range-meta.d.ts +0 -21
  159. package/dist/types/client/data/charts.d.ts +0 -1
  160. package/dist/types/client/data/useResource.d.ts +0 -13
  161. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  162. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  163. package/dist/types/client/ui/DataTable.d.ts +0 -17
  164. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  165. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  166. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  167. package/dist/types/client/ui/Panel.d.ts +0 -7
  168. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  169. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  170. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  171. package/src/client/app/AppLayout.tsx +0 -93
  172. package/src/client/app/NavRail.tsx +0 -44
  173. package/src/client/app/RangeControl.tsx +0 -39
  174. package/src/client/app/SyncButton.tsx +0 -75
  175. package/src/client/app/TopBar.tsx +0 -73
  176. package/src/client/app/routes.ts +0 -93
  177. package/src/client/components/AgentTokenShare.tsx +0 -68
  178. package/src/client/components/chart-shared.tsx +0 -273
  179. package/src/client/components/models-table-shared.tsx +0 -255
  180. package/src/client/components/range-meta.ts +0 -73
  181. package/src/client/data/charts.ts +0 -14
  182. package/src/client/data/useResource.ts +0 -154
  183. package/src/client/routes/BehaviorRoute.tsx +0 -623
  184. package/src/client/ui/AsyncBoundary.tsx +0 -54
  185. package/src/client/ui/DataTable.tsx +0 -122
  186. package/src/client/ui/EmptyState.tsx +0 -16
  187. package/src/client/ui/ErrorState.tsx +0 -25
  188. package/src/client/ui/MetricCluster.tsx +0 -92
  189. package/src/client/ui/Panel.tsx +0 -24
  190. package/src/client/ui/SegmentedControl.tsx +0 -36
  191. package/src/client/ui/Skeleton.tsx +0 -17
  192. package/src/client/ui/StatusPill.tsx +0 -15
  193. package/tailwind.config.js +0 -40
package/src/aggregator.ts CHANGED
@@ -1,19 +1,31 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getStatsDbPath, workerHostEntry } from "@linxiraos/pi-utils";
3
+ import { getAgentDbPath, getStatsDbPath, workerHostEntry } from "@linxiraos/pi-utils";
4
4
  import { withFileLock } from "@linxiraos/pi-utils/file-lock";
5
5
  import {
6
- applySessionParseResult,
6
+ applySessionParseResults,
7
7
  completeSessionSync,
8
8
  getRecentErrors as dbGetRecentErrors,
9
9
  getRecentRequests as dbGetRecentRequests,
10
- getBehaviorByModel,
11
- getBehaviorOverall,
12
- getBehaviorTimeSeries,
13
- getCostTimeSeries,
14
- getFileOffset,
10
+ type FileOffset,
11
+ getFileOffsets,
15
12
  getMessageById,
16
13
  getMessageCount,
14
+ initDb,
15
+ markSessionBackfillsComplete,
16
+ type ParsedSession,
17
+ prepareSessionSync,
18
+ } from "./db";
19
+ import {
20
+ getSessionEntry,
21
+ listAllSessionFiles,
22
+ matchesSessionFile,
23
+ type ParseSessionResult,
24
+ parseSessionFile,
25
+ type SessionParserState,
26
+ } from "./parser";
27
+ import {
28
+ getCostTimeSeries,
17
29
  getModelPerformanceSeries,
18
30
  getModelTimeSeries,
19
31
  getOverallStats,
@@ -27,37 +39,31 @@ import {
27
39
  getToolStats,
28
40
  getToolStatsByModel,
29
41
  getToolTimeSeries,
30
- initDb,
31
- markSessionBackfillsComplete,
32
- prepareSessionSync,
33
- pruneOrphanSessions,
34
- } from "./db";
35
- import {
36
- getSessionEntry,
37
- listAllSessionFiles,
38
- matchesSessionFile,
39
- type ParseSessionResult,
40
- parseSessionFile,
41
- type SessionParserState,
42
- } from "./parser";
42
+ type RangeWindow,
43
+ } from "./rollup";
43
44
  import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
44
45
  // Coding-agent binary/bundle workers route through the CLI entrypoint with a
45
46
  // hidden argv mode, so the compiled binary and npm bundle only need one
46
- // JavaScript entry. Standalone source `zeta-stats` keeps using this package's
47
+ // JavaScript entry. Standalone source `omp-stats` keeps using this package's
47
48
  // own sync-worker source file.
48
49
  import type {
49
- BehaviorDashboardStats,
50
50
  DashboardStats,
51
51
  FolderStats,
52
52
  MessageStats,
53
53
  ProviderDashboardStats,
54
+ ProviderWindowStats,
54
55
  RequestDetails,
55
56
  ToolDashboardStats,
56
57
  } from "./types";
57
- import { computeUsageWindowStats, fetchUsageData } from "./usage-windows";
58
+ import { computeUsageWindowStats, fetchUsageData, type UsageDataSnapshot } from "./usage-windows";
58
59
 
59
60
  const STATS_SYNC_LOCK_RETRY_MS = 25;
60
61
  const STATS_SYNC_LOCK_WAIT_MS = 60 * 60 * 1000;
62
+ // Bound queued results by both file count and derived rows; never hold a SQLite transaction across I/O.
63
+ const SYNC_BATCH_FILES = 512;
64
+ const SYNC_BATCH_ROWS = 8192;
65
+ const SYNC_METADATA_FILES = 128;
66
+ const SYNC_INLINE_READS = 8;
61
67
 
62
68
  /**
63
69
  * Serialize stats ingestion and archive reconciliation across processes.
@@ -73,11 +79,12 @@ export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>)
73
79
  retries: Math.ceil(STATS_SYNC_LOCK_WAIT_MS / STATS_SYNC_LOCK_RETRY_MS),
74
80
  });
75
81
  }
82
+
76
83
  /**
77
84
  * Progress event emitted after each session file is fully processed.
78
85
  * `current` is the number of files completed (skipped + parsed),
79
86
  * `total` is the size of the work set. `processed` is the running total
80
- * of inserted rows.
87
+ * of inserted rows. Parsed files are reported only after their batch commits.
81
88
  */
82
89
  export interface SyncProgress {
83
90
  current: number;
@@ -92,9 +99,14 @@ export interface SyncOptions {
92
99
  /**
93
100
  * Worker pool size. Defaults to a sensible value derived from the host
94
101
  * (capped to avoid drowning a small machine in workers). Set to `1` to
95
- * force serial parsing without spawning workers.
102
+ * parse on the calling thread without spawning workers. File I/O is pipelined.
96
103
  */
97
104
  workers?: number;
105
+ /**
106
+ * Sync only these transcripts (e.g. files a watcher saw change) instead of
107
+ * listing every session. Missing files are skipped.
108
+ */
109
+ files?: readonly string[];
98
110
  }
99
111
 
100
112
  function defaultWorkerCount(): number {
@@ -222,9 +234,11 @@ export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: n
222
234
  /**
223
235
  * Sync all session files to the database.
224
236
  *
225
- * `workers: 1` parses inline. Larger pools fan parsing out across workers
237
+ * `workers: 1` pipelines file reads and parses inline, committing in discovery order.
238
+ * Larger pools fan parsing out across workers
226
239
  * (one in-flight job per worker) while DB writes and offset bookkeeping stay on
227
- * the calling thread so the single SQLite handle stays uncontended.
240
+ * the calling thread. Bounded batches commit rows and cursors atomically without
241
+ * holding a database transaction open during file I/O.
228
242
  * `onProgress` fires once per completed file (skipped files included so the
229
243
  * bar walks at a steady rate).
230
244
  */
@@ -241,27 +255,58 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
241
255
  });
242
256
  }
243
257
 
258
+ /** Recency tiers for {@link orderForIngest}, newest first. */
259
+ const INGEST_TIERS_MS = [24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000];
260
+
261
+ /**
262
+ * Order a full sync so recent activity lands first: transcripts modified in
263
+ * the last day, then the last week, then the rest. Within a tier files go in
264
+ * creation order, so a fork still follows its parent and the parent keeps
265
+ * ownership of the entries the fork copied (first writer wins). The only
266
+ * exception is a from-scratch ingest where a fork was touched more recently
267
+ * than a parent idle for a day: the fork then owns the copied entries, which
268
+ * changes per-session attribution but never totals.
269
+ */
270
+ async function orderForIngest(files: string[]): Promise<string[]> {
271
+ const now = Date.now();
272
+ const ranked: { file: string; tier: number; born: number; index: number }[] = [];
273
+ for (let start = 0; start < files.length; start += SYNC_METADATA_FILES) {
274
+ const batch = files.slice(start, start + SYNC_METADATA_FILES);
275
+ const stats = await Promise.all(batch.map(file => fs.promises.stat(file).catch(() => null)));
276
+ for (let i = 0; i < batch.length; i++) {
277
+ const stat = stats[i];
278
+ const age = stat ? now - stat.mtimeMs : Number.POSITIVE_INFINITY;
279
+ const tier = INGEST_TIERS_MS.findIndex(limit => age <= limit);
280
+ ranked.push({
281
+ file: batch[i],
282
+ tier: tier === -1 ? INGEST_TIERS_MS.length : tier,
283
+ born: stat ? stat.birthtimeMs || stat.ctimeMs : 0,
284
+ index: start + i,
285
+ });
286
+ }
287
+ }
288
+ ranked.sort((a, b) => a.tier - b.tier || a.born - b.born || a.index - b.index);
289
+ return ranked.map(entry => entry.file);
290
+ }
291
+
244
292
  async function syncAllSessionsLocked(
245
293
  opts?: SyncOptions,
246
294
  ): Promise<{ processed: number; files: number; reconcile: boolean }> {
247
295
  await initDb();
248
296
  const replay = prepareSessionSync();
249
297
 
250
- const files = await listAllSessionFiles();
251
- // Drop rows for transcripts that no longer exist on disk (deleted temp/test
252
- // projects) before parsing, so every aggregate reflects live files only.
253
- pruneOrphanSessions(files);
298
+ const files = opts?.files ?? (await orderForIngest(await listAllSessionFiles()));
254
299
  let totalProcessed = 0;
255
300
  let filesProcessed = 0;
256
301
  let completed = 0;
257
302
  let cursor = 0;
258
303
  let reconcile = false;
259
- const finish = () => {
260
- completeSessionSync(reconcile);
261
- markSessionBackfillsComplete();
262
- return { processed: totalProcessed, files: filesProcessed, reconcile };
263
- };
264
- if (files.length === 0) return finish();
304
+ let pending: ParsedSession[] = [];
305
+ let pendingRows = 0;
306
+ let failed = false;
307
+ let metadataStart = 0;
308
+ let metadataEnd = 0;
309
+ let metadata: Promise<{ fileStats?: fs.Stats; stored?: FileOffset }[]> = Promise.resolve([]);
265
310
 
266
311
  const report = (sessionFile: string) => {
267
312
  completed++;
@@ -273,53 +318,117 @@ async function syncAllSessionsLocked(
273
318
  });
274
319
  };
275
320
 
276
- const processFile = async (
277
- sessionFile: string,
321
+ const flush = () => {
322
+ if (pending.length === 0) return;
323
+ const batch = pending;
324
+ const applied = applySessionParseResults(batch);
325
+ pending = [];
326
+ pendingRows = 0;
327
+ totalProcessed += applied.processed;
328
+ filesProcessed += applied.files;
329
+ reconcile ||= applied.reconcile;
330
+ // Report only durable progress: callbacks may interrupt the sync.
331
+ for (const { sessionFile } of batch) report(sessionFile);
332
+ };
333
+
334
+ const finish = () => {
335
+ flush();
336
+ // A targeted sync cannot settle reconciliation or backfills (they need
337
+ // every transcript); leave their markers for the next full sync.
338
+ if (opts?.files) return { processed: totalProcessed, files: filesProcessed, reconcile: false };
339
+ completeSessionSync(reconcile);
340
+ markSessionBackfillsComplete();
341
+ return { processed: totalProcessed, files: filesProcessed, reconcile };
342
+ };
343
+ if (files.length === 0) return finish();
344
+
345
+ const prepareFile = async (
346
+ index: number,
278
347
  parse: (
279
348
  sessionFile: string,
280
349
  fromOffset: number,
281
350
  state?: SessionParserState,
282
351
  replay?: boolean,
283
352
  ) => Promise<ParseSessionResult>,
284
- ): Promise<void> => {
285
- let fileStats: fs.Stats;
286
- try {
287
- fileStats = await fs.promises.stat(sessionFile);
288
- } catch {
289
- report(sessionFile);
290
- return;
353
+ ): Promise<ParsedSession | null> => {
354
+ if (index >= metadataEnd) {
355
+ const batch = files.slice(index, index + SYNC_METADATA_FILES);
356
+ const offsets = getFileOffsets(batch);
357
+ metadataStart = index;
358
+ metadataEnd = index + batch.length;
359
+ metadata = Promise.all(
360
+ batch.map(async sessionFile => {
361
+ try {
362
+ const fileStats = await fs.promises.stat(sessionFile);
363
+ return { fileStats, stored: offsets.get(sessionFile) };
364
+ } catch {
365
+ return {};
366
+ }
367
+ }),
368
+ );
291
369
  }
292
- const lastModified = fileStats.mtimeMs;
293
- const stored = getFileOffset(sessionFile);
370
+ // Capture the position before awaiting: another worker can start the next metadata batch.
371
+ const metadataIndex = index - metadataStart;
372
+ const { fileStats, stored } = (await metadata)[metadataIndex];
373
+ if (failed || !fileStats) return null;
294
374
  if (
295
375
  !replay &&
296
376
  stored?.parserState &&
297
- stored.lastModified === lastModified &&
377
+ stored.lastModified === fileStats.mtimeMs &&
298
378
  stored.parserState.size === fileStats.size &&
299
379
  matchesSessionFile(stored.parserState, fileStats)
300
380
  ) {
301
- report(sessionFile);
302
- return;
381
+ return null;
303
382
  }
304
383
 
305
- const unknownIdentity = stored !== null && !stored.parserState;
384
+ const sessionFile = files[index];
385
+ const unknownIdentity = stored !== undefined && !stored.parserState;
306
386
  const fromOffset = unknownIdentity ? 0 : (stored?.offset ?? 0);
307
387
  const result = await parse(sessionFile, fromOffset, stored?.parserState, replay);
308
388
  if (unknownIdentity && result.parserState) result.reset = true;
309
- const applied = applySessionParseResult(sessionFile, result, replay || !stored?.parserState);
310
- const inserted = applied.processed;
311
- if (applied.reconcile) reconcile = true;
312
- if (inserted > 0) {
313
- totalProcessed += inserted;
314
- filesProcessed++;
389
+ return {
390
+ sessionFile,
391
+ result,
392
+ // During a full replay, a changed file can be a rewrite even if its old tail checkpoint matches.
393
+ rebuild:
394
+ !stored?.parserState ||
395
+ (replay &&
396
+ (result.parserState?.size !== stored.parserState.size ||
397
+ result.parserState?.mtimeMs !== stored.parserState.mtimeMs)),
398
+ replay,
399
+ };
400
+ };
401
+
402
+ const acceptFile = (sessionFile: string, parsed: ParsedSession | null) => {
403
+ if (!parsed) {
404
+ report(sessionFile);
405
+ return;
315
406
  }
316
- report(sessionFile);
407
+ pending.push(parsed);
408
+ const { result } = parsed;
409
+ pendingRows +=
410
+ result.stats.length +
411
+ result.userStats.length +
412
+ result.userLinks.length +
413
+ result.toolCalls.length +
414
+ result.toolResults.length;
415
+ if (pending.length >= SYNC_BATCH_FILES || pendingRows >= SYNC_BATCH_ROWS) flush();
317
416
  };
318
417
 
319
418
  const requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
320
419
  if (requestedWorkers === 1) {
321
- for (const sessionFile of files) {
322
- await processFile(sessionFile, parseSessionFile);
420
+ for (let start = 0; start < files.length; start += SYNC_INLINE_READS) {
421
+ const results = await Promise.allSettled(
422
+ Array.from({ length: Math.min(SYNC_INLINE_READS, files.length - start) }, (_, offset) =>
423
+ prepareFile(start + offset, parseSessionFile),
424
+ ),
425
+ );
426
+ // Preserve fork ownership order and drain reads before a callback can release the sync lock.
427
+ for (let offset = 0; offset < results.length; offset++) {
428
+ const result = results[offset];
429
+ if (result.status === "rejected") throw result.reason;
430
+ acceptFile(files[start + offset], result.value);
431
+ }
323
432
  }
324
433
  return finish();
325
434
  }
@@ -327,21 +436,30 @@ async function syncAllSessionsLocked(
327
436
  const poolSize = Math.min(files.length, requestedWorkers);
328
437
 
329
438
  const handles: WorkerHandle[] = [];
330
- for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
331
439
 
332
440
  async function drain(handle: WorkerHandle): Promise<void> {
333
- while (true) {
334
- const idx = cursor++;
335
- if (idx >= files.length) return;
336
- const sessionFile = files[idx];
337
- await processFile(sessionFile, (file, fromOffset, parserState, replay) =>
338
- dispatch(handle, { sessionFile: file, fromOffset, parserState, replay }),
339
- );
441
+ try {
442
+ while (!failed) {
443
+ const idx = cursor++;
444
+ if (idx >= files.length) return;
445
+ const parsed = await prepareFile(idx, (file, fromOffset, parserState, replay) =>
446
+ dispatch(handle, { sessionFile: file, fromOffset, parserState, replay }),
447
+ );
448
+ if (!failed) acceptFile(files[idx], parsed);
449
+ }
450
+ } catch (error) {
451
+ failed = true;
452
+ throw error;
340
453
  }
341
454
  }
342
455
 
343
456
  try {
344
- await Promise.all(handles.map(drain));
457
+ for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
458
+ // Drain in-flight work before releasing the sync lock, even after a failed batch.
459
+ const results = await Promise.allSettled(handles.map(drain));
460
+ for (const result of results) {
461
+ if (result.status === "rejected") throw result.reason;
462
+ }
345
463
  } finally {
346
464
  for (const handle of handles) handle.worker.terminate();
347
465
  }
@@ -351,92 +469,31 @@ async function syncAllSessionsLocked(
351
469
 
352
470
  const HOUR_MS = 60 * 60 * 1000;
353
471
  const DAY_MS = 24 * HOUR_MS;
354
- const FIVE_MIN_MS = 5 * 60 * 1000;
355
472
 
356
473
  type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
357
474
 
358
- interface TimeRangeConfig {
359
- timeSeriesHours: number;
360
- timeSeriesBucketMs: number;
361
- modelSeriesDays: number;
362
- modelSeriesBucketMs: number;
363
- modelPerformanceDays: number;
364
- modelPerformanceBucketMs: number;
365
- costSeriesDays: number;
366
- cutoff: number | null;
367
- }
368
-
369
475
  const DEFAULT_TIME_RANGE: TimeRange = "24h";
370
476
 
371
- const TIME_RANGE_TO_CONFIG: Record<TimeRange, Omit<TimeRangeConfig, "cutoff">> = {
372
- "1h": {
373
- timeSeriesHours: 1,
374
- timeSeriesBucketMs: FIVE_MIN_MS,
375
- modelSeriesDays: 1,
376
- modelSeriesBucketMs: FIVE_MIN_MS,
377
- modelPerformanceDays: 1,
378
- modelPerformanceBucketMs: FIVE_MIN_MS,
379
- costSeriesDays: 1,
380
- },
381
- "24h": {
382
- timeSeriesHours: 24,
383
- timeSeriesBucketMs: HOUR_MS,
384
- modelSeriesDays: 1,
385
- modelSeriesBucketMs: HOUR_MS,
386
- modelPerformanceDays: 1,
387
- modelPerformanceBucketMs: HOUR_MS,
388
- costSeriesDays: 1,
389
- },
390
- "7d": {
391
- timeSeriesHours: 24 * 7,
392
- timeSeriesBucketMs: DAY_MS,
393
- modelSeriesDays: 7,
394
- modelSeriesBucketMs: DAY_MS,
395
- modelPerformanceDays: 7,
396
- modelPerformanceBucketMs: DAY_MS,
397
- costSeriesDays: 7,
398
- },
399
- "30d": {
400
- timeSeriesHours: 24 * 30,
401
- timeSeriesBucketMs: DAY_MS,
402
- modelSeriesDays: 30,
403
- modelSeriesBucketMs: DAY_MS,
404
- modelPerformanceDays: 30,
405
- modelPerformanceBucketMs: DAY_MS,
406
- costSeriesDays: 30,
407
- },
408
- "90d": {
409
- timeSeriesHours: 24 * 90,
410
- timeSeriesBucketMs: DAY_MS,
411
- modelSeriesDays: 90,
412
- modelSeriesBucketMs: DAY_MS,
413
- modelPerformanceDays: 90,
414
- modelPerformanceBucketMs: DAY_MS,
415
- costSeriesDays: 90,
416
- },
417
- all: {
418
- timeSeriesHours: 24 * 3650,
419
- timeSeriesBucketMs: DAY_MS,
420
- modelSeriesDays: 3650,
421
- modelSeriesBucketMs: DAY_MS,
422
- modelPerformanceDays: 3650,
423
- modelPerformanceBucketMs: DAY_MS,
424
- costSeriesDays: 3650,
425
- },
477
+ /** Span and series bucket per range; mirrored by the client's `data/range.ts`. */
478
+ const TIME_RANGES: Record<TimeRange, { spanMs: number | null; bucketMs: number }> = {
479
+ "1h": { spanMs: HOUR_MS, bucketMs: 5 * 60 * 1000 },
480
+ "24h": { spanMs: DAY_MS, bucketMs: HOUR_MS },
481
+ "7d": { spanMs: 7 * DAY_MS, bucketMs: DAY_MS },
482
+ "30d": { spanMs: 30 * DAY_MS, bucketMs: DAY_MS },
483
+ "90d": { spanMs: 90 * DAY_MS, bucketMs: DAY_MS },
484
+ all: { spanMs: null, bucketMs: DAY_MS },
426
485
  };
427
486
 
428
- export function getTimeRangeConfig(range?: string | null): TimeRangeConfig {
429
- const normalized = range?.trim().toLowerCase() ?? DEFAULT_TIME_RANGE;
430
- const config = TIME_RANGE_TO_CONFIG[normalized as TimeRange];
431
- if (config) {
432
- const cutoff = normalized === "all" ? null : Date.now() - Math.max(1, config.timeSeriesHours * 60 * 60 * 1000);
433
- return { ...config, cutoff };
434
- }
487
+ /** Most folders the projects payload carries (busiest first); the tail is thousands of temp dirs. */
488
+ const FOLDER_LIMIT = 2000;
435
489
 
436
- const fallbackConfig = TIME_RANGE_TO_CONFIG[DEFAULT_TIME_RANGE];
490
+ /** Resolve a `?range=` value (default 24h) to its cutoff and series bucket size. */
491
+ export function getTimeRangeConfig(range?: string | null): RangeWindow {
492
+ const normalized = range?.trim().toLowerCase() ?? DEFAULT_TIME_RANGE;
493
+ const config = TIME_RANGES[normalized as TimeRange] ?? TIME_RANGES[DEFAULT_TIME_RANGE];
437
494
  return {
438
- ...fallbackConfig,
439
- cutoff: Date.now() - fallbackConfig.timeSeriesHours * 60 * 60 * 1000,
495
+ cutoff: config.spanMs === null ? null : Date.now() - config.spanMs,
496
+ bucketMs: config.bucketMs,
440
497
  };
441
498
  }
442
499
 
@@ -445,26 +502,16 @@ export function getTimeRangeConfig(range?: string | null): TimeRangeConfig {
445
502
  */
446
503
  export async function getDashboardStats(range?: string | null): Promise<DashboardStats> {
447
504
  await initDb();
448
- const {
449
- timeSeriesHours,
450
- timeSeriesBucketMs,
451
- modelSeriesDays,
452
- modelSeriesBucketMs,
453
- modelPerformanceDays,
454
- modelPerformanceBucketMs,
455
- costSeriesDays,
456
- cutoff,
457
- } = getTimeRangeConfig(range);
458
-
505
+ const window = getTimeRangeConfig(range);
459
506
  return {
460
- overall: getOverallStats(cutoff ?? undefined),
461
- byModel: getStatsByModel(cutoff ?? undefined),
462
- byFolder: getStatsByFolder(cutoff ?? undefined),
463
- byAgentType: getStatsByAgentType(cutoff ?? undefined),
464
- timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
465
- modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
466
- modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
467
- costSeries: getCostTimeSeries(costSeriesDays, cutoff),
507
+ overall: getOverallStats(window.cutoff),
508
+ byModel: getStatsByModel(window.cutoff),
509
+ byFolder: getStatsByFolder(window.cutoff, FOLDER_LIMIT),
510
+ byAgentType: getStatsByAgentType(window.cutoff),
511
+ timeSeries: getTimeSeries(window),
512
+ modelSeries: getModelTimeSeries(window),
513
+ modelPerformanceSeries: getModelPerformanceSeries(window),
514
+ costSeries: getCostTimeSeries(window.cutoff),
468
515
  };
469
516
  }
470
517
 
@@ -472,12 +519,11 @@ export async function getOverviewStats(
472
519
  range?: string | null,
473
520
  ): Promise<Pick<DashboardStats, "overall" | "byAgentType" | "timeSeries">> {
474
521
  await initDb();
475
- const { timeSeriesHours, timeSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
476
-
522
+ const window = getTimeRangeConfig(range);
477
523
  return {
478
- overall: getOverallStats(cutoff ?? undefined),
479
- byAgentType: getStatsByAgentType(cutoff ?? undefined),
480
- timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
524
+ overall: getOverallStats(window.cutoff),
525
+ byAgentType: getStatsByAgentType(window.cutoff),
526
+ timeSeries: getTimeSeries(window),
481
527
  };
482
528
  }
483
529
 
@@ -485,29 +531,22 @@ export async function getModelDashboardStats(
485
531
  range?: string | null,
486
532
  ): Promise<Pick<DashboardStats, "byModel" | "modelSeries" | "modelPerformanceSeries">> {
487
533
  await initDb();
488
- const { modelSeriesDays, modelSeriesBucketMs, modelPerformanceDays, modelPerformanceBucketMs, cutoff } =
489
- getTimeRangeConfig(range);
490
-
534
+ const window = getTimeRangeConfig(range);
491
535
  return {
492
- byModel: getStatsByModel(cutoff ?? undefined),
493
- modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
494
- modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
536
+ byModel: getStatsByModel(window.cutoff),
537
+ modelSeries: getModelTimeSeries(window),
538
+ modelPerformanceSeries: getModelPerformanceSeries(window),
495
539
  };
496
540
  }
497
541
 
498
542
  export async function getCostDashboardStats(range?: string | null): Promise<Pick<DashboardStats, "costSeries">> {
499
543
  await initDb();
500
- const { costSeriesDays, cutoff } = getTimeRangeConfig(range);
501
-
502
- return {
503
- costSeries: getCostTimeSeries(costSeriesDays, cutoff),
504
- };
544
+ return { costSeries: getCostTimeSeries(getTimeRangeConfig(range).cutoff) };
505
545
  }
506
546
 
507
547
  export async function getFolderStats(range?: string | null): Promise<FolderStats[]> {
508
548
  await initDb();
509
- const { cutoff } = getTimeRangeConfig(range);
510
- return getStatsByFolder(cutoff ?? undefined);
549
+ return getStatsByFolder(getTimeRangeConfig(range).cutoff, FOLDER_LIMIT);
511
550
  }
512
551
 
513
552
  export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
@@ -517,8 +556,7 @@ export async function getRecentRequests(limit?: number): Promise<MessageStats[]>
517
556
 
518
557
  export async function getRecentErrors(range?: string | null, limit?: number): Promise<MessageStats[]> {
519
558
  await initDb();
520
- const { cutoff } = getTimeRangeConfig(range);
521
- return dbGetRecentErrors(limit, cutoff);
559
+ return dbGetRecentErrors(limit, getTimeRangeConfig(range).cutoff);
522
560
  }
523
561
 
524
562
  export async function getRequestDetails(id: number): Promise<RequestDetails | null> {
@@ -527,16 +565,12 @@ export async function getRequestDetails(id: number): Promise<RequestDetails | nu
527
565
  if (!msg) return null;
528
566
 
529
567
  const entry = await getSessionEntry(msg.sessionFile, msg.entryId);
530
- if (entry?.type !== "message") return null;
531
-
532
- // TODO: Get parent/context messages?
533
- // For now we return the single entry which contains the assistant response.
534
- // The user prompt is likely the parent.
568
+ if (entry?.type !== "message" || !("message" in entry)) return null;
535
569
 
536
570
  return {
537
571
  ...msg,
538
572
  messages: [entry],
539
- output: (entry as any).message,
573
+ output: entry.message,
540
574
  };
541
575
  }
542
576
 
@@ -548,51 +582,79 @@ export async function getTotalMessageCount(): Promise<number> {
548
582
  return getMessageCount();
549
583
  }
550
584
 
551
- export async function getBehaviorDashboardStats(range?: string | null): Promise<BehaviorDashboardStats> {
585
+ /**
586
+ * Get the tools dashboard payload: per-tool totals, per-(tool, model)
587
+ * breakdown, and the call time series.
588
+ */
589
+ export async function getToolDashboardStats(range?: string | null): Promise<ToolDashboardStats> {
552
590
  await initDb();
553
- const { cutoff } = getTimeRangeConfig(range);
591
+ const window = getTimeRangeConfig(range);
554
592
  return {
555
- overall: getBehaviorOverall(cutoff),
556
- byModel: getBehaviorByModel(cutoff),
557
- behaviorSeries: getBehaviorTimeSeries(cutoff),
593
+ byTool: getToolStats(window.cutoff),
594
+ byToolModel: getToolStatsByModel(window.cutoff),
595
+ series: getToolTimeSeries(window),
558
596
  };
559
597
  }
560
598
 
561
599
  /**
562
- * Get the tools dashboard payload: per-tool totals, per-(tool, model)
563
- * breakdown, and the call time series (bucketed like the model series).
600
+ * Get the providers dashboard payload: per-provider totals, peak-burn-hours
601
+ * histogram and provider token time series. Subscription windows are served
602
+ * separately by {@link getProviderWindowStats}.
564
603
  */
565
- export async function getToolDashboardStats(range?: string | null): Promise<ToolDashboardStats> {
604
+ export async function getProviderDashboardStats(range?: string | null): Promise<ProviderDashboardStats> {
566
605
  await initDb();
567
- const { modelSeriesDays, modelSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
606
+ const window = getTimeRangeConfig(range);
568
607
  return {
569
- byTool: getToolStats(cutoff ?? undefined),
570
- byToolModel: getToolStatsByModel(cutoff ?? undefined),
571
- series: getToolTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
608
+ providers: getStatsByProvider(window.cutoff),
609
+ hourly: getProviderHourlyBurn(window.cutoff),
610
+ series: getProviderTimeSeries(window),
572
611
  };
573
612
  }
574
613
 
614
+ const USAGE_CACHE_TTL_MS = 60_000;
615
+ let usageCache: { key: string; at: number; data: Promise<UsageDataSnapshot> } | null = null;
616
+
575
617
  /**
576
- * Get the providers dashboard payload: per-provider totals, peak-burn-hours
577
- * histogram, provider token time series, and subscription-window analytics
578
- * (utilization series + insights) derived from recorded usage-limit snapshots.
618
+ * Usage snapshots since `sinceMs`, memoized for a minute per hour-aligned
619
+ * cutoff and source database: the broker fetch takes seconds and the
620
+ * dashboard asks on every range switch and live refresh.
621
+ */
622
+ function cachedUsageData(sinceMs: number): Promise<UsageDataSnapshot> {
623
+ const sinceHour = Math.floor(sinceMs / HOUR_MS) * HOUR_MS;
624
+ const key = `${getAgentDbPath()}:${sinceHour}`;
625
+ const now = Date.now();
626
+ if (usageCache?.key === key && now - usageCache.at < USAGE_CACHE_TTL_MS) {
627
+ return usageCache.data;
628
+ }
629
+ const data = fetchUsageData(sinceHour);
630
+ usageCache = { key, at: now, data };
631
+ data.catch(() => {
632
+ if (usageCache?.data === data) usageCache = null;
633
+ });
634
+ return data;
635
+ }
636
+
637
+ /**
638
+ * Subscription-window analytics derived from recorded usage-limit snapshots:
639
+ * insights for every provider window, plus utilization series for `provider`
640
+ * only (all series can number in the thousands).
579
641
  *
580
642
  * Window token estimates use broker-held fleet token burn when a broker is
581
643
  * configured — the window fractions cover every install sharing the broker's
582
644
  * credentials, so dividing them into local-only tokens would undercount.
583
645
  */
584
- export async function getProviderDashboardStats(range?: string | null): Promise<ProviderDashboardStats> {
646
+ export async function getProviderWindowStats(
647
+ range?: string | null,
648
+ provider?: string | null,
649
+ ): Promise<ProviderWindowStats> {
585
650
  await initDb();
586
- const { modelSeriesDays, modelSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
587
- const providers = getStatsByProvider(cutoff ?? undefined);
588
- const usage = await fetchUsageData(cutoff ?? 0);
589
- const tokensByProvider = usage.fleetTokensByProvider ?? new Map(providers.map(p => [p.provider, p.totalTokens]));
651
+ const { cutoff } = getTimeRangeConfig(range);
652
+ const usage = await cachedUsageData(cutoff ?? 0);
653
+ const tokensByProvider =
654
+ usage.fleetTokensByProvider ?? new Map(getStatsByProvider(cutoff).map(p => [p.provider, p.totalTokens]));
590
655
  const { usageSeries, windowInsights } = computeUsageWindowStats(usage.rows, tokensByProvider);
591
656
  return {
592
- providers,
593
- hourly: getProviderHourlyBurn(cutoff ?? undefined),
594
- series: getProviderTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
595
- usageSeries,
596
657
  windowInsights,
658
+ usageSeries: provider ? usageSeries.filter(series => series.provider === provider) : [],
597
659
  };
598
660
  }