@wangxiaocuo/dsh-session-trash 0.6.4 → 0.6.6

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/cleanup.js CHANGED
@@ -68,7 +68,7 @@ function resolveTimer(ctx) {
68
68
  * start: () => void,
69
69
  * stop: () => void,
70
70
  * runNow: (options?: { manual?: boolean, reason?: string }) => Promise<any>,
71
- * maybeSweepOnPageOpen: () => void,
71
+ * sweepOnPageOpen: () => Promise<{ ran: boolean, reason?: string, result?: object }>,
72
72
  * nextSweepAt: () => number | null,
73
73
  * lastSweepAt: () => number,
74
74
  * diagnostics: () => any,
@@ -294,17 +294,24 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
294
294
  runNow,
295
295
 
296
296
  /**
297
- * Trigger a throttled background sweep when the trash page opens. The
297
+ * Run the throttled sweep the trash page requests when it opens. The
298
298
  * timer-backed composition uses a one-hour throttle; without a timer the
299
- * configured sweep interval throttles instead.
299
+ * configured sweep interval throttles instead. This is an explicit POST so
300
+ * the read-only `/state` projection never deletes anything.
300
301
  *
301
- * @returns {void}
302
+ * @returns {Promise<{ ran: boolean, reason?: string, result?: object }>} the sweep outcome.
302
303
  */
303
- maybeSweepOnPageOpen() {
304
- if (stopped) return
304
+ async sweepOnPageOpen() {
305
+ if (stopped) return { ran: false, reason: 'stopped' }
305
306
  const throttle = timer === undefined || !config.enableAutoCleanup ? intervalMs : PAGE_OPEN_THROTTLE_MS
306
- if (lastAttemptAt !== 0 && Date.now() - lastAttemptAt <= throttle) return
307
- void runNow({ reason: 'page-open' }).catch(report)
307
+ if (lastAttemptAt !== 0 && Date.now() - lastAttemptAt <= throttle) return { ran: false, reason: 'throttled' }
308
+ try {
309
+ const result = await runNow({ reason: 'page-open' })
310
+ return { ran: true, result }
311
+ } catch (error) {
312
+ report(error)
313
+ return { ran: false, reason: 'failed' }
314
+ }
308
315
  },
309
316
 
310
317
  /**
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 {any} [details] optional structured context.
46
+ * @param {Record<string, unknown>} [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 {any} error arbitrary thrown value.
57
+ * @param {unknown} error arbitrary thrown value.
58
58
  * @returns {number} HTTP status for this error.
59
59
  */
60
60
  export function statusOf(error) {
@@ -86,26 +86,29 @@ const SAFE_DETAIL_KEYS = new Set([
86
86
  * Metrics snapshots are the only nested detail value the Host returns; keep
87
87
  * only their numeric counters so no path can ride along.
88
88
  *
89
- * @param {any} value candidate metrics object.
90
- * @returns {any | undefined} the numeric metrics.
89
+ * @param {unknown} value candidate metrics object.
90
+ * @returns {{ sizeBytes?: number, fileCount?: number, symlinkCount?: number } | undefined} the numeric metrics.
91
91
  */
92
92
  function sanitizeMetrics(value) {
93
93
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
94
+ const record = /** @type {Record<string, unknown>} */ (value)
95
+ /** @type {Record<string, unknown>} */
94
96
  const safe = {}
95
97
  for (const key of ['sizeBytes', 'fileCount', 'symlinkCount']) {
96
- if (typeof value[key] === 'number') safe[key] = value[key]
98
+ if (typeof record[key] === 'number') safe[key] = record[key]
97
99
  }
98
100
  return Object.keys(safe).length === 0 ? undefined : safe
99
101
  }
100
102
 
101
103
  /**
102
- * @param {any} details arbitrary structured context.
103
- * @returns {any | undefined} the same context restricted to whitelisted keys.
104
+ * @param {unknown} details arbitrary structured context.
105
+ * @returns {Record<string, unknown> | undefined} the same context restricted to whitelisted keys.
104
106
  */
105
107
  export function sanitizeDetails(details) {
106
108
  if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
109
+ /** @type {Record<string, unknown>} */
107
110
  const safe = {}
108
- for (const [key, value] of Object.entries(details)) {
111
+ for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (details))) {
109
112
  if (!SAFE_DETAIL_KEYS.has(key)) continue
110
113
  if (key === 'expected' || key === 'measured') {
111
114
  const metrics = sanitizeMetrics(value)
@@ -122,8 +125,8 @@ export function sanitizeDetails(details) {
122
125
  }
123
126
 
124
127
  /**
125
- * @param {any} error arbitrary thrown value.
126
- * @returns {{ code: string, message: string, details?: object }} the wire error shape.
128
+ * @param {unknown} error arbitrary thrown value.
129
+ * @returns {{ code: string, message: string, details?: Record<string, unknown> }} the wire error shape.
127
130
  */
128
131
  export function toWireError(error) {
129
132
  if (error instanceof TrashError) {
package/lib/fsops.js CHANGED
@@ -36,11 +36,13 @@ const COPY_OPTIONS = {
36
36
  }
37
37
 
38
38
  /**
39
- * @param {any} error thrown filesystem failure.
39
+ * @param {unknown} error thrown filesystem failure.
40
40
  * @returns {string | undefined} the Node error code.
41
41
  */
42
42
  export function errorCode(error) {
43
- return error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : undefined
43
+ if (error === null || typeof error !== 'object') return undefined
44
+ const code = /** @type {Record<string, unknown>} */ (error).code
45
+ return typeof code === 'string' ? code : undefined
44
46
  }
45
47
 
46
48
  /**
@@ -194,7 +196,7 @@ export function isInside(root, target) {
194
196
  }
195
197
 
196
198
  /**
197
- * @param {any} trashId candidate object identity.
199
+ * @param {unknown} trashId candidate object identity.
198
200
  * @returns {boolean} whether the identity is one safe path segment.
199
201
  */
200
202
  export function isSafeTrashId(trashId) {
@@ -214,7 +216,7 @@ export function isSafeTrashId(trashId) {
214
216
  * object. Session ids are `session-<uuid>` or a bare uuid today, so the
215
217
  * accepted alphabet is deliberately conservative.
216
218
  *
217
- * @param {any} sessionId candidate member identity.
219
+ * @param {unknown} sessionId candidate member identity.
218
220
  * @returns {boolean} whether the identity is one safe path segment.
219
221
  */
220
222
  export function isSafeMemberId(sessionId) {
@@ -339,7 +341,7 @@ export function createTrashId() {
339
341
  * @param {string} destination final directory path.
340
342
  * @param {{ sizeBytes: number, fileCount: number }} expected recorded metrics.
341
343
  * @param {(from: string, to: string) => Promise<void>} rename rename implementation for tests.
342
- * @param {(source: string, destination: string, options: any) => Promise<void>} copy copy implementation for tests.
344
+ * @param {(source: string, destination: string, options: import('node:fs').CopyOptions) => Promise<void>} copy copy implementation for tests.
343
345
  * @returns {Promise<void>} resolution after the destination is published.
344
346
  */
345
347
  async function copyAcrossDevices(source, destination, expected, rename, copy) {
@@ -382,7 +384,7 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
382
384
  * @param {{
383
385
  * logger?: { warn: Function },
384
386
  * rename?: (from: string, to: string) => Promise<void>,
385
- * copy?: (source: string, destination: string, options: any) => Promise<void>,
387
+ * copy?: (source: string, destination: string, options: import('node:fs').CopyOptions) => Promise<void>,
386
388
  * }} [options] diagnostics sink and test seams.
387
389
  * @returns {Promise<void>} resolution after the directory lives at `destination`.
388
390
  */
package/lib/http.js CHANGED
@@ -160,12 +160,13 @@ function requireSessionId(body) {
160
160
  * forget: (sessionId: string) => Promise<any>,
161
161
  * reveal: (sessionId: string, signal?: AbortSignal) => Promise<any>,
162
162
  * rebuild: () => Promise<any>,
163
+ * purgeEmptyOrphans: () => Promise<{ removed: number }>,
163
164
  * },
164
165
  * cleanupService?: {
165
- runNow: (options?: any) => Promise<any>,
166
- maybeSweepOnPageOpen: () => void,
167
- diagnostics?: () => any,
168
- },
166
+ * runNow: (options?: any) => Promise<any>,
167
+ * sweepOnPageOpen: () => Promise<{ ran: boolean, reason?: string, result?: any }>,
168
+ * diagnostics?: () => any,
169
+ * },
169
170
  * ready: Promise<unknown>,
170
171
  * config: any,
171
172
  * logger: any,
@@ -264,11 +265,28 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
264
265
 
265
266
  route('state', 'GET', async (_req, res) => {
266
267
  sendJson(res, 200, await stateService.snapshot())
267
- try {
268
- cleanupService?.maybeSweepOnPageOpen()
269
- } catch (error) {
270
- logger.warn(`cannot schedule the page-open trash sweep: ${error instanceof Error ? error.message : String(error)}`)
271
- }
268
+ })
269
+
270
+ route('sweep', 'POST', async (_req, res) => {
271
+ const outcome = await cleanupService.sweepOnPageOpen()
272
+ const result = outcome.result !== null && outcome.result !== undefined ? outcome.result : null
273
+ sendJson(res, 200, {
274
+ ok: true,
275
+ ran: outcome.ran === true,
276
+ ...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
277
+ ...(result === null
278
+ ? {}
279
+ : {
280
+ purged: result.purged ?? 0,
281
+ failed: result.failed ?? 0,
282
+ settled: result.changed ?? 0,
283
+ }),
284
+ })
285
+ })
286
+
287
+ route('orphans/forget', 'POST', async (_req, res) => {
288
+ const result = await trashService.purgeEmptyOrphans()
289
+ sendJson(res, 200, { ok: true, removed: result.removed ?? 0 })
272
290
  })
273
291
 
274
292
  route('preflight', 'POST', async (req, res) => {
package/lib/inventory.js CHANGED
@@ -336,6 +336,30 @@ export async function scanObjects(store, logger) {
336
336
  return { objects, unindexed }
337
337
  }
338
338
 
339
+ /**
340
+ * Plugin-owned sidecar names that may remain inside an otherwise empty object
341
+ * directory. Everything else (session/, children/, stray files) is payload.
342
+ */
343
+ const PLUGIN_OWNED_OBJECT_NAMES = new Set(['meta.json', 'meta.json.tmp'])
344
+
345
+ /**
346
+ * Whether an object directory holds no session payload, only plugin metadata
347
+ * that can be recreated. Used to offer a safe cleanup for unindexed leftovers.
348
+ *
349
+ * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
350
+ * @param {string} trashId object identity.
351
+ * @returns {Promise<boolean>} whether the directory is safe to remove.
352
+ */
353
+ export async function isEmptyObjectDir(store, trashId) {
354
+ if (!isSafeTrashId(trashId)) return false
355
+ try {
356
+ const names = await fs.readdir(store.objectDir(trashId))
357
+ return names.every((name) => PLUGIN_OWNED_OBJECT_NAMES.has(name))
358
+ } catch {
359
+ return false
360
+ }
361
+ }
362
+
339
363
  /**
340
364
  * Import object directories the index does not know about and mark entries
341
365
  * whose object directory vanished. Never deletes a session file or an object
package/lib/members.js CHANGED
@@ -8,39 +8,74 @@
8
8
  */
9
9
 
10
10
  /**
11
- * @param {any} entry index entry (v1 or v2 shape).
12
- * @returns {object|null} the parent member, or null when the entry has no directory data.
11
+ * @typedef {object} Member
12
+ * @property {string} sessionId
13
+ * @property {'subagent' | null} [origin]
14
+ * @property {string} [title]
15
+ * @property {string} [originalSessionDir]
16
+ * @property {string} [originalLogPath]
17
+ * @property {string} [persistenceProjectDirHint]
18
+ * @property {string} [formatStatus]
19
+ * @property {number | null} [sizeBytes]
20
+ * @property {number | null} [fileCount]
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} TrashEntry
25
+ * @property {string} sessionId
26
+ * @property {string} [trashId]
27
+ * @property {string} [state]
28
+ * @property {string} [title]
29
+ * @property {number} [trashedAt]
30
+ * @property {number} [startedAt]
31
+ * @property {string | null} [originalCwd]
32
+ * @property {boolean} [workspaceExistsAtDeletion]
33
+ * @property {string} [originalSessionDir]
34
+ * @property {string} [originalLogPath]
35
+ * @property {string} [persistenceProjectDirHint]
36
+ * @property {string} [formatStatus]
37
+ * @property {number | null} [sizeBytes]
38
+ * @property {number | null} [fileCount]
39
+ * @property {string | null} [lastError]
40
+ * @property {Member[]} [members]
41
+ */
42
+
43
+ /**
44
+ * @param {unknown} entry index entry (v1 or v2 shape).
45
+ * @returns {Member | null} the parent member, or null when the entry has no directory data.
13
46
  */
14
47
  export function parentMember(entry) {
15
48
  if (entry === null || typeof entry !== 'object') return null
16
- if (typeof entry.sessionId !== 'string' || entry.sessionId === '') return null
49
+ const record = /** @type {Record<string, any>} */ (entry)
50
+ if (typeof record.sessionId !== 'string' || record.sessionId === '') return null
17
51
  return {
18
- sessionId: entry.sessionId,
52
+ sessionId: record.sessionId,
19
53
  origin: null,
20
- title: typeof entry.title === 'string' ? entry.title : entry.sessionId,
21
- originalSessionDir: entry.originalSessionDir,
22
- originalLogPath: entry.originalLogPath,
23
- persistenceProjectDirHint: entry.persistenceProjectDirHint,
24
- formatStatus: entry.formatStatus,
25
- sizeBytes: entry.sizeBytes,
26
- fileCount: entry.fileCount,
54
+ title: typeof record.title === 'string' ? record.title : record.sessionId,
55
+ originalSessionDir: record.originalSessionDir,
56
+ originalLogPath: record.originalLogPath,
57
+ persistenceProjectDirHint: record.persistenceProjectDirHint,
58
+ formatStatus: record.formatStatus,
59
+ sizeBytes: record.sizeBytes,
60
+ fileCount: record.fileCount,
27
61
  }
28
62
  }
29
63
 
30
64
  /**
31
- * @param {any} entry index entry (v1 or v2 shape).
32
- * @returns {any[]} every owned directory, parent first.
65
+ * @param {unknown} entry index entry (v1 or v2 shape).
66
+ * @returns {Member[]} every owned directory, parent first.
33
67
  */
34
68
  export function memberList(entry) {
35
- if (entry !== null && typeof entry === 'object' && Array.isArray(entry.members) && entry.members.length > 0) {
36
- return entry.members
69
+ if (entry !== null && typeof entry === 'object') {
70
+ const record = /** @type {Record<string, any>} */ (entry)
71
+ if (Array.isArray(record.members) && record.members.length > 0) return record.members
37
72
  }
38
73
  const parent = parentMember(entry)
39
74
  return parent === null ? [] : [parent]
40
75
  }
41
76
 
42
77
  /**
43
- * @param {readonly any[]} members member list.
78
+ * @param {ReadonlyArray<Partial<Member> | null | undefined>} members member list.
44
79
  * @returns {{ sizeBytes: number, fileCount: number }} summed measurable totals.
45
80
  */
46
81
  export function aggregateMetrics(members) {
@@ -57,21 +92,26 @@ export function aggregateMetrics(members) {
57
92
  * Shape-level validation shared by the durable index and object sidecars.
58
93
  * Path strictness belongs to the sidecar validator.
59
94
  *
60
- * @param {any} member candidate member.
95
+ * @param {unknown} member candidate member.
61
96
  * @returns {boolean} whether the member carries the required v2 identity.
62
97
  */
63
98
  export function isValidMemberShape(member) {
64
99
  if (member === null || typeof member !== 'object' || Array.isArray(member)) return false
65
- if (typeof member.sessionId !== 'string' || member.sessionId === '' || member.sessionId.length > 256) return false
66
- if (member.origin !== undefined && member.origin !== null && member.origin !== 'subagent') return false
67
- if (member.title !== undefined && typeof member.title !== 'string') return false
68
- if (member.formatStatus !== undefined && member.formatStatus !== 'current' && member.formatStatus !== 'migration-required') {
100
+ const record = /** @type {Record<string, any>} */ (member)
101
+ if (typeof record.sessionId !== 'string' || record.sessionId === '' || record.sessionId.length > 256) return false
102
+ if (record.origin !== undefined && record.origin !== null && record.origin !== 'subagent') return false
103
+ if (record.title !== undefined && typeof record.title !== 'string') return false
104
+ if (
105
+ record.formatStatus !== undefined &&
106
+ record.formatStatus !== 'current' &&
107
+ record.formatStatus !== 'migration-required'
108
+ ) {
69
109
  return false
70
110
  }
71
- if (member.sizeBytes !== undefined && member.sizeBytes !== null && typeof member.sizeBytes !== 'number') return false
72
- if (member.fileCount !== undefined && member.fileCount !== null && typeof member.fileCount !== 'number') return false
111
+ if (record.sizeBytes !== undefined && record.sizeBytes !== null && typeof record.sizeBytes !== 'number') return false
112
+ if (record.fileCount !== undefined && record.fileCount !== null && typeof record.fileCount !== 'number') return false
73
113
  for (const key of ['originalSessionDir', 'originalLogPath', 'persistenceProjectDirHint']) {
74
- if (member[key] !== undefined && member[key] !== null && typeof member[key] !== 'string') return false
114
+ if (record[key] !== undefined && record[key] !== null && typeof record[key] !== 'string') return false
75
115
  }
76
116
  return true
77
117
  }
@@ -79,17 +119,19 @@ export function isValidMemberShape(member) {
79
119
  /**
80
120
  * Whether the v2 member list is internally consistent with its parent entry.
81
121
  *
82
- * @param {any} entry index entry carrying `members`.
122
+ * @param {unknown} entry index entry carrying `members`.
83
123
  * @returns {boolean} whether the list is acceptable.
84
124
  */
85
125
  export function isValidMemberList(entry) {
86
126
  if (entry === null || typeof entry !== 'object') return false
87
- if (!Array.isArray(entry.members) || entry.members.length === 0 || entry.members.length > 64) return false
127
+ const record = /** @type {Record<string, any>} */ (entry)
128
+ const members = record.members
129
+ if (!Array.isArray(members) || members.length === 0 || members.length > 64) return false
88
130
  const seen = new Set()
89
- for (const member of entry.members) {
131
+ for (const member of members) {
90
132
  if (!isValidMemberShape(member)) return false
91
133
  if (seen.has(member.sessionId)) return false
92
134
  seen.add(member.sessionId)
93
135
  }
94
- return entry.members[0].sessionId === entry.sessionId
136
+ return members[0].sessionId === record.sessionId
95
137
  }
package/lib/state.js CHANGED
@@ -48,18 +48,60 @@ async function directoryExists(directory) {
48
48
  }
49
49
  }
50
50
 
51
+ /**
52
+ * @typedef {object} TrashConfig
53
+ * @property {number} retentionDays
54
+ * @property {number} sweepIntervalHours
55
+ * @property {boolean} enableAutoCleanup
56
+ */
57
+
58
+ /**
59
+ * @typedef {object} CleanupDiagnostics
60
+ * @property {boolean} enabled
61
+ * @property {boolean} timerBacked
62
+ * @property {number | null} lastAttemptAt
63
+ * @property {number | null} lastSuccessAt
64
+ * @property {number | null} nextSweepAt
65
+ * @property {number | null} retryAt
66
+ * @property {number} consecutiveFailures
67
+ * @property {object | null} lastRun
68
+ */
69
+
70
+ /**
71
+ * @typedef {object} StateEntry
72
+ * @property {string} sessionId
73
+ * @property {string | undefined} title
74
+ * @property {number | undefined} trashedAt
75
+ * @property {number | null} expiresAt
76
+ * @property {string | null} originalCwd
77
+ * @property {boolean | null} workspaceExistsAtDeletion
78
+ * @property {boolean | null} workspaceNowExists
79
+ * @property {number | null} sizeBytes
80
+ * @property {number | null} fileCount
81
+ * @property {string | null} formatStatus
82
+ * @property {string | undefined} state
83
+ * @property {boolean} canRestore
84
+ * @property {boolean} canPurge
85
+ * @property {boolean} canForget
86
+ * @property {boolean} requiresOrphanRestore
87
+ * @property {string | null} lastError
88
+ * @property {string | null} lastErrorCode
89
+ * @property {number} subagentCount
90
+ * @property {{ sessionId: string, origin: 'subagent' | null, sizeBytes: number | null }[]} members
91
+ */
92
+
51
93
  /**
52
94
  * Build the read-only `/state` projection from the committed index.
53
95
  *
54
96
  * @param {{
55
97
  * store: import('./store.js').TrashStore,
56
- * config: { retentionDays: number, sweepIntervalHours: number, enableAutoCleanup: boolean },
98
+ * config: TrashConfig,
57
99
  * trashRootDisplay: string,
58
100
  * nextSweepAt?: () => number | null,
59
- * cleanup?: () => any,
60
- * directoryExists?: (directory?: any) => Promise<boolean>,
101
+ * cleanup?: () => CleanupDiagnostics | null,
102
+ * directoryExists?: (directory?: string | null) => Promise<boolean>,
61
103
  * }} deps state inputs.
62
- * @returns {{ snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> }} the state service.
104
+ * @returns {{ snapshot: () => Promise<object>, describe: (entry: import('./members.js').TrashEntry) => Promise<StateEntry> }} the state service.
63
105
  */
64
106
  export function createStateService({
65
107
  store,
@@ -70,13 +112,13 @@ export function createStateService({
70
112
  directoryExists: probeDirectory = directoryExists,
71
113
  }) {
72
114
  /**
73
- * @returns {any} cleanup diagnostics, or a neutral fallback.
115
+ * @returns {CleanupDiagnostics} cleanup diagnostics, or a neutral fallback.
74
116
  */
75
117
  function cleanupDiagnostics() {
76
118
  if (typeof cleanup === 'function') {
77
119
  try {
78
120
  const value = cleanup()
79
- if (value !== null && typeof value === 'object') return value
121
+ if (value !== null && typeof value === 'object') return /** @type {CleanupDiagnostics} */ (value)
80
122
  } catch {
81
123
  // Diagnostics must never break the read-only state projection.
82
124
  }
@@ -94,10 +136,10 @@ export function createStateService({
94
136
  }
95
137
 
96
138
  /**
97
- * @param {any} entry committed index entry.
139
+ * @param {import('./members.js').TrashEntry} entry committed index entry.
98
140
  * @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
99
141
  * started for this snapshot, so entries sharing a cwd probe it once.
100
- * @returns {Promise<any>} the wire entry.
142
+ * @returns {Promise<StateEntry>} the wire entry.
101
143
  */
102
144
  async function describeEntry(entry, probeCache = new Map()) {
103
145
  const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
@@ -140,12 +182,12 @@ export function createStateService({
140
182
 
141
183
  return {
142
184
  /**
143
- * @param {any} entry committed index entry.
144
- * @returns {Promise<any>} the wire entry.
185
+ * @param {import('./members.js').TrashEntry} entry committed index entry.
186
+ * @returns {Promise<StateEntry>} the wire entry.
145
187
  */
146
188
  describe: describeEntry,
147
189
  /**
148
- * @returns {Promise<any>} the complete `/state` response body.
190
+ * @returns {Promise<object>} the complete `/state` response body.
149
191
  */
150
192
  async snapshot() {
151
193
  const probeCache = new Map()
package/lib/store.js CHANGED
@@ -14,6 +14,22 @@ export const LOCK_STALE_MS = 10 * 60 * 1000
14
14
  /** How often a long-running operation refreshes its lock heartbeat. */
15
15
  export const LOCK_REFRESH_MS = 60 * 1000
16
16
 
17
+ /** How many times a caller retries a lock held by another live process. */
18
+ export const LOCK_WAIT_ATTEMPTS = 3
19
+
20
+ /** Base delay between live-lock retries. */
21
+ export const LOCK_WAIT_DELAY_MS = 200
22
+
23
+ /**
24
+ * @param {number} milliseconds delay length.
25
+ * @returns {Promise<void>} resolution after the delay.
26
+ */
27
+ function sleep(milliseconds) {
28
+ return new Promise((resolve) => {
29
+ setTimeout(resolve, milliseconds)
30
+ })
31
+ }
32
+
17
33
  /**
18
34
  * @param {number} [now] timestamp for the new index.
19
35
  * @returns {{ version: number, updatedAt: number, entries: any[] }} an empty index document.
@@ -558,10 +574,15 @@ export class TrashStore {
558
574
  }
559
575
 
560
576
  /**
577
+ * Acquire the cross-process lock. A lock held by another live process is
578
+ * retried for a short window first, so two windows clicking at the same time
579
+ * do not surface a spurious LOCKED error.
580
+ *
561
581
  * @returns {Promise<void>} resolution once this process owns the lock.
562
582
  */
563
583
  async acquireLock() {
564
- for (let attempt = 0; attempt < 3; attempt += 1) {
584
+ let waits = 0
585
+ for (let attempt = 0; attempt < 6; attempt += 1) {
565
586
  try {
566
587
  const handle = await fs.open(this.lockPath, 'wx', 0o600)
567
588
  try {
@@ -576,6 +597,11 @@ export class TrashStore {
576
597
  throw new TrashError('IO_ERROR', `Cannot create the trash lock: ${error instanceof Error ? error.message : String(error)}`)
577
598
  }
578
599
  if (!(await this.lockIsStale())) {
600
+ if (waits < LOCK_WAIT_ATTEMPTS - 1) {
601
+ waits += 1
602
+ await sleep(LOCK_WAIT_DELAY_MS * waits)
603
+ continue
604
+ }
579
605
  throw new TrashError('LOCKED', 'Another trash operation is in progress. Try again in a moment.')
580
606
  }
581
607
  try {
@@ -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. */
@@ -1011,15 +1011,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1011
1011
  */
1012
1012
  async function trash(sessionId) {
1013
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
+ }
1014
1021
  return store.withLock(async () => {
1015
1022
  if (store.isProtected()) {
1016
1023
  throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
1017
1024
  }
1018
- eligibility.invalidate(sessionId)
1019
- const verdict = await eligibility.evaluate(sessionId)
1020
- if (verdict.eligible !== true) {
1021
- throw new TrashError(verdict.code, verdict.message, verdict.details)
1022
- }
1023
1025
  const existing = store.findEntry(sessionId)
1024
1026
  if (existing !== undefined) {
1025
1027
  if (existing.state === 'missing') {
@@ -1034,6 +1036,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1034
1036
 
1035
1037
  const plan = Array.isArray(verdict.members) ? verdict.members : []
1036
1038
  if (plan.length === 0) throw new TrashError('SESSION_NOT_FOUND', 'This session is not in session storage.')
1039
+ /** @type {import('./members.js').Member[]} */
1037
1040
  const members = []
1038
1041
  let parentSnapshot
1039
1042
  for (let index = 0; index < plan.length; index += 1) {
@@ -1520,6 +1523,40 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1520
1523
  })
1521
1524
  }
1522
1525
 
1526
+ /**
1527
+ * Remove unindexed object directories that contain no session payload, only
1528
+ * plugin metadata. Directories that may still hold session logs are kept for
1529
+ * manual inspection.
1530
+ *
1531
+ * @returns {Promise<{ removed: number }>} how many empty object directories were removed.
1532
+ */
1533
+ async function purgeEmptyOrphans() {
1534
+ return store.withLock(async () => {
1535
+ if (store.isProtected()) {
1536
+ throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses changes.')
1537
+ }
1538
+ const { objects } = await scanObjects(store, logger)
1539
+ const indexed = new Set(store.entries().map((entry) => entry.trashId))
1540
+ let removed = 0
1541
+ for (const object of objects) {
1542
+ if (indexed.has(object.trashId)) continue
1543
+ if (!(await isEmptyObjectDir(store, object.trashId))) continue
1544
+ try {
1545
+ await store.removeObjectDir(object.trashId)
1546
+ removed += 1
1547
+ logger.info(`removed an empty unindexed trash object (${object.trashId})`)
1548
+ } catch (error) {
1549
+ logger.warn(`cannot remove empty unindexed trash object ${object.trashId}: ${messageOf(error)}`)
1550
+ }
1551
+ }
1552
+ if (removed > 0) {
1553
+ const { unindexed } = await scanObjects(store, logger)
1554
+ store.unindexed = unindexed
1555
+ }
1556
+ return { removed }
1557
+ })
1558
+ }
1559
+
1523
1560
  /**
1524
1561
  * Restore the index from object sidecars after the primary index and its
1525
1562
  * backup were both rejected.
@@ -1741,5 +1778,5 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1741
1778
  return summary
1742
1779
  }
1743
1780
 
1744
- return { trash, restore, recover, purge, purgeAll, forget, sweep, rebuild, reveal }
1781
+ return { trash, restore, recover, purge, purgeAll, forget, sweep, rebuild, reveal, purgeEmptyOrphans }
1745
1782
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangxiaocuo/dsh-session-trash",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
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",