@oh-my-pi/omp-stats 18.3.5 → 18.4.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 (192) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +13 -11
  3. package/build.ts +5 -49
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/index.js +105 -126
  7. package/dist/types/aggregator.d.ts +25 -22
  8. package/dist/types/client/api.d.ts +11 -7
  9. package/dist/types/client/app/LiveChip.d.ts +2 -0
  10. package/dist/types/client/app/Shell.d.ts +16 -0
  11. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  12. package/dist/types/client/app/nav.d.ts +18 -0
  13. package/dist/types/client/charts/BarList.d.ts +17 -0
  14. package/dist/types/client/charts/Chart.d.ts +49 -0
  15. package/dist/types/client/charts/Legend.d.ts +16 -0
  16. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  17. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  18. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  19. package/dist/types/client/charts/index.d.ts +7 -0
  20. package/dist/types/client/charts/types.d.ts +25 -0
  21. package/dist/types/client/charts/useWidth.d.ts +3 -0
  22. package/dist/types/client/data/colors.d.ts +25 -0
  23. package/dist/types/client/data/formatters.d.ts +9 -1
  24. package/dist/types/client/data/live.d.ts +29 -0
  25. package/dist/types/client/data/query.d.ts +35 -0
  26. package/dist/types/client/data/range.d.ts +31 -0
  27. package/dist/types/client/data/series.d.ts +28 -0
  28. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  29. package/dist/types/client/data/view-models.d.ts +111 -28
  30. package/dist/types/client/index.d.ts +0 -1
  31. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  32. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  34. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  35. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  36. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  37. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  38. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  39. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  40. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  41. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  42. package/dist/types/client/routes/index.d.ts +1 -1
  43. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  44. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  45. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  46. package/dist/types/client/ui/Badge.d.ts +19 -0
  47. package/dist/types/client/ui/Card.d.ts +25 -0
  48. package/dist/types/client/ui/Drawer.d.ts +25 -0
  49. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  50. package/dist/types/client/ui/Modal.d.ts +23 -0
  51. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  52. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  53. package/dist/types/client/ui/Segmented.d.ts +15 -0
  54. package/dist/types/client/ui/Stat.d.ts +36 -0
  55. package/dist/types/client/ui/States.d.ts +41 -0
  56. package/dist/types/client/ui/Table.d.ts +48 -0
  57. package/dist/types/client/ui/index.d.ts +9 -10
  58. package/dist/types/db.d.ts +90 -106
  59. package/dist/types/frustration.d.ts +70 -0
  60. package/dist/types/index.d.ts +3 -1
  61. package/dist/types/live.d.ts +30 -0
  62. package/dist/types/rollup.d.ts +115 -0
  63. package/dist/types/server.d.ts +6 -1
  64. package/dist/types/shared-types.d.ts +116 -56
  65. package/dist/types/types.d.ts +4 -0
  66. package/dist/types/user-metrics.d.ts +8 -0
  67. package/package.json +7 -13
  68. package/src/aggregator.ts +276 -211
  69. package/src/client/App.tsx +38 -80
  70. package/src/client/api.ts +65 -7
  71. package/src/client/app/LiveChip.tsx +97 -0
  72. package/src/client/app/Shell.tsx +181 -0
  73. package/src/client/app/ThemeToggle.tsx +5 -2
  74. package/src/client/app/nav.ts +78 -0
  75. package/src/client/charts/BarList.tsx +48 -0
  76. package/src/client/charts/Chart.tsx +388 -0
  77. package/src/client/charts/Legend.tsx +61 -0
  78. package/src/client/charts/ShareBar.tsx +26 -0
  79. package/src/client/charts/Sparkline.tsx +38 -0
  80. package/src/client/charts/TimeChart.tsx +22 -0
  81. package/src/client/charts/index.ts +7 -0
  82. package/src/client/charts/types.ts +27 -0
  83. package/src/client/charts/useWidth.ts +19 -0
  84. package/src/client/data/colors.ts +45 -0
  85. package/src/client/data/formatters.ts +30 -9
  86. package/src/client/data/live.tsx +85 -0
  87. package/src/client/data/query.ts +166 -0
  88. package/src/client/data/range.ts +82 -0
  89. package/src/client/data/series.ts +85 -0
  90. package/src/client/data/useHashRoute.ts +41 -83
  91. package/src/client/data/view-models.ts +347 -140
  92. package/src/client/index.tsx +3 -6
  93. package/src/client/routes/CostsRoute.tsx +394 -222
  94. package/src/client/routes/ErrorsRoute.tsx +401 -98
  95. package/src/client/routes/FrustrationRoute.tsx +924 -0
  96. package/src/client/routes/GainRoute.tsx +209 -206
  97. package/src/client/routes/ModelsRoute.tsx +540 -431
  98. package/src/client/routes/OverviewRoute.tsx +283 -311
  99. package/src/client/routes/ProjectsRoute.tsx +298 -156
  100. package/src/client/routes/ProvidersRoute.tsx +948 -416
  101. package/src/client/routes/RequestsRoute.tsx +295 -107
  102. package/src/client/routes/ToolsRoute.tsx +495 -398
  103. package/src/client/routes/TracesRoute.tsx +136 -117
  104. package/src/client/routes/costs.css +24 -0
  105. package/src/client/routes/errors.css +60 -0
  106. package/src/client/routes/frustration.css +123 -0
  107. package/src/client/routes/index.ts +1 -1
  108. package/src/client/routes/models.css +43 -0
  109. package/src/client/routes/projects.css +6 -0
  110. package/src/client/routes/providers.css +28 -0
  111. package/src/client/routes/tools.css +14 -0
  112. package/src/client/styles.css +1396 -1299
  113. package/src/client/traces/AggregatesPanel.tsx +55 -74
  114. package/src/client/traces/Minimap.tsx +6 -15
  115. package/src/client/traces/SpanDrawer.tsx +128 -194
  116. package/src/client/traces/SummaryStrip.tsx +21 -24
  117. package/src/client/traces/TimelineCanvas.tsx +27 -44
  118. package/src/client/traces/TraceView.tsx +175 -130
  119. package/src/client/traces/TranscriptList.tsx +13 -41
  120. package/src/client/traces/trace-colors.ts +60 -53
  121. package/src/client/traces/traces.css +279 -0
  122. package/src/client/ui/Badge.tsx +29 -0
  123. package/src/client/ui/Card.tsx +61 -0
  124. package/src/client/ui/Drawer.tsx +93 -0
  125. package/src/client/ui/JsonBlock.tsx +26 -44
  126. package/src/client/ui/Modal.tsx +106 -0
  127. package/src/client/ui/RequestDrawer.tsx +163 -188
  128. package/src/client/ui/SearchInput.tsx +25 -0
  129. package/src/client/ui/Segmented.tsx +61 -0
  130. package/src/client/ui/Stat.tsx +104 -0
  131. package/src/client/ui/States.tsx +81 -0
  132. package/src/client/ui/Table.tsx +229 -0
  133. package/src/client/ui/index.ts +9 -10
  134. package/src/client/ui/modal.css +54 -0
  135. package/src/client/ui/request-drawer.css +40 -0
  136. package/src/db.ts +379 -983
  137. package/src/frustration.ts +473 -0
  138. package/src/index.ts +22 -22
  139. package/src/live.ts +259 -0
  140. package/src/parser.ts +55 -66
  141. package/src/rollup.ts +951 -0
  142. package/src/server.ts +123 -40
  143. package/src/shared-types.ts +120 -56
  144. package/src/trace.ts +14 -7
  145. package/src/types.ts +4 -0
  146. package/src/user-metrics.ts +13 -0
  147. package/dist/client/styles.css +0 -1976
  148. package/dist/types/client/app/AppLayout.d.ts +0 -16
  149. package/dist/types/client/app/NavRail.d.ts +0 -7
  150. package/dist/types/client/app/RangeControl.d.ts +0 -7
  151. package/dist/types/client/app/SyncButton.d.ts +0 -14
  152. package/dist/types/client/app/TopBar.d.ts +0 -15
  153. package/dist/types/client/app/routes.d.ts +0 -12
  154. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  155. package/dist/types/client/components/chart-shared.d.ts +0 -178
  156. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  157. package/dist/types/client/components/range-meta.d.ts +0 -21
  158. package/dist/types/client/data/charts.d.ts +0 -1
  159. package/dist/types/client/data/useResource.d.ts +0 -13
  160. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  161. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  162. package/dist/types/client/ui/DataTable.d.ts +0 -17
  163. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  164. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  165. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  166. package/dist/types/client/ui/Panel.d.ts +0 -7
  167. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  168. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  169. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  170. package/src/client/app/AppLayout.tsx +0 -93
  171. package/src/client/app/NavRail.tsx +0 -44
  172. package/src/client/app/RangeControl.tsx +0 -39
  173. package/src/client/app/SyncButton.tsx +0 -75
  174. package/src/client/app/TopBar.tsx +0 -73
  175. package/src/client/app/routes.ts +0 -93
  176. package/src/client/components/AgentTokenShare.tsx +0 -68
  177. package/src/client/components/chart-shared.tsx +0 -273
  178. package/src/client/components/models-table-shared.tsx +0 -255
  179. package/src/client/components/range-meta.ts +0 -73
  180. package/src/client/data/charts.ts +0 -14
  181. package/src/client/data/useResource.ts +0 -154
  182. package/src/client/routes/BehaviorRoute.tsx +0 -623
  183. package/src/client/ui/AsyncBoundary.tsx +0 -54
  184. package/src/client/ui/DataTable.tsx +0 -122
  185. package/src/client/ui/EmptyState.tsx +0 -16
  186. package/src/client/ui/ErrorState.tsx +0 -25
  187. package/src/client/ui/MetricCluster.tsx +0 -92
  188. package/src/client/ui/Panel.tsx +0 -24
  189. package/src/client/ui/SegmentedControl.tsx +0 -36
  190. package/src/client/ui/Skeleton.tsx +0 -17
  191. package/src/client/ui/StatusPill.tsx +0 -15
  192. 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 "@oh-my-pi/pi-utils";
