session-steward 0.2.0 → 0.4.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.
- package/CHANGELOG.md +73 -0
- package/README.md +78 -22
- package/bin/session-steward-cli.mjs +37 -7
- package/bin/session-steward.mjs +3 -0
- package/dist/assets/index-BQ6SUqXr.css +2 -0
- package/dist/assets/index-QhQbSn0H.js +9 -0
- package/dist/index.html +14 -3
- package/lib/cli.mjs +371 -59
- package/lib/providers/claude-code/index.mjs +7 -0
- package/lib/providers/claude-code/store.mjs +996 -0
- package/lib/providers/codex/database-families.mjs +177 -0
- package/lib/providers/codex/index.mjs +4 -0
- package/lib/providers/codex/store.mjs +685 -333
- package/lib/providers/index.mjs +2 -1
- package/lib/server.mjs +152 -62
- package/lib/settings.mjs +39 -0
- package/lib/storage/files.mjs +39 -0
- package/package.json +10 -2
- package/dist/assets/index-kZ4XDVk-.js +0 -9
- package/dist/assets/index-pzaccjP4.css +0 -2
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import readline from "node:readline";
|
|
7
7
|
|
|
8
|
+
import { measurePath } from "../../storage/files.mjs";
|
|
8
9
|
import {
|
|
9
10
|
inspectJsonlMatches,
|
|
10
11
|
readJsonlEntries,
|
|
@@ -17,6 +18,13 @@ import {
|
|
|
17
18
|
placeholders,
|
|
18
19
|
queryRows,
|
|
19
20
|
} from "../../storage/sqlite.mjs";
|
|
21
|
+
import {
|
|
22
|
+
allValidDatabases,
|
|
23
|
+
CODEX_DATABASE_PROFILE,
|
|
24
|
+
databaseFamilySummary,
|
|
25
|
+
invalidateCodexDatabaseResolution,
|
|
26
|
+
resolveCodexDatabases,
|
|
27
|
+
} from "./database-families.mjs";
|
|
20
28
|
|
|
21
29
|
function expandHome(value) {
|
|
22
30
|
if (!value || value === "~") {
|
|
@@ -42,6 +50,27 @@ function normalizeDisplayName(value) {
|
|
|
42
50
|
return normalizeText(value);
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
function cleanDerivedTitle(value) {
|
|
54
|
+
const original = normalizeDisplayName(value);
|
|
55
|
+
|
|
56
|
+
if (!original) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let cleaned = original.replace(/^\[\d+\]\s+(?:user|assistant):\s*/u, "");
|
|
61
|
+
const nextRoleMarker = cleaned.search(/\s\[\d+\]\s+(?:user|assistant):\s*/u);
|
|
62
|
+
|
|
63
|
+
if (nextRoleMarker >= 0) {
|
|
64
|
+
cleaned = cleaned.slice(0, nextRoleMarker);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
cleaned = normalizeDisplayName(
|
|
68
|
+
cleaned.replace(/\[([^\[\]]+?)\]\([^()]*?\)/gu, "$1"),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return cleaned && /[\p{L}\p{N}]/u.test(cleaned) ? cleaned : original;
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
function toTimestampMs(value) {
|
|
46
75
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
47
76
|
return value;
|
|
@@ -368,7 +397,7 @@ function deriveDisplayName({
|
|
|
368
397
|
if (sessionIndexEntry?.threadName && (!sqliteTitle || sqliteTitleLooksPrompt)) {
|
|
369
398
|
return {
|
|
370
399
|
source: "session_index",
|
|
371
|
-
value: sessionIndexEntry.threadName,
|
|
400
|
+
value: cleanDerivedTitle(sessionIndexEntry.threadName),
|
|
372
401
|
};
|
|
373
402
|
}
|
|
374
403
|
|
|
@@ -378,21 +407,21 @@ function deriveDisplayName({
|
|
|
378
407
|
) {
|
|
379
408
|
return {
|
|
380
409
|
source: "transcript_thread_name",
|
|
381
|
-
value: transcriptFallback.latestThreadName,
|
|
410
|
+
value: cleanDerivedTitle(transcriptFallback.latestThreadName),
|
|
382
411
|
};
|
|
383
412
|
}
|
|
384
413
|
|
|
385
414
|
if (sqliteTitle) {
|
|
386
415
|
return {
|
|
387
416
|
source: "sqlite_title",
|
|
388
|
-
value: sqliteTitle,
|
|
417
|
+
value: cleanDerivedTitle(sqliteTitle),
|
|
389
418
|
};
|
|
390
419
|
}
|
|
391
420
|
|
|
392
421
|
if (sqliteFirstUserMessage) {
|
|
393
422
|
return {
|
|
394
423
|
source: "sqlite_first_user_message",
|
|
395
|
-
value: sqliteFirstUserMessage,
|
|
424
|
+
value: cleanDerivedTitle(sqliteFirstUserMessage),
|
|
396
425
|
};
|
|
397
426
|
}
|
|
398
427
|
|
|
@@ -401,14 +430,14 @@ function deriveDisplayName({
|
|
|
401
430
|
if (historyEntry?.text) {
|
|
402
431
|
return {
|
|
403
432
|
source: "history_first_user_message",
|
|
404
|
-
value: historyEntry.text,
|
|
433
|
+
value: cleanDerivedTitle(historyEntry.text),
|
|
405
434
|
};
|
|
406
435
|
}
|
|
407
436
|
|
|
408
437
|
if (transcriptFallback?.firstUserMessage) {
|
|
409
438
|
return {
|
|
410
439
|
source: "transcript_first_user_message",
|
|
411
|
-
value: transcriptFallback.firstUserMessage,
|
|
440
|
+
value: cleanDerivedTitle(transcriptFallback.firstUserMessage),
|
|
412
441
|
};
|
|
413
442
|
}
|
|
414
443
|
|
|
@@ -418,23 +447,36 @@ function deriveDisplayName({
|
|
|
418
447
|
};
|
|
419
448
|
}
|
|
420
449
|
|
|
421
|
-
function getCodexPaths(codexHomeInput) {
|
|
450
|
+
export function getCodexPaths(codexHomeInput, { refresh = false } = {}) {
|
|
422
451
|
const codexHome = path.resolve(expandHome(codexHomeInput || "~/.codex"));
|
|
423
452
|
const archivedSessionsDirectory = path.join(codexHome, "archived_sessions");
|
|
424
453
|
const sessionsDirectory = path.join(codexHome, "sessions");
|
|
454
|
+
const resolution = resolveCodexDatabases(codexHome, { refresh });
|
|
455
|
+
const { goals, logs, memories, state } = resolution.families;
|
|
456
|
+
const stateDatabasePath = state.primary?.path ?? path.join(codexHome, "state_5.sqlite");
|
|
457
|
+
const logsDatabasePath = logs.primary?.path ?? path.join(codexHome, "logs_2.sqlite");
|
|
458
|
+
const memoryDatabasePath = memories.primary?.path ?? path.join(codexHome, "memories_1.sqlite");
|
|
459
|
+
const goalsDatabasePath = goals.primary?.path ?? path.join(codexHome, "goals_1.sqlite");
|
|
425
460
|
|
|
426
461
|
return {
|
|
427
462
|
archivedSessionsDirectory,
|
|
428
463
|
codexHome,
|
|
429
464
|
desktopStateBackupPath: path.join(codexHome, ".codex-global-state.json.bak"),
|
|
430
465
|
desktopStatePath: path.join(codexHome, ".codex-global-state.json"),
|
|
431
|
-
|
|
466
|
+
databaseFamilies: resolution.families,
|
|
467
|
+
goalsDatabasePath,
|
|
468
|
+
goalsDatabasePaths: allValidDatabases(goals).map((database) => database.path),
|
|
432
469
|
historyPath: path.join(codexHome, "history.jsonl"),
|
|
433
|
-
logsDatabasePath
|
|
434
|
-
|
|
470
|
+
logsDatabasePath,
|
|
471
|
+
logsDatabasePaths: allValidDatabases(logs).map((database) => database.path),
|
|
472
|
+
memoryDatabasePath,
|
|
473
|
+
memoryDatabasePaths: allValidDatabases(memories).map((database) => database.path),
|
|
474
|
+
resolvedDatabases: databaseFamilySummary(resolution),
|
|
435
475
|
sessionIndexPath: path.join(codexHome, "session_index.jsonl"),
|
|
436
476
|
sessionsDirectory,
|
|
437
|
-
stateDatabasePath
|
|
477
|
+
stateDatabasePath,
|
|
478
|
+
stateDatabases: allValidDatabases(state),
|
|
479
|
+
stateDatabasePaths: allValidDatabases(state).map((database) => database.path),
|
|
438
480
|
transcriptDirectories: [sessionsDirectory, archivedSessionsDirectory],
|
|
439
481
|
};
|
|
440
482
|
}
|
|
@@ -448,27 +490,6 @@ const DESKTOP_THREAD_MAP_KEYS = [
|
|
|
448
490
|
|
|
449
491
|
const DESKTOP_THREAD_ARRAY_KEYS = ["projectless-thread-ids"];
|
|
450
492
|
|
|
451
|
-
const COMPATIBILITY_PROFILE = {
|
|
452
|
-
id: "local-store-2026-07",
|
|
453
|
-
builtFor: {
|
|
454
|
-
chatgptDesktop: ["26.727.40816"],
|
|
455
|
-
codexCli: ["0.144.1"],
|
|
456
|
-
},
|
|
457
|
-
};
|
|
458
|
-
|
|
459
|
-
const SCHEMA_REQUIREMENTS = [
|
|
460
|
-
{
|
|
461
|
-
database: "state_5.sqlite",
|
|
462
|
-
required: true,
|
|
463
|
-
tables: [
|
|
464
|
-
{ name: "threads", columns: ["id", "rollout_path", "cwd", "title", "first_user_message", "agent_nickname", "agent_role", "archived", "is_pinned"] },
|
|
465
|
-
{ name: "thread_spawn_edges", columns: ["parent_thread_id", "child_thread_id", "status"] },
|
|
466
|
-
],
|
|
467
|
-
},
|
|
468
|
-
{ database: "logs_2.sqlite", required: false, tables: [{ name: "logs", columns: ["thread_id"] }] },
|
|
469
|
-
{ database: "memories_1.sqlite", required: false, tables: [{ name: "stage1_outputs", columns: ["thread_id"] }] },
|
|
470
|
-
{ database: "goals_1.sqlite", required: false, tables: [{ name: "thread_goals", columns: ["thread_id"] }, { name: "thread_goal_continuation_deferrals", columns: ["thread_id"] }] },
|
|
471
|
-
];
|
|
472
493
|
|
|
473
494
|
function getMatchingDesktopStateEntryCount(state, deletedIdSet) {
|
|
474
495
|
if (!state || typeof state !== "object" || Array.isArray(state)) {
|
|
@@ -565,35 +586,22 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
565
586
|
pathExists(paths.logsDatabasePath),
|
|
566
587
|
pathExists(paths.memoryDatabasePath),
|
|
567
588
|
]);
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
order by updated_at_ms desc, updated_at desc
|
|
585
|
-
`,
|
|
586
|
-
);
|
|
587
|
-
const spawnEdges = queryRows(
|
|
588
|
-
paths.stateDatabasePath,
|
|
589
|
-
`
|
|
590
|
-
select
|
|
591
|
-
parent_thread_id,
|
|
592
|
-
child_thread_id,
|
|
593
|
-
status
|
|
594
|
-
from thread_spawn_edges
|
|
595
|
-
`,
|
|
596
|
-
);
|
|
589
|
+
const threadRowsById = new Map();
|
|
590
|
+
const spawnEdgesByKey = new Map();
|
|
591
|
+
for (const database of paths.stateDatabases) {
|
|
592
|
+
for (const row of queryRows(database.path, `select ${sessionColumns(database)} from threads t`)) {
|
|
593
|
+
const id = String(row.id);
|
|
594
|
+
if (!threadRowsById.has(id)) threadRowsById.set(id, { ...row, _stateDatabasePath: database.path });
|
|
595
|
+
}
|
|
596
|
+
if (stateSchema(database).hasSpawnEdges) {
|
|
597
|
+
for (const edge of queryRows(database.path, "select parent_thread_id, child_thread_id, status from thread_spawn_edges")) {
|
|
598
|
+
const key = `${edge.parent_thread_id}\0${edge.child_thread_id}`;
|
|
599
|
+
if (!spawnEdgesByKey.has(key)) spawnEdgesByKey.set(key, edge);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const threadRows = [...threadRowsById.values()];
|
|
604
|
+
const spawnEdges = [...spawnEdgesByKey.values()];
|
|
597
605
|
const transcriptHeaders = await indexTranscriptHeaders(paths.transcriptDirectories);
|
|
598
606
|
|
|
599
607
|
const discoveryIds = new Set(threadRows.map((threadRow) => String(threadRow.id)));
|
|
@@ -651,7 +659,7 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
651
659
|
agentRole: threadRow.agent_role ?? transcriptHeader?.agentRole ?? null,
|
|
652
660
|
archived: Boolean(threadRow.archived),
|
|
653
661
|
childThreadIds: childIdsByParentId.get(threadRow.id) ?? [],
|
|
654
|
-
createdAtMs: toTimestampMs(threadRow.created_at_ms),
|
|
662
|
+
createdAtMs: toTimestampMs(threadRow.created_at_ms_resolved ?? threadRow.created_at_ms),
|
|
655
663
|
cwd: threadRow.cwd ?? transcriptHeader?.cwd ?? "",
|
|
656
664
|
displayName: "",
|
|
657
665
|
firstUserMessage: threadRow.first_user_message ?? "",
|
|
@@ -669,7 +677,8 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
669
677
|
rolloutPath,
|
|
670
678
|
title: threadRow.title ?? "",
|
|
671
679
|
titleSource: "",
|
|
672
|
-
updatedAtMs: toTimestampMs(threadRow.updated_at_ms),
|
|
680
|
+
updatedAtMs: toTimestampMs(threadRow.updated_at_ms_resolved ?? threadRow.updated_at_ms),
|
|
681
|
+
_stateDatabasePath: threadRow._stateDatabasePath,
|
|
673
682
|
};
|
|
674
683
|
|
|
675
684
|
if (
|
|
@@ -776,6 +785,12 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
776
785
|
sessionIndexPath: paths.sessionIndexPath,
|
|
777
786
|
spawnEdges,
|
|
778
787
|
stateDatabasePath: paths.stateDatabasePath,
|
|
788
|
+
stateDatabasePaths: paths.stateDatabasePaths,
|
|
789
|
+
stateDatabases: paths.stateDatabases,
|
|
790
|
+
resolvedDatabases: paths.resolvedDatabases,
|
|
791
|
+
logsDatabasePaths: paths.logsDatabasePaths,
|
|
792
|
+
memoryDatabasePaths: paths.memoryDatabasePaths,
|
|
793
|
+
goalsDatabasePaths: paths.goalsDatabasePaths,
|
|
779
794
|
transcriptHeaders,
|
|
780
795
|
historyPath: paths.historyPath,
|
|
781
796
|
};
|
|
@@ -802,51 +817,21 @@ function inspectSqliteTable(databasePath, tableName) {
|
|
|
802
817
|
}
|
|
803
818
|
|
|
804
819
|
export async function diagnoseStorageCompatibility({ codexHome }) {
|
|
805
|
-
const paths = getCodexPaths(codexHome);
|
|
806
|
-
const recognizedDatabases = new Set(SCHEMA_REQUIREMENTS.map((requirement) => requirement.database));
|
|
820
|
+
const paths = getCodexPaths(codexHome, { refresh: true });
|
|
807
821
|
const missing = [];
|
|
808
822
|
const changed = [];
|
|
809
823
|
const available = [];
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
if (requirement.required) {
|
|
816
|
-
missing.push(`Required session database is missing: ${requirement.database}`);
|
|
817
|
-
} else {
|
|
818
|
-
available.push(`Not present: ${requirement.database}`);
|
|
819
|
-
}
|
|
820
|
-
continue;
|
|
824
|
+
for (const [familyName, family] of Object.entries(paths.databaseFamilies)) {
|
|
825
|
+
if (!family.primary && family.required) {
|
|
826
|
+
missing.push(`A supported ${familyName} session database was not found.`);
|
|
827
|
+
} else if (!family.primary) {
|
|
828
|
+
available.push(`Not present: ${familyName} database`);
|
|
821
829
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
for (const table of requirement.tables) {
|
|
826
|
-
const inspection = inspectSqliteTable(databasePath, table.name);
|
|
827
|
-
|
|
828
|
-
if (inspection.error) {
|
|
829
|
-
changed.push(`Could not read ${requirement.database}.`);
|
|
830
|
-
databaseChanged = true;
|
|
831
|
-
continue;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
if (!inspection.exists) {
|
|
835
|
-
changed.push(`Expected table is missing in ${requirement.database}: ${table.name}.`);
|
|
836
|
-
databaseChanged = true;
|
|
837
|
-
continue;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
const missingColumns = table.columns.filter((column) => !inspection.columns.includes(column));
|
|
841
|
-
|
|
842
|
-
if (missingColumns.length > 0) {
|
|
843
|
-
changed.push(`Expected fields changed in ${requirement.database}: ${table.name}.`);
|
|
844
|
-
databaseChanged = true;
|
|
845
|
-
}
|
|
830
|
+
for (const database of allValidDatabases(family)) {
|
|
831
|
+
available.push(`Supported: ${database.filename}`);
|
|
846
832
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
available.push(`Supported: ${requirement.database}`);
|
|
833
|
+
for (const database of family.invalid) {
|
|
834
|
+
changed.push(`Could not use ${database.filename}: ${database.reason}`);
|
|
850
835
|
}
|
|
851
836
|
}
|
|
852
837
|
|
|
@@ -857,23 +842,28 @@ export async function diagnoseStorageCompatibility({ codexHome }) {
|
|
|
857
842
|
missing.push("The local Codex folder could not be read.");
|
|
858
843
|
}
|
|
859
844
|
|
|
845
|
+
const recognizedDatabases = new Set(Object.values(paths.databaseFamilies).flatMap((family) => [
|
|
846
|
+
...allValidDatabases(family),
|
|
847
|
+
...family.invalid,
|
|
848
|
+
]).map((database) => database.filename));
|
|
860
849
|
const newlyDiscovered = entries
|
|
861
850
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".sqlite") && !recognizedDatabases.has(entry.name))
|
|
862
851
|
.map((entry) => `Other local database found: ${entry.name}`)
|
|
863
852
|
.sort();
|
|
864
853
|
const status = missing.length > 0 || changed.length > 0
|
|
865
|
-
? "
|
|
854
|
+
? "unsupported"
|
|
866
855
|
: newlyDiscovered.length > 0
|
|
867
|
-
? "
|
|
856
|
+
? "partial"
|
|
868
857
|
: "ready";
|
|
869
858
|
|
|
870
859
|
return {
|
|
871
860
|
available,
|
|
872
|
-
builtFor:
|
|
861
|
+
builtFor: CODEX_DATABASE_PROFILE.builtFor,
|
|
873
862
|
changed,
|
|
874
863
|
missing,
|
|
875
864
|
newlyDiscovered,
|
|
876
|
-
profileId:
|
|
865
|
+
profileId: CODEX_DATABASE_PROFILE.id,
|
|
866
|
+
resolvedDatabases: paths.resolvedDatabases,
|
|
877
867
|
status,
|
|
878
868
|
};
|
|
879
869
|
}
|
|
@@ -881,11 +871,7 @@ export async function diagnoseStorageCompatibility({ codexHome }) {
|
|
|
881
871
|
export async function assertDeepCleanupSupported({ codexHome }) {
|
|
882
872
|
const diagnostic = await diagnoseStorageCompatibility({ codexHome });
|
|
883
873
|
|
|
884
|
-
if (diagnostic.status === "
|
|
885
|
-
throw new Error("Deep cleanup is paused because unrecognized Codex storage was found.");
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
if (diagnostic.status !== "ready") {
|
|
874
|
+
if (diagnostic.status === "unsupported") {
|
|
889
875
|
throw new Error("Deep cleanup is paused because this Codex storage layout is not supported.");
|
|
890
876
|
}
|
|
891
877
|
|
|
@@ -895,23 +881,181 @@ export async function assertDeepCleanupSupported({ codexHome }) {
|
|
|
895
881
|
const DEFAULT_PAGE_SIZE = 25;
|
|
896
882
|
const MAX_PAGE_SIZE = 100;
|
|
897
883
|
const OVERVIEW_STAT_BATCH_SIZE = 24;
|
|
884
|
+
const SESSION_SIZE_CACHE_TTL_MS = 45 * 1000;
|
|
885
|
+
const SESSION_SIZE_STAT_CONCURRENCY = 64;
|
|
898
886
|
const SUPPORTING_THREAD_PREFIX = "The following is the Codex agent history whose request action you are assessing";
|
|
899
887
|
const SESSION_CREATED_SQL = "coalesce(nullif(t.created_at_ms, 0), nullif(t.created_at, 0) * 1000, 0)";
|
|
900
888
|
const SESSION_UPDATED_SQL = "coalesce(nullif(t.updated_at_ms, 0), nullif(t.updated_at, 0) * 1000, 0)";
|
|
901
889
|
const SESSION_ACTIVITY_SQL = `coalesce(nullif(${SESSION_UPDATED_SQL}, 0), nullif(${SESSION_CREATED_SQL}, 0), 0)`;
|
|
890
|
+
const SESSION_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
|
|
891
|
+
const sessionSizeCache = new Map();
|
|
892
|
+
const stateSchemaCache = new WeakMap();
|
|
893
|
+
|
|
894
|
+
const OPTIONAL_THREAD_COLUMNS = [
|
|
895
|
+
"agent_nickname", "agent_role", "archived", "cwd", "first_user_message",
|
|
896
|
+
"is_pinned", "title",
|
|
897
|
+
];
|
|
898
|
+
|
|
899
|
+
function stateSchema(database) {
|
|
900
|
+
const known = stateSchemaCache.get(database);
|
|
901
|
+
if (known) return known;
|
|
902
|
+
const cached = database.tables?.threads;
|
|
903
|
+
const columns = cached?.columns?.size
|
|
904
|
+
? cached.columns
|
|
905
|
+
: new Set(inspectSqliteTable(database.path, "threads").columns ?? []);
|
|
906
|
+
const spawn = database.tables?.thread_spawn_edges?.exists !== undefined
|
|
907
|
+
? database.tables.thread_spawn_edges
|
|
908
|
+
: inspectSqliteTable(database.path, "thread_spawn_edges");
|
|
909
|
+
const schema = { columns, hasSpawnEdges: Boolean(spawn?.exists) };
|
|
910
|
+
stateSchemaCache.set(database, schema);
|
|
911
|
+
return schema;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function columnExpression(columns, column, fallback = "null") {
|
|
915
|
+
return columns.has(column) ? `t.${column}` : fallback;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function timestampExpression(columns, prefix) {
|
|
919
|
+
const expressions = [];
|
|
920
|
+
if (columns.has(`${prefix}_at_ms`)) expressions.push(`nullif(t.${prefix}_at_ms, 0)`);
|
|
921
|
+
if (columns.has(`${prefix}_at`)) expressions.push(`nullif(t.${prefix}_at, 0) * 1000`);
|
|
922
|
+
return expressions.length ? `coalesce(${expressions.join(", ")}, 0)` : "0";
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function stateExpressions(database) {
|
|
926
|
+
const schema = stateSchema(database);
|
|
927
|
+
const created = timestampExpression(schema.columns, "created");
|
|
928
|
+
const updated = timestampExpression(schema.columns, "updated");
|
|
929
|
+
return { ...schema, activity: `coalesce(nullif(${updated}, 0), nullif(${created}, 0), 0)`, created, updated };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function buildSessionSizeIndex(paths) {
|
|
933
|
+
let rows;
|
|
934
|
+
if (paths.stateDatabases.length === 1) {
|
|
935
|
+
rows = queryRows(paths.stateDatabases[0].path, "select id, rollout_path from threads");
|
|
936
|
+
} else {
|
|
937
|
+
const rowsById = new Map();
|
|
938
|
+
for (const database of paths.stateDatabases) {
|
|
939
|
+
for (const row of queryRows(database.path, "select id, rollout_path from threads")) {
|
|
940
|
+
const id = String(row.id);
|
|
941
|
+
if (!rowsById.has(id)) rowsById.set(id, row);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
rows = [...rowsById.values()];
|
|
945
|
+
}
|
|
946
|
+
const sizes = new Map();
|
|
947
|
+
let nextIndex = 0;
|
|
902
948
|
|
|
903
|
-
|
|
904
|
-
|
|
949
|
+
const measureNext = async () => {
|
|
950
|
+
while (nextIndex < rows.length) {
|
|
951
|
+
const row = rows[nextIndex];
|
|
952
|
+
nextIndex += 1;
|
|
953
|
+
const id = String(row.id);
|
|
954
|
+
|
|
955
|
+
if (!row.rollout_path) {
|
|
956
|
+
sizes.set(id, null);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
try {
|
|
961
|
+
const stats = await fs.stat(row.rollout_path);
|
|
962
|
+
sizes.set(id, stats.isFile() ? stats.size : null);
|
|
963
|
+
} catch {
|
|
964
|
+
sizes.set(id, null);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
await Promise.all(
|
|
970
|
+
Array.from(
|
|
971
|
+
{ length: Math.min(SESSION_SIZE_STAT_CONCURRENCY, rows.length) },
|
|
972
|
+
measureNext,
|
|
973
|
+
),
|
|
974
|
+
);
|
|
975
|
+
return sizes;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function getSessionSizeIndex(paths, { refresh = false } = {}) {
|
|
979
|
+
const cached = sessionSizeCache.get(paths.codexHome);
|
|
980
|
+
|
|
981
|
+
if (!refresh && cached?.expiresAtMs > Date.now()) {
|
|
982
|
+
return cached.promise;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const promise = buildSessionSizeIndex(paths).catch((error) => {
|
|
986
|
+
if (sessionSizeCache.get(paths.codexHome)?.promise === promise) {
|
|
987
|
+
sessionSizeCache.delete(paths.codexHome);
|
|
988
|
+
}
|
|
989
|
+
throw error;
|
|
990
|
+
});
|
|
991
|
+
sessionSizeCache.set(paths.codexHome, {
|
|
992
|
+
expiresAtMs: Date.now() + SESSION_SIZE_CACHE_TTL_MS,
|
|
993
|
+
promise,
|
|
994
|
+
});
|
|
995
|
+
return promise;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
export function invalidateSessionCache({ codexHome }) {
|
|
999
|
+
const resolvedHome = path.resolve(expandHome(codexHome || "~/.codex"));
|
|
1000
|
+
sessionSizeCache.delete(resolvedHome);
|
|
1001
|
+
invalidateCodexDatabaseResolution(resolvedHome);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function compareSessionIdsBySize(leftId, rightId, sizes) {
|
|
1005
|
+
const leftSize = sizes.get(leftId);
|
|
1006
|
+
const rightSize = sizes.get(rightId);
|
|
1007
|
+
const leftKnown = Number.isFinite(leftSize);
|
|
1008
|
+
const rightKnown = Number.isFinite(rightSize);
|
|
1009
|
+
|
|
1010
|
+
if (leftKnown !== rightKnown) {
|
|
1011
|
+
return leftKnown ? -1 : 1;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return (rightSize ?? 0) - (leftSize ?? 0) || leftId.localeCompare(rightId);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function getSessionSort(sort, database) {
|
|
1018
|
+
const { activity, columns, created } = stateExpressions(database);
|
|
1019
|
+
const nameParts = ["t.title", "t.first_user_message"].filter((column) => columns.has(column.slice(2)))
|
|
1020
|
+
.map((column) => `nullif(trim(${column}), '')`);
|
|
1021
|
+
const name = `lower(coalesce(${[...nameParts, "t.id"].join(", ")}))`;
|
|
1022
|
+
const cwd = columns.has("cwd") ? "lower(coalesce(t.cwd, ''))" : "''";
|
|
905
1023
|
|
|
906
1024
|
return {
|
|
907
|
-
created:
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1025
|
+
created: {
|
|
1026
|
+
compare: (left, right) => Number(right.sort_number) - Number(left.sort_number),
|
|
1027
|
+
order: `${created} desc, t.id asc`,
|
|
1028
|
+
selection: `${created} as sort_number`,
|
|
1029
|
+
},
|
|
1030
|
+
cwd: {
|
|
1031
|
+
compare: (left, right) => String(left.sort_text).localeCompare(String(right.sort_text))
|
|
1032
|
+
|| Number(right.sort_activity) - Number(left.sort_activity),
|
|
1033
|
+
order: `${cwd} asc, ${activity} desc, t.id asc`,
|
|
1034
|
+
selection: `${cwd} as sort_text, ${activity} as sort_activity`,
|
|
1035
|
+
},
|
|
1036
|
+
name: {
|
|
1037
|
+
compare: (left, right) => String(left.sort_text).localeCompare(String(right.sort_text))
|
|
1038
|
+
|| Number(right.sort_activity) - Number(left.sort_activity),
|
|
1039
|
+
order: `${name} asc, ${activity} desc, t.id asc`,
|
|
1040
|
+
selection: `${name} as sort_text, ${activity} as sort_activity`,
|
|
1041
|
+
},
|
|
1042
|
+
updated: {
|
|
1043
|
+
compare: (left, right) => Number(right.sort_number) - Number(left.sort_number),
|
|
1044
|
+
order: `${activity} desc, t.id asc`,
|
|
1045
|
+
selection: `${activity} as sort_number`,
|
|
1046
|
+
},
|
|
1047
|
+
}[sort] ?? {
|
|
1048
|
+
compare: (left, right) => Number(right.sort_number) - Number(left.sort_number),
|
|
1049
|
+
order: `${activity} desc, t.id asc`,
|
|
1050
|
+
selection: `${activity} as sort_number`,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function getSessionOrder(sort, database) {
|
|
1055
|
+
return getSessionSort(sort, database).order;
|
|
912
1056
|
}
|
|
913
1057
|
|
|
914
|
-
function getSessionConditions({
|
|
1058
|
+
function getSessionConditions(database, {
|
|
915
1059
|
archiveStatus,
|
|
916
1060
|
inactiveBeforeMs,
|
|
917
1061
|
includeInternals,
|
|
@@ -919,32 +1063,34 @@ function getSessionConditions({
|
|
|
919
1063
|
search,
|
|
920
1064
|
workspace,
|
|
921
1065
|
}) {
|
|
1066
|
+
const { activity, columns, hasSpawnEdges } = stateExpressions(database);
|
|
922
1067
|
const conditions = [];
|
|
923
1068
|
const parameters = [];
|
|
924
1069
|
|
|
925
|
-
if (archiveStatus === "active") {
|
|
1070
|
+
if (archiveStatus === "active" && columns.has("archived")) {
|
|
926
1071
|
conditions.push("coalesce(t.archived, 0) = 0");
|
|
927
1072
|
} else if (archiveStatus === "archived") {
|
|
928
|
-
conditions.push("coalesce(t.archived, 0) <> 0");
|
|
1073
|
+
conditions.push(columns.has("archived") ? "coalesce(t.archived, 0) <> 0" : "0 = 1");
|
|
929
1074
|
}
|
|
930
1075
|
|
|
931
|
-
if (!includeInternals) {
|
|
1076
|
+
if (!includeInternals && hasSpawnEdges) {
|
|
932
1077
|
conditions.push(`not exists (
|
|
933
1078
|
select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id
|
|
934
1079
|
)`);
|
|
935
1080
|
}
|
|
936
1081
|
|
|
937
|
-
|
|
938
|
-
|
|
1082
|
+
const supportingColumns = ["title", "first_user_message"].filter((column) => columns.has(column));
|
|
1083
|
+
if (!includeSupporting && supportingColumns.length > 0) {
|
|
1084
|
+
conditions.push(`coalesce(${supportingColumns.map((column) => `nullif(trim(t.${column}), '')`).join(", ")}, '') not like ?`);
|
|
939
1085
|
parameters.push(`${SUPPORTING_THREAD_PREFIX}%`);
|
|
940
1086
|
}
|
|
941
1087
|
|
|
942
1088
|
if (Number.isFinite(inactiveBeforeMs) && inactiveBeforeMs > 0) {
|
|
943
|
-
conditions.push(`${
|
|
1089
|
+
conditions.push(`${activity} > 0 and ${activity} <= ?`);
|
|
944
1090
|
parameters.push(Math.trunc(inactiveBeforeMs));
|
|
945
1091
|
}
|
|
946
1092
|
|
|
947
|
-
if (typeof workspace === "string") {
|
|
1093
|
+
if (typeof workspace === "string" && columns.has("cwd")) {
|
|
948
1094
|
conditions.push("coalesce(t.cwd, '') = ?");
|
|
949
1095
|
parameters.push(workspace);
|
|
950
1096
|
}
|
|
@@ -952,14 +1098,10 @@ function getSessionConditions({
|
|
|
952
1098
|
const normalizedSearch = normalizeText(search).toLowerCase();
|
|
953
1099
|
|
|
954
1100
|
if (normalizedSearch) {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
or instr(lower(coalesce(t.cwd, '')), ?) > 0
|
|
960
|
-
or instr(lower(coalesce(t.rollout_path, '')), ?) > 0
|
|
961
|
-
)`);
|
|
962
|
-
parameters.push(...Array(5).fill(normalizedSearch));
|
|
1101
|
+
const searchColumns = ["id", "title", "first_user_message", "cwd", "rollout_path"]
|
|
1102
|
+
.filter((column) => column === "id" || columns.has(column));
|
|
1103
|
+
conditions.push(`(${searchColumns.map((column) => `instr(lower(coalesce(t.${column}, '')), ?) > 0`).join(" or ")})`);
|
|
1104
|
+
parameters.push(...Array(searchColumns.length).fill(normalizedSearch));
|
|
963
1105
|
}
|
|
964
1106
|
|
|
965
1107
|
return {
|
|
@@ -968,30 +1110,25 @@ function getSessionConditions({
|
|
|
968
1110
|
};
|
|
969
1111
|
}
|
|
970
1112
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
t.
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
limit 1
|
|
988
|
-
) as parent_thread_id
|
|
989
|
-
`;
|
|
990
|
-
|
|
991
|
-
async function formatPagedThreadRows(stateDatabasePath, threadRows) {
|
|
1113
|
+
function sessionColumns(database) {
|
|
1114
|
+
const { activity, columns, created, hasSpawnEdges } = stateExpressions(database);
|
|
1115
|
+
const optional = OPTIONAL_THREAD_COLUMNS.map((column) =>
|
|
1116
|
+
`${columnExpression(columns, column)} as ${column}`,
|
|
1117
|
+
);
|
|
1118
|
+
const parent = hasSpawnEdges
|
|
1119
|
+
? `(select edge.parent_thread_id from thread_spawn_edges edge where edge.child_thread_id = t.id limit 1)`
|
|
1120
|
+
: "null";
|
|
1121
|
+
return `t.id, t.rollout_path, ${optional.join(", ")}, ${created} as created_at_ms_resolved, ${activity} as updated_at_ms_resolved, ${parent} as parent_thread_id`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async function formatPagedThreadRows(stateDatabase, threadRows) {
|
|
1125
|
+
const stateDatabasePath = typeof stateDatabase === "string" ? stateDatabase : stateDatabase.path;
|
|
1126
|
+
const hasSpawnEdges = typeof stateDatabase === "string"
|
|
1127
|
+
? inspectSqliteTable(stateDatabasePath, "thread_spawn_edges").exists
|
|
1128
|
+
: stateSchema(stateDatabase).hasSpawnEdges;
|
|
992
1129
|
const childIdsByParentId = new Map();
|
|
993
1130
|
|
|
994
|
-
if (threadRows.length > 0) {
|
|
1131
|
+
if (threadRows.length > 0 && hasSpawnEdges) {
|
|
995
1132
|
const ids = threadRows.map((row) => String(row.id));
|
|
996
1133
|
|
|
997
1134
|
for (const idBatch of batches(ids)) {
|
|
@@ -1014,18 +1151,22 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
|
|
|
1014
1151
|
const records = [];
|
|
1015
1152
|
|
|
1016
1153
|
for (const threadRow of threadRows) {
|
|
1017
|
-
const title =
|
|
1018
|
-
const firstUserMessage =
|
|
1154
|
+
const title = cleanDerivedTitle(threadRow.title ?? "");
|
|
1155
|
+
const firstUserMessage = cleanDerivedTitle(
|
|
1156
|
+
getMeaningfulUserText(threadRow.first_user_message ?? ""),
|
|
1157
|
+
);
|
|
1019
1158
|
const displayName = title || firstUserMessage || `Untitled ${String(threadRow.id).slice(0, 8)}`;
|
|
1020
1159
|
const rolloutPath = threadRow.rollout_path ?? "";
|
|
1021
1160
|
let transcriptHeader = null;
|
|
1161
|
+
let transcriptBytes = null;
|
|
1022
1162
|
|
|
1023
1163
|
if (rolloutPath) {
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1164
|
+
const [header, stats] = await Promise.all([
|
|
1165
|
+
parseTranscriptHeader(rolloutPath).catch(() => null),
|
|
1166
|
+
fs.stat(rolloutPath).catch(() => null),
|
|
1167
|
+
]);
|
|
1168
|
+
transcriptHeader = header;
|
|
1169
|
+
transcriptBytes = stats?.isFile() ? stats.size : null;
|
|
1029
1170
|
}
|
|
1030
1171
|
|
|
1031
1172
|
const parentThreadId = threadRow.parent_thread_id ?? transcriptHeader?.parentThreadId ?? null;
|
|
@@ -1035,7 +1176,7 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
|
|
|
1035
1176
|
agentRole: threadRow.agent_role ?? transcriptHeader?.agentRole ?? null,
|
|
1036
1177
|
archived: Boolean(threadRow.archived),
|
|
1037
1178
|
childThreadIds: [...(childIdsByParentId.get(threadRow.id) ?? [])].sort(),
|
|
1038
|
-
createdAtMs: toTimestampMs(threadRow.created_at_ms),
|
|
1179
|
+
createdAtMs: toTimestampMs(threadRow.created_at_ms_resolved ?? threadRow.created_at_ms),
|
|
1039
1180
|
cwd: threadRow.cwd ?? "",
|
|
1040
1181
|
displayName,
|
|
1041
1182
|
firstUserMessage: threadRow.first_user_message ?? "",
|
|
@@ -1051,7 +1192,9 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
|
|
|
1051
1192
|
rolloutPath,
|
|
1052
1193
|
title: threadRow.title ?? "",
|
|
1053
1194
|
titleSource: title ? "sqlite_title" : firstUserMessage ? "sqlite_first_user_message" : "fallback",
|
|
1054
|
-
|
|
1195
|
+
transcriptBytes,
|
|
1196
|
+
updatedAtMs: toTimestampMs(threadRow.updated_at_ms_resolved ?? threadRow.updated_at_ms),
|
|
1197
|
+
_stateDatabasePath: stateDatabasePath,
|
|
1055
1198
|
});
|
|
1056
1199
|
}
|
|
1057
1200
|
|
|
@@ -1068,9 +1211,9 @@ async function getStoreAvailability(paths) {
|
|
|
1068
1211
|
] = await Promise.all([
|
|
1069
1212
|
pathExists(paths.desktopStatePath),
|
|
1070
1213
|
pathExists(paths.desktopStateBackupPath),
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1214
|
+
Promise.resolve(paths.goalsDatabasePaths.length > 0),
|
|
1215
|
+
Promise.resolve(paths.logsDatabasePaths.length > 0),
|
|
1216
|
+
Promise.resolve(paths.memoryDatabasePaths.length > 0),
|
|
1074
1217
|
]);
|
|
1075
1218
|
|
|
1076
1219
|
return {
|
|
@@ -1156,13 +1299,13 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1156
1299
|
|
|
1157
1300
|
for (let offset = 0; offset < pendingIds.length; offset += 400) {
|
|
1158
1301
|
const idBatch = pendingIds.slice(offset, offset + 400);
|
|
1159
|
-
const stateChildren =
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1302
|
+
const stateChildren = paths.stateDatabases.flatMap((database) => stateSchema(database).hasSpawnEdges
|
|
1303
|
+
? queryRows(
|
|
1304
|
+
database.path,
|
|
1305
|
+
`select parent_thread_id, child_thread_id from thread_spawn_edges where parent_thread_id in (${placeholders(idBatch)})`,
|
|
1306
|
+
idBatch,
|
|
1307
|
+
)
|
|
1308
|
+
: []);
|
|
1166
1309
|
|
|
1167
1310
|
for (const edge of stateChildren) {
|
|
1168
1311
|
const childId = String(edge.child_thread_id);
|
|
@@ -1183,19 +1326,29 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1183
1326
|
}
|
|
1184
1327
|
|
|
1185
1328
|
const ids = [...selectedIds];
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1329
|
+
const threadRowsById = new Map();
|
|
1330
|
+
for (const database of paths.stateDatabases) {
|
|
1331
|
+
for (const idBatch of batches(ids)) {
|
|
1332
|
+
for (const row of queryRows(
|
|
1333
|
+
database.path,
|
|
1334
|
+
`select ${sessionColumns(database)} from threads t where t.id in (${placeholders(idBatch)})`,
|
|
1335
|
+
idBatch,
|
|
1336
|
+
)) {
|
|
1337
|
+
const id = String(row.id);
|
|
1338
|
+
if (!threadRowsById.has(id)) threadRowsById.set(id, { ...row, _database: database });
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1196
1341
|
}
|
|
1197
|
-
|
|
1198
|
-
const
|
|
1342
|
+
const threadRows = [...threadRowsById.values()];
|
|
1343
|
+
const spawnEdgesByKey = new Map();
|
|
1344
|
+
for (const database of paths.stateDatabases) {
|
|
1345
|
+
if (!stateSchema(database).hasSpawnEdges) continue;
|
|
1346
|
+
for (const edge of queryRelatedSpawnEdges(database.path, ids)) {
|
|
1347
|
+
const key = `${edge.parent_thread_id}\0${edge.child_thread_id}`;
|
|
1348
|
+
if (!spawnEdgesByKey.has(key)) spawnEdgesByKey.set(key, edge);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
const spawnEdges = [...spawnEdgesByKey.values()];
|
|
1199
1352
|
const childIdsByParentId = new Map();
|
|
1200
1353
|
const parentIdsByChildId = new Map();
|
|
1201
1354
|
|
|
@@ -1220,7 +1373,13 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1220
1373
|
}
|
|
1221
1374
|
}
|
|
1222
1375
|
|
|
1223
|
-
const formattedRows =
|
|
1376
|
+
const formattedRows = [];
|
|
1377
|
+
for (const database of paths.stateDatabases) {
|
|
1378
|
+
formattedRows.push(...await formatPagedThreadRows(
|
|
1379
|
+
database,
|
|
1380
|
+
threadRows.filter((row) => row._database === database),
|
|
1381
|
+
));
|
|
1382
|
+
}
|
|
1224
1383
|
const recordsById = new Map();
|
|
1225
1384
|
|
|
1226
1385
|
for (const record of formattedRows) {
|
|
@@ -1322,6 +1481,12 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1322
1481
|
sessionIndexPath: paths.sessionIndexPath,
|
|
1323
1482
|
spawnEdges,
|
|
1324
1483
|
stateDatabasePath: paths.stateDatabasePath,
|
|
1484
|
+
stateDatabasePaths: paths.stateDatabasePaths,
|
|
1485
|
+
stateDatabases: paths.stateDatabases,
|
|
1486
|
+
resolvedDatabases: paths.resolvedDatabases,
|
|
1487
|
+
logsDatabasePaths: paths.logsDatabasePaths,
|
|
1488
|
+
memoryDatabasePaths: paths.memoryDatabasePaths,
|
|
1489
|
+
goalsDatabasePaths: paths.goalsDatabasePaths,
|
|
1325
1490
|
transcriptHeaders: new Map(
|
|
1326
1491
|
[...transcriptHeaders].filter(([id]) => relevantIds.has(id)),
|
|
1327
1492
|
),
|
|
@@ -1336,46 +1501,113 @@ export async function listSessions({
|
|
|
1336
1501
|
includeSupporting = false,
|
|
1337
1502
|
page = 1,
|
|
1338
1503
|
pageSize = DEFAULT_PAGE_SIZE,
|
|
1504
|
+
refresh = false,
|
|
1339
1505
|
search = "",
|
|
1340
1506
|
sort = "updated",
|
|
1341
1507
|
workspace,
|
|
1508
|
+
forceUnion = false,
|
|
1342
1509
|
}) {
|
|
1343
|
-
const paths = getCodexPaths(codexHome);
|
|
1510
|
+
const paths = getCodexPaths(codexHome, { refresh });
|
|
1344
1511
|
const boundedPageSize = Number.isFinite(pageSize)
|
|
1345
1512
|
? Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(pageSize)))
|
|
1346
1513
|
: DEFAULT_PAGE_SIZE;
|
|
1347
1514
|
const requestedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1515
|
+
const resolvedSort = SESSION_SORTS.has(sort) ? sort : "updated";
|
|
1516
|
+
if (paths.stateDatabases.length === 1 && resolvedSort !== "size" && !forceUnion) {
|
|
1517
|
+
const database = paths.stateDatabases[0];
|
|
1518
|
+
const conditions = getSessionConditions(database, {
|
|
1519
|
+
archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
|
|
1520
|
+
});
|
|
1521
|
+
const countRow = queryRows(
|
|
1522
|
+
database.path,
|
|
1523
|
+
`select count(*) as count from threads t ${conditions.sql}`,
|
|
1524
|
+
conditions.parameters,
|
|
1525
|
+
)[0];
|
|
1526
|
+
const total = Number(countRow?.count ?? 0);
|
|
1527
|
+
const pageCount = Math.max(1, Math.ceil(total / boundedPageSize));
|
|
1528
|
+
const currentPage = Math.min(requestedPage, pageCount);
|
|
1529
|
+
const rows = queryRows(
|
|
1530
|
+
database.path,
|
|
1531
|
+
`select ${sessionColumns(database)}
|
|
1532
|
+
from threads t
|
|
1533
|
+
${conditions.sql}
|
|
1534
|
+
order by ${getSessionOrder(resolvedSort, database)}
|
|
1535
|
+
limit ? offset ?`,
|
|
1536
|
+
[...conditions.parameters, boundedPageSize, (currentPage - 1) * boundedPageSize],
|
|
1537
|
+
);
|
|
1538
|
+
|
|
1539
|
+
return {
|
|
1540
|
+
page: currentPage,
|
|
1541
|
+
pageCount,
|
|
1542
|
+
pageSize: boundedPageSize,
|
|
1543
|
+
records: await formatPagedThreadRows(database, rows),
|
|
1544
|
+
total,
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const ordered = [];
|
|
1549
|
+
const seenIds = paths.stateDatabases.length > 1 ? new Set() : null;
|
|
1550
|
+
const compactSizeIds = resolvedSort === "size" && paths.stateDatabases.length === 1;
|
|
1551
|
+
let compareSortRows = null;
|
|
1552
|
+
for (const database of paths.stateDatabases) {
|
|
1553
|
+
const conditions = getSessionConditions(database, {
|
|
1554
|
+
archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
|
|
1555
|
+
});
|
|
1556
|
+
const sessionSort = resolvedSort === "size" ? null : getSessionSort(resolvedSort, database);
|
|
1557
|
+
compareSortRows ??= sessionSort?.compare ?? null;
|
|
1558
|
+
const rows = queryRows(
|
|
1559
|
+
database.path,
|
|
1560
|
+
`select t.id${sessionSort ? `, ${sessionSort.selection}` : ""} from threads t ${conditions.sql}`,
|
|
1561
|
+
conditions.parameters,
|
|
1562
|
+
);
|
|
1563
|
+
for (const row of rows) {
|
|
1564
|
+
const id = String(row.id);
|
|
1565
|
+
if (seenIds?.has(id)) continue;
|
|
1566
|
+
seenIds?.add(id);
|
|
1567
|
+
if (compactSizeIds) {
|
|
1568
|
+
ordered.push(id);
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
row.database = database;
|
|
1572
|
+
row.id = id;
|
|
1573
|
+
ordered.push(row);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
if (resolvedSort === "size") {
|
|
1577
|
+
const sizes = await getSessionSizeIndex(paths, { refresh });
|
|
1578
|
+
ordered.sort((left, right) => compareSessionIdsBySize(
|
|
1579
|
+
compactSizeIds ? left : left.id,
|
|
1580
|
+
compactSizeIds ? right : right.id,
|
|
1581
|
+
sizes,
|
|
1582
|
+
));
|
|
1583
|
+
} else {
|
|
1584
|
+
ordered.sort((left, right) => compareSortRows(left, right) || left.id.localeCompare(right.id));
|
|
1585
|
+
}
|
|
1586
|
+
const total = ordered.length;
|
|
1362
1587
|
const pageCount = Math.max(1, Math.ceil(total / boundedPageSize));
|
|
1363
1588
|
const currentPage = Math.min(requestedPage, pageCount);
|
|
1364
|
-
const
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1589
|
+
const pageItems = ordered.slice((currentPage - 1) * boundedPageSize, currentPage * boundedPageSize);
|
|
1590
|
+
const recordsById = new Map();
|
|
1591
|
+
const itemsByDatabase = new Map();
|
|
1592
|
+
if (compactSizeIds) {
|
|
1593
|
+
itemsByDatabase.set(paths.stateDatabases[0], pageItems);
|
|
1594
|
+
} else {
|
|
1595
|
+
for (const item of pageItems) {
|
|
1596
|
+
const values = itemsByDatabase.get(item.database) ?? [];
|
|
1597
|
+
values.push(item.id);
|
|
1598
|
+
itemsByDatabase.set(item.database, values);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
for (const [database, ids] of itemsByDatabase) {
|
|
1602
|
+
const rows = queryRows(database.path, `select ${sessionColumns(database)} from threads t where t.id in (${placeholders(ids)})`, ids);
|
|
1603
|
+
for (const record of await formatPagedThreadRows(database, rows)) recordsById.set(record.id, record);
|
|
1604
|
+
}
|
|
1373
1605
|
|
|
1374
1606
|
return {
|
|
1375
1607
|
page: currentPage,
|
|
1376
1608
|
pageCount,
|
|
1377
1609
|
pageSize: boundedPageSize,
|
|
1378
|
-
records:
|
|
1610
|
+
records: pageItems.map((item) => recordsById.get(compactSizeIds ? item : item.id)).filter(Boolean),
|
|
1379
1611
|
total,
|
|
1380
1612
|
};
|
|
1381
1613
|
}
|
|
@@ -1429,75 +1661,83 @@ async function measureTranscriptStorage(transcriptDirectories) {
|
|
|
1429
1661
|
};
|
|
1430
1662
|
}
|
|
1431
1663
|
|
|
1432
|
-
export async function getSessionOverview({ codexHome }) {
|
|
1433
|
-
const paths = getCodexPaths(codexHome);
|
|
1434
|
-
const
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1664
|
+
export async function getSessionOverview({ codexHome, refresh = false }) {
|
|
1665
|
+
const paths = getCodexPaths(codexHome, { refresh });
|
|
1666
|
+
const sessions = new Map();
|
|
1667
|
+
for (const database of paths.stateDatabases) {
|
|
1668
|
+
const { activity, columns, hasSpawnEdges } = stateExpressions(database);
|
|
1669
|
+
const projection = [
|
|
1670
|
+
"t.id",
|
|
1671
|
+
`${columns.has("cwd") ? "coalesce(t.cwd, '')" : "''"} as path`,
|
|
1672
|
+
`${columns.has("archived") ? "coalesce(t.archived, 0)" : "0"} as archived`,
|
|
1673
|
+
`${columns.has("title") ? "coalesce(t.title, '')" : "''"} as title`,
|
|
1674
|
+
`${columns.has("first_user_message") ? "coalesce(t.first_user_message, '')" : "''"} as first_user_message`,
|
|
1675
|
+
`${activity} as last_activity_at_ms`,
|
|
1676
|
+
`${hasSpawnEdges ? "exists(select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id)" : "0"} as is_subagent`,
|
|
1677
|
+
].join(", ");
|
|
1678
|
+
for (const row of queryRows(database.path, `select ${projection} from threads t`)) {
|
|
1679
|
+
const id = String(row.id);
|
|
1680
|
+
if (!sessions.has(id)) sessions.set(id, row);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
const workspaceSessionRows = [...sessions.values()];
|
|
1684
|
+
const [sizes, storage] = await Promise.all([
|
|
1685
|
+
getSessionSizeIndex(paths, { refresh }),
|
|
1686
|
+
measureTranscriptStorage(paths.transcriptDirectories),
|
|
1687
|
+
]);
|
|
1688
|
+
const workspaces = new Map();
|
|
1689
|
+
let archivedSessionCount = 0;
|
|
1690
|
+
let subagentCount = 0;
|
|
1691
|
+
let supportingCount = 0;
|
|
1692
|
+
let unknownActivityCount = 0;
|
|
1693
|
+
|
|
1694
|
+
for (const row of workspaceSessionRows) {
|
|
1695
|
+
const supporting = `${row.title || row.first_user_message || ""}`.startsWith(SUPPORTING_THREAD_PREFIX);
|
|
1696
|
+
archivedSessionCount += row.archived ? 1 : 0;
|
|
1697
|
+
subagentCount += row.is_subagent ? 1 : 0;
|
|
1698
|
+
supportingCount += supporting ? 1 : 0;
|
|
1699
|
+
unknownActivityCount += Number(row.last_activity_at_ms) ? 0 : 1;
|
|
1700
|
+
const workspacePath = row.path ?? "";
|
|
1701
|
+
const current = workspaces.get(workspacePath) ?? {
|
|
1702
|
+
lastActivityAtMs: 0,
|
|
1703
|
+
path: workspacePath,
|
|
1704
|
+
sessionCount: 0,
|
|
1705
|
+
transcriptBytes: 0,
|
|
1706
|
+
};
|
|
1707
|
+
current.lastActivityAtMs = Math.max(
|
|
1708
|
+
current.lastActivityAtMs,
|
|
1709
|
+
toTimestampMs(row.last_activity_at_ms),
|
|
1710
|
+
);
|
|
1711
|
+
current.sessionCount += 1;
|
|
1712
|
+
const transcriptBytes = sizes.get(String(row.id));
|
|
1713
|
+
if (Number.isFinite(transcriptBytes)) current.transcriptBytes += transcriptBytes;
|
|
1714
|
+
workspaces.set(workspacePath, current);
|
|
1715
|
+
}
|
|
1473
1716
|
|
|
1474
1717
|
return {
|
|
1475
|
-
activeSessionCount:
|
|
1476
|
-
archivedSessionCount
|
|
1718
|
+
activeSessionCount: sessions.size - archivedSessionCount,
|
|
1719
|
+
archivedSessionCount,
|
|
1477
1720
|
calculatedAtMs: Date.now(),
|
|
1478
|
-
primarySessionCount:
|
|
1479
|
-
sessionCount:
|
|
1480
|
-
subagentCount
|
|
1481
|
-
supportingCount
|
|
1482
|
-
unknownActivityCount
|
|
1483
|
-
workspaces:
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
})),
|
|
1721
|
+
primarySessionCount: sessions.size - subagentCount - supportingCount,
|
|
1722
|
+
sessionCount: sessions.size,
|
|
1723
|
+
subagentCount,
|
|
1724
|
+
supportingCount,
|
|
1725
|
+
unknownActivityCount,
|
|
1726
|
+
workspaces: [...workspaces.values()].sort((left, right) =>
|
|
1727
|
+
Number(!left.path) - Number(!right.path)
|
|
1728
|
+
|| right.lastActivityAtMs - left.lastActivityAtMs
|
|
1729
|
+
|| left.path.localeCompare(right.path)),
|
|
1488
1730
|
...storage,
|
|
1489
1731
|
};
|
|
1490
1732
|
}
|
|
1491
1733
|
|
|
1492
1734
|
export async function getSessionRecord({ codexHome, id }) {
|
|
1493
1735
|
const paths = getCodexPaths(codexHome);
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
const records = await formatPagedThreadRows(paths.stateDatabasePath, rows);
|
|
1500
|
-
return records[0] ?? null;
|
|
1736
|
+
for (const database of paths.stateDatabases) {
|
|
1737
|
+
const rows = queryRows(database.path, `select ${sessionColumns(database)} from threads t where t.id = ? limit 1`, [id]);
|
|
1738
|
+
if (rows.length) return (await formatPagedThreadRows(database, rows))[0] ?? null;
|
|
1739
|
+
}
|
|
1740
|
+
return null;
|
|
1501
1741
|
}
|
|
1502
1742
|
|
|
1503
1743
|
export function filterAndSortSessions({
|
|
@@ -1545,6 +1785,12 @@ export function filterAndSortSessions({
|
|
|
1545
1785
|
name: (left, right) =>
|
|
1546
1786
|
left.displayName.localeCompare(right.displayName) ||
|
|
1547
1787
|
right.updatedAtMs - left.updatedAtMs,
|
|
1788
|
+
size: (left, right) => {
|
|
1789
|
+
const leftKnown = Number.isFinite(left.transcriptBytes);
|
|
1790
|
+
const rightKnown = Number.isFinite(right.transcriptBytes);
|
|
1791
|
+
if (leftKnown !== rightKnown) return leftKnown ? -1 : 1;
|
|
1792
|
+
return (right.transcriptBytes ?? 0) - (left.transcriptBytes ?? 0);
|
|
1793
|
+
},
|
|
1548
1794
|
updated: (left, right) =>
|
|
1549
1795
|
right.updatedAtMs - left.updatedAtMs || left.displayName.localeCompare(right.displayName),
|
|
1550
1796
|
};
|
|
@@ -1588,6 +1834,18 @@ function findRowsForIds(databasePath, tableName, columnName, ids, { limitOne = f
|
|
|
1588
1834
|
return rows;
|
|
1589
1835
|
}
|
|
1590
1836
|
|
|
1837
|
+
function fingerprintRowsForIds(hash, databasePath, tableName, columnName, ids) {
|
|
1838
|
+
for (const idBatch of batches(ids)) {
|
|
1839
|
+
const rows = queryRows(
|
|
1840
|
+
databasePath,
|
|
1841
|
+
`select * from ${tableName} where ${columnName} in (${placeholders(idBatch)})`,
|
|
1842
|
+
idBatch,
|
|
1843
|
+
);
|
|
1844
|
+
rows.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
1845
|
+
hash.update(`${databasePath}\0${tableName}\0${JSON.stringify(rows)}\0`);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1591
1849
|
function* deleteStatements(tableName, columnName, ids) {
|
|
1592
1850
|
for (const idBatch of batches(ids)) {
|
|
1593
1851
|
yield {
|
|
@@ -1677,15 +1935,16 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1677
1935
|
|
|
1678
1936
|
return count;
|
|
1679
1937
|
}, 0);
|
|
1680
|
-
const logRowCount = store.
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
const
|
|
1687
|
-
|
|
1688
|
-
:
|
|
1938
|
+
const logRowCount = (store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean))
|
|
1939
|
+
.reduce((total, databasePath) => total + countRowsForIds(databasePath, "logs", "thread_id", deletionIds), 0);
|
|
1940
|
+
const memoryRowCount = (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean))
|
|
1941
|
+
.reduce((total, databasePath) => total + countRowsForIds(databasePath, "stage1_outputs", "thread_id", deletionIds), 0);
|
|
1942
|
+
const goalRowCount = (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean))
|
|
1943
|
+
.reduce((total, databasePath) => total + countRowsForIds(databasePath, "thread_goals", "thread_id", deletionIds), 0);
|
|
1944
|
+
const stateTargets = (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({
|
|
1945
|
+
database,
|
|
1946
|
+
ids: findRowsForIds(database.path, "threads", "id", deletionIds).map((row) => String(row.id)),
|
|
1947
|
+
}));
|
|
1689
1948
|
|
|
1690
1949
|
return {
|
|
1691
1950
|
childCount: Math.max(0, deletionIds.length - recordIds.length),
|
|
@@ -1704,6 +1963,7 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1704
1963
|
records: selectedRecords,
|
|
1705
1964
|
sessionIndexMatchCount: sessionIndexMatches.count,
|
|
1706
1965
|
spawnEdgeCount,
|
|
1966
|
+
stateTargets,
|
|
1707
1967
|
transcriptBytes,
|
|
1708
1968
|
transcriptFileCount,
|
|
1709
1969
|
transcriptPaths,
|
|
@@ -1713,19 +1973,19 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1713
1973
|
const BACKUP_MINIMUM_RESERVE_BYTES = 1024 * 1024;
|
|
1714
1974
|
const BACKUP_RESERVE_RATIO = 0.05;
|
|
1715
1975
|
|
|
1716
|
-
function getBackupSourcePaths({ plan, store }) {
|
|
1976
|
+
function getBackupSourcePaths({ plan, scope, store }) {
|
|
1717
1977
|
const databasePaths = [
|
|
1718
|
-
store.stateDatabasePath,
|
|
1719
|
-
store.
|
|
1720
|
-
store.
|
|
1721
|
-
store.
|
|
1978
|
+
...(store.stateDatabasePaths ?? [store.stateDatabasePath]),
|
|
1979
|
+
...(store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean)),
|
|
1980
|
+
...(scope === "deep" ? (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) : []),
|
|
1981
|
+
...(scope === "deep" ? (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) : []),
|
|
1722
1982
|
].filter(Boolean);
|
|
1723
1983
|
|
|
1724
1984
|
return [
|
|
1725
1985
|
store.historyPath,
|
|
1726
1986
|
store.sessionIndexPath,
|
|
1727
|
-
store.hasDesktopState ? store.desktopStatePath : null,
|
|
1728
|
-
store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
|
|
1987
|
+
scope === "deep" && store.hasDesktopState ? store.desktopStatePath : null,
|
|
1988
|
+
scope === "deep" && store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
|
|
1729
1989
|
...plan.transcriptPaths,
|
|
1730
1990
|
...databasePaths.flatMap((databasePath) => [
|
|
1731
1991
|
databasePath,
|
|
@@ -1772,6 +2032,7 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
1772
2032
|
store.hasMemoryDatabase,
|
|
1773
2033
|
] : []),
|
|
1774
2034
|
].map(Number).join("\0")}\0`);
|
|
2035
|
+
hash.update(`resolved-databases\0${JSON.stringify(store.resolvedDatabases ?? {})}\0`);
|
|
1775
2036
|
|
|
1776
2037
|
for (const record of [...plan.records].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
1777
2038
|
hash.update([
|
|
@@ -1790,6 +2051,26 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
1790
2051
|
hash.update(`id\0${id}\0`);
|
|
1791
2052
|
}
|
|
1792
2053
|
|
|
2054
|
+
for (const database of store.stateDatabases ?? [{ path: store.stateDatabasePath }]) {
|
|
2055
|
+
fingerprintRowsForIds(hash, database.path, "threads", "id", plan.ids);
|
|
2056
|
+
if (stateSchema(database).hasSpawnEdges) {
|
|
2057
|
+
fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "parent_thread_id", plan.ids);
|
|
2058
|
+
fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "child_thread_id", plan.ids);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
for (const databasePath of store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean)) {
|
|
2062
|
+
fingerprintRowsForIds(hash, databasePath, "logs", "thread_id", plan.ids);
|
|
2063
|
+
}
|
|
2064
|
+
if (scope === "deep") {
|
|
2065
|
+
for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) {
|
|
2066
|
+
fingerprintRowsForIds(hash, databasePath, "stage1_outputs", "thread_id", plan.ids);
|
|
2067
|
+
}
|
|
2068
|
+
for (const databasePath of store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) {
|
|
2069
|
+
fingerprintRowsForIds(hash, databasePath, "thread_goals", "thread_id", plan.ids);
|
|
2070
|
+
fingerprintRowsForIds(hash, databasePath, "thread_goal_continuation_deferrals", "thread_id", plan.ids);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
|
|
1793
2074
|
for (const filePath of [...plan.missingTranscriptPaths].sort()) {
|
|
1794
2075
|
hash.update(`missing-transcript\0${filePath}\0`);
|
|
1795
2076
|
}
|
|
@@ -1811,9 +2092,9 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
1811
2092
|
return hash.digest("hex");
|
|
1812
2093
|
}
|
|
1813
2094
|
|
|
1814
|
-
async function estimateBackupBytes({ plan, store }) {
|
|
2095
|
+
async function estimateBackupBytes({ plan, scope, store }) {
|
|
1815
2096
|
let sourceBytes = 0;
|
|
1816
|
-
const uniquePaths = new Set(getBackupSourcePaths({ plan, store }));
|
|
2097
|
+
const uniquePaths = new Set(getBackupSourcePaths({ plan, scope, store }));
|
|
1817
2098
|
|
|
1818
2099
|
for (const sourcePath of uniquePaths) {
|
|
1819
2100
|
try {
|
|
@@ -1869,7 +2150,7 @@ function formatBytes(bytes) {
|
|
|
1869
2150
|
return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`;
|
|
1870
2151
|
}
|
|
1871
2152
|
|
|
1872
|
-
export async function preflightSessionDeletion({ availableDiskBytes, plan, store }) {
|
|
2153
|
+
export async function preflightSessionDeletion({ availableDiskBytes, plan, scope = "deep", store }) {
|
|
1873
2154
|
const requiredPaths = [store.stateDatabasePath, store.sessionIndexPath, store.historyPath];
|
|
1874
2155
|
const missingRequiredPaths = [];
|
|
1875
2156
|
|
|
@@ -1889,7 +2170,7 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, store
|
|
|
1889
2170
|
store.hasDesktopStateBackup ? readJsonFile(store.desktopStateBackupPath) : null,
|
|
1890
2171
|
]);
|
|
1891
2172
|
const [desktopState, desktopStateBackup] = desktopStates;
|
|
1892
|
-
const backupEstimate = await estimateBackupBytes({ plan, store });
|
|
2173
|
+
const backupEstimate = await estimateBackupBytes({ plan, scope, store });
|
|
1893
2174
|
const diskCapacityBytes = availableDiskBytes ?? await getAvailableDiskBytes(store.codexHome);
|
|
1894
2175
|
|
|
1895
2176
|
if (diskCapacityBytes < backupEstimate.estimatedBackupBytes) {
|
|
@@ -1958,10 +2239,10 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
|
|
|
1958
2239
|
transcriptNameCounts.set(name, (transcriptNameCounts.get(name) ?? 0) + 1);
|
|
1959
2240
|
}
|
|
1960
2241
|
const snapshotCandidates = [
|
|
1961
|
-
[store.stateDatabasePath,
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2242
|
+
...(store.stateDatabasePaths ?? [store.stateDatabasePath]).map((databasePath) => [databasePath, path.basename(databasePath), true]),
|
|
2243
|
+
...(store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean)).map((databasePath) => [databasePath, path.basename(databasePath), true]),
|
|
2244
|
+
...(scope === "deep" ? (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) : []).map((databasePath) => [databasePath, path.basename(databasePath), true]),
|
|
2245
|
+
...(scope === "deep" ? (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) : []).map((databasePath) => [databasePath, path.basename(databasePath), true]),
|
|
1965
2246
|
];
|
|
1966
2247
|
const totalItems = backupFiles.length + plan.transcriptPaths.length + snapshotCandidates.length;
|
|
1967
2248
|
let completedItems = 0;
|
|
@@ -2034,7 +2315,18 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
|
|
|
2034
2315
|
|
|
2035
2316
|
await atomicWriteFile(
|
|
2036
2317
|
path.join(backupDirectory, "operation.json"),
|
|
2037
|
-
`${JSON.stringify({
|
|
2318
|
+
`${JSON.stringify({
|
|
2319
|
+
version: 3,
|
|
2320
|
+
ids: plan.ids,
|
|
2321
|
+
scope,
|
|
2322
|
+
createdAtMs: Date.now(),
|
|
2323
|
+
copiedFiles,
|
|
2324
|
+
databaseSnapshots,
|
|
2325
|
+
files,
|
|
2326
|
+
profileId: CODEX_DATABASE_PROFILE.id,
|
|
2327
|
+
resolvedDatabases: store.resolvedDatabases ?? {},
|
|
2328
|
+
compatibilityStatus: (await diagnoseStorageCompatibility({ codexHome: store.codexHome })).status,
|
|
2329
|
+
}, null, 2)}\n`,
|
|
2038
2330
|
);
|
|
2039
2331
|
|
|
2040
2332
|
return backupDirectory;
|
|
@@ -2079,7 +2371,7 @@ export async function executeSessionDeletion({
|
|
|
2079
2371
|
});
|
|
2080
2372
|
if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
|
|
2081
2373
|
const deletedIdSet = new Set(plan.ids);
|
|
2082
|
-
const preflight = await preflightSessionDeletion({ plan, store });
|
|
2374
|
+
const preflight = await preflightSessionDeletion({ plan, scope, store });
|
|
2083
2375
|
if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
|
|
2084
2376
|
const backupDirectory = await createOperationBackup({
|
|
2085
2377
|
onProgress,
|
|
@@ -2101,39 +2393,40 @@ export async function executeSessionDeletion({
|
|
|
2101
2393
|
|
|
2102
2394
|
try {
|
|
2103
2395
|
if (scope === "deep" && store.hasMemoryDatabase) {
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
);
|
|
2396
|
+
for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath]) {
|
|
2397
|
+
executeTransaction(databasePath, deleteStatements("stage1_outputs", "thread_id", plan.ids));
|
|
2398
|
+
}
|
|
2108
2399
|
}
|
|
2109
2400
|
if (scope === "deep" && store.hasGoalsDatabase) {
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2401
|
+
for (const databasePath of store.goalsDatabasePaths ?? [store.goalsDatabasePath]) {
|
|
2402
|
+
executeTransaction(databasePath, [
|
|
2403
|
+
...deleteStatements("thread_goal_continuation_deferrals", "thread_id", plan.ids),
|
|
2404
|
+
...deleteStatements("thread_goals", "thread_id", plan.ids),
|
|
2405
|
+
]);
|
|
2406
|
+
}
|
|
2114
2407
|
}
|
|
2115
2408
|
|
|
2116
2409
|
if (store.hasLogsDatabase) {
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
);
|
|
2410
|
+
for (const databasePath of store.logsDatabasePaths ?? [store.logsDatabasePath]) {
|
|
2411
|
+
executeTransaction(databasePath, deleteStatements("logs", "thread_id", plan.ids));
|
|
2412
|
+
}
|
|
2121
2413
|
}
|
|
2122
|
-
const
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
const
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2414
|
+
for (const { database, ids } of plan.stateTargets ?? (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({ database, ids: plan.ids }))) {
|
|
2415
|
+
const stateStatements = [];
|
|
2416
|
+
const hasSpawnEdges = stateSchema(database).hasSpawnEdges;
|
|
2417
|
+
for (const idBatch of batches(ids)) {
|
|
2418
|
+
const idPlaceholders = placeholders(idBatch);
|
|
2419
|
+
if (hasSpawnEdges) stateStatements.push({
|
|
2420
|
+
parameters: [...idBatch, ...idBatch],
|
|
2421
|
+
sql: `delete from thread_spawn_edges where parent_thread_id in (${idPlaceholders}) or child_thread_id in (${idPlaceholders})`,
|
|
2422
|
+
});
|
|
2423
|
+
stateStatements.push({
|
|
2424
|
+
parameters: idBatch,
|
|
2425
|
+
sql: `delete from threads where id in (${idPlaceholders})`,
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
executeTransaction(database.path, stateStatements);
|
|
2134
2429
|
}
|
|
2135
|
-
|
|
2136
|
-
executeTransaction(store.stateDatabasePath, stateStatements);
|
|
2137
2430
|
await reportProgress(onProgress, {
|
|
2138
2431
|
canCancel: false,
|
|
2139
2432
|
message: "Updating session records",
|
|
@@ -2241,6 +2534,55 @@ export async function deleteSessionDeletionBackup({ backupDirectory, codexHome }
|
|
|
2241
2534
|
return { backupDirectory: resolvedBackupDirectory };
|
|
2242
2535
|
}
|
|
2243
2536
|
|
|
2537
|
+
export async function listSessionDeletionBackups({ codexHome }) {
|
|
2538
|
+
const backupRoot = path.join(path.resolve(codexHome), "session-steward-backups");
|
|
2539
|
+
let entries;
|
|
2540
|
+
|
|
2541
|
+
try {
|
|
2542
|
+
entries = await fs.readdir(backupRoot, { withFileTypes: true });
|
|
2543
|
+
} catch (error) {
|
|
2544
|
+
if (error?.code === "ENOENT") return [];
|
|
2545
|
+
throw error;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
const backups = [];
|
|
2549
|
+
|
|
2550
|
+
for (const entry of entries) {
|
|
2551
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
2552
|
+
const backupDirectory = path.join(backupRoot, entry.name);
|
|
2553
|
+
let operation = null;
|
|
2554
|
+
|
|
2555
|
+
try {
|
|
2556
|
+
operation = await readJsonFile(path.join(backupDirectory, "operation.json"));
|
|
2557
|
+
} catch (error) {
|
|
2558
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
const [stats, measured] = await Promise.all([
|
|
2562
|
+
fs.stat(backupDirectory),
|
|
2563
|
+
measurePath(backupDirectory),
|
|
2564
|
+
]);
|
|
2565
|
+
const restorable = [2, 3].includes(operation?.version) &&
|
|
2566
|
+
Array.isArray(operation.files) &&
|
|
2567
|
+
operation.files.length > 0;
|
|
2568
|
+
|
|
2569
|
+
backups.push({
|
|
2570
|
+
backupDirectory,
|
|
2571
|
+
bytes: measured.bytes,
|
|
2572
|
+
createdAtMs: toTimestampMs(operation?.createdAtMs) || stats.mtimeMs,
|
|
2573
|
+
fileCount: measured.fileCount,
|
|
2574
|
+
id: entry.name,
|
|
2575
|
+
itemCount: Array.isArray(operation?.files) ? operation.files.length : measured.fileCount,
|
|
2576
|
+
providerId: "codex",
|
|
2577
|
+
restorable,
|
|
2578
|
+
scope: operation?.scope === "core" || operation?.scope === "deep" ? operation.scope : null,
|
|
2579
|
+
sessionCount: Array.isArray(operation?.ids) ? operation.ids.length : null,
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
return backups.sort((left, right) => right.createdAtMs - left.createdAtMs || left.id.localeCompare(right.id));
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2244
2586
|
async function atomicCopyFile(sourcePath, destinationPath) {
|
|
2245
2587
|
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
2246
2588
|
const temporaryPath = `${destinationPath}.session-steward-${process.pid}-${Date.now()}.tmp`;
|
|
@@ -2270,9 +2612,10 @@ export async function restoreSessionDeletionBackup({
|
|
|
2270
2612
|
const operationPath = path.join(resolvedBackupDirectory, "operation.json");
|
|
2271
2613
|
const operation = await readJsonFile(operationPath);
|
|
2272
2614
|
|
|
2273
|
-
if (operation?.version
|
|
2615
|
+
if (![2, 3].includes(operation?.version) || !Array.isArray(operation.files) || operation.files.length === 0) {
|
|
2274
2616
|
throw new Error("This backup cannot be restored automatically. Its files are still available for manual recovery.");
|
|
2275
2617
|
}
|
|
2618
|
+
const currentDatabasesBeforeRestore = getCodexPaths(codexHome, { refresh: true }).resolvedDatabases;
|
|
2276
2619
|
|
|
2277
2620
|
const files = operation.files.map((entry) => {
|
|
2278
2621
|
if (typeof entry?.backupPath !== "string" || typeof entry?.originalPath !== "string") {
|
|
@@ -2385,7 +2728,10 @@ export async function restoreSessionDeletionBackup({
|
|
|
2385
2728
|
progress: 100,
|
|
2386
2729
|
});
|
|
2387
2730
|
|
|
2731
|
+
const layoutChanged = operation.version === 3
|
|
2732
|
+
&& JSON.stringify(operation.resolvedDatabases ?? {}) !== JSON.stringify(currentDatabasesBeforeRestore);
|
|
2388
2733
|
return {
|
|
2734
|
+
note: layoutChanged ? "The Codex storage layout changed after this backup was created. The original files were restored to their recorded locations." : null,
|
|
2389
2735
|
restoredFileCount: files.length,
|
|
2390
2736
|
safetyBackupDirectory,
|
|
2391
2737
|
};
|
|
@@ -2401,17 +2747,22 @@ export async function restoreSessionDeletionBackup({
|
|
|
2401
2747
|
|
|
2402
2748
|
export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
2403
2749
|
const deletedIdSet = new Set(plan.ids);
|
|
2404
|
-
const remainingThreads =
|
|
2405
|
-
|
|
2406
|
-
);
|
|
2750
|
+
const remainingThreads = (store.stateDatabasePaths ?? [store.stateDatabasePath])
|
|
2751
|
+
.flatMap((databasePath) => findRowsForIds(databasePath, "threads", "id", plan.ids));
|
|
2407
2752
|
const remainingMemoryRecords = scope === "deep" && store.hasMemoryDatabase
|
|
2408
|
-
?
|
|
2753
|
+
? (store.memoryDatabasePaths ?? [store.memoryDatabasePath]).flatMap((databasePath) =>
|
|
2754
|
+
findRowsForIds(databasePath, "stage1_outputs", "thread_id", plan.ids))
|
|
2409
2755
|
: [];
|
|
2410
2756
|
const remainingGoalRecords = scope === "deep" && store.hasGoalsDatabase
|
|
2411
|
-
?
|
|
2757
|
+
? (store.goalsDatabasePaths ?? [store.goalsDatabasePath]).flatMap((databasePath) =>
|
|
2758
|
+
[
|
|
2759
|
+
...findRowsForIds(databasePath, "thread_goals", "thread_id", plan.ids),
|
|
2760
|
+
...findRowsForIds(databasePath, "thread_goal_continuation_deferrals", "thread_id", plan.ids),
|
|
2761
|
+
])
|
|
2412
2762
|
: [];
|
|
2413
2763
|
const remainingLogRecords = store.hasLogsDatabase
|
|
2414
|
-
?
|
|
2764
|
+
? (store.logsDatabasePaths ?? [store.logsDatabasePath]).flatMap((databasePath) =>
|
|
2765
|
+
findRowsForIds(databasePath, "logs", "thread_id", plan.ids, { limitOne: true }))
|
|
2415
2766
|
: [];
|
|
2416
2767
|
const [sessionIndexMatches, historyMatches] = await Promise.all([
|
|
2417
2768
|
inspectJsonlMatches(
|
|
@@ -2493,6 +2844,7 @@ export function formatSessionForJson(sessionRecord) {
|
|
|
2493
2844
|
rolloutMissing: sessionRecord.rolloutMissing,
|
|
2494
2845
|
rolloutPath: sessionRecord.rolloutPath,
|
|
2495
2846
|
titleSource: sessionRecord.titleSource,
|
|
2847
|
+
transcriptBytes: sessionRecord.transcriptBytes,
|
|
2496
2848
|
updatedAtMs: sessionRecord.updatedAtMs,
|
|
2497
2849
|
};
|
|
2498
2850
|
}
|