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.
@@ -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 = 0;
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, settings.getHome(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(provider.formatSessionForJson),
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
- sendJson(response, 200, { record: provider.formatSessionForJson(record) });
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,
@@ -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 plan = await provider.planSessionDeletion({ recordIds, store });
189
- const fingerprint = await provider.fingerprintSessionDeletion({ plan, scope, store });
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 = await provider.preflightSessionDeletion({ plan, scope, store });
195
- return { fingerprint, plan, preflight, store };
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 current = await revalidateSessionCleanup({
236
- expectedFingerprint,
237
- options,
238
- provider,
239
- recordIds,
240
- scope,
241
- });
242
- let deletion;
380
+ const releaseProtectionLease = await protectionStore?.acquireLease();
243
381
  try {
244
- deletion = await provider.executeSessionDeletion({
245
- onProgress,
246
- plan: current.plan,
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
- } catch (error) {
252
- if (error && typeof error === "object") error.cleanupPlan ??= current.plan;
253
- throw error;
254
- }
255
- onProgress({
256
- canCancel: false,
257
- message: "Checking that cleanup completed",
258
- phase: "verification",
259
- progress: 94,
260
- });
261
- let verification;
262
- try {
263
- verification = await provider.verifySessionDeletion({
264
- plan: current.plan,
265
- scope,
266
- store: current.store,
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
- } catch (error) {
269
- if (error && typeof error === "object") {
270
- error.backupDirectory ??= deletion.backupDirectory;
271
- error.cleanupPlan ??= current.plan;
272
- error.deletionResult ??= deletion;
273
- error.mutationStarted ??= true;
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
- throw error;
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,