3
+ import { getAgentDbPath, getStatsDbPath, workerHostEntry } from "@oh-my-pi/pi-utils";
4
4
  import { withFileLock } from "@oh-my-pi/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,36 +39,31 @@ import {
27
39
  getToolStats,
28
40
  getToolStatsByModel,
29
41
  getToolTimeSeries,
30
- initDb,
31
- markSessionBackfillsComplete,
32
- prepareSessionSync,
33
- } from "./db";
34
- import {
35
- getSessionEntry,
36
- listAllSessionFiles,
37
- matchesSessionFile,
38
- type ParseSessionResult,
39
- parseSessionFile,
40
- type SessionParserState,
41
- } from "./parser";
42
+ type RangeWindow,
43
+ } from "./rollup";
42
44
  import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
43
45
  // Coding-agent binary/bundle workers route through the CLI entrypoint with a
44
46
  // hidden argv mode, so the compiled binary and npm bundle only need one
45
47
  // JavaScript entry. Standalone source `omp-stats` keeps using this package's
46
48
  // own sync-worker source file.
47
49
  import type {
48
- BehaviorDashboardStats,
49
50
  DashboardStats,
50
51
  FolderStats,
51
52
  MessageStats,
52
53
  ProviderDashboardStats,
54
+ ProviderWindowStats,
53
55
  RequestDetails,
54
56
  ToolDashboardStats,
55
57
  } from "./types";
