@wangxiaocuo/dsh-session-trash 0.6.2 → 0.6.4

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
@@ -43,7 +43,7 @@ export class TrashError extends Error {
43
43
  /**
44
44
  * @param {string} code stable error code.
45
45
  * @param {string} message owner-facing explanation.
46
- * @param {object} [details] optional structured context.
46
+ * @param {any} [details] optional structured context.
47
47
  */
48
48
  constructor(code, message, details) {
49
49
  super(message)
@@ -54,7 +54,7 @@ export class TrashError extends Error {
54
54
  }
55
55
 
56
56
  /**
57
- * @param {unknown} error arbitrary thrown value.
57
+ * @param {any} error arbitrary thrown value.
58
58
  * @returns {number} HTTP status for this error.
59
59
  */
60
60
  export function statusOf(error) {
@@ -63,15 +63,75 @@ export function statusOf(error) {
63
63
  }
64
64
 
65
65
  /**
66
- * @param {unknown} error arbitrary thrown value.
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 {any} value candidate metrics object.
90
+ * @returns {any | 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 {any} details arbitrary structured context.
103
+ * @returns {any | 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
+
124
+ /**
125
+ * @param {any} 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
@@ -36,7 +36,7 @@ const COPY_OPTIONS = {
36
36
  }
37
37
 
38
38
  /**
39
- * @param {unknown} error thrown filesystem failure.
39
+ * @param {any} error thrown filesystem failure.
40
40
  * @returns {string | undefined} the Node error code.
41
41
  */
42
42
  export function errorCode(error) {
@@ -194,7 +194,7 @@ export function isInside(root, target) {
194
194
  }
195
195
 
196
196
  /**
197
- * @param {unknown} trashId candidate object identity.
197
+ * @param {any} trashId candidate object identity.
198
198
  * @returns {boolean} whether the identity is one safe path segment.
199
199
  */
200
200
  export function isSafeTrashId(trashId) {
@@ -214,7 +214,7 @@ export function isSafeTrashId(trashId) {
214
214
  * object. Session ids are `session-<uuid>` or a bare uuid today, so the
215
215
  * accepted alphabet is deliberately conservative.
216
216
  *
217
- * @param {unknown} sessionId candidate member identity.
217
+ * @param {any} sessionId candidate member identity.
218
218
  * @returns {boolean} whether the identity is one safe path segment.
219
219
  */
220
220
  export function isSafeMemberId(sessionId) {
@@ -286,19 +286,24 @@ export async function measureDirectory(directory) {
286
286
  }
287
287
 
288
288
  /**
289
- * @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
290
- * @param {{ sizeBytes: number, fileCount: number }} measured current metrics.
289
+ * @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
290
+ * @param {{ sizeBytes: number, fileCount: number, symlinkCount?: number }} measured current metrics.
291
291
  * @returns {boolean} whether the recorded metrics are absent or match exactly.
292
292
  */
293
293
  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
 
300
305
  /**
301
- * @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
306
+ * @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
302
307
  * @returns {boolean} whether the entry carries a complete move snapshot.
303
308
  */
304
309
  export function hasMetrics(entry) {
@@ -334,7 +339,7 @@ export function createTrashId() {
334
339
  * @param {string} destination final directory path.
335
340
  * @param {{ sizeBytes: number, fileCount: number }} expected recorded metrics.
336
341
  * @param {(from: string, to: string) => Promise<void>} rename rename implementation for tests.
337
- * @param {(source: string, destination: string, options: object) => Promise<void>} copy copy implementation for tests.
342
+ * @param {(source: string, destination: string, options: any) => Promise<void>} copy copy implementation for tests.
338
343
  * @returns {Promise<void>} resolution after the destination is published.
339
344
  */
340
345
  async function copyAcrossDevices(source, destination, expected, rename, copy) {
@@ -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
  }
@@ -375,7 +382,7 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
375
382
  * @param {{
376
383
  * logger?: { warn: Function },
377
384
  * rename?: (from: string, to: string) => Promise<void>,
378
- * copy?: (source: string, destination: string, options: object) => Promise<void>,
385
+ * copy?: (source: string, destination: string, options: any) => Promise<void>,
379
386
  * }} [options] diagnostics sink and test seams.
380
387
  * @returns {Promise<void>} resolution after the directory lives at `destination`.
381
388
  */
package/lib/http.js CHANGED
@@ -45,7 +45,7 @@ function headerBag(req) {
45
45
  /**
46
46
  * @param {import('node:http').ServerResponse} res response to complete.
47
47
  * @param {number} status HTTP status.
48
- * @param {unknown} body JSON-serializable body.
48
+ * @param {any} body JSON-serializable body.
49
49
  * @returns {void}
50
50
  */
51
51
  function sendJson(res, status, body) {
@@ -62,7 +62,7 @@ function sendJson(res, status, body) {
62
62
 
63
63
  /**
64
64
  * @param {import('node:http').ServerResponse} res response to complete.
65
- * @param {unknown} error thrown failure.
65
+ * @param {any} error thrown failure.
66
66
  * @returns {void}
67
67
  */
68
68
  function sendError(res, error) {
@@ -151,20 +151,24 @@ function requireSessionId(body) {
151
151
  * @param {{
152
152
  * ctx: import('@deepseek-ai/cordis').Context,
153
153
  * eligibility: { evaluate: Function, invalidate: Function },
154
- * stateService: { snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> },
154
+ * stateService: { snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> },
155
155
  * trashService: {
156
- * trash: (sessionId: string) => Promise<object>,
157
- * restore: (sessionId: string, mode?: string) => Promise<object>,
158
- * purge: (sessionId: string) => Promise<object>,
159
- * purgeAll: () => Promise<object>,
160
- * forget: (sessionId: string) => Promise<object>,
161
- * reveal: (sessionId: string, signal?: AbortSignal) => Promise<object>,
162
- * rebuild: () => Promise<object>,
156
+ * trash: (sessionId: string) => Promise<any>,
157
+ * restore: (sessionId: string, mode?: string) => Promise<any>,
158
+ * purge: (sessionId: string) => Promise<any>,
159
+ * purgeAll: () => Promise<any>,
160
+ * forget: (sessionId: string) => Promise<any>,
161
+ * reveal: (sessionId: string, signal?: AbortSignal) => Promise<any>,
162
+ * rebuild: () => Promise<any>,
163
163
  * },
164
- * cleanupService?: { runNow: (options?: object) => Promise<object>, maybeSweepOnPageOpen: () => void },
164
+ * cleanupService?: {
165
+ runNow: (options?: any) => Promise<any>,
166
+ maybeSweepOnPageOpen: () => void,
167
+ diagnostics?: () => any,
168
+ },
165
169
  * ready: Promise<unknown>,
166
- * config: object,
167
- * logger: object,
170
+ * config: any,
171
+ * logger: any,
168
172
  * }} deps host services.
169
173
  * @returns {void}
170
174
  */
@@ -292,7 +296,11 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
292
296
  route('restore', 'POST', async (req, res) => {
293
297
  const body = await readJsonRequest(req)
294
298
  const sessionId = requireSessionId(body)
295
- const mode = body.mode
299
+ const rawMode = body.mode
300
+ if (rawMode !== undefined && typeof rawMode !== 'string') {
301
+ throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
302
+ }
303
+ const mode = typeof rawMode === 'string' ? rawMode : undefined
296
304
  if (mode !== undefined && mode !== 'normal' && mode !== 'orphan') {
297
305
  throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
298
306
  }
package/lib/inventory.js CHANGED
@@ -17,7 +17,7 @@ const META_VERSION = 2
17
17
  const MAX_MEMBERS = 64
18
18
 
19
19
  /**
20
- * @param {unknown} value candidate timestamp.
20
+ * @param {any} value candidate timestamp.
21
21
  * @returns {boolean} whether the value is a finite number.
22
22
  */
23
23
  function isFiniteNumber(value) {
@@ -25,7 +25,7 @@ function isFiniteNumber(value) {
25
25
  }
26
26
 
27
27
  /**
28
- * @param {unknown} value candidate metric.
28
+ * @param {any} value candidate metric.
29
29
  * @returns {boolean} whether the value is a usable number or absent.
30
30
  */
31
31
  function isMetric(value) {
@@ -36,7 +36,7 @@ function isMetric(value) {
36
36
  * Validate one v2 member's absolute paths. The parent's sidecar is validated
37
37
  * field by field so a hand-edited file is rejected instead of guessed at.
38
38
  *
39
- * @param {unknown} member candidate member.
39
+ * @param {any} member candidate member.
40
40
  * @returns {boolean} whether the member names safe, coherent paths.
41
41
  */
42
42
  function isValidMemberPaths(member) {
@@ -72,7 +72,7 @@ function isValidMemberPaths(member) {
72
72
  /**
73
73
  * Validate the v2 member list of one sidecar.
74
74
  *
75
- * @param {object} meta candidate sidecar.
75
+ * @param {any} meta candidate sidecar.
76
76
  * @returns {boolean} whether the list is coherent with its parent fields.
77
77
  */
78
78
  function isValidMetaMembers(meta) {
@@ -100,7 +100,7 @@ function isValidMetaMembers(meta) {
100
100
  * entry. A malformed sidecar is reported as an unindexed object instead of
101
101
  * being guessed at.
102
102
  *
103
- * @param {unknown} meta parsed `meta.json`.
103
+ * @param {any} meta parsed `meta.json`.
104
104
  * @param {string} trashId object directory identity.
105
105
  * @returns {boolean} whether the sidecar can rebuild one entry.
106
106
  */
@@ -132,9 +132,9 @@ export function isValidMeta(meta, trashId) {
132
132
  }
133
133
 
134
134
  /**
135
- * @param {Record<string, unknown>} member raw member fields.
135
+ * @param {any} member raw member fields.
136
136
  * @param {string} fallbackTitle title used when the member carries none.
137
- * @returns {object} one normalized v2 member.
137
+ * @returns {any} one normalized v2 member.
138
138
  */
139
139
  function toMember(member, fallbackTitle) {
140
140
  return {
@@ -151,8 +151,8 @@ function toMember(member, fallbackTitle) {
151
151
  }
152
152
 
153
153
  /**
154
- * @param {Record<string, unknown>} meta valid sidecar.
155
- * @returns {object} a clean index entry with defaulted optional fields.
154
+ * @param {any} meta valid sidecar.
155
+ * @returns {any} a clean index entry with defaulted optional fields.
156
156
  */
157
157
  export function toEntry(meta) {
158
158
  const originalCwd = typeof meta.originalCwd === 'string' && meta.originalCwd !== '' ? meta.originalCwd : null
@@ -201,7 +201,7 @@ export function toEntry(meta) {
201
201
  * Which of one entry's member directories currently exist inside its object.
202
202
  *
203
203
  * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
204
- * @param {object} entry index entry, v1 or v2 shaped.
204
+ * @param {any} entry index entry, v1 or v2 shaped.
205
205
  * @returns {Promise<{ total: number, present: number, missing: string[] }>} presence summary.
206
206
  */
207
207
  export async function memberPresence(store, entry) {
@@ -229,8 +229,8 @@ export async function memberPresence(store, entry) {
229
229
  * Whether two member lists name the same directories in the same order, so a
230
230
  * presence probe taken for one can be reused for the other.
231
231
  *
232
- * @param {readonly object[]} left first member list.
233
- * @param {readonly object[]} right second member list.
232
+ * @param {readonly any[]} left first member list.
233
+ * @param {readonly any[]} right second member list.
234
234
  * @returns {boolean} whether both lists describe the same members.
235
235
  */
236
236
  function sameMembers(left, right) {
@@ -244,10 +244,10 @@ function sameMembers(left, right) {
244
244
  /**
245
245
  * Reconcile a rebuilt entry with the object directory observed on disk.
246
246
  *
247
- * @param {object} entry index-shaped entry.
247
+ * @param {any} entry index-shaped entry.
248
248
  * @param {{ total: number, present: number, missing: string[] }} presence member presence.
249
249
  * @param {number} now current timestamp.
250
- * @returns {object} the entry whose state matches the filesystem.
250
+ * @returns {any} the entry whose state matches the filesystem.
251
251
  */
252
252
  export function deriveEntry(entry, presence, now) {
253
253
  const complete = presence.total > 0 && presence.present === presence.total
@@ -282,7 +282,7 @@ export function deriveEntry(entry, presence, now) {
282
282
  *
283
283
  * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
284
284
  * @param {{ warn: Function }} logger diagnostics sink.
285
- * @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean, entry?: object, presence?: object }[], unindexed: number }>} the inventory.
285
+ * @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: any | undefined, metaValid: boolean, entry?: any, presence?: object }[], unindexed: number }>} the inventory.
286
286
  */
287
287
  export async function scanObjects(store, logger) {
288
288
  let entries
@@ -342,7 +342,7 @@ export async function scanObjects(store, logger) {
342
342
  * directory.
343
343
  *
344
344
  * @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
345
- * @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, object> }>} reconcile summary plus the presence probe per object.
345
+ * @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, any> }>} reconcile summary plus the presence probe per object.
346
346
  */
347
347
  export async function reconcileIndex({ store, logger, now = Date.now() }) {
348
348
  if (store.isProtected()) {
package/lib/members.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  /**
11
- * @param {unknown} entry index entry (v1 or v2 shape).
11
+ * @param {any} entry index entry (v1 or v2 shape).
12
12
  * @returns {object|null} the parent member, or null when the entry has no directory data.
13
13
  */
14
14
  export function parentMember(entry) {
@@ -28,8 +28,8 @@ export function parentMember(entry) {
28
28
  }
29
29
 
30
30
  /**
31
- * @param {unknown} entry index entry (v1 or v2 shape).
32
- * @returns {object[]} every owned directory, parent first.
31
+ * @param {any} entry index entry (v1 or v2 shape).
32
+ * @returns {any[]} every owned directory, parent first.
33
33
  */
34
34
  export function memberList(entry) {
35
35
  if (entry !== null && typeof entry === 'object' && Array.isArray(entry.members) && entry.members.length > 0) {
@@ -40,7 +40,7 @@ export function memberList(entry) {
40
40
  }
41
41
 
42
42
  /**
43
- * @param {readonly object[]} members member list.
43
+ * @param {readonly any[]} members member list.
44
44
  * @returns {{ sizeBytes: number, fileCount: number }} summed measurable totals.
45
45
  */
46
46
  export function aggregateMetrics(members) {
@@ -57,7 +57,7 @@ export function aggregateMetrics(members) {
57
57
  * Shape-level validation shared by the durable index and object sidecars.
58
58
  * Path strictness belongs to the sidecar validator.
59
59
  *
60
- * @param {unknown} member candidate member.
60
+ * @param {any} member candidate member.
61
61
  * @returns {boolean} whether the member carries the required v2 identity.
62
62
  */
63
63
  export function isValidMemberShape(member) {
@@ -79,7 +79,7 @@ export function isValidMemberShape(member) {
79
79
  /**
80
80
  * Whether the v2 member list is internally consistent with its parent entry.
81
81
  *
82
- * @param {unknown} entry index entry carrying `members`.
82
+ * @param {any} entry index entry carrying `members`.
83
83
  * @returns {boolean} whether the list is acceptable.
84
84
  */
85
85
  export function isValidMemberList(entry) {
package/lib/state.js CHANGED
@@ -5,6 +5,35 @@ 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
+ /** @type {[RegExp, string][]} */
14
+ const LAST_ERROR_KINDS = [
15
+ [/^The trash copy of this session is missing\./, 'TRASH_COPY_MISSING'],
16
+ [/^The trash copy is missing these member sessions:/, 'TRASH_COPY_MISSING_MEMBERS'],
17
+ [/^The trash object directory is missing\./, 'TRASH_OBJECT_MISSING'],
18
+ [/^The session log could not be deleted:/, 'PURGE_FAILED'],
19
+ [/^The restored session (tree )?(is|was) not visible to session storage\./, 'RESTORE_INVISIBLE'],
20
+ [/^Session storage still reports the moved session tree/, 'MOVE_UNSETTLED'],
21
+ [/^An original session directory unexpectedly exists/, 'SOURCE_CONFLICT'],
22
+ [/^The trash index could not be committed/, 'INDEX_WRITE_FAILED'],
23
+ ]
24
+
25
+ /**
26
+ * @param {any} message raw `lastError` text.
27
+ * @returns {string | null} a stable failure family, or null when none was recorded.
28
+ */
29
+ export function lastErrorCode(message) {
30
+ if (typeof message !== 'string' || message === '') return null
31
+ for (const [pattern, code] of LAST_ERROR_KINDS) {
32
+ if (pattern.test(message)) return code
33
+ }
34
+ return 'IO_ERROR'
35
+ }
36
+
8
37
  /**
9
38
  * @param {string | null | undefined} directory candidate workspace directory.
10
39
  * @returns {Promise<boolean>} whether the directory exists right now.
@@ -27,13 +56,21 @@ async function directoryExists(directory) {
27
56
  * config: { retentionDays: number, sweepIntervalHours: number, enableAutoCleanup: boolean },
28
57
  * trashRootDisplay: string,
29
58
  * nextSweepAt?: () => number | null,
30
- * cleanup?: () => object,
59
+ * cleanup?: () => any,
60
+ * directoryExists?: (directory?: any) => Promise<boolean>,
31
61
  * }} deps state inputs.
32
- * @returns {{ snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> }} the state service.
62
+ * @returns {{ snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> }} the state service.
33
63
  */
34
- export function createStateService({ store, config, trashRootDisplay, nextSweepAt = () => null, cleanup }) {
64
+ export function createStateService({
65
+ store,
66
+ config,
67
+ trashRootDisplay,
68
+ nextSweepAt = () => null,
69
+ cleanup,
70
+ directoryExists: probeDirectory = directoryExists,
71
+ }) {
35
72
  /**
36
- * @returns {object} cleanup diagnostics, or a neutral fallback.
73
+ * @returns {any} cleanup diagnostics, or a neutral fallback.
37
74
  */
38
75
  function cleanupDiagnostics() {
39
76
  if (typeof cleanup === 'function') {
@@ -57,12 +94,18 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
57
94
  }
58
95
 
59
96
  /**
60
- * @param {object} entry committed index entry.
61
- * @returns {Promise<object>} the wire entry.
97
+ * @param {any} entry committed index entry.
98
+ * @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
99
+ * started for this snapshot, so entries sharing a cwd probe it once.
100
+ * @returns {Promise<any>} the wire entry.
62
101
  */
63
- async function describeEntry(entry) {
102
+ async function describeEntry(entry, probeCache = new Map()) {
64
103
  const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
65
- const workspaceNowExists = cwd === null ? null : await directoryExists(cwd)
104
+ let workspaceNowExists = null
105
+ if (cwd !== null) {
106
+ if (!probeCache.has(cwd)) probeCache.set(cwd, probeDirectory(cwd))
107
+ workspaceNowExists = await probeCache.get(cwd)
108
+ }
66
109
  const expiresAt =
67
110
  typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
68
111
  const trashed = entry.state === 'trashed'
@@ -85,6 +128,7 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
85
128
  canForget: entry.state === 'missing',
86
129
  requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
87
130
  lastError: entry.lastError ?? null,
131
+ lastErrorCode: lastErrorCode(entry.lastError),
88
132
  subagentCount: Math.max(0, members.length - 1),
89
133
  members: members.map((member) => ({
90
134
  sessionId: member.sessionId,
@@ -96,15 +140,16 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
96
140
 
97
141
  return {
98
142
  /**
99
- * @param {object} entry committed index entry.
100
- * @returns {Promise<object>} the wire entry.
143
+ * @param {any} entry committed index entry.
144
+ * @returns {Promise<any>} the wire entry.
101
145
  */
102
146
  describe: describeEntry,
103
147
  /**
104
- * @returns {Promise<object>} the complete `/state` response body.
148
+ * @returns {Promise<any>} the complete `/state` response body.
105
149
  */
106
150
  async snapshot() {
107
- const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry))
151
+ const probeCache = new Map()
152
+ const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry, probeCache))
108
153
  let totalBytes = 0
109
154
  let failedEntries = 0
110
155
  for (const entry of entries) {
package/lib/store.js CHANGED
@@ -16,7 +16,7 @@ export const LOCK_REFRESH_MS = 60 * 1000
16
16
 
17
17
  /**
18
18
  * @param {number} [now] timestamp for the new index.
19
- * @returns {{ version: number, updatedAt: number, entries: never[] }} an empty index document.
19
+ * @returns {{ version: number, updatedAt: number, entries: any[] }} an empty index document.
20
20
  */
21
21
  export function emptyIndex(now = Date.now()) {
22
22
  return { version: INDEX_VERSION, updatedAt: now, entries: [] }
@@ -26,8 +26,8 @@ export function emptyIndex(now = Date.now()) {
26
26
  * Coerce one durable entry to the v2 shape. A v1 entry names a single
27
27
  * directory, which becomes the parent member of a one-member tree.
28
28
  *
29
- * @param {object} entry entry from either durable shape.
30
- * @returns {object} the v2 entry.
29
+ * @param {any} entry entry from either durable shape.
30
+ * @returns {any} the v2 entry.
31
31
  */
32
32
  function normalizeEntry(entry) {
33
33
  if (entry === null || typeof entry !== 'object') return entry
@@ -40,7 +40,7 @@ function normalizeEntry(entry) {
40
40
  * Validate the persisted index shape strictly enough that a truncated or
41
41
  * hand-edited file enters protection mode instead of silently vanishing.
42
42
  *
43
- * @param {unknown} value parsed JSON document.
43
+ * @param {any} value parsed JSON document.
44
44
  * @returns {boolean} whether the document is a supported index.
45
45
  */
46
46
  export function isValidIndex(value) {
@@ -141,7 +141,7 @@ export class TrashStore {
141
141
  }
142
142
 
143
143
  /**
144
- * @returns {readonly object[]} the committed entries.
144
+ * @returns {readonly any[]} the committed entries.
145
145
  */
146
146
  entries() {
147
147
  return this.index.entries
@@ -149,7 +149,7 @@ export class TrashStore {
149
149
 
150
150
  /**
151
151
  * @param {string} sessionId session to look up.
152
- * @returns {object | undefined} the index entry for this session.
152
+ * @returns {any | undefined} the index entry for this session.
153
153
  */
154
154
  findEntry(sessionId) {
155
155
  return this.index.entries.find((entry) => entry.sessionId === sessionId)
@@ -235,7 +235,7 @@ export class TrashStore {
235
235
 
236
236
  /**
237
237
  * @param {string} trashId object identity.
238
- * @returns {Promise<object | undefined>} the parsed sidecar metadata, when present.
238
+ * @returns {Promise<any | undefined>} the parsed sidecar metadata, when present.
239
239
  */
240
240
  async readMeta(trashId) {
241
241
  try {
@@ -279,7 +279,7 @@ export class TrashStore {
279
279
  * Atomically write one object sidecar. Metadata is written before the session
280
280
  * directory moves, so a damaged index can rebuild from it later.
281
281
  *
282
- * @param {object} entry index-shaped entry carrying `trashId`.
282
+ * @param {any} entry index-shaped entry carrying `trashId`.
283
283
  * @returns {Promise<void>} resolution once the sidecar is durable.
284
284
  */
285
285
  async writeMeta(entry) {
@@ -331,7 +331,7 @@ export class TrashStore {
331
331
  /**
332
332
  * Commit a complete entry list and refresh the in-memory projection.
333
333
  *
334
- * @param {readonly object[]} entries complete new entry list.
334
+ * @param {readonly any[]} entries complete new entry list.
335
335
  * @returns {Promise<void>} resolution once committed.
336
336
  */
337
337
  async commit(entries) {
@@ -346,7 +346,7 @@ export class TrashStore {
346
346
 
347
347
  /**
348
348
  * @param {string} file index file to read.
349
- * @returns {Promise<unknown>} parsed JSON.
349
+ * @returns {Promise<any>} parsed JSON.
350
350
  */
351
351
  async readJson(file) {
352
352
  return JSON.parse(await fs.readFile(file, 'utf8'))
@@ -392,8 +392,8 @@ export class TrashStore {
392
392
  }
393
393
 
394
394
  /**
395
- * @param {{ version: number, updatedAt: number, entries: readonly object[] }} data parsed index document.
396
- * @returns {{ version: number, updatedAt: number, entries: object[] }} the v2 document.
395
+ * @param {{ version: number, updatedAt: number, entries: readonly any[] }} data parsed index document.
396
+ * @returns {{ version: number, updatedAt: number, entries: any[] }} the v2 document.
397
397
  */
398
398
  normalizeIndex(data) {
399
399
  if (data.version !== INDEX_VERSION) this.needsUpgrade = true
@@ -408,8 +408,8 @@ export class TrashStore {
408
408
  * Atomically commit the index: write a temporary file, preserve the previous
409
409
  * commit as `.bak`, then rename over the live index.
410
410
  *
411
- * @param {readonly object[]} entries the complete entry list to persist.
412
- * @returns {Promise<{ version: number, updatedAt: number, entries: readonly object[] }>} the committed document.
411
+ * @param {readonly any[]} entries the complete entry list to persist.
412
+ * @returns {Promise<{ version: number, updatedAt: number, entries: readonly any[] }>} the committed document.
413
413
  */
414
414
  async writeIndex(entries) {
415
415
  const normalized = entries.map((entry) => normalizeEntry(entry))
@@ -582,7 +582,10 @@ export class TrashStore {
582
582
  await removePathWithRetry(this.lockPath, { recursive: false, force: true, attempts: 5, delayMs: 20 })
583
583
  } catch (removeError) {
584
584
  throw new TrashError('LOCKED', 'A stale trash lock could not be removed. Try again in a moment.', {
585
- code: removeError instanceof Error && typeof removeError.code === 'string' ? removeError.code : null,
585
+ code:
586
+ removeError instanceof Error && typeof /** @type {any} */ (removeError).code === 'string'
587
+ ? /** @type {any} */ (removeError).code
588
+ : null,
586
589
  })
587
590
  }
588
591
  }