@linxiraos/pi-stats 1.0.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 (126) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +82 -0
  3. package/build.ts +95 -0
  4. package/dist/client/index.css +1 -0
  5. package/dist/client/index.html +24 -0
  6. package/dist/client/index.js +257 -0
  7. package/dist/client/styles.css +1695 -0
  8. package/dist/types/aggregator.d.ts +98 -0
  9. package/dist/types/client/App.d.ts +1 -0
  10. package/dist/types/client/api.d.ts +22 -0
  11. package/dist/types/client/app/AppLayout.d.ts +16 -0
  12. package/dist/types/client/app/NavRail.d.ts +7 -0
  13. package/dist/types/client/app/RangeControl.d.ts +7 -0
  14. package/dist/types/client/app/SyncButton.d.ts +14 -0
  15. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  16. package/dist/types/client/app/TopBar.d.ts +15 -0
  17. package/dist/types/client/app/routes.d.ts +12 -0
  18. package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
  19. package/dist/types/client/components/chart-shared.d.ts +173 -0
  20. package/dist/types/client/components/models-table-shared.d.ts +175 -0
  21. package/dist/types/client/components/range-meta.d.ts +21 -0
  22. package/dist/types/client/data/charts.d.ts +1 -0
  23. package/dist/types/client/data/formatters.d.ts +8 -0
  24. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  25. package/dist/types/client/data/useResource.d.ts +13 -0
  26. package/dist/types/client/data/view-models.d.ts +74 -0
  27. package/dist/types/client/index.d.ts +2 -0
  28. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  29. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  30. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  31. package/dist/types/client/routes/GainRoute.d.ts +7 -0
  32. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  33. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  34. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  35. package/dist/types/client/routes/ProvidersRoute.d.ts +7 -0
  36. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  37. package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
  38. package/dist/types/client/routes/index.d.ts +10 -0
  39. package/dist/types/client/types.d.ts +63 -0
  40. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  41. package/dist/types/client/ui/DataTable.d.ts +17 -0
  42. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  43. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  44. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  45. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  46. package/dist/types/client/ui/Panel.d.ts +7 -0
  47. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  48. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  49. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  50. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  51. package/dist/types/client/ui/index.d.ts +11 -0
  52. package/dist/types/client/useSystemTheme.d.ts +11 -0
  53. package/dist/types/db.d.ts +159 -0
  54. package/dist/types/embedded-client.d.ts +18 -0
  55. package/dist/types/gain-aggregator.d.ts +26 -0
  56. package/dist/types/index.d.ts +7 -0
  57. package/dist/types/parser.d.ts +53 -0
  58. package/dist/types/port-conflict.d.ts +14 -0
  59. package/dist/types/server.d.ts +12 -0
  60. package/dist/types/shared-types.d.ts +403 -0
  61. package/dist/types/sync-worker.d.ts +31 -0
  62. package/dist/types/types.d.ts +164 -0
  63. package/dist/types/usage-windows.d.ts +45 -0
  64. package/dist/types/user-metrics.d.ts +81 -0
  65. package/package.json +94 -0
  66. package/src/aggregator.ts +615 -0
  67. package/src/client/App.tsx +112 -0
  68. package/src/client/api.ts +118 -0
  69. package/src/client/app/AppLayout.tsx +93 -0
  70. package/src/client/app/NavRail.tsx +44 -0
  71. package/src/client/app/RangeControl.tsx +39 -0
  72. package/src/client/app/SyncButton.tsx +75 -0
  73. package/src/client/app/ThemeToggle.tsx +37 -0
  74. package/src/client/app/TopBar.tsx +73 -0
  75. package/src/client/app/routes.ts +86 -0
  76. package/src/client/components/AgentTokenShare.tsx +68 -0
  77. package/src/client/components/chart-shared.tsx +257 -0
  78. package/src/client/components/models-table-shared.tsx +255 -0
  79. package/src/client/components/range-meta.ts +73 -0
  80. package/src/client/css.d.ts +1 -0
  81. package/src/client/data/charts.ts +14 -0
  82. package/src/client/data/formatters.ts +45 -0
  83. package/src/client/data/useHashRoute.ts +88 -0
  84. package/src/client/data/useResource.ts +154 -0
  85. package/src/client/data/view-models.ts +259 -0
  86. package/src/client/index.tsx +10 -0
  87. package/src/client/routes/BehaviorRoute.tsx +623 -0
  88. package/src/client/routes/CostsRoute.tsx +234 -0
  89. package/src/client/routes/ErrorsRoute.tsx +118 -0
  90. package/src/client/routes/GainRoute.tsx +226 -0
  91. package/src/client/routes/ModelsRoute.tsx +430 -0
  92. package/src/client/routes/OverviewRoute.tsx +342 -0
  93. package/src/client/routes/ProjectsRoute.tsx +163 -0
  94. package/src/client/routes/ProvidersRoute.tsx +492 -0
  95. package/src/client/routes/RequestsRoute.tsx +123 -0
  96. package/src/client/routes/ToolsRoute.tsx +463 -0
  97. package/src/client/routes/index.ts +10 -0
  98. package/src/client/styles.css +1384 -0
  99. package/src/client/types.ts +80 -0
  100. package/src/client/ui/AsyncBoundary.tsx +54 -0
  101. package/src/client/ui/DataTable.tsx +122 -0
  102. package/src/client/ui/EmptyState.tsx +16 -0
  103. package/src/client/ui/ErrorState.tsx +25 -0
  104. package/src/client/ui/JsonBlock.tsx +75 -0
  105. package/src/client/ui/MetricCluster.tsx +78 -0
  106. package/src/client/ui/Panel.tsx +24 -0
  107. package/src/client/ui/RequestDrawer.tsx +208 -0
  108. package/src/client/ui/SegmentedControl.tsx +36 -0
  109. package/src/client/ui/Skeleton.tsx +17 -0
  110. package/src/client/ui/StatusPill.tsx +15 -0
  111. package/src/client/ui/index.ts +11 -0
  112. package/src/client/useSystemTheme.ts +87 -0
  113. package/src/db.ts +1702 -0
  114. package/src/embedded-client.generated.txt +0 -0
  115. package/src/embedded-client.ts +26 -0
  116. package/src/gain-aggregator.ts +311 -0
  117. package/src/index.ts +194 -0
  118. package/src/parser.ts +499 -0
  119. package/src/port-conflict.ts +263 -0
  120. package/src/server.ts +396 -0
  121. package/src/shared-types.ts +432 -0
  122. package/src/sync-worker.ts +40 -0
  123. package/src/types.ts +171 -0
  124. package/src/usage-windows.ts +304 -0
  125. package/src/user-metrics.ts +695 -0
  126. package/tailwind.config.js +40 -0
@@ -0,0 +1,615 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import { workerHostEntry } from "@linxiraos/pi-utils";
4
+ import {
5
+ getRecentErrors as dbGetRecentErrors,
6
+ getRecentRequests as dbGetRecentRequests,
7
+ getBehaviorByModel,
8
+ getBehaviorOverall,
9
+ getBehaviorTimeSeries,
10
+ getCostTimeSeries,
11
+ getFileOffset,
12
+ getMessageById,
13
+ getMessageCount,
14
+ getModelPerformanceSeries,
15
+ getModelTimeSeries,
16
+ getOverallStats,
17
+ getProviderHourlyBurn,
18
+ getProviderTimeSeries,
19
+ getStatsByAgentType,
20
+ getStatsByFolder,
21
+ getStatsByModel,
22
+ getStatsByProvider,
23
+ getTimeSeries,
24
+ getToolStats,
25
+ getToolStatsByModel,
26
+ getToolTimeSeries,
27
+ initDb,
28
+ insertMessageStats,
29
+ insertToolCalls,
30
+ insertUserMessageStats,
31
+ markSessionBackfillsComplete,
32
+ setFileOffset,
33
+ updateToolResults,
34
+ updateUserMessageLinks,
35
+ } from "./db";
36
+ import { getSessionEntry, listAllSessionFiles, type ParseSessionResult, parseSessionFile } from "./parser";
37
+ import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
38
+ // Coding-agent binary/bundle workers route through the CLI entrypoint with a
39
+ // hidden argv mode, so the compiled binary and npm bundle only need one
40
+ // JavaScript entry. Standalone source `omp-stats` keeps using this package's
41
+ // own sync-worker source file.
42
+ import type {
43
+ BehaviorDashboardStats,
44
+ DashboardStats,
45
+ MessageStats,
46
+ ProviderDashboardStats,
47
+ RequestDetails,
48
+ ToolDashboardStats,
49
+ } from "./types";
50
+ import { computeUsageWindowStats, fetchUsageSnapshots } from "./usage-windows";
51
+
52
+ /**
53
+ * Apply a freshly parsed result to the database. Runs entirely on the
54
+ * main thread so the single SQLite handle owns every write.
55
+ */
56
+ function applyParseResult(sessionFile: string, lastModified: number, result: ParseSessionResult): number {
57
+ if (result.stats.length > 0) insertMessageStats(result.stats);
58
+ if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
59
+ if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
60
+ if (result.toolCalls.length > 0) insertToolCalls(result.toolCalls);
61
+ if (result.toolResults.length > 0) updateToolResults(result.toolResults);
62
+ setFileOffset(sessionFile, result.newOffset, lastModified);
63
+ return result.stats.length + result.userStats.length;
64
+ }
65
+
66
+ /**
67
+ * Progress event emitted after each session file is fully processed.
68
+ * `current` is the number of files completed (skipped + parsed),
69
+ * `total` is the size of the work set. `processed` is the running total
70
+ * of inserted rows.
71
+ */
72
+ export interface SyncProgress {
73
+ current: number;
74
+ total: number;
75
+ processed: number;
76
+ sessionFile: string;
77
+ }
78
+
79
+ export interface SyncOptions {
80
+ /** Called after each file completes. Synchronous; keep it cheap. */
81
+ onProgress?: (event: SyncProgress) => void;
82
+ /**
83
+ * Worker pool size. Defaults to a sensible value derived from the host
84
+ * (capped to avoid drowning a small machine in workers). Set to `1` to
85
+ * force serial parsing without spawning workers.
86
+ */
87
+ workers?: number;
88
+ }
89
+
90
+ function defaultWorkerCount(): number {
91
+ // Bun 1.3.x can abort the macOS process when stats sync workers re-enter
92
+ // the compiled `omp` binary. Keep macOS on the documented serial path.
93
+ if (process.platform === "darwin") return 1;
94
+ // `navigator.hardwareConcurrency` is the portable answer in Bun; fall
95
+ // back to a small fixed pool if it's somehow unavailable.
96
+ const hw = typeof navigator !== "undefined" ? (navigator.hardwareConcurrency ?? 0) : 0;
97
+ const raw = hw > 0 ? hw : 4;
98
+ // Cap at 8 - parse is JSON-bound, and SQLite writes serialize on main
99
+ // thread anyway, so more workers stop helping.
100
+ return Math.min(8, Math.max(2, Math.floor(raw)));
101
+ }
102
+
103
+ interface WorkerHandle {
104
+ worker: Worker;
105
+ busy: boolean;
106
+ resolve: ((res: ParseSessionResult) => void) | null;
107
+ reject: ((err: Error) => void) | null;
108
+ }
109
+
110
+ /**
111
+ * Create a fresh sync worker. When the process was started from a
112
+ * self-dispatching CLI entry (omp in source, npm-bundle, or compiled form),
113
+ * re-enter that entry with a worker argv selector; otherwise (standalone
114
+ * omp-stats, bun test, SDK embedding) load the worker module directly, so this
115
+ * package keeps zero runtime dependency on `@linxiraos/zeta`.
116
+ */
117
+ function createSyncWorker(): Worker {
118
+ const hostEntry = workerHostEntry();
119
+ if (hostEntry) {
120
+ return new Worker(hostEntry, { type: "module", argv: ["__omp_worker_stats_sync"] });
121
+ }
122
+ return new Worker(new URL("./sync-worker.ts", import.meta.url).href, { type: "module" });
123
+ }
124
+
125
+ function spawnWorker(): WorkerHandle {
126
+ const worker = createSyncWorker();
127
+ const handle: WorkerHandle = { worker, busy: false, resolve: null, reject: null };
128
+ worker.onmessage = (event: MessageEvent<SyncWorkerResponse>) => {
129
+ const { resolve, reject } = handle;
130
+ handle.resolve = null;
131
+ handle.reject = null;
132
+ handle.busy = false;
133
+ if (!resolve || !reject) return;
134
+ const data = event.data;
135
+ if (!data.ok) {
136
+ reject(new Error(data.error));
137
+ return;
138
+ }
139
+ if (data.kind === "pong") {
140
+ reject(new Error("sync worker: unexpected pong on parse channel"));
141
+ return;
142
+ }
143
+ resolve(data.result);
144
+ };
145
+ worker.onerror = (event: ErrorEvent) => {
146
+ const { reject } = handle;
147
+ handle.resolve = null;
148
+ handle.reject = null;
149
+ handle.busy = false;
150
+ reject?.(event.error instanceof Error ? event.error : new Error(event.message || "worker error"));
151
+ };
152
+ return handle;
153
+ }
154
+
155
+ function dispatch(handle: WorkerHandle, request: SyncWorkerRequest): Promise<ParseSessionResult> {
156
+ if (handle.busy) {
157
+ return Promise.reject(new Error("worker is busy - this is a bug in the dispatcher"));
158
+ }
159
+ const { promise, resolve, reject } = Promise.withResolvers<ParseSessionResult>();
160
+ handle.busy = true;
161
+ handle.resolve = resolve;
162
+ handle.reject = reject;
163
+ handle.worker.postMessage(request);
164
+ return promise;
165
+ }
166
+
167
+ /**
168
+ * Smoke test: spawns one sync worker, pings it, asserts the pong response,
169
+ * then terminates. Used by `omp --smoke-test` so the install-method CI jobs
170
+ * catch the silent worker-load failure that hit compiled binaries in #1011
171
+ * and #1027 — neither `--version` nor `stats --summary` exercises the worker
172
+ * spawn path on a fresh install (no session files = early return), so a
173
+ * dedicated probe is the only reliable signal.
174
+ *
175
+ * No-op on darwin: `syncAllSessions` keeps macOS on the serial parser path
176
+ * (see {@link defaultWorkerCount}) so the worker spawn surface is unreachable
177
+ * from the CLI, and probing it under the hardened runtime in
178
+ * `scripts/ci-macos-sign.sh` would re-enter the Bun-worker abort surface that
179
+ * motivated the darwin serial default in the first place.
180
+ *
181
+ * Rejects on transport error, error response, or timeout.
182
+ */
183
+ export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: number } = {}): Promise<void> {
184
+ if (process.platform === "darwin") return;
185
+ const worker = createSyncWorker();
186
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
187
+ const timer = setTimeout(() => reject(new Error(`sync worker did not pong within ${timeoutMs}ms`)), timeoutMs);
188
+ worker.onmessage = (event: MessageEvent<SyncWorkerResponse>) => {
189
+ const data = event.data;
190
+ if (!data.ok) {
191
+ reject(new Error(data.error));
192
+ return;
193
+ }
194
+ if (data.kind !== "pong") {
195
+ reject(new Error(`sync worker: expected pong, got ${JSON.stringify(data)}`));
196
+ return;
197
+ }
198
+ resolve();
199
+ };
200
+ worker.onerror = (event: ErrorEvent) => {
201
+ reject(event.error instanceof Error ? event.error : new Error(event.message || "worker error"));
202
+ };
203
+ try {
204
+ worker.postMessage({ kind: "ping" } satisfies SyncWorkerRequest);
205
+ await promise;
206
+ } finally {
207
+ clearTimeout(timer);
208
+ worker.terminate();
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Sync all session files to the database.
214
+ *
215
+ * `workers: 1` parses inline. Larger pools fan parsing out across workers
216
+ * (one in-flight job per worker) while DB writes and offset bookkeeping stay on
217
+ * the calling thread so the single SQLite handle stays uncontended.
218
+ * `onProgress` fires once per completed file (skipped files included so the
219
+ * bar walks at a steady rate).
220
+ */
221
+ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
222
+ await initDb();
223
+
224
+ const files = await listAllSessionFiles();
225
+ let totalProcessed = 0;
226
+ let filesProcessed = 0;
227
+ let completed = 0;
228
+ let cursor = 0;
229
+ const finish = () => {
230
+ markSessionBackfillsComplete();
231
+ return { processed: totalProcessed, files: filesProcessed };
232
+ };
233
+ if (files.length === 0) return finish();
234
+
235
+ const report = (sessionFile: string) => {
236
+ completed++;
237
+ opts?.onProgress?.({
238
+ current: completed,
239
+ total: files.length,
240
+ processed: totalProcessed,
241
+ sessionFile,
242
+ });
243
+ };
244
+
245
+ const processFile = async (
246
+ sessionFile: string,
247
+ parse: (sessionFile: string, fromOffset: number) => Promise<ParseSessionResult>,
248
+ ): Promise<void> => {
249
+ let fileStats: fs.Stats;
250
+ try {
251
+ fileStats = await fs.promises.stat(sessionFile);
252
+ } catch {
253
+ report(sessionFile);
254
+ return;
255
+ }
256
+ const lastModified = fileStats.mtimeMs;
257
+ const stored = getFileOffset(sessionFile);
258
+ if (stored && stored.lastModified >= lastModified) {
259
+ report(sessionFile);
260
+ return;
261
+ }
262
+
263
+ const fromOffset = stored?.offset ?? 0;
264
+ const result = await parse(sessionFile, fromOffset);
265
+ const inserted = applyParseResult(sessionFile, lastModified, result);
266
+ if (inserted > 0) {
267
+ totalProcessed += inserted;
268
+ filesProcessed++;
269
+ }
270
+ report(sessionFile);
271
+ };
272
+
273
+ const requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
274
+ if (requestedWorkers === 1) {
275
+ for (const sessionFile of files) {
276
+ await processFile(sessionFile, parseSessionFile);
277
+ }
278
+ return finish();
279
+ }
280
+
281
+ const poolSize = Math.min(files.length, requestedWorkers);
282
+
283
+ const handles: WorkerHandle[] = [];
284
+ for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
285
+
286
+ async function drain(handle: WorkerHandle): Promise<void> {
287
+ while (true) {
288
+ const idx = cursor++;
289
+ if (idx >= files.length) return;
290
+ const sessionFile = files[idx];
291
+ await processFile(sessionFile, (file, fromOffset) => dispatch(handle, { sessionFile: file, fromOffset }));
292
+ }
293
+ }
294
+
295
+ try {
296
+ await Promise.all(handles.map(drain));
297
+ } finally {
298
+ for (const handle of handles) handle.worker.terminate();
299
+ }
300
+
301
+ return finish();
302
+ }
303
+
304
+ const HOUR_MS = 60 * 60 * 1000;
305
+ const DAY_MS = 24 * HOUR_MS;
306
+ const FIVE_MIN_MS = 5 * 60 * 1000;
307
+
308
+ type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
309
+
310
+ interface TimeRangeConfig {
311
+ timeSeriesHours: number;
312
+ timeSeriesBucketMs: number;
313
+ modelSeriesDays: number;
314
+ modelSeriesBucketMs: number;
315
+ modelPerformanceDays: number;
316
+ modelPerformanceBucketMs: number;
317
+ costSeriesDays: number;
318
+ cutoff: number | null;
319
+ }
320
+
321
+ const DEFAULT_TIME_RANGE: TimeRange = "24h";
322
+
323
+ const TIME_RANGE_TO_CONFIG: Record<TimeRange, Omit<TimeRangeConfig, "cutoff">> = {
324
+ "1h": {
325
+ timeSeriesHours: 1,
326
+ timeSeriesBucketMs: FIVE_MIN_MS,
327
+ modelSeriesDays: 1,
328
+ modelSeriesBucketMs: FIVE_MIN_MS,
329
+ modelPerformanceDays: 1,
330
+ modelPerformanceBucketMs: FIVE_MIN_MS,
331
+ costSeriesDays: 1,
332
+ },
333
+ "24h": {
334
+ timeSeriesHours: 24,
335
+ timeSeriesBucketMs: HOUR_MS,
336
+ modelSeriesDays: 1,
337
+ modelSeriesBucketMs: HOUR_MS,
338
+ modelPerformanceDays: 1,
339
+ modelPerformanceBucketMs: HOUR_MS,
340
+ costSeriesDays: 1,
341
+ },
342
+ "7d": {
343
+ timeSeriesHours: 24 * 7,
344
+ timeSeriesBucketMs: DAY_MS,
345
+ modelSeriesDays: 7,
346
+ modelSeriesBucketMs: DAY_MS,
347
+ modelPerformanceDays: 7,
348
+ modelPerformanceBucketMs: DAY_MS,
349
+ costSeriesDays: 7,
350
+ },
351
+ "30d": {
352
+ timeSeriesHours: 24 * 30,
353
+ timeSeriesBucketMs: DAY_MS,
354
+ modelSeriesDays: 30,
355
+ modelSeriesBucketMs: DAY_MS,
356
+ modelPerformanceDays: 30,
357
+ modelPerformanceBucketMs: DAY_MS,
358
+ costSeriesDays: 30,
359
+ },
360
+ "90d": {
361
+ timeSeriesHours: 24 * 90,
362
+ timeSeriesBucketMs: DAY_MS,
363
+ modelSeriesDays: 90,
364
+ modelSeriesBucketMs: DAY_MS,
365
+ modelPerformanceDays: 90,
366
+ modelPerformanceBucketMs: DAY_MS,
367
+ costSeriesDays: 90,
368
+ },
369
+ all: {
370
+ timeSeriesHours: 24 * 3650,
371
+ timeSeriesBucketMs: DAY_MS,
372
+ modelSeriesDays: 3650,
373
+ modelSeriesBucketMs: DAY_MS,
374
+ modelPerformanceDays: 3650,
375
+ modelPerformanceBucketMs: DAY_MS,
376
+ costSeriesDays: 3650,
377
+ },
378
+ };
379
+
380
+ export function getTimeRangeConfig(range?: string | null): TimeRangeConfig {
381
+ const normalized = range?.trim().toLowerCase() ?? DEFAULT_TIME_RANGE;
382
+ const config = TIME_RANGE_TO_CONFIG[normalized as TimeRange];
383
+ if (config) {
384
+ const cutoff = normalized === "all" ? null : Date.now() - Math.max(1, config.timeSeriesHours * 60 * 60 * 1000);
385
+ return { ...config, cutoff };
386
+ }
387
+
388
+ const fallbackConfig = TIME_RANGE_TO_CONFIG[DEFAULT_TIME_RANGE];
389
+ return {
390
+ ...fallbackConfig,
391
+ cutoff: Date.now() - fallbackConfig.timeSeriesHours * 60 * 60 * 1000,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Get all dashboard stats.
397
+ */
398
+ export async function getDashboardStats(range?: string | null): Promise<DashboardStats> {
399
+ await initDb();
400
+ const {
401
+ timeSeriesHours,
402
+ timeSeriesBucketMs,
403
+ modelSeriesDays,
404
+ modelSeriesBucketMs,
405
+ modelPerformanceDays,
406
+ modelPerformanceBucketMs,
407
+ costSeriesDays,
408
+ cutoff,
409
+ } = getTimeRangeConfig(range);
410
+
411
+ return {
412
+ overall: getOverallStats(cutoff ?? undefined),
413
+ byModel: getStatsByModel(cutoff ?? undefined),
414
+ byFolder: getStatsByFolder(cutoff ?? undefined),
415
+ byAgentType: getStatsByAgentType(cutoff ?? undefined),
416
+ timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
417
+ modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
418
+ modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
419
+ costSeries: getCostTimeSeries(costSeriesDays, cutoff),
420
+ };
421
+ }
422
+
423
+ export async function getOverviewStats(
424
+ range?: string | null,
425
+ ): Promise<Pick<DashboardStats, "overall" | "byAgentType" | "timeSeries">> {
426
+ await initDb();
427
+ const { timeSeriesHours, timeSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
428
+
429
+ return {
430
+ overall: getOverallStats(cutoff ?? undefined),
431
+ byAgentType: getStatsByAgentType(cutoff ?? undefined),
432
+ timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
433
+ };
434
+ }
435
+
436
+ export async function getModelDashboardStats(
437
+ range?: string | null,
438
+ ): Promise<Pick<DashboardStats, "byModel" | "modelSeries" | "modelPerformanceSeries">> {
439
+ await initDb();
440
+ const { modelSeriesDays, modelSeriesBucketMs, modelPerformanceDays, modelPerformanceBucketMs, cutoff } =
441
+ getTimeRangeConfig(range);
442
+
443
+ return {
444
+ byModel: getStatsByModel(cutoff ?? undefined),
445
+ modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
446
+ modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
447
+ };
448
+ }
449
+
450
+ export async function getCostDashboardStats(range?: string | null): Promise<Pick<DashboardStats, "costSeries">> {
451
+ await initDb();
452
+ const { costSeriesDays, cutoff } = getTimeRangeConfig(range);
453
+
454
+ return {
455
+ costSeries: getCostTimeSeries(costSeriesDays, cutoff),
456
+ };
457
+ }
458
+ export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
459
+ await initDb();
460
+ return dbGetRecentRequests(limit);
461
+ }
462
+
463
+ export async function getRecentErrors(range?: string | null, limit?: number): Promise<MessageStats[]> {
464
+ await initDb();
465
+ const { cutoff } = getTimeRangeConfig(range);
466
+ return dbGetRecentErrors(limit, cutoff);
467
+ }
468
+
469
+ export async function getRequestDetails(id: number): Promise<RequestDetails | null> {
470
+ await initDb();
471
+ const msg = getMessageById(id);
472
+ if (!msg) return null;
473
+
474
+ const entry = await getSessionEntry(msg.sessionFile, msg.entryId);
475
+ if (entry?.type !== "message") return null;
476
+
477
+ // TODO: Get parent/context messages?
478
+ // For now we return the single entry which contains the assistant response.
479
+ // The user prompt is likely the parent.
480
+
481
+ return {
482
+ ...msg,
483
+ messages: [entry],
484
+ output: (entry as any).message,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Get the current message count in the database.
490
+ */
491
+ export async function getTotalMessageCount(): Promise<number> {
492
+ await initDb();
493
+ return getMessageCount();
494
+ }
495
+
496
+ export async function getBehaviorDashboardStats(range?: string | null): Promise<BehaviorDashboardStats> {
497
+ await initDb();
498
+ const { cutoff } = getTimeRangeConfig(range);
499
+ return {
500
+ overall: getBehaviorOverall(cutoff),
501
+ byModel: getBehaviorByModel(cutoff),
502
+ behaviorSeries: getBehaviorTimeSeries(cutoff),
503
+ };
504
+ }
505
+
506
+ /**
507
+ * Get the tools dashboard payload: per-tool totals, per-(tool, model)
508
+ * breakdown, and the call time series (bucketed like the model series).
509
+ */
510
+ export async function getToolDashboardStats(range?: string | null): Promise<ToolDashboardStats> {
511
+ await initDb();
512
+ const { modelSeriesDays, modelSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
513
+ return {
514
+ byTool: getToolStats(cutoff ?? undefined),
515
+ byToolModel: getToolStatsByModel(cutoff ?? undefined),
516
+ series: getToolTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
517
+ };
518
+ }
519
+
520
+ /**
521
+ * Get the providers dashboard payload: per-provider totals, peak-burn-hours
522
+ * histogram, provider token time series, and subscription-window analytics
523
+ * (utilization series + insights) derived from recorded usage-limit snapshots.
524
+ */
525
+ export async function getProviderDashboardStats(range?: string | null): Promise<ProviderDashboardStats> {
526
+ await initDb();
527
+ const { modelSeriesDays, modelSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
528
+ const providers = getStatsByProvider(cutoff ?? undefined);
529
+ const tokensByProvider = new Map(providers.map(p => [p.provider, p.totalTokens]));
530
+ const snapshots = await fetchUsageSnapshots(cutoff ?? 0);
531
+ const { usageSeries, windowInsights } = computeUsageWindowStats(snapshots, tokensByProvider);
532
+ return {
533
+ providers,
534
+ hourly: getProviderHourlyBurn(cutoff ?? undefined),
535
+ series: getProviderTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
536
+ usageSeries,
537
+ windowInsights,
538
+ };
539
+ }
540
+
541
+ const STATS_LOCK_TIMEOUT_MS = 30_000;
542
+ const STATS_LOCK_STALE_MS = 60_000;
543
+
544
+ async function statIfPresent(path: string) {
545
+ try {
546
+ return await fsp.stat(path);
547
+ } catch (error) {
548
+ const code =
549
+ typeof error === "object" && error !== null && "code" in error ? (error as { code: string }).code : undefined;
550
+ if (code === "ENOENT") return null;
551
+ throw error;
552
+ }
553
+ }
554
+
555
+ function processExists(pid: number): boolean {
556
+ try {
557
+ process.kill(pid, 0);
558
+ return true;
559
+ } catch (error) {
560
+ const code =
561
+ typeof error === "object" && error !== null && "code" in error ? (error as { code: string }).code : undefined;
562
+ if (code === "ESRCH" || code === "EINVAL") return false;
563
+ return true;
564
+ }
565
+ }
566
+
567
+ async function isStatsLockStale(lockPath: string): Promise<boolean> {
568
+ const stat = await statIfPresent(lockPath);
569
+ if (!stat) return false;
570
+ const pid = Number.parseInt((await Bun.file(lockPath).text()).split(/\r?\n/, 1)[0] ?? "", 10);
571
+ if (Number.isSafeInteger(pid) && pid > 0) return !processExists(pid);
572
+ return Date.now() - stat.mtimeMs > STATS_LOCK_STALE_MS;
573
+ }
574
+
575
+ /**
576
+ * Acquire an exclusive lock on the stats database and execute `fn`.
577
+ * Used by GC to prevent stats row cleanup from racing with stats sync.
578
+ */
579
+ export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T> {
580
+ const lockPath = `${dbPath}.lock`;
581
+ const deadline = Date.now() + STATS_LOCK_TIMEOUT_MS;
582
+
583
+ while (Date.now() < deadline) {
584
+ try {
585
+ const handle = await fsp.open(lockPath, "wx");
586
+ try {
587
+ await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`);
588
+ return await fn();
589
+ } finally {
590
+ await handle.close();
591
+ await fsp.unlink(lockPath).catch(() => {});
592
+ }
593
+ } catch (error) {
594
+ const code =
595
+ typeof error === "object" && error !== null && "code" in error
596
+ ? (error as { code: string }).code
597
+ : undefined;
598
+ if (code !== "EEXIST") throw error;
599
+ }
600
+ if (await isStatsLockStale(lockPath)) {
601
+ try {
602
+ await fsp.unlink(lockPath);
603
+ } catch (error) {
604
+ const code =
605
+ typeof error === "object" && error !== null && "code" in error
606
+ ? (error as { code: string }).code
607
+ : undefined;
608
+ if (code !== "ENOENT") throw error;
609
+ }
610
+ }
611
+ await Bun.sleep(100);
612
+ }
613
+
614
+ throw new Error(`timed out waiting for stats lock: ${lockPath}`);
615
+ }