56
- import { computeUsageWindowStats, fetchUsageData } from "./usage-windows";
58
+ import { computeUsageWindowStats, fetchUsageData, type UsageDataSnapshot } from "./usage-windows";
57
59
 
58
60
  const STATS_SYNC_LOCK_RETRY_MS = 25;
59
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;
60
67
 
61
68
  /**
62
69
  * Serialize stats ingestion and archive reconciliation across processes.
@@ -77,7 +84,7 @@ export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>)
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,24 +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();
298
+ const files = opts?.files ?? (await orderForIngest(await listAllSessionFiles()));
251
299
  let totalProcessed = 0;
252
300
  let filesProcessed = 0;
253
301
  let completed = 0;
254
302
  let cursor = 0;
255
303
  let reconcile = false;
256
- const finish = () => {
257
- completeSessionSync(reconcile);
258
- markSessionBackfillsComplete();
259
- return { processed: totalProcessed, files: filesProcessed, reconcile };
260
- };
261
- 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([]);
262
310
 
263
311
  const report = (sessionFile: string) => {
264
312
  completed++;
@@ -270,53 +318,117 @@ async function syncAllSessionsLocked(
270
318
  });
271
319
  };
272
320
 
273
- const processFile = async (
274
- 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,
275
347
  parse: (
276
348
  sessionFile: string,
277
349
  fromOffset: number,
278
350
  state?: SessionParserState,
279
351
  replay?: boolean,
280
352
  ) => Promise<ParseSessionResult>,
281
- ): Promise<void> => {
282
- let fileStats: fs.Stats;
283
- try {
284
- fileStats = await fs.promises.stat(sessionFile);
285
- } catch {
286
- report(sessionFile);
287
- 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
+ );
288
369
  }
289
- const lastModified = fileStats.mtimeMs;
290
- 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;
291
374
  if (
292
375
  !replay &&
293
376
  stored?.parserState &&
294
- stored.lastModified === lastModified &&
377
+ stored.lastModified === fileStats.mtimeMs &&
295
378
  stored.parserState.size === fileStats.size &&
296
379
  matchesSessionFile(stored.parserState, fileStats)
297
380
  ) {
298
- report(sessionFile);
299
- return;
381
+ return null;
300
382
  }
301
383
 
302
- const unknownIdentity = stored !== null && !stored.parserState;
384
+ const sessionFile = files[index];
385
+ const unknownIdentity = stored !== undefined && !stored.parserState;
303
386
  const fromOffset = unknownIdentity ? 0 : (stored?.offset ?? 0);
304
387
  const result = await parse(sessionFile, fromOffset, stored?.parserState, replay);
305
388
  if (unknownIdentity && result.parserState) result.reset = true;
306
- const applied = applySessionParseResult(sessionFile, result, replay || !stored?.parserState);
307
- const inserted = applied.processed;
308
- if (applied.reconcile) reconcile = true;
309
- if (inserted > 0) {
310
- totalProcessed += inserted;
311
- 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;
312
406
  }
313
- 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();
314
416
  };
315
417
 
316
418
  const requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
317
419
  if (requestedWorkers === 1) {
318
- for (const sessionFile of files) {
319
- 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
+ }
320
432
  }
321
433
  return finish();
322
434
  }
@@ -324,21 +436,30 @@ async function syncAllSessionsLocked(
324
436
  const poolSize = Math.min(files.length, requestedWorkers);
325
437
 
326
438
  const handles: WorkerHandle[] = [];
327
- for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
328
439
 
329
440
  async function drain(handle: WorkerHandle): Promise<void> {
330
- while (true) {
331
- const idx = cursor++;
332
- if (idx >= files.length) return;
333
- const sessionFile = files[idx];
334
- await processFile(sessionFile, (file, fromOffset, parserState, replay) =>
335
- dispatch(handle, { sessionFile: file, fromOffset, parserState, replay }),
336
- );
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;
337
453
  }
338
454
  }
339
455
 
340
456
  try {
341
- 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
+ }
342
463
  } finally {
343
464
  for (const handle of handles) handle.worker.terminate();
344
465
  }
@@ -348,92 +469,31 @@ async function syncAllSessionsLocked(
348
469
 
349
470
  const HOUR_MS = 60 * 60 * 1000;
350
471
  const DAY_MS = 24 * HOUR_MS;
351
- const FIVE_MIN_MS = 5 * 60 * 1000;
352
472
 
353
473
  type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
354
474
 
355
- interface TimeRangeConfig {
356
- timeSeriesHours: number;
357
- timeSeriesBucketMs: number;
358
- modelSeriesDays: number;
359
- modelSeriesBucketMs: number;
360
- modelPerformanceDays: number;
361
- modelPerformanceBucketMs: number;
362
- costSeriesDays: number;
363
- cutoff: number | null;
364
- }
365
-
366
475
  const DEFAULT_TIME_RANGE: TimeRange = "24h";
367
476
 
368
- const TIME_RANGE_TO_CONFIG: Record<TimeRange, Omit<TimeRangeConfig, "cutoff">> = {
369
- "1h": {
370
- timeSeriesHours: 1,
371
- timeSeriesBucketMs: FIVE_MIN_MS,
372
- modelSeriesDays: 1,
373
- modelSeriesBucketMs: FIVE_MIN_MS,
374
- modelPerformanceDays: 1,
375
- modelPerformanceBucketMs: FIVE_MIN_MS,
376
- costSeriesDays: 1,
377
- },
378
- "24h": {
379
- timeSeriesHours: 24,
380
- timeSeriesBucketMs: HOUR_MS,
381
- modelSeriesDays: 1,
382
- modelSeriesBucketMs: HOUR_MS,
383
- modelPerformanceDays: 1,
384
- modelPerformanceBucketMs: HOUR_MS,
385
- costSeriesDays: 1,
386
- },
387
- "7d": {
388
- timeSeriesHours: 24 * 7,
389
- timeSeriesBucketMs: DAY_MS,
390
- modelSeriesDays: 7,
391
- modelSeriesBucketMs: DAY_MS,
392
- modelPerformanceDays: 7,
393
- modelPerformanceBucketMs: DAY_MS,
394
- costSeriesDays: 7,
395
- },
396
- "30d": {
397
- timeSeriesHours: 24 * 30,
398
- timeSeriesBucketMs: DAY_MS,
399
- modelSeriesDays: 30,
400
- modelSeriesBucketMs: DAY_MS,
401
- modelPerformanceDays: 30,
402
- modelPerformanceBucketMs: DAY_MS,
403
- costSeriesDays: 30,
404
- },
405
- "90d": {
406
- timeSeriesHours: 24 * 90,
407
- timeSeriesBucketMs: DAY_MS,
408
- modelSeriesDays: 90,
409
- modelSeriesBucketMs: DAY_MS,
410
- modelPerformanceDays: 90,
411
- modelPerformanceBucketMs: DAY_MS,
412
- costSeriesDays: 90,
413
- },
414
- all: {
415
- timeSeriesHours: 24 * 3650,
416
- timeSeriesBucketMs: DAY_MS,
417
- modelSeriesDays: 3650,
418
- modelSeriesBucketMs: DAY_MS,
419
- modelPerformanceDays: 3650,
420
- modelPerformanceBucketMs: DAY_MS,
421
- costSeriesDays: 3650,
422
- },
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 },
423
485
  };
424
486
 
425
- export function getTimeRangeConfig(range?: string | null): TimeRangeConfig {
426
- const normalized = range?.trim().toLowerCase() ?? DEFAULT_TIME_RANGE;
427
- const config = TIME_RANGE_TO_CONFIG[normalized as TimeRange];
428
- if (config) {
429
- const cutoff = normalized === "all" ? null : Date.now() - Math.max(1, config.timeSeriesHours * 60 * 60 * 1000);
430
- return { ...config, cutoff };
431
- }
487
+ /** Most folders the projects payload carries (busiest first); the tail is thousands of temp dirs. */
488
+ const FOLDER_LIMIT = 2000;
432
489
 
