@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/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
+ }
@@ -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
+ }