@wangxiaocuo/dsh-session-trash 0.5.1 → 0.6.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.
@@ -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,8 @@ import {
12
13
  moveDirectory,
13
14
  samePath,
14
15
  } from './fsops.js'
15
- import { reconcileIndex, rebuildIndex } from './inventory.js'
16
+ import { reconcileIndex, rebuildIndex, memberPresence } from './inventory.js'
17
+ import { aggregateMetrics, memberList } from './members.js'
16
18
 
17
19
  /** How many short retries confirm a moved or restored session is visible. */
18
20
  const VISIBILITY_RETRIES = 5
@@ -170,9 +172,34 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
170
172
  return undefined
171
173
  }
172
174
 
175
+ /**
176
+ * @param {unknown} target path to probe.
177
+ * @returns {Promise<string>} the entry kind, treating an absent path as missing.
178
+ */
179
+ async function kindOf(target) {
180
+ return typeof target === 'string' && target !== '' ? entryKind(target) : 'missing'
181
+ }
182
+
183
+ /**
184
+ * @param {object} entry index entry.
185
+ * @returns {object[]} every owned directory, parent first.
186
+ */
187
+ function membersOf(entry) {
188
+ return memberList(entry)
189
+ }
190
+
191
+ /**
192
+ * @param {object} entry index entry.
193
+ * @param {number} index member position.
194
+ * @returns {string} this member's directory inside the trash object.
195
+ */
196
+ function memberTrashPath(entry, index) {
197
+ return store.memberPath(entry.trashId, index, membersOf(entry)[index].sessionId)
198
+ }
199
+
173
200
  /**
174
201
  * @param {object} entry trash index entry.
175
- * @returns {Promise<void>} resolution once the object directory is inside `objects/`.
202
+ * @returns {Promise<void>} resolution once every member path stays inside `objects/`.
176
203
  */
177
204
  async function assertSafeObject(entry) {
178
205
  if (!isSafeTrashId(entry.trashId)) {
@@ -182,14 +209,202 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
182
209
  if (!isInside(store.objectsDir, objectDir)) {
183
210
  throw new TrashError('IO_ERROR', 'The trash entry names an object outside the trash root.')
184
211
  }
212
+ const members = membersOf(entry)
213
+ for (let index = 0; index < members.length; index += 1) {
214
+ const memberPath = path.resolve(memberTrashPath(entry, index))
215
+ if (!isInside(objectDir, memberPath)) {
216
+ throw new TrashError('IO_ERROR', 'The trash entry names a member outside its object.')
217
+ }
218
+ }
185
219
  }
186
220
 
187
221
  /**
188
- * @param {object} entry trash index entry.
189
- * @returns {Promise<boolean>} whether the moved session directory still exists.
222
+ * @param {object} entry index entry.
223
+ * @returns {Promise<{ index: number, member: object, source: boolean, trash: boolean }[]>} member positions.
224
+ */
225
+ async function memberRows(entry) {
226
+ const members = membersOf(entry)
227
+ const rows = []
228
+ for (let index = 0; index < members.length; index += 1) {
229
+ const member = members[index]
230
+ rows.push({
231
+ index,
232
+ member,
233
+ source: (await kindOf(member.originalSessionDir)) === 'directory',
234
+ trash: (await kindOf(memberTrashPath(entry, index))) === 'directory',
235
+ })
236
+ }
237
+ return rows
238
+ }
239
+
240
+ /**
241
+ * @param {object} entry index entry.
242
+ * @param {number} index member position.
243
+ * @returns {Promise<void>} resolution once the member directory moved into its object.
244
+ */
245
+ async function moveMemberToTrash(entry, index) {
246
+ const member = membersOf(entry)[index]
247
+ await moveDirectory(member.originalSessionDir, memberTrashPath(entry, index), member, { logger })
248
+ }
249
+
250
+ /**
251
+ * @param {object} entry index entry.
252
+ * @param {number} index member position.
253
+ * @returns {Promise<void>} resolution once the member directory moved back to its original location.
254
+ */
255
+ async function moveMemberBack(entry, index) {
256
+ const member = membersOf(entry)[index]
257
+ await moveDirectory(memberTrashPath(entry, index), member.originalSessionDir, member, { logger })
258
+ }
259
+
260
+ /**
261
+ * @param {object} entry index entry.
262
+ * @param {readonly { index: number }[]} rows members to move into the object.
263
+ * @returns {Promise<{ index: number, error: unknown }[]>} per-member failures.
264
+ */
265
+ async function moveMembersIntoTrash(entry, rows) {
266
+ const failures = []
267
+ for (const row of [...rows].sort((left, right) => right.index - left.index)) {
268
+ try {
269
+ await moveMemberToTrash(entry, row.index)
270
+ } catch (error) {
271
+ failures.push({ index: row.index, error })
272
+ }
273
+ }
274
+ return failures
275
+ }
276
+
277
+ /**
278
+ * @param {object} entry index entry.
279
+ * @param {readonly { index: number }[]} rows members to move back to their original locations.
280
+ * @returns {Promise<{ index: number, error: unknown }[]>} per-member failures.
281
+ */
282
+ async function moveMembersBackFromTrash(entry, rows) {
283
+ const failures = []
284
+ for (const row of [...rows].sort((left, right) => left.index - right.index)) {
285
+ try {
286
+ await moveMemberBack(entry, row.index)
287
+ } catch (error) {
288
+ failures.push({ index: row.index, error })
289
+ }
290
+ }
291
+ return failures
292
+ }
293
+
294
+ /**
295
+ * Move every member directory into the trash object, children first and the
296
+ * visible parent last so the sidebar keeps the row until the move settles.
297
+ *
298
+ * @param {object} entry index entry.
299
+ * @returns {Promise<void>} resolution once every member directory moved.
300
+ */
301
+ async function moveMembersToTrash(entry) {
302
+ const rows = membersOf(entry).map((_, index) => ({ index }))
303
+ const failures = await moveMembersIntoTrash(entry, rows)
304
+ if (failures.length === 0) return
305
+ const failure = failures[0]
306
+ throw failure.error instanceof TrashError
307
+ ? failure.error
308
+ : new TrashError('IO_ERROR', `Cannot move a session directory into the trash: ${messageOf(failure.error)}`)
309
+ }
310
+
311
+ /**
312
+ * @param {object} entry index entry.
313
+ * @param {number} index member position.
314
+ * @returns {Promise<void>} resolution once the unusable trash copy is gone.
315
+ */
316
+ async function removeTrashMember(entry, index) {
317
+ const target = path.resolve(memberTrashPath(entry, index))
318
+ const objectDir = path.resolve(store.objectDir(entry.trashId))
319
+ if (samePath(objectDir, target) || !isInside(objectDir, target)) {
320
+ throw new TrashError('IO_ERROR', 'Refusing to remove a member directory outside its trash object.')
321
+ }
322
+ await fs.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
323
+ }
324
+
325
+ /**
326
+ * @param {object} member planned member whose restored copy failed verification.
327
+ * @returns {Promise<void>} resolution once the unusable restored copy is gone.
190
328
  */
191
- async function trashSessionExists(entry) {
192
- return (await entryKind(store.sessionPath(entry.trashId))) === 'directory'
329
+ async function removeRestoredMember(member) {
330
+ const target = path.resolve(String(member.originalSessionDir))
331
+ if (!isSafeDirectoryPath(target) || isInside(paths.root, target)) {
332
+ throw new TrashError('IO_ERROR', 'Refusing to remove an unsafe session directory.')
333
+ }
334
+ await fs.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
335
+ }
336
+
337
+ /**
338
+ * @param {object} entry index entry.
339
+ * @returns {Promise<{ ok: boolean, index?: number, message?: string }>} whether every member landed with its recorded metrics.
340
+ */
341
+ async function verifyTrashedMembers(entry) {
342
+ const members = membersOf(entry)
343
+ for (let index = 0; index < members.length; index += 1) {
344
+ let measured
345
+ try {
346
+ measured = await measureDirectory(memberTrashPath(entry, index))
347
+ } catch (error) {
348
+ return { ok: false, index, message: messageOf(error) }
349
+ }
350
+ if (!metricsMatch(members[index], measured)) {
351
+ return { ok: false, index, message: 'the recorded size does not match' }
352
+ }
353
+ if ((await kindOf(members[index].originalSessionDir)) !== 'missing') {
354
+ return { ok: false, index, message: 'the original directory still exists' }
355
+ }
356
+ }
357
+ return { ok: true }
358
+ }
359
+
360
+ /**
361
+ * @param {object} entry index entry.
362
+ * @returns {Promise<{ ok: boolean, index?: number, message?: string }>} whether every member returned with its recorded metrics.
363
+ */
364
+ async function verifyRestoredMembers(entry) {
365
+ const members = membersOf(entry)
366
+ for (let index = 0; index < members.length; index += 1) {
367
+ let measured
368
+ try {
369
+ measured = await measureDirectory(members[index].originalSessionDir)
370
+ } catch (error) {
371
+ return { ok: false, index, message: messageOf(error) }
372
+ }
373
+ if (!metricsMatch(members[index], measured)) {
374
+ return { ok: false, index, message: 'the recorded size does not match' }
375
+ }
376
+ }
377
+ return { ok: true }
378
+ }
379
+
380
+ /**
381
+ * @param {object} entry index entry.
382
+ * @returns {Promise<{ hidden: boolean, sessionId?: string }>} whether session storage stopped reporting every member.
383
+ */
384
+ async function waitForMembersHidden(entry) {
385
+ for (const member of membersOf(entry)) {
386
+ const hidden = await waitForVisibility(
387
+ member.sessionId,
388
+ (current) => current === undefined || current === null,
389
+ )
390
+ if (!hidden) return { hidden: false, sessionId: member.sessionId }
391
+ }
392
+ return { hidden: true }
393
+ }
394
+
395
+ /**
396
+ * @param {object} entry index entry.
397
+ * @returns {Promise<{ visible: boolean, sessionId?: string }>} whether session storage reports every member again.
398
+ */
399
+ async function waitForMembersVisible(entry) {
400
+ for (const member of membersOf(entry)) {
401
+ const visible = await waitForVisibility(
402
+ member.sessionId,
403
+ (current) => current !== undefined && current !== null && current.location !== undefined,
404
+ )
405
+ if (!visible) return { visible: false, sessionId: member.sessionId }
406
+ }
407
+ return { visible: true }
193
408
  }
194
409
 
195
410
  /**
@@ -419,98 +634,185 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
419
634
  }
420
635
 
421
636
  /**
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.
637
+ * Resolve every member directory of a `trashing` entry to either the trash
638
+ * object or its original location. Never writes the index: the caller owns
639
+ * the commit so startup recovery can accumulate one write.
425
640
  *
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.
641
+ * @param {object} entry `trashing` index entry.
642
+ * @returns {Promise<{ kind: 'ready' } | { kind: 'rolledBack' } | { kind: 'missing', entry: object }>} the settled position.
429
643
  */
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 })
644
+ async function resolveTrashing(entry) {
645
+ const rows = await memberRows(entry)
646
+ const lost = rows.filter((row) => !row.source && !row.trash)
647
+ if (lost.length > 0) {
648
+ return {
649
+ kind: 'missing',
650
+ entry: {
651
+ ...entry,
652
+ state: 'missing',
653
+ lastError: errorText(`The trash copy is missing for: ${lost.map((row) => row.member.sessionId).join(', ')}`),
654
+ },
443
655
  }
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
656
  }
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
- }
657
+ for (const row of rows) {
658
+ if (!row.source || !row.trash) continue
659
+ const measured = await measureDirectory(memberTrashPath(entry, row.index))
660
+ if (metricsMatch(row.member, measured)) {
661
+ await store.removeSessionSource(row.member.originalSessionDir)
662
+ row.source = false
465
663
  } else {
466
- // The source is authoritative: remove the unusable copy and roll back.
467
- await rollbackTrash(entry.sessionId, entry.trashId)
468
- return undefined
664
+ await removeTrashMember(entry, row.index)
665
+ row.trash = false
666
+ }
667
+ }
668
+ const inTrash = rows.filter((row) => row.trash)
669
+ const inSource = rows.filter((row) => row.source)
670
+ if (inTrash.length === 0) return { kind: 'rolledBack' }
671
+ if (inSource.length > 0) {
672
+ const failures = await moveMembersIntoTrash(entry, inSource)
673
+ if (failures.length > 0) {
674
+ const current = await memberRows(entry)
675
+ const rollback = await moveMembersBackFromTrash(entry, current.filter((row) => row.trash))
676
+ if (rollback.length === 0) return { kind: 'rolledBack' }
677
+ await markFailure(entry, `The session tree could not be completed or rolled back: ${messageOf(failures[0].error)}`)
678
+ throw new TrashError('MOVE_CONFLICT', 'The session tree could not be moved or rolled back; both copies were kept.')
469
679
  }
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
680
  }
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.', {})
681
+ const verified = await verifyTrashedMembers(entry)
682
+ if (!verified.ok) {
683
+ const current = await memberRows(entry)
684
+ const rollback = await moveMembersBackFromTrash(entry, current.filter((row) => row.trash))
685
+ if (rollback.length === 0) return { kind: 'rolledBack' }
686
+ await markFailure(entry, `The moved session tree does not match its recorded size (${verified.message}).`)
687
+ throw new TrashError('MOVE_VERIFY_FAILED', 'The moved session tree does not match its recorded size.')
688
+ }
689
+ return { kind: 'ready' }
477
690
  }
478
691
 
479
692
  /**
480
- * Settle a failed restore move. Returns `true` when the session directory is
481
- * at its original location and the operation may continue.
693
+ * Resolve every member directory of a `restoring` entry back to its original
694
+ * location, or back into the trash object when the restore cannot complete.
695
+ * Never writes the index: the caller owns the commit.
482
696
  *
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.
697
+ * @param {object} entry `restoring` index entry.
698
+ * @returns {Promise<{ kind: 'ready' } | { kind: 'trashed', entry: object } | { kind: 'missing', entry: object }>} the settled position.
486
699
  */
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
700
+ async function resolveRestoring(entry) {
701
+ const rows = await memberRows(entry)
702
+ const lost = rows.filter((row) => !row.source && !row.trash)
703
+ if (lost.length > 0) {
704
+ return {
705
+ kind: 'missing',
706
+ entry: {
707
+ ...entry,
708
+ state: 'missing',
709
+ lastError: errorText(`Both copies are missing for: ${lost.map((row) => row.member.sessionId).join(', ')}`),
710
+ },
502
711
  }
503
- await markFailure(entry, `The restored session does not match its recorded size (${failure}).`)
504
- return false
505
712
  }
506
- if (trashExists) {
507
- const next = { ...entry, state: 'trashed', lastError: errorText(failure) }
713
+ for (const row of rows) {
714
+ if (!row.source || !row.trash) continue
715
+ const measured = await measureDirectory(row.member.originalSessionDir)
716
+ if (metricsMatch(row.member, measured)) {
717
+ await removeTrashMember(entry, row.index)
718
+ row.trash = false
719
+ } else {
720
+ await removeRestoredMember(row.member)
721
+ row.source = false
722
+ }
723
+ }
724
+ const rolledBack = () => ({
725
+ kind: 'trashed',
726
+ entry: {
727
+ ...entry,
728
+ state: 'trashed',
729
+ trashedAt:
730
+ typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt : entry.startedAt || Date.now(),
731
+ lastError: 'Recovered the trash copy after an interrupted restore.',
732
+ },
733
+ })
734
+ const inSource = rows.filter((row) => row.source)
735
+ const inTrash = rows.filter((row) => row.trash)
736
+ if (inSource.length === 0) return rolledBack()
737
+ if (inTrash.length > 0) {
738
+ const failures = await moveMembersBackFromTrash(entry, inTrash)
739
+ if (failures.length > 0) {
740
+ const current = await memberRows(entry)
741
+ const rollback = await moveMembersIntoTrash(entry, current.filter((row) => row.source && !row.trash))
742
+ if (rollback.length === 0) return rolledBack()
743
+ await markFailure(entry, `The restore could not be completed or rolled back: ${messageOf(failures[0].error)}`)
744
+ throw new TrashError('MOVE_CONFLICT', 'The restore could not be completed or rolled back; both copies were kept.')
745
+ }
746
+ }
747
+ const verified = await verifyRestoredMembers(entry)
748
+ if (!verified.ok) {
749
+ const current = await memberRows(entry)
750
+ const rollback = await moveMembersIntoTrash(entry, current.filter((row) => row.source))
751
+ if (rollback.length === 0) return rolledBack()
752
+ await markFailure(entry, `The restored session tree does not match its recorded size (${verified.message}).`)
753
+ throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session tree does not match its recorded size.')
754
+ }
755
+ return { kind: 'ready' }
756
+ }
757
+
758
+ /**
759
+ * @param {object} entry settled `trashing` entry.
760
+ * @param {{ emitRemoval?: boolean }} [options] whether to announce the removal to clients.
761
+ * @returns {Promise<object>} the committed `trashed` entry.
762
+ */
763
+ async function commitTrashed(entry, options = {}) {
764
+ const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
765
+ try {
508
766
  await store.commit(upsertEntry(store.entries(), next))
509
- await store.writeMeta(next).catch(() => {})
510
- return false
767
+ } catch (error) {
768
+ await markFailure(entry, `The trash index could not be committed: ${messageOf(error)}`)
769
+ throw new TrashError('INDEX_WRITE_FAILED', 'The session moved, but the trash index could not be updated.')
511
770
  }
512
- await markFailure(entry, `Both the trash copy and the restore target are missing (${failure}).`)
513
- return false
771
+ await store.writeMeta(next).catch((error) => {
772
+ logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
773
+ })
774
+ eligibility.invalidate(entry.sessionId)
775
+ if (options.emitRemoval === true) emit('api-session/removed', entry.sessionId)
776
+ return next
777
+ }
778
+
779
+ /**
780
+ * Finish a settled restore: wait for visibility, restore workspace
781
+ * accounting, drop the entry, and optionally announce the session.
782
+ *
783
+ * @param {object} entry settled `restoring` entry.
784
+ * @param {{ mode?: 'normal' | 'orphan', emitAdded?: boolean, reattach?: boolean }} [options] finishing policy.
785
+ * @returns {Promise<object>} the workspace attachment outcome.
786
+ */
787
+ async function completeRestore(entry, options = {}) {
788
+ const mode = options.mode ?? 'normal'
789
+ const visible = await waitForMembersVisible(entry)
790
+ if (!visible.visible) {
791
+ throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session tree is not visible to session storage.', {
792
+ sessionId: visible.sessionId,
793
+ })
794
+ }
795
+ const attachment =
796
+ options.reattach === false
797
+ ? { reattached: false }
798
+ : mode === 'orphan'
799
+ ? await detachWorkspace(entry)
800
+ : await reattachWorkspace(entry)
801
+ try {
802
+ await store.commit(dropEntry(store.entries(), entry.sessionId))
803
+ } catch (error) {
804
+ await markFailure(entry, `The trash index could not be committed after restore: ${messageOf(error)}`)
805
+ throw new TrashError('INDEX_WRITE_FAILED', 'The session was restored, but the trash index could not be updated.')
806
+ }
807
+ await store.removeObjectDir(entry.trashId).catch((error) => {
808
+ logger.warn(`cannot remove the restored trash object ${entry.trashId}: ${messageOf(error)}`)
809
+ })
810
+ eligibility.invalidate(entry.sessionId)
811
+ if (options.emitAdded !== false) {
812
+ const restoredSnapshot = await stat(entry.sessionId).catch(() => undefined)
813
+ emit('api-session/added', buildSummary(entry.sessionId, entry.title, restoredSnapshot))
814
+ }
815
+ return attachment
514
816
  }
515
817
 
516
818
  /**
@@ -576,14 +878,15 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
576
878
  }
577
879
 
578
880
  /**
579
- * Move one cold session into the trash.
881
+ * Move one cold session tree into the trash. A session that owns only hidden
882
+ * `origin: 'subagent'` descendants moves together with them.
580
883
  *
581
884
  * @param {string} sessionId session to trash.
582
885
  * @returns {Promise<object>} the committed `trashed` entry.
583
886
  */
584
887
  async function trash(sessionId) {
585
888
  const startedAt = Date.now()
586
- const result = await store.withLock(async () => {
889
+ return store.withLock(async () => {
587
890
  if (store.isProtected()) {
588
891
  throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
589
892
  }
@@ -603,28 +906,65 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
603
906
  throw new TrashError('ALREADY_TRASHED', 'This session is already recorded in the trash.')
604
907
  }
605
908
  }
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.')
909
+
910
+ const plan = Array.isArray(verdict.members) ? verdict.members : []
911
+ if (plan.length === 0) throw new TrashError('SESSION_NOT_FOUND', 'This session is not in session storage.')
912
+ const members = []
913
+ let parentSnapshot
914
+ for (let index = 0; index < plan.length; index += 1) {
915
+ const planned = plan[index]
916
+ const snapshot = await stat(planned.sessionId)
917
+ if (snapshot === undefined || snapshot === null) {
918
+ throw new TrashError('SESSION_NOT_FOUND', 'A session in this lineage is not in session storage.', {
919
+ sessionId: planned.sessionId,
920
+ })
921
+ }
922
+ if (index === 0) parentSnapshot = snapshot
923
+ const location = snapshot.location
924
+ if (
925
+ location === undefined ||
926
+ location === null ||
927
+ typeof location.path !== 'string' ||
928
+ location.kind !== 'jsonl' ||
929
+ !path.isAbsolute(location.path)
930
+ ) {
931
+ throw new TrashError('SESSION_NOT_MATERIALIZED', 'A session in this lineage has no movable JSONL log.', {
932
+ sessionId: planned.sessionId,
933
+ })
934
+ }
935
+ const resolvedLogPath = path.resolve(location.path)
936
+ const sessionDir = path.dirname(resolvedLogPath)
937
+ const projectDir = path.dirname(sessionDir)
938
+ if (!isSafeDirectoryPath(sessionDir) || (await kindOf(sessionDir)) !== 'directory') {
939
+ throw new TrashError('UNSAFE_SESSION_DIR', 'A session directory is not a plain directory this plugin can move.', {
940
+ sessionId: planned.sessionId,
941
+ })
942
+ }
943
+ if (isLive(planned.sessionId)) {
944
+ throw new TrashError('SESSION_LIVE', 'A session in this lineage became live before it could be moved.', {
945
+ sessionId: planned.sessionId,
946
+ })
947
+ }
948
+ const measured = await measureDirectory(sessionDir)
949
+ const title = await resolveTitle(planned.sessionId, snapshot.header)
950
+ members.push({
951
+ sessionId: planned.sessionId,
952
+ origin: index === 0 ? null : 'subagent',
953
+ title,
954
+ originalSessionDir: sessionDir,
955
+ originalLogPath: resolvedLogPath,
956
+ persistenceProjectDirHint: projectDir,
957
+ formatStatus: snapshot.formatStatus === 'migration-required' ? 'migration-required' : 'current',
958
+ sizeBytes: measured.sizeBytes,
959
+ fileCount: measured.fileCount,
960
+ })
621
961
  }
622
962
 
623
963
  let trashId
624
964
  for (let attempt = 0; attempt < 5; attempt += 1) {
625
965
  const candidate = createTrashId()
626
966
  const indexed = store.entries().some((entry) => entry.trashId === candidate)
627
- if (!indexed && (await entryKind(store.objectDir(candidate))) === 'missing') {
967
+ if (!indexed && (await kindOf(store.objectDir(candidate))) === 'missing') {
628
968
  trashId = candidate
629
969
  break
630
970
  }
@@ -639,32 +979,34 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
639
979
  phase: 'create-object',
640
980
  })
641
981
  }
642
- let entry
982
+
983
+ const totals = aggregateMetrics(members)
984
+ const parent = members[0]
985
+ const header = parentSnapshot?.header ?? {}
986
+ const originalCwd = typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : null
987
+ const workspaceInfo = findOwningWorkspace(sessionId)
988
+ const entry = {
989
+ sessionId,
990
+ trashId,
991
+ state: 'trashing',
992
+ title: parent.title,
993
+ trashedAt: 0,
994
+ startedAt,
995
+ originalCwd,
996
+ workspaceExistsAtDeletion: originalCwd === null ? null : await directoryExists(originalCwd),
997
+ originalWorkspaceId: workspaceInfo?.workspace?.id ?? null,
998
+ originalWorkspaceTitle: workspaceInfo?.workspace?.title ?? null,
999
+ originalNextSessionId: workspaceInfo?.nextSessionId ?? null,
1000
+ originalSessionDir: parent.originalSessionDir,
1001
+ originalLogPath: parent.originalLogPath,
1002
+ persistenceProjectDirHint: parent.persistenceProjectDirHint,
1003
+ formatStatus: parent.formatStatus,
1004
+ sizeBytes: totals.sizeBytes,
1005
+ fileCount: totals.fileCount,
1006
+ members,
1007
+ lastError: null,
1008
+ }
643
1009
  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
1010
  await store.writeMeta(entry)
669
1011
  await store.commit(upsertEntry(store.entries(), entry))
670
1012
  } catch (error) {
@@ -674,88 +1016,69 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
674
1016
  : new TrashError('IO_ERROR', `Cannot prepare the trash entry: ${messageOf(error)}`)
675
1017
  }
676
1018
 
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)) {
1019
+ // Another process may have opened or resumed a member between the
1020
+ // preflight and the move. Re-check every member while the trash lock is
1021
+ // held; the sources are still untouched at this point.
1022
+ const raced = members.find((member) => isLive(member.sessionId))
1023
+ if (raced !== undefined) {
681
1024
  await rollbackTrash(sessionId, trashId)
682
- throw new TrashError('SESSION_LIVE', 'This session became live before it could be moved.')
1025
+ throw new TrashError('SESSION_LIVE', 'A session in this lineage became live before it could be moved.', {
1026
+ sessionId: raced.sessionId,
1027
+ })
683
1028
  }
684
1029
 
1030
+ let moveError
685
1031
  try {
686
- await moveDirectory(sessionDir, store.sessionPath(trashId), entry, { logger })
1032
+ await moveMembersToTrash(entry)
687
1033
  } 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)}`)
1034
+ moveError = error
699
1035
  }
700
-
701
- let measured
1036
+ let settled
702
1037
  try {
703
- measured = await measureDirectory(store.sessionPath(trashId))
1038
+ settled = await resolveTrashing(entry)
704
1039
  } 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)}`)
1040
+ throw error instanceof TrashError
1041
+ ? error
1042
+ : new TrashError('IO_ERROR', `Cannot move the session tree into the trash: ${messageOf(error)}`)
714
1043
  }
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,
1044
+ if (settled.kind === 'missing') {
1045
+ await store.commit(upsertEntry(store.entries(), settled.entry))
1046
+ await store.writeMeta(settled.entry).catch(() => {})
1047
+ throw new TrashError('MOVE_CONFLICT', 'A session directory disappeared during the move.', {
1048
+ sessionId: settled.entry.sessionId,
1049
+ lastError: settled.entry.lastError,
725
1050
  })
726
1051
  }
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
- }
1052
+ if (settled.kind === 'rolledBack') {
734
1053
  await rollbackTrash(sessionId, trashId)
735
- throw new TrashError('MOVE_VERIFY_FAILED', 'Session storage still reports the session after the move.')
1054
+ throw moveError instanceof TrashError
1055
+ ? moveError
1056
+ : new TrashError('MOVE_VERIFY_FAILED', 'The session tree could not be moved into the trash.')
736
1057
  }
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.')
1058
+ const hidden = await waitForMembersHidden(entry)
1059
+ if (!hidden.hidden) {
1060
+ const rows = await memberRows(entry)
1061
+ const rollback = await moveMembersBackFromTrash(entry, rows.filter((row) => row.trash))
1062
+ if (rollback.length > 0) {
1063
+ await markFailure(entry, 'Session storage still reports the moved session tree, and moving it back failed.')
1064
+ throw new TrashError('MOVE_CONFLICT', 'The session tree was moved but session storage still reports it.')
1065
+ }
1066
+ await rollbackTrash(sessionId, trashId)
1067
+ throw new TrashError('MOVE_VERIFY_FAILED', 'Session storage still reports the session after the move.', {
1068
+ sessionId: hidden.sessionId,
1069
+ })
744
1070
  }
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`)
1071
+ const next = await commitTrashed(entry, { emitRemoval: true })
1072
+ logger.info(
1073
+ `trashed ${sessionId} as ${trashId} with ${members.length - 1} child sessions in ${Date.now() - startedAt}ms`,
1074
+ )
751
1075
  return next
752
1076
  })
753
- return result
754
1077
  }
755
1078
 
756
1079
  /**
757
- * Restore one trashed session to its original directory. Phase 2 supports
758
- * `normal` only; orphan restore arrives later.
1080
+ * Restore one trashed session tree to its original directories. Phase 2
1081
+ * supports `normal` only; orphan restore arrives later.
759
1082
  *
760
1083
  * @param {string} sessionId session to restore.
761
1084
  * @param {'normal' | 'orphan'} [mode] restore mode; orphan restores the log and detaches the Workspace.
@@ -776,28 +1099,49 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
776
1099
  throw new TrashError('TRASH_STATE_INVALID', `The trash entry is in state "${entry.state}" and cannot be restored.`)
777
1100
  }
778
1101
  await assertSafeObject(entry)
779
- const target = path.resolve(String(entry.originalSessionDir))
780
- const projectDirHint = path.resolve(String(entry.persistenceProjectDirHint))
1102
+ const members = membersOf(entry)
1103
+ const parent = members[0]
781
1104
  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)
1105
+ !samePath(path.resolve(String(entry.originalSessionDir)), path.resolve(String(parent.originalSessionDir))) ||
1106
+ !samePath(path.resolve(String(entry.originalLogPath)), path.resolve(String(parent.originalLogPath))) ||
1107
+ !samePath(
1108
+ path.resolve(String(entry.persistenceProjectDirHint)),
1109
+ path.resolve(String(parent.persistenceProjectDirHint)),
1110
+ )
788
1111
  ) {
789
- throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target failed the path safety check.')
1112
+ throw new TrashError('UNSAFE_RESTORE_PATH', 'The trash entry names inconsistent restore paths.')
790
1113
  }
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.')
1114
+ for (let index = 0; index < members.length; index += 1) {
1115
+ const member = members[index]
1116
+ const target = path.resolve(String(member.originalSessionDir))
1117
+ const projectDirHint = path.resolve(String(member.persistenceProjectDirHint))
1118
+ if (
1119
+ !isSafeDirectoryPath(target) ||
1120
+ !samePath(path.dirname(target), projectDirHint) ||
1121
+ isInside(paths.root, target) ||
1122
+ typeof member.originalLogPath !== 'string' ||
1123
+ !samePath(path.dirname(path.resolve(member.originalLogPath)), target)
1124
+ ) {
1125
+ throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target failed the path safety check.', {
1126
+ sessionId: member.sessionId,
1127
+ })
1128
+ }
1129
+ const projectDirKind = await kindOf(projectDirHint)
1130
+ if (projectDirKind !== 'missing' && projectDirKind !== 'directory') {
1131
+ throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target parent is not a plain directory.', {
1132
+ sessionId: member.sessionId,
1133
+ })
1134
+ }
1135
+ const targetKind = await kindOf(target)
1136
+ if (targetKind !== 'missing') {
1137
+ throw new TrashError('RESTORE_TARGET_EXISTS', 'A directory already exists at the restore target.', {
1138
+ sessionId: member.sessionId,
1139
+ })
1140
+ }
798
1141
  }
799
- if (!(await trashSessionExists(entry))) {
800
- const missing = { ...entry, state: 'missing', lastError: 'The trash object directory is missing.' }
1142
+ const presence = await memberPresence(store, entry)
1143
+ if (presence.present !== presence.total) {
1144
+ const missing = { ...entry, state: 'missing', lastError: 'The trash copy of this session is missing.' }
801
1145
  await store.commit(upsertEntry(store.entries(), missing))
802
1146
  await store.writeMeta(missing).catch(() => {})
803
1147
  throw new TrashError('TRASH_DIR_MISSING', 'The trash copy of this session is missing.')
@@ -814,76 +1158,56 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
814
1158
  logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
815
1159
  })
816
1160
 
1161
+ let moveError
1162
+ const rows = members.map((_, index) => ({ index }))
1163
+ const moveFailures = await moveMembersBackFromTrash(restoring, rows)
1164
+ if (moveFailures.length > 0) moveError = moveFailures[0].error
1165
+ let settled
817
1166
  try {
818
- await moveDirectory(store.sessionPath(entry.trashId), target, entry, { logger })
819
- } 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
- }
835
- }
836
-
837
- let measured
838
- try {
839
- measured = await measureDirectory(target)
1167
+ settled = await resolveRestoring(restoring)
840
1168
  } catch (error) {
841
- throw new TrashError('RESTORE_VERIFY_FAILED', `Cannot verify the restored session: ${messageOf(error)}`)
1169
+ throw error instanceof TrashError
1170
+ ? error
1171
+ : new TrashError('IO_ERROR', `Cannot move the session tree back: ${messageOf(error)}`)
842
1172
  }
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
- })
1173
+ if (settled.kind === 'missing') {
1174
+ await store.commit(upsertEntry(store.entries(), settled.entry))
1175
+ await store.writeMeta(settled.entry).catch(() => {})
1176
+ throw new TrashError('RESTORE_VERIFY_FAILED', settled.entry.lastError ?? 'The restored session tree is missing.')
856
1177
  }
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.')
1178
+ if (settled.kind === 'trashed') {
1179
+ await store.commit(upsertEntry(store.entries(), settled.entry))
1180
+ await store.writeMeta(settled.entry).catch(() => {})
1181
+ throw moveError instanceof TrashError
1182
+ ? moveError
1183
+ : new TrashError('RESTORE_VERIFY_FAILED', 'The session tree could not be restored.')
871
1184
  }
872
1185
 
873
- const attachment = mode === 'orphan' ? await detachWorkspace(entry) : await reattachWorkspace(entry)
874
- const restoredSnapshot = await stat(sessionId).catch(() => undefined)
875
-
1186
+ let attachment
876
1187
  try {
877
- await store.commit(dropEntry(store.entries(), sessionId))
1188
+ attachment = await completeRestore(restoring, { mode })
878
1189
  } 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.')
1190
+ if (error instanceof TrashError && error.code === 'RESTORE_VERIFY_FAILED') {
1191
+ const current = await memberRows(restoring)
1192
+ const rollback = await moveMembersIntoTrash(
1193
+ restoring,
1194
+ current.filter((row) => row.source && !row.trash),
1195
+ )
1196
+ if (rollback.length > 0) {
1197
+ await markFailure(restoring, 'The restored session tree is not visible, and rolling it back failed.')
1198
+ throw new TrashError('MOVE_CONFLICT', 'The restored session tree is not visible, and rolling it back failed.')
1199
+ }
1200
+ const next = {
1201
+ ...restoring,
1202
+ state: 'trashed',
1203
+ trashedAt: entry.trashedAt,
1204
+ lastError: 'The restored session was not visible to session storage.',
1205
+ }
1206
+ await store.commit(upsertEntry(store.entries(), next))
1207
+ await store.writeMeta(next).catch(() => {})
1208
+ }
1209
+ throw error
881
1210
  }
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
1211
  logger.info(`restored ${sessionId} from ${entry.trashId} (${mode}) in ${Date.now() - startedAt}ms`)
888
1212
  return {
889
1213
  sessionId,
@@ -930,40 +1254,19 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
930
1254
 
931
1255
  if (original.state === 'trashing') {
932
1256
  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.' })
1257
+ const settled = await resolveTrashing(original)
1258
+ if (settled.kind === 'missing') {
1259
+ commit(settled.entry)
937
1260
  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) {
1261
+ } else if (settled.kind === 'rolledBack') {
947
1262
  await store.removeObjectDir(original.trashId).catch((error) => {
948
1263
  logger.warn(`cannot remove the rolled-back trash object ${original.trashId}: ${messageOf(error)}`)
949
1264
  })
950
1265
  drop(original.sessionId)
951
1266
  rolledBack += 1
952
1267
  } 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
- }
1268
+ commit({ ...original, state: 'trashed', trashedAt: original.startedAt || Date.now(), lastError: null })
1269
+ completed += 1
967
1270
  }
968
1271
  changed += 1
969
1272
  continue
@@ -971,15 +1274,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
971
1274
 
972
1275
  if (original.state === 'restoring') {
973
1276
  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.'))
1277
+ const settled = await resolveRestoring(original)
1278
+ if (settled.kind === 'missing') {
1279
+ commit(settled.entry)
1280
+ missing += 1
1281
+ } else if (settled.kind === 'trashed') {
1282
+ commit(settled.entry)
1283
+ rolledBack += 1
1284
+ } else {
1285
+ const visible = await waitForMembersVisible(original)
1286
+ if (!visible.visible) {
1287
+ commit(fail(original, 'The restored session tree is not visible to session storage.'))
983
1288
  changed += 1
984
1289
  continue
985
1290
  }
@@ -988,15 +1293,6 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
988
1293
  })
989
1294
  drop(original.sessionId)
990
1295
  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
1296
  }
1001
1297
  changed += 1
1002
1298
  continue
@@ -1004,22 +1300,23 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1004
1300
 
1005
1301
  if (original.state === 'trashed') {
1006
1302
  await assertSafeObject(original)
1007
- const sourceExists = (await entryKind(original.originalSessionDir)) === 'directory'
1008
- const targetExists = await trashSessionExists(original)
1009
- if (!targetExists) {
1303
+ const presence = await memberPresence(store, original)
1304
+ if (presence.present < presence.total) {
1010
1305
  commit({
1011
1306
  ...original,
1012
1307
  state: 'missing',
1013
- lastError: sourceExists
1014
- ? 'The trash copy is missing; the original session directory is still present.'
1015
- : 'The trash copy is missing.',
1308
+ lastError:
1309
+ presence.present === 0
1310
+ ? 'The trash copy is missing.'
1311
+ : `The trash copy is missing these member sessions: ${presence.missing.join(', ')}.`,
1016
1312
  })
1017
1313
  missing += 1
1018
1314
  changed += 1
1019
1315
  continue
1020
1316
  }
1021
- if (sourceExists) {
1022
- commit(fail(original, 'The original session directory unexpectedly exists alongside the trash copy; both were kept.'))
1317
+ const rows = await memberRows(original)
1318
+ if (rows.some((row) => row.source)) {
1319
+ commit(fail(original, 'An original session directory unexpectedly exists alongside the trash copy; both were kept.'))
1023
1320
  changed += 1
1024
1321
  }
1025
1322
  }