session-steward 0.1.1 → 0.3.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 +57 -0
- package/README.md +158 -104
- package/bin/session-steward-cli.mjs +49 -7
- package/bin/session-steward.mjs +3 -0
- package/dist/assets/index-CN9iax_v.css +2 -0
- package/dist/assets/index-fUX3qen0.js +9 -0
- package/dist/index.html +14 -3
- package/lib/cli.mjs +464 -61
- package/lib/providers/claude-code/index.mjs +7 -0
- package/lib/providers/claude-code/store.mjs +951 -0
- package/lib/providers/codex/index.mjs +8 -0
- package/lib/providers/codex/store.mjs +477 -64
- package/lib/providers/index.mjs +2 -1
- package/lib/server.mjs +316 -59
- package/lib/settings.mjs +39 -0
- package/lib/storage/files.mjs +39 -0
- package/package.json +19 -10
- package/dist/assets/index-DGVNvKX8.js +0 -9
- package/dist/assets/index-DTDZWQb9.css +0 -2
- package/docs/session-steward-overview.jpg +0 -0
|
@@ -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,
|
|
@@ -42,6 +43,27 @@ function normalizeDisplayName(value) {
|
|
|
42
43
|
return normalizeText(value);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function cleanDerivedTitle(value) {
|
|
47
|
+
const original = normalizeDisplayName(value);
|
|
48
|
+
|
|
49
|
+
if (!original) {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let cleaned = original.replace(/^\[\d+\]\s+(?:user|assistant):\s*/u, "");
|
|
54
|
+
const nextRoleMarker = cleaned.search(/\s\[\d+\]\s+(?:user|assistant):\s*/u);
|
|
55
|
+
|
|
56
|
+
if (nextRoleMarker >= 0) {
|
|
57
|
+
cleaned = cleaned.slice(0, nextRoleMarker);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cleaned = normalizeDisplayName(
|
|
61
|
+
cleaned.replace(/\[([^\[\]]+?)\]\([^()]*?\)/gu, "$1"),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return cleaned && /[\p{L}\p{N}]/u.test(cleaned) ? cleaned : original;
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
function toTimestampMs(value) {
|
|
46
68
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
47
69
|
return value;
|
|
@@ -139,6 +161,12 @@ async function pathExists(targetPath) {
|
|
|
139
161
|
}
|
|
140
162
|
}
|
|
141
163
|
|
|
164
|
+
function isContainedPath(rootDirectory, candidatePath) {
|
|
165
|
+
if (!candidatePath) return false;
|
|
166
|
+
const relativePath = path.relative(path.resolve(rootDirectory), path.resolve(candidatePath));
|
|
167
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
168
|
+
}
|
|
169
|
+
|
|
142
170
|
async function readFirstLine(filePath) {
|
|
143
171
|
const stream = createReadStream(filePath, {
|
|
144
172
|
encoding: "utf8",
|
|
@@ -362,7 +390,7 @@ function deriveDisplayName({
|
|
|
362
390
|
if (sessionIndexEntry?.threadName && (!sqliteTitle || sqliteTitleLooksPrompt)) {
|
|
363
391
|
return {
|
|
364
392
|
source: "session_index",
|
|
365
|
-
value: sessionIndexEntry.threadName,
|
|
393
|
+
value: cleanDerivedTitle(sessionIndexEntry.threadName),
|
|
366
394
|
};
|
|
367
395
|
}
|
|
368
396
|
|
|
@@ -372,21 +400,21 @@ function deriveDisplayName({
|
|
|
372
400
|
) {
|
|
373
401
|
return {
|
|
374
402
|
source: "transcript_thread_name",
|
|
375
|
-
value: transcriptFallback.latestThreadName,
|
|
403
|
+
value: cleanDerivedTitle(transcriptFallback.latestThreadName),
|
|
376
404
|
};
|
|
377
405
|
}
|
|
378
406
|
|
|
379
407
|
if (sqliteTitle) {
|
|
380
408
|
return {
|
|
381
409
|
source: "sqlite_title",
|
|
382
|
-
value: sqliteTitle,
|
|
410
|
+
value: cleanDerivedTitle(sqliteTitle),
|
|
383
411
|
};
|
|
384
412
|
}
|
|
385
413
|
|
|
386
414
|
if (sqliteFirstUserMessage) {
|
|
387
415
|
return {
|
|
388
416
|
source: "sqlite_first_user_message",
|
|
389
|
-
value: sqliteFirstUserMessage,
|
|
417
|
+
value: cleanDerivedTitle(sqliteFirstUserMessage),
|
|
390
418
|
};
|
|
391
419
|
}
|
|
392
420
|
|
|
@@ -395,14 +423,14 @@ function deriveDisplayName({
|
|
|
395
423
|
if (historyEntry?.text) {
|
|
396
424
|
return {
|
|
397
425
|
source: "history_first_user_message",
|
|
398
|
-
value: historyEntry.text,
|
|
426
|
+
value: cleanDerivedTitle(historyEntry.text),
|
|
399
427
|
};
|
|
400
428
|
}
|
|
401
429
|
|
|
402
430
|
if (transcriptFallback?.firstUserMessage) {
|
|
403
431
|
return {
|
|
404
432
|
source: "transcript_first_user_message",
|
|
405
|
-
value: transcriptFallback.firstUserMessage,
|
|
433
|
+
value: cleanDerivedTitle(transcriptFallback.firstUserMessage),
|
|
406
434
|
};
|
|
407
435
|
}
|
|
408
436
|
|
|
@@ -414,8 +442,11 @@ function deriveDisplayName({
|
|
|
414
442
|
|
|
415
443
|
function getCodexPaths(codexHomeInput) {
|
|
416
444
|
const codexHome = path.resolve(expandHome(codexHomeInput || "~/.codex"));
|
|
445
|
+
const archivedSessionsDirectory = path.join(codexHome, "archived_sessions");
|
|
446
|
+
const sessionsDirectory = path.join(codexHome, "sessions");
|
|
417
447
|
|
|
418
448
|
return {
|
|
449
|
+
archivedSessionsDirectory,
|
|
419
450
|
codexHome,
|
|
420
451
|
desktopStateBackupPath: path.join(codexHome, ".codex-global-state.json.bak"),
|
|
421
452
|
desktopStatePath: path.join(codexHome, ".codex-global-state.json"),
|
|
@@ -424,8 +455,9 @@ function getCodexPaths(codexHomeInput) {
|
|
|
424
455
|
logsDatabasePath: path.join(codexHome, "logs_2.sqlite"),
|
|
425
456
|
memoryDatabasePath: path.join(codexHome, "memories_1.sqlite"),
|
|
426
457
|
sessionIndexPath: path.join(codexHome, "session_index.jsonl"),
|
|
427
|
-
sessionsDirectory
|
|
458
|
+
sessionsDirectory,
|
|
428
459
|
stateDatabasePath: path.join(codexHome, "state_5.sqlite"),
|
|
460
|
+
transcriptDirectories: [sessionsDirectory, archivedSessionsDirectory],
|
|
429
461
|
};
|
|
430
462
|
}
|
|
431
463
|
|
|
@@ -442,7 +474,7 @@ const COMPATIBILITY_PROFILE = {
|
|
|
442
474
|
id: "local-store-2026-07",
|
|
443
475
|
builtFor: {
|
|
444
476
|
chatgptDesktop: ["26.727.40816"],
|
|
445
|
-
codexCli: ["0.144.1"],
|
|
477
|
+
codexCli: ["0.144.1", "0.146.0"],
|
|
446
478
|
},
|
|
447
479
|
};
|
|
448
480
|
|
|
@@ -584,21 +616,7 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
584
616
|
from thread_spawn_edges
|
|
585
617
|
`,
|
|
586
618
|
);
|
|
587
|
-
const transcriptHeaders =
|
|
588
|
-
|
|
589
|
-
for await (const transcriptFile of findTranscriptFiles(paths.sessionsDirectory)) {
|
|
590
|
-
try {
|
|
591
|
-
const header = await parseTranscriptHeader(transcriptFile);
|
|
592
|
-
|
|
593
|
-
const current = header?.id ? transcriptHeaders.get(header.id) : null;
|
|
594
|
-
|
|
595
|
-
if (header?.id && (!current || header.filePath > current.filePath)) {
|
|
596
|
-
transcriptHeaders.set(header.id, header);
|
|
597
|
-
}
|
|
598
|
-
} catch {
|
|
599
|
-
continue;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
619
|
+
const transcriptHeaders = await indexTranscriptHeaders(paths.transcriptDirectories);
|
|
602
620
|
|
|
603
621
|
const discoveryIds = new Set(threadRows.map((threadRow) => String(threadRow.id)));
|
|
604
622
|
|
|
@@ -698,7 +716,7 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
698
716
|
const record = {
|
|
699
717
|
agentNickname: transcriptHeader.agentNickname,
|
|
700
718
|
agentRole: transcriptHeader.agentRole,
|
|
701
|
-
archived:
|
|
719
|
+
archived: isContainedPath(paths.archivedSessionsDirectory, transcriptHeader.filePath),
|
|
702
720
|
childThreadIds: childIdsByParentId.get(transcriptId) ?? [],
|
|
703
721
|
createdAtMs: transcriptHeader.timestampMs,
|
|
704
722
|
cwd: transcriptHeader.cwd,
|
|
@@ -898,25 +916,118 @@ export async function assertDeepCleanupSupported({ codexHome }) {
|
|
|
898
916
|
|
|
899
917
|
const DEFAULT_PAGE_SIZE = 25;
|
|
900
918
|
const MAX_PAGE_SIZE = 100;
|
|
919
|
+
const OVERVIEW_STAT_BATCH_SIZE = 24;
|
|
920
|
+
const SESSION_SIZE_CACHE_TTL_MS = 45 * 1000;
|
|
921
|
+
const SESSION_SIZE_STAT_CONCURRENCY = 64;
|
|
901
922
|
const SUPPORTING_THREAD_PREFIX = "The following is the Codex agent history whose request action you are assessing";
|
|
923
|
+
const SESSION_CREATED_SQL = "coalesce(nullif(t.created_at_ms, 0), nullif(t.created_at, 0) * 1000, 0)";
|
|
924
|
+
const SESSION_UPDATED_SQL = "coalesce(nullif(t.updated_at_ms, 0), nullif(t.updated_at, 0) * 1000, 0)";
|
|
925
|
+
const SESSION_ACTIVITY_SQL = `coalesce(nullif(${SESSION_UPDATED_SQL}, 0), nullif(${SESSION_CREATED_SQL}, 0), 0)`;
|
|
926
|
+
const SESSION_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
|
|
927
|
+
const sessionSizeCache = new Map();
|
|
928
|
+
|
|
929
|
+
async function buildSessionSizeIndex(paths) {
|
|
930
|
+
const rows = queryRows(
|
|
931
|
+
paths.stateDatabasePath,
|
|
932
|
+
"select id, rollout_path from threads",
|
|
933
|
+
);
|
|
934
|
+
const sizes = new Map();
|
|
935
|
+
let nextIndex = 0;
|
|
936
|
+
|
|
937
|
+
const measureNext = async () => {
|
|
938
|
+
while (nextIndex < rows.length) {
|
|
939
|
+
const row = rows[nextIndex];
|
|
940
|
+
nextIndex += 1;
|
|
941
|
+
const id = String(row.id);
|
|
942
|
+
|
|
943
|
+
if (!row.rollout_path) {
|
|
944
|
+
sizes.set(id, null);
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
try {
|
|
949
|
+
const stats = await fs.stat(row.rollout_path);
|
|
950
|
+
sizes.set(id, stats.isFile() ? stats.size : null);
|
|
951
|
+
} catch {
|
|
952
|
+
sizes.set(id, null);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
await Promise.all(
|
|
958
|
+
Array.from(
|
|
959
|
+
{ length: Math.min(SESSION_SIZE_STAT_CONCURRENCY, rows.length) },
|
|
960
|
+
measureNext,
|
|
961
|
+
),
|
|
962
|
+
);
|
|
963
|
+
return sizes;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async function getSessionSizeIndex(paths, { refresh = false } = {}) {
|
|
967
|
+
const cached = sessionSizeCache.get(paths.codexHome);
|
|
968
|
+
|
|
969
|
+
if (!refresh && cached?.expiresAtMs > Date.now()) {
|
|
970
|
+
return cached.promise;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const promise = buildSessionSizeIndex(paths).catch((error) => {
|
|
974
|
+
if (sessionSizeCache.get(paths.codexHome)?.promise === promise) {
|
|
975
|
+
sessionSizeCache.delete(paths.codexHome);
|
|
976
|
+
}
|
|
977
|
+
throw error;
|
|
978
|
+
});
|
|
979
|
+
sessionSizeCache.set(paths.codexHome, {
|
|
980
|
+
expiresAtMs: Date.now() + SESSION_SIZE_CACHE_TTL_MS,
|
|
981
|
+
promise,
|
|
982
|
+
});
|
|
983
|
+
return promise;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function invalidateSessionCache({ codexHome }) {
|
|
987
|
+
sessionSizeCache.delete(getCodexPaths(codexHome).codexHome);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function compareSessionIdsBySize(leftId, rightId, sizes) {
|
|
991
|
+
const leftSize = sizes.get(leftId);
|
|
992
|
+
const rightSize = sizes.get(rightId);
|
|
993
|
+
const leftKnown = Number.isFinite(leftSize);
|
|
994
|
+
const rightKnown = Number.isFinite(rightSize);
|
|
995
|
+
|
|
996
|
+
if (leftKnown !== rightKnown) {
|
|
997
|
+
return leftKnown ? -1 : 1;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return (rightSize ?? 0) - (leftSize ?? 0) || leftId.localeCompare(rightId);
|
|
1001
|
+
}
|
|
902
1002
|
|
|
903
1003
|
function getSessionOrder(sort) {
|
|
904
|
-
const updated = "coalesce(t.updated_at_ms, t.updated_at * 1000, 0)";
|
|
905
|
-
const created = "coalesce(t.created_at_ms, t.created_at * 1000, 0)";
|
|
906
1004
|
const name = "lower(coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), t.id))";
|
|
907
1005
|
|
|
908
1006
|
return {
|
|
909
|
-
created: `${
|
|
910
|
-
cwd: `lower(coalesce(t.cwd, '')) asc, ${
|
|
911
|
-
name: `${name} asc, ${
|
|
912
|
-
updated: `${
|
|
913
|
-
}[sort] ?? `${
|
|
1007
|
+
created: `${SESSION_CREATED_SQL} desc, t.id asc`,
|
|
1008
|
+
cwd: `lower(coalesce(t.cwd, '')) asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
|
|
1009
|
+
name: `${name} asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
|
|
1010
|
+
updated: `${SESSION_ACTIVITY_SQL} desc, t.id asc`,
|
|
1011
|
+
}[sort] ?? `${SESSION_ACTIVITY_SQL} desc, t.id asc`;
|
|
914
1012
|
}
|
|
915
1013
|
|
|
916
|
-
function getSessionConditions({
|
|
1014
|
+
function getSessionConditions({
|
|
1015
|
+
archiveStatus,
|
|
1016
|
+
inactiveBeforeMs,
|
|
1017
|
+
includeInternals,
|
|
1018
|
+
includeSupporting,
|
|
1019
|
+
search,
|
|
1020
|
+
workspace,
|
|
1021
|
+
}) {
|
|
917
1022
|
const conditions = [];
|
|
918
1023
|
const parameters = [];
|
|
919
1024
|
|
|
1025
|
+
if (archiveStatus === "active") {
|
|
1026
|
+
conditions.push("coalesce(t.archived, 0) = 0");
|
|
1027
|
+
} else if (archiveStatus === "archived") {
|
|
1028
|
+
conditions.push("coalesce(t.archived, 0) <> 0");
|
|
1029
|
+
}
|
|
1030
|
+
|
|
920
1031
|
if (!includeInternals) {
|
|
921
1032
|
conditions.push(`not exists (
|
|
922
1033
|
select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id
|
|
@@ -928,6 +1039,16 @@ function getSessionConditions({ includeInternals, includeSupporting, search }) {
|
|
|
928
1039
|
parameters.push(`${SUPPORTING_THREAD_PREFIX}%`);
|
|
929
1040
|
}
|
|
930
1041
|
|
|
1042
|
+
if (Number.isFinite(inactiveBeforeMs) && inactiveBeforeMs > 0) {
|
|
1043
|
+
conditions.push(`${SESSION_ACTIVITY_SQL} > 0 and ${SESSION_ACTIVITY_SQL} <= ?`);
|
|
1044
|
+
parameters.push(Math.trunc(inactiveBeforeMs));
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (typeof workspace === "string") {
|
|
1048
|
+
conditions.push("coalesce(t.cwd, '') = ?");
|
|
1049
|
+
parameters.push(workspace);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
931
1052
|
const normalizedSearch = normalizeText(search).toLowerCase();
|
|
932
1053
|
|
|
933
1054
|
if (normalizedSearch) {
|
|
@@ -957,8 +1078,8 @@ const SESSION_COLUMNS = `
|
|
|
957
1078
|
t.agent_role,
|
|
958
1079
|
t.archived,
|
|
959
1080
|
t.is_pinned,
|
|
960
|
-
|
|
961
|
-
|
|
1081
|
+
${SESSION_CREATED_SQL} as created_at_ms,
|
|
1082
|
+
${SESSION_ACTIVITY_SQL} as updated_at_ms,
|
|
962
1083
|
(
|
|
963
1084
|
select edge.parent_thread_id
|
|
964
1085
|
from thread_spawn_edges edge
|
|
@@ -993,18 +1114,22 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
|
|
|
993
1114
|
const records = [];
|
|
994
1115
|
|
|
995
1116
|
for (const threadRow of threadRows) {
|
|
996
|
-
const title =
|
|
997
|
-
const firstUserMessage =
|
|
1117
|
+
const title = cleanDerivedTitle(threadRow.title ?? "");
|
|
1118
|
+
const firstUserMessage = cleanDerivedTitle(
|
|
1119
|
+
getMeaningfulUserText(threadRow.first_user_message ?? ""),
|
|
1120
|
+
);
|
|
998
1121
|
const displayName = title || firstUserMessage || `Untitled ${String(threadRow.id).slice(0, 8)}`;
|
|
999
1122
|
const rolloutPath = threadRow.rollout_path ?? "";
|
|
1000
1123
|
let transcriptHeader = null;
|
|
1124
|
+
let transcriptBytes = null;
|
|
1001
1125
|
|
|
1002
1126
|
if (rolloutPath) {
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1127
|
+
const [header, stats] = await Promise.all([
|
|
1128
|
+
parseTranscriptHeader(rolloutPath).catch(() => null),
|
|
1129
|
+
fs.stat(rolloutPath).catch(() => null),
|
|
1130
|
+
]);
|
|
1131
|
+
transcriptHeader = header;
|
|
1132
|
+
transcriptBytes = stats?.isFile() ? stats.size : null;
|
|
1008
1133
|
}
|
|
1009
1134
|
|
|
1010
1135
|
const parentThreadId = threadRow.parent_thread_id ?? transcriptHeader?.parentThreadId ?? null;
|
|
@@ -1030,6 +1155,7 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
|
|
|
1030
1155
|
rolloutPath,
|
|
1031
1156
|
title: threadRow.title ?? "",
|
|
1032
1157
|
titleSource: title ? "sqlite_title" : firstUserMessage ? "sqlite_first_user_message" : "fallback",
|
|
1158
|
+
transcriptBytes,
|
|
1033
1159
|
updatedAtMs: toTimestampMs(threadRow.updated_at_ms),
|
|
1034
1160
|
});
|
|
1035
1161
|
}
|
|
@@ -1061,19 +1187,21 @@ async function getStoreAvailability(paths) {
|
|
|
1061
1187
|
};
|
|
1062
1188
|
}
|
|
1063
1189
|
|
|
1064
|
-
async function indexTranscriptHeaders(
|
|
1190
|
+
async function indexTranscriptHeaders(transcriptDirectories) {
|
|
1065
1191
|
const headersById = new Map();
|
|
1066
1192
|
|
|
1067
|
-
for
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1193
|
+
for (const transcriptDirectory of transcriptDirectories) {
|
|
1194
|
+
for await (const transcriptFile of findTranscriptFiles(transcriptDirectory)) {
|
|
1195
|
+
try {
|
|
1196
|
+
const header = await parseTranscriptHeader(transcriptFile);
|
|
1197
|
+
const current = header?.id ? headersById.get(String(header.id)) : null;
|
|
1071
1198
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1199
|
+
if (header?.id && (!current || header.filePath > current.filePath)) {
|
|
1200
|
+
headersById.set(String(header.id), { ...header, id: String(header.id) });
|
|
1201
|
+
}
|
|
1202
|
+
} catch {
|
|
1203
|
+
continue;
|
|
1074
1204
|
}
|
|
1075
|
-
} catch {
|
|
1076
|
-
continue;
|
|
1077
1205
|
}
|
|
1078
1206
|
}
|
|
1079
1207
|
|
|
@@ -1126,7 +1254,7 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1126
1254
|
|
|
1127
1255
|
const [availability, transcriptHeaders] = await Promise.all([
|
|
1128
1256
|
getStoreAvailability(paths),
|
|
1129
|
-
indexTranscriptHeaders(paths.
|
|
1257
|
+
indexTranscriptHeaders(paths.transcriptDirectories),
|
|
1130
1258
|
]);
|
|
1131
1259
|
const transcriptChildrenByParentId = getTranscriptChildrenByParentId(transcriptHeaders);
|
|
1132
1260
|
const pendingIds = [...selectedIds];
|
|
@@ -1218,7 +1346,7 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1218
1346
|
recordsById.set(id, {
|
|
1219
1347
|
agentNickname: header.agentNickname,
|
|
1220
1348
|
agentRole: header.agentRole,
|
|
1221
|
-
archived:
|
|
1349
|
+
archived: isContainedPath(paths.archivedSessionsDirectory, header.filePath),
|
|
1222
1350
|
childThreadIds: [...(childIdsByParentId.get(id) ?? [])].sort(),
|
|
1223
1351
|
createdAtMs: header.timestampMs,
|
|
1224
1352
|
cwd: header.cwd,
|
|
@@ -1306,20 +1434,68 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1306
1434
|
}
|
|
1307
1435
|
|
|
1308
1436
|
export async function listSessions({
|
|
1437
|
+
archiveStatus = "all",
|
|
1309
1438
|
codexHome,
|
|
1439
|
+
inactiveBeforeMs = null,
|
|
1310
1440
|
includeInternals = false,
|
|
1311
1441
|
includeSupporting = false,
|
|
1312
1442
|
page = 1,
|
|
1313
1443
|
pageSize = DEFAULT_PAGE_SIZE,
|
|
1444
|
+
refresh = false,
|
|
1314
1445
|
search = "",
|
|
1315
1446
|
sort = "updated",
|
|
1447
|
+
workspace,
|
|
1316
1448
|
}) {
|
|
1317
1449
|
const paths = getCodexPaths(codexHome);
|
|
1318
1450
|
const boundedPageSize = Number.isFinite(pageSize)
|
|
1319
1451
|
? Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(pageSize)))
|
|
1320
1452
|
: DEFAULT_PAGE_SIZE;
|
|
1321
1453
|
const requestedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
|
|
1322
|
-
const
|
|
1454
|
+
const resolvedSort = SESSION_SORTS.has(sort) ? sort : "updated";
|
|
1455
|
+
const conditions = getSessionConditions({
|
|
1456
|
+
archiveStatus,
|
|
1457
|
+
inactiveBeforeMs,
|
|
1458
|
+
includeInternals,
|
|
1459
|
+
includeSupporting,
|
|
1460
|
+
search,
|
|
1461
|
+
workspace,
|
|
1462
|
+
});
|
|
1463
|
+
if (resolvedSort === "size") {
|
|
1464
|
+
const sizes = await getSessionSizeIndex(paths, { refresh });
|
|
1465
|
+
const matchingIds = queryRows(
|
|
1466
|
+
paths.stateDatabasePath,
|
|
1467
|
+
`select t.id from threads t ${conditions.sql}`,
|
|
1468
|
+
conditions.parameters,
|
|
1469
|
+
).map((row) => String(row.id));
|
|
1470
|
+
matchingIds.sort((left, right) => compareSessionIdsBySize(left, right, sizes));
|
|
1471
|
+
const total = matchingIds.length;
|
|
1472
|
+
const pageCount = Math.max(1, Math.ceil(total / boundedPageSize));
|
|
1473
|
+
const currentPage = Math.min(requestedPage, pageCount);
|
|
1474
|
+
const pageIds = matchingIds.slice(
|
|
1475
|
+
(currentPage - 1) * boundedPageSize,
|
|
1476
|
+
currentPage * boundedPageSize,
|
|
1477
|
+
);
|
|
1478
|
+
const threadRows = pageIds.length > 0
|
|
1479
|
+
? queryRows(
|
|
1480
|
+
paths.stateDatabasePath,
|
|
1481
|
+
`select ${SESSION_COLUMNS}
|
|
1482
|
+
from threads t
|
|
1483
|
+
where t.id in (${placeholders(pageIds)})`,
|
|
1484
|
+
pageIds,
|
|
1485
|
+
)
|
|
1486
|
+
: [];
|
|
1487
|
+
const rowsById = new Map(threadRows.map((row) => [String(row.id), row]));
|
|
1488
|
+
const orderedRows = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
|
|
1489
|
+
|
|
1490
|
+
return {
|
|
1491
|
+
page: currentPage,
|
|
1492
|
+
pageCount,
|
|
1493
|
+
pageSize: boundedPageSize,
|
|
1494
|
+
records: await formatPagedThreadRows(paths.stateDatabasePath, orderedRows),
|
|
1495
|
+
total,
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1323
1499
|
const countRow = queryRows(
|
|
1324
1500
|
paths.stateDatabasePath,
|
|
1325
1501
|
`select count(*) as count from threads t ${conditions.sql}`,
|
|
@@ -1333,7 +1509,7 @@ export async function listSessions({
|
|
|
1333
1509
|
`select ${SESSION_COLUMNS}
|
|
1334
1510
|
from threads t
|
|
1335
1511
|
${conditions.sql}
|
|
1336
|
-
order by ${getSessionOrder(
|
|
1512
|
+
order by ${getSessionOrder(resolvedSort)}
|
|
1337
1513
|
limit ? offset ?`,
|
|
1338
1514
|
[...conditions.parameters, boundedPageSize, (currentPage - 1) * boundedPageSize],
|
|
1339
1515
|
);
|
|
@@ -1347,6 +1523,131 @@ export async function listSessions({
|
|
|
1347
1523
|
};
|
|
1348
1524
|
}
|
|
1349
1525
|
|
|
1526
|
+
async function measureTranscriptStorage(transcriptDirectories) {
|
|
1527
|
+
let transcriptBytes = 0;
|
|
1528
|
+
let transcriptFileCount = 0;
|
|
1529
|
+
let unreadableFileCount = 0;
|
|
1530
|
+
let pendingPaths = [];
|
|
1531
|
+
|
|
1532
|
+
const measurePendingPaths = async () => {
|
|
1533
|
+
const pathsToMeasure = pendingPaths;
|
|
1534
|
+
pendingPaths = [];
|
|
1535
|
+
const results = await Promise.all(pathsToMeasure.map(async (filePath) => {
|
|
1536
|
+
try {
|
|
1537
|
+
return await fs.stat(filePath);
|
|
1538
|
+
} catch {
|
|
1539
|
+
return null;
|
|
1540
|
+
}
|
|
1541
|
+
}));
|
|
1542
|
+
|
|
1543
|
+
for (const stats of results) {
|
|
1544
|
+
if (!stats?.isFile()) {
|
|
1545
|
+
unreadableFileCount += 1;
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
transcriptBytes += stats.size;
|
|
1550
|
+
transcriptFileCount += 1;
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1554
|
+
for (const transcriptDirectory of transcriptDirectories) {
|
|
1555
|
+
for await (const transcriptPath of findTranscriptFiles(transcriptDirectory)) {
|
|
1556
|
+
pendingPaths.push(transcriptPath);
|
|
1557
|
+
|
|
1558
|
+
if (pendingPaths.length >= OVERVIEW_STAT_BATCH_SIZE) {
|
|
1559
|
+
await measurePendingPaths();
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
if (pendingPaths.length > 0) {
|
|
1565
|
+
await measurePendingPaths();
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
return {
|
|
1569
|
+
transcriptBytes,
|
|
1570
|
+
transcriptFileCount,
|
|
1571
|
+
unreadableFileCount,
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
export async function getSessionOverview({ codexHome, refresh = false }) {
|
|
1576
|
+
const paths = getCodexPaths(codexHome);
|
|
1577
|
+
const supportingPattern = `${SUPPORTING_THREAD_PREFIX}%`;
|
|
1578
|
+
const overviewRow = queryRows(
|
|
1579
|
+
paths.stateDatabasePath,
|
|
1580
|
+
`select
|
|
1581
|
+
count(*) as total,
|
|
1582
|
+
coalesce(sum(case when coalesce(t.archived, 0) <> 0 then 1 else 0 end), 0) as archived_count,
|
|
1583
|
+
coalesce(sum(case when coalesce(t.archived, 0) = 0 then 1 else 0 end), 0) as active_count,
|
|
1584
|
+
count(children.child_thread_id) as subagent_count,
|
|
1585
|
+
coalesce(sum(case
|
|
1586
|
+
when children.child_thread_id is null
|
|
1587
|
+
and not (coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ?)
|
|
1588
|
+
then 1
|
|
1589
|
+
else 0
|
|
1590
|
+
end), 0) as primary_session_count,
|
|
1591
|
+
coalesce(sum(case
|
|
1592
|
+
when coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ? then 1
|
|
1593
|
+
else 0
|
|
1594
|
+
end), 0) as supporting_count,
|
|
1595
|
+
coalesce(sum(case when ${SESSION_ACTIVITY_SQL} = 0 then 1 else 0 end), 0) as unknown_activity_count
|
|
1596
|
+
from threads t
|
|
1597
|
+
left join (
|
|
1598
|
+
select distinct child_thread_id from thread_spawn_edges
|
|
1599
|
+
) children on children.child_thread_id = t.id`,
|
|
1600
|
+
[supportingPattern, supportingPattern],
|
|
1601
|
+
)[0] ?? {};
|
|
1602
|
+
const workspaceSessionRows = queryRows(
|
|
1603
|
+
paths.stateDatabasePath,
|
|
1604
|
+
`select
|
|
1605
|
+
t.id,
|
|
1606
|
+
coalesce(t.cwd, '') as path,
|
|
1607
|
+
${SESSION_ACTIVITY_SQL} as last_activity_at_ms
|
|
1608
|
+
from threads t`,
|
|
1609
|
+
);
|
|
1610
|
+
const [sizes, storage] = await Promise.all([
|
|
1611
|
+
getSessionSizeIndex(paths, { refresh }),
|
|
1612
|
+
measureTranscriptStorage(paths.transcriptDirectories),
|
|
1613
|
+
]);
|
|
1614
|
+
const workspaces = new Map();
|
|
1615
|
+
|
|
1616
|
+
for (const row of workspaceSessionRows) {
|
|
1617
|
+
const workspacePath = row.path ?? "";
|
|
1618
|
+
const current = workspaces.get(workspacePath) ?? {
|
|
1619
|
+
lastActivityAtMs: 0,
|
|
1620
|
+
path: workspacePath,
|
|
1621
|
+
sessionCount: 0,
|
|
1622
|
+
transcriptBytes: 0,
|
|
1623
|
+
};
|
|
1624
|
+
current.lastActivityAtMs = Math.max(
|
|
1625
|
+
current.lastActivityAtMs,
|
|
1626
|
+
toTimestampMs(row.last_activity_at_ms),
|
|
1627
|
+
);
|
|
1628
|
+
current.sessionCount += 1;
|
|
1629
|
+
const transcriptBytes = sizes.get(String(row.id));
|
|
1630
|
+
if (Number.isFinite(transcriptBytes)) current.transcriptBytes += transcriptBytes;
|
|
1631
|
+
workspaces.set(workspacePath, current);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
return {
|
|
1635
|
+
activeSessionCount: Number(overviewRow.active_count ?? 0),
|
|
1636
|
+
archivedSessionCount: Number(overviewRow.archived_count ?? 0),
|
|
1637
|
+
calculatedAtMs: Date.now(),
|
|
1638
|
+
primarySessionCount: Number(overviewRow.primary_session_count ?? 0),
|
|
1639
|
+
sessionCount: Number(overviewRow.total ?? 0),
|
|
1640
|
+
subagentCount: Number(overviewRow.subagent_count ?? 0),
|
|
1641
|
+
supportingCount: Number(overviewRow.supporting_count ?? 0),
|
|
1642
|
+
unknownActivityCount: Number(overviewRow.unknown_activity_count ?? 0),
|
|
1643
|
+
workspaces: [...workspaces.values()].sort((left, right) =>
|
|
1644
|
+
Number(!left.path) - Number(!right.path)
|
|
1645
|
+
|| right.lastActivityAtMs - left.lastActivityAtMs
|
|
1646
|
+
|| left.path.localeCompare(right.path)),
|
|
1647
|
+
...storage,
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1350
1651
|
export async function getSessionRecord({ codexHome, id }) {
|
|
1351
1652
|
const paths = getCodexPaths(codexHome);
|
|
1352
1653
|
const rows = queryRows(
|
|
@@ -1359,6 +1660,7 @@ export async function getSessionRecord({ codexHome, id }) {
|
|
|
1359
1660
|
}
|
|
1360
1661
|
|
|
1361
1662
|
export function filterAndSortSessions({
|
|
1663
|
+
archiveStatus = "all",
|
|
1362
1664
|
includeInternals,
|
|
1363
1665
|
records,
|
|
1364
1666
|
search,
|
|
@@ -1366,6 +1668,14 @@ export function filterAndSortSessions({
|
|
|
1366
1668
|
}) {
|
|
1367
1669
|
const normalizedSearch = normalizeText(search).toLowerCase();
|
|
1368
1670
|
const filteredRecords = records.filter((record) => {
|
|
1671
|
+
if (archiveStatus === "active" && record.archived) {
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
if (archiveStatus === "archived" && !record.archived) {
|
|
1676
|
+
return false;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1369
1679
|
if (!includeInternals && record.isSubagent) {
|
|
1370
1680
|
return false;
|
|
1371
1681
|
}
|
|
@@ -1394,6 +1704,12 @@ export function filterAndSortSessions({
|
|
|
1394
1704
|
name: (left, right) =>
|
|
1395
1705
|
left.displayName.localeCompare(right.displayName) ||
|
|
1396
1706
|
right.updatedAtMs - left.updatedAtMs,
|
|
1707
|
+
size: (left, right) => {
|
|
1708
|
+
const leftKnown = Number.isFinite(left.transcriptBytes);
|
|
1709
|
+
const rightKnown = Number.isFinite(right.transcriptBytes);
|
|
1710
|
+
if (leftKnown !== rightKnown) return leftKnown ? -1 : 1;
|
|
1711
|
+
return (right.transcriptBytes ?? 0) - (left.transcriptBytes ?? 0);
|
|
1712
|
+
},
|
|
1397
1713
|
updated: (left, right) =>
|
|
1398
1714
|
right.updatedAtMs - left.updatedAtMs || left.displayName.localeCompare(right.displayName),
|
|
1399
1715
|
};
|
|
@@ -1477,6 +1793,33 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1477
1793
|
.map((record) => record.rolloutPath)
|
|
1478
1794
|
.filter(Boolean);
|
|
1479
1795
|
const deletionIdSet = new Set(deletionIds);
|
|
1796
|
+
const requestedIdSet = new Set(recordIds);
|
|
1797
|
+
const newestLinkedActivityAtMs = selectedRecords.reduce((newest, record) => {
|
|
1798
|
+
if (requestedIdSet.has(record.id)) {
|
|
1799
|
+
return newest;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
return Math.max(newest, record.updatedAtMs ?? 0);
|
|
1803
|
+
}, 0);
|
|
1804
|
+
let transcriptBytes = 0;
|
|
1805
|
+
let transcriptFileCount = 0;
|
|
1806
|
+
|
|
1807
|
+
for (const transcriptBatch of batches([...new Set(transcriptPaths)], OVERVIEW_STAT_BATCH_SIZE)) {
|
|
1808
|
+
const results = await Promise.all(transcriptBatch.map(async (transcriptPath) => {
|
|
1809
|
+
try {
|
|
1810
|
+
return await fs.stat(transcriptPath);
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
if (error?.code === "ENOENT") return null;
|
|
1813
|
+
throw error;
|
|
1814
|
+
}
|
|
1815
|
+
}));
|
|
1816
|
+
|
|
1817
|
+
for (const stats of results) {
|
|
1818
|
+
if (!stats?.isFile()) continue;
|
|
1819
|
+
transcriptBytes += stats.size;
|
|
1820
|
+
transcriptFileCount += 1;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1480
1823
|
const [historyMatches, sessionIndexMatches] = await Promise.all([
|
|
1481
1824
|
inspectJsonlMatches(
|
|
1482
1825
|
store.historyPath,
|
|
@@ -1522,9 +1865,12 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1522
1865
|
logRowCount,
|
|
1523
1866
|
memoryRowCount,
|
|
1524
1867
|
missingTranscriptPaths,
|
|
1868
|
+
newestLinkedActivityAtMs,
|
|
1525
1869
|
records: selectedRecords,
|
|
1526
1870
|
sessionIndexMatchCount: sessionIndexMatches.count,
|
|
1527
1871
|
spawnEdgeCount,
|
|
1872
|
+
transcriptBytes,
|
|
1873
|
+
transcriptFileCount,
|
|
1528
1874
|
transcriptPaths,
|
|
1529
1875
|
};
|
|
1530
1876
|
}
|
|
@@ -1532,19 +1878,19 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1532
1878
|
const BACKUP_MINIMUM_RESERVE_BYTES = 1024 * 1024;
|
|
1533
1879
|
const BACKUP_RESERVE_RATIO = 0.05;
|
|
1534
1880
|
|
|
1535
|
-
function getBackupSourcePaths({ plan, store }) {
|
|
1881
|
+
function getBackupSourcePaths({ plan, scope, store }) {
|
|
1536
1882
|
const databasePaths = [
|
|
1537
1883
|
store.stateDatabasePath,
|
|
1538
1884
|
store.hasLogsDatabase ? store.logsDatabasePath : null,
|
|
1539
|
-
store.hasMemoryDatabase ? store.memoryDatabasePath : null,
|
|
1540
|
-
store.hasGoalsDatabase ? store.goalsDatabasePath : null,
|
|
1885
|
+
scope === "deep" && store.hasMemoryDatabase ? store.memoryDatabasePath : null,
|
|
1886
|
+
scope === "deep" && store.hasGoalsDatabase ? store.goalsDatabasePath : null,
|
|
1541
1887
|
].filter(Boolean);
|
|
1542
1888
|
|
|
1543
1889
|
return [
|
|
1544
1890
|
store.historyPath,
|
|
1545
1891
|
store.sessionIndexPath,
|
|
1546
|
-
store.hasDesktopState ? store.desktopStatePath : null,
|
|
1547
|
-
store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
|
|
1892
|
+
scope === "deep" && store.hasDesktopState ? store.desktopStatePath : null,
|
|
1893
|
+
scope === "deep" && store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
|
|
1548
1894
|
...plan.transcriptPaths,
|
|
1549
1895
|
...databasePaths.flatMap((databasePath) => [
|
|
1550
1896
|
databasePath,
|
|
@@ -1630,9 +1976,9 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
1630
1976
|
return hash.digest("hex");
|
|
1631
1977
|
}
|
|
1632
1978
|
|
|
1633
|
-
async function estimateBackupBytes({ plan, store }) {
|
|
1979
|
+
async function estimateBackupBytes({ plan, scope, store }) {
|
|
1634
1980
|
let sourceBytes = 0;
|
|
1635
|
-
const uniquePaths = new Set(getBackupSourcePaths({ plan, store }));
|
|
1981
|
+
const uniquePaths = new Set(getBackupSourcePaths({ plan, scope, store }));
|
|
1636
1982
|
|
|
1637
1983
|
for (const sourcePath of uniquePaths) {
|
|
1638
1984
|
try {
|
|
@@ -1688,7 +2034,7 @@ function formatBytes(bytes) {
|
|
|
1688
2034
|
return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`;
|
|
1689
2035
|
}
|
|
1690
2036
|
|
|
1691
|
-
export async function preflightSessionDeletion({ availableDiskBytes, plan, store }) {
|
|
2037
|
+
export async function preflightSessionDeletion({ availableDiskBytes, plan, scope = "deep", store }) {
|
|
1692
2038
|
const requiredPaths = [store.stateDatabasePath, store.sessionIndexPath, store.historyPath];
|
|
1693
2039
|
const missingRequiredPaths = [];
|
|
1694
2040
|
|
|
@@ -1708,7 +2054,7 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, store
|
|
|
1708
2054
|
store.hasDesktopStateBackup ? readJsonFile(store.desktopStateBackupPath) : null,
|
|
1709
2055
|
]);
|
|
1710
2056
|
const [desktopState, desktopStateBackup] = desktopStates;
|
|
1711
|
-
const backupEstimate = await estimateBackupBytes({ plan, store });
|
|
2057
|
+
const backupEstimate = await estimateBackupBytes({ plan, scope, store });
|
|
1712
2058
|
const diskCapacityBytes = availableDiskBytes ?? await getAvailableDiskBytes(store.codexHome);
|
|
1713
2059
|
|
|
1714
2060
|
if (diskCapacityBytes < backupEstimate.estimatedBackupBytes) {
|
|
@@ -1898,7 +2244,7 @@ export async function executeSessionDeletion({
|
|
|
1898
2244
|
});
|
|
1899
2245
|
if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
|
|
1900
2246
|
const deletedIdSet = new Set(plan.ids);
|
|
1901
|
-
const preflight = await preflightSessionDeletion({ plan, store });
|
|
2247
|
+
const preflight = await preflightSessionDeletion({ plan, scope, store });
|
|
1902
2248
|
if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
|
|
1903
2249
|
const backupDirectory = await createOperationBackup({
|
|
1904
2250
|
onProgress,
|
|
@@ -2043,6 +2389,72 @@ function resolveContainedPath(rootDirectory, candidatePath) {
|
|
|
2043
2389
|
return resolved;
|
|
2044
2390
|
}
|
|
2045
2391
|
|
|
2392
|
+
export async function deleteSessionDeletionBackup({ backupDirectory, codexHome }) {
|
|
2393
|
+
const backupRoot = path.join(codexHome, "session-steward-backups");
|
|
2394
|
+
const resolvedBackupDirectory = resolveContainedPath(backupRoot, backupDirectory);
|
|
2395
|
+
|
|
2396
|
+
if (resolvedBackupDirectory === path.resolve(backupRoot)) {
|
|
2397
|
+
throw new Error("Choose one recovery backup to delete.");
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
await fs.rm(resolvedBackupDirectory, {
|
|
2401
|
+
force: true,
|
|
2402
|
+
maxRetries: 3,
|
|
2403
|
+
recursive: true,
|
|
2404
|
+
retryDelay: 25,
|
|
2405
|
+
});
|
|
2406
|
+
return { backupDirectory: resolvedBackupDirectory };
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
export async function listSessionDeletionBackups({ codexHome }) {
|
|
2410
|
+
const backupRoot = path.join(path.resolve(codexHome), "session-steward-backups");
|
|
2411
|
+
let entries;
|
|
2412
|
+
|
|
2413
|
+
try {
|
|
2414
|
+
entries = await fs.readdir(backupRoot, { withFileTypes: true });
|
|
2415
|
+
} catch (error) {
|
|
2416
|
+
if (error?.code === "ENOENT") return [];
|
|
2417
|
+
throw error;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const backups = [];
|
|
2421
|
+
|
|
2422
|
+
for (const entry of entries) {
|
|
2423
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
2424
|
+
const backupDirectory = path.join(backupRoot, entry.name);
|
|
2425
|
+
let operation = null;
|
|
2426
|
+
|
|
2427
|
+
try {
|
|
2428
|
+
operation = await readJsonFile(path.join(backupDirectory, "operation.json"));
|
|
2429
|
+
} catch (error) {
|
|
2430
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
const [stats, measured] = await Promise.all([
|
|
2434
|
+
fs.stat(backupDirectory),
|
|
2435
|
+
measurePath(backupDirectory),
|
|
2436
|
+
]);
|
|
2437
|
+
const restorable = operation?.version === 2 &&
|
|
2438
|
+
Array.isArray(operation.files) &&
|
|
2439
|
+
operation.files.length > 0;
|
|
2440
|
+
|
|
2441
|
+
backups.push({
|
|
2442
|
+
backupDirectory,
|
|
2443
|
+
bytes: measured.bytes,
|
|
2444
|
+
createdAtMs: toTimestampMs(operation?.createdAtMs) || stats.mtimeMs,
|
|
2445
|
+
fileCount: measured.fileCount,
|
|
2446
|
+
id: entry.name,
|
|
2447
|
+
itemCount: Array.isArray(operation?.files) ? operation.files.length : measured.fileCount,
|
|
2448
|
+
providerId: "codex",
|
|
2449
|
+
restorable,
|
|
2450
|
+
scope: operation?.scope === "core" || operation?.scope === "deep" ? operation.scope : null,
|
|
2451
|
+
sessionCount: Array.isArray(operation?.ids) ? operation.ids.length : null,
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
return backups.sort((left, right) => right.createdAtMs - left.createdAtMs || left.id.localeCompare(right.id));
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2046
2458
|
async function atomicCopyFile(sourcePath, destinationPath) {
|
|
2047
2459
|
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
2048
2460
|
const temporaryPath = `${destinationPath}.session-steward-${process.pid}-${Date.now()}.tmp`;
|
|
@@ -2295,6 +2707,7 @@ export function formatSessionForJson(sessionRecord) {
|
|
|
2295
2707
|
rolloutMissing: sessionRecord.rolloutMissing,
|
|
2296
2708
|
rolloutPath: sessionRecord.rolloutPath,
|
|
2297
2709
|
titleSource: sessionRecord.titleSource,
|
|
2710
|
+
transcriptBytes: sessionRecord.transcriptBytes,
|
|
2298
2711
|
updatedAtMs: sessionRecord.updatedAtMs,
|
|
2299
2712
|
};
|
|
2300
2713
|
}
|