@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/CHANGELOG.md +32 -0
- package/README.en.md +22 -4
- package/README.md +22 -5
- package/client.js +465 -13
- package/lib/cleanup.js +15 -8
- package/lib/errors.js +13 -10
- package/lib/fsops.js +8 -6
- package/lib/http.js +27 -9
- package/lib/inventory.js +24 -0
- package/lib/members.js +70 -28
- package/lib/state.js +53 -11
- package/lib/store.js +27 -1
- package/lib/trash-service.js +44 -7
- package/package.json +1 -1
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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
302
|
+
* @returns {Promise<{ ran: boolean, reason?: string, result?: object }>} the sweep outcome.
|
|
302
303
|
*/
|
|
303
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
90
|
-
* @returns {
|
|
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
|
|
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 {
|
|
103
|
-
* @returns {
|
|
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 {
|
|
126
|
-
* @returns {{ code: string, message: string, details?:
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
* @
|
|
12
|
-
* @
|
|
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
|
-
|
|
49
|
+
const record = /** @type {Record<string, any>} */ (entry)
|
|
50
|
+
if (typeof record.sessionId !== 'string' || record.sessionId === '') return null
|
|
17
51
|
return {
|
|
18
|
-
sessionId:
|
|
52
|
+
sessionId: record.sessionId,
|
|
19
53
|
origin: null,
|
|
20
|
-
title: typeof
|
|
21
|
-
originalSessionDir:
|
|
22
|
-
originalLogPath:
|
|
23
|
-
persistenceProjectDirHint:
|
|
24
|
-
formatStatus:
|
|
25
|
-
sizeBytes:
|
|
26
|
-
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 {
|
|
32
|
-
* @returns {
|
|
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'
|
|
36
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
66
|
-
if (
|
|
67
|
-
if (
|
|
68
|
-
if (
|
|
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 (
|
|
72
|
-
if (
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
98
|
+
* config: TrashConfig,
|
|
57
99
|
* trashRootDisplay: string,
|
|
58
100
|
* nextSweepAt?: () => number | null,
|
|
59
|
-
* cleanup?: () =>
|
|
60
|
-
* directoryExists?: (directory?:
|
|
101
|
+
* cleanup?: () => CleanupDiagnostics | null,
|
|
102
|
+
* directoryExists?: (directory?: string | null) => Promise<boolean>,
|
|
61
103
|
* }} deps state inputs.
|
|
62
|
-
* @returns {{ snapshot: () => Promise<
|
|
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 {
|
|
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 {
|
|
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<
|
|
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 {
|
|
144
|
-
* @returns {Promise<
|
|
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<
|
|
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
|
-
|
|
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 {
|
package/lib/trash-service.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
samePath,
|
|
15
15
|
} from './fsops.js'
|
|
16
16
|
import { mapLimit } from './concurrency.js'
|
|
17
|
-
import {
|
|
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.
|
|
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",
|