@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/CHANGELOG.md +2 -9
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/client/index.css +1 -1
- package/dist/client/styles.css +20 -0
- package/dist/types/aggregator.d.ts +8 -5
- package/dist/types/db.d.ts +9 -1
- package/dist/types/parser.d.ts +18 -17
- package/dist/types/shared-types.d.ts +6 -0
- package/dist/types/sync-worker.d.ts +4 -2
- package/dist/types/trace.d.ts +9 -3
- package/package.json +5 -5
- package/src/aggregator.ts +70 -103
- package/src/client/styles.css +30 -0
- package/src/db.ts +101 -13
- package/src/parser.ts +137 -31
- package/src/port-conflict.ts +18 -7
- package/src/server.ts +25 -3
- package/src/shared-types.ts +6 -0
- package/src/sync-worker.ts +11 -4
- package/src/trace.ts +171 -14
package/src/aggregator.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import * as
|
|
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 {
|
|
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
|
-
*
|
|
56
|
-
*
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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: (
|
|
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 (
|
|
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
|
|
269
|
-
const
|
|
270
|
-
const
|
|
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
|
|
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
|
-
}
|
package/src/client/styles.css
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
599
|
-
|
|
600
|
-
|
|
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(
|
|
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
|
/**
|