433
- 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];
434
494
  return {
435
- ...fallbackConfig,
436
- cutoff: Date.now() - fallbackConfig.timeSeriesHours * 60 * 60 * 1000,
495
+ cutoff: config.spanMs === null ? null : Date.now() - config.spanMs,
496
+ bucketMs: config.bucketMs,
437
497
  };
438
498
  }
439
499
 
@@ -442,26 +502,16 @@ export function getTimeRangeConfig(range?: string | null): TimeRangeConfig {
442
502
  */
443
503
  export async function getDashboardStats(range?: string | null): Promise<DashboardStats> {
444
504
  await initDb();
445
- const {
446
- timeSeriesHours,
447
- timeSeriesBucketMs,
448
- modelSeriesDays,
449
- modelSeriesBucketMs,
450
- modelPerformanceDays,
451
- modelPerformanceBucketMs,
452
- costSeriesDays,
453
- cutoff,
454
- } = getTimeRangeConfig(range);
455
-
505
+ const window = getTimeRangeConfig(range);
456
506
  return {
457
- overall: getOverallStats(cutoff ?? undefined),
458
- byModel: getStatsByModel(cutoff ?? undefined),
459
- byFolder: getStatsByFolder(cutoff ?? undefined),
460
- byAgentType: getStatsByAgentType(cutoff ?? undefined),
461
- timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
462
- modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
463
- modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
464
- 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),
465
515
  };
466
516
  }
