@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.5.1
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/LICENSE +21 -0
- package/README.en.md +194 -0
- package/README.md +193 -2
- package/client.js +2173 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +10 -0
- package/index.js +129 -0
- package/lib/cleanup.js +342 -0
- package/lib/eligibility.js +212 -0
- package/lib/errors.js +91 -0
- package/lib/fsops.js +414 -0
- package/lib/http.js +346 -0
- package/lib/inventory.js +271 -0
- package/lib/paths.js +39 -0
- package/lib/state.js +147 -0
- package/lib/store.js +545 -0
- package/lib/trash-service.js +1294 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
package/lib/http.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { TrashError, statusOf, toWireError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
/** Prefix shared by every route this plugin owns. */
|
|
4
|
+
export const API_PREFIX = '/plugins/dsh-session-trash/api'
|
|
5
|
+
|
|
6
|
+
/** Request header that proves a same-origin scripted call. */
|
|
7
|
+
export const CSRF_HEADER = 'x-dsh-session-trash'
|
|
8
|
+
|
|
9
|
+
/** Largest accepted JSON request body. */
|
|
10
|
+
export const MAX_BODY_BYTES = 64 * 1024
|
|
11
|
+
|
|
12
|
+
/** Budget for one native reveal request. */
|
|
13
|
+
const REVEAL_TIMEOUT_MS = 10_000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @returns {AbortSignal} a signal that aborts the reveal after its budget.
|
|
17
|
+
*/
|
|
18
|
+
function revealSignal() {
|
|
19
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
20
|
+
return AbortSignal.timeout(REVEAL_TIMEOUT_MS)
|
|
21
|
+
}
|
|
22
|
+
const controller = new AbortController()
|
|
23
|
+
const handle = setTimeout(() => controller.abort(), REVEAL_TIMEOUT_MS)
|
|
24
|
+
handle.unref?.()
|
|
25
|
+
return controller.signal
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('node:http').IncomingMessage} req incoming request.
|
|
30
|
+
* @returns {Headers} a Fetch-shaped header bag for the connection service.
|
|
31
|
+
*/
|
|
32
|
+
function headerBag(req) {
|
|
33
|
+
const headers = new Headers()
|
|
34
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
35
|
+
if (value === undefined) continue
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
for (const item of value) headers.append(name, item)
|
|
38
|
+
} else {
|
|
39
|
+
headers.set(name, value)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return headers
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {import('node:http').ServerResponse} res response to complete.
|
|
47
|
+
* @param {number} status HTTP status.
|
|
48
|
+
* @param {unknown} body JSON-serializable body.
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
51
|
+
function sendJson(res, status, body) {
|
|
52
|
+
if (res.headersSent || res.writableEnded) return
|
|
53
|
+
const payload = JSON.stringify(body)
|
|
54
|
+
res.writeHead(status, {
|
|
55
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
56
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
57
|
+
'Cache-Control': 'no-store',
|
|
58
|
+
'X-Content-Type-Options': 'nosniff',
|
|
59
|
+
})
|
|
60
|
+
res.end(payload)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {import('node:http').ServerResponse} res response to complete.
|
|
65
|
+
* @param {unknown} error thrown failure.
|
|
66
|
+
* @returns {void}
|
|
67
|
+
*/
|
|
68
|
+
function sendError(res, error) {
|
|
69
|
+
sendJson(res, statusOf(error), { ok: false, error: toWireError(error) })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {import('node:http').IncomingMessage} req request whose body to read.
|
|
74
|
+
* @param {number} limit maximum accepted bytes.
|
|
75
|
+
* @returns {Promise<Buffer>} the complete body.
|
|
76
|
+
*/
|
|
77
|
+
function readBody(req, limit) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const chunks = []
|
|
80
|
+
let size = 0
|
|
81
|
+
let failed = false
|
|
82
|
+
req.on('data', (chunk) => {
|
|
83
|
+
if (failed) return
|
|
84
|
+
size += chunk.length
|
|
85
|
+
if (size > limit) {
|
|
86
|
+
failed = true
|
|
87
|
+
chunks.length = 0
|
|
88
|
+
reject(new TrashError('INVALID_REQUEST', 'Request body is too large.'))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
chunks.push(chunk)
|
|
92
|
+
})
|
|
93
|
+
req.on('end', () => {
|
|
94
|
+
if (!failed) resolve(Buffer.concat(chunks))
|
|
95
|
+
})
|
|
96
|
+
req.on('error', (error) => {
|
|
97
|
+
if (!failed) reject(error)
|
|
98
|
+
})
|
|
99
|
+
req.on('aborted', () => {
|
|
100
|
+
if (!failed) reject(new TrashError('INVALID_REQUEST', 'Request was aborted.'))
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {Buffer} buffer raw request body.
|
|
107
|
+
* @returns {Record<string, unknown>} the decoded JSON object.
|
|
108
|
+
*/
|
|
109
|
+
function parseJsonObject(buffer) {
|
|
110
|
+
if (buffer.length === 0) return {}
|
|
111
|
+
let parsed
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(buffer.toString('utf8'))
|
|
114
|
+
} catch {
|
|
115
|
+
throw new TrashError('INVALID_REQUEST', 'Request body is not valid JSON.')
|
|
116
|
+
}
|
|
117
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
118
|
+
throw new TrashError('INVALID_REQUEST', 'Request body must be a JSON object.')
|
|
119
|
+
}
|
|
120
|
+
return parsed
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {import('node:http').IncomingMessage} req request whose JSON body to read.
|
|
125
|
+
* @returns {Promise<Record<string, unknown>>} the validated JSON object.
|
|
126
|
+
*/
|
|
127
|
+
async function readJsonRequest(req) {
|
|
128
|
+
if (!String(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
|
|
129
|
+
throw new TrashError('INVALID_REQUEST', 'Content-Type must be application/json.')
|
|
130
|
+
}
|
|
131
|
+
return parseJsonObject(await readBody(req, MAX_BODY_BYTES))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {Record<string, unknown>} body decoded request body.
|
|
136
|
+
* @returns {string} a validated session identity.
|
|
137
|
+
*/
|
|
138
|
+
function requireSessionId(body) {
|
|
139
|
+
const sessionId = body.sessionId
|
|
140
|
+
if (typeof sessionId !== 'string' || sessionId === '' || sessionId.length > 256) {
|
|
141
|
+
throw new TrashError('INVALID_REQUEST', 'sessionId must be a non-empty string.')
|
|
142
|
+
}
|
|
143
|
+
return sessionId
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Register the plugin HTTP surface: state, menu preflight, trash/restore,
|
|
148
|
+
* permanent deletion (single and bulk), index maintenance, reveal, and
|
|
149
|
+
* cleanup.
|
|
150
|
+
*
|
|
151
|
+
* @param {{
|
|
152
|
+
* ctx: import('@deepseek-ai/cordis').Context,
|
|
153
|
+
* eligibility: { evaluate: Function, invalidate: Function },
|
|
154
|
+
* stateService: { snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> },
|
|
155
|
+
* trashService: {
|
|
156
|
+
* trash: (sessionId: string) => Promise<object>,
|
|
157
|
+
* restore: (sessionId: string, mode?: string) => Promise<object>,
|
|
158
|
+
* purge: (sessionId: string) => Promise<object>,
|
|
159
|
+
* purgeAll: () => Promise<object>,
|
|
160
|
+
* forget: (sessionId: string) => Promise<object>,
|
|
161
|
+
* reveal: (sessionId: string, signal?: AbortSignal) => Promise<object>,
|
|
162
|
+
* rebuild: () => Promise<object>,
|
|
163
|
+
* },
|
|
164
|
+
* cleanupService?: { runNow: (options?: object) => Promise<object>, maybeSweepOnPageOpen: () => void },
|
|
165
|
+
* ready: Promise<unknown>,
|
|
166
|
+
* config: object,
|
|
167
|
+
* logger: object,
|
|
168
|
+
* }} deps host services.
|
|
169
|
+
* @returns {void}
|
|
170
|
+
*/
|
|
171
|
+
export function registerApi({ ctx, eligibility, stateService, trashService, cleanupService, ready, config, logger }) {
|
|
172
|
+
/**
|
|
173
|
+
* Apply the shared trust, ownership, and CSRF checks.
|
|
174
|
+
*
|
|
175
|
+
* @param {import('node:http').IncomingMessage} req incoming request.
|
|
176
|
+
* @param {import('node:http').ServerResponse} res response to complete on refusal.
|
|
177
|
+
* @param {{ csrf: boolean }} options whether the scripted-call header is required.
|
|
178
|
+
* @returns {boolean} whether the request may proceed.
|
|
179
|
+
*/
|
|
180
|
+
function authorize(req, res, { csrf }) {
|
|
181
|
+
const headers = headerBag(req)
|
|
182
|
+
let rejection
|
|
183
|
+
try {
|
|
184
|
+
rejection = ctx.connection.requestRejection({ headers })
|
|
185
|
+
} catch (error) {
|
|
186
|
+
logger.warn(`connection rejection check failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
187
|
+
sendError(res, new TrashError('FORBIDDEN', 'The request could not be authenticated.'))
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
if (rejection !== undefined) {
|
|
191
|
+
sendJson(res, rejection, { ok: false, error: { code: 'FORBIDDEN', message: 'The request is not trusted.' } })
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
let admission
|
|
195
|
+
try {
|
|
196
|
+
admission = ctx.connection.admit({ headers })
|
|
197
|
+
} catch (error) {
|
|
198
|
+
logger.warn(`connection admission failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
199
|
+
sendError(res, new TrashError('FORBIDDEN', 'The request could not be admitted.'))
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
if (admission.rejection !== undefined) {
|
|
203
|
+
sendJson(res, admission.rejection, {
|
|
204
|
+
ok: false,
|
|
205
|
+
error: { code: 'FORBIDDEN', message: 'The request is not authenticated.' },
|
|
206
|
+
})
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
if (admission.peer?.principal?.kind !== 'owner') {
|
|
210
|
+
sendJson(res, 403, { ok: false, error: { code: 'FORBIDDEN', message: 'Only the owner may use the trash.' } })
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
if (csrf && headers.get(CSRF_HEADER) !== '1') {
|
|
214
|
+
sendJson(res, 403, { ok: false, error: { code: 'FORBIDDEN', message: `Missing the ${CSRF_HEADER} header.` } })
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {string} pathname route path below the API prefix.
|
|
222
|
+
* @param {string} method allowed HTTP method.
|
|
223
|
+
* @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>} handler route body.
|
|
224
|
+
* @returns {void}
|
|
225
|
+
*/
|
|
226
|
+
function route(pathname, method, handler) {
|
|
227
|
+
const path = `${API_PREFIX}/${pathname}`
|
|
228
|
+
ctx.effect(
|
|
229
|
+
() =>
|
|
230
|
+
ctx.webServer.register({
|
|
231
|
+
kind: 'exact',
|
|
232
|
+
path,
|
|
233
|
+
async handler(req, res) {
|
|
234
|
+
const startedAt = Date.now()
|
|
235
|
+
try {
|
|
236
|
+
if (req.method !== method) {
|
|
237
|
+
res.setHeader('Allow', method)
|
|
238
|
+
sendJson(res, 405, {
|
|
239
|
+
ok: false,
|
|
240
|
+
error: { code: 'METHOD_NOT_ALLOWED', message: `Use ${method} for this endpoint.` },
|
|
241
|
+
})
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (!authorize(req, res, { csrf: method !== 'GET' })) return
|
|
245
|
+
await ready
|
|
246
|
+
await handler(req, res)
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof TrashError) {
|
|
249
|
+
logger.debug(`${method} ${path} -> ${error.code} (${Date.now() - startedAt}ms)`)
|
|
250
|
+
} else {
|
|
251
|
+
logger.error(`${method} ${path} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`)
|
|
252
|
+
}
|
|
253
|
+
sendError(res, error)
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
`session-trash: route ${method} ${path}`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
route('state', 'GET', async (_req, res) => {
|
|
262
|
+
sendJson(res, 200, await stateService.snapshot())
|
|
263
|
+
try {
|
|
264
|
+
cleanupService?.maybeSweepOnPageOpen()
|
|
265
|
+
} catch (error) {
|
|
266
|
+
logger.warn(`cannot schedule the page-open trash sweep: ${error instanceof Error ? error.message : String(error)}`)
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
route('preflight', 'POST', async (req, res) => {
|
|
271
|
+
const body = await readJsonRequest(req)
|
|
272
|
+
const sessionId = requireSessionId(body)
|
|
273
|
+
const verdict = await eligibility.evaluate(sessionId)
|
|
274
|
+
if (config.debug) {
|
|
275
|
+
logger.debug(`preflight ${sessionId} -> ${verdict.eligible ? 'eligible' : verdict.code}`)
|
|
276
|
+
}
|
|
277
|
+
sendJson(res, 200, verdict)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
route('trash', 'POST', async (req, res) => {
|
|
281
|
+
const body = await readJsonRequest(req)
|
|
282
|
+
const sessionId = requireSessionId(body)
|
|
283
|
+
const entry = await trashService.trash(sessionId)
|
|
284
|
+
sendJson(res, 200, { ok: true, entry: await stateService.describe(entry) })
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
route('restore', 'POST', async (req, res) => {
|
|
288
|
+
const body = await readJsonRequest(req)
|
|
289
|
+
const sessionId = requireSessionId(body)
|
|
290
|
+
const mode = body.mode
|
|
291
|
+
if (mode !== undefined && mode !== 'normal' && mode !== 'orphan') {
|
|
292
|
+
throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
|
|
293
|
+
}
|
|
294
|
+
const session = await trashService.restore(sessionId, mode ?? 'normal')
|
|
295
|
+
sendJson(res, 200, { ok: true, session })
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
route('purge', 'POST', async (req, res) => {
|
|
299
|
+
const body = await readJsonRequest(req)
|
|
300
|
+
const sessionId = requireSessionId(body)
|
|
301
|
+
const result = await trashService.purge(sessionId)
|
|
302
|
+
sendJson(res, 200, { ok: true, sessionId: result.sessionId })
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
route('purge-all', 'POST', async (_req, res) => {
|
|
306
|
+
const result = await trashService.purgeAll()
|
|
307
|
+
sendJson(res, 200, {
|
|
308
|
+
ok: true,
|
|
309
|
+
purged: result.purged ?? 0,
|
|
310
|
+
forgotten: result.forgotten ?? 0,
|
|
311
|
+
failed: result.failed ?? 0,
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
route('forget', 'POST', async (req, res) => {
|
|
316
|
+
const body = await readJsonRequest(req)
|
|
317
|
+
const sessionId = requireSessionId(body)
|
|
318
|
+
const result = await trashService.forget(sessionId)
|
|
319
|
+
sendJson(res, 200, { ok: true, sessionId: result.sessionId })
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
route('reveal', 'POST', async (req, res) => {
|
|
323
|
+
const body = await readJsonRequest(req)
|
|
324
|
+
const sessionId = requireSessionId(body)
|
|
325
|
+
await trashService.reveal(sessionId, revealSignal())
|
|
326
|
+
sendJson(res, 200, { ok: true, opened: true })
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
route('cleanup', 'POST', async (_req, res) => {
|
|
330
|
+
const result = await cleanupService.runNow({ manual: true, reason: 'manual' })
|
|
331
|
+
const diagnostics =
|
|
332
|
+
typeof cleanupService.diagnostics === 'function' ? cleanupService.diagnostics() : undefined
|
|
333
|
+
sendJson(res, 200, {
|
|
334
|
+
ok: true,
|
|
335
|
+
purged: result.purged ?? 0,
|
|
336
|
+
failed: result.failed ?? 0,
|
|
337
|
+
settled: result.changed ?? 0,
|
|
338
|
+
...(diagnostics === undefined ? {} : { cleanup: diagnostics }),
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
route('rebuild', 'POST', async (_req, res) => {
|
|
343
|
+
const result = await trashService.rebuild()
|
|
344
|
+
sendJson(res, 200, { ok: true, entries: result.entries, unindexed: result.unindexed })
|
|
345
|
+
})
|
|
346
|
+
}
|
package/lib/inventory.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { entryKind, isSafeDirectoryPath, isSafeTrashId, samePath } from './fsops.js'
|
|
4
|
+
|
|
5
|
+
/** Every state the durable index understands. */
|
|
6
|
+
export const ENTRY_STATES = new Set(['trashing', 'trashed', 'restoring', 'purging', 'missing'])
|
|
7
|
+
|
|
8
|
+
/** Sidecar schema version understood by this release. */
|
|
9
|
+
const META_VERSION = 1
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {unknown} value candidate timestamp.
|
|
13
|
+
* @returns {boolean} whether the value is a finite number.
|
|
14
|
+
*/
|
|
15
|
+
function isFiniteNumber(value) {
|
|
16
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate one object sidecar strictly enough to be rebuilt into an index
|
|
21
|
+
* entry. A malformed sidecar is reported as an unindexed object instead of
|
|
22
|
+
* being guessed at.
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown} meta parsed `meta.json`.
|
|
25
|
+
* @param {string} trashId object directory identity.
|
|
26
|
+
* @returns {boolean} whether the sidecar can rebuild one entry.
|
|
27
|
+
*/
|
|
28
|
+
export function isValidMeta(meta, trashId) {
|
|
29
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return false
|
|
30
|
+
if (meta.version !== undefined && meta.version !== META_VERSION) return false
|
|
31
|
+
if (typeof meta.sessionId !== 'string' || meta.sessionId === '' || meta.sessionId.length > 256) return false
|
|
32
|
+
if (meta.trashId !== trashId) return false
|
|
33
|
+
if (!ENTRY_STATES.has(meta.state)) return false
|
|
34
|
+
if (!isFiniteNumber(meta.trashedAt) || !isFiniteNumber(meta.startedAt)) return false
|
|
35
|
+
if (typeof meta.title !== 'string' || meta.title.trim() === '') return false
|
|
36
|
+
if (!isSafeDirectoryPath(meta.originalSessionDir)) return false
|
|
37
|
+
if (typeof meta.persistenceProjectDirHint !== 'string' || !path.isAbsolute(meta.persistenceProjectDirHint)) return false
|
|
38
|
+
if (!samePath(path.dirname(path.resolve(meta.originalSessionDir)), path.resolve(meta.persistenceProjectDirHint))) return false
|
|
39
|
+
if (typeof meta.originalLogPath !== 'string' || !path.isAbsolute(meta.originalLogPath)) return false
|
|
40
|
+
if (!samePath(path.dirname(path.resolve(meta.originalLogPath)), path.resolve(meta.originalSessionDir))) return false
|
|
41
|
+
if (meta.originalCwd !== null && meta.originalCwd !== undefined) {
|
|
42
|
+
if (typeof meta.originalCwd !== 'string' || !path.isAbsolute(meta.originalCwd)) return false
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
meta.workspaceExistsAtDeletion !== null &&
|
|
46
|
+
meta.workspaceExistsAtDeletion !== undefined &&
|
|
47
|
+
typeof meta.workspaceExistsAtDeletion !== 'boolean'
|
|
48
|
+
) {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {Record<string, unknown>} meta valid sidecar.
|
|
56
|
+
* @returns {object} a clean index entry with defaulted optional fields.
|
|
57
|
+
*/
|
|
58
|
+
function toEntry(meta) {
|
|
59
|
+
const originalCwd = typeof meta.originalCwd === 'string' && meta.originalCwd !== '' ? meta.originalCwd : null
|
|
60
|
+
return {
|
|
61
|
+
sessionId: meta.sessionId,
|
|
62
|
+
trashId: meta.trashId,
|
|
63
|
+
state: meta.state,
|
|
64
|
+
title: meta.title,
|
|
65
|
+
trashedAt: meta.trashedAt,
|
|
66
|
+
startedAt: meta.startedAt,
|
|
67
|
+
originalCwd,
|
|
68
|
+
workspaceExistsAtDeletion: typeof meta.workspaceExistsAtDeletion === 'boolean' ? meta.workspaceExistsAtDeletion : null,
|
|
69
|
+
originalWorkspaceId: typeof meta.originalWorkspaceId === 'string' ? meta.originalWorkspaceId : null,
|
|
70
|
+
originalWorkspaceTitle: typeof meta.originalWorkspaceTitle === 'string' ? meta.originalWorkspaceTitle : null,
|
|
71
|
+
originalNextSessionId: typeof meta.originalNextSessionId === 'string' ? meta.originalNextSessionId : null,
|
|
72
|
+
originalSessionDir: path.resolve(meta.originalSessionDir),
|
|
73
|
+
originalLogPath: path.resolve(meta.originalLogPath),
|
|
74
|
+
persistenceProjectDirHint: path.resolve(meta.persistenceProjectDirHint),
|
|
75
|
+
formatStatus: meta.formatStatus === 'migration-required' ? 'migration-required' : 'current',
|
|
76
|
+
sizeBytes: isFiniteNumber(meta.sizeBytes) ? meta.sizeBytes : null,
|
|
77
|
+
fileCount: isFiniteNumber(meta.fileCount) ? meta.fileCount : null,
|
|
78
|
+
lastError: typeof meta.lastError === 'string' && meta.lastError !== '' ? meta.lastError : null,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reconcile a rebuilt entry with the object directory observed on disk.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} entry index-shaped entry.
|
|
86
|
+
* @param {boolean} sessionExists whether `objects/<trashId>/session` is a directory.
|
|
87
|
+
* @param {number} now current timestamp.
|
|
88
|
+
* @returns {object} the entry whose state matches the filesystem.
|
|
89
|
+
*/
|
|
90
|
+
export function deriveEntry(entry, sessionExists, now) {
|
|
91
|
+
if (!sessionExists) {
|
|
92
|
+
if (entry.state === 'purging' || entry.state === 'missing') return { ...entry }
|
|
93
|
+
return {
|
|
94
|
+
...entry,
|
|
95
|
+
state: 'missing',
|
|
96
|
+
lastError: 'The trash copy of this session is missing.',
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (entry.state === 'missing') {
|
|
100
|
+
return {
|
|
101
|
+
...entry,
|
|
102
|
+
state: 'trashed',
|
|
103
|
+
trashedAt: isFiniteNumber(entry.trashedAt) && entry.trashedAt > 0 ? entry.trashedAt : entry.startedAt || now,
|
|
104
|
+
lastError: null,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (entry.state === 'trashed' && (!isFiniteNumber(entry.trashedAt) || entry.trashedAt <= 0)) {
|
|
108
|
+
return { ...entry, trashedAt: entry.startedAt || now }
|
|
109
|
+
}
|
|
110
|
+
return { ...entry }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read every object directory and its sidecar without following symlinks.
|
|
115
|
+
*
|
|
116
|
+
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
117
|
+
* @param {{ warn: Function }} logger diagnostics sink.
|
|
118
|
+
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean }[], unindexed: number }>} the inventory.
|
|
119
|
+
*/
|
|
120
|
+
export async function scanObjects(store, logger) {
|
|
121
|
+
let entries
|
|
122
|
+
try {
|
|
123
|
+
entries = await fs.readdir(store.objectsDir, { withFileTypes: true })
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error !== null && error !== undefined && error.code === 'ENOENT') return { objects: [], unindexed: 0 }
|
|
126
|
+
throw error
|
|
127
|
+
}
|
|
128
|
+
const objects = []
|
|
129
|
+
let unindexed = 0
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
if (!entry.isDirectory() || !isSafeTrashId(entry.name)) {
|
|
132
|
+
unindexed += 1
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const trashId = entry.name
|
|
136
|
+
let meta
|
|
137
|
+
try {
|
|
138
|
+
meta = await store.readMeta(trashId)
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logger.warn(`cannot read the trash metadata for ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
141
|
+
// Keep the directory in the inventory so reconcile never mistakes an
|
|
142
|
+
// unreadable sidecar for a vanished object directory.
|
|
143
|
+
objects.push({ trashId, sessionExists: false, meta: undefined, metaValid: false })
|
|
144
|
+
unindexed += 1
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
const metaValid = meta !== undefined && isValidMeta(meta, trashId)
|
|
148
|
+
if (!metaValid) {
|
|
149
|
+
objects.push({ trashId, sessionExists: false, meta, metaValid: false })
|
|
150
|
+
unindexed += 1
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
let sessionExists = false
|
|
154
|
+
try {
|
|
155
|
+
sessionExists = (await entryKind(store.sessionPath(trashId))) === 'directory'
|
|
156
|
+
} catch (error) {
|
|
157
|
+
logger.warn(`cannot inspect the trash copy ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
158
|
+
}
|
|
159
|
+
objects.push({ trashId, sessionExists, meta, metaValid })
|
|
160
|
+
}
|
|
161
|
+
return { objects, unindexed }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Import object directories the index does not know about and mark entries
|
|
166
|
+
* whose object directory vanished. Never deletes a session file or an object
|
|
167
|
+
* directory.
|
|
168
|
+
*
|
|
169
|
+
* @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
|
|
170
|
+
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number }>} reconcile summary.
|
|
171
|
+
*/
|
|
172
|
+
export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
173
|
+
if (store.isProtected()) return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0 }
|
|
174
|
+
const { objects, unindexed: invalidObjects } = await scanObjects(store, logger)
|
|
175
|
+
const byTrashId = new Map(objects.map((object) => [object.trashId, object]))
|
|
176
|
+
const nextEntries = [...store.entries()]
|
|
177
|
+
const indexedTrashIds = new Set(nextEntries.map((entry) => entry.trashId))
|
|
178
|
+
const sessionIds = new Set(nextEntries.map((entry) => entry.sessionId))
|
|
179
|
+
const metaUpdates = []
|
|
180
|
+
let unindexed = invalidObjects
|
|
181
|
+
let markedMissing = 0
|
|
182
|
+
let imported = 0
|
|
183
|
+
let changed = false
|
|
184
|
+
|
|
185
|
+
for (let index = 0; index < nextEntries.length; index += 1) {
|
|
186
|
+
const entry = nextEntries[index]
|
|
187
|
+
const object = byTrashId.get(entry.trashId)
|
|
188
|
+
if (object === undefined) {
|
|
189
|
+
if (entry.state !== 'trashed') continue
|
|
190
|
+
nextEntries[index] = {
|
|
191
|
+
...entry,
|
|
192
|
+
state: 'missing',
|
|
193
|
+
lastError: entry.lastError ?? 'The trash object directory is missing.',
|
|
194
|
+
}
|
|
195
|
+
metaUpdates.push(nextEntries[index])
|
|
196
|
+
markedMissing += 1
|
|
197
|
+
changed = true
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (entry.state === 'missing' && object.sessionExists) {
|
|
201
|
+
nextEntries[index] = {
|
|
202
|
+
...entry,
|
|
203
|
+
state: 'trashed',
|
|
204
|
+
trashedAt: entry.trashedAt > 0 ? entry.trashedAt : entry.startedAt || now,
|
|
205
|
+
lastError: null,
|
|
206
|
+
}
|
|
207
|
+
metaUpdates.push(nextEntries[index])
|
|
208
|
+
changed = true
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const object of objects) {
|
|
213
|
+
if (indexedTrashIds.has(object.trashId)) continue
|
|
214
|
+
if (!object.metaValid || object.meta === undefined) continue
|
|
215
|
+
const entry = toEntry(object.meta)
|
|
216
|
+
if (sessionIds.has(entry.sessionId)) {
|
|
217
|
+
// Two object directories claim the same session: keep the indexed one.
|
|
218
|
+
unindexed += 1
|
|
219
|
+
logger.warn(`the trash object ${object.trashId} duplicates the indexed session ${entry.sessionId}`)
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const derived = deriveEntry(entry, object.sessionExists, now)
|
|
223
|
+
nextEntries.push(derived)
|
|
224
|
+
indexedTrashIds.add(derived.trashId)
|
|
225
|
+
sessionIds.add(derived.sessionId)
|
|
226
|
+
metaUpdates.push(derived)
|
|
227
|
+
imported += 1
|
|
228
|
+
changed = true
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (changed) {
|
|
232
|
+
await store.commit(nextEntries)
|
|
233
|
+
for (const entry of metaUpdates) {
|
|
234
|
+
await store.writeMeta(entry).catch((error) => {
|
|
235
|
+
logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
store.unindexed = unindexed
|
|
240
|
+
return { imported, markedMissing, unindexed, changed: changed ? 1 : 0 }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Rebuild the complete index from one `meta.json` sidecar per object directory.
|
|
245
|
+
* Used only after the primary index and its backup were both rejected;
|
|
246
|
+
* unindexed directories stay untouched and are reported.
|
|
247
|
+
*
|
|
248
|
+
* @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
|
|
249
|
+
* @returns {Promise<{ entries: number, unindexed: number }>} rebuild summary.
|
|
250
|
+
*/
|
|
251
|
+
export async function rebuildIndex({ store, logger, now = Date.now() }) {
|
|
252
|
+
const { objects, unindexed: invalidObjects } = await scanObjects(store, logger)
|
|
253
|
+
const entries = []
|
|
254
|
+
const seen = new Set()
|
|
255
|
+
let unindexed = invalidObjects
|
|
256
|
+
for (const object of objects) {
|
|
257
|
+
if (!object.metaValid || object.meta === undefined) continue
|
|
258
|
+
const entry = toEntry(object.meta)
|
|
259
|
+
if (seen.has(entry.sessionId)) {
|
|
260
|
+
unindexed += 1
|
|
261
|
+
logger.warn(`the trash object ${object.trashId} duplicates the rebuilt session ${entry.sessionId}`)
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
seen.add(entry.sessionId)
|
|
265
|
+
entries.push(deriveEntry(entry, object.sessionExists, now))
|
|
266
|
+
}
|
|
267
|
+
entries.sort((left, right) => left.startedAt - right.startedAt || left.trashId.localeCompare(right.trashId))
|
|
268
|
+
await store.commit(entries)
|
|
269
|
+
store.unindexed = unindexed
|
|
270
|
+
return { entries: entries.length, unindexed }
|
|
271
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { resolveDshHome, dshHomePath, dshHomeDisplay } from '@deepseek-ai/dsh-home-paths'
|
|
3
|
+
import { TrashError } from './errors.js'
|
|
4
|
+
|
|
5
|
+
/** Trash directory name under the DSH home. */
|
|
6
|
+
export const TRASH_DIRNAME = '.session-trash'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve every trash path from the harness home.
|
|
10
|
+
*
|
|
11
|
+
* The host refuses to activate when the shared path library cannot produce an
|
|
12
|
+
* absolute home, so no caller falls back to an environment guess of its own.
|
|
13
|
+
*
|
|
14
|
+
* @returns {{ home: string, root: string, display: string }} resolved absolute paths plus the symbolic display form.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveTrashPaths() {
|
|
17
|
+
let home
|
|
18
|
+
try {
|
|
19
|
+
home = resolveDshHome()
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
throw new TrashError(
|
|
22
|
+
'HOME_UNRESOLVED',
|
|
23
|
+
`Cannot resolve the DeepSeek Harness home: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
if (typeof home !== 'string' || home === '' || !path.isAbsolute(home)) {
|
|
27
|
+
throw new TrashError(
|
|
28
|
+
'HOME_UNRESOLVED',
|
|
29
|
+
'@deepseek-ai/dsh-home-paths resolved a non-absolute harness home; the trash plugin refuses to activate.',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
const root = dshHomePath(TRASH_DIRNAME)
|
|
33
|
+
if (typeof root !== 'string' || !path.isAbsolute(root)) {
|
|
34
|
+
throw new TrashError('HOME_UNRESOLVED', '@deepseek-ai/dsh-home-paths did not produce an absolute trash root.')
|
|
35
|
+
}
|
|
36
|
+
const homeLabel = dshHomeDisplay(home)
|
|
37
|
+
const display = `${homeLabel === undefined || homeLabel === null || homeLabel === '' ? home : homeLabel}/${TRASH_DIRNAME}`
|
|
38
|
+
return { home, root, display }
|
|
39
|
+
}
|