@linxiraos/pi-stats 1.1.15 → 1.1.16

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.
package/src/aggregator.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import * as fs from "node:fs";
2
- import * as fsp from "node:fs/promises";
3
- import { workerHostEntry } from "@linxiraos/pi-utils";
2
+ import * as path from "node:path";
3
+ import { getStatsDbPath, workerHostEntry } from "@linxiraos/pi-utils";
4
+ import { withFileLock } from "@linxiraos/pi-utils/file-lock";
4
5
  import {
6
+ applySessionParseResult,
7
+ completeSessionSync,
5
8
  getRecentErrors as dbGetRecentErrors,
6
9
  getRecentRequests as dbGetRecentRequests,
7
10
  getBehaviorByModel,
@@ -25,16 +28,18 @@ import {
25
28
  getToolStatsByModel,
26
29
  getToolTimeSeries,
27
30
  initDb,
28
- insertMessageStats,
29
- insertToolCalls,
30
- insertUserMessageStats,
31
31
  markSessionBackfillsComplete,
32
+ prepareSessionSync,
32
33
  pruneOrphanSessions,
33
- setFileOffset,
34
- updateToolResults,
35
- updateUserMessageLinks,
36
34
  } from "./db";
37
- import { getSessionEntry, listAllSessionFiles, type ParseSessionResult, parseSessionFile } from "./parser";
35
+ import {
36
+ getSessionEntry,
37
+ listAllSessionFiles,
38
+ matchesSessionFile,
39
+ type ParseSessionResult,
40
+ parseSessionFile,
41
+ type SessionParserState,
42
+ } from "./parser";
38
43
  import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
39
44
  // Coding-agent binary/bundle workers route through the CLI entrypoint with a
40
45
  // hidden argv mode, so the compiled binary and npm bundle only need one
@@ -51,20 +56,23 @@ import type {
51
56
  } from "./types";
52
57
  import { computeUsageWindowStats, fetchUsageData } from "./usage-windows";
53
58
 
59
+ const STATS_SYNC_LOCK_RETRY_MS = 25;
60
+ const STATS_SYNC_LOCK_WAIT_MS = 60 * 60 * 1000;
61
+
54
62
  /**
55
- * Apply a freshly parsed result to the database. Runs entirely on the
56
- * main thread so the single SQLite handle owns every write.
63
+ * Serialize stats ingestion and archive reconciliation across processes.
64
+ * The lock covers file discovery, parsing, and the final SQLite write so a
65
+ * parse result for a session moved by GC can never commit after cleanup.
66
+ * The native lock is owned by an operating-system primitive, so an interrupted
67
+ * owner is released automatically and a live owner is never displaced.
57
68
  */
58
- function applyParseResult(sessionFile: string, lastModified: number, result: ParseSessionResult): number {
59
- if (result.stats.length > 0) insertMessageStats(result.stats);
60
- if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
61
- if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
62
- if (result.toolCalls.length > 0) insertToolCalls(result.toolCalls);
63
- if (result.toolResults.length > 0) updateToolResults(result.toolResults);
64
- setFileOffset(sessionFile, result.newOffset, lastModified);
65
- return result.stats.length + result.userStats.length;
69
+ export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T> {
70
+ await fs.promises.mkdir(path.dirname(dbPath), { recursive: true });
71
+ return await withFileLock(`${dbPath}.sync`, fn, {
72
+ retryDelayMs: STATS_SYNC_LOCK_RETRY_MS,
73
+ retries: Math.ceil(STATS_SYNC_LOCK_WAIT_MS / STATS_SYNC_LOCK_RETRY_MS),
74
+ });
66
75
  }
67
-
68
76
  /**
69
77
  * Progress event emitted after each session file is fully processed.
70
78
  * `current` is the number of files completed (skipped + parsed),
@@ -221,7 +229,23 @@ export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: n
221
229
  * bar walks at a steady rate).
222
230
  */
223
231
  export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
232
+ return withStatsSyncLock(getStatsDbPath(), async () => {
233
+ let processed = 0;
234
+ let files = 0;
235
+ while (true) {
236
+ const result = await syncAllSessionsLocked(opts);
237
+ processed += result.processed;
238
+ files += result.files;
239
+ if (!result.reconcile) return { processed, files };
240
+ }
241
+ });
242
+ }
243
+
244
+ async function syncAllSessionsLocked(
245
+ opts?: SyncOptions,
246
+ ): Promise<{ processed: number; files: number; reconcile: boolean }> {
224
247
  await initDb();
248
+ const replay = prepareSessionSync();
225
249
 
226
250
  const files = await listAllSessionFiles();
227
251
  // Drop rows for transcripts that no longer exist on disk (deleted temp/test
@@ -231,9 +255,11 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
231
255
  let filesProcessed = 0;
232
256
  let completed = 0;
233
257
  let cursor = 0;
258
+ let reconcile = false;
234
259
  const finish = () => {
260
+ completeSessionSync(reconcile);
235
261
  markSessionBackfillsComplete();
236
- return { processed: totalProcessed, files: filesProcessed };
262
+ return { processed: totalProcessed, files: filesProcessed, reconcile };
237
263
  };
238
264
  if (files.length === 0) return finish();
239
265
 
@@ -249,7 +275,12 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
249
275
 
250
276
  const processFile = async (
251
277
  sessionFile: string,
252
- parse: (sessionFile: string, fromOffset: number) => Promise<ParseSessionResult>,
278
+ parse: (
279
+ sessionFile: string,
280
+ fromOffset: number,
281
+ state?: SessionParserState,
282
+ replay?: boolean,
283
+ ) => Promise<ParseSessionResult>,
253
284
  ): Promise<void> => {
254
285
  let fileStats: fs.Stats;
255
286
  try {
@@ -260,14 +291,24 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
260
291
  }
261
292
  const lastModified = fileStats.mtimeMs;
262
293
  const stored = getFileOffset(sessionFile);
263
- if (stored && stored.lastModified >= lastModified) {
294
+ if (
295
+ !replay &&
296
+ stored?.parserState &&
297
+ stored.lastModified === lastModified &&
298
+ stored.parserState.size === fileStats.size &&
299
+ matchesSessionFile(stored.parserState, fileStats)
300
+ ) {
264
301
  report(sessionFile);
265
302
  return;
266
303
  }
267
304
 
268
- const fromOffset = stored?.offset ?? 0;
269
- const result = await parse(sessionFile, fromOffset);
270
- const inserted = applyParseResult(sessionFile, lastModified, result);
305
+ const unknownIdentity = stored !== null && !stored.parserState;
306
+ const fromOffset = unknownIdentity ? 0 : (stored?.offset ?? 0);
307
+ const result = await parse(sessionFile, fromOffset, stored?.parserState, replay);
308
+ if (unknownIdentity && result.parserState) result.reset = true;
309
+ const applied = applySessionParseResult(sessionFile, result, replay || !stored?.parserState);
310
+ const inserted = applied.processed;
311
+ if (applied.reconcile) reconcile = true;
271
312
  if (inserted > 0) {
272
313
  totalProcessed += inserted;
273
314
  filesProcessed++;
@@ -293,7 +334,9 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
293
334
  const idx = cursor++;
294
335
  if (idx >= files.length) return;
295
336
  const sessionFile = files[idx];
296
- await processFile(sessionFile, (file, fromOffset) => dispatch(handle, { sessionFile: file, fromOffset }));
337
+ await processFile(sessionFile, (file, fromOffset, parserState, replay) =>
338
+ dispatch(handle, { sessionFile: file, fromOffset, parserState, replay }),
339
+ );
297
340
  }
298
341
  }
299
342
 
@@ -553,79 +596,3 @@ export async function getProviderDashboardStats(range?: string | null): Promise<
553
596
  windowInsights,
554
597
  };
555
598
  }
556
-
557
- const STATS_LOCK_TIMEOUT_MS = 30_000;
558
- const STATS_LOCK_STALE_MS = 60_000;
559
-
560
- async function statIfPresent(path: string) {
561
- try {
562
- return await fsp.stat(path);
563
- } catch (error) {
564
- const code =
565
- typeof error === "object" && error !== null && "code" in error ? (error as { code: string }).code : undefined;
566
- if (code === "ENOENT") return null;
567
- throw error;
568
- }
569
- }
570
-
571
- function processExists(pid: number): boolean {
572
- try {
573
- process.kill(pid, 0);
574
- return true;
575
- } catch (error) {
576
- const code =
577
- typeof error === "object" && error !== null && "code" in error ? (error as { code: string }).code : undefined;
578
- if (code === "ESRCH" || code === "EINVAL") return false;
579
- return true;
580
- }
581
- }
582
-
583
- async function isStatsLockStale(lockPath: string): Promise<boolean> {
584
- const stat = await statIfPresent(lockPath);
585
- if (!stat) return false;
586
- const pid = Number.parseInt((await Bun.file(lockPath).text()).split(/\r?\n/, 1)[0] ?? "", 10);
587
- if (Number.isSafeInteger(pid) && pid > 0) return !processExists(pid);
588
- return Date.now() - stat.mtimeMs > STATS_LOCK_STALE_MS;
589
- }
590
-
591
- /**
592
- * Acquire an exclusive lock on the stats database and execute `fn`.
593
- * Used by GC to prevent stats row cleanup from racing with stats sync.
594
- */
595
- export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T> {
596
- const lockPath = `${dbPath}.lock`;
597
- const deadline = Date.now() + STATS_LOCK_TIMEOUT_MS;
598
-
599
- while (Date.now() < deadline) {
600
- try {
601
- const handle = await fsp.open(lockPath, "wx");
602
- try {
603
- await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`);
604
- return await fn();
605
- } finally {
606
- await handle.close();
607
- await fsp.unlink(lockPath).catch(() => {});
608
- }
609
- } catch (error) {
610
- const code =
611
- typeof error === "object" && error !== null && "code" in error
612
- ? (error as { code: string }).code
613
- : undefined;
614
- if (code !== "EEXIST") throw error;
615
- }
616
- if (await isStatsLockStale(lockPath)) {
617
- try {
618
- await fsp.unlink(lockPath);
619
- } catch (error) {
620
- const code =
621
- typeof error === "object" && error !== null && "code" in error
622
- ? (error as { code: string }).code
623
- : undefined;
624
- if (code !== "ENOENT") throw error;
625
- }
626
- }
627
- await Bun.sleep(100);
628
- }
629
-
630
- throw new Error(`timed out waiting for stats lock: ${lockPath}`);
631
- }
@@ -1511,10 +1511,35 @@
1511
1511
  border: 1px solid var(--border);
1512
1512
  border-radius: var(--radius-md);
1513
1513
  color: var(--text);
1514
+ font-family: inherit;
1514
1515
  font-size: 12px;
1515
1516
  padding: 6px 10px;
1516
1517
  }
1517
1518
 
1519
+ /* Prevent iOS from zooming the viewport when a search input receives focus
1520
+ (mirrors the .stats-select coarse-pointer treatment). */
1521
+ @media (pointer: coarse) {
1522
+ .stats-trace-input {
1523
+ font-size: 16px;
1524
+ }
1525
+ }
1526
+
1527
+ /* Native clear button is a dark glyph in WebKit; keep it visible in the dark
1528
+ theme by inverting it (no-op in light). */
1529
+ .stats-trace-input::-webkit-search-cancel-button {
1530
+ filter: invert(1);
1531
+ }
1532
+
1533
+ @media (prefers-color-scheme: light) {
1534
+ :root:not([data-theme]) .stats-trace-input::-webkit-search-cancel-button {
1535
+ filter: none;
1536
+ }
1537
+ }
1538
+
1539
+ [data-theme="light"] .stats-trace-input::-webkit-search-cancel-button {
1540
+ filter: none;
1541
+ }
1542
+
1518
1543
  .stats-trace-input:focus-visible {
1519
1544
  outline: 2px solid var(--link);
1520
1545
  outline-offset: 1px;
@@ -1534,6 +1559,11 @@
1534
1559
  user-select: none;
1535
1560
  }
1536
1561
 
1562
+ /* Brand-tint the native checkbox instead of leaving the browser default. */
1563
+ .stats-trace-check input[type="checkbox"] {
1564
+ accent-color: var(--accent);
1565
+ }
1566
+
1537
1567
  .stats-trace-icon-btn {
1538
1568
  display: inline-flex;
1539
1569
  align-items: center;
package/src/db.ts CHANGED
@@ -9,8 +9,7 @@ import {
9
9
  } from "@linxiraos/pi-catalog/models";
10
10
  import type { ModelCost } from "@linxiraos/pi-catalog/types";
11
11
  import { getConfigRootDir, getStatsDbPath } from "@linxiraos/pi-utils";
12
-
13
- import { classifyAgentType } from "./parser";
12
+ import { classifyAgentType, type ParseSessionResult, type SessionParserState } from "./parser";
14
13
  import type {
15
14
  AgentType,
16
15
  AgentTypeStats,
@@ -145,7 +144,10 @@ const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
145
144
  const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
146
145
  const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
147
146
  const FORK_DEDUPE_KEY = "fork_dedupe_v1";
148
- const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v1";
147
+ // v2: tool-name sanitization at ingest (see `sanitizeToolName` in parser.ts)
148
+ // collapses provider-side garbage names; a full re-parse replaces the polluted
149
+ // rows already stored in existing databases.
150
+ const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v2";
149
151
  // Older ingests dropped `Usage.orchestration` (never a stored column) when
150
152
  // pricing, so subscription models billed on orchestration tokens — multi-agent
151
153
  // Grok most notably — were priced from conversation buckets alone and could not
@@ -213,6 +215,7 @@ export async function initDb(): Promise<Database> {
213
215
  );
214
216
 
215
217
  CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
218
+ CREATE INDEX IF NOT EXISTS idx_messages_entry_timestamp ON messages(entry_id, timestamp);
216
219
  CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
217
220
  CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
218
221
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
@@ -223,7 +226,8 @@ export async function initDb(): Promise<Database> {
223
226
  CREATE TABLE IF NOT EXISTS file_offsets (
224
227
  session_file TEXT PRIMARY KEY,
225
228
  offset INTEGER NOT NULL,
226
- last_modified INTEGER NOT NULL
229
+ last_modified INTEGER NOT NULL,
230
+ parser_state TEXT
227
231
  );
228
232
 
229
233
  CREATE TABLE IF NOT EXISTS user_messages (
@@ -246,6 +250,7 @@ export async function initDb(): Promise<Database> {
246
250
  );
247
251
 
248
252
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
253
+ CREATE INDEX IF NOT EXISTS idx_user_messages_entry_timestamp ON user_messages(entry_id, timestamp);
249
254
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
250
255
 
251
256
  CREATE TABLE IF NOT EXISTS tool_calls (
@@ -267,6 +272,7 @@ export async function initDb(): Promise<Database> {
267
272
  );
268
273
 
269
274
  CREATE INDEX IF NOT EXISTS idx_tool_calls_timestamp ON tool_calls(timestamp);
275
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_entry_timestamp ON tool_calls(entry_id, timestamp);
270
276
  CREATE INDEX IF NOT EXISTS idx_tool_calls_tool_timestamp ON tool_calls(tool_name, timestamp);
271
277
 
272
278
  CREATE TABLE IF NOT EXISTS meta (
@@ -275,6 +281,10 @@ export async function initDb(): Promise<Database> {
275
281
  );
276
282
  `);
277
283
 
284
+ const offsetColumns = db.prepare("PRAGMA table_info(file_offsets)").all() as { name: string }[];
285
+ if (!offsetColumns.some(column => column.name === "parser_state")) {
286
+ db.run("ALTER TABLE file_offsets ADD COLUMN parser_state TEXT");
287
+ }
278
288
  const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
279
289
  if (!messageColumns.some(column => column.name === "premium_requests")) {
280
290
  db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
@@ -591,26 +601,104 @@ function backfillNoCacheInputCosts(database: Database): void {
591
601
  /**
592
602
  * Get the stored offset for a session file.
593
603
  */
594
- export function getFileOffset(sessionFile: string): { offset: number; lastModified: number } | null {
604
+ export function getFileOffset(
605
+ sessionFile: string,
606
+ ): { offset: number; lastModified: number; parserState?: SessionParserState } | null {
595
607
  if (!db) return null;
596
608
 
597
- const stmt = db.prepare("SELECT offset, last_modified FROM file_offsets WHERE session_file = ?");
598
- const row = stmt.get(sessionFile) as { offset: number; last_modified: number } | undefined;
599
-
600
- return row ? { offset: row.offset, lastModified: row.last_modified } : null;
609
+ const stmt = db.prepare("SELECT offset, last_modified, parser_state FROM file_offsets WHERE session_file = ?");
610
+ const row = stmt.get(sessionFile) as
611
+ | { offset: number; last_modified: number; parser_state: string | null }
612
+ | undefined;
613
+ if (!row) return null;
614
+ let parserState: SessionParserState | undefined;
615
+ if (row.parser_state) {
616
+ try {
617
+ const state = JSON.parse(row.parser_state) as SessionParserState;
618
+ if (state?.version === 1 && state.offset === row.offset) parserState = state;
619
+ } catch {
620
+ /* A missing cursor is reconstructed from the transcript. */
621
+ }
622
+ }
623
+ return { offset: row.offset, lastModified: row.last_modified, parserState };
601
624
  }
602
625
 
603
626
  /**
604
627
  * Update the stored offset for a session file.
605
628
  */
606
- export function setFileOffset(sessionFile: string, offset: number, lastModified: number): void {
629
+ export function setFileOffset(
630
+ sessionFile: string,
631
+ offset: number,
632
+ lastModified: number,
633
+ parserState?: SessionParserState,
634
+ ): void {
607
635
  if (!db) return;
608
636
 
609
637
  const stmt = db.prepare(`
610
- INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified)
611
- VALUES (?, ?, ?)
638
+ INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified, parser_state)
639
+ VALUES (?, ?, ?, ?)
612
640
  `);
613
- stmt.run(sessionFile, offset, lastModified);
641
+ stmt.run(sessionFile, offset, lastModified, parserState ? JSON.stringify(parserState) : null);
642
+ }
643
+
644
+ export function applySessionParseResult(
645
+ sessionFile: string,
646
+ result: ParseSessionResult,
647
+ rebuild = false,
648
+ ): { processed: number; reconcile: boolean } {
649
+ const parserState = result.parserState;
650
+ if (!db || !parserState) return { processed: 0, reconcile: false };
651
+ const database = db;
652
+ return database.transaction(() => {
653
+ let reconcile = result.reset ?? false;
654
+ if (result.reset || rebuild) {
655
+ const retainedMessages = new Set(result.stats.map(row => JSON.stringify([row.entryId, row.timestamp])));
656
+ const retainedUsers = new Set(result.userStats.map(row => JSON.stringify([row.entryId, row.timestamp])));
657
+ const retainedTools = new Set(
658
+ result.toolCalls.map(row => JSON.stringify([row.entryId, row.timestamp, row.toolCallId])),
659
+ );
660
+ const messages = database
661
+ .prepare("SELECT entry_id, timestamp FROM messages WHERE session_file = ?")
662
+ .all(sessionFile) as {
663
+ entry_id: string;
664
+ timestamp: number;
665
+ }[];
666
+ const users = database
667
+ .prepare("SELECT entry_id, timestamp FROM user_messages WHERE session_file = ?")
668
+ .all(sessionFile) as { entry_id: string; timestamp: number }[];
669
+ const tools = database
670
+ .prepare("SELECT entry_id, timestamp, tool_call_id FROM tool_calls WHERE session_file = ?")
671
+ .all(sessionFile) as { entry_id: string; timestamp: number; tool_call_id: string }[];
672
+ // A removed owner may have surviving fork copies skipped earlier in this pass.
673
+ reconcile ||=
674
+ messages.some(row => !retainedMessages.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
675
+ users.some(row => !retainedUsers.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
676
+ tools.some(row => !retainedTools.has(JSON.stringify([row.entry_id, row.timestamp, row.tool_call_id])));
677
+ database.prepare("DELETE FROM messages WHERE session_file = ?").run(sessionFile);
678
+ database.prepare("DELETE FROM user_messages WHERE session_file = ?").run(sessionFile);
679
+ database.prepare("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile);
680
+ }
681
+ if (reconcile) {
682
+ database
683
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')")
684
+ .run();
685
+ }
686
+ if (result.stats.length > 0) insertMessageStats(result.stats);
687
+ if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
688
+ if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
689
+ if (result.toolCalls.length > 0) insertToolCalls(result.toolCalls);
690
+ if (result.toolResults.length > 0) updateToolResults(result.toolResults);
691
+ setFileOffset(sessionFile, result.newOffset, parserState.mtimeMs, parserState);
692
+ return { processed: result.stats.length + result.userStats.length, reconcile };
693
+ })();
694
+ }
695
+
696
+ export function prepareSessionSync(): boolean {
697
+ return Boolean(db?.prepare("SELECT 1 FROM meta WHERE key = 'session_reconciliation'").get());
698
+ }
699
+
700
+ export function completeSessionSync(reconcile: boolean): void {
701
+ if (!reconcile) db?.prepare("DELETE FROM meta WHERE key = 'session_reconciliation'").run();
614
702
  }
615
703
 
616
704
  /**