467
517
 
@@ -469,12 +519,11 @@ export async function getOverviewStats(
469
519
  range?: string | null,
470
520
  ): Promise<Pick<DashboardStats, "overall" | "byAgentType" | "timeSeries">> {
471
521
  await initDb();
472
- const { timeSeriesHours, timeSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
473
-
522
+ const window = getTimeRangeConfig(range);
474
523
  return {
475
- overall: getOverallStats(cutoff ?? undefined),
476
- byAgentType: getStatsByAgentType(cutoff ?? undefined),
477
- timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
524
+ overall: getOverallStats(window.cutoff),
525
+ byAgentType: getStatsByAgentType(window.cutoff),
526
+ timeSeries: getTimeSeries(window),
478
527
  };
479
528
  }
480
529
 
@@ -482,29 +531,22 @@ export async function getModelDashboardStats(
482
531
  range?: string | null,
483
532
  ): Promise<Pick<DashboardStats, "byModel" | "modelSeries" | "modelPerformanceSeries">> {
484
533
  await initDb();
485
- const { modelSeriesDays, modelSeriesBucketMs, modelPerformanceDays, modelPerformanceBucketMs, cutoff } =
486
- getTimeRangeConfig(range);
487
-
534
+ const window = getTimeRangeConfig(range);
488
535
  return {
489
- byModel: getStatsByModel(cutoff ?? undefined),
490
- modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
491
- modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
536
+ byModel: getStatsByModel(window.cutoff),
537
+ modelSeries: getModelTimeSeries(window),
538
+ modelPerformanceSeries: getModelPerformanceSeries(window),
492
539
  };
493
540
  }
494
541
 
495
542
  export async function getCostDashboardStats(range?: string | null): Promise<Pick<DashboardStats, "costSeries">> {
496
543
  await initDb();
497
- const { costSeriesDays, cutoff } = getTimeRangeConfig(range);
498
-
499
- return {
500
- costSeries: getCostTimeSeries(costSeriesDays, cutoff),
501
- };
544
+ return { costSeries: getCostTimeSeries(getTimeRangeConfig(range).cutoff) };
502
545
  }
503
546
 
504
547
  export async function getFolderStats(range?: string | null): Promise<FolderStats[]> {
505
548
  await initDb();
506
- const { cutoff } = getTimeRangeConfig(range);
507
- return getStatsByFolder(cutoff ?? undefined);
549
+ return getStatsByFolder(getTimeRangeConfig(range).cutoff, FOLDER_LIMIT);
508
550
  }
509
551
 
510
552
  export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
@@ -514,8 +556,7 @@ export async function getRecentRequests(limit?: number): Promise<MessageStats[]>
514
556
 
515
557
  export async function getRecentErrors(range?: string | null, limit?: number): Promise<MessageStats[]> {
516
558
  await initDb();
517
- const { cutoff } = getTimeRangeConfig(range);
518
- return dbGetRecentErrors(limit, cutoff);
559
+ return dbGetRecentErrors(limit, getTimeRangeConfig(range).cutoff);
519
560
  }
520
561
 
521
562
  export async function getRequestDetails(id: number): Promise<RequestDetails | null> {
@@ -524,16 +565,12 @@ export async function getRequestDetails(id: number): Promise<RequestDetails | nu
524
565
  if (!msg) return null;
525
566
 
526
567
  const entry = await getSessionEntry(msg.sessionFile, msg.entryId);
527
- if (entry?.type !== "message") return null;
528
-
529
- // TODO: Get parent/context messages?
530
- // For now we return the single entry which contains the assistant response.
531
- // The user prompt is likely the parent.
568
+ if (entry?.type !== "message" || !("message" in entry)) return null;
532
569
 
533
570
  return {
534
571
  ...msg,
535
572
  messages: [entry],
536
- output: (entry as any).message,
573
+ output: entry.message,
537
574
  };
538
575
  }
539
576
 
@@ -545,51 +582,79 @@ export async function getTotalMessageCount(): Promise<number> {
545
582
  return getMessageCount();
546
583
  }
547
584
 
548
- 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> {
549
590
  await initDb();
550
- const { cutoff } = getTimeRangeConfig(range);
591
+ const window = getTimeRangeConfig(range);
551
592
  return {
552
- overall: getBehaviorOverall(cutoff),
553
- byModel: getBehaviorByModel(cutoff),
554
- behaviorSeries: getBehaviorTimeSeries(cutoff),
593
+ byTool: getToolStats(window.cutoff),
594
+ byToolModel: getToolStatsByModel(window.cutoff),
595
+ series: getToolTimeSeries(window),
555
596
  };
556
597
  }
557
598
 
558
599
  /**
559
- * Get the tools dashboard payload: per-tool totals, per-(tool, model)
560
- * 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}.
561
603
  */
562
- export async function getToolDashboardStats(range?: string | null): Promise<ToolDashboardStats> {
604
+ export async function getProviderDashboardStats(range?: string | null): Promise<ProviderDashboardStats> {
563
605
  await initDb();
564
- const { modelSeriesDays, modelSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
606
+ const window = getTimeRangeConfig(range);
565
607
  return {
566
- byTool: getToolStats(cutoff ?? undefined),
567
- byToolModel: getToolStatsByModel(cutoff ?? undefined),
568
- series: getToolTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
608
+ providers: getStatsByProvider(window.cutoff),
609
+ hourly: getProviderHourlyBurn(window.cutoff),
610
+ series: getProviderTimeSeries(window),
569
611
  };
570
612
  }
571
613
 
614
+ const USAGE_CACHE_TTL_MS = 60_000;
615
+ let usageCache: { key: string; at: number; data: Promise<UsageDataSnapshot> } | null = null;
616
+
572
617
  /**
573
- * Get the providers dashboard payload: per-provider totals, peak-burn-hours
574
- * histogram, provider token time series, and subscription-window analytics
575
- * (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).
576
641
  *
577
642
  * Window token estimates use broker-held fleet token burn when a broker is
578
643
  * configured — the window fractions cover every install sharing the broker's
579
644
  * credentials, so dividing them into local-only tokens would undercount.
580
645
  */
581
- 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> {
582
650
  await initDb();
583
- const { modelSeriesDays, modelSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
584
- const providers = getStatsByProvider(cutoff ?? undefined);
585
- const usage = await fetchUsageData(cutoff ?? 0);
586
- 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]));
587
655
  const { usageSeries, windowInsights } = computeUsageWindowStats(usage.rows, tokensByProvider);
588
656
  return {
589
- providers,
590
- hourly: getProviderHourlyBurn(cutoff ?? undefined),
591
- series: getProviderTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
592
- usageSeries,
593
657
  windowInsights,
658
+ usageSeries: provider ? usageSeries.filter(series => series.provider === provider) : [],
594
659
  };
595
660
  }