@wangxiaocuo/dsh-session-trash 0.6.1 → 0.6.3

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/lib/errors.js CHANGED
@@ -62,16 +62,76 @@ export function statusOf(error) {
62
62
  return ERROR_STATUS[code] ?? 500
63
63
  }
64
64
 
65
+ /**
66
+ * Detail keys the Host may return to the Client. Anything else - notably raw
67
+ * filesystem paths or nested error text - stays in the Host log, because the
68
+ * wire contract promises that the trash root's absolute path is never exposed.
69
+ */
70
+ const SAFE_DETAIL_KEYS = new Set([
71
+ 'sessionId',
72
+ 'trashId',
73
+ 'code',
74
+ 'phase',
75
+ 'copied',
76
+ 'kind',
77
+ 'targetKind',
78
+ 'expected',
79
+ 'measured',
80
+ 'activity',
81
+ 'descendants',
82
+ 'sessions',
83
+ ])
84
+
85
+ /**
86
+ * Metrics snapshots are the only nested detail value the Host returns; keep
87
+ * only their numeric counters so no path can ride along.
88
+ *
89
+ * @param {unknown} value candidate metrics object.
90
+ * @returns {object | undefined} the numeric metrics.
91
+ */
92
+ function sanitizeMetrics(value) {
93
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
94
+ const safe = {}
95
+ for (const key of ['sizeBytes', 'fileCount', 'symlinkCount']) {
96
+ if (typeof value[key] === 'number') safe[key] = value[key]
97
+ }
98
+ return Object.keys(safe).length === 0 ? undefined : safe
99
+ }
100
+
101
+ /**
102
+ * @param {unknown} details arbitrary structured context.
103
+ * @returns {object | undefined} the same context restricted to whitelisted keys.
104
+ */
105
+ export function sanitizeDetails(details) {
106
+ if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
107
+ const safe = {}
108
+ for (const [key, value] of Object.entries(details)) {
109
+ if (!SAFE_DETAIL_KEYS.has(key)) continue
110
+ if (key === 'expected' || key === 'measured') {
111
+ const metrics = sanitizeMetrics(value)
112
+ if (metrics !== undefined) safe[key] = metrics
113
+ continue
114
+ }
115
+ if (Array.isArray(value)) {
116
+ safe[key] = value.filter((item) => typeof item === 'string' || typeof item === 'number')
117
+ continue
118
+ }
119
+ if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) safe[key] = value
120
+ }
121
+ return Object.keys(safe).length === 0 ? undefined : safe
122
+ }
123
+
65
124
  /**
66
125
  * @param {unknown} error arbitrary thrown value.
67
126
  * @returns {{ code: string, message: string, details?: object }} the wire error shape.
68
127
  */
69
128
  export function toWireError(error) {
70
129
  if (error instanceof TrashError) {
130
+ const details = sanitizeDetails(error.details)
71
131
  return {
72
132
  code: error.code,
73
133
  message: error.message,
74
- ...(error.details === undefined ? {} : { details: error.details }),
134
+ ...(details === undefined ? {} : { details }),
75
135
  }
76
136
  }
77
137
  const message = error instanceof Error ? error.message : String(error)
package/lib/fsops.js CHANGED
@@ -294,6 +294,11 @@ export function metricsMatch(entry, measured) {
294
294
  if (entry === null || entry === undefined) return true
295
295
  if (typeof entry.sizeBytes === 'number' && entry.sizeBytes !== measured.sizeBytes) return false
296
296
  if (typeof entry.fileCount === 'number' && entry.fileCount !== measured.fileCount) return false
297
+ // A cross-device copy records the symlink count of the source; comparing it
298
+ // catches a symlink that the copy silently dropped.
299
+ if (typeof entry.symlinkCount === 'number' && typeof measured.symlinkCount === 'number' && entry.symlinkCount !== measured.symlinkCount) {
300
+ return false
301
+ }
297
302
  return true
298
303
  }
299
304
 
@@ -356,10 +361,12 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
356
361
  try {
357
362
  await fs.rm(source, { recursive: true, force: false, maxRetries: 3, retryDelay: 100 })
358
363
  } catch (error) {
364
+ // The paths stay in the Host log; the wire error only carries the error
365
+ // code and the fact that a verified copy already exists.
359
366
  throw new TrashError(
360
367
  'IO_ERROR',
361
368
  'The session was copied to the destination, but the source could not be removed.',
362
- { source, destination, code: errorCode(error) ?? null, copied: true },
369
+ { code: errorCode(error) ?? null, copied: true },
363
370
  )
364
371
  }
365
372
  }
package/lib/state.js CHANGED
@@ -5,6 +5,34 @@ import { memberList } from './members.js'
5
5
  /** Maximum concurrent filesystem probes while describing entries. */
6
6
  const PROBE_CONCURRENCY = 3
7
7
 
8
+ /**
9
+ * Known failure families recorded in `lastError`, in match order. The raw text
10
+ * stays an English diagnostic for logs and tooltips; the code is what the
11
+ * Client localizes.
12
+ */
13
+ const LAST_ERROR_KINDS = [
14
+ [/^The trash copy of this session is missing\./, 'TRASH_COPY_MISSING'],
15
+ [/^The trash copy is missing these member sessions:/, 'TRASH_COPY_MISSING_MEMBERS'],
16
+ [/^The trash object directory is missing\./, 'TRASH_OBJECT_MISSING'],
17
+ [/^The session log could not be deleted:/, 'PURGE_FAILED'],
18
+ [/^The restored session (tree )?(is|was) not visible to session storage\./, 'RESTORE_INVISIBLE'],
19
+ [/^Session storage still reports the moved session tree/, 'MOVE_UNSETTLED'],
20
+ [/^An original session directory unexpectedly exists/, 'SOURCE_CONFLICT'],
21
+ [/^The trash index could not be committed/, 'INDEX_WRITE_FAILED'],
22
+ ]
23
+
24
+ /**
25
+ * @param {unknown} message raw `lastError` text.
26
+ * @returns {string | null} a stable failure family, or null when none was recorded.
27
+ */
28
+ export function lastErrorCode(message) {
29
+ if (typeof message !== 'string' || message === '') return null
30
+ for (const [pattern, code] of LAST_ERROR_KINDS) {
31
+ if (pattern.test(message)) return code
32
+ }
33
+ return 'IO_ERROR'
34
+ }
35
+
8
36
  /**
9
37
  * @param {string | null | undefined} directory candidate workspace directory.
10
38
  * @returns {Promise<boolean>} whether the directory exists right now.
@@ -31,7 +59,14 @@ async function directoryExists(directory) {
31
59
  * }} deps state inputs.
32
60
  * @returns {{ snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> }} the state service.
33
61
  */
34
- export function createStateService({ store, config, trashRootDisplay, nextSweepAt = () => null, cleanup }) {
62
+ export function createStateService({
63
+ store,
64
+ config,
65
+ trashRootDisplay,
66
+ nextSweepAt = () => null,
67
+ cleanup,
68
+ directoryExists: probeDirectory = directoryExists,
69
+ }) {
35
70
  /**
36
71
  * @returns {object} cleanup diagnostics, or a neutral fallback.
37
72
  */
@@ -58,11 +93,17 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
58
93
 
59
94
  /**
60
95
  * @param {object} entry committed index entry.
96
+ * @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
97
+ * started for this snapshot, so entries sharing a cwd probe it once.
61
98
  * @returns {Promise<object>} the wire entry.
62
99
  */
63
- async function describeEntry(entry) {
100
+ async function describeEntry(entry, probeCache = new Map()) {
64
101
  const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
65
- const workspaceNowExists = cwd === null ? null : await directoryExists(cwd)
102
+ let workspaceNowExists = null
103
+ if (cwd !== null) {
104
+ if (!probeCache.has(cwd)) probeCache.set(cwd, probeDirectory(cwd))
105
+ workspaceNowExists = await probeCache.get(cwd)
106
+ }
66
107
  const expiresAt =
67
108
  typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
68
109
  const trashed = entry.state === 'trashed'
@@ -85,6 +126,7 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
85
126
  canForget: entry.state === 'missing',
86
127
  requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
87
128
  lastError: entry.lastError ?? null,
129
+ lastErrorCode: lastErrorCode(entry.lastError),
88
130
  subagentCount: Math.max(0, members.length - 1),
89
131
  members: members.map((member) => ({
90
132
  sessionId: member.sessionId,
@@ -104,7 +146,8 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
104
146
  * @returns {Promise<object>} the complete `/state` response body.
105
147
  */
106
148
  async snapshot() {
107
- const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry))
149
+ const probeCache = new Map()
150
+ const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry, probeCache))
108
151
  let totalBytes = 0
109
152
  let failedEntries = 0
110
153
  for (const entry of entries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangxiaocuo/dsh-session-trash",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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",
@@ -40,11 +40,12 @@
40
40
  "locale/*.json",
41
41
  "icon.svg",
42
42
  "README.md",
43
- "README.en.md"
43
+ "README.en.md",
44
+ "CHANGELOG.md"
44
45
  ],
45
46
  "scripts": {
46
- "test": "node --test test/*.test.js",
47
- "test:coverage": "node --experimental-test-coverage --test test/*.test.js",
47
+ "test": "node test/run-unit.mjs",
48
+ "test:coverage": "node test/run-unit.mjs --experimental-test-coverage",
48
49
  "test:e2e": "node test/e2e-web.mjs",
49
50
  "test:smoke": "node test/smoke-host.mjs"
50
51
  },