@wangxiaocuo/dsh-session-trash 0.6.3 → 0.6.5

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.
@@ -14,7 +14,7 @@ import {
14
14
  samePath,
15
15
  } from './fsops.js'
16
16
  import { mapLimit } from './concurrency.js'
17
- import { reconcileIndex, rebuildIndex, memberPresence } from './inventory.js'
17
+ import { isEmptyObjectDir, memberPresence, rebuildIndex, reconcileIndex, scanObjects } from './inventory.js'
18
18
  import { aggregateMetrics, memberList } from './members.js'
19
19
 
20
20
  /** How many short retries confirm a moved or restored session is visible. */
@@ -45,7 +45,7 @@ function errorText(message) {
45
45
  }
46
46
 
47
47
  /**
48
- * @param {unknown} error arbitrary failure.
48
+ * @param {any} error arbitrary failure.
49
49
  * @returns {string} a user-facing error message.
50
50
  */
51
51
  function messageOf(error) {
@@ -53,9 +53,9 @@ function messageOf(error) {
53
53
  }
54
54
 
55
55
  /**
56
- * @param {readonly object[]} entries current index entries.
57
- * @param {object} next replacement entry.
58
- * @returns {object[]} a new list with the entry replaced or appended.
56
+ * @param {readonly any[]} entries current index entries.
57
+ * @param {any} next replacement entry.
58
+ * @returns {any[]} a new list with the entry replaced or appended.
59
59
  */
60
60
  function upsertEntry(entries, next) {
61
61
  const copy = [...entries]
@@ -66,9 +66,9 @@ function upsertEntry(entries, next) {
66
66
  }
67
67
 
68
68
  /**
69
- * @param {readonly object[]} entries current index entries.
69
+ * @param {readonly any[]} entries current index entries.
70
70
  * @param {string} sessionId session to drop.
71
- * @returns {object[]} a new list without that session.
71
+ * @returns {any[]} a new list without that session.
72
72
  */
73
73
  function dropEntry(entries, sessionId) {
74
74
  return entries.filter((entry) => entry.sessionId !== sessionId)
@@ -81,23 +81,23 @@ function dropEntry(entries, sessionId) {
81
81
  * @param {{
82
82
  * ctx: import('@deepseek-ai/cordis').Context,
83
83
  * store: import('./store.js').TrashStore,
84
- * eligibility: { evaluate: (sessionId: string) => Promise<object>, invalidate: (sessionId?: string) => void },
84
+ * eligibility: { evaluate: (sessionId: string) => Promise<any>, invalidate: (sessionId?: string) => void },
85
85
  * config: { retentionDays: number, sweepIntervalHours: number, enableAutoCleanup: boolean },
86
86
  * logger: { info: Function, warn: Function, error: Function, debug: Function },
87
87
  * paths: { root: string, display: string },
88
88
  * }} deps host services and resolved paths.
89
- * @returns {object} the operation surface.
89
+ * @returns {any} the operation surface.
90
90
  */
91
91
  export function createTrashService({ ctx, store, eligibility, config, logger, paths }) {
92
92
  /**
93
- * @returns {object | undefined} the optional title service.
93
+ * @returns {any | undefined} the optional title service.
94
94
  */
95
95
  function sessionQuery() {
96
96
  return ctx.get('sessionQuery')
97
97
  }
98
98
 
99
99
  /**
100
- * @returns {object | undefined} the optional workspace registry.
100
+ * @returns {any | undefined} the optional workspace registry.
101
101
  */
102
102
  function workspaceRegistry() {
103
103
  return ctx.get('workspaceRegistry')
@@ -105,7 +105,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
105
105
 
106
106
  /**
107
107
  * @param {string} sessionId session whose storage snapshot to read.
108
- * @returns {Promise<object | undefined>} the persistence snapshot.
108
+ * @returns {Promise<any | undefined>} the persistence snapshot.
109
109
  */
110
110
  async function stat(sessionId) {
111
111
  return ctx.sessionPersistence.stat(sessionId)
@@ -113,7 +113,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
113
113
 
114
114
  /**
115
115
  * @param {string} sessionId session to check.
116
- * @returns {Promise<boolean>} whether the session is live in this process.
116
+ * @returns {boolean} whether the session is live in this process.
117
117
  */
118
118
  function isLive(sessionId) {
119
119
  const liveSession = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
@@ -122,7 +122,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
122
122
  }
123
123
 
124
124
  /**
125
- * @param {object} snapshot persistence snapshot.
125
+ * @param {any} snapshot persistence snapshot.
126
126
  * @returns {string} the session header's cwd, or an empty string when absent.
127
127
  */
128
128
  function cwdOf(snapshot) {
@@ -134,7 +134,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
134
134
  * query service.
135
135
  *
136
136
  * @param {string} sessionId session whose title to read.
137
- * @param {object} header persistence header.
137
+ * @param {any} header persistence header.
138
138
  * @returns {Promise<string>} the title, then cwd basename, then session id.
139
139
  */
140
140
  async function resolveTitle(sessionId, header) {
@@ -158,7 +158,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
158
158
  * Find the Workspace that currently accounts for a session.
159
159
  *
160
160
  * @param {string} sessionId session to locate.
161
- * @returns {{ workspace: object, nextSessionId: string | null } | undefined} owning workspace and following sibling.
161
+ * @returns {{ workspace: any, nextSessionId: string | null } | undefined} owning workspace and following sibling.
162
162
  */
163
163
  function findOwningWorkspace(sessionId) {
164
164
  const registry = workspaceRegistry()
@@ -177,7 +177,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
177
177
  }
178
178
 
179
179
  /**
180
- * @param {unknown} target path to probe.
180
+ * @param {any} target path to probe.
181
181
  * @returns {Promise<string>} the entry kind, treating an absent path as missing.
182
182
  */
183
183
  async function kindOf(target) {
@@ -185,15 +185,15 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
185
185
  }
186
186
 
187
187
  /**
188
- * @param {object} entry index entry.
189
- * @returns {object[]} every owned directory, parent first.
188
+ * @param {any} entry index entry.
189
+ * @returns {any[]} every owned directory, parent first.
190
190
  */
191
191
  function membersOf(entry) {
192
192
  return memberList(entry)
193
193
  }
194
194
 
195
195
  /**
196
- * @param {object} entry index entry.
196
+ * @param {any} entry index entry.
197
197
  * @param {number} index member position.
198
198
  * @returns {string} this member's directory inside the trash object.
199
199
  */
@@ -202,7 +202,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
202
202
  }
203
203
 
204
204
  /**
205
- * @param {object} entry trash index entry.
205
+ * @param {any} entry trash index entry.
206
206
  * @returns {Promise<void>} resolution once every member path stays inside `objects/`.
207
207
  */
208
208
  async function assertSafeObject(entry) {
@@ -226,8 +226,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
226
226
  * Probe every member directory in parallel: where the source copy and the
227
227
  * trash copy currently live.
228
228
  *
229
- * @param {object} entry index entry.
230
- * @returns {Promise<{ index: number, member: object, source: boolean, trash: boolean }[]>} member positions.
229
+ * @param {any} entry index entry.
230
+ * @returns {Promise<{ index: number, member: any, source: boolean, trash: boolean }[]>} member positions.
231
231
  */
232
232
  async function memberRows(entry) {
233
233
  const members = membersOf(entry)
@@ -251,8 +251,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
251
251
  * Probe only the original session directories of every member, in parallel.
252
252
  * Used by recovery after the trash copies were already proven present.
253
253
  *
254
- * @param {object} entry index entry.
255
- * @returns {Promise<{ index: number, member: object, source: boolean }[]>} member source positions.
254
+ * @param {any} entry index entry.
255
+ * @returns {Promise<{ index: number, member: any, source: boolean }[]>} member source positions.
256
256
  */
257
257
  async function sourceRows(entry) {
258
258
  const members = membersOf(entry)
@@ -266,7 +266,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
266
266
  }
267
267
 
268
268
  /**
269
- * @param {object} entry index entry.
269
+ * @param {any} entry index entry.
270
270
  * @param {number} index member position.
271
271
  * @returns {Promise<void>} resolution once the member directory moved into its object.
272
272
  */
@@ -276,7 +276,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
276
276
  }
277
277
 
278
278
  /**
279
- * @param {object} entry index entry.
279
+ * @param {any} entry index entry.
280
280
  * @param {number} index member position.
281
281
  * @returns {Promise<void>} resolution once the member directory moved back to its original location.
282
282
  */
@@ -286,7 +286,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
286
286
  }
287
287
 
288
288
  /**
289
- * @param {object} entry index entry.
289
+ * @param {any} entry index entry.
290
290
  * @param {readonly { index: number }[]} rows members to move into the object.
291
291
  * @returns {Promise<{ index: number, error: unknown }[]>} per-member failures.
292
292
  */
@@ -303,7 +303,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
303
303
  }
304
304
 
305
305
  /**
306
- * @param {object} entry index entry.
306
+ * @param {any} entry index entry.
307
307
  * @param {readonly { index: number }[]} rows members to move back to their original locations.
308
308
  * @returns {Promise<{ index: number, error: unknown }[]>} per-member failures.
309
309
  */
@@ -323,7 +323,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
323
323
  * Move every member directory into the trash object, children first and the
324
324
  * visible parent last so the sidebar keeps the row until the move settles.
325
325
  *
326
- * @param {object} entry index entry.
326
+ * @param {any} entry index entry.
327
327
  * @returns {Promise<void>} resolution once every member directory moved.
328
328
  */
329
329
  async function moveMembersToTrash(entry) {
@@ -337,7 +337,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
337
337
  }
338
338
 
339
339
  /**
340
- * @param {object} entry index entry.
340
+ * @param {any} entry index entry.
341
341
  * @param {number} index member position.
342
342
  * @returns {Promise<void>} resolution once the unusable trash copy is gone.
343
343
  */
@@ -351,7 +351,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
351
351
  }
352
352
 
353
353
  /**
354
- * @param {object} member planned member whose restored copy failed verification.
354
+ * @param {any} member planned member whose restored copy failed verification.
355
355
  * @returns {Promise<void>} resolution once the unusable restored copy is gone.
356
356
  */
357
357
  async function removeRestoredMember(member) {
@@ -363,7 +363,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
363
363
  }
364
364
 
365
365
  /**
366
- * @param {object} entry index entry.
366
+ * @param {any} entry index entry.
367
367
  * @returns {Promise<{ ok: boolean, index?: number, message?: string }>} whether every member landed with its recorded metrics.
368
368
  */
369
369
  async function verifyTrashedMembers(entry) {
@@ -386,7 +386,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
386
386
  }
387
387
 
388
388
  /**
389
- * @param {object} entry index entry.
389
+ * @param {any} entry index entry.
390
390
  * @returns {Promise<{ ok: boolean, index?: number, message?: string }>} whether every member returned with its recorded metrics.
391
391
  */
392
392
  async function verifyRestoredMembers(entry) {
@@ -406,7 +406,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
406
406
  }
407
407
 
408
408
  /**
409
- * @param {object} entry index entry.
409
+ * @param {any} entry index entry.
410
410
  * @returns {Promise<{ hidden: boolean, sessionId?: string }>} whether session storage stopped reporting every member.
411
411
  */
412
412
  async function waitForMembersHidden(entry) {
@@ -421,7 +421,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
421
421
  }
422
422
 
423
423
  /**
424
- * @param {object} entry index entry.
424
+ * @param {any} entry index entry.
425
425
  * @returns {Promise<{ visible: boolean, sessionId?: string }>} whether session storage reports every member again.
426
426
  */
427
427
  async function waitForMembersVisible(entry) {
@@ -440,7 +440,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
440
440
 
441
441
  /**
442
442
  * @param {string} sessionId session to probe.
443
- * @param {(snapshot: object | undefined) => boolean} predicate expected visibility state.
443
+ * @param {(snapshot: any | undefined) => boolean} predicate expected visibility state.
444
444
  * @returns {Promise<boolean>} whether the persistence backend agreed within the retry budget.
445
445
  */
446
446
  async function waitForVisibility(sessionId, predicate) {
@@ -479,8 +479,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
479
479
  *
480
480
  * @param {string} sessionId restored session identity.
481
481
  * @param {string} title restored display title.
482
- * @param {object | undefined} snapshot persistence snapshot.
483
- * @returns {object} a `SessionSummary`.
482
+ * @param {any | undefined} snapshot persistence snapshot.
483
+ * @returns {any} a `SessionSummary`.
484
484
  */
485
485
  function buildSummary(sessionId, title, snapshot) {
486
486
  const header = snapshot?.header ?? {}
@@ -501,9 +501,9 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
501
501
  /**
502
502
  * Record a failure summary on one entry and commit it, best effort.
503
503
  *
504
- * @param {object} entry entry to annotate.
504
+ * @param {any} entry entry to annotate.
505
505
  * @param {string} message failure summary.
506
- * @returns {Promise<object>} the annotated entry.
506
+ * @returns {Promise<any>} the annotated entry.
507
507
  */
508
508
  async function markFailure(entry, message) {
509
509
  const next = { ...entry, lastError: errorText(message) }
@@ -521,7 +521,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
521
521
  /**
522
522
  * Restore the workspace accounting after a normal restore.
523
523
  *
524
- * @param {object} entry restored index entry.
524
+ * @param {any} entry restored index entry.
525
525
  * @returns {Promise<{ reattached: boolean, warning?: string }>} reattachment outcome.
526
526
  */
527
527
  async function reattachWorkspace(entry) {
@@ -584,7 +584,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
584
584
  * Remove a session from every workspace that still accounts for it. Used by
585
585
  * orphan restore so the restored log appears under “Ungrouped”.
586
586
  *
587
- * @param {object} entry restored index entry.
587
+ * @param {any} entry restored index entry.
588
588
  * @returns {Promise<{ detached: boolean, warning?: string }>} detach outcome.
589
589
  */
590
590
  async function detachWorkspace(entry) {
@@ -625,7 +625,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
625
625
  *
626
626
  * @param {string} from current path.
627
627
  * @param {string} to previous path.
628
- * @param {object | null | undefined} expected metrics for a cross-device copy.
628
+ * @param {any | null | undefined} expected metrics for a cross-device copy.
629
629
  * @returns {Promise<boolean>} whether the directory now lives at `to`.
630
630
  */
631
631
  async function moveBack(from, to, expected) {
@@ -669,7 +669,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
669
669
  * object or its original location. Never writes the index: the caller owns
670
670
  * the commit so startup recovery can accumulate one write.
671
671
  *
672
- * @param {object} entry `trashing` index entry.
672
+ * @param {any} entry `trashing` index entry.
673
673
  * @returns {Promise<{ kind: 'ready' } | { kind: 'rolledBack' } | { kind: 'missing', entry: object }>} the settled position.
674
674
  */
675
675
  async function resolveTrashing(entry) {
@@ -725,7 +725,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
725
725
  * location, or back into the trash object when the restore cannot complete.
726
726
  * Never writes the index: the caller owns the commit.
727
727
  *
728
- * @param {object} entry `restoring` index entry.
728
+ * @param {any} entry `restoring` index entry.
729
729
  * @returns {Promise<{ kind: 'ready' } | { kind: 'trashed', entry: object } | { kind: 'missing', entry: object }>} the settled position.
730
730
  */
731
731
  async function resolveRestoring(entry) {
@@ -752,6 +752,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
752
752
  row.source = false
753
753
  }
754
754
  }
755
+ /** @returns {{ kind: 'trashed', entry: any }} */
755
756
  const rolledBack = () => ({
756
757
  kind: 'trashed',
757
758
  entry: {
@@ -787,9 +788,9 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
787
788
  }
788
789
 
789
790
  /**
790
- * @param {object} entry settled `trashing` entry.
791
+ * @param {any} entry settled `trashing` entry.
791
792
  * @param {{ emitRemoval?: boolean }} [options] whether to announce the removal to clients.
792
- * @returns {Promise<object>} the committed `trashed` entry.
793
+ * @returns {Promise<any>} the committed `trashed` entry.
793
794
  */
794
795
  async function commitTrashed(entry, options = {}) {
795
796
  const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
@@ -811,9 +812,9 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
811
812
  * Finish a settled restore: wait for visibility, restore workspace
812
813
  * accounting, drop the entry, and optionally announce the session.
813
814
  *
814
- * @param {object} entry settled `restoring` entry.
815
+ * @param {any} entry settled `restoring` entry.
815
816
  * @param {{ mode?: 'normal' | 'orphan', emitAdded?: boolean, reattach?: boolean }} [options] finishing policy.
816
- * @returns {Promise<object>} the workspace attachment outcome.
817
+ * @returns {Promise<any>} the workspace attachment outcome.
817
818
  */
818
819
  async function completeRestore(entry, options = {}) {
819
820
  const mode = options.mode ?? 'normal'
@@ -847,7 +848,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
847
848
  }
848
849
 
849
850
  /**
850
- * @param {object} entry index entry.
851
+ * @param {any} entry index entry.
851
852
  * @param {number} now current timestamp.
852
853
  * @returns {boolean} whether the retention window has elapsed.
853
854
  */
@@ -864,8 +865,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
864
865
  * recovery; successes are dropped and failures annotated in one final write.
865
866
  * The caller owns the trash lock.
866
867
  *
867
- * @param {readonly object[]} targets `trashed` entries to purge.
868
- * @returns {Promise<{ purged: number, failures: { entry: object, error: unknown, phase: string }[] }>} per-entry outcome.
868
+ * @param {readonly any[]} targets `trashed` entries to purge.
869
+ * @returns {Promise<{ purged: number, failures: { entry: any, error: unknown, phase: string }[] }>} per-entry outcome.
869
870
  */
870
871
  async function purgeManyInsideLock(targets) {
871
872
  const failures = []
@@ -954,7 +955,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
954
955
  * Permanently remove one object directory and its index entry. The caller
955
956
  * owns the trash lock.
956
957
  *
957
- * @param {object} entry `trashed` index entry to purge.
958
+ * @param {any} entry `trashed` index entry to purge.
958
959
  * @returns {Promise<void>} resolution once the object and entry are gone.
959
960
  */
960
961
  async function purgeInsideLock(entry) {
@@ -967,7 +968,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
967
968
  * their plugin-owned remnants. No session file is deleted. The caller owns
968
969
  * the trash lock.
969
970
  *
970
- * @param {readonly object[]} entries `missing` index entries to forget.
971
+ * @param {readonly any[]} entries `missing` index entries to forget.
971
972
  * @returns {Promise<number>} how many entries were removed from the index.
972
973
  */
973
974
  async function forgetManyInsideLock(entries) {
@@ -993,7 +994,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
993
994
  * Drop one `missing` entry from the index and remove its plugin-owned
994
995
  * remnants. No session file is deleted. The caller owns the trash lock.
995
996
  *
996
- * @param {object} entry `missing` index entry to forget.
997
+ * @param {any} entry `missing` index entry to forget.
997
998
  * @returns {Promise<{ sessionId: string, title: string }>} the removed identity.
998
999
  */
999
1000
  async function forgetInsideLock(entry) {
@@ -1006,19 +1007,21 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1006
1007
  * `origin: 'subagent'` descendants moves together with them.
1007
1008
  *
1008
1009
  * @param {string} sessionId session to trash.
1009
- * @returns {Promise<object>} the committed `trashed` entry.
1010
+ * @returns {Promise<any>} the committed `trashed` entry.
1010
1011
  */
1011
1012
  async function trash(sessionId) {
1012
1013
  const startedAt = Date.now()
1014
+ // Lineage listing and live checks are slow; resolve them before taking the
1015
+ // cross-process lock and keep only the directory re-checks inside it.
1016
+ eligibility.invalidate(sessionId)
1017
+ const verdict = await eligibility.evaluate(sessionId)
1018
+ if (verdict.eligible !== true) {
1019
+ throw new TrashError(verdict.code, verdict.message, verdict.details)
1020
+ }
1013
1021
  return store.withLock(async () => {
1014
1022
  if (store.isProtected()) {
1015
1023
  throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
1016
1024
  }
1017
- eligibility.invalidate(sessionId)
1018
- const verdict = await eligibility.evaluate(sessionId)
1019
- if (verdict.eligible !== true) {
1020
- throw new TrashError(verdict.code, verdict.message, verdict.details)
1021
- }
1022
1025
  const existing = store.findEntry(sessionId)
1023
1026
  if (existing !== undefined) {
1024
1027
  if (existing.state === 'missing') {
@@ -1206,7 +1209,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1206
1209
  *
1207
1210
  * @param {string} sessionId session to restore.
1208
1211
  * @param {'normal' | 'orphan'} [mode] restore mode; orphan restores the log and detaches the Workspace.
1209
- * @returns {Promise<object>} the restore result.
1212
+ * @returns {Promise<any>} the restore result.
1210
1213
  */
1211
1214
  async function restore(sessionId, mode = 'normal') {
1212
1215
  if (mode !== 'normal' && mode !== 'orphan') {
@@ -1471,7 +1474,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1471
1474
  * object directory vanished. The caller must hold the trash lock.
1472
1475
  *
1473
1476
  * @param {number} now current timestamp.
1474
- * @returns {Promise<object>} reconcile summary.
1477
+ * @returns {Promise<any>} reconcile summary.
1475
1478
  */
1476
1479
  async function reconcileInsideLock(now) {
1477
1480
  try {
@@ -1493,7 +1496,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1493
1496
  * entries. Used by startup recovery, the scheduled sweep, and manual cleanup.
1494
1497
  *
1495
1498
  * @param {{ allowPurge?: boolean, now?: number, reason?: string }} [options] sweep policy.
1496
- * @returns {Promise<object>} the sweep summary.
1499
+ * @returns {Promise<any>} the sweep summary.
1497
1500
  */
1498
1501
  async function sweep({ allowPurge = true, now = Date.now(), reason = 'scheduled' } = {}) {
1499
1502
  return store.withLock(async () => {
@@ -1519,6 +1522,40 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1519
1522
  })
1520
1523
  }
1521
1524
 
1525
+ /**
1526
+ * Remove unindexed object directories that contain no session payload, only
1527
+ * plugin metadata. Directories that may still hold session logs are kept for
1528
+ * manual inspection.
1529
+ *
1530
+ * @returns {Promise<{ removed: number }>} how many empty object directories were removed.
1531
+ */
1532
+ async function purgeEmptyOrphans() {
1533
+ return store.withLock(async () => {
1534
+ if (store.isProtected()) {
1535
+ throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses changes.')
1536
+ }
1537
+ const { objects } = await scanObjects(store, logger)
1538
+ const indexed = new Set(store.entries().map((entry) => entry.trashId))
1539
+ let removed = 0
1540
+ for (const object of objects) {
1541
+ if (indexed.has(object.trashId)) continue
1542
+ if (!(await isEmptyObjectDir(store, object.trashId))) continue
1543
+ try {
1544
+ await store.removeObjectDir(object.trashId)
1545
+ removed += 1
1546
+ logger.info(`removed an empty unindexed trash object (${object.trashId})`)
1547
+ } catch (error) {
1548
+ logger.warn(`cannot remove empty unindexed trash object ${object.trashId}: ${messageOf(error)}`)
1549
+ }
1550
+ }
1551
+ if (removed > 0) {
1552
+ const { unindexed } = await scanObjects(store, logger)
1553
+ store.unindexed = unindexed
1554
+ }
1555
+ return { removed }
1556
+ })
1557
+ }
1558
+
1522
1559
  /**
1523
1560
  * Restore the index from object sidecars after the primary index and its
1524
1561
  * backup were both rejected.
@@ -1740,5 +1777,5 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1740
1777
  return summary
1741
1778
  }
1742
1779
 
1743
- return { trash, restore, recover, purge, purgeAll, forget, sweep, rebuild, reveal }
1780
+ return { trash, restore, recover, purge, purgeAll, forget, sweep, rebuild, reveal, purgeEmptyOrphans }
1744
1781
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangxiaocuo/dsh-session-trash",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "DeepSeek Harness plugin: move cold sessions into a recoverable trash, restore them within the retention window, or delete their logs permanently.",
5
5
  "license": "MIT",
6
6
  "author": "wangxiaocuo",
@@ -47,14 +47,21 @@
47
47
  "test": "node test/run-unit.mjs",
48
48
  "test:coverage": "node test/run-unit.mjs --experimental-test-coverage",
49
49
  "test:e2e": "node test/e2e-web.mjs",
50
- "test:smoke": "node test/smoke-host.mjs"
50
+ "test:smoke": "node test/smoke-host.mjs",
51
+ "typecheck": "tsc -p tsconfig.json",
52
+ "test:dom": "node --test test/client-dom.test.js"
51
53
  },
52
54
  "dependencies": {
53
55
  "@deepseek-ai/dsh-home-paths": "0.2.2-rc.1",
54
56
  "@deepseek-ai/schemastery": "3.19.0"
55
57
  },
56
58
  "devDependencies": {
57
- "@deepseek-ai/cordis": "4.1.0"
59
+ "@deepseek-ai/cordis": "4.1.0",
60
+ "@types/node": "20.19.43",
61
+ "jsdom": "27.4.0",
62
+ "react": "19.3.1",
63
+ "react-dom": "19.3.1",
64
+ "typescript": "5.9.3"
58
65
  },
59
66
  "dsh": {
60
67
  "bundle": {