@oh-my-pi/pi-coding-agent 17.2.5 → 17.2.7
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 +35 -1
- package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-1p8yvde7.md} +35 -1
- package/dist/cli.js +3340 -3339
- package/dist/types/advisor/advise-tool.d.ts +10 -4
- package/dist/types/autoresearch/tools/init-experiment.d.ts +19 -7
- package/dist/types/autoresearch/tools/log-experiment.d.ts +19 -11
- package/dist/types/autoresearch/tools/run-experiment.d.ts +4 -2
- package/dist/types/autoresearch/tools/update-notes.d.ts +5 -2
- package/dist/types/cli/gc-cli.d.ts +1 -0
- package/dist/types/commit/agentic/tools/analyze-file.d.ts +5 -2
- package/dist/types/commit/agentic/tools/git-file-diff.d.ts +5 -2
- package/dist/types/commit/agentic/tools/git-hunk.d.ts +6 -2
- package/dist/types/commit/agentic/tools/git-overview.d.ts +5 -2
- package/dist/types/commit/agentic/tools/propose-changelog.d.ts +29 -7
- package/dist/types/commit/agentic/tools/propose-commit.d.ts +14 -4
- package/dist/types/commit/agentic/tools/recent-commits.d.ts +4 -2
- package/dist/types/commit/agentic/tools/schemas.d.ts +7 -3
- package/dist/types/commit/agentic/tools/split-commit.d.ts +37 -10
- package/dist/types/commit/changelog/generate.d.ts +13 -3
- package/dist/types/commit/shared-llm.d.ts +13 -4
- package/dist/types/config/config-file.d.ts +1 -1
- package/dist/types/config/models-config-schema-bundle.d.ts +1992 -981
- package/dist/types/config/models-config-schema.d.ts +1201 -580
- package/dist/types/config/models-config.d.ts +355 -371
- package/dist/types/edit/hashline/params.d.ts +4 -2
- package/dist/types/edit/modes/apply-patch.d.ts +4 -2
- package/dist/types/edit/modes/patch.d.ts +16 -5
- package/dist/types/edit/modes/replace.d.ts +8 -3
- package/dist/types/eval/bridge-timeout.d.ts +5 -2
- package/dist/types/eval/completion-bridge.d.ts +13 -0
- package/dist/types/eval/py/tool-bridge.d.ts +12 -0
- package/dist/types/extensibility/custom-commands/types.d.ts +4 -4
- package/dist/types/extensibility/custom-tools/types.d.ts +4 -4
- package/dist/types/extensibility/extensions/types.d.ts +4 -4
- package/dist/types/extensibility/hooks/types.d.ts +4 -4
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
- package/dist/types/extensibility/legacy-typebox.d.ts +8 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +6 -13
- package/dist/types/goals/tools/goal-tool.d.ts +12 -4
- package/dist/types/launch/client.d.ts +9 -1
- package/dist/types/launch/protocol.d.ts +17 -0
- package/dist/types/lsp/index.d.ts +15 -5
- package/dist/types/lsp/types.d.ts +15 -5
- package/dist/types/modes/components/btw-panel.d.ts +3 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +4 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/security/contracts/schemas.d.ts +1115 -537
- package/dist/types/security/publication.d.ts +92 -34
- package/dist/types/session/agent-session-types.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +26 -2
- package/dist/types/session/launch-completion.d.ts +10 -0
- package/dist/types/session/session-entries.d.ts +15 -1
- package/dist/types/session/session-manager.d.ts +7 -0
- package/dist/types/session/yield-queue.d.ts +6 -1
- package/dist/types/task/types.d.ts +63 -18
- package/dist/types/tools/ask.d.ts +34 -8
- package/dist/types/tools/ast-edit.d.ts +16 -4
- package/dist/types/tools/ast-grep.d.ts +12 -4
- package/dist/types/tools/bash.d.ts +20 -11
- package/dist/types/tools/browser.d.ts +64 -20
- package/dist/types/tools/checkpoint.d.ts +16 -8
- package/dist/types/tools/computer.d.ts +1 -1
- package/dist/types/tools/debug.d.ts +118 -48
- package/dist/types/tools/eval.d.ts +11 -5
- package/dist/types/tools/gh.d.ts +74 -26
- package/dist/types/tools/glob.d.ts +16 -6
- package/dist/types/tools/grep.d.ts +18 -6
- package/dist/types/tools/hub/index.d.ts +116 -46
- package/dist/types/tools/image-gen.d.ts +25 -8
- package/dist/types/tools/index.d.ts +9 -0
- package/dist/types/tools/inspect-image.d.ts +10 -4
- package/dist/types/tools/learn.d.ts +26 -8
- package/dist/types/tools/manage-skill.d.ts +16 -6
- package/dist/types/tools/memory-edit.d.ts +18 -6
- package/dist/types/tools/memory-recall.d.ts +8 -4
- package/dist/types/tools/memory-reflect.d.ts +10 -4
- package/dist/types/tools/memory-retain.d.ts +14 -4
- package/dist/types/tools/read.d.ts +8 -4
- package/dist/types/tools/security-scan.d.ts +82 -30
- package/dist/types/tools/todo.d.ts +30 -10
- package/dist/types/tools/tts.d.ts +12 -5
- package/dist/types/tools/vibe.d.ts +42 -18
- package/dist/types/tools/write.d.ts +10 -4
- package/dist/types/web/search/index.d.ts +20 -6
- package/package.json +13 -13
- package/scripts/legacy-pi-virtual-module.ts +2 -2
- package/src/advisor/advise-tool.ts +1 -1
- package/src/advisor/config.ts +1 -1
- package/src/autoresearch/tools/init-experiment.ts +1 -2
- package/src/autoresearch/tools/log-experiment.ts +1 -2
- package/src/autoresearch/tools/run-experiment.ts +1 -1
- package/src/autoresearch/tools/update-notes.ts +1 -1
- package/src/cli/gc-cli.ts +641 -21
- package/src/commit/agentic/tools/analyze-file.ts +1 -1
- package/src/commit/agentic/tools/git-file-diff.ts +1 -1
- package/src/commit/agentic/tools/git-hunk.ts +1 -1
- package/src/commit/agentic/tools/git-overview.ts +1 -1
- package/src/commit/agentic/tools/propose-changelog.ts +1 -1
- package/src/commit/agentic/tools/propose-commit.ts +1 -1
- package/src/commit/agentic/tools/recent-commits.ts +1 -1
- package/src/commit/agentic/tools/schemas.ts +1 -1
- package/src/commit/agentic/tools/split-commit.ts +1 -1
- package/src/commit/analysis/summary.ts +1 -1
- package/src/commit/changelog/generate.ts +1 -1
- package/src/config/config-file.ts +2 -2
- package/src/config/model-discovery.ts +14 -14
- package/src/config/model-registry.ts +10 -8
- package/src/config/models-config-schema-bundle.ts +1 -8
- package/src/config/settings.ts +1 -1
- package/src/discovery/codex.ts +22 -9
- package/src/edit/hashline/params.ts +1 -1
- package/src/edit/modes/apply-patch.ts +1 -1
- package/src/edit/modes/patch.ts +1 -1
- package/src/edit/modes/replace.ts +2 -1
- package/src/eval/agent-bridge.ts +4 -2
- package/src/eval/bridge-timeout.ts +5 -2
- package/src/eval/completion-bridge.ts +3 -2
- package/src/eval/executor-base.ts +20 -1
- package/src/eval/js/context-manager.ts +94 -6
- package/src/eval/py/tool-bridge.ts +29 -9
- package/src/export/html/index.ts +10 -3
- package/src/export/share.ts +4 -0
- package/src/extensibility/custom-commands/loader.ts +3 -3
- package/src/extensibility/custom-commands/types.ts +4 -4
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/custom-tools/types.ts +5 -4
- package/src/extensibility/extensions/loader.ts +3 -3
- package/src/extensibility/extensions/types.ts +5 -4
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/hooks/types.ts +4 -4
- package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
- package/src/extensibility/legacy-typebox.ts +101 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +13 -22
- package/src/goals/tools/goal-tool.ts +1 -1
- package/src/launch/broker.ts +222 -6
- package/src/launch/client.ts +161 -9
- package/src/launch/protocol.ts +52 -0
- package/src/lsp/types.ts +1 -1
- package/src/main.ts +13 -6
- package/src/mcp/config-writer.ts +1 -1
- package/src/modes/components/btw-panel.ts +41 -4
- package/src/modes/controllers/btw-controller.ts +55 -7
- package/src/modes/controllers/command-controller.ts +31 -0
- package/src/modes/controllers/input-controller.ts +24 -7
- package/src/modes/interactive-mode.ts +16 -2
- package/src/modes/theme/theme.ts +3 -3
- package/src/modes/types.ts +8 -1
- package/src/prompts/session/launch-completion.md +1 -0
- package/src/sdk.ts +15 -0
- package/src/security/contracts/schemas.ts +202 -184
- package/src/security/contracts/validation.ts +6 -2
- package/src/security/publication.ts +1 -1
- package/src/security/store.ts +2 -2
- package/src/session/agent-session-types.ts +6 -0
- package/src/session/agent-session.ts +190 -14
- package/src/session/launch-completion.ts +37 -0
- package/src/session/session-context.ts +26 -0
- package/src/session/session-entries.ts +17 -1
- package/src/session/session-manager.ts +21 -0
- package/src/session/yield-queue.ts +121 -16
- package/src/slash-commands/builtin-registry.ts +10 -1
- package/src/task/types.ts +1 -1
- package/src/tools/ask.ts +1 -1
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +1 -1
- package/src/tools/bash.ts +61 -3
- package/src/tools/browser/cmux/cmux-tab.ts +92 -4
- package/src/tools/browser.ts +1 -1
- package/src/tools/checkpoint.ts +1 -1
- package/src/tools/computer.ts +1 -1
- package/src/tools/debug.ts +1 -1
- package/src/tools/eval.ts +1 -1
- package/src/tools/gh.ts +1 -2
- package/src/tools/glob.ts +1 -1
- package/src/tools/grep.ts +1 -1
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/launch.ts +122 -6
- package/src/tools/image-gen.ts +1 -1
- package/src/tools/index.ts +9 -0
- package/src/tools/inspect-image.ts +1 -1
- package/src/tools/learn.ts +1 -1
- package/src/tools/manage-skill.ts +1 -1
- package/src/tools/memory-edit.ts +1 -1
- package/src/tools/memory-recall.ts +1 -1
- package/src/tools/memory-reflect.ts +1 -1
- package/src/tools/memory-retain.ts +1 -1
- package/src/tools/read.ts +1 -1
- package/src/tools/security-scan.ts +1 -1
- package/src/tools/todo.ts +1 -1
- package/src/tools/tts.ts +1 -1
- package/src/tools/vibe.ts +2 -1
- package/src/tools/write.ts +1 -1
- package/src/web/search/index.ts +2 -1
- package/dist/types/config/file-lock.d.ts +0 -29
- package/dist/types/extensibility/typebox.d.ts +0 -181
- package/src/config/file-lock.ts +0 -164
- package/src/extensibility/typebox.ts +0 -958
package/src/cli/gc-cli.ts
CHANGED
|
@@ -2,7 +2,16 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
5
|
-
import {
|
|
5
|
+
import { withStatsSyncLock } from "@oh-my-pi/omp-stats/aggregator";
|
|
6
|
+
import {
|
|
7
|
+
getAgentDir,
|
|
8
|
+
getBlobsDir,
|
|
9
|
+
getHistoryDbPath,
|
|
10
|
+
getModelDbPath,
|
|
11
|
+
getSessionsDir,
|
|
12
|
+
getStatsDbPath,
|
|
13
|
+
readLines,
|
|
14
|
+
} from "@oh-my-pi/pi-utils";
|
|
6
15
|
import { Settings } from "../config/settings";
|
|
7
16
|
import { getDefault } from "../config/settings-schema";
|
|
8
17
|
import { BLOB_HASH_RE } from "../session/blob-store";
|
|
@@ -54,6 +63,7 @@ export interface ArchiveGcResult {
|
|
|
54
63
|
wouldArchive: number;
|
|
55
64
|
archived: number;
|
|
56
65
|
historyRowsDeleted: number;
|
|
66
|
+
statsRowsDeleted: number;
|
|
57
67
|
ftsRebuilt: boolean;
|
|
58
68
|
errors: string[];
|
|
59
69
|
}
|
|
@@ -420,33 +430,39 @@ function sessionArtifactsPath(sessionPath: string): string {
|
|
|
420
430
|
return sessionPath.slice(0, -SESSION_SUFFIX.length);
|
|
421
431
|
}
|
|
422
432
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return id || undefined;
|
|
428
|
-
}
|
|
429
|
-
if (basename.endsWith(SESSION_SUFFIX)) {
|
|
430
|
-
const id = basename.slice(0, -SESSION_SUFFIX.length);
|
|
431
|
-
return id || undefined;
|
|
432
|
-
}
|
|
433
|
-
return undefined;
|
|
433
|
+
interface SessionLineageHeader {
|
|
434
|
+
id: string;
|
|
435
|
+
parentSession?: string;
|
|
436
|
+
previousSessionFiles: string[];
|
|
434
437
|
}
|
|
435
438
|
|
|
436
|
-
function
|
|
439
|
+
function sessionLineageHeaderFromText(text: string): SessionLineageHeader | undefined {
|
|
437
440
|
let sawTitleSlot = false;
|
|
438
441
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
439
442
|
const line = rawLine.trim();
|
|
440
443
|
if (!line) continue;
|
|
441
444
|
try {
|
|
442
|
-
const record = JSON.parse(line) as {
|
|
445
|
+
const record = JSON.parse(line) as {
|
|
446
|
+
type?: unknown;
|
|
447
|
+
id?: unknown;
|
|
448
|
+
parentSession?: unknown;
|
|
449
|
+
previousSessionFiles?: unknown;
|
|
450
|
+
};
|
|
443
451
|
if (!sawTitleSlot && record.type === "title") {
|
|
444
452
|
sawTitleSlot = true;
|
|
445
453
|
continue;
|
|
446
454
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
:
|
|
455
|
+
if (record.type !== "session" || typeof record.id !== "string" || record.id.length === 0) return undefined;
|
|
456
|
+
return {
|
|
457
|
+
id: record.id,
|
|
458
|
+
parentSession: typeof record.parentSession === "string" ? record.parentSession : undefined,
|
|
459
|
+
previousSessionFiles: Array.isArray(record.previousSessionFiles)
|
|
460
|
+
? record.previousSessionFiles.filter(
|
|
461
|
+
(previousSessionFile): previousSessionFile is string =>
|
|
462
|
+
typeof previousSessionFile === "string" && previousSessionFile.length > 0,
|
|
463
|
+
)
|
|
464
|
+
: [],
|
|
465
|
+
};
|
|
450
466
|
} catch {
|
|
451
467
|
return undefined;
|
|
452
468
|
}
|
|
@@ -454,8 +470,17 @@ function sessionIdFromSessionText(text: string): string | undefined {
|
|
|
454
470
|
return undefined;
|
|
455
471
|
}
|
|
456
472
|
|
|
457
|
-
async function
|
|
458
|
-
|
|
473
|
+
async function readSessionLineageHeader(file: string): Promise<SessionLineageHeader | undefined> {
|
|
474
|
+
const decoder = new TextDecoder();
|
|
475
|
+
const lines: string[] = [];
|
|
476
|
+
for await (const line of readLines(Bun.file(file).stream())) {
|
|
477
|
+
const decoded = decoder.decode(line).trim();
|
|
478
|
+
if (!decoded) continue;
|
|
479
|
+
lines.push(decoded);
|
|
480
|
+
const header = sessionLineageHeaderFromText(lines.join("\n"));
|
|
481
|
+
if (header || lines.length >= 2) return header;
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
459
484
|
}
|
|
460
485
|
|
|
461
486
|
async function gzipSessionFile(source: string, destination: string): Promise<void> {
|
|
@@ -563,7 +588,7 @@ function deleteHistoryRowsForSessions(dbPath: string, sessionIds: string[]): { d
|
|
|
563
588
|
async function collectArchivedSessionIds(archiveRoot: string): Promise<string[]> {
|
|
564
589
|
const ids = new Set<string>();
|
|
565
590
|
for (const file of await collectCompressedJsonlFiles(archiveRoot)) {
|
|
566
|
-
const id = await
|
|
591
|
+
const id = sessionLineageHeaderFromText(await readTextIfPresent(file))?.id;
|
|
567
592
|
if (id) ids.add(id);
|
|
568
593
|
}
|
|
569
594
|
return [...ids].sort();
|
|
@@ -594,6 +619,597 @@ async function cleanupHistoryRowsForArchivedSessions(
|
|
|
594
619
|
}
|
|
595
620
|
}
|
|
596
621
|
|
|
622
|
+
const STATS_SESSION_TABLES = ["messages", "user_messages", "tool_calls", "file_offsets"] as const;
|
|
623
|
+
const STATS_ENTRY_TABLES = ["messages", "user_messages", "tool_calls"] as const;
|
|
624
|
+
type StatsEntryTable = (typeof STATS_ENTRY_TABLES)[number];
|
|
625
|
+
|
|
626
|
+
const STATS_IDENTITY_COLUMNS: Record<StatsEntryTable, readonly string[]> = {
|
|
627
|
+
messages: ["entry_id", "timestamp"],
|
|
628
|
+
user_messages: ["entry_id", "timestamp"],
|
|
629
|
+
tool_calls: ["entry_id", "timestamp", "tool_call_id"],
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
interface StatsSession {
|
|
633
|
+
path: string;
|
|
634
|
+
id: string;
|
|
635
|
+
parentSession?: string;
|
|
636
|
+
historicalPaths: string[];
|
|
637
|
+
identities: Record<StatsEntryTable, StatsEntryIdentity[]>;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
interface StatsLineageNode extends StatsSession {
|
|
641
|
+
statsPaths: Set<string>;
|
|
642
|
+
identityKeys: Set<string>;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
interface StatsEntryIdentity {
|
|
646
|
+
entryId: string;
|
|
647
|
+
timestamp: number;
|
|
648
|
+
toolCallId: string;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
interface StatsTransferTarget {
|
|
652
|
+
path: string;
|
|
653
|
+
identities: Record<StatsEntryTable, StatsEntryIdentity[]>;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
interface StatsCleanupPlan {
|
|
657
|
+
sessionPaths: string[];
|
|
658
|
+
retainedSessions: StatsLineageNode[];
|
|
659
|
+
transfers: StatsTransferTarget[];
|
|
660
|
+
archivedIdentityKeys: Set<string>;
|
|
661
|
+
preserveAll: boolean;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
interface StatsCleanupContext {
|
|
665
|
+
plans: StatsCleanupPlan[];
|
|
666
|
+
incompleteRetainedSessions: StatsLineageNode[];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function createStatsIdentities(): Record<StatsEntryTable, StatsEntryIdentity[]> {
|
|
670
|
+
return {
|
|
671
|
+
messages: [],
|
|
672
|
+
user_messages: [],
|
|
673
|
+
tool_calls: [],
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function statsIdentityKey(table: StatsEntryTable, identity: StatsEntryIdentity): string {
|
|
678
|
+
return `${table}\0${identity.entryId}\0${identity.timestamp}\0${identity.toolCallId}`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function statsIdentityKeys(identities: Record<StatsEntryTable, StatsEntryIdentity[]>): Set<string> {
|
|
682
|
+
const keys = new Set<string>();
|
|
683
|
+
for (const table of STATS_ENTRY_TABLES) {
|
|
684
|
+
for (const identity of identities[table]) keys.add(statsIdentityKey(table, identity));
|
|
685
|
+
}
|
|
686
|
+
return keys;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function tableHasColumn(db: Database, table: string, column: string): boolean {
|
|
690
|
+
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: string | null }>;
|
|
691
|
+
return rows.some(row => row.name === column);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function collectStoredStatsSessionPaths(db: Database): string[] {
|
|
695
|
+
const sessionPaths = new Set<string>();
|
|
696
|
+
for (const table of STATS_SESSION_TABLES) {
|
|
697
|
+
if (!tableExists(db, table) || !tableHasColumn(db, table, "session_file")) continue;
|
|
698
|
+
const rows = db.prepare(`SELECT DISTINCT session_file FROM ${table}`).all() as Array<{
|
|
699
|
+
session_file?: string | null;
|
|
700
|
+
}>;
|
|
701
|
+
for (const row of rows) {
|
|
702
|
+
if (typeof row.session_file === "string") sessionPaths.add(row.session_file);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return [...sessionPaths];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* `/move` preserves the session filename and artifacts-directory basename.
|
|
710
|
+
* Recover the top-level path represented by any main or nested stats row so
|
|
711
|
+
* one archived logical session can reconcile every historical location.
|
|
712
|
+
*/
|
|
713
|
+
function logicalSessionRootForStatsPath(statsPath: string, sessionPath: string): string | undefined {
|
|
714
|
+
const sessionFilename = path.basename(sessionPath);
|
|
715
|
+
if (path.basename(statsPath) === sessionFilename) return statsPath;
|
|
716
|
+
|
|
717
|
+
const artifactsDirname = sessionFilename.slice(0, -SESSION_SUFFIX.length);
|
|
718
|
+
let directory = path.dirname(path.resolve(statsPath));
|
|
719
|
+
while (true) {
|
|
720
|
+
if (path.basename(directory) === artifactsDirname) return `${directory}${SESSION_SUFFIX}`;
|
|
721
|
+
const parent = path.dirname(directory);
|
|
722
|
+
if (parent === directory) return undefined;
|
|
723
|
+
directory = parent;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function sessionPathEncodesId(sessionPath: string, sessionId: string): boolean {
|
|
728
|
+
const stem = path.basename(sessionPath, SESSION_SUFFIX);
|
|
729
|
+
return stem === sessionId || stem.endsWith(`_${sessionId}`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function managedHistoricalSessionPaths(
|
|
733
|
+
header: SessionLineageHeader,
|
|
734
|
+
currentSessionPath: string,
|
|
735
|
+
sessionsRoot: string,
|
|
736
|
+
): string[] {
|
|
737
|
+
const root = path.resolve(sessionsRoot);
|
|
738
|
+
const filename = path.basename(currentSessionPath);
|
|
739
|
+
const paths = new Set<string>();
|
|
740
|
+
for (const previousSessionFile of header.previousSessionFiles) {
|
|
741
|
+
if (!path.isAbsolute(previousSessionFile) || path.basename(previousSessionFile) !== filename) continue;
|
|
742
|
+
const resolved = path.resolve(previousSessionFile);
|
|
743
|
+
const relative = path.relative(root, resolved);
|
|
744
|
+
if (
|
|
745
|
+
!relative ||
|
|
746
|
+
relative === ".." ||
|
|
747
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
748
|
+
path.isAbsolute(relative) ||
|
|
749
|
+
!resolved.endsWith(SESSION_SUFFIX)
|
|
750
|
+
) {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
paths.add(resolved);
|
|
754
|
+
}
|
|
755
|
+
return [...paths];
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function resolveLogicalSessionMatch(
|
|
759
|
+
statsPath: string,
|
|
760
|
+
nodes: StatsLineageNode[],
|
|
761
|
+
): { node: StatsLineageNode; logicalRoot: string } | undefined {
|
|
762
|
+
const matches: Array<{ node: StatsLineageNode; logicalRoot: string }> = [];
|
|
763
|
+
for (const node of nodes) {
|
|
764
|
+
const logicalRoot = logicalSessionRootForStatsPath(statsPath, node.path);
|
|
765
|
+
if (logicalRoot) matches.push({ node, logicalRoot });
|
|
766
|
+
}
|
|
767
|
+
const exact = matches.filter(match => path.resolve(match.logicalRoot) === path.resolve(match.node.path));
|
|
768
|
+
if (exact.length === 1) return exact[0];
|
|
769
|
+
if (exact.length > 1) return undefined;
|
|
770
|
+
|
|
771
|
+
const known = matches.filter(match =>
|
|
772
|
+
[...match.node.statsPaths].some(statsPath => path.resolve(statsPath) === path.resolve(match.logicalRoot)),
|
|
773
|
+
);
|
|
774
|
+
if (known.length === 1) return known[0];
|
|
775
|
+
if (known.length > 1) return undefined;
|
|
776
|
+
|
|
777
|
+
const idMatches = matches.filter(match => sessionPathEncodesId(match.logicalRoot, match.node.id));
|
|
778
|
+
return idMatches.length === 1 ? idMatches[0] : undefined;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Resolve retained peers through `parentSession`, which is historically either
|
|
783
|
+
* a session id or an absolute path. Ambiguous aliases are deliberately left
|
|
784
|
+
* unresolved so cleanup fails safe instead of transferring across sessions.
|
|
785
|
+
*/
|
|
786
|
+
function buildStatsCleanupPlans(
|
|
787
|
+
archivedSessions: StatsSession[],
|
|
788
|
+
retainedSessions: StatsSession[],
|
|
789
|
+
dbPath: string,
|
|
790
|
+
): StatsCleanupContext {
|
|
791
|
+
const archivedNodes: StatsLineageNode[] = archivedSessions.map(session => ({
|
|
792
|
+
...session,
|
|
793
|
+
statsPaths: new Set([session.path, ...session.historicalPaths]),
|
|
794
|
+
identityKeys: statsIdentityKeys(session.identities),
|
|
795
|
+
}));
|
|
796
|
+
const retainedNodes: StatsLineageNode[] = retainedSessions.map(session => ({
|
|
797
|
+
...session,
|
|
798
|
+
statsPaths: new Set([session.path, ...session.historicalPaths]),
|
|
799
|
+
identityKeys: statsIdentityKeys(session.identities),
|
|
800
|
+
}));
|
|
801
|
+
const nodes = [...archivedNodes, ...retainedNodes];
|
|
802
|
+
|
|
803
|
+
const db = new Database(dbPath);
|
|
804
|
+
try {
|
|
805
|
+
db.run("PRAGMA busy_timeout = 5000");
|
|
806
|
+
for (const storedPath of collectStoredStatsSessionPaths(db)) {
|
|
807
|
+
const match = resolveLogicalSessionMatch(storedPath, nodes);
|
|
808
|
+
if (match) match.node.statsPaths.add(match.logicalRoot);
|
|
809
|
+
}
|
|
810
|
+
} finally {
|
|
811
|
+
db.close();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
for (const child of nodes) {
|
|
815
|
+
if (
|
|
816
|
+
!child.parentSession ||
|
|
817
|
+
(!path.isAbsolute(child.parentSession) && !child.parentSession.endsWith(SESSION_SUFFIX))
|
|
818
|
+
) {
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
const match = resolveLogicalSessionMatch(child.parentSession, nodes);
|
|
822
|
+
if (match) match.node.statsPaths.add(match.logicalRoot);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const archivedPathClaimCounts = new Map<string, number>();
|
|
826
|
+
for (const archived of archivedNodes) {
|
|
827
|
+
for (const statsPath of archived.statsPaths) {
|
|
828
|
+
const key = path.resolve(statsPath);
|
|
829
|
+
archivedPathClaimCounts.set(key, (archivedPathClaimCounts.get(key) ?? 0) + 1);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const ambiguousArchivedPathKeys = new Set(
|
|
833
|
+
[...archivedPathClaimCounts].filter(([, count]) => count > 1).map(([key]) => key),
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
const aliases = new Map<string, StatsLineageNode | null>();
|
|
837
|
+
const identityKeysByNode = new Map<StatsLineageNode, Set<string>>();
|
|
838
|
+
for (const node of nodes) {
|
|
839
|
+
const keys = new Set([
|
|
840
|
+
`id:${node.id}`,
|
|
841
|
+
...[...node.statsPaths].map(statsPath => `path:${path.resolve(statsPath)}`),
|
|
842
|
+
]);
|
|
843
|
+
identityKeysByNode.set(node, keys);
|
|
844
|
+
for (const key of keys) {
|
|
845
|
+
if (!aliases.has(key)) {
|
|
846
|
+
aliases.set(key, node);
|
|
847
|
+
} else if (aliases.get(key) !== node) {
|
|
848
|
+
aliases.set(key, null);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const incompleteLineage = new Set<StatsLineageNode>();
|
|
854
|
+
const lineageKeysByNode = new Map<StatsLineageNode, Set<string>>();
|
|
855
|
+
for (const node of nodes) {
|
|
856
|
+
const lineageKeys = new Set(identityKeysByNode.get(node));
|
|
857
|
+
let current: StatsLineageNode | null = node;
|
|
858
|
+
const seen = new Set<StatsLineageNode>();
|
|
859
|
+
while (current?.parentSession) {
|
|
860
|
+
if (seen.has(current)) {
|
|
861
|
+
incompleteLineage.add(node);
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
seen.add(current);
|
|
865
|
+
const parentReference = current.parentSession;
|
|
866
|
+
const parentKey =
|
|
867
|
+
path.isAbsolute(parentReference) || parentReference.endsWith(SESSION_SUFFIX)
|
|
868
|
+
? `path:${path.resolve(parentReference)}`
|
|
869
|
+
: `id:${parentReference}`;
|
|
870
|
+
lineageKeys.add(parentKey);
|
|
871
|
+
const parent = aliases.get(parentKey);
|
|
872
|
+
if (!parent) {
|
|
873
|
+
incompleteLineage.add(node);
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
for (const key of identityKeysByNode.get(parent) ?? []) lineageKeys.add(key);
|
|
877
|
+
current = parent;
|
|
878
|
+
}
|
|
879
|
+
lineageKeysByNode.set(node, lineageKeys);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const retainedPathKeys = new Set(
|
|
883
|
+
retainedNodes.flatMap(retained => [...retained.statsPaths].map(statsPath => path.resolve(statsPath))),
|
|
884
|
+
);
|
|
885
|
+
const plans = archivedNodes.map(archived => {
|
|
886
|
+
const archivedLineage = lineageKeysByNode.get(archived) ?? new Set<string>();
|
|
887
|
+
return {
|
|
888
|
+
sessionPaths: [...archived.statsPaths].filter(statsPath => {
|
|
889
|
+
const key = path.resolve(statsPath);
|
|
890
|
+
return !retainedPathKeys.has(key) && !ambiguousArchivedPathKeys.has(key);
|
|
891
|
+
}),
|
|
892
|
+
retainedSessions: retainedNodes.filter(retained => {
|
|
893
|
+
if (incompleteLineage.has(retained)) return false;
|
|
894
|
+
const retainedLineage = lineageKeysByNode.get(retained);
|
|
895
|
+
return retainedLineage ? [...retainedLineage].some(key => archivedLineage.has(key)) : false;
|
|
896
|
+
}),
|
|
897
|
+
transfers: [],
|
|
898
|
+
archivedIdentityKeys: archived.identityKeys,
|
|
899
|
+
preserveAll: false,
|
|
900
|
+
};
|
|
901
|
+
});
|
|
902
|
+
return {
|
|
903
|
+
plans,
|
|
904
|
+
incompleteRetainedSessions: retainedNodes.filter(retained => incompleteLineage.has(retained)),
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function addSessionStatsIdentity(line: string, identities: Record<StatsEntryTable, StatsEntryIdentity[]>): void {
|
|
909
|
+
if (line.length === 0) return;
|
|
910
|
+
try {
|
|
911
|
+
const record: unknown = JSON.parse(line);
|
|
912
|
+
if (
|
|
913
|
+
!record ||
|
|
914
|
+
typeof record !== "object" ||
|
|
915
|
+
!("type" in record) ||
|
|
916
|
+
record.type !== "message" ||
|
|
917
|
+
!("id" in record) ||
|
|
918
|
+
typeof record.id !== "string" ||
|
|
919
|
+
record.id.length === 0 ||
|
|
920
|
+
!("message" in record) ||
|
|
921
|
+
!record.message ||
|
|
922
|
+
typeof record.message !== "object"
|
|
923
|
+
) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const message = record.message;
|
|
927
|
+
if (!("role" in message)) return;
|
|
928
|
+
const parsedEntryTimestamp =
|
|
929
|
+
"timestamp" in record && typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN;
|
|
930
|
+
if (message.role === "user") {
|
|
931
|
+
identities.user_messages.push({
|
|
932
|
+
entryId: record.id,
|
|
933
|
+
timestamp: Number.isFinite(parsedEntryTimestamp) ? parsedEntryTimestamp : 0,
|
|
934
|
+
toolCallId: "",
|
|
935
|
+
});
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (message.role !== "assistant") return;
|
|
939
|
+
const timestamp =
|
|
940
|
+
"timestamp" in message && typeof message.timestamp === "number" && Number.isFinite(message.timestamp)
|
|
941
|
+
? message.timestamp
|
|
942
|
+
: Number.isFinite(parsedEntryTimestamp)
|
|
943
|
+
? parsedEntryTimestamp
|
|
944
|
+
: 0;
|
|
945
|
+
identities.messages.push({ entryId: record.id, timestamp, toolCallId: "" });
|
|
946
|
+
if (!("content" in message) || !Array.isArray(message.content)) return;
|
|
947
|
+
for (const block of message.content) {
|
|
948
|
+
if (
|
|
949
|
+
block &&
|
|
950
|
+
typeof block === "object" &&
|
|
951
|
+
"type" in block &&
|
|
952
|
+
block.type === "toolCall" &&
|
|
953
|
+
"id" in block &&
|
|
954
|
+
typeof block.id === "string"
|
|
955
|
+
) {
|
|
956
|
+
identities.tool_calls.push({ entryId: record.id, timestamp, toolCallId: block.id });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
} catch {
|
|
960
|
+
// Stats parsing is also lenient: a malformed line cannot own a retained row.
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function collectSessionStatsIdentitiesFromText(text: string): Record<StatsEntryTable, StatsEntryIdentity[]> {
|
|
965
|
+
const identities = createStatsIdentities();
|
|
966
|
+
for (const line of text.split(/\r?\n/)) addSessionStatsIdentity(line, identities);
|
|
967
|
+
return identities;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function collectSessionStatsIdentities(
|
|
971
|
+
sessionPath: string,
|
|
972
|
+
): Promise<Record<StatsEntryTable, StatsEntryIdentity[]>> {
|
|
973
|
+
const identities = createStatsIdentities();
|
|
974
|
+
const decoder = new TextDecoder();
|
|
975
|
+
for await (const line of readLines(Bun.file(sessionPath).stream())) {
|
|
976
|
+
addSessionStatsIdentity(decoder.decode(line), identities);
|
|
977
|
+
}
|
|
978
|
+
return identities;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function populateStatsTransferTargets(context: StatsCleanupContext): Promise<void> {
|
|
982
|
+
const identitiesBySession = new Map<string, Promise<Record<StatsEntryTable, StatsEntryIdentity[]>>>();
|
|
983
|
+
const identitiesFor = (session: StatsLineageNode): Promise<Record<StatsEntryTable, StatsEntryIdentity[]>> => {
|
|
984
|
+
let identities = identitiesBySession.get(session.path);
|
|
985
|
+
if (!identities) {
|
|
986
|
+
identities = collectSessionStatsIdentities(session.path);
|
|
987
|
+
identitiesBySession.set(session.path, identities);
|
|
988
|
+
}
|
|
989
|
+
return identities;
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
const incompleteIdentityKeys = new Set<string>();
|
|
993
|
+
let hasUnidentifiableIncompleteSession = false;
|
|
994
|
+
for (const retained of context.incompleteRetainedSessions) {
|
|
995
|
+
const keys = statsIdentityKeys(await identitiesFor(retained));
|
|
996
|
+
if (keys.size === 0) hasUnidentifiableIncompleteSession = true;
|
|
997
|
+
for (const key of keys) incompleteIdentityKeys.add(key);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
for (const plan of context.plans) {
|
|
1001
|
+
if (context.incompleteRetainedSessions.length > 0) {
|
|
1002
|
+
plan.preserveAll =
|
|
1003
|
+
hasUnidentifiableIncompleteSession ||
|
|
1004
|
+
plan.archivedIdentityKeys.size === 0 ||
|
|
1005
|
+
[...plan.archivedIdentityKeys].some(key => incompleteIdentityKeys.has(key));
|
|
1006
|
+
}
|
|
1007
|
+
for (const retained of plan.retainedSessions) {
|
|
1008
|
+
plan.transfers.push({ path: retained.path, identities: await identitiesFor(retained) });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Rekey copied entry rows to the newest retained lineage peer before pruning.
|
|
1015
|
+
* The retained file's own offset is never rewritten: it already describes that
|
|
1016
|
+
* file's byte position, while every archived historical offset is deleted.
|
|
1017
|
+
*/
|
|
1018
|
+
function reconcileStatsRowsForSessions(dbPath: string, plans: StatsCleanupPlan[]): number {
|
|
1019
|
+
if (plans.length === 0) return 0;
|
|
1020
|
+
const db = new Database(dbPath);
|
|
1021
|
+
try {
|
|
1022
|
+
db.run("PRAGMA busy_timeout = 5000");
|
|
1023
|
+
const sessionTables = STATS_SESSION_TABLES.filter(
|
|
1024
|
+
table => tableExists(db, table) && tableHasColumn(db, table, "session_file"),
|
|
1025
|
+
);
|
|
1026
|
+
const entryTables = STATS_ENTRY_TABLES.filter(
|
|
1027
|
+
table =>
|
|
1028
|
+
sessionTables.includes(table) &&
|
|
1029
|
+
STATS_IDENTITY_COLUMNS[table].every(column => tableHasColumn(db, table, column)),
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
db.run(`
|
|
1033
|
+
CREATE TEMP TABLE gc_retained_entries (
|
|
1034
|
+
table_name TEXT NOT NULL,
|
|
1035
|
+
entry_id TEXT NOT NULL,
|
|
1036
|
+
timestamp INTEGER NOT NULL,
|
|
1037
|
+
tool_call_id TEXT NOT NULL,
|
|
1038
|
+
target_session_file TEXT NOT NULL,
|
|
1039
|
+
PRIMARY KEY (table_name, entry_id, timestamp, tool_call_id)
|
|
1040
|
+
)
|
|
1041
|
+
`);
|
|
1042
|
+
const clearRetainedEntries = db.prepare("DELETE FROM gc_retained_entries");
|
|
1043
|
+
const insertRetainedEntry = db.prepare(`
|
|
1044
|
+
INSERT OR IGNORE INTO gc_retained_entries (
|
|
1045
|
+
table_name, entry_id, timestamp, tool_call_id, target_session_file
|
|
1046
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1047
|
+
`);
|
|
1048
|
+
const transferStatements = entryTables.map(table => {
|
|
1049
|
+
const toolCallMatch =
|
|
1050
|
+
table === "tool_calls" ? `retained.tool_call_id = ${table}.tool_call_id` : "retained.tool_call_id = ''";
|
|
1051
|
+
const identityMatch = `
|
|
1052
|
+
retained.table_name = '${table}'
|
|
1053
|
+
AND retained.entry_id = ${table}.entry_id
|
|
1054
|
+
AND retained.timestamp = ${table}.timestamp
|
|
1055
|
+
AND ${toolCallMatch}
|
|
1056
|
+
`;
|
|
1057
|
+
return db.prepare(`
|
|
1058
|
+
UPDATE OR IGNORE ${table}
|
|
1059
|
+
SET session_file = (
|
|
1060
|
+
SELECT retained.target_session_file
|
|
1061
|
+
FROM gc_retained_entries AS retained
|
|
1062
|
+
WHERE ${identityMatch}
|
|
1063
|
+
)
|
|
1064
|
+
WHERE session_file = ?
|
|
1065
|
+
AND EXISTS (
|
|
1066
|
+
SELECT 1
|
|
1067
|
+
FROM gc_retained_entries AS retained
|
|
1068
|
+
WHERE ${identityMatch}
|
|
1069
|
+
)
|
|
1070
|
+
`);
|
|
1071
|
+
});
|
|
1072
|
+
const deletionStatements = sessionTables.map(table => ({
|
|
1073
|
+
table,
|
|
1074
|
+
statement: db.prepare(`DELETE FROM ${table} WHERE session_file = ? OR instr(session_file, ?) = 1`),
|
|
1075
|
+
}));
|
|
1076
|
+
let deleted = 0;
|
|
1077
|
+
const tx = db.transaction((cleanupPlans: StatsCleanupPlan[]) => {
|
|
1078
|
+
for (const plan of cleanupPlans) {
|
|
1079
|
+
if (plan.preserveAll) continue;
|
|
1080
|
+
clearRetainedEntries.run();
|
|
1081
|
+
for (const target of plan.transfers) {
|
|
1082
|
+
for (const table of entryTables) {
|
|
1083
|
+
for (const identity of target.identities[table]) {
|
|
1084
|
+
insertRetainedEntry.run(
|
|
1085
|
+
table,
|
|
1086
|
+
identity.entryId,
|
|
1087
|
+
identity.timestamp,
|
|
1088
|
+
identity.toolCallId,
|
|
1089
|
+
target.path,
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
for (const sessionPath of new Set(plan.sessionPaths)) {
|
|
1095
|
+
for (const statement of transferStatements) statement.run(sessionPath);
|
|
1096
|
+
}
|
|
1097
|
+
for (const sessionPath of new Set(plan.sessionPaths)) {
|
|
1098
|
+
const nestedPrefix = `${sessionArtifactsPath(sessionPath)}${path.sep}`;
|
|
1099
|
+
for (const { table, statement } of deletionStatements) {
|
|
1100
|
+
const requiresTransfer =
|
|
1101
|
+
plan.retainedSessions.length > 0 &&
|
|
1102
|
+
table !== "file_offsets" &&
|
|
1103
|
+
!entryTables.some(entryTable => entryTable === table);
|
|
1104
|
+
if (requiresTransfer) continue;
|
|
1105
|
+
const result = statement.run(sessionPath, nestedPrefix) as SqliteRunResult;
|
|
1106
|
+
deleted += sqliteNumber(result.changes);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
tx(plans);
|
|
1112
|
+
return deleted;
|
|
1113
|
+
} finally {
|
|
1114
|
+
db.close();
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async function collectArchivedStatsSessions(
|
|
1119
|
+
archiveRoot: string,
|
|
1120
|
+
sessionsRoot: string,
|
|
1121
|
+
onError: (file: string, error: unknown) => void,
|
|
1122
|
+
): Promise<StatsSession[]> {
|
|
1123
|
+
const sessions: StatsSession[] = [];
|
|
1124
|
+
for (const file of await collectCompressedJsonlFiles(archiveRoot)) {
|
|
1125
|
+
const relative = path.relative(archiveRoot, file);
|
|
1126
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) continue;
|
|
1127
|
+
const sourcePath = path.join(sessionsRoot, relative.slice(0, -".gz".length));
|
|
1128
|
+
try {
|
|
1129
|
+
const text = await readTextIfPresent(file);
|
|
1130
|
+
const header = sessionLineageHeaderFromText(text);
|
|
1131
|
+
if (!header) throw new Error("archive is missing a valid session header");
|
|
1132
|
+
sessions.push({
|
|
1133
|
+
path: sourcePath,
|
|
1134
|
+
id: header.id,
|
|
1135
|
+
parentSession: header.parentSession,
|
|
1136
|
+
historicalPaths: managedHistoricalSessionPaths(header, sourcePath, sessionsRoot),
|
|
1137
|
+
identities: collectSessionStatsIdentitiesFromText(text),
|
|
1138
|
+
});
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
onError(file, error);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return sessions;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function cleanupStatsRowsForArchivedSessions(
|
|
1147
|
+
options: ResolvedGcOptions,
|
|
1148
|
+
archiveRoot: string,
|
|
1149
|
+
newlyArchivedSessions: SessionInfo[],
|
|
1150
|
+
result: ArchiveGcResult,
|
|
1151
|
+
): Promise<void> {
|
|
1152
|
+
const dbPath =
|
|
1153
|
+
path.resolve(options.agentDir) === path.resolve(getAgentDir())
|
|
1154
|
+
? getStatsDbPath()
|
|
1155
|
+
: path.join(options.agentDir, "stats.db");
|
|
1156
|
+
if (!(await pathExists(dbPath))) return;
|
|
1157
|
+
const sessionsRoot = getSessionsDir(options.agentDir);
|
|
1158
|
+
|
|
1159
|
+
const archivedByPath = new Map<string, StatsSession>();
|
|
1160
|
+
for (const session of newlyArchivedSessions) {
|
|
1161
|
+
archivedByPath.set(path.resolve(session.path), {
|
|
1162
|
+
path: session.path,
|
|
1163
|
+
id: session.id,
|
|
1164
|
+
parentSession: session.parentSessionPath,
|
|
1165
|
+
historicalPaths: [],
|
|
1166
|
+
identities: createStatsIdentities(),
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
let retainedSessions: SessionInfo[];
|
|
1171
|
+
try {
|
|
1172
|
+
for (const session of await collectArchivedStatsSessions(archiveRoot, sessionsRoot, (file, error) => {
|
|
1173
|
+
result.errors.push(`stats cleanup scan ${file}: ${errorMessage(error)}`);
|
|
1174
|
+
})) {
|
|
1175
|
+
archivedByPath.set(path.resolve(session.path), session);
|
|
1176
|
+
}
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
result.errors.push(`stats cleanup scan: ${errorMessage(error)}`);
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
retainedSessions = await listActiveSessions(sessionsRoot);
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
result.errors.push(`stats cleanup scan: ${errorMessage(error)}`);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
try {
|
|
1188
|
+
await withStatsSyncLock(dbPath, async () => {
|
|
1189
|
+
const retainedStatsSessions = await Promise.all(
|
|
1190
|
+
retainedSessions.map(async session => {
|
|
1191
|
+
const header = await readSessionLineageHeader(session.path);
|
|
1192
|
+
if (!header || header.id !== session.id) {
|
|
1193
|
+
throw new Error(`session header changed during stats cleanup: ${session.path}`);
|
|
1194
|
+
}
|
|
1195
|
+
return {
|
|
1196
|
+
path: session.path,
|
|
1197
|
+
id: session.id,
|
|
1198
|
+
parentSession: header.parentSession,
|
|
1199
|
+
historicalPaths: managedHistoricalSessionPaths(header, session.path, sessionsRoot),
|
|
1200
|
+
identities: createStatsIdentities(),
|
|
1201
|
+
};
|
|
1202
|
+
}),
|
|
1203
|
+
);
|
|
1204
|
+
const context = buildStatsCleanupPlans([...archivedByPath.values()], retainedStatsSessions, dbPath);
|
|
1205
|
+
await populateStatsTransferTargets(context);
|
|
1206
|
+
result.statsRowsDeleted = reconcileStatsRowsForSessions(dbPath, context.plans);
|
|
1207
|
+
});
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
result.errors.push(`stats cleanup: ${errorMessage(error)}`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
597
1213
|
async function runArchiveGc(options: ResolvedGcOptions, archiveRoot: string): Promise<ArchiveGcResult> {
|
|
598
1214
|
const sessionsRoot = getSessionsDir(options.agentDir);
|
|
599
1215
|
const sessions = await listActiveSessions(sessionsRoot);
|
|
@@ -606,6 +1222,7 @@ async function runArchiveGc(options: ResolvedGcOptions, archiveRoot: string): Pr
|
|
|
606
1222
|
wouldArchive: 0,
|
|
607
1223
|
archived: 0,
|
|
608
1224
|
historyRowsDeleted: 0,
|
|
1225
|
+
statsRowsDeleted: 0,
|
|
609
1226
|
ftsRebuilt: false,
|
|
610
1227
|
errors: [],
|
|
611
1228
|
};
|
|
@@ -651,17 +1268,20 @@ async function runArchiveGc(options: ResolvedGcOptions, archiveRoot: string): Pr
|
|
|
651
1268
|
if (!options.apply) return result;
|
|
652
1269
|
|
|
653
1270
|
const archivedSessionIds: string[] = [];
|
|
1271
|
+
const archivedSessions: SessionInfo[] = [];
|
|
654
1272
|
for (const candidate of candidates) {
|
|
655
1273
|
try {
|
|
656
1274
|
await moveSessionWithArtifacts(candidate);
|
|
657
1275
|
result.archived += 1;
|
|
658
1276
|
archivedSessionIds.push(candidate.session.id);
|
|
1277
|
+
archivedSessions.push(candidate.session);
|
|
659
1278
|
} catch (error) {
|
|
660
1279
|
result.errors.push(`${candidate.session.path}: ${errorMessage(error)}`);
|
|
661
1280
|
}
|
|
662
1281
|
}
|
|
663
1282
|
|
|
664
1283
|
await cleanupHistoryRowsForArchivedSessions(options, archiveRoot, archivedSessionIds, result);
|
|
1284
|
+
await cleanupStatsRowsForArchivedSessions(options, archiveRoot, archivedSessions, result);
|
|
665
1285
|
return result;
|
|
666
1286
|
}
|
|
667
1287
|
|
|
@@ -917,7 +1537,7 @@ function renderText(result: GcResult): string {
|
|
|
917
1537
|
}
|
|
918
1538
|
if (result.archive) {
|
|
919
1539
|
lines.push(
|
|
920
|
-
`sessions: ${result.archive.archived}/${result.archive.wouldArchive} archived, ${result.archive.historyRowsDeleted} history rows removed`,
|
|
1540
|
+
`sessions: ${result.archive.archived}/${result.archive.wouldArchive} archived, ${result.archive.historyRowsDeleted} history rows and ${result.archive.statsRowsDeleted} stats rows removed`,
|
|
921
1541
|
);
|
|
922
1542
|
if (result.archive.skippedActive > 0) lines.push(`sessions skipped active: ${result.archive.skippedActive}`);
|
|
923
1543
|
if (result.archive.errors.length > 0) lines.push(`session errors: ${result.archive.errors.length}`);
|