session-steward 0.10.3 → 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.
@@ -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,