@wangxiaocuo/dsh-session-trash 0.6.0 → 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.
package/README.en.md CHANGED
@@ -175,19 +175,14 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
175
175
 
176
176
  ```bash
177
177
  pnpm install
178
- pnpm test
179
- npm run test:e2e
178
+ pnpm test # unit tests
179
+ pnpm test:smoke # Host smoke test (real Cordis, plain Node)
180
+ npm run test:e2e # real dsh web end-to-end (needs the desktop app)
180
181
  ```
181
182
 
182
- `npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow. It uses `/Applications/DeepSeek Harness.app` by default; set `DSH_ELECTRON` to another Electron executable. Set `DSH_E2E_KEEP=1` to keep the temporary home and logs.
183
-
184
- The Host smoke test needs a separate temporary `DSH_HOME`:
183
+ The Host smoke test wires the plugin into a real Cordis context through the `@deepseek-ai/cordis` devDependency, creates its own temporary `DSH_HOME`, and removes it afterwards; it does not need the desktop app. Set `DSH_SMOKE_KEEP=1` to keep the temporary home for debugging. CI (GitHub Actions) runs the unit tests, the smoke test, and a package-contents check; the end-to-end suite needs a local DeepSeek Harness desktop install and is run manually.
185
184
 
186
- ```bash
187
- DSH_HOME=$(mktemp -d) ELECTRON_RUN_AS_NODE=1 \
188
- "/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness" \
189
- test/smoke-host.mjs
190
- ```
185
+ `npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow. It uses `/Applications/DeepSeek Harness.app` by default; set `DSH_ELECTRON` to another Electron executable. Set `DSH_E2E_KEEP=1` to keep the temporary home and logs.
191
186
 
192
187
  Project layout:
193
188
 
package/README.md CHANGED
@@ -174,19 +174,14 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
174
174
 
175
175
  ```bash
176
176
  pnpm install
177
- pnpm test
178
- npm run test:e2e
177
+ pnpm test # 单元测试
178
+ pnpm test:smoke # Host 冒烟测试(真实 Cordis,普通 Node 即可)
179
+ npm run test:e2e # 真实 dsh web 端到端(需要本机桌面版)
179
180
  ```
180
181
 
181
- `npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。默认使用 `/Applications/DeepSeek Harness.app`,也可以用 `DSH_ELECTRON` 指向其他 Electron 可执行文件。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
182
-
183
- Host 冒烟测试需要单独的临时 `DSH_HOME`:
182
+ Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实插件上下文,自己创建并清理临时 `DSH_HOME`,不需要桌面版;设置 `DSH_SMOKE_KEEP=1` 可以保留临时目录用于排查。CI(GitHub Actions)会跑单元测试、冒烟测试和打包内容检查;e2e 依赖本机的 DeepSeek Harness 桌面版,需要手动执行。
184
183
 
185
- ```bash
186
- DSH_HOME=$(mktemp -d) ELECTRON_RUN_AS_NODE=1 \
187
- "/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness" \
188
- test/smoke-host.mjs
189
- ```
184
+ `npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。默认使用 `/Applications/DeepSeek Harness.app`,也可以用 `DSH_ELECTRON` 指向其他 Electron 可执行文件。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
190
185
 
191
186
  项目结构:
192
187
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Small concurrency helpers shared by the trash services.
3
+ *
4
+ * @module dsh-session-trash/concurrency
5
+ */
6
+
7
+ /**
8
+ * Run `worker` over every item with a bounded number of in-flight operations.
9
+ * Results keep the input order; the first rejection stops the remaining work and
10
+ * rejects the returned promise.
11
+ *
12
+ * @param {readonly T[]} items input items.
13
+ * @param {number} limit maximum in-flight operations.
14
+ * @param {(item: T, index: number) => Promise<R>} worker per-item operation.
15
+ * @returns {Promise<R[]>} results in input order.
16
+ * @template T, R
17
+ */
18
+ export async function mapLimit(items, limit, worker) {
19
+ const results = new Array(items.length)
20
+ let next = 0
21
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
22
+ while (next < items.length) {
23
+ const index = next
24
+ next += 1
25
+ results[index] = await worker(items[index], index)
26
+ }
27
+ })
28
+ await Promise.all(runners)
29
+ return results
30
+ }
package/lib/inventory.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
+ import { mapLimit } from './concurrency.js'
3
4
  import { entryKind, isSafeDirectoryPath, isSafeMemberId, isSafeTrashId, samePath } from './fsops.js'
5
+ import { memberList } from './members.js'
6
+
7
+ /** Maximum concurrent object directories inspected during a scan. */
8
+ const SCAN_CONCURRENCY = 4
4
9
 
5
10
  /** Every state the durable index understands. */
6
11
  export const ENTRY_STATES = new Set(['trashing', 'trashed', 'restoring', 'purging', 'missing'])
@@ -220,6 +225,22 @@ export async function memberPresence(store, entry) {
220
225
  return { total: members.length, present, missing }
221
226
  }
222
227
 
228
+ /**
229
+ * Whether two member lists name the same directories in the same order, so a
230
+ * presence probe taken for one can be reused for the other.
231
+ *
232
+ * @param {readonly object[]} left first member list.
233
+ * @param {readonly object[]} right second member list.
234
+ * @returns {boolean} whether both lists describe the same members.
235
+ */
236
+ function sameMembers(left, right) {
237
+ if (left.length !== right.length) return false
238
+ for (let index = 0; index < left.length; index += 1) {
239
+ if (left[index]?.sessionId !== right[index]?.sessionId) return false
240
+ }
241
+ return true
242
+ }
243
+
223
244
  /**
224
245
  * Reconcile a rebuilt entry with the object directory observed on disk.
225
246
  *
@@ -271,13 +292,8 @@ export async function scanObjects(store, logger) {
271
292
  if (error !== null && error !== undefined && error.code === 'ENOENT') return { objects: [], unindexed: 0 }
272
293
  throw error
273
294
  }
274
- const objects = []
275
- let unindexed = 0
276
- for (const entry of entries) {
277
- if (!entry.isDirectory() || !isSafeTrashId(entry.name)) {
278
- unindexed += 1
279
- continue
280
- }
295
+ const scanned = await mapLimit(entries, SCAN_CONCURRENCY, async (entry) => {
296
+ if (!entry.isDirectory() || !isSafeTrashId(entry.name)) return { unindexed: 1 }
281
297
  const trashId = entry.name
282
298
  let meta
283
299
  try {
@@ -286,15 +302,11 @@ export async function scanObjects(store, logger) {
286
302
  logger.warn(`cannot read the trash metadata for ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
287
303
  // Keep the directory in the inventory so reconcile never mistakes an
288
304
  // unreadable sidecar for a vanished object directory.
289
- objects.push({ trashId, sessionExists: false, meta: undefined, metaValid: false })
290
- unindexed += 1
291
- continue
305
+ return { object: { trashId, sessionExists: false, meta: undefined, metaValid: false }, unindexed: 1 }
292
306
  }
293
307
  const metaValid = meta !== undefined && isValidMeta(meta, trashId)
294
308
  if (!metaValid) {
295
- objects.push({ trashId, sessionExists: false, meta, metaValid: false })
296
- unindexed += 1
297
- continue
309
+ return { object: { trashId, sessionExists: false, meta, metaValid: false }, unindexed: 1 }
298
310
  }
299
311
  let presence
300
312
  try {
@@ -303,14 +315,23 @@ export async function scanObjects(store, logger) {
303
315
  logger.warn(`cannot inspect the trash copy ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
304
316
  presence = { total: 0, present: 0, missing: [] }
305
317
  }
306
- objects.push({
307
- trashId,
308
- sessionExists: presence.total > 0 && presence.present === presence.total,
309
- meta,
310
- metaValid,
311
- entry: toEntry(meta),
312
- presence,
313
- })
318
+ return {
319
+ object: {
320
+ trashId,
321
+ sessionExists: presence.total > 0 && presence.present === presence.total,
322
+ meta,
323
+ metaValid,
324
+ entry: toEntry(meta),
325
+ presence,
326
+ },
327
+ unindexed: 0,
328
+ }
329
+ })
330
+ const objects = []
331
+ let unindexed = 0
332
+ for (const result of scanned) {
333
+ unindexed += result.unindexed
334
+ if (result.object !== undefined) objects.push(result.object)
314
335
  }
315
336
  return { objects, unindexed }
316
337
  }
@@ -321,12 +342,15 @@ export async function scanObjects(store, logger) {
321
342
  * directory.
322
343
  *
323
344
  * @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
324
- * @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number }>} reconcile summary.
345
+ * @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, object> }>} reconcile summary plus the presence probe per object.
325
346
  */
326
347
  export async function reconcileIndex({ store, logger, now = Date.now() }) {
327
- if (store.isProtected()) return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0 }
348
+ if (store.isProtected()) {
349
+ return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0, presence: new Map() }
350
+ }
328
351
  const { objects, unindexed: invalidObjects } = await scanObjects(store, logger)
329
352
  const byTrashId = new Map(objects.map((object) => [object.trashId, object]))
353
+ const presenceByTrashId = new Map()
330
354
  const nextEntries = [...store.entries()]
331
355
  const indexedTrashIds = new Set(nextEntries.map((entry) => entry.trashId))
332
356
  const sessionIds = new Set(nextEntries.map((entry) => entry.sessionId))
@@ -351,7 +375,16 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
351
375
  changed = true
352
376
  continue
353
377
  }
354
- const presence = await memberPresence(store, entry)
378
+ // The scan already probed every member directory of this object. Reuse that
379
+ // answer when the sidecar and the index entry describe the same members;
380
+ // otherwise probe again rather than trust a mismatched snapshot.
381
+ const presence =
382
+ object.presence !== undefined &&
383
+ object.entry !== undefined &&
384
+ sameMembers(memberList(object.entry), memberList(entry))
385
+ ? object.presence
386
+ : await memberPresence(store, entry)
387
+ presenceByTrashId.set(object.trashId, presence)
355
388
  if (entry.state === 'missing' && presence.total > 0 && presence.present === presence.total) {
356
389
  nextEntries[index] = {
357
390
  ...entry,
@@ -392,7 +425,7 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
392
425
  }
393
426
  }
394
427
  store.unindexed = unindexed
395
- return { imported, markedMissing, unindexed, changed: changed ? 1 : 0 }
428
+ return { imported, markedMissing, unindexed, changed: changed ? 1 : 0, presence: presenceByTrashId }
396
429
  }
397
430
 
398
431
  /**
package/lib/state.js CHANGED
@@ -1,30 +1,10 @@
1
1
  import fs from 'node:fs/promises'
2
+ import { mapLimit } from './concurrency.js'
2
3
  import { memberList } from './members.js'
3
4
 
4
5
  /** Maximum concurrent filesystem probes while describing entries. */
5
6
  const PROBE_CONCURRENCY = 3
6
7
 
7
- /**
8
- * @param {readonly T[]} items input items.
9
- * @param {number} limit maximum in-flight operations.
10
- * @param {(item: T) => Promise<R>} worker per-item operation.
11
- * @returns {Promise<R[]>} results in input order.
12
- * @template T, R
13
- */
14
- async function mapLimit(items, limit, worker) {
15
- const results = new Array(items.length)
16
- let next = 0
17
- const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
18
- while (next < items.length) {
19
- const index = next
20
- next += 1
21
- results[index] = await worker(items[index])
22
- }
23
- })
24
- await Promise.all(runners)
25
- return results
26
- }
27
-
28
8
  /**
29
9
  * @param {string | null | undefined} directory candidate workspace directory.
30
10
  * @returns {Promise<boolean>} whether the directory exists right now.
@@ -13,6 +13,7 @@ import {
13
13
  moveDirectory,
14
14
  samePath,
15
15
  } from './fsops.js'
16
+ import { mapLimit } from './concurrency.js'
16
17
  import { reconcileIndex, rebuildIndex, memberPresence } from './inventory.js'
17
18
  import { aggregateMetrics, memberList } from './members.js'
18
19
 
@@ -22,6 +23,9 @@ const VISIBILITY_RETRIES = 5
22
23
  /** Delay between visibility probes. */
23
24
  const VISIBILITY_DELAY_MS = 100
24
25
 
26
+ /** Maximum concurrent object deletions and sidecar rewrites during a purge. */
27
+ const PURGE_CONCURRENCY = 4
28
+
25
29
  /**
26
30
  * @param {number} milliseconds delay length.
27
31
  * @returns {Promise<void>} resolution after the delay.
@@ -219,22 +223,46 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
219
223
  }
220
224
 
221
225
  /**
226
+ * Probe every member directory in parallel: where the source copy and the
227
+ * trash copy currently live.
228
+ *
222
229
  * @param {object} entry index entry.
223
230
  * @returns {Promise<{ index: number, member: object, source: boolean, trash: boolean }[]>} member positions.
224
231
  */
225
232
  async function memberRows(entry) {
226
233
  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({
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) => ({
231
261
  index,
232
262
  member,
233
263
  source: (await kindOf(member.originalSessionDir)) === 'directory',
234
- trash: (await kindOf(memberTrashPath(entry, index))) === 'directory',
235
- })
236
- }
237
- return rows
264
+ })),
265
+ )
238
266
  }
239
267
 
240
268
  /**
@@ -382,14 +410,14 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
382
410
  * @returns {Promise<{ hidden: boolean, sessionId?: string }>} whether session storage stopped reporting every member.
383
411
  */
384
412
  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 }
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 }
393
421
  }
394
422
 
395
423
  /**
@@ -397,14 +425,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
397
425
  * @returns {Promise<{ visible: boolean, sessionId?: string }>} whether session storage reports every member again.
398
426
  */
399
427
  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 }
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 }
408
439
  }
409
440
 
410
441
  /**
@@ -826,6 +857,99 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
826
857
  return entry.trashedAt + config.retentionDays * 86_400_000 <= now
827
858
  }
828
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
+
829
953
  /**
830
954
  * Permanently remove one object directory and its index entry. The caller
831
955
  * owns the trash lock.
@@ -834,26 +958,35 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
834
958
  * @returns {Promise<void>} resolution once the object and entry are gone.
835
959
  */
836
960
  async function purgeInsideLock(entry) {
837
- await assertSafeObject(entry)
838
- const purging = { ...entry, state: 'purging', lastError: null }
839
- await store.commit(upsertEntry(store.entries(), purging))
840
- await store.writeMeta(purging).catch((error) => {
841
- logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
842
- })
843
- try {
844
- await store.removeObjectDir(entry.trashId)
845
- } catch (error) {
846
- await markFailure(purging, `The session log could not be deleted: ${messageOf(error)}`)
847
- throw new TrashError('IO_ERROR', 'The session log could not be deleted.', { trashId: entry.trashId })
848
- }
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)
849
977
  try {
850
- await store.commit(dropEntry(store.entries(), entry.sessionId))
978
+ await store.commit(next)
851
979
  } catch (error) {
852
- 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.', {
853
981
  code: error instanceof TrashError ? error.code : null,
854
982
  })
855
983
  }
856
- 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
857
990
  }
858
991
 
859
992
  /**
@@ -864,16 +997,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
864
997
  * @returns {Promise<{ sessionId: string, title: string }>} the removed identity.
865
998
  */
866
999
  async function forgetInsideLock(entry) {
867
- try {
868
- await store.commit(dropEntry(store.entries(), entry.sessionId))
869
- } catch (error) {
870
- throw new TrashError('INDEX_WRITE_FAILED', 'The trash index could not be updated.', {
871
- code: error instanceof TrashError ? error.code : null,
872
- })
873
- }
874
- await store.removeObjectRemnants(entry.trashId).catch((error) => {
875
- logger.warn(`cannot remove the remnants of ${entry.trashId}: ${messageOf(error)}`)
876
- })
1000
+ await forgetManyInsideLock([entry])
877
1001
  return { sessionId: entry.sessionId, title: entry.title }
878
1002
  }
879
1003
 
@@ -1222,12 +1346,16 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1222
1346
  /**
1223
1347
  * Apply the crash matrix to every index entry. The caller must hold the trash
1224
1348
  * lock. It never deletes an unindexed object and never removes a `missing`
1225
- * 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.
1226
1352
  *
1353
+ * @param {Map<string, { total: number, present: number, missing: string[] }>} [presenceByTrashId] reconcile probe results keyed by object identity.
1227
1354
  * @returns {Promise<{ changed: number, completed: number, rolledBack: number, missing: number }>} recovery summary.
1228
1355
  */
1229
- async function recoverInsideLock() {
1356
+ async function recoverInsideLock(presenceByTrashId = new Map()) {
1230
1357
  let entries = [...store.entries()]
1358
+ const metaUpdates = []
1231
1359
  let changed = 0
1232
1360
  let completed = 0
1233
1361
  let rolledBack = 0
@@ -1235,6 +1363,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1235
1363
  const commit = (next) => {
1236
1364
  if (next === undefined) return
1237
1365
  entries = upsertEntry(entries, next)
1366
+ metaUpdates.push(next)
1238
1367
  }
1239
1368
  const drop = (sessionId) => {
1240
1369
  entries = dropEntry(entries, sessionId)
@@ -1300,7 +1429,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1300
1429
 
1301
1430
  if (original.state === 'trashed') {
1302
1431
  await assertSafeObject(original)
1303
- const presence = await memberPresence(store, original)
1432
+ const presence = presenceByTrashId.get(original.trashId) ?? (await memberPresence(store, original))
1304
1433
  if (presence.present < presence.total) {
1305
1434
  commit({
1306
1435
  ...original,
@@ -1314,8 +1443,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1314
1443
  changed += 1
1315
1444
  continue
1316
1445
  }
1317
- const rows = await memberRows(original)
1318
- if (rows.some((row) => row.source)) {
1446
+ const sources = await sourceRows(original)
1447
+ if (sources.some((row) => row.source)) {
1319
1448
  commit(fail(original, 'An original session directory unexpectedly exists alongside the trash copy; both were kept.'))
1320
1449
  changed += 1
1321
1450
  }
@@ -1328,7 +1457,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1328
1457
 
1329
1458
  if (changed > 0) {
1330
1459
  await store.commit(entries)
1331
- for (const entry of entries) {
1460
+ for (const entry of metaUpdates) {
1332
1461
  await store.writeMeta(entry).catch((error) => {
1333
1462
  logger.warn(`cannot refresh trash metadata for ${entry.trashId}: ${messageOf(error)}`)
1334
1463
  })
@@ -1355,7 +1484,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1355
1484
  return summary
1356
1485
  } catch (error) {
1357
1486
  logger.warn(`trash index reconciliation failed: ${messageOf(error)}`)
1358
- return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0 }
1487
+ return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0, presence: new Map() }
1359
1488
  }
1360
1489
  }
1361
1490
 
@@ -1372,18 +1501,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1372
1501
  throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses cleanup.')
1373
1502
  }
1374
1503
  const reconcile = await reconcileInsideLock(now)
1375
- const recovery = await recoverInsideLock()
1504
+ const recovery = await recoverInsideLock(reconcile.presence)
1376
1505
  let purged = 0
1377
1506
  let failed = 0
1378
1507
  if (allowPurge) {
1379
- for (const entry of [...store.entries()]) {
1380
- if (!isExpired(entry, now)) continue
1381
- try {
1382
- await purgeInsideLock(entry)
1383
- purged += 1
1384
- } catch (error) {
1385
- failed += 1
1386
- 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)}`)
1387
1515
  }
1388
1516
  }
1389
1517
  }
@@ -1453,34 +1581,59 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1453
1581
  throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses deletion.')
1454
1582
  }
1455
1583
  const reconcile = await reconcileInsideLock(Date.now())
1456
- const recovery = await recoverInsideLock()
1457
- let purged = 0
1458
- let forgotten = 0
1459
- let failed = 0
1584
+ const recovery = await recoverInsideLock(reconcile.presence)
1460
1585
  const failures = []
1586
+ const purgeTargets = []
1587
+ const forgetTargets = []
1588
+ let failed = 0
1461
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
+ }
1462
1598
  const title = typeof entry.title === 'string' ? entry.title : ''
1463
- try {
1464
- if (entry.state === 'trashed') {
1465
- await purgeInsideLock(entry)
1466
- purged += 1
1467
- continue
1468
- }
1469
- if (entry.state === 'missing') {
1470
- await forgetInsideLock(entry)
1471
- forgotten += 1
1472
- continue
1473
- }
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) {
1474
1610
  failed += 1
1475
- const message = `The trash entry is in state "${entry.state}" and cannot be cleaned up.`
1476
- failures.push({ sessionId: entry.sessionId, title, message })
1477
- 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)
1478
1624
  } catch (error) {
1479
- failed += 1
1480
- failures.push({ sessionId: entry.sessionId, title, message: messageOf(error) })
1481
- 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
+ }
1482
1634
  }
1483
1635
  }
1636
+
1484
1637
  logger.info(`bulk purge finished: purged ${purged}, forgotten ${forgotten}, failed ${failed}`)
1485
1638
  return {
1486
1639
  purged,
@@ -1569,8 +1722,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1569
1722
  try {
1570
1723
  summary = await store.withLock(async () => {
1571
1724
  if (store.isProtected()) return { changed: 0, completed: 0, rolledBack: 0, missing: 0 }
1572
- await reconcileInsideLock(Date.now())
1573
- return await recoverInsideLock()
1725
+ const reconcile = await reconcileInsideLock(Date.now())
1726
+ return await recoverInsideLock(reconcile.presence)
1574
1727
  })
1575
1728
  } catch (error) {
1576
1729
  if (error instanceof TrashError && error.code === 'LOCKED') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangxiaocuo/dsh-session-trash",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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",
@@ -23,6 +23,7 @@
23
23
  ],
24
24
  "type": "module",
25
25
  "engines": {
26
+ "node": ">=20",
26
27
  "dsh": "^0.2.2-rc.1"
27
28
  },
28
29
  "exports": {
@@ -43,13 +44,17 @@
43
44
  ],
44
45
  "scripts": {
45
46
  "test": "node --test test/*.test.js",
47
+ "test:coverage": "node --experimental-test-coverage --test test/*.test.js",
46
48
  "test:e2e": "node test/e2e-web.mjs",
47
- "test:smoke": "DSH_HOME=$(mktemp -d) ELECTRON_RUN_AS_NODE=1 \"/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness\" test/smoke-host.mjs"
49
+ "test:smoke": "node test/smoke-host.mjs"
48
50
  },
49
51
  "dependencies": {
50
52
  "@deepseek-ai/dsh-home-paths": "0.2.2-rc.1",
51
53
  "@deepseek-ai/schemastery": "3.19.0"
52
54
  },
55
+ "devDependencies": {
56
+ "@deepseek-ai/cordis": "4.1.0"
57
+ },
53
58
  "dsh": {
54
59
  "bundle": {
55
60
  "patch": "./cordis.patch.yml"