session-steward 0.10.2 → 0.11.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 +15 -0
- package/README.md +17 -1
- package/bin/session-steward-cli.mjs +1 -0
- package/bin/session-steward-scheduler.mjs +5 -1
- package/dist/assets/index-BAo7Iw_z.js +9 -0
- package/dist/assets/index-DQxHKbAB.css +2 -0
- package/dist/index.html +2 -2
- package/lib/cleanup-schedules.mjs +38 -18
- package/lib/cli.mjs +81 -2
- package/lib/mcp.mjs +82 -13
- package/lib/providers/claude-code/process-state.mjs +73 -0
- package/lib/providers/claude-code/store.mjs +196 -33
- package/lib/providers/codex/database-families.mjs +2 -2
- package/lib/providers/codex/desktop-catalog.mjs +179 -0
- package/lib/providers/codex/store.mjs +31 -4
- package/lib/server.mjs +90 -5
- package/lib/session-cleanup.mjs +230 -53
- package/lib/session-protections.mjs +467 -0
- package/lib/storage/jsonl.mjs +2 -1
- package/package.json +2 -1
- package/dist/assets/index-BDiEQG6G.js +0 -9
- package/dist/assets/index-CXq8Tw8T.css +0 -2
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
resolveCodexDatabases,
|
|
28
28
|
} from "./database-families.mjs";
|
|
29
29
|
import { expandHomePath } from "../../platform.mjs";
|
|
30
|
+
import {
|
|
31
|
+
deleteDesktopCatalogs,
|
|
32
|
+
readDesktopCatalogs,
|
|
33
|
+
restoreDesktopCatalogs,
|
|
34
|
+
validateDesktopCatalogBackup,
|
|
35
|
+
} from "./desktop-catalog.mjs";
|
|
30
36
|
|
|
31
37
|
function normalizeText(value) {
|
|
32
38
|
if (typeof value !== "string") {
|
|
@@ -1616,6 +1622,7 @@ export async function listSessions({
|
|
|
1616
1622
|
sort = "updated",
|
|
1617
1623
|
workspace,
|
|
1618
1624
|
forceUnion = false,
|
|
1625
|
+
recordFilter = null,
|
|
1619
1626
|
}) {
|
|
1620
1627
|
const paths = getCodexPaths(codexHome, { refresh });
|
|
1621
1628
|
const boundedPageSize = Number.isFinite(pageSize)
|
|
@@ -1628,7 +1635,7 @@ export async function listSessions({
|
|
|
1628
1635
|
? Math.trunc(minimumTranscriptBytes)
|
|
1629
1636
|
: null;
|
|
1630
1637
|
const needsSizeIndex = resolvedSort === "size" || resolvedMinimumTranscriptBytes !== null;
|
|
1631
|
-
if (paths.stateDatabases.length === 1 && !needsSizeIndex && !forceUnion) {
|
|
1638
|
+
if (paths.stateDatabases.length === 1 && !needsSizeIndex && !forceUnion && typeof recordFilter !== "function") {
|
|
1632
1639
|
const database = paths.stateDatabases[0];
|
|
1633
1640
|
const conditions = getSessionConditions(database, {
|
|
1634
1641
|
archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
|
|
@@ -1662,7 +1669,7 @@ export async function listSessions({
|
|
|
1662
1669
|
|
|
1663
1670
|
const ordered = [];
|
|
1664
1671
|
const seenIds = paths.stateDatabases.length > 1 ? new Set() : null;
|
|
1665
|
-
const compactSizeIds = resolvedSort === "size" && paths.stateDatabases.length === 1;
|
|
1672
|
+
const compactSizeIds = resolvedSort === "size" && paths.stateDatabases.length === 1 && typeof recordFilter !== "function";
|
|
1666
1673
|
const sizes = needsSizeIndex ? await getSessionSizeIndex(paths, { refresh }) : null;
|
|
1667
1674
|
let compareSortRows = null;
|
|
1668
1675
|
for (const database of paths.stateDatabases) {
|
|
@@ -1670,16 +1677,22 @@ export async function listSessions({
|
|
|
1670
1677
|
archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
|
|
1671
1678
|
});
|
|
1672
1679
|
const sessionSort = resolvedSort === "size" ? null : getSessionSort(resolvedSort, database);
|
|
1680
|
+
const filterSelection = typeof recordFilter === "function"
|
|
1681
|
+
? `, ${stateSchema(database).columns.has("cwd") ? "coalesce(t.cwd, '')" : "''"} as filter_cwd`
|
|
1682
|
+
: "";
|
|
1673
1683
|
compareSortRows ??= sessionSort?.compare ?? null;
|
|
1674
1684
|
const rows = queryRows(
|
|
1675
1685
|
database.path,
|
|
1676
|
-
`select t.id${sessionSort ? `, ${sessionSort.selection}` : ""} from threads t ${conditions.sql}`,
|
|
1686
|
+
`select t.id${sessionSort ? `, ${sessionSort.selection}` : ""}${filterSelection} from threads t ${conditions.sql}`,
|
|
1677
1687
|
conditions.parameters,
|
|
1678
1688
|
);
|
|
1679
1689
|
for (const row of rows) {
|
|
1680
1690
|
const id = String(row.id);
|
|
1681
1691
|
if (seenIds?.has(id)) continue;
|
|
1682
1692
|
seenIds?.add(id);
|
|
1693
|
+
if (typeof recordFilter === "function" && !recordFilter({ cwd: String(row.filter_cwd || ""), id })) {
|
|
1694
|
+
continue;
|
|
1695
|
+
}
|
|
1683
1696
|
if (resolvedMinimumTranscriptBytes !== null) {
|
|
1684
1697
|
const transcriptBytes = sizes.get(id);
|
|
1685
1698
|
if (!Number.isFinite(transcriptBytes) || transcriptBytes < resolvedMinimumTranscriptBytes) {
|
|
@@ -2349,6 +2362,7 @@ async function getPathFingerprint(filePath) {
|
|
|
2349
2362
|
export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
2350
2363
|
const hash = createHash("sha256");
|
|
2351
2364
|
hash.update(`${store.codexHome}\0${scope}\0`);
|
|
2365
|
+
hash.update(`desktop-catalogs\0${JSON.stringify(readDesktopCatalogs(store.codexHome, plan.ids))}\0`);
|
|
2352
2366
|
|
|
2353
2367
|
const counts = [
|
|
2354
2368
|
plan.dynamicToolRowCount,
|
|
@@ -2467,7 +2481,7 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
2467
2481
|
}
|
|
2468
2482
|
|
|
2469
2483
|
async function estimateBackupBytes({ plan, scope, store }) {
|
|
2470
|
-
let sourceBytes =
|
|
2484
|
+
let sourceBytes = Buffer.byteLength(JSON.stringify(readDesktopCatalogs(store.codexHome, plan.ids), null, 2));
|
|
2471
2485
|
const uniquePaths = new Set(getBackupSourcePaths({ plan, scope, store }));
|
|
2472
2486
|
|
|
2473
2487
|
for (const sourcePath of uniquePaths) {
|
|
@@ -2685,6 +2699,8 @@ function formatBytes(bytes) {
|
|
|
2685
2699
|
}
|
|
2686
2700
|
|
|
2687
2701
|
export async function preflightSessionDeletion({ availableDiskBytes, plan, scope = "deep", store }) {
|
|
2702
|
+
// Fail on an unknown app catalogue before backing up or mutating sessions.
|
|
2703
|
+
readDesktopCatalogs(store.codexHome, plan.ids);
|
|
2688
2704
|
const requiredPaths = [store.stateDatabasePath, store.sessionIndexPath, store.historyPath];
|
|
2689
2705
|
const missingRequiredPaths = [];
|
|
2690
2706
|
|
|
@@ -2865,6 +2881,7 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
|
|
|
2865
2881
|
files,
|
|
2866
2882
|
profileId: CODEX_DATABASE_PROFILE.id,
|
|
2867
2883
|
resolvedDatabases: store.resolvedDatabases ?? {},
|
|
2884
|
+
desktopCatalogs: readDesktopCatalogs(store.codexHome, plan.ids),
|
|
2868
2885
|
compatibilityStatus: (await diagnoseStorageCompatibility({ codexHome: store.codexHome })).status,
|
|
2869
2886
|
}, null, 2)}\n`,
|
|
2870
2887
|
);
|
|
@@ -2941,6 +2958,8 @@ export async function executeSessionDeletion({
|
|
|
2941
2958
|
});
|
|
2942
2959
|
|
|
2943
2960
|
try {
|
|
2961
|
+
const operation = await readJsonFile(path.join(backupDirectory, "operation.json"));
|
|
2962
|
+
deleteDesktopCatalogs(store.codexHome, plan.ids, operation.desktopCatalogs);
|
|
2944
2963
|
if (scope === "deep" && store.hasMemoryDatabase) {
|
|
2945
2964
|
for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath]) {
|
|
2946
2965
|
executeTransaction(
|
|
@@ -3188,6 +3207,8 @@ export async function restoreSessionDeletionBackup({
|
|
|
3188
3207
|
throw new Error("This backup cannot be restored automatically. Its files are still available for manual recovery.");
|
|
3189
3208
|
}
|
|
3190
3209
|
const currentDatabasesBeforeRestore = getCodexPaths(codexHome, { refresh: true }).resolvedDatabases;
|
|
3210
|
+
const desktopCatalogs = operation.desktopCatalogs ?? [];
|
|
3211
|
+
validateDesktopCatalogBackup(codexHome, operation.ids ?? [], desktopCatalogs);
|
|
3191
3212
|
|
|
3192
3213
|
const files = operation.files.map((entry) => {
|
|
3193
3214
|
if (typeof entry?.backupPath !== "string" || typeof entry?.originalPath !== "string") {
|
|
@@ -3293,6 +3314,8 @@ export async function restoreSessionDeletionBackup({
|
|
|
3293
3314
|
}
|
|
3294
3315
|
}
|
|
3295
3316
|
|
|
3317
|
+
restoreDesktopCatalogs(codexHome, operation.ids ?? [], desktopCatalogs);
|
|
3318
|
+
|
|
3296
3319
|
await reportProgress(onProgress, {
|
|
3297
3320
|
canCancel: false,
|
|
3298
3321
|
message: "Restore completed",
|
|
@@ -3318,6 +3341,8 @@ export async function restoreSessionDeletionBackup({
|
|
|
3318
3341
|
}
|
|
3319
3342
|
|
|
3320
3343
|
export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
3344
|
+
const remainingDesktopCatalogEntries = readDesktopCatalogs(store.codexHome, plan.ids)
|
|
3345
|
+
.flatMap((entry) => entry.rows.map((row) => ({ filename: entry.filename, id: row.thread_id })));
|
|
3321
3346
|
const deletedIdSet = new Set(plan.ids);
|
|
3322
3347
|
const remainingThreads = (store.stateDatabasePaths ?? [store.stateDatabasePath])
|
|
3323
3348
|
.flatMap((databasePath) => findRowsForIds(databasePath, "threads", "id", plan.ids));
|
|
@@ -3418,7 +3443,9 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
3418
3443
|
sessionIndexMatches.count === 0 &&
|
|
3419
3444
|
historyMatches.count === 0 &&
|
|
3420
3445
|
remainingTranscriptPaths.length === 0 &&
|
|
3446
|
+
remainingDesktopCatalogEntries.length === 0 &&
|
|
3421
3447
|
remainingDesktopStateReferences.length === 0,
|
|
3448
|
+
remainingDesktopCatalogEntries,
|
|
3422
3449
|
remainingDesktopStateReferences,
|
|
3423
3450
|
remainingDynamicTools,
|
|
3424
3451
|
remainingGoalRecords,
|
package/lib/server.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
MAX_SESSION_EVENT_LIMIT,
|
|
12
12
|
} from "./session-event-reader.mjs";
|
|
13
13
|
import { createProviderSettings } from "./settings.mjs";
|
|
14
|
+
import { createSessionProtectionStore } from "./session-protections.mjs";
|
|
14
15
|
import { classifyInstalledVersion } from "./version-support.mjs";
|
|
15
16
|
import {
|
|
16
17
|
acquireSessionMutationLock,
|
|
@@ -349,6 +350,9 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
349
350
|
...(claudeHome === undefined ? {} : { "claude-code": claudeHome }),
|
|
350
351
|
},
|
|
351
352
|
});
|
|
353
|
+
const protectionStore = createSessionProtectionStore({
|
|
354
|
+
configDirectory: settings.getConfigDirectory(),
|
|
355
|
+
});
|
|
352
356
|
const mutationToken = randomBytes(32).toString("base64url");
|
|
353
357
|
let mutationInProgress = false;
|
|
354
358
|
let activeOperationId = null;
|
|
@@ -463,6 +467,7 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
463
467
|
expectedFingerprint: savedPlan.fingerprint,
|
|
464
468
|
onProgress: (update) => Object.assign(operation, update),
|
|
465
469
|
options,
|
|
470
|
+
protectionStore,
|
|
466
471
|
provider,
|
|
467
472
|
recordIds: savedPlan.requestedIds,
|
|
468
473
|
scope: savedPlan.scope,
|
|
@@ -758,9 +763,20 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
758
763
|
if (request.method === "GET" && requestUrl.pathname === "/api/sessions") {
|
|
759
764
|
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
760
765
|
const provider = getProvider(providerId);
|
|
766
|
+
const home = settings.getHome(providerId);
|
|
767
|
+
const protectionSnapshot = await protectionStore.list();
|
|
768
|
+
const protectionMatcher = protectionStore.matcher({
|
|
769
|
+
providerHome: home,
|
|
770
|
+
providerId,
|
|
771
|
+
snapshot: protectionSnapshot,
|
|
772
|
+
});
|
|
773
|
+
const keepFilter = requestUrl.searchParams.get("keep");
|
|
774
|
+
if (keepFilter && keepFilter !== "kept") {
|
|
775
|
+
throw new Error("Keep filter must be kept.");
|
|
776
|
+
}
|
|
761
777
|
const result = await provider.listSessions({
|
|
762
778
|
archiveStatus: getArchiveStatus(requestUrl.searchParams.get("archiveStatus")),
|
|
763
|
-
...providerOptions(providerId,
|
|
779
|
+
...providerOptions(providerId, home),
|
|
764
780
|
inactiveBeforeMs: getInactiveBeforeMs(requestUrl.searchParams.get("inactiveDays")),
|
|
765
781
|
includeInternals: requestUrl.searchParams.get("includeInternals") === "true",
|
|
766
782
|
includeSupporting: requestUrl.searchParams.get("includeSupporting") === "true",
|
|
@@ -772,10 +788,16 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
772
788
|
workspace: requestUrl.searchParams.has("workspace")
|
|
773
789
|
? requestUrl.searchParams.get("workspace")
|
|
774
790
|
: undefined,
|
|
791
|
+
recordFilter: keepFilter === "kept"
|
|
792
|
+
? (record) => protectionMatcher(record).kept
|
|
793
|
+
: null,
|
|
775
794
|
});
|
|
776
795
|
sendJson(response, 200, {
|
|
777
796
|
...result,
|
|
778
|
-
records: result.records.map(
|
|
797
|
+
records: result.records.map((record) => ({
|
|
798
|
+
...provider.formatSessionForJson(record),
|
|
799
|
+
keep: protectionMatcher(record),
|
|
800
|
+
})),
|
|
779
801
|
});
|
|
780
802
|
return;
|
|
781
803
|
}
|
|
@@ -852,7 +874,67 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
852
874
|
return;
|
|
853
875
|
}
|
|
854
876
|
|
|
855
|
-
|
|
877
|
+
const protectionSnapshot = await protectionStore.list();
|
|
878
|
+
sendJson(response, 200, {
|
|
879
|
+
record: {
|
|
880
|
+
...provider.formatSessionForJson(record),
|
|
881
|
+
keep: protectionStore.classify({
|
|
882
|
+
providerHome: settings.getHome(providerId),
|
|
883
|
+
providerId,
|
|
884
|
+
record,
|
|
885
|
+
snapshot: protectionSnapshot,
|
|
886
|
+
}),
|
|
887
|
+
},
|
|
888
|
+
});
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/protections") {
|
|
893
|
+
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
894
|
+
const kind = requestUrl.searchParams.get("kind") || "workspaces";
|
|
895
|
+
if (kind !== "workspaces") throw new Error("Keep rule kind must be workspaces.");
|
|
896
|
+
const result = await protectionStore.listWorkspaceRules({
|
|
897
|
+
page: getPositiveInteger(requestUrl.searchParams.get("page"), 1),
|
|
898
|
+
pageSize: getPositiveInteger(requestUrl.searchParams.get("pageSize"), 25, 100),
|
|
899
|
+
search: requestUrl.searchParams.get("search"),
|
|
900
|
+
});
|
|
901
|
+
sendJson(response, 200, { ...result, providerId });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (["PUT", "DELETE"].includes(request.method) && requestUrl.pathname === "/api/protections") {
|
|
906
|
+
requireMutationAuthorization({ request, requestUrl, token: mutationToken });
|
|
907
|
+
if (activeOperationId) {
|
|
908
|
+
sendJson(response, 409, { error: "Wait for the current cleanup to finish before changing Keep." });
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const body = await readJsonBody(request);
|
|
912
|
+
if (body.kind === "session") {
|
|
913
|
+
const providerId = resolveProviderId(body.providerId);
|
|
914
|
+
const input = {
|
|
915
|
+
providerHome: settings.getHome(providerId),
|
|
916
|
+
providerId,
|
|
917
|
+
sessionId: getSessionId(body.sessionId),
|
|
918
|
+
};
|
|
919
|
+
if (request.method === "PUT") await protectionStore.keepSession(input);
|
|
920
|
+
else await protectionStore.removeSession(input);
|
|
921
|
+
} else if (body.kind === "sessions") {
|
|
922
|
+
const providerId = resolveProviderId(body.providerId);
|
|
923
|
+
const input = {
|
|
924
|
+
providerHome: settings.getHome(providerId),
|
|
925
|
+
providerId,
|
|
926
|
+
sessionIds: normalizeIds(body.sessionIds).map(getSessionId),
|
|
927
|
+
};
|
|
928
|
+
if (request.method === "PUT") await protectionStore.keepSessions(input);
|
|
929
|
+
else await protectionStore.removeSessions(input);
|
|
930
|
+
} else if (body.kind === "workspace") {
|
|
931
|
+
if (request.method === "PUT") await protectionStore.keepWorkspace({ workspace: body.workspace });
|
|
932
|
+
else await protectionStore.removeWorkspace({ workspace: body.workspace });
|
|
933
|
+
} else {
|
|
934
|
+
throw new Error("Keep kind must be session, sessions, or workspace.");
|
|
935
|
+
}
|
|
936
|
+
deletionPlans.clear();
|
|
937
|
+
sendJson(response, 200, { updated: true });
|
|
856
938
|
return;
|
|
857
939
|
}
|
|
858
940
|
|
|
@@ -867,12 +949,13 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
867
949
|
|
|
868
950
|
const prepared = await prepareSessionCleanup({
|
|
869
951
|
options,
|
|
952
|
+
protectionStore,
|
|
870
953
|
provider,
|
|
871
954
|
recordIds: ids,
|
|
872
955
|
scope,
|
|
873
956
|
});
|
|
874
957
|
const { plan, preflight } = prepared;
|
|
875
|
-
const id = randomBytes(18).toString("base64url");
|
|
958
|
+
const id = plan.ids.length > 0 ? randomBytes(18).toString("base64url") : null;
|
|
876
959
|
const expiresAtMs = Date.now() + PLAN_TTL_MS;
|
|
877
960
|
const savedPlan = {
|
|
878
961
|
home,
|
|
@@ -888,7 +971,7 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
888
971
|
while (deletionPlans.size >= MAX_SAVED_PLANS) {
|
|
889
972
|
deletionPlans.delete(deletionPlans.keys().next().value);
|
|
890
973
|
}
|
|
891
|
-
deletionPlans.set(id, savedPlan);
|
|
974
|
+
if (id) deletionPlans.set(id, savedPlan);
|
|
892
975
|
const warnings = [];
|
|
893
976
|
if (preflight.activeThreadDetection === "unavailable") {
|
|
894
977
|
warnings.push(`The current ${provider.displayName} runtime cannot identify an active session. Confirm it is safe to delete the selected sessions.`);
|
|
@@ -901,6 +984,8 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
901
984
|
...summarizePlan(plan, preflight, scope),
|
|
902
985
|
expiresAtMs,
|
|
903
986
|
id,
|
|
987
|
+
skippedProtectionCount: prepared.skippedProtections.length,
|
|
988
|
+
skippedProtections: prepared.skippedProtections,
|
|
904
989
|
warnings,
|
|
905
990
|
},
|
|
906
991
|
scope,
|
package/lib/session-cleanup.mjs
CHANGED
|
@@ -142,6 +142,124 @@ function validateScope(scope) {
|
|
|
142
142
|
return scope;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
function emptyDeletionPlan(plan) {
|
|
146
|
+
return {
|
|
147
|
+
...plan,
|
|
148
|
+
childCount: 0,
|
|
149
|
+
deepFiles: [],
|
|
150
|
+
deepPaths: [],
|
|
151
|
+
desktopStateMatchCount: 0,
|
|
152
|
+
dynamicToolRowCount: 0,
|
|
153
|
+
goalRowCount: 0,
|
|
154
|
+
historyMatchBytes: 0,
|
|
155
|
+
historyMatchCount: 0,
|
|
156
|
+
ids: [],
|
|
157
|
+
logRowCount: 0,
|
|
158
|
+
memoryJobRowCount: 0,
|
|
159
|
+
memoryOutputRowCount: 0,
|
|
160
|
+
memoryRowCount: 0,
|
|
161
|
+
memoryConsolidations: [],
|
|
162
|
+
missingTranscriptPaths: [],
|
|
163
|
+
newestLinkedActivityAtMs: 0,
|
|
164
|
+
queueRevisionRowCount: 0,
|
|
165
|
+
queueRowCount: 0,
|
|
166
|
+
records: [],
|
|
167
|
+
sessionIndexMatchCount: 0,
|
|
168
|
+
spawnEdgeCount: 0,
|
|
169
|
+
stateTargets: [],
|
|
170
|
+
threadHistoryRowCount: 0,
|
|
171
|
+
transcriptBytes: 0,
|
|
172
|
+
transcriptFileCount: 0,
|
|
173
|
+
transcriptPaths: [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function emptyPreflight() {
|
|
178
|
+
return {
|
|
179
|
+
desktopStateMatchCount: 0,
|
|
180
|
+
estimatedBackupBytes: 0,
|
|
181
|
+
transcriptBytes: 0,
|
|
182
|
+
transcriptFileCount: 0,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function applySessionProtections({ options, plan, protectionStore, provider, recordIds, store }) {
|
|
187
|
+
if (!protectionStore) {
|
|
188
|
+
return { plan, protectionRevision: null, skippedProtections: [] };
|
|
189
|
+
}
|
|
190
|
+
const snapshot = await protectionStore.list();
|
|
191
|
+
const home = providerHome({ options, provider });
|
|
192
|
+
const classifications = new Map(plan.records.map((record) => [
|
|
193
|
+
record.id,
|
|
194
|
+
protectionStore.classify({ providerHome: home, providerId: provider.id, record, snapshot }),
|
|
195
|
+
]));
|
|
196
|
+
const requested = new Set(recordIds);
|
|
197
|
+
const skippedById = new Map();
|
|
198
|
+
|
|
199
|
+
for (const id of recordIds) {
|
|
200
|
+
const protection = classifications.get(id);
|
|
201
|
+
if (protection?.kept) {
|
|
202
|
+
skippedById.set(id, { id, keep: protection, reason: "kept-session" });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const recordsById = new Map(plan.records.map((record) => [record.id, record]));
|
|
207
|
+
for (const [protectedId, protection] of classifications) {
|
|
208
|
+
if (!protection.kept || requested.has(protectedId)) continue;
|
|
209
|
+
let current = recordsById.get(protectedId);
|
|
210
|
+
const visited = new Set();
|
|
211
|
+
let assignedToRequestedRoot = false;
|
|
212
|
+
while (current?.parentThreadId && !visited.has(current.id)) {
|
|
213
|
+
visited.add(current.id);
|
|
214
|
+
const parentId = current.parentThreadId;
|
|
215
|
+
if (requested.has(parentId)) {
|
|
216
|
+
if (!skippedById.has(parentId)) {
|
|
217
|
+
skippedById.set(parentId, {
|
|
218
|
+
id: parentId,
|
|
219
|
+
keep: protection,
|
|
220
|
+
linkedSessionId: protectedId,
|
|
221
|
+
reason: "kept-linked-session",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
assignedToRequestedRoot = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
current = recordsById.get(parentId);
|
|
228
|
+
}
|
|
229
|
+
if (!assignedToRequestedRoot) {
|
|
230
|
+
throw cleanupReviewError("A linked session is marked Keep. Review the selection again before cleanup.");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const effectiveIds = recordIds.filter((id) => !skippedById.has(id));
|
|
235
|
+
if (effectiveIds.length === recordIds.length) {
|
|
236
|
+
return { plan, protectionRevision: snapshot.revision, skippedProtections: [] };
|
|
237
|
+
}
|
|
238
|
+
if (effectiveIds.length === 0) {
|
|
239
|
+
return {
|
|
240
|
+
plan: emptyDeletionPlan(plan),
|
|
241
|
+
protectionRevision: snapshot.revision,
|
|
242
|
+
skippedProtections: [...skippedById.values()],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const effectivePlan = await provider.planSessionDeletion({ recordIds: effectiveIds, store });
|
|
246
|
+
for (const record of effectivePlan.records) {
|
|
247
|
+
if (protectionStore.classify({
|
|
248
|
+
providerHome: home,
|
|
249
|
+
providerId: provider.id,
|
|
250
|
+
record,
|
|
251
|
+
snapshot,
|
|
252
|
+
}).kept) {
|
|
253
|
+
throw cleanupReviewError("A linked session is marked Keep. Review the selection again before cleanup.");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
plan: effectivePlan,
|
|
258
|
+
protectionRevision: snapshot.revision,
|
|
259
|
+
skippedProtections: [...skippedById.values()],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
145
263
|
export async function resolveSessionCleanupScope({ options, provider, scope }) {
|
|
146
264
|
const requestedScope = validateScope(scope);
|
|
147
265
|
if (requestedScope !== "deep") {
|
|
@@ -172,7 +290,7 @@ function summarizeVerification(verification) {
|
|
|
172
290
|
};
|
|
173
291
|
}
|
|
174
292
|
|
|
175
|
-
async function loadCurrentCleanup({ expectedFingerprint, options, provider, recordIds, scope }) {
|
|
293
|
+
async function loadCurrentCleanup({ expectedFingerprint, options, protectionStore, provider, recordIds, scope }) {
|
|
176
294
|
if (scope === "deep") await provider.assertDeepCleanupSupported(options);
|
|
177
295
|
|
|
178
296
|
let store;
|
|
@@ -185,21 +303,45 @@ async function loadCurrentCleanup({ expectedFingerprint, options, provider, reco
|
|
|
185
303
|
throw error;
|
|
186
304
|
}
|
|
187
305
|
|
|
188
|
-
const
|
|
189
|
-
const
|
|
306
|
+
const requestedPlan = await provider.planSessionDeletion({ recordIds, store });
|
|
307
|
+
const protectedPlan = await applySessionProtections({
|
|
308
|
+
options,
|
|
309
|
+
plan: requestedPlan,
|
|
310
|
+
protectionStore,
|
|
311
|
+
provider,
|
|
312
|
+
recordIds,
|
|
313
|
+
store,
|
|
314
|
+
});
|
|
315
|
+
const plan = protectedPlan.plan;
|
|
316
|
+
const providerFingerprint = plan.ids.length > 0
|
|
317
|
+
? await provider.fingerprintSessionDeletion({ plan, scope, store })
|
|
318
|
+
: "no-unprotected-sessions";
|
|
319
|
+
const fingerprint = JSON.stringify({
|
|
320
|
+
protectionRevision: protectedPlan.protectionRevision,
|
|
321
|
+
providerFingerprint,
|
|
322
|
+
});
|
|
190
323
|
if (expectedFingerprint !== undefined && fingerprint !== expectedFingerprint) {
|
|
191
324
|
throw cleanupReviewError("Session data changed after this preview. Review the selection again.");
|
|
192
325
|
}
|
|
193
326
|
|
|
194
|
-
const preflight =
|
|
195
|
-
|
|
327
|
+
const preflight = plan.ids.length > 0
|
|
328
|
+
? await provider.preflightSessionDeletion({ plan, scope, store })
|
|
329
|
+
: emptyPreflight();
|
|
330
|
+
return {
|
|
331
|
+
fingerprint,
|
|
332
|
+
plan,
|
|
333
|
+
preflight,
|
|
334
|
+
skippedProtections: protectedPlan.skippedProtections,
|
|
335
|
+
store,
|
|
336
|
+
};
|
|
196
337
|
}
|
|
197
338
|
|
|
198
|
-
export async function prepareSessionCleanup({ options, provider, recordIds, scope }) {
|
|
339
|
+
export async function prepareSessionCleanup({ options, protectionStore, provider, recordIds, scope }) {
|
|
199
340
|
const requestedIds = normalizeRecordIds(recordIds);
|
|
200
341
|
const resolvedScope = validateScope(scope);
|
|
201
342
|
const current = await loadCurrentCleanup({
|
|
202
343
|
options,
|
|
344
|
+
protectionStore,
|
|
203
345
|
provider,
|
|
204
346
|
recordIds: requestedIds,
|
|
205
347
|
scope: resolvedScope,
|
|
@@ -209,14 +351,16 @@ export async function prepareSessionCleanup({ options, provider, recordIds, scop
|
|
|
209
351
|
plan: current.plan,
|
|
210
352
|
preflight: current.preflight,
|
|
211
353
|
requestedIds,
|
|
354
|
+
skippedProtections: current.skippedProtections,
|
|
212
355
|
scope: resolvedScope,
|
|
213
356
|
};
|
|
214
357
|
}
|
|
215
358
|
|
|
216
|
-
export async function revalidateSessionCleanup({ expectedFingerprint, options, provider, recordIds, scope }) {
|
|
359
|
+
export async function revalidateSessionCleanup({ expectedFingerprint, options, protectionStore, provider, recordIds, scope }) {
|
|
217
360
|
return loadCurrentCleanup({
|
|
218
361
|
expectedFingerprint,
|
|
219
362
|
options,
|
|
363
|
+
protectionStore,
|
|
220
364
|
provider,
|
|
221
365
|
recordIds: normalizeRecordIds(recordIds),
|
|
222
366
|
scope: validateScope(scope),
|
|
@@ -227,59 +371,76 @@ export async function executePreparedSessionCleanup({
|
|
|
227
371
|
expectedFingerprint,
|
|
228
372
|
onProgress = () => {},
|
|
229
373
|
options,
|
|
374
|
+
protectionStore,
|
|
230
375
|
provider,
|
|
231
376
|
recordIds,
|
|
232
377
|
scope,
|
|
233
378
|
shouldCancel = () => false,
|
|
234
379
|
}) {
|
|
235
|
-
const
|
|
236
|
-
expectedFingerprint,
|
|
237
|
-
options,
|
|
238
|
-
provider,
|
|
239
|
-
recordIds,
|
|
240
|
-
scope,
|
|
241
|
-
});
|
|
242
|
-
let deletion;
|
|
380
|
+
const releaseProtectionLease = await protectionStore?.acquireLease();
|
|
243
381
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
382
|
+
const current = await revalidateSessionCleanup({
|
|
383
|
+
expectedFingerprint,
|
|
384
|
+
options,
|
|
385
|
+
protectionStore,
|
|
386
|
+
provider,
|
|
387
|
+
recordIds,
|
|
247
388
|
scope,
|
|
248
|
-
shouldCancel,
|
|
249
|
-
store: current.store,
|
|
250
389
|
});
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
390
|
+
if (current.plan.ids.length === 0) {
|
|
391
|
+
return {
|
|
392
|
+
deletion: null,
|
|
393
|
+
plan: current.plan,
|
|
394
|
+
preflight: current.preflight,
|
|
395
|
+
skippedProtections: current.skippedProtections,
|
|
396
|
+
verification: null,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
let deletion;
|
|
400
|
+
try {
|
|
401
|
+
deletion = await provider.executeSessionDeletion({
|
|
402
|
+
onProgress,
|
|
403
|
+
plan: current.plan,
|
|
404
|
+
scope,
|
|
405
|
+
shouldCancel,
|
|
406
|
+
store: current.store,
|
|
407
|
+
});
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error && typeof error === "object") error.cleanupPlan ??= current.plan;
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
onProgress({
|
|
413
|
+
canCancel: false,
|
|
414
|
+
message: "Checking that cleanup completed",
|
|
415
|
+
phase: "verification",
|
|
416
|
+
progress: 94,
|
|
267
417
|
});
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
418
|
+
let verification;
|
|
419
|
+
try {
|
|
420
|
+
verification = await provider.verifySessionDeletion({
|
|
421
|
+
plan: current.plan,
|
|
422
|
+
scope,
|
|
423
|
+
store: current.store,
|
|
424
|
+
});
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error && typeof error === "object") {
|
|
427
|
+
error.backupDirectory ??= deletion.backupDirectory;
|
|
428
|
+
error.cleanupPlan ??= current.plan;
|
|
429
|
+
error.deletionResult ??= deletion;
|
|
430
|
+
error.mutationStarted ??= true;
|
|
431
|
+
}
|
|
432
|
+
throw error;
|
|
274
433
|
}
|
|
275
|
-
|
|
434
|
+
return {
|
|
435
|
+
deletion,
|
|
436
|
+
plan: current.plan,
|
|
437
|
+
preflight: current.preflight,
|
|
438
|
+
skippedProtections: current.skippedProtections,
|
|
439
|
+
verification,
|
|
440
|
+
};
|
|
441
|
+
} finally {
|
|
442
|
+
await releaseProtectionLease?.();
|
|
276
443
|
}
|
|
277
|
-
return {
|
|
278
|
-
deletion,
|
|
279
|
-
plan: current.plan,
|
|
280
|
-
preflight: current.preflight,
|
|
281
|
-
verification,
|
|
282
|
-
};
|
|
283
444
|
}
|
|
284
445
|
|
|
285
446
|
async function deleteBackups(provider, options, backupDirectories) {
|
|
@@ -327,12 +488,14 @@ async function restoreAndCheck({ backupDirectory, options, plan, provider, onPro
|
|
|
327
488
|
}
|
|
328
489
|
}
|
|
329
490
|
|
|
330
|
-
function baseResult({ plan, preflight, requestedIds, scope }) {
|
|
491
|
+
function baseResult({ plan, preflight, requestedIds, scope, skippedProtections = [] }) {
|
|
331
492
|
return {
|
|
332
493
|
affectedSessionCount: plan.ids.length,
|
|
333
|
-
childSessionCount: Math.max(0, plan.ids.length - requestedIds.length),
|
|
494
|
+
childSessionCount: plan.childCount ?? Math.max(0, plan.ids.length - requestedIds.length),
|
|
334
495
|
cleanupMode: scope === "deep" ? "thorough" : "standard",
|
|
335
496
|
requestedSessionCount: requestedIds.length,
|
|
497
|
+
skippedProtectionCount: skippedProtections.length,
|
|
498
|
+
skippedProtections,
|
|
336
499
|
transcriptBytes: preflight.transcriptBytes ?? plan.transcriptBytes ?? 0,
|
|
337
500
|
transcriptFileCount: preflight.transcriptFileCount ?? plan.transcriptFileCount ?? 0,
|
|
338
501
|
};
|
|
@@ -350,10 +513,10 @@ function safeCleanupError(error, backupRetained) {
|
|
|
350
513
|
);
|
|
351
514
|
}
|
|
352
515
|
|
|
353
|
-
async function runSessionCleanupUnlocked({ onProgress, options, provider, recordIds, scope, signal }) {
|
|
516
|
+
async function runSessionCleanupUnlocked({ onProgress, options, protectionStore, provider, recordIds, scope, signal }) {
|
|
354
517
|
let prepared;
|
|
355
518
|
try {
|
|
356
|
-
prepared = await prepareSessionCleanup({ options, provider, recordIds, scope });
|
|
519
|
+
prepared = await prepareSessionCleanup({ options, protectionStore, provider, recordIds, scope });
|
|
357
520
|
} catch (error) {
|
|
358
521
|
if (error?.code === SESSION_CLEANUP_REVIEW_REQUIRED) {
|
|
359
522
|
throw cleanupReviewError("The selected session data changed before cleanup. Find matching sessions again and retry.");
|
|
@@ -365,7 +528,20 @@ async function runSessionCleanupUnlocked({ onProgress, options, provider, record
|
|
|
365
528
|
preflight: prepared.preflight,
|
|
366
529
|
requestedIds: prepared.requestedIds,
|
|
367
530
|
scope: prepared.scope,
|
|
531
|
+
skippedProtections: prepared.skippedProtections,
|
|
368
532
|
});
|
|
533
|
+
if (prepared.plan.ids.length === 0) {
|
|
534
|
+
return {
|
|
535
|
+
...base,
|
|
536
|
+
deletedSessionCount: 0,
|
|
537
|
+
deletedTranscriptCount: 0,
|
|
538
|
+
recovery: { attempted: false, backupRetained: false, completed: false },
|
|
539
|
+
skippedTranscriptCount: 0,
|
|
540
|
+
status: "protected",
|
|
541
|
+
unrecognizedLocationCount: 0,
|
|
542
|
+
verification: null,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
369
545
|
let backupDirectory = null;
|
|
370
546
|
let deletionResult = null;
|
|
371
547
|
let execution = null;
|
|
@@ -375,6 +551,7 @@ async function runSessionCleanupUnlocked({ onProgress, options, provider, record
|
|
|
375
551
|
expectedFingerprint: prepared.fingerprint,
|
|
376
552
|
onProgress,
|
|
377
553
|
options,
|
|
554
|
+
protectionStore,
|
|
378
555
|
provider,
|
|
379
556
|
recordIds: prepared.requestedIds,
|
|
380
557
|
scope: prepared.scope,
|