@wangxiaocuo/dsh-session-trash 0.5.1 → 0.6.1

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.
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs/promises'
1
2
  import path from 'node:path'
2
3
  import { TrashError } from './errors.js'
3
4
  import {
@@ -12,7 +13,9 @@ import {
12
13
  moveDirectory,
13
14
  samePath,
14
15
  } from './fsops.js'
15
- import { reconcileIndex, rebuildIndex } from './inventory.js'
16
+ import { mapLimit } from './concurrency.js'
17
+ import { reconcileIndex, rebuildIndex, memberPresence } from './inventory.js'
18
+ import { aggregateMetrics, memberList } from './members.js'
16
19
 
17
20
  /** How many short retries confirm a moved or restored session is visible. */
18
21
  const VISIBILITY_RETRIES = 5
@@ -20,6 +23,9 @@ const VISIBILITY_RETRIES = 5
20
23
  /** Delay between visibility probes. */
21
24
  const VISIBILITY_DELAY_MS = 100
22
25
 
26
+ /** Maximum concurrent object deletions and sidecar rewrites during a purge. */
27
+ const PURGE_CONCURRENCY = 4
28
+
23
29
  /**
24
30
  * @param {number} milliseconds delay length.
25
31
  * @returns {Promise<void>} resolution after the delay.
@@ -170,9 +176,34 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
170
176
  return undefined
171
177
  }
172
178
 
179
+ /**
180
+ * @param {unknown} target path to probe.
181
+ * @returns {Promise<string>} the entry kind, treating an absent path as missing.
182
+ */
183
+ async function kindOf(target) {
184
+ return typeof target === 'string' && target !== '' ? entryKind(target) : 'missing'
185
+ }
186
+
187
+ /**
188
+ * @param {object} entry index entry.
189
+ * @returns {object[]} every owned directory, parent first.
190
+ */
191
+ function membersOf(entry) {
192
+ return memberList(entry)
193
+ }
194
+
195
+ /**
196
+ * @param {object} entry index entry.
197
+ * @param {number} index member position.
198
+ * @returns {string} this member's directory inside the trash object.
199
+ */
200
+ function memberTrashPath(entry, index) {
201
+ return store.memberPath(entry.trashId, index, membersOf(entry)[index].sessionId)
202
+ }
203
+
173
204
  /**
174
205
  * @param {object} entry trash index entry.
175
- * @returns {Promise<void>} resolution once the object directory is inside `objects/`.
206
+ * @returns {Promise<void>} resolution once every member path stays inside `objects/`.
176
207
  */
177
208
  async function assertSafeObject(entry) {
178
209
  if (!isSafeTrashId(entry.trashId)) {
@@ -182,14 +213,229 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
182
213
  if (!isInside(store.objectsDir, objectDir)) {
183
214
  throw new TrashError('IO_ERROR', 'The trash entry names an object outside the trash root.')
184
215
  }
216
+ const members = membersOf(entry)
217
+ for (let index = 0; index < members.length; index += 1) {
218
+ const memberPath = path.resolve(memberTrashPath(entry, index))
219
+ if (!isInside(objectDir, memberPath)) {
220
+ throw new TrashError('IO_ERROR', 'The trash entry names a member outside its object.')
221
+ }
222
+ }
185
223
  }
186
224
 
187
225
  /**
188
- * @param {object} entry trash index entry.
189
- * @returns {Promise<boolean>} whether the moved session directory still exists.
226
+ * Probe every member directory in parallel: where the source copy and the
227
+ * trash copy currently live.
228
+ *
229
+ * @param {object} entry index entry.
230
+ * @returns {Promise<{ index: number, member: object, source: boolean, trash: boolean }[]>} member positions.
190
231
  */
191
- async function trashSessionExists(entry) {
192
- return (await entryKind(store.sessionPath(entry.trashId))) === 'directory'
232
+ async function memberRows(entry) {
233
+ const members = membersOf(entry)
234
+ return Promise.all(
235
+ members.map(async (member, index) => {
236
+ const [sourceKind, trashKind] = await Promise.all([
237
+ kindOf(member.originalSessionDir),
238
+ kindOf(memberTrashPath(entry, index)),
239
+ ])
240
+ return {
241
+ index,
242
+ member,
243
+ source: sourceKind === 'directory',
244
+ trash: trashKind === 'directory',
245
+ }
246
+ }),
247
+ )
248
+ }
249
+
250
+ /**
251
+ * Probe only the original session directories of every member, in parallel.
252
+ * Used by recovery after the trash copies were already proven present.
253
+ *
254
+ * @param {object} entry index entry.
255
+ * @returns {Promise<{ index: number, member: object, source: boolean }[]>} member source positions.
256
+ */
257
+ async function sourceRows(entry) {
258
+ const members = membersOf(entry)
259
+ return Promise.all(
260
+ members.map(async (member, index) => ({
261
+ index,
262
+ member,
263
+ source: (await kindOf(member.originalSessionDir)) === 'directory',
264
+ })),
265
+ )
266
+ }
267
+
268
+ /**
269
+ * @param {object} entry index entry.
270
+ * @param {number} index member position.
271
+ * @returns {Promise<void>} resolution once the member directory moved into its object.
272
+ */
273
+ async function moveMemberToTrash(entry, index) {
274
+ const member = membersOf(entry)[index]
275
+ await moveDirectory(member.originalSessionDir, memberTrashPath(entry, index), member, { logger })
276
+ }
277
+
278
+ /**
279
+ * @param {object} entry index entry.
280
+ * @param {number} index member position.
281
+ * @returns {Promise<void>} resolution once the member directory moved back to its original location.
282
+ */
283
+ async function moveMemberBack(entry, index) {
284
+ const member = membersOf(entry)[index]
285
+ await moveDirectory(memberTrashPath(entry, index), member.originalSessionDir, member, { logger })
286
+ }
287
+
288
+ /**
289
+ * @param {object} entry index entry.
290
+ * @param {readonly { index: number }[]} rows members to move into the object.
291
+ * @returns {Promise<{ index: number, error: unknown }[]>} per-member failures.
292
+ */
293
+ async function moveMembersIntoTrash(entry, rows) {
294
+ const failures = []
295
+ for (const row of [...rows].sort((left, right) => right.index - left.index)) {
296
+ try {
297
+ await moveMemberToTrash(entry, row.index)
298
+ } catch (error) {
299
+ failures.push({ index: row.index, error })
300
+ }
301
+ }
302
+ return failures
303
+ }
304
+
305
+ /**
306
+ * @param {object} entry index entry.
307
+ * @param {readonly { index: number }[]} rows members to move back to their original locations.
308
+ * @returns {Promise<{ index: number, error: unknown }[]>} per-member failures.
309
+ */
310
+ async function moveMembersBackFromTrash(entry, rows) {
311
+ const failures = []
312
+ for (const row of [...rows].sort((left, right) => left.index - right.index)) {
313
+ try {
314
+ await moveMemberBack(entry, row.index)
315
+ } catch (error) {
316
+ failures.push({ index: row.index, error })
317
+ }
318
+ }
319
+ return failures
320
+ }
321
+
322
+ /**
323
+ * Move every member directory into the trash object, children first and the
324
+ * visible parent last so the sidebar keeps the row until the move settles.
325
+ *
326
+ * @param {object} entry index entry.
327
+ * @returns {Promise<void>} resolution once every member directory moved.
328
+ */
329
+ async function moveMembersToTrash(entry) {
330
+ const rows = membersOf(entry).map((_, index) => ({ index }))
331
+ const failures = await moveMembersIntoTrash(entry, rows)
332
+ if (failures.length === 0) return
333
+ const failure = failures[0]
334
+ throw failure.error instanceof TrashError
335
+ ? failure.error
336
+ : new TrashError('IO_ERROR', `Cannot move a session directory into the trash: ${messageOf(failure.error)}`)
337
+ }
338
+
339
+ /**
340
+ * @param {object} entry index entry.
341
+ * @param {number} index member position.
342
+ * @returns {Promise<void>} resolution once the unusable trash copy is gone.
343
+ */
344
+ async function removeTrashMember(entry, index) {
345
+ const target = path.resolve(memberTrashPath(entry, index))
346
+ const objectDir = path.resolve(store.objectDir(entry.trashId))
347
+ if (samePath(objectDir, target) || !isInside(objectDir, target)) {
348
+ throw new TrashError('IO_ERROR', 'Refusing to remove a member directory outside its trash object.')
349
+ }
350
+ await fs.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
351
+ }
352
+
353
+ /**
354
+ * @param {object} member planned member whose restored copy failed verification.
355
+ * @returns {Promise<void>} resolution once the unusable restored copy is gone.
356
+ */
357
+ async function removeRestoredMember(member) {
358
+ const target = path.resolve(String(member.originalSessionDir))
359
+ if (!isSafeDirectoryPath(target) || isInside(paths.root, target)) {
360
+ throw new TrashError('IO_ERROR', 'Refusing to remove an unsafe session directory.')
361
+ }
362
+ await fs.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
363
+ }
364
+
365
+ /**
366
+ * @param {object} entry index entry.
367
+ * @returns {Promise<{ ok: boolean, index?: number, message?: string }>} whether every member landed with its recorded metrics.
368
+ */
369
+ async function verifyTrashedMembers(entry) {
370
+ const members = membersOf(entry)
371
+ for (let index = 0; index < members.length; index += 1) {
372
+ let measured
373
+ try {
374
+ measured = await measureDirectory(memberTrashPath(entry, index))
375
+ } catch (error) {
376
+ return { ok: false, index, message: messageOf(error) }
377
+ }
378
+ if (!metricsMatch(members[index], measured)) {
379
+ return { ok: false, index, message: 'the recorded size does not match' }
380
+ }
381
+ if ((await kindOf(members[index].originalSessionDir)) !== 'missing') {
382
+ return { ok: false, index, message: 'the original directory still exists' }
383
+ }
384
+ }
385
+ return { ok: true }
386
+ }
387
+
388
+ /**
389
+ * @param {object} entry index entry.
390
+ * @returns {Promise<{ ok: boolean, index?: number, message?: string }>} whether every member returned with its recorded metrics.
391
+ */
392
+ async function verifyRestoredMembers(entry) {
393
+ const members = membersOf(entry)
394
+ for (let index = 0; index < members.length; index += 1) {
395
+ let measured
396
+ try {
397
+ measured = await measureDirectory(members[index].originalSessionDir)
398
+ } catch (error) {
399
+ return { ok: false, index, message: messageOf(error) }
400
+ }
401
+ if (!metricsMatch(members[index], measured)) {
402
+ return { ok: false, index, message: 'the recorded size does not match' }
403
+ }
404
+ }
405
+ return { ok: true }
406
+ }
407
+
408
+ /**
409
+ * @param {object} entry index entry.
410
+ * @returns {Promise<{ hidden: boolean, sessionId?: string }>} whether session storage stopped reporting every member.
411
+ */
412
+ async function waitForMembersHidden(entry) {
413
+ const results = await Promise.all(
414
+ membersOf(entry).map(async (member) => ({
415
+ sessionId: member.sessionId,
416
+ hidden: await waitForVisibility(member.sessionId, (current) => current === undefined || current === null),
417
+ })),
418
+ )
419
+ const failed = results.find((result) => !result.hidden)
420
+ return failed === undefined ? { hidden: true } : { hidden: false, sessionId: failed.sessionId }
421
+ }
422
+
423
+ /**
424
+ * @param {object} entry index entry.
425
+ * @returns {Promise<{ visible: boolean, sessionId?: string }>} whether session storage reports every member again.
426
+ */
427
+ async function waitForMembersVisible(entry) {
428
+ const results = await Promise.all(
429
+ membersOf(entry).map(async (member) => ({
430
+ sessionId: member.sessionId,
431
+ visible: await waitForVisibility(
432
+ member.sessionId,
433
+ (current) => current !== undefined && current !== null && current.location !== undefined,
434
+ ),
435
+ })),
436
+ )
437
+ const failed = results.find((result) => !result.visible)
438
+ return failed === undefined ? { visible: true } : { visible: false, sessionId: failed.sessionId }
193
439
  }
194
440
 
195
441
  /**
@@ -419,98 +665,185 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
419
665
  }
420
666
 
421
667
  /**
422
- * Settle a failed move by applying the `trashing` crash matrix to the live
423
- * filesystem. Returns a committed `trashed` entry when the data landed in the
424
- * trash, or `undefined` when the operation rolled back.
668
+ * Resolve every member directory of a `trashing` entry to either the trash
669
+ * object or its original location. Never writes the index: the caller owns
670
+ * the commit so startup recovery can accumulate one write.
425
671
  *
426
- * @param {object} entry current `trashing` entry.
427
- * @param {unknown} cause failure that triggered recovery.
428
- * @returns {Promise<object | undefined>} the completed entry, or undefined after rollback.
672
+ * @param {object} entry `trashing` index entry.
673
+ * @returns {Promise<{ kind: 'ready' } | { kind: 'rolledBack' } | { kind: 'missing', entry: object }>} the settled position.
429
674
  */
430
- async function settleFailedTrashMove(entry, cause) {
431
- const sourceExists = (await entryKind(entry.originalSessionDir)) === 'directory'
432
- const targetExists = await trashSessionExists(entry)
433
- const failure = messageOf(cause)
434
- if (sourceExists && !targetExists) {
435
- await rollbackTrash(entry.sessionId, entry.trashId)
436
- return undefined
437
- }
438
- if (!sourceExists && targetExists) {
439
- const measured = await measureDirectory(store.sessionPath(entry.trashId))
440
- if (!metricsMatch(entry, measured)) {
441
- await markFailure(entry, `The moved session does not match its recorded size (${failure}).`)
442
- throw new TrashError('MOVE_CONFLICT', 'The moved session does not match its recorded size.', { measured })
675
+ async function resolveTrashing(entry) {
676
+ const rows = await memberRows(entry)
677
+ const lost = rows.filter((row) => !row.source && !row.trash)
678
+ if (lost.length > 0) {
679
+ return {
680
+ kind: 'missing',
681
+ entry: {
682
+ ...entry,
683
+ state: 'missing',
684
+ lastError: errorText(`The trash copy is missing for: ${lost.map((row) => row.member.sessionId).join(', ')}`),
685
+ },
443
686
  }
444
- const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
445
- await store.commit(upsertEntry(store.entries(), next))
446
- await store.writeMeta(next).catch(() => {})
447
- eligibility.invalidate(entry.sessionId)
448
- emit('api-session/removed', entry.sessionId)
449
- return next
450
687
  }
451
- if (sourceExists && targetExists) {
452
- const measured = await measureDirectory(store.sessionPath(entry.trashId))
453
- if (metricsMatch(entry, measured)) {
454
- try {
455
- await store.removeSessionSource(entry.originalSessionDir)
456
- const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
457
- await store.commit(upsertEntry(store.entries(), next))
458
- await store.writeMeta(next).catch(() => {})
459
- eligibility.invalidate(entry.sessionId)
460
- emit('api-session/removed', entry.sessionId)
461
- return next
462
- } catch (error) {
463
- logger.warn(`cannot remove the source after copying session ${entry.sessionId}: ${messageOf(error)}`)
464
- }
688
+ for (const row of rows) {
689
+ if (!row.source || !row.trash) continue
690
+ const measured = await measureDirectory(memberTrashPath(entry, row.index))
691
+ if (metricsMatch(row.member, measured)) {
692
+ await store.removeSessionSource(row.member.originalSessionDir)
693
+ row.source = false
465
694
  } else {
466
- // The source is authoritative: remove the unusable copy and roll back.
467
- await rollbackTrash(entry.sessionId, entry.trashId)
468
- return undefined
695
+ await removeTrashMember(entry, row.index)
696
+ row.trash = false
697
+ }
698
+ }
699
+ const inTrash = rows.filter((row) => row.trash)
700
+ const inSource = rows.filter((row) => row.source)
701
+ if (inTrash.length === 0) return { kind: 'rolledBack' }
702
+ if (inSource.length > 0) {
703
+ const failures = await moveMembersIntoTrash(entry, inSource)
704
+ if (failures.length > 0) {
705
+ const current = await memberRows(entry)
706
+ const rollback = await moveMembersBackFromTrash(entry, current.filter((row) => row.trash))
707
+ if (rollback.length === 0) return { kind: 'rolledBack' }
708
+ await markFailure(entry, `The session tree could not be completed or rolled back: ${messageOf(failures[0].error)}`)
709
+ throw new TrashError('MOVE_CONFLICT', 'The session tree could not be moved or rolled back; both copies were kept.')
469
710
  }
470
- await markFailure(entry, failure)
471
- throw new TrashError('MOVE_CONFLICT', 'The session move could not be completed; both copies were kept.', {
472
- source: entry.originalSessionDir,
473
- })
474
711
  }
475
- await markFailure(entry, `The session log disappeared during the move (${failure}).`)
476
- throw new TrashError('MOVE_CONFLICT', 'The session log disappeared during the move.', {})
712
+ const verified = await verifyTrashedMembers(entry)
713
+ if (!verified.ok) {
714
+ const current = await memberRows(entry)
715
+ const rollback = await moveMembersBackFromTrash(entry, current.filter((row) => row.trash))
716
+ if (rollback.length === 0) return { kind: 'rolledBack' }
717
+ await markFailure(entry, `The moved session tree does not match its recorded size (${verified.message}).`)
718
+ throw new TrashError('MOVE_VERIFY_FAILED', 'The moved session tree does not match its recorded size.')
719
+ }
720
+ return { kind: 'ready' }
477
721
  }
478
722
 
479
723
  /**
480
- * Settle a failed restore move. Returns `true` when the session directory is
481
- * at its original location and the operation may continue.
724
+ * Resolve every member directory of a `restoring` entry back to its original
725
+ * location, or back into the trash object when the restore cannot complete.
726
+ * Never writes the index: the caller owns the commit.
482
727
  *
483
- * @param {object} entry `restoring` entry.
484
- * @param {unknown} cause failure that triggered recovery.
485
- * @returns {Promise<boolean>} whether the target now contains the restored session.
728
+ * @param {object} entry `restoring` index entry.
729
+ * @returns {Promise<{ kind: 'ready' } | { kind: 'trashed', entry: object } | { kind: 'missing', entry: object }>} the settled position.
486
730
  */
487
- async function settleFailedRestoreMove(entry, cause) {
488
- const targetExists = (await entryKind(entry.originalSessionDir)) === 'directory'
489
- const trashExists = await trashSessionExists(entry)
490
- const failure = messageOf(cause)
491
- if (targetExists) {
492
- const measured = await measureDirectory(entry.originalSessionDir)
493
- if (metricsMatch(entry, measured)) {
494
- if (trashExists) {
495
- try {
496
- await store.removeObjectDir(entry.trashId)
497
- } catch (error) {
498
- logger.warn(`cannot remove the leftover trash copy for ${entry.sessionId}: ${messageOf(error)}`)
499
- }
500
- }
501
- return true
731
+ async function resolveRestoring(entry) {
732
+ const rows = await memberRows(entry)
733
+ const lost = rows.filter((row) => !row.source && !row.trash)
734
+ if (lost.length > 0) {
735
+ return {
736
+ kind: 'missing',
737
+ entry: {
738
+ ...entry,
739
+ state: 'missing',
740
+ lastError: errorText(`Both copies are missing for: ${lost.map((row) => row.member.sessionId).join(', ')}`),
741
+ },
502
742
  }
503
- await markFailure(entry, `The restored session does not match its recorded size (${failure}).`)
504
- return false
505
743
  }
506
- if (trashExists) {
507
- const next = { ...entry, state: 'trashed', lastError: errorText(failure) }
744
+ for (const row of rows) {
745
+ if (!row.source || !row.trash) continue
746
+ const measured = await measureDirectory(row.member.originalSessionDir)
747
+ if (metricsMatch(row.member, measured)) {
748
+ await removeTrashMember(entry, row.index)
749
+ row.trash = false
750
+ } else {
751
+ await removeRestoredMember(row.member)
752
+ row.source = false
753
+ }
754
+ }
755
+ const rolledBack = () => ({
756
+ kind: 'trashed',
757
+ entry: {
758
+ ...entry,
759
+ state: 'trashed',
760
+ trashedAt:
761
+ typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt : entry.startedAt || Date.now(),
762
+ lastError: 'Recovered the trash copy after an interrupted restore.',
763
+ },
764
+ })
765
+ const inSource = rows.filter((row) => row.source)
766
+ const inTrash = rows.filter((row) => row.trash)
767
+ if (inSource.length === 0) return rolledBack()
768
+ if (inTrash.length > 0) {
769
+ const failures = await moveMembersBackFromTrash(entry, inTrash)
770
+ if (failures.length > 0) {
771
+ const current = await memberRows(entry)
772
+ const rollback = await moveMembersIntoTrash(entry, current.filter((row) => row.source && !row.trash))
773
+ if (rollback.length === 0) return rolledBack()
774
+ await markFailure(entry, `The restore could not be completed or rolled back: ${messageOf(failures[0].error)}`)
775
+ throw new TrashError('MOVE_CONFLICT', 'The restore could not be completed or rolled back; both copies were kept.')
776
+ }
777
+ }
778
+ const verified = await verifyRestoredMembers(entry)
779
+ if (!verified.ok) {
780
+ const current = await memberRows(entry)
781
+ const rollback = await moveMembersIntoTrash(entry, current.filter((row) => row.source))
782
+ if (rollback.length === 0) return rolledBack()
783
+ await markFailure(entry, `The restored session tree does not match its recorded size (${verified.message}).`)
784
+ throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session tree does not match its recorded size.')
785
+ }
786
+ return { kind: 'ready' }
787
+ }
788
+
789
+ /**
790
+ * @param {object} entry settled `trashing` entry.
791
+ * @param {{ emitRemoval?: boolean }} [options] whether to announce the removal to clients.
792
+ * @returns {Promise<object>} the committed `trashed` entry.
793
+ */
794
+ async function commitTrashed(entry, options = {}) {
795
+ const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
796
+ try {
508
797
  await store.commit(upsertEntry(store.entries(), next))
509
- await store.writeMeta(next).catch(() => {})
510
- return false
798
+ } catch (error) {
799
+ await markFailure(entry, `The trash index could not be committed: ${messageOf(error)}`)
800
+ throw new TrashError('INDEX_WRITE_FAILED', 'The session moved, but the trash index could not be updated.')
511
801
  }
512
- await markFailure(entry, `Both the trash copy and the restore target are missing (${failure}).`)
513
- return false
802
+ await store.writeMeta(next).catch((error) => {
803
+ logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
804
+ })
805
+ eligibility.invalidate(entry.sessionId)
806
+ if (options.emitRemoval === true) emit('api-session/removed', entry.sessionId)
807
+ return next
808
+ }
809
+
810
+ /**
811
+ * Finish a settled restore: wait for visibility, restore workspace
812
+ * accounting, drop the entry, and optionally announce the session.
813
+ *
814
+ * @param {object} entry settled `restoring` entry.
815
+ * @param {{ mode?: 'normal' | 'orphan', emitAdded?: boolean, reattach?: boolean }} [options] finishing policy.
816
+ * @returns {Promise<object>} the workspace attachment outcome.
817
+ */
818
+ async function completeRestore(entry, options = {}) {
819
+ const mode = options.mode ?? 'normal'
820
+ const visible = await waitForMembersVisible(entry)
821
+ if (!visible.visible) {
822
+ throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session tree is not visible to session storage.', {
823
+ sessionId: visible.sessionId,
824
+ })
825
+ }
826
+ const attachment =
827
+ options.reattach === false
828
+ ? { reattached: false }
829
+ : mode === 'orphan'
830
+ ? await detachWorkspace(entry)
831
+ : await reattachWorkspace(entry)
832
+ try {
833
+ await store.commit(dropEntry(store.entries(), entry.sessionId))
834
+ } catch (error) {
835
+ await markFailure(entry, `The trash index could not be committed after restore: ${messageOf(error)}`)
836
+ throw new TrashError('INDEX_WRITE_FAILED', 'The session was restored, but the trash index could not be updated.')
837
+ }
838
+ await store.removeObjectDir(entry.trashId).catch((error) => {
839
+ logger.warn(`cannot remove the restored trash object ${entry.trashId}: ${messageOf(error)}`)
840
+ })
841
+ eligibility.invalidate(entry.sessionId)
842
+ if (options.emitAdded !== false) {
843
+ const restoredSnapshot = await stat(entry.sessionId).catch(() => undefined)
844
+ emit('api-session/added', buildSummary(entry.sessionId, entry.title, restoredSnapshot))
845
+ }
846
+ return attachment
514
847
  }
515
848
 
516
849
  /**
@@ -524,6 +857,99 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
524
857
  return entry.trashedAt + config.retentionDays * 86_400_000 <= now
525
858
  }
526
859
 
860
+ /**
861
+ * Permanently remove a batch of object directories and their index entries in
862
+ * a bounded number of index writes. Every entry is first committed as
863
+ * `purging` in one write, so a crash still settles forward on the next
864
+ * recovery; successes are dropped and failures annotated in one final write.
865
+ * The caller owns the trash lock.
866
+ *
867
+ * @param {readonly object[]} targets `trashed` entries to purge.
868
+ * @returns {Promise<{ purged: number, failures: { entry: object, error: unknown, phase: string }[] }>} per-entry outcome.
869
+ */
870
+ async function purgeManyInsideLock(targets) {
871
+ const failures = []
872
+ const purging = []
873
+ for (const entry of targets) {
874
+ try {
875
+ await assertSafeObject(entry)
876
+ purging.push({ ...entry, state: 'purging', lastError: null })
877
+ } catch (error) {
878
+ failures.push({ entry, error, phase: 'validate' })
879
+ }
880
+ }
881
+ if (purging.length === 0) return { purged: 0, failures }
882
+
883
+ let marked = store.entries()
884
+ for (const entry of purging) marked = upsertEntry(marked, entry)
885
+ try {
886
+ await store.commit(marked)
887
+ } catch (error) {
888
+ const failure = new TrashError(
889
+ 'INDEX_WRITE_FAILED',
890
+ 'The session logs were not deleted because the trash index could not be updated.',
891
+ { code: error instanceof TrashError ? error.code : null },
892
+ )
893
+ for (const entry of purging) failures.push({ entry, error: failure, phase: 'mark' })
894
+ return { purged: 0, failures }
895
+ }
896
+ await mapLimit(purging, PURGE_CONCURRENCY, async (entry) => {
897
+ await store.writeMeta(entry).catch((error) => {
898
+ logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
899
+ })
900
+ })
901
+
902
+ const deleted = await mapLimit(purging, PURGE_CONCURRENCY, async (entry) => {
903
+ try {
904
+ await store.removeObjectDir(entry.trashId)
905
+ return { entry, deleted: true }
906
+ } catch (error) {
907
+ return { entry, deleted: false, error }
908
+ }
909
+ })
910
+ const completed = []
911
+ const annotated = []
912
+ for (const result of deleted) {
913
+ if (result.deleted) {
914
+ completed.push(result.entry)
915
+ logger.info(`permanently deleted ${result.entry.sessionId} (${result.entry.trashId})`)
916
+ continue
917
+ }
918
+ annotated.push({
919
+ ...result.entry,
920
+ lastError: errorText(`The session log could not be deleted: ${messageOf(result.error)}`),
921
+ })
922
+ failures.push({
923
+ entry: result.entry,
924
+ error: new TrashError('IO_ERROR', 'The session log could not be deleted.', { trashId: result.entry.trashId }),
925
+ phase: 'delete',
926
+ })
927
+ }
928
+
929
+ let settled = store.entries()
930
+ for (const entry of completed) settled = dropEntry(settled, entry.sessionId)
931
+ for (const entry of annotated) settled = upsertEntry(settled, entry)
932
+ try {
933
+ await store.commit(settled)
934
+ } catch (error) {
935
+ const failure = new TrashError(
936
+ 'INDEX_WRITE_FAILED',
937
+ 'The session logs were deleted, but the trash index could not be updated.',
938
+ { code: error instanceof TrashError ? error.code : null },
939
+ )
940
+ for (const entry of completed) failures.push({ entry, error: failure, phase: 'drop' })
941
+ // The deleted objects no longer match the purging entries; the next
942
+ // recovery drops them, so nothing is reported as successfully purged.
943
+ return { purged: 0, failures }
944
+ }
945
+ await mapLimit(annotated, PURGE_CONCURRENCY, async (entry) => {
946
+ await store.writeMeta(entry).catch((error) => {
947
+ logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
948
+ })
949
+ })
950
+ return { purged: completed.length, failures }
951
+ }
952
+
527
953
  /**
528
954
  * Permanently remove one object directory and its index entry. The caller
529
955
  * owns the trash lock.
@@ -532,26 +958,35 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
532
958
  * @returns {Promise<void>} resolution once the object and entry are gone.
533
959
  */
534
960
  async function purgeInsideLock(entry) {
535
- await assertSafeObject(entry)
536
- const purging = { ...entry, state: 'purging', lastError: null }
537
- await store.commit(upsertEntry(store.entries(), purging))
538
- await store.writeMeta(purging).catch((error) => {
539
- logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
540
- })
541
- try {
542
- await store.removeObjectDir(entry.trashId)
543
- } catch (error) {
544
- await markFailure(purging, `The session log could not be deleted: ${messageOf(error)}`)
545
- throw new TrashError('IO_ERROR', 'The session log could not be deleted.', { trashId: entry.trashId })
546
- }
961
+ const result = await purgeManyInsideLock([entry])
962
+ if (result.failures.length > 0) throw result.failures[0].error
963
+ }
964
+
965
+ /**
966
+ * Drop a batch of `missing` entries from the index in one write and remove
967
+ * their plugin-owned remnants. No session file is deleted. The caller owns
968
+ * the trash lock.
969
+ *
970
+ * @param {readonly object[]} entries `missing` index entries to forget.
971
+ * @returns {Promise<number>} how many entries were removed from the index.
972
+ */
973
+ async function forgetManyInsideLock(entries) {
974
+ if (entries.length === 0) return 0
975
+ let next = store.entries()
976
+ for (const entry of entries) next = dropEntry(next, entry.sessionId)
547
977
  try {
548
- await store.commit(dropEntry(store.entries(), entry.sessionId))
978
+ await store.commit(next)
549
979
  } catch (error) {
550
- throw new TrashError('INDEX_WRITE_FAILED', 'The session log was deleted, but the trash index could not be updated.', {
980
+ throw new TrashError('INDEX_WRITE_FAILED', 'The trash index could not be updated.', {
551
981
  code: error instanceof TrashError ? error.code : null,
552
982
  })
553
983
  }
554
- logger.info(`permanently deleted ${entry.sessionId} (${entry.trashId})`)
984
+ for (const entry of entries) {
985
+ await store.removeObjectRemnants(entry.trashId).catch((error) => {
986
+ logger.warn(`cannot remove the remnants of ${entry.trashId}: ${messageOf(error)}`)
987
+ })
988
+ }
989
+ return entries.length
555
990
  }
556
991
 
557
992
  /**
@@ -562,28 +997,20 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
562
997
  * @returns {Promise<{ sessionId: string, title: string }>} the removed identity.
563
998
  */
564
999
  async function forgetInsideLock(entry) {
565
- try {
566
- await store.commit(dropEntry(store.entries(), entry.sessionId))
567
- } catch (error) {
568
- throw new TrashError('INDEX_WRITE_FAILED', 'The trash index could not be updated.', {
569
- code: error instanceof TrashError ? error.code : null,
570
- })
571
- }
572
- await store.removeObjectRemnants(entry.trashId).catch((error) => {
573
- logger.warn(`cannot remove the remnants of ${entry.trashId}: ${messageOf(error)}`)
574
- })
1000
+ await forgetManyInsideLock([entry])
575
1001
  return { sessionId: entry.sessionId, title: entry.title }
576
1002
  }
577
1003
 
578
1004
  /**
579
- * Move one cold session into the trash.
1005
+ * Move one cold session tree into the trash. A session that owns only hidden
1006
+ * `origin: 'subagent'` descendants moves together with them.
580
1007
  *
581
1008
  * @param {string} sessionId session to trash.
582
1009
  * @returns {Promise<object>} the committed `trashed` entry.
583
1010
  */
584
1011
  async function trash(sessionId) {
585
1012
  const startedAt = Date.now()
586
- const result = await store.withLock(async () => {
1013
+ return store.withLock(async () => {
587
1014
  if (store.isProtected()) {
588
1015
  throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
589
1016
  }
@@ -603,28 +1030,65 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
603
1030
  throw new TrashError('ALREADY_TRASHED', 'This session is already recorded in the trash.')
604
1031
  }
605
1032
  }
606
- const snapshot = await stat(sessionId)
607
- if (snapshot === undefined) throw new TrashError('SESSION_NOT_FOUND', 'This session is not in session storage.')
608
- if (isLive(sessionId)) throw new TrashError('SESSION_LIVE', 'This session is open right now.')
609
- const location = snapshot.location
610
- if (location === undefined || location === null || location.kind !== 'jsonl' || typeof location.path !== 'string') {
611
- throw new TrashError('SESSION_NOT_MATERIALIZED', 'This session has no movable JSONL log.')
612
- }
613
- const resolvedLogPath = path.resolve(location.path)
614
- if (!path.isAbsolute(location.path)) {
615
- throw new TrashError('STORAGE_UNSUPPORTED', 'The session log path is not absolute.')
616
- }
617
- const sessionDir = path.dirname(resolvedLogPath)
618
- const projectDir = path.dirname(sessionDir)
619
- if (!isSafeDirectoryPath(sessionDir) || (await entryKind(sessionDir)) !== 'directory') {
620
- throw new TrashError('UNSAFE_SESSION_DIR', 'The session directory is not a plain directory this plugin can move.')
1033
+
1034
+ const plan = Array.isArray(verdict.members) ? verdict.members : []
1035
+ if (plan.length === 0) throw new TrashError('SESSION_NOT_FOUND', 'This session is not in session storage.')
1036
+ const members = []
1037
+ let parentSnapshot
1038
+ for (let index = 0; index < plan.length; index += 1) {
1039
+ const planned = plan[index]
1040
+ const snapshot = await stat(planned.sessionId)
1041
+ if (snapshot === undefined || snapshot === null) {
1042
+ throw new TrashError('SESSION_NOT_FOUND', 'A session in this lineage is not in session storage.', {
1043
+ sessionId: planned.sessionId,
1044
+ })
1045
+ }
1046
+ if (index === 0) parentSnapshot = snapshot
1047
+ const location = snapshot.location
1048
+ if (
1049
+ location === undefined ||
1050
+ location === null ||
1051
+ typeof location.path !== 'string' ||
1052
+ location.kind !== 'jsonl' ||
1053
+ !path.isAbsolute(location.path)
1054
+ ) {
1055
+ throw new TrashError('SESSION_NOT_MATERIALIZED', 'A session in this lineage has no movable JSONL log.', {
1056
+ sessionId: planned.sessionId,
1057
+ })
1058
+ }
1059
+ const resolvedLogPath = path.resolve(location.path)
1060
+ const sessionDir = path.dirname(resolvedLogPath)
1061
+ const projectDir = path.dirname(sessionDir)
1062
+ if (!isSafeDirectoryPath(sessionDir) || (await kindOf(sessionDir)) !== 'directory') {
1063
+ throw new TrashError('UNSAFE_SESSION_DIR', 'A session directory is not a plain directory this plugin can move.', {
1064
+ sessionId: planned.sessionId,
1065
+ })
1066
+ }
1067
+ if (isLive(planned.sessionId)) {
1068
+ throw new TrashError('SESSION_LIVE', 'A session in this lineage became live before it could be moved.', {
1069
+ sessionId: planned.sessionId,
1070
+ })
1071
+ }
1072
+ const measured = await measureDirectory(sessionDir)
1073
+ const title = await resolveTitle(planned.sessionId, snapshot.header)
1074
+ members.push({
1075
+ sessionId: planned.sessionId,
1076
+ origin: index === 0 ? null : 'subagent',
1077
+ title,
1078
+ originalSessionDir: sessionDir,
1079
+ originalLogPath: resolvedLogPath,
1080
+ persistenceProjectDirHint: projectDir,
1081
+ formatStatus: snapshot.formatStatus === 'migration-required' ? 'migration-required' : 'current',
1082
+ sizeBytes: measured.sizeBytes,
1083
+ fileCount: measured.fileCount,
1084
+ })
621
1085
  }
622
1086
 
623
1087
  let trashId
624
1088
  for (let attempt = 0; attempt < 5; attempt += 1) {
625
1089
  const candidate = createTrashId()
626
1090
  const indexed = store.entries().some((entry) => entry.trashId === candidate)
627
- if (!indexed && (await entryKind(store.objectDir(candidate))) === 'missing') {
1091
+ if (!indexed && (await kindOf(store.objectDir(candidate))) === 'missing') {
628
1092
  trashId = candidate
629
1093
  break
630
1094
  }
@@ -639,32 +1103,34 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
639
1103
  phase: 'create-object',
640
1104
  })
641
1105
  }
642
- let entry
1106
+
1107
+ const totals = aggregateMetrics(members)
1108
+ const parent = members[0]
1109
+ const header = parentSnapshot?.header ?? {}
1110
+ const originalCwd = typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : null
1111
+ const workspaceInfo = findOwningWorkspace(sessionId)
1112
+ const entry = {
1113
+ sessionId,
1114
+ trashId,
1115
+ state: 'trashing',
1116
+ title: parent.title,
1117
+ trashedAt: 0,
1118
+ startedAt,
1119
+ originalCwd,
1120
+ workspaceExistsAtDeletion: originalCwd === null ? null : await directoryExists(originalCwd),
1121
+ originalWorkspaceId: workspaceInfo?.workspace?.id ?? null,
1122
+ originalWorkspaceTitle: workspaceInfo?.workspace?.title ?? null,
1123
+ originalNextSessionId: workspaceInfo?.nextSessionId ?? null,
1124
+ originalSessionDir: parent.originalSessionDir,
1125
+ originalLogPath: parent.originalLogPath,
1126
+ persistenceProjectDirHint: parent.persistenceProjectDirHint,
1127
+ formatStatus: parent.formatStatus,
1128
+ sizeBytes: totals.sizeBytes,
1129
+ fileCount: totals.fileCount,
1130
+ members,
1131
+ lastError: null,
1132
+ }
643
1133
  try {
644
- const measured = await measureDirectory(sessionDir)
645
- const title = await resolveTitle(sessionId, snapshot.header)
646
- const originalCwd = cwdOf(snapshot) === '' ? null : cwdOf(snapshot)
647
- const workspaceInfo = findOwningWorkspace(sessionId)
648
- entry = {
649
- sessionId,
650
- trashId,
651
- state: 'trashing',
652
- title,
653
- trashedAt: 0,
654
- startedAt,
655
- originalCwd,
656
- workspaceExistsAtDeletion: originalCwd === null ? null : await directoryExists(originalCwd),
657
- originalWorkspaceId: workspaceInfo?.workspace?.id ?? null,
658
- originalWorkspaceTitle: workspaceInfo?.workspace?.title ?? null,
659
- originalNextSessionId: workspaceInfo?.nextSessionId ?? null,
660
- originalSessionDir: sessionDir,
661
- originalLogPath: resolvedLogPath,
662
- persistenceProjectDirHint: projectDir,
663
- formatStatus: snapshot.formatStatus ?? 'current',
664
- sizeBytes: measured.sizeBytes,
665
- fileCount: measured.fileCount,
666
- lastError: null,
667
- }
668
1134
  await store.writeMeta(entry)
669
1135
  await store.commit(upsertEntry(store.entries(), entry))
670
1136
  } catch (error) {
@@ -674,88 +1140,69 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
674
1140
  : new TrashError('IO_ERROR', `Cannot prepare the trash entry: ${messageOf(error)}`)
675
1141
  }
676
1142
 
677
- // Another process may have opened or resumed the session between the
678
- // preflight and the move. Re-check live state while the trash lock is
679
- // held; the source is still untouched at this point.
680
- if (isLive(sessionId)) {
1143
+ // Another process may have opened or resumed a member between the
1144
+ // preflight and the move. Re-check every member while the trash lock is
1145
+ // held; the sources are still untouched at this point.
1146
+ const raced = members.find((member) => isLive(member.sessionId))
1147
+ if (raced !== undefined) {
681
1148
  await rollbackTrash(sessionId, trashId)
682
- throw new TrashError('SESSION_LIVE', 'This session became live before it could be moved.')
1149
+ throw new TrashError('SESSION_LIVE', 'A session in this lineage became live before it could be moved.', {
1150
+ sessionId: raced.sessionId,
1151
+ })
683
1152
  }
684
1153
 
1154
+ let moveError
685
1155
  try {
686
- await moveDirectory(sessionDir, store.sessionPath(trashId), entry, { logger })
1156
+ await moveMembersToTrash(entry)
687
1157
  } catch (error) {
688
- let settled
689
- try {
690
- settled = await settleFailedTrashMove(entry, error)
691
- } catch (settleError) {
692
- await markFailure(entry, messageOf(settleError))
693
- throw settleError
694
- }
695
- if (settled !== undefined) return settled
696
- throw error instanceof TrashError
697
- ? error
698
- : new TrashError('IO_ERROR', `Cannot move the session into the trash: ${messageOf(error)}`)
1158
+ moveError = error
699
1159
  }
700
-
701
- let measured
1160
+ let settled
702
1161
  try {
703
- measured = await measureDirectory(store.sessionPath(trashId))
1162
+ settled = await resolveTrashing(entry)
704
1163
  } catch (error) {
705
- let settled
706
- try {
707
- settled = await settleFailedTrashMove(entry, error)
708
- } catch (settleError) {
709
- await markFailure(entry, messageOf(settleError))
710
- throw settleError
711
- }
712
- if (settled !== undefined) return settled
713
- throw new TrashError('MOVE_VERIFY_FAILED', `Cannot verify the moved session: ${messageOf(error)}`)
714
- }
715
- if (!metricsMatch(entry, measured)) {
716
- const movedBack = await moveBack(store.sessionPath(trashId), sessionDir, entry)
717
- if (!movedBack) {
718
- await markFailure(entry, 'The moved session does not match its recorded size, and moving it back failed.')
719
- throw new TrashError('MOVE_CONFLICT', 'The session was moved but could not be verified or rolled back.')
720
- }
721
- await rollbackTrash(sessionId, trashId)
722
- throw new TrashError('MOVE_VERIFY_FAILED', 'The moved session does not match its recorded size.', {
723
- expected: { sizeBytes: entry.sizeBytes, fileCount: entry.fileCount },
724
- measured,
1164
+ throw error instanceof TrashError
1165
+ ? error
1166
+ : new TrashError('IO_ERROR', `Cannot move the session tree into the trash: ${messageOf(error)}`)
1167
+ }
1168
+ if (settled.kind === 'missing') {
1169
+ await store.commit(upsertEntry(store.entries(), settled.entry))
1170
+ await store.writeMeta(settled.entry).catch(() => {})
1171
+ throw new TrashError('MOVE_CONFLICT', 'A session directory disappeared during the move.', {
1172
+ sessionId: settled.entry.sessionId,
1173
+ lastError: settled.entry.lastError,
725
1174
  })
726
1175
  }
727
- const hidden = await waitForVisibility(sessionId, (current) => current === undefined)
728
- if (!hidden) {
729
- const movedBack = await moveBack(store.sessionPath(trashId), sessionDir, entry)
730
- if (!movedBack) {
731
- await markFailure(entry, 'Session storage still reports the moved session, and moving it back failed.')
732
- throw new TrashError('MOVE_CONFLICT', 'The session was moved but session storage still reports it.')
733
- }
1176
+ if (settled.kind === 'rolledBack') {
734
1177
  await rollbackTrash(sessionId, trashId)
735
- throw new TrashError('MOVE_VERIFY_FAILED', 'Session storage still reports the session after the move.')
1178
+ throw moveError instanceof TrashError
1179
+ ? moveError
1180
+ : new TrashError('MOVE_VERIFY_FAILED', 'The session tree could not be moved into the trash.')
736
1181
  }
737
-
738
- const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
739
- try {
740
- await store.commit(upsertEntry(store.entries(), next))
741
- } catch (error) {
742
- await markFailure(entry, `The trash index could not be committed: ${messageOf(error)}`)
743
- throw new TrashError('INDEX_WRITE_FAILED', 'The session moved, but the trash index could not be updated.')
1182
+ const hidden = await waitForMembersHidden(entry)
1183
+ if (!hidden.hidden) {
1184
+ const rows = await memberRows(entry)
1185
+ const rollback = await moveMembersBackFromTrash(entry, rows.filter((row) => row.trash))
1186
+ if (rollback.length > 0) {
1187
+ await markFailure(entry, 'Session storage still reports the moved session tree, and moving it back failed.')
1188
+ throw new TrashError('MOVE_CONFLICT', 'The session tree was moved but session storage still reports it.')
1189
+ }
1190
+ await rollbackTrash(sessionId, trashId)
1191
+ throw new TrashError('MOVE_VERIFY_FAILED', 'Session storage still reports the session after the move.', {
1192
+ sessionId: hidden.sessionId,
1193
+ })
744
1194
  }
745
- await store.writeMeta(next).catch((error) => {
746
- logger.warn(`cannot rewrite trash metadata for ${trashId}: ${messageOf(error)}`)
747
- })
748
- eligibility.invalidate(sessionId)
749
- emit('api-session/removed', sessionId)
750
- logger.info(`trashed ${sessionId} as ${trashId} in ${Date.now() - startedAt}ms`)
1195
+ const next = await commitTrashed(entry, { emitRemoval: true })
1196
+ logger.info(
1197
+ `trashed ${sessionId} as ${trashId} with ${members.length - 1} child sessions in ${Date.now() - startedAt}ms`,
1198
+ )
751
1199
  return next
752
1200
  })
753
- return result
754
1201
  }
755
1202
 
756
1203
  /**
757
- * Restore one trashed session to its original directory. Phase 2 supports
758
- * `normal` only; orphan restore arrives later.
1204
+ * Restore one trashed session tree to its original directories. Phase 2
1205
+ * supports `normal` only; orphan restore arrives later.
759
1206
  *
760
1207
  * @param {string} sessionId session to restore.
761
1208
  * @param {'normal' | 'orphan'} [mode] restore mode; orphan restores the log and detaches the Workspace.
@@ -776,28 +1223,49 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
776
1223
  throw new TrashError('TRASH_STATE_INVALID', `The trash entry is in state "${entry.state}" and cannot be restored.`)
777
1224
  }
778
1225
  await assertSafeObject(entry)
779
- const target = path.resolve(String(entry.originalSessionDir))
780
- const projectDirHint = path.resolve(String(entry.persistenceProjectDirHint))
1226
+ const members = membersOf(entry)
1227
+ const parent = members[0]
781
1228
  if (
782
- !isSafeDirectoryPath(target) ||
783
- !samePath(path.dirname(target), projectDirHint) ||
784
- isInside(paths.root, target) ||
785
- !isInside(paths.root, store.objectDir(entry.trashId)) ||
786
- typeof entry.originalLogPath !== 'string' ||
787
- !samePath(path.dirname(path.resolve(entry.originalLogPath)), target)
1229
+ !samePath(path.resolve(String(entry.originalSessionDir)), path.resolve(String(parent.originalSessionDir))) ||
1230
+ !samePath(path.resolve(String(entry.originalLogPath)), path.resolve(String(parent.originalLogPath))) ||
1231
+ !samePath(
1232
+ path.resolve(String(entry.persistenceProjectDirHint)),
1233
+ path.resolve(String(parent.persistenceProjectDirHint)),
1234
+ )
788
1235
  ) {
789
- throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target failed the path safety check.')
1236
+ throw new TrashError('UNSAFE_RESTORE_PATH', 'The trash entry names inconsistent restore paths.')
790
1237
  }
791
- const projectDirKind = await entryKind(projectDirHint)
792
- if (projectDirKind !== 'missing' && projectDirKind !== 'directory') {
793
- throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target parent is not a plain directory.')
794
- }
795
- const targetKind = await entryKind(target)
796
- if (targetKind !== 'missing') {
797
- throw new TrashError('RESTORE_TARGET_EXISTS', 'A directory already exists at the restore target.')
1238
+ for (let index = 0; index < members.length; index += 1) {
1239
+ const member = members[index]
1240
+ const target = path.resolve(String(member.originalSessionDir))
1241
+ const projectDirHint = path.resolve(String(member.persistenceProjectDirHint))
1242
+ if (
1243
+ !isSafeDirectoryPath(target) ||
1244
+ !samePath(path.dirname(target), projectDirHint) ||
1245
+ isInside(paths.root, target) ||
1246
+ typeof member.originalLogPath !== 'string' ||
1247
+ !samePath(path.dirname(path.resolve(member.originalLogPath)), target)
1248
+ ) {
1249
+ throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target failed the path safety check.', {
1250
+ sessionId: member.sessionId,
1251
+ })
1252
+ }
1253
+ const projectDirKind = await kindOf(projectDirHint)
1254
+ if (projectDirKind !== 'missing' && projectDirKind !== 'directory') {
1255
+ throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target parent is not a plain directory.', {
1256
+ sessionId: member.sessionId,
1257
+ })
1258
+ }
1259
+ const targetKind = await kindOf(target)
1260
+ if (targetKind !== 'missing') {
1261
+ throw new TrashError('RESTORE_TARGET_EXISTS', 'A directory already exists at the restore target.', {
1262
+ sessionId: member.sessionId,
1263
+ })
1264
+ }
798
1265
  }
799
- if (!(await trashSessionExists(entry))) {
800
- const missing = { ...entry, state: 'missing', lastError: 'The trash object directory is missing.' }
1266
+ const presence = await memberPresence(store, entry)
1267
+ if (presence.present !== presence.total) {
1268
+ const missing = { ...entry, state: 'missing', lastError: 'The trash copy of this session is missing.' }
801
1269
  await store.commit(upsertEntry(store.entries(), missing))
802
1270
  await store.writeMeta(missing).catch(() => {})
803
1271
  throw new TrashError('TRASH_DIR_MISSING', 'The trash copy of this session is missing.')
@@ -814,76 +1282,56 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
814
1282
  logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
815
1283
  })
816
1284
 
1285
+ let moveError
1286
+ const rows = members.map((_, index) => ({ index }))
1287
+ const moveFailures = await moveMembersBackFromTrash(restoring, rows)
1288
+ if (moveFailures.length > 0) moveError = moveFailures[0].error
1289
+ let settled
817
1290
  try {
818
- await moveDirectory(store.sessionPath(entry.trashId), target, entry, { logger })
1291
+ settled = await resolveRestoring(restoring)
819
1292
  } catch (error) {
820
- // A target created between the pre-check and the move belongs to
821
- // someone else: never treat it as the restored copy.
822
- if (error instanceof TrashError && error.code === 'RESTORE_TARGET_EXISTS') throw error
823
- let completed
824
- try {
825
- completed = await settleFailedRestoreMove(restoring, error)
826
- } catch (settleError) {
827
- await markFailure(restoring, messageOf(settleError))
828
- throw settleError
829
- }
830
- if (!completed) {
831
- throw error instanceof TrashError
832
- ? error
833
- : new TrashError('IO_ERROR', `Cannot move the session back: ${messageOf(error)}`)
834
- }
1293
+ throw error instanceof TrashError
1294
+ ? error
1295
+ : new TrashError('IO_ERROR', `Cannot move the session tree back: ${messageOf(error)}`)
835
1296
  }
836
-
837
- let measured
838
- try {
839
- measured = await measureDirectory(target)
840
- } catch (error) {
841
- throw new TrashError('RESTORE_VERIFY_FAILED', `Cannot verify the restored session: ${messageOf(error)}`)
1297
+ if (settled.kind === 'missing') {
1298
+ await store.commit(upsertEntry(store.entries(), settled.entry))
1299
+ await store.writeMeta(settled.entry).catch(() => {})
1300
+ throw new TrashError('RESTORE_VERIFY_FAILED', settled.entry.lastError ?? 'The restored session tree is missing.')
842
1301
  }
843
- if (!metricsMatch(entry, measured)) {
844
- const rolledBack = await moveBack(target, store.sessionPath(entry.trashId), measured)
845
- if (!rolledBack) {
846
- await markFailure(restoring, 'The restored session does not match its recorded size, and moving it back failed.')
847
- throw new TrashError('MOVE_CONFLICT', 'The restored session does not match its recorded size.')
848
- }
849
- const next = { ...restoring, state: 'trashed', lastError: 'The restored session did not match its recorded size.' }
850
- await store.commit(upsertEntry(store.entries(), next))
851
- await store.writeMeta(next).catch(() => {})
852
- throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session does not match its recorded size.', {
853
- expected: { sizeBytes: entry.sizeBytes, fileCount: entry.fileCount },
854
- measured,
855
- })
1302
+ if (settled.kind === 'trashed') {
1303
+ await store.commit(upsertEntry(store.entries(), settled.entry))
1304
+ await store.writeMeta(settled.entry).catch(() => {})
1305
+ throw moveError instanceof TrashError
1306
+ ? moveError
1307
+ : new TrashError('RESTORE_VERIFY_FAILED', 'The session tree could not be restored.')
856
1308
  }
857
- const visible = await waitForVisibility(
858
- sessionId,
859
- (current) => current !== undefined && current !== null && current.location !== undefined,
860
- )
861
- if (!visible) {
862
- const rolledBack = await moveBack(target, store.sessionPath(entry.trashId), measured)
863
- if (!rolledBack) {
864
- await markFailure(restoring, 'Session storage cannot see the restored session, and moving it back failed.')
865
- throw new TrashError('MOVE_CONFLICT', 'Session storage cannot see the restored session.')
866
- }
867
- const next = { ...restoring, state: 'trashed', lastError: 'The restored session was not visible to session storage.' }
868
- await store.commit(upsertEntry(store.entries(), next))
869
- await store.writeMeta(next).catch(() => {})
870
- throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session was not visible to session storage.')
871
- }
872
-
873
- const attachment = mode === 'orphan' ? await detachWorkspace(entry) : await reattachWorkspace(entry)
874
- const restoredSnapshot = await stat(sessionId).catch(() => undefined)
875
1309
 
1310
+ let attachment
876
1311
  try {
877
- await store.commit(dropEntry(store.entries(), sessionId))
1312
+ attachment = await completeRestore(restoring, { mode })
878
1313
  } catch (error) {
879
- await markFailure(restoring, `The trash index could not be committed after restore: ${messageOf(error)}`)
880
- throw new TrashError('INDEX_WRITE_FAILED', 'The session was restored, but the trash index could not be updated.')
1314
+ if (error instanceof TrashError && error.code === 'RESTORE_VERIFY_FAILED') {
1315
+ const current = await memberRows(restoring)
1316
+ const rollback = await moveMembersIntoTrash(
1317
+ restoring,
1318
+ current.filter((row) => row.source && !row.trash),
1319
+ )
1320
+ if (rollback.length > 0) {
1321
+ await markFailure(restoring, 'The restored session tree is not visible, and rolling it back failed.')
1322
+ throw new TrashError('MOVE_CONFLICT', 'The restored session tree is not visible, and rolling it back failed.')
1323
+ }
1324
+ const next = {
1325
+ ...restoring,
1326
+ state: 'trashed',
1327
+ trashedAt: entry.trashedAt,
1328
+ lastError: 'The restored session was not visible to session storage.',
1329
+ }
1330
+ await store.commit(upsertEntry(store.entries(), next))
1331
+ await store.writeMeta(next).catch(() => {})
1332
+ }
1333
+ throw error
881
1334
  }
882
- await store.removeObjectDir(entry.trashId).catch((error) => {
883
- logger.warn(`cannot remove the restored trash object ${entry.trashId}: ${messageOf(error)}`)
884
- })
885
- eligibility.invalidate(sessionId)
886
- emit('api-session/added', buildSummary(sessionId, entry.title, restoredSnapshot))
887
1335
  logger.info(`restored ${sessionId} from ${entry.trashId} (${mode}) in ${Date.now() - startedAt}ms`)
888
1336
  return {
889
1337
  sessionId,
@@ -898,12 +1346,16 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
898
1346
  /**
899
1347
  * Apply the crash matrix to every index entry. The caller must hold the trash
900
1348
  * lock. It never deletes an unindexed object and never removes a `missing`
901
- * entry on its own.
1349
+ * entry on its own. An optional presence map lets the caller reuse the probe
1350
+ * results from the preceding reconcile instead of stat-ing every member
1351
+ * directory again.
902
1352
  *
1353
+ * @param {Map<string, { total: number, present: number, missing: string[] }>} [presenceByTrashId] reconcile probe results keyed by object identity.
903
1354
  * @returns {Promise<{ changed: number, completed: number, rolledBack: number, missing: number }>} recovery summary.
904
1355
  */
905
- async function recoverInsideLock() {
1356
+ async function recoverInsideLock(presenceByTrashId = new Map()) {
906
1357
  let entries = [...store.entries()]
1358
+ const metaUpdates = []
907
1359
  let changed = 0
908
1360
  let completed = 0
909
1361
  let rolledBack = 0
@@ -911,6 +1363,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
911
1363
  const commit = (next) => {
912
1364
  if (next === undefined) return
913
1365
  entries = upsertEntry(entries, next)
1366
+ metaUpdates.push(next)
914
1367
  }
915
1368
  const drop = (sessionId) => {
916
1369
  entries = dropEntry(entries, sessionId)
@@ -930,40 +1383,19 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
930
1383
 
931
1384
  if (original.state === 'trashing') {
932
1385
  await assertSafeObject(original)
933
- const sourceExists = (await entryKind(original.originalSessionDir)) === 'directory'
934
- const targetExists = await trashSessionExists(original)
935
- if (!sourceExists && !targetExists) {
936
- commit({ ...original, state: 'missing', lastError: 'Both the session source and the trash copy are missing.' })
1386
+ const settled = await resolveTrashing(original)
1387
+ if (settled.kind === 'missing') {
1388
+ commit(settled.entry)
937
1389
  missing += 1
938
- } else if (!sourceExists && targetExists) {
939
- const measured = await measureDirectory(store.sessionPath(original.trashId))
940
- if (!metricsMatch(original, measured)) {
941
- commit(fail({ ...original, state: 'trashing' }, 'The trash copy does not match the recorded size.'))
942
- } else {
943
- commit({ ...original, state: 'trashed', trashedAt: original.startedAt || Date.now(), lastError: null })
944
- completed += 1
945
- }
946
- } else if (sourceExists && !targetExists) {
1390
+ } else if (settled.kind === 'rolledBack') {
947
1391
  await store.removeObjectDir(original.trashId).catch((error) => {
948
1392
  logger.warn(`cannot remove the rolled-back trash object ${original.trashId}: ${messageOf(error)}`)
949
1393
  })
950
1394
  drop(original.sessionId)
951
1395
  rolledBack += 1
952
1396
  } else {
953
- const measured = await measureDirectory(store.sessionPath(original.trashId))
954
- if (!metricsMatch(original, measured)) {
955
- await store.removeObjectDir(original.trashId)
956
- drop(original.sessionId)
957
- rolledBack += 1
958
- } else {
959
- try {
960
- await store.removeSessionSource(original.originalSessionDir)
961
- commit({ ...original, state: 'trashed', trashedAt: original.startedAt || Date.now(), lastError: null })
962
- completed += 1
963
- } catch (error) {
964
- commit(fail(original, `Cannot remove the source after completing the move: ${messageOf(error)}`))
965
- }
966
- }
1397
+ commit({ ...original, state: 'trashed', trashedAt: original.startedAt || Date.now(), lastError: null })
1398
+ completed += 1
967
1399
  }
968
1400
  changed += 1
969
1401
  continue
@@ -971,15 +1403,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
971
1403
 
972
1404
  if (original.state === 'restoring') {
973
1405
  await assertSafeObject(original)
974
- const sourceExists = (await entryKind(original.originalSessionDir)) === 'directory'
975
- const targetExists = await trashSessionExists(original)
976
- if (sourceExists) {
977
- const measured = await measureDirectory(original.originalSessionDir)
978
- const visible = metricsMatch(original, measured)
979
- ? await waitForVisibility(original.sessionId, (current) => current !== undefined && current !== null && current.location !== undefined)
980
- : false
981
- if (!visible) {
982
- commit(fail(original, 'The restored session is not visible to session storage.'))
1406
+ const settled = await resolveRestoring(original)
1407
+ if (settled.kind === 'missing') {
1408
+ commit(settled.entry)
1409
+ missing += 1
1410
+ } else if (settled.kind === 'trashed') {
1411
+ commit(settled.entry)
1412
+ rolledBack += 1
1413
+ } else {
1414
+ const visible = await waitForMembersVisible(original)
1415
+ if (!visible.visible) {
1416
+ commit(fail(original, 'The restored session tree is not visible to session storage.'))
983
1417
  changed += 1
984
1418
  continue
985
1419
  }
@@ -988,15 +1422,6 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
988
1422
  })
989
1423
  drop(original.sessionId)
990
1424
  completed += 1
991
- changed += 1
992
- continue
993
- }
994
- if (targetExists) {
995
- commit({ ...original, state: 'trashed', lastError: 'Recovered the trash copy after an interrupted restore.' })
996
- rolledBack += 1
997
- } else {
998
- commit({ ...original, state: 'missing', lastError: 'Both the trash copy and the restore target are missing.' })
999
- missing += 1
1000
1425
  }
1001
1426
  changed += 1
1002
1427
  continue
@@ -1004,22 +1429,23 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1004
1429
 
1005
1430
  if (original.state === 'trashed') {
1006
1431
  await assertSafeObject(original)
1007
- const sourceExists = (await entryKind(original.originalSessionDir)) === 'directory'
1008
- const targetExists = await trashSessionExists(original)
1009
- if (!targetExists) {
1432
+ const presence = presenceByTrashId.get(original.trashId) ?? (await memberPresence(store, original))
1433
+ if (presence.present < presence.total) {
1010
1434
  commit({
1011
1435
  ...original,
1012
1436
  state: 'missing',
1013
- lastError: sourceExists
1014
- ? 'The trash copy is missing; the original session directory is still present.'
1015
- : 'The trash copy is missing.',
1437
+ lastError:
1438
+ presence.present === 0
1439
+ ? 'The trash copy is missing.'
1440
+ : `The trash copy is missing these member sessions: ${presence.missing.join(', ')}.`,
1016
1441
  })
1017
1442
  missing += 1
1018
1443
  changed += 1
1019
1444
  continue
1020
1445
  }
1021
- if (sourceExists) {
1022
- commit(fail(original, 'The original session directory unexpectedly exists alongside the trash copy; both were kept.'))
1446
+ const sources = await sourceRows(original)
1447
+ if (sources.some((row) => row.source)) {
1448
+ commit(fail(original, 'An original session directory unexpectedly exists alongside the trash copy; both were kept.'))
1023
1449
  changed += 1
1024
1450
  }
1025
1451
  }
@@ -1031,7 +1457,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1031
1457
 
1032
1458
  if (changed > 0) {
1033
1459
  await store.commit(entries)
1034
- for (const entry of entries) {
1460
+ for (const entry of metaUpdates) {
1035
1461
  await store.writeMeta(entry).catch((error) => {
1036
1462
  logger.warn(`cannot refresh trash metadata for ${entry.trashId}: ${messageOf(error)}`)
1037
1463
  })
@@ -1058,7 +1484,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1058
1484
  return summary
1059
1485
  } catch (error) {
1060
1486
  logger.warn(`trash index reconciliation failed: ${messageOf(error)}`)
1061
- return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0 }
1487
+ return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0, presence: new Map() }
1062
1488
  }
1063
1489
  }
1064
1490
 
@@ -1075,18 +1501,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1075
1501
  throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses cleanup.')
1076
1502
  }
1077
1503
  const reconcile = await reconcileInsideLock(now)
1078
- const recovery = await recoverInsideLock()
1504
+ const recovery = await recoverInsideLock(reconcile.presence)
1079
1505
  let purged = 0
1080
1506
  let failed = 0
1081
1507
  if (allowPurge) {
1082
- for (const entry of [...store.entries()]) {
1083
- if (!isExpired(entry, now)) continue
1084
- try {
1085
- await purgeInsideLock(entry)
1086
- purged += 1
1087
- } catch (error) {
1088
- failed += 1
1089
- logger.warn(`cannot purge the expired session ${entry.sessionId}: ${messageOf(error)}`)
1508
+ const expired = [...store.entries()].filter((entry) => isExpired(entry, now))
1509
+ if (expired.length > 0) {
1510
+ const result = await purgeManyInsideLock(expired)
1511
+ purged = result.purged
1512
+ failed = result.failures.length
1513
+ for (const failure of result.failures) {
1514
+ logger.warn(`cannot purge the expired session ${failure.entry.sessionId}: ${messageOf(failure.error)}`)
1090
1515
  }
1091
1516
  }
1092
1517
  }
@@ -1156,34 +1581,59 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1156
1581
  throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses deletion.')
1157
1582
  }
1158
1583
  const reconcile = await reconcileInsideLock(Date.now())
1159
- const recovery = await recoverInsideLock()
1160
- let purged = 0
1161
- let forgotten = 0
1162
- let failed = 0
1584
+ const recovery = await recoverInsideLock(reconcile.presence)
1163
1585
  const failures = []
1586
+ const purgeTargets = []
1587
+ const forgetTargets = []
1588
+ let failed = 0
1164
1589
  for (const entry of [...store.entries()]) {
1590
+ if (entry.state === 'trashed') {
1591
+ purgeTargets.push(entry)
1592
+ continue
1593
+ }
1594
+ if (entry.state === 'missing') {
1595
+ forgetTargets.push(entry)
1596
+ continue
1597
+ }
1165
1598
  const title = typeof entry.title === 'string' ? entry.title : ''
1166
- try {
1167
- if (entry.state === 'trashed') {
1168
- await purgeInsideLock(entry)
1169
- purged += 1
1170
- continue
1171
- }
1172
- if (entry.state === 'missing') {
1173
- await forgetInsideLock(entry)
1174
- forgotten += 1
1175
- continue
1176
- }
1599
+ const message = `The trash entry is in state "${entry.state}" and cannot be cleaned up.`
1600
+ failed += 1
1601
+ failures.push({ sessionId: entry.sessionId, title, message })
1602
+ logger.warn(`cannot purge the session ${entry.sessionId}: ${message}`)
1603
+ }
1604
+
1605
+ let purged = 0
1606
+ if (purgeTargets.length > 0) {
1607
+ const result = await purgeManyInsideLock(purgeTargets)
1608
+ purged = result.purged
1609
+ for (const failure of result.failures) {
1177
1610
  failed += 1
1178
- const message = `The trash entry is in state "${entry.state}" and cannot be cleaned up.`
1179
- failures.push({ sessionId: entry.sessionId, title, message })
1180
- logger.warn(`cannot purge the session ${entry.sessionId}: ${message}`)
1611
+ failures.push({
1612
+ sessionId: failure.entry.sessionId,
1613
+ title: typeof failure.entry.title === 'string' ? failure.entry.title : '',
1614
+ message: messageOf(failure.error),
1615
+ })
1616
+ logger.warn(`cannot purge the trashed session ${failure.entry.sessionId}: ${messageOf(failure.error)}`)
1617
+ }
1618
+ }
1619
+
1620
+ let forgotten = 0
1621
+ if (forgetTargets.length > 0) {
1622
+ try {
1623
+ forgotten = await forgetManyInsideLock(forgetTargets)
1181
1624
  } catch (error) {
1182
- failed += 1
1183
- failures.push({ sessionId: entry.sessionId, title, message: messageOf(error) })
1184
- logger.warn(`cannot purge the trashed session ${entry.sessionId}: ${messageOf(error)}`)
1625
+ failed += forgetTargets.length
1626
+ for (const entry of forgetTargets) {
1627
+ failures.push({
1628
+ sessionId: entry.sessionId,
1629
+ title: typeof entry.title === 'string' ? entry.title : '',
1630
+ message: messageOf(error),
1631
+ })
1632
+ logger.warn(`cannot forget the missing session ${entry.sessionId}: ${messageOf(error)}`)
1633
+ }
1185
1634
  }
1186
1635
  }
1636
+
1187
1637
  logger.info(`bulk purge finished: purged ${purged}, forgotten ${forgotten}, failed ${failed}`)
1188
1638
  return {
1189
1639
  purged,
@@ -1272,8 +1722,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1272
1722
  try {
1273
1723
  summary = await store.withLock(async () => {
1274
1724
  if (store.isProtected()) return { changed: 0, completed: 0, rolledBack: 0, missing: 0 }
1275
- await reconcileInsideLock(Date.now())
1276
- return await recoverInsideLock()
1725
+ const reconcile = await reconcileInsideLock(Date.now())
1726
+ return await recoverInsideLock(reconcile.presence)
1277
1727
  })
1278
1728
  } catch (error) {
1279
1729
  if (error instanceof TrashError && error.code === 'LOCKED') {