@oh-my-pi/omp-stats 18.6.3 → 18.8.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 (51) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +96 -96
  4. package/dist/types/aggregator.d.ts +3 -0
  5. package/dist/types/client/api.d.ts +5 -1
  6. package/dist/types/client/data/live.d.ts +16 -11
  7. package/dist/types/client/data/query-store.d.ts +39 -0
  8. package/dist/types/client/data/query.d.ts +10 -5
  9. package/dist/types/client/traces/Minimap.d.ts +2 -0
  10. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  11. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  12. package/dist/types/db.d.ts +14 -2
  13. package/dist/types/embedded-client.d.ts +5 -0
  14. package/dist/types/live.d.ts +5 -3
  15. package/dist/types/rollup.d.ts +9 -1
  16. package/dist/types/trace.d.ts +18 -6
  17. package/dist/types/types.d.ts +8 -0
  18. package/package.json +4 -4
  19. package/src/aggregator.ts +26 -9
  20. package/src/client/api.ts +40 -5
  21. package/src/client/app/LiveChip.tsx +2 -2
  22. package/src/client/app/Shell.tsx +2 -2
  23. package/src/client/data/live.tsx +85 -45
  24. package/src/client/data/query-store.ts +108 -0
  25. package/src/client/data/query.ts +47 -56
  26. package/src/client/routes/CostsRoute.tsx +4 -2
  27. package/src/client/routes/ErrorsRoute.tsx +3 -1
  28. package/src/client/routes/FrustrationRoute.tsx +1 -1
  29. package/src/client/routes/GainRoute.tsx +3 -1
  30. package/src/client/routes/ModelsRoute.tsx +9 -2
  31. package/src/client/routes/OverviewRoute.tsx +2 -2
  32. package/src/client/routes/ProjectsRoute.tsx +1 -1
  33. package/src/client/routes/ProvidersRoute.tsx +5 -3
  34. package/src/client/routes/RequestsRoute.tsx +1 -1
  35. package/src/client/routes/ToolsRoute.tsx +1 -1
  36. package/src/client/routes/TracesRoute.tsx +5 -4
  37. package/src/client/styles.css +4 -2
  38. package/src/client/traces/Minimap.tsx +63 -26
  39. package/src/client/traces/TimelineCanvas.tsx +224 -105
  40. package/src/client/traces/TraceView.tsx +29 -10
  41. package/src/client/traces/TranscriptList.tsx +17 -9
  42. package/src/client/traces/traces.css +26 -0
  43. package/src/client/ui/RequestDrawer.tsx +3 -1
  44. package/src/db.ts +123 -69
  45. package/src/embedded-client.ts +9 -0
  46. package/src/live.ts +36 -11
  47. package/src/parser.ts +18 -17
  48. package/src/rollup.ts +41 -20
  49. package/src/server.ts +38 -24
  50. package/src/trace.ts +54 -40
  51. package/src/types.ts +8 -0
@@ -8,6 +8,14 @@
8
8
  * keeps building the dashboard from source.
9
9
  */
10
10
 
11
+ /**
12
+ * Cheap check whether the generated text carries a gzip archive: sniffs the
13
+ * base64 of the gzip magic (`1f 8b 08`) without decoding megabytes.
14
+ */
15
+ export function hasEmbeddedClientArchive(txt: string): boolean {
16
+ return /^\s*H4s/.test(txt);
17
+ }
18
+
11
19
  /**
12
20
  * Decode the generated archive text.
13
21
  *
@@ -17,6 +25,7 @@
17
25
  * rather than decoded into garbage bytes.
18
26
  */
19
27
  export function decodeEmbeddedClientArchive(txt: string): Buffer | null {
28
+ if (!hasEmbeddedClientArchive(txt)) return null;
20
29
  const normalized = txt.replaceAll(/\s+/g, "");
21
30
  if (!normalized) return null;
22
31
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) return null;
package/src/live.ts CHANGED
@@ -5,15 +5,16 @@
5
5
  * The dashboard never waits for ingest. {@link StatsLive.start} kicks a full
6
6
  * sync in the background and watches the sessions directory; changed
7
7
  * transcripts are re-synced (only those files) shortly after they are written.
8
- * Every committed batch bumps {@link LiveStatus.version} (throttled), so open
9
- * pages refetch and fill in while parsing is still running.
8
+ * Every batch that changes stored rows bumps {@link LiveStatus.version}
9
+ * (throttled), so open pages refetch and fill in while parsing is still
10
+ * running; syncs that change nothing leave the version alone.
10
11
  */
11
12
 
12
13
  import * as fs from "node:fs";
13
14
  import * as path from "node:path";
14
15
  import { getSessionsDir, logger } from "@oh-my-pi/pi-utils";
15
16
  import { syncAllSessions } from "./aggregator";
16
- import { initDb } from "./db";
17
+ import { getDataVersion, initDb } from "./db";
17
18
  import { getRollupStatus, refreshRollups } from "./rollup";
18
19
  import type { LiveStatus, LiveSyncStatus } from "./shared-types";
19
20
 
@@ -28,7 +29,8 @@ const SYNC_RETRY_MS = 10_000;
28
29
  /** Minimum spacing of progress-only status events. */
29
30
  const PROGRESS_THROTTLE_MS = 150;
30
31
 
31
- type Listener = (status: LiveStatus) => void;
32
+ /** Receives each distinct status and its JSON serialization (shared by every listener). */
33
+ type Listener = (status: LiveStatus, frame: string) => void;
32
34
 
33
35
  /** One per process; owned by the dashboard server (see `startServer`). */
34
36
  export class StatsLive {
@@ -36,6 +38,10 @@ export class StatsLive {
36
38
  #sync: LiveSyncStatus = { phase: "idle", current: 0, total: 0, processed: 0, lastSyncedAt: null, error: null };
37
39
  #indexingHours = 0;
38
40
  #listeners = new Set<Listener>();
41
+ /** Last frame sent to listeners; identical statuses are not re-sent. */
42
+ #lastFrame: string | null = null;
43
+ /** `PRAGMA data_version` at the last sync; null before the first. */
44
+ #dataVersion: number | null = null;
39
45
 
40
46
  #started = false;
41
47
  #watcher: fs.FSWatcher | null = null;
@@ -133,8 +139,9 @@ export class StatsLive {
133
139
  };
134
140
  this.#emitProgress();
135
141
  }
136
- if (event.processed > committed) {
137
- committed = event.processed;
142
+ // Only committed row changes make pages refetch; unchanged transcripts cost nothing.
143
+ if (event.changes > committed) {
144
+ committed = event.changes;
138
145
  this.#changed();
139
146
  }
140
147
  },
@@ -159,14 +166,28 @@ export class StatsLive {
159
166
  }, SYNC_RETRY_MS);
160
167
  }
161
168
  }
162
- // Another process may have ingested too; always let clients revalidate.
163
- this.#changed();
169
+ // Another process may have ingested too; its commits move SQLite's data_version.
170
+ if (this.#externalWrites()) this.#changed();
164
171
  this.#emit();
165
172
  }
166
173
 
174
+ /** Whether another connection committed since the last check (always true on the first). */
175
+ #externalWrites(): boolean {
176
+ const current = getDataVersion();
177
+ if (current === null) return false;
178
+ const changed = current !== this.#dataVersion;
179
+ this.#dataVersion = current;
180
+ return changed;
181
+ }
182
+
167
183
  /** Stored data changed: refresh rollups and (throttled) bump the version. */
168
184
  #changed(): void {
169
185
  void this.#refresh();
186
+ this.#bumpVersion();
187
+ }
188
+
189
+ /** Bump the data version (throttled), making open pages refetch. */
190
+ #bumpVersion(): void {
170
191
  if (this.#versionTimer) return;
171
192
  const wait = Math.max(0, this.#lastVersionAt + VERSION_THROTTLE_MS - Date.now());
172
193
  this.#versionTimer = setTimeout(() => {
@@ -190,8 +211,9 @@ export class StatsLive {
190
211
  await refreshRollups({
191
212
  onProgress: remaining => {
192
213
  this.#indexingHours = remaining;
193
- // Each rolled batch fills in more history for open pages.
194
- if (remaining > 0) this.#changed();
214
+ // Each rolled batch fills in more history for open pages; the rows
215
+ // themselves are already being rolled, so only the version moves.
216
+ if (remaining > 0) this.#bumpVersion();
195
217
  },
196
218
  });
197
219
  } while (this.#refreshAgain);
@@ -240,9 +262,12 @@ export class StatsLive {
240
262
 
241
263
  #emit(): void {
242
264
  const status = this.status();
265
+ const frame = JSON.stringify(status);
266
+ if (frame === this.#lastFrame) return;
267
+ this.#lastFrame = frame;
243
268
  for (const listener of this.#listeners) {
244
269
  try {
245
- listener(status);
270
+ listener(status, frame);
246
271
  } catch (error) {
247
272
  logger.warn("Stats live listener failed", { error: String(error) });
248
273
  }
package/src/parser.ts CHANGED
@@ -4,7 +4,8 @@ import * as path from "node:path";
4
4
  import {
5
5
  type AssistantMessage,
6
6
  coerceServiceTierByFamily,
7
- getPriorityPremiumRequests,
7
+ getPremiumServiceTierRequests,
8
+ parseServiceTier,
8
9
  resolveModelServiceTier,
9
10
  type ServiceTierByFamily,
10
11
  type ToolCall,
@@ -236,20 +237,26 @@ function extractStats(
236
237
  const rawUsage = msg.usage as Partial<Usage> | undefined;
237
238
  if (!rawUsage || typeof rawUsage !== "object") return null;
238
239
 
239
- // Backfill: when the session recorded `priority` as the active service tier
240
- // at this point but the AI usage payload was captured before priority
241
- // requests were folded into `premiumRequests`, derive the count here so the
242
- // "Premium Reqs" stat aggregates priority traffic on re-sync. Trust any
243
- // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
244
- // the new AI code path) and only synthesise when the field is missing/zero.
240
+ // Backfill: when the session recorded a premium tier (`priority`/`ultrafast`)
241
+ // as the active service tier at this point but the AI usage payload was
242
+ // captured before premium requests were folded into `premiumRequests`, derive
243
+ // the count here so the "Premium Reqs" stat aggregates premium traffic on
244
+ // re-sync. The tier the provider reported serving the turn (`msg.serviceTier`)
245
+ // is authoritative — it is proof the tier reached the wire, so the
246
+ // discovery-metadata gate is skipped; the session's live setting is only a
247
+ // fallback for turns recorded before that field existed. Trust any non-zero
248
+ // value already in `usage.premiumRequests` (Copilot multipliers or the new AI
249
+ // code path) and only synthesise when the field is missing/zero.
245
250
  const recorded = rawUsage.premiumRequests ?? 0;
246
251
  const model = {
247
252
  provider: msg.provider,
248
253
  api: msg.api,
249
254
  identity: classifyModel(msg.provider, msg.model, { lenient: true }),
250
255
  };
251
- const tier = resolveModelServiceTier(currentServiceTier, model);
252
- const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
256
+ const servedTier = parseServiceTier(msg.serviceTier);
257
+ const tier = servedTier ?? resolveModelServiceTier(currentServiceTier, model);
258
+ const derived =
259
+ recorded > 0 ? recorded : getPremiumServiceTierRequests(tier, model, { served: servedTier !== undefined });
253
260
  const wellFormed =
254
261
  isFiniteCount(rawUsage.input) &&
255
262
  isFiniteCount(rawUsage.output) &&
@@ -291,6 +298,7 @@ function extractStats(
291
298
  errorMessage: msg.errorMessage ?? null,
292
299
  usage,
293
300
  agentType,
301
+ serviceTier: servedTier ?? null,
294
302
  };
295
303
  }
296
304
 
@@ -680,14 +688,7 @@ export async function listSessionFiles(folderPath: string): Promise<string[]> {
680
688
  */
681
689
  export async function listAllSessionFiles(): Promise<string[]> {
682
690
  const folders = await listSessionFolders();
683
- const allFiles: string[] = [];
684
-
685
- for (const folder of folders) {
686
- const files = await listSessionFiles(folder);
687
- allFiles.push(...files);
688
- }
689
-
690
- return allFiles;
691
+ return (await Promise.all(folders.map(listSessionFiles))).flat();
691
692
  }
692
693
 
693
694
  /**
package/src/rollup.ts CHANGED
@@ -32,6 +32,7 @@ import type {
32
32
  AgentTypeStats,
33
33
  AggregatedStats,
34
34
  CostTimeSeriesPoint,
35
+ DailyActivityPoint,
35
36
  FolderStats,
36
37
  ModelPerformancePoint,
37
38
  ModelStats,
@@ -179,7 +180,7 @@ const EXACT_DIRTY_SESSIONS = 512;
179
180
  */
180
181
  export function ensureRollupSchema(database: Database): void {
181
182
  const readMeta = (key: string) =>
182
- (database.prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined)?.value;
183
+ (database.query("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined)?.value;
183
184
  const fresh = readMeta(ROLLUP_VERSION_KEY) !== ROLLUP_VERSION;
184
185
  // Steady state touches no schema: DDL on every open would contend with
185
186
  // other omp processes writing the same database.
@@ -273,7 +274,7 @@ export function ensureRollupSchema(database: Database): void {
273
274
  }
274
275
  }
275
276
  database
276
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
277
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
277
278
  .run(TRIGGER_VERSION_KEY, TRIGGER_VERSION);
278
279
  if (fresh) {
279
280
  database.run(`
@@ -286,7 +287,7 @@ export function ensureRollupSchema(database: Database): void {
286
287
  SELECT DISTINCT session_file FROM messages UNION SELECT DISTINCT session_file FROM tool_calls
287
288
  `);
288
289
  database
289
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
290
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
290
291
  .run(ROLLUP_VERSION_KEY, ROLLUP_VERSION);
291
292
  }
292
293
  })
@@ -307,7 +308,7 @@ export function getRollupStatus(): RollupStatus {
307
308
  const database = currentDb();
308
309
  if (!database) return { dirtyHours: 0, dirtySessions: 0 };
309
310
  return database
310
- .prepare(
311
+ .query(
311
312
  "SELECT (SELECT COUNT(*) FROM rollup_dirty) AS dirtyHours, (SELECT COUNT(*) FROM session_dirty) AS dirtySessions",
312
313
  )
313
314
  .get() as RollupStatus;
@@ -322,25 +323,25 @@ export function refreshRollupBatch(limit: number): number {
322
323
  const database = currentDb();
323
324
  if (!database) return 0;
324
325
  const run = database.transaction(() => {
325
- const buckets = database.prepare("SELECT bucket FROM rollup_dirty ORDER BY bucket DESC LIMIT ?").all(limit) as {
326
+ const buckets = database.query("SELECT bucket FROM rollup_dirty ORDER BY bucket DESC LIMIT ?").all(limit) as {
326
327
  bucket: number;
327
328
  }[];
328
- const clearMessages = database.prepare("DELETE FROM message_rollup WHERE bucket = ?");
329
- const clearTools = database.prepare("DELETE FROM tool_rollup WHERE bucket = ?");
330
- const fillMessages = database.prepare(`
329
+ const clearMessages = database.query("DELETE FROM message_rollup WHERE bucket = ?");
330
+ const clearTools = database.query("DELETE FROM tool_rollup WHERE bucket = ?");
331
+ const fillMessages = database.query(`
331
332
  INSERT INTO message_rollup (${MESSAGE_ROLLUP_COLUMNS})
332
333
  SELECT ${messageFacts("?1", "")}
333
334
  FROM messages WHERE timestamp >= ?1 AND timestamp < ?1 + ${HOUR_MS}
334
335
  GROUP BY ${MESSAGE_DIMENSIONS}
335
336
  `);
336
- const fillTools = database.prepare(`
337
+ const fillTools = database.query(`
337
338
  INSERT INTO tool_rollup (${TOOL_ROLLUP_COLUMNS})
338
339
  SELECT ${toolFacts("?1")}
339
340
  FROM tool_calls t ${TOOL_JOIN}
340
341
  WHERE t.timestamp >= ?1 AND t.timestamp < ?1 + ${HOUR_MS}
341
342
  GROUP BY t.tool_name, t.model, t.provider
342
343
  `);
343
- const clean = database.prepare("DELETE FROM rollup_dirty WHERE bucket = ?");
344
+ const clean = database.query("DELETE FROM rollup_dirty WHERE bucket = ?");
344
345
  for (const { bucket } of buckets) {
345
346
  clearMessages.run(bucket);
346
347
  clearTools.run(bucket);
@@ -351,16 +352,16 @@ export function refreshRollupBatch(limit: number): number {
351
352
 
352
353
  // Transcripts are cheap (indexed by session_file); roll many per hour-batch.
353
354
  const sessions = database
354
- .prepare("SELECT session_file FROM session_dirty LIMIT ?")
355
+ .query("SELECT session_file FROM session_dirty LIMIT ?")
355
356
  .all(limit * SESSIONS_PER_HOUR) as { session_file: string }[];
356
- const clearSession = database.prepare("DELETE FROM session_rollup WHERE session_file = ?");
357
- const fillSession = database.prepare(`
357
+ const clearSession = database.query("DELETE FROM session_rollup WHERE session_file = ?");
358
+ const fillSession = database.query(`
358
359
  INSERT INTO session_rollup (${SESSION_ROLLUP_COLUMNS})
359
360
  SELECT ${sessionFacts("m.")}
360
361
  FROM messages m WHERE m.session_file = ?1
361
362
  GROUP BY m.session_file
362
363
  `);
363
- const cleanSession = database.prepare("DELETE FROM session_dirty WHERE session_file = ?");
364
+ const cleanSession = database.query("DELETE FROM session_dirty WHERE session_file = ?");
364
365
  for (const { session_file } of sessions) {
365
366
  clearSession.run(session_file);
366
367
  fillSession.run(session_file);
@@ -393,7 +394,7 @@ export async function refreshRollups(opts?: RefreshOptions): Promise<void> {
393
394
  const elapsed = performance.now() - started;
394
395
  if (elapsed < REFRESH_TARGET_MS / 2) batch = Math.min(REFRESH_MAX_BATCH, batch * 2);
395
396
  else if (elapsed > REFRESH_TARGET_MS * 2) batch = Math.max(1, Math.floor(batch / 2));
396
- opts?.onProgress?.(getRollupStatus().dirtyHours);
397
+ if (opts?.onProgress) opts.onProgress(getRollupStatus().dirtyHours);
397
398
  await Bun.sleep(0);
398
399
  }
399
400
  }
@@ -416,7 +417,7 @@ interface Source {
416
417
 
417
418
  function dirtyIsSmall(database: Database): boolean {
418
419
  const row = database
419
- .prepare(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM rollup_dirty LIMIT ${EXACT_DIRTY_LIMIT + 1})`)
420
+ .query(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM rollup_dirty LIMIT ${EXACT_DIRTY_LIMIT + 1})`)
420
421
  .get() as {
421
422
  n: number;
422
423
  };
@@ -508,7 +509,8 @@ function queryMessages<T>(window: Pick<RangeWindow, "cutoff"> & { bucketMs?: num
508
509
  if (!database) return [];
509
510
  const fine = window.bucketMs !== undefined && window.bucketMs < HOUR_MS;
510
511
  const source = messageSource(database, normalizeCutoff(window.cutoff), fine);
511
- return database.prepare(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
512
+ // `query` caches the compiled statement: the SQL varies only over a small set of shapes.
513
+ return database.query(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
512
514
  }
513
515
 
514
516
  function queryTools<T>(window: Pick<RangeWindow, "cutoff"> & { bucketMs?: number }, select: string, tail = ""): T[] {
@@ -516,7 +518,7 @@ function queryTools<T>(window: Pick<RangeWindow, "cutoff"> & { bucketMs?: number
516
518
  if (!database) return [];
517
519
  const fine = window.bucketMs !== undefined && window.bucketMs < HOUR_MS;
518
520
  const source = toolSource(database, normalizeCutoff(window.cutoff), fine);
519
- return database.prepare(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
521
+ return database.query(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
520
522
  }
521
523
 
522
524
  function normalizeCutoff(cutoff: number | null | undefined): number | null {
@@ -804,6 +806,25 @@ export function getProviderHourlyBurn(cutoff: number | null = null): ProviderHou
804
806
  }));
805
807
  }
806
808
 
809
+ /**
810
+ * Per-local-day request/token/cost totals since `cutoff`, oldest first, read
811
+ * from the hourly rollup. Returns null when the local UTC offset is not a whole
812
+ * hour (now or at `cutoff`), since hourly buckets then straddle local midnight,
813
+ * or while the dirty backlog is too large for exact reads; callers then
814
+ * aggregate the raw rows instead.
815
+ */
816
+ export function getDailyActivityFromRollup(cutoff: number): DailyActivityPoint[] | null {
817
+ if (new Date().getTimezoneOffset() % 60 !== 0 || new Date(cutoff).getTimezoneOffset() % 60 !== 0) return null;
818
+ const database = currentDb();
819
+ if (!database || !dirtyIsSmall(database)) return null;
820
+ return queryMessages<{ day: string; cost: number; requests: number; total_tokens: number | null }>(
821
+ { cutoff },
822
+ `date(f.bucket / 1000, 'unixepoch', 'localtime') AS day, TOTAL(f.cost_total) AS cost,
823
+ SUM(f.requests) AS requests, SUM(f.total_tokens) AS total_tokens`,
824
+ "GROUP BY day ORDER BY day",
825
+ ).map(row => ({ day: row.day, cost: row.cost, requests: row.requests, totalTokens: row.total_tokens ?? 0 }));
826
+ }
827
+
807
828
  /** Tokens/cost per bucket per provider. */
808
829
  export function getProviderTimeSeries({ cutoff, bucketMs }: RangeWindow): ProviderTimeSeriesPoint[] {
809
830
  return queryMessages<{
@@ -855,7 +876,7 @@ export function getSessionRollups(): SessionRollupRow[] {
855
876
  const database = currentDb();
856
877
  if (!database) return [];
857
878
  const { n } = database
858
- .prepare(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM session_dirty LIMIT ${EXACT_DIRTY_SESSIONS + 1})`)
879
+ .query(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM session_dirty LIMIT ${EXACT_DIRTY_SESSIONS + 1})`)
859
880
  .get() as { n: number };
860
881
  const exact = n <= EXACT_DIRTY_SESSIONS;
861
882
  const parts = [
@@ -869,7 +890,7 @@ export function getSessionRollups(): SessionRollupRow[] {
869
890
  );
870
891
  }
871
892
  return database
872
- .prepare(
893
+ .query(
873
894
  `SELECT session_file AS sessionFile, requests, started_at AS startedAt, ended_at AS endedAt,
874
895
  total_tokens AS totalTokens, cost_total AS costTotal, unpriced AS unpricedRequests, models,
875
896
  tool_calls AS toolCalls
package/src/server.ts CHANGED
@@ -14,9 +14,11 @@ import {
14
14
  getRecentErrors,
15
15
  getRecentRequests,
16
16
  getRequestDetails,
17
+ getTimeRangeConfig,
17
18
  getToolDashboardStats,
18
19
  } from "./aggregator";
19
- import { decodeEmbeddedClientArchive } from "./embedded-client";
20
+ import { initDb } from "./db";
21
+ import { decodeEmbeddedClientArchive, hasEmbeddedClientArchive } from "./embedded-client";
20
22
  import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
21
23
  import {
22
24
  cancelFrustrationRun,
@@ -36,18 +38,17 @@ import {
36
38
  STATS_DASHBOARD_HOSTNAME_HEADER,
37
39
  STATS_DASHBOARD_SECURITY_VERSION,
38
40
  } from "./port-conflict";
39
- import type { LiveStatus } from "./shared-types";
41
+ import { getStatsByModel, getTimeSeries } from "./rollup";
40
42
  import {
41
43
  buildSessionTrace,
42
44
  getTraceEntry,
43
45
  listSessionSummaries,
44
46
  TRACE_ETAG_VERSION,
47
+ type TraceFingerprint,
45
48
  traceFingerprintForEtag,
46
49
  TracePathError,
47
50
  } from "./trace";
48
51
 
49
- const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
50
-
51
52
  const CLIENT_DIR = path.join(import.meta.dir, "client");
52
53
  const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
53
54
  const IS_BUN_COMPILED =
@@ -60,21 +61,24 @@ const IS_BUN_COMPILED =
60
61
  // dashboard sources or prebuilt dist/client next to the bundle, so the
61
62
  // embedded archive is the only viable asset source.
62
63
  const IS_PREBUILT = IS_BUN_COMPILED || Boolean(process.env.PI_BUNDLED || Bun.env.PI_BUNDLED);
63
- const USE_EMBEDDED_CLIENT = EMBEDDED_CLIENT_ARCHIVE !== null || IS_PREBUILT;
64
+ // Sniff the base64 gzip magic instead of decoding: importing this module (every
65
+ // TUI start) must not pay for a multi-megabyte archive nobody may serve.
66
+ const USE_EMBEDDED_CLIENT = hasEmbeddedClientArchive(embeddedClientArchiveTxt) || IS_PREBUILT;
64
67
 
65
68
  let embeddedClientFilesPromise: Promise<Map<string, Blob>> | null = null;
66
69
 
67
70
  async function getEmbeddedClientFiles(): Promise<Map<string, Blob>> {
68
71
  if (embeddedClientFilesPromise) return embeddedClientFilesPromise;
69
72
 
70
- if (!EMBEDDED_CLIENT_ARCHIVE) {
73
+ const archive = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
74
+ if (!archive) {
71
75
  throw new Error(
72
76
  "Embedded stats client bundle missing. Rebuild the omp binary or npm bundle with embedded stats assets.",
73
77
  );
74
78
  }
75
79
 
76
80
  // Keep bundled assets in memory so OS temporary-file cleanup cannot break a live dashboard.
77
- embeddedClientFilesPromise = new Bun.Archive(EMBEDDED_CLIENT_ARCHIVE).files().then(files => {
81
+ embeddedClientFilesPromise = new Bun.Archive(archive).files().then(files => {
78
82
  for (const [name, file] of files) {
79
83
  // Archive entries are untyped blobs; infer MIME types just as disk-backed Bun files do.
80
84
  files.set(name, new File([file], name, { type: Bun.file(name).type }));
@@ -241,8 +245,9 @@ export async function handleApi(req: Request): Promise<Response> {
241
245
  }
242
246
 
243
247
  if (path === "/api/stats/models") {
244
- const stats = await getDashboardStats(range);
245
- return Response.json(stats.byModel);
248
+ // Also the port-conflict identity probe: keep it to the one query it serves.
249
+ await initDb();
250
+ return Response.json(getStatsByModel(getTimeRangeConfig(range).cutoff));
246
251
  }
247
252
 
248
253
  if (path === "/api/stats/folders") {
@@ -251,8 +256,8 @@ export async function handleApi(req: Request): Promise<Response> {
251
256
  }
252
257
 
253
258
  if (path === "/api/stats/timeseries") {
254
- const stats = await getDashboardStats(range);
255
- return Response.json(stats.timeSeries);
259
+ await initDb();
260
+ return Response.json(getTimeSeries(getTimeRangeConfig(range)));
256
261
  }
257
262
 
258
263
  if (path.startsWith("/api/request/")) {
@@ -293,14 +298,15 @@ export async function handleApi(req: Request): Promise<Response> {
293
298
  // The fingerprint covers child transcripts, so a subagent-only
294
299
  // append changes the ETag and never 304s stale.
295
300
  const clientEtag = req.headers.get("if-none-match");
301
+ let fingerprint: TraceFingerprint | undefined;
296
302
  if (clientEtag) {
297
- const fingerprint = await traceFingerprintForEtag(file);
303
+ fingerprint = await traceFingerprintForEtag(file);
298
304
  if (fingerprint !== undefined) {
299
- const etag = `"${TRACE_ETAG_VERSION}:${fingerprint}"`;
305
+ const etag = `"${TRACE_ETAG_VERSION}:${fingerprint.rootMtimeMs}:${fingerprint.childFingerprint}"`;
300
306
  if (clientEtag === etag) return new Response(null, { status: 304 });
301
307
  }
302
308
  }
303
- const trace = await buildSessionTrace(file);
309
+ const trace = await buildSessionTrace(file, fingerprint);
304
310
  const etag = `"${TRACE_ETAG_VERSION}:${trace.etag}"`;
305
311
  if (clientEtag === etag) return new Response(null, { status: 304 });
306
312
  return Response.json(trace, { headers: { ETag: etag } });
@@ -383,7 +389,7 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
383
389
  if (path === "/api/events") {
384
390
  // Long-lived stream: exempt from the idle timeout.
385
391
  server.timeout(req, 0);
386
- return liveEventStream(dashboardHeaders);
392
+ return liveEventStream(dashboardHeaders, req.signal);
387
393
  }
388
394
 
389
395
  try {
@@ -423,8 +429,9 @@ const EVENT_HEARTBEAT_MS = 15_000;
423
429
  /**
424
430
  * Server-sent events of {@link LiveStatus}: the current status immediately,
425
431
  * then every change (sync progress, data version bumps, indexing backlog).
432
+ * Disconnects unsubscribe, so ingest winds down once no page is listening.
426
433
  */
427
- function liveEventStream(headers: Record<string, string>): Response {
434
+ function liveEventStream(headers: Record<string, string>, signal: AbortSignal): Response {
428
435
  const live = statsLive();
429
436
  // Ingest starts when the first page connects, not when the server binds.
430
437
  live.start();
@@ -432,19 +439,26 @@ function liveEventStream(headers: Record<string, string>): Response {
432
439
  let cleanup = () => {};
433
440
  const stream = new ReadableStream<Uint8Array>({
434
441
  start(controller) {
435
- const send = (status: LiveStatus) => {
436
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(status)}\n\n`));
442
+ const write = (chunk: string) => {
443
+ try {
444
+ controller.enqueue(encoder.encode(chunk));
445
+ } catch {
446
+ // Stream already closed: the client left without a cancel.
447
+ cleanup();
448
+ }
437
449
  };
438
- send(live.status());
439
- const unsubscribe = live.subscribe(send);
440
- const heartbeat = setInterval(
441
- () => controller.enqueue(encoder.encode(": keep-alive\n\n")),
442
- EVENT_HEARTBEAT_MS,
443
- );
450
+ write(`data: ${JSON.stringify(live.status())}\n\n`);
451
+ const unsubscribe = live.subscribe((_status, frame) => write(`data: ${frame}\n\n`));
452
+ const heartbeat = setInterval(() => write(": keep-alive\n\n"), EVENT_HEARTBEAT_MS);
453
+ const onAbort = () => cleanup();
444
454
  cleanup = () => {
455
+ cleanup = () => {};
445
456
  unsubscribe();
446
457
  clearInterval(heartbeat);
458
+ signal.removeEventListener("abort", onAbort);
447
459
  };
460
+ if (signal.aborted) cleanup();
461
+ else signal.addEventListener("abort", onAbort, { once: true });
448
462
  },
449
463
  cancel() {
450
464
  cleanup();