@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/CHANGELOG.md +102 -0
- package/README.en.md +10 -8
- package/README.md +10 -8
- package/client.js +164 -46
- package/index.js +2 -2
- package/lib/cleanup.js +8 -8
- package/lib/eligibility.js +5 -5
- package/lib/errors.js +64 -4
- package/lib/fsops.js +16 -9
- package/lib/http.js +22 -14
- package/lib/inventory.js +16 -16
- package/lib/members.js +6 -6
- package/lib/state.js +57 -12
- package/lib/store.js +18 -15
- package/lib/trash-service.js +60 -59
- package/package.json +12 -4
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 {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 {
|
|
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
|
-
*
|
|
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
|
-
...(
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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:
|
|
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
|
-
{
|
|
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:
|
|
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 {
|
|
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 {
|
|
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<
|
|
154
|
+
* stateService: { snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> },
|
|
155
155
|
* trashService: {
|
|
156
|
-
* trash: (sessionId: string) => Promise<
|
|
157
|
-
* restore: (sessionId: string, mode?: string) => Promise<
|
|
158
|
-
* purge: (sessionId: string) => Promise<
|
|
159
|
-
* purgeAll: () => Promise<
|
|
160
|
-
* forget: (sessionId: string) => Promise<
|
|
161
|
-
* reveal: (sessionId: string, signal?: AbortSignal) => Promise<
|
|
162
|
-
* rebuild: () => Promise<
|
|
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?: {
|
|
164
|
+
* cleanupService?: {
|
|
165
|
+
runNow: (options?: any) => Promise<any>,
|
|
166
|
+
maybeSweepOnPageOpen: () => void,
|
|
167
|
+
diagnostics?: () => any,
|
|
168
|
+
},
|
|
165
169
|
* ready: Promise<unknown>,
|
|
166
|
-
* config:
|
|
167
|
-
* logger:
|
|
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
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
135
|
+
* @param {any} member raw member fields.
|
|
136
136
|
* @param {string} fallbackTitle title used when the member carries none.
|
|
137
|
-
* @returns {
|
|
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 {
|
|
155
|
-
* @returns {
|
|
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 {
|
|
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
|
|
233
|
-
* @param {readonly
|
|
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 {
|
|
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 {
|
|
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:
|
|
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,
|
|
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 {
|
|
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 {
|
|
32
|
-
* @returns {
|
|
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
|
|
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 {
|
|
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 {
|
|
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?: () =>
|
|
59
|
+
* cleanup?: () => any,
|
|
60
|
+
* directoryExists?: (directory?: any) => Promise<boolean>,
|
|
31
61
|
* }} deps state inputs.
|
|
32
|
-
* @returns {{ snapshot: () => Promise<
|
|
62
|
+
* @returns {{ snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> }} the state service.
|
|
33
63
|
*/
|
|
34
|
-
export function createStateService({
|
|
64
|
+
export function createStateService({
|
|
65
|
+
store,
|
|
66
|
+
config,
|
|
67
|
+
trashRootDisplay,
|
|
68
|
+
nextSweepAt = () => null,
|
|
69
|
+
cleanup,
|
|
70
|
+
directoryExists: probeDirectory = directoryExists,
|
|
71
|
+
}) {
|
|
35
72
|
/**
|
|
36
|
-
* @returns {
|
|
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 {
|
|
61
|
-
* @
|
|
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
|
-
|
|
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 {
|
|
100
|
-
* @returns {Promise<
|
|
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<
|
|
148
|
+
* @returns {Promise<any>} the complete `/state` response body.
|
|
105
149
|
*/
|
|
106
150
|
async snapshot() {
|
|
107
|
-
const
|
|
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:
|
|
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 {
|
|
30
|
-
* @returns {
|
|
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 {
|
|
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
|
|
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 {
|
|
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<
|
|
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 {
|
|
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
|
|
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<
|
|
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
|
|
396
|
-
* @returns {{ version: number, updatedAt: number, entries:
|
|
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
|
|
412
|
-
* @returns {Promise<{ version: number, updatedAt: number, entries: readonly
|
|
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:
|
|
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
|
}
|