@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.
@@ -0,0 +1,1294 @@
1
+ import path from 'node:path'
2
+ import { TrashError } from './errors.js'
3
+ import {
4
+ createTrashId,
5
+ directoryExists,
6
+ entryKind,
7
+ isInside,
8
+ isSafeDirectoryPath,
9
+ isSafeTrashId,
10
+ measureDirectory,
11
+ metricsMatch,
12
+ moveDirectory,
13
+ samePath,
14
+ } from './fsops.js'
15
+ import { reconcileIndex, rebuildIndex } from './inventory.js'
16
+
17
+ /** How many short retries confirm a moved or restored session is visible. */
18
+ const VISIBILITY_RETRIES = 5
19
+
20
+ /** Delay between visibility probes. */
21
+ const VISIBILITY_DELAY_MS = 100
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
+
33
+ /**
34
+ * @param {string} message diagnostic text.
35
+ * @returns {string} the same text, trimmed for a wire `lastError`.
36
+ */
37
+ function errorText(message) {
38
+ return message.length > 500 ? `${message.slice(0, 497)}...` : message
39
+ }
40
+
41
+ /**
42
+ * @param {unknown} error arbitrary failure.
43
+ * @returns {string} a user-facing error message.
44
+ */
45
+ function messageOf(error) {
46
+ return error instanceof Error ? error.message : String(error)
47
+ }
48
+
49
+ /**
50
+ * @param {readonly object[]} entries current index entries.
51
+ * @param {object} next replacement entry.
52
+ * @returns {object[]} a new list with the entry replaced or appended.
53
+ */
54
+ function upsertEntry(entries, next) {
55
+ const copy = [...entries]
56
+ const index = copy.findIndex((entry) => entry.sessionId === next.sessionId)
57
+ if (index === -1) copy.push(next)
58
+ else copy[index] = next
59
+ return copy
60
+ }
61
+
62
+ /**
63
+ * @param {readonly object[]} entries current index entries.
64
+ * @param {string} sessionId session to drop.
65
+ * @returns {object[]} a new list without that session.
66
+ */
67
+ function dropEntry(entries, sessionId) {
68
+ return entries.filter((entry) => entry.sessionId !== sessionId)
69
+ }
70
+
71
+ /**
72
+ * Implement trash, restore (normal and orphan), permanent deletion, index
73
+ * maintenance, and the retention sweep for cold JSONL sessions.
74
+ *
75
+ * @param {{
76
+ * ctx: import('@deepseek-ai/cordis').Context,
77
+ * store: import('./store.js').TrashStore,
78
+ * eligibility: { evaluate: (sessionId: string) => Promise<object>, invalidate: (sessionId?: string) => void },
79
+ * config: { retentionDays: number, sweepIntervalHours: number, enableAutoCleanup: boolean },
80
+ * logger: { info: Function, warn: Function, error: Function, debug: Function },
81
+ * paths: { root: string, display: string },
82
+ * }} deps host services and resolved paths.
83
+ * @returns {object} the operation surface.
84
+ */
85
+ export function createTrashService({ ctx, store, eligibility, config, logger, paths }) {
86
+ /**
87
+ * @returns {object | undefined} the optional title service.
88
+ */
89
+ function sessionQuery() {
90
+ return ctx.get('sessionQuery')
91
+ }
92
+
93
+ /**
94
+ * @returns {object | undefined} the optional workspace registry.
95
+ */
96
+ function workspaceRegistry() {
97
+ return ctx.get('workspaceRegistry')
98
+ }
99
+
100
+ /**
101
+ * @param {string} sessionId session whose storage snapshot to read.
102
+ * @returns {Promise<object | undefined>} the persistence snapshot.
103
+ */
104
+ async function stat(sessionId) {
105
+ return ctx.sessionPersistence.stat(sessionId)
106
+ }
107
+
108
+ /**
109
+ * @param {string} sessionId session to check.
110
+ * @returns {Promise<boolean>} whether the session is live in this process.
111
+ */
112
+ function isLive(sessionId) {
113
+ const liveSession = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
114
+ const liveAgent = typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined
115
+ return liveSession !== undefined || liveAgent !== undefined
116
+ }
117
+
118
+ /**
119
+ * @param {object} snapshot persistence snapshot.
120
+ * @returns {string} the session header's cwd, or an empty string when absent.
121
+ */
122
+ function cwdOf(snapshot) {
123
+ return typeof snapshot?.header?.cwd === 'string' ? snapshot.header.cwd : ''
124
+ }
125
+
126
+ /**
127
+ * Resolve a human title without making the operation depend on the optional
128
+ * query service.
129
+ *
130
+ * @param {string} sessionId session whose title to read.
131
+ * @param {object} header persistence header.
132
+ * @returns {Promise<string>} the title, then cwd basename, then session id.
133
+ */
134
+ async function resolveTitle(sessionId, header) {
135
+ const query = sessionQuery()
136
+ if (query !== undefined && query !== null && typeof query.readTitle === 'function') {
137
+ try {
138
+ const title = await query.readTitle(sessionId)
139
+ if (typeof title === 'string' && title.trim() !== '') return title.trim()
140
+ } catch (error) {
141
+ logger.warn(`cannot read the title for ${sessionId}: ${messageOf(error)}`)
142
+ }
143
+ }
144
+ if (typeof header?.cwd === 'string' && header.cwd !== '') {
145
+ const base = path.basename(header.cwd)
146
+ if (base !== '') return base
147
+ }
148
+ return sessionId
149
+ }
150
+
151
+ /**
152
+ * Find the Workspace that currently accounts for a session.
153
+ *
154
+ * @param {string} sessionId session to locate.
155
+ * @returns {{ workspace: object, nextSessionId: string | null } | undefined} owning workspace and following sibling.
156
+ */
157
+ function findOwningWorkspace(sessionId) {
158
+ const registry = workspaceRegistry()
159
+ if (registry === undefined || registry === null || typeof registry.list !== 'function') return undefined
160
+ try {
161
+ for (const workspace of registry.list()) {
162
+ const sessionIds = Array.isArray(workspace?.sessionIds) ? workspace.sessionIds : []
163
+ const index = sessionIds.indexOf(sessionId)
164
+ if (index === -1) continue
165
+ return { workspace, nextSessionId: sessionIds[index + 1] ?? null }
166
+ }
167
+ } catch (error) {
168
+ logger.warn(`cannot read the workspace registry while trashing ${sessionId}: ${messageOf(error)}`)
169
+ }
170
+ return undefined
171
+ }
172
+
173
+ /**
174
+ * @param {object} entry trash index entry.
175
+ * @returns {Promise<void>} resolution once the object directory is inside `objects/`.
176
+ */
177
+ async function assertSafeObject(entry) {
178
+ if (!isSafeTrashId(entry.trashId)) {
179
+ throw new TrashError('IO_ERROR', 'The trash entry carries an unsafe object identity.')
180
+ }
181
+ const objectDir = store.objectDir(entry.trashId)
182
+ if (!isInside(store.objectsDir, objectDir)) {
183
+ throw new TrashError('IO_ERROR', 'The trash entry names an object outside the trash root.')
184
+ }
185
+ }
186
+
187
+ /**
188
+ * @param {object} entry trash index entry.
189
+ * @returns {Promise<boolean>} whether the moved session directory still exists.
190
+ */
191
+ async function trashSessionExists(entry) {
192
+ return (await entryKind(store.sessionPath(entry.trashId))) === 'directory'
193
+ }
194
+
195
+ /**
196
+ * @param {string} sessionId session to probe.
197
+ * @param {(snapshot: object | undefined) => boolean} predicate expected visibility state.
198
+ * @returns {Promise<boolean>} whether the persistence backend agreed within the retry budget.
199
+ */
200
+ async function waitForVisibility(sessionId, predicate) {
201
+ let lastError = null
202
+ for (let attempt = 0; attempt < VISIBILITY_RETRIES; attempt += 1) {
203
+ try {
204
+ const snapshot = await stat(sessionId)
205
+ if (predicate(snapshot)) return true
206
+ } catch (error) {
207
+ lastError = error
208
+ if (attempt === VISIBILITY_RETRIES - 1) {
209
+ logger.warn(`session storage visibility probe for ${sessionId} failed: ${messageOf(error)}`)
210
+ }
211
+ }
212
+ if (attempt < VISIBILITY_RETRIES - 1) await sleep(VISIBILITY_DELAY_MS)
213
+ }
214
+ if (lastError !== null) logger.warn(`session storage never reported the expected visibility for ${sessionId}`)
215
+ return false
216
+ }
217
+
218
+ /**
219
+ * @param {string} event forwarded host event name.
220
+ * @param {...unknown} args event payload.
221
+ * @returns {void}
222
+ */
223
+ function emit(event, ...args) {
224
+ try {
225
+ ctx.emit(event, ...args)
226
+ } catch (error) {
227
+ logger.warn(`cannot emit ${event}: ${messageOf(error)}`)
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Build the cold-session summary consumed by the client SessionManager.
233
+ *
234
+ * @param {string} sessionId restored session identity.
235
+ * @param {string} title restored display title.
236
+ * @param {object | undefined} snapshot persistence snapshot.
237
+ * @returns {object} a `SessionSummary`.
238
+ */
239
+ function buildSummary(sessionId, title, snapshot) {
240
+ const header = snapshot?.header ?? {}
241
+ return {
242
+ sessionId,
243
+ updatedAt: Date.now(),
244
+ running: false,
245
+ blank: false,
246
+ agentAvailable: false,
247
+ ...(typeof header.cwd === 'string' ? { cwd: header.cwd } : {}),
248
+ formatStatus: snapshot?.formatStatus ?? 'current',
249
+ ...(typeof title === 'string' && title !== ''
250
+ ? { projections: { kind: 'cached', asOfSeq: 0, values: { title } } }
251
+ : {}),
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Record a failure summary on one entry and commit it, best effort.
257
+ *
258
+ * @param {object} entry entry to annotate.
259
+ * @param {string} message failure summary.
260
+ * @returns {Promise<object>} the annotated entry.
261
+ */
262
+ async function markFailure(entry, message) {
263
+ const next = { ...entry, lastError: errorText(message) }
264
+ try {
265
+ await store.commit(upsertEntry(store.entries(), next))
266
+ await store.writeMeta(next).catch((error) => {
267
+ logger.warn(`cannot rewrite trash metadata for ${next.trashId}: ${messageOf(error)}`)
268
+ })
269
+ } catch (error) {
270
+ logger.error(`cannot record a trash failure for ${entry.sessionId}: ${messageOf(error)}`)
271
+ }
272
+ return next
273
+ }
274
+
275
+ /**
276
+ * Restore the workspace accounting after a normal restore.
277
+ *
278
+ * @param {object} entry restored index entry.
279
+ * @returns {Promise<{ reattached: boolean, warning?: string }>} reattachment outcome.
280
+ */
281
+ async function reattachWorkspace(entry) {
282
+ const registry = workspaceRegistry()
283
+ if (registry === undefined || registry === null || typeof registry.list !== 'function') {
284
+ return { reattached: false, warning: 'The workspace registry is unavailable.' }
285
+ }
286
+ let workspace
287
+ if (typeof entry.originalWorkspaceId === 'string' && entry.originalWorkspaceId !== '') {
288
+ try {
289
+ if (typeof registry.get === 'function') workspace = registry.get(entry.originalWorkspaceId)
290
+ } catch (error) {
291
+ logger.warn(`cannot read the original workspace for ${entry.sessionId}: ${messageOf(error)}`)
292
+ }
293
+ }
294
+ if (workspace === undefined) {
295
+ try {
296
+ workspace = registry
297
+ .list()
298
+ .find((candidate) => Array.isArray(candidate?.sessionIds) && candidate.sessionIds.includes(entry.sessionId))
299
+ } catch (error) {
300
+ logger.warn(`cannot scan workspaces for ${entry.sessionId}: ${messageOf(error)}`)
301
+ }
302
+ }
303
+ if (workspace === undefined) return { reattached: false, warning: 'The original workspace no longer exists.' }
304
+ if (entry.originalCwd === null) return { reattached: false }
305
+ if (!(await directoryExists(entry.originalCwd))) {
306
+ return { reattached: false, warning: 'The original workspace directory no longer exists.' }
307
+ }
308
+ try {
309
+ // Detach first so attach validates the freshly restored header and
310
+ // rebuilds the registry's session-path index in this process.
311
+ await workspace.detachSession(entry.sessionId)
312
+ await workspace.attachSession(entry.sessionId)
313
+ } catch (error) {
314
+ logger.warn(`cannot reattach ${entry.sessionId} to its workspace: ${messageOf(error)}`)
315
+ return { reattached: false, warning: messageOf(error) }
316
+ }
317
+ try {
318
+ const nextId = entry.originalNextSessionId
319
+ if (
320
+ typeof nextId === 'string' &&
321
+ nextId !== '' &&
322
+ nextId !== entry.sessionId &&
323
+ Array.isArray(workspace.sessionIds) &&
324
+ workspace.sessionIds.includes(nextId)
325
+ ) {
326
+ await workspace.insertSessionBefore(entry.sessionId, nextId)
327
+ } else if (typeof workspace.insertSessionBefore === 'function') {
328
+ await workspace.insertSessionBefore(entry.sessionId)
329
+ }
330
+ } catch (error) {
331
+ // The session is attached; only the exact sibling position was lost.
332
+ logger.warn(`cannot restore the workspace position of ${entry.sessionId}: ${messageOf(error)}`)
333
+ }
334
+ return { reattached: true }
335
+ }
336
+
337
+ /**
338
+ * Remove a session from every workspace that still accounts for it. Used by
339
+ * orphan restore so the restored log appears under “Ungrouped”.
340
+ *
341
+ * @param {object} entry restored index entry.
342
+ * @returns {Promise<{ detached: boolean, warning?: string }>} detach outcome.
343
+ */
344
+ async function detachWorkspace(entry) {
345
+ const registry = workspaceRegistry()
346
+ if (registry === undefined || registry === null || typeof registry.list !== 'function') {
347
+ return { detached: false, warning: 'The workspace registry is unavailable, so the session could not be ungrouped.' }
348
+ }
349
+ let workspace
350
+ if (typeof entry.originalWorkspaceId === 'string' && entry.originalWorkspaceId !== '') {
351
+ try {
352
+ if (typeof registry.get === 'function') workspace = registry.get(entry.originalWorkspaceId)
353
+ } catch (error) {
354
+ logger.warn(`cannot read the original workspace for ${entry.sessionId}: ${messageOf(error)}`)
355
+ }
356
+ }
357
+ if (workspace === undefined) {
358
+ try {
359
+ workspace = registry
360
+ .list()
361
+ .find((candidate) => Array.isArray(candidate?.sessionIds) && candidate.sessionIds.includes(entry.sessionId))
362
+ } catch (error) {
363
+ logger.warn(`cannot scan workspaces for ${entry.sessionId}: ${messageOf(error)}`)
364
+ }
365
+ }
366
+ if (workspace === undefined) return { detached: false }
367
+ try {
368
+ await workspace.detachSession(entry.sessionId)
369
+ return { detached: true }
370
+ } catch (error) {
371
+ logger.warn(`cannot detach ${entry.sessionId} from its workspace: ${messageOf(error)}`)
372
+ return { detached: false, warning: messageOf(error) }
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Move one directory back and report whether the rollback physically
378
+ * succeeded. Never throws.
379
+ *
380
+ * @param {string} from current path.
381
+ * @param {string} to previous path.
382
+ * @param {object | null | undefined} expected metrics for a cross-device copy.
383
+ * @returns {Promise<boolean>} whether the directory now lives at `to`.
384
+ */
385
+ async function moveBack(from, to, expected) {
386
+ if ((await entryKind(to)) !== 'missing') {
387
+ logger.error(`cannot roll back a session move because ${to} already exists`)
388
+ return false
389
+ }
390
+ try {
391
+ await moveDirectory(from, to, expected, { logger })
392
+ return true
393
+ } catch (error) {
394
+ logger.error(`cannot roll back a session move to ${to}: ${messageOf(error)}`)
395
+ return false
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Drop one trash entry and its object directory after a rollback.
401
+ *
402
+ * @param {string} sessionId index identity.
403
+ * @param {string | undefined} trashId optional object identity.
404
+ * @returns {Promise<void>} resolution once the best-effort cleanup ends.
405
+ */
406
+ async function rollbackTrash(sessionId, trashId) {
407
+ try {
408
+ await store.commit(dropEntry(store.entries(), sessionId))
409
+ } catch (error) {
410
+ logger.error(`cannot remove the rollback trash entry for ${sessionId}: ${messageOf(error)}`)
411
+ }
412
+ if (typeof trashId === 'string' && isSafeTrashId(trashId)) {
413
+ try {
414
+ await store.removeObjectDir(trashId)
415
+ } catch (error) {
416
+ logger.warn(`cannot remove the rollback trash object ${trashId}: ${messageOf(error)}`)
417
+ }
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Settle a failed move by applying the `trashing` crash matrix to the live
423
+ * filesystem. Returns a committed `trashed` entry when the data landed in the
424
+ * trash, or `undefined` when the operation rolled back.
425
+ *
426
+ * @param {object} entry current `trashing` entry.
427
+ * @param {unknown} cause failure that triggered recovery.
428
+ * @returns {Promise<object | undefined>} the completed entry, or undefined after rollback.
429
+ */
430
+ async function settleFailedTrashMove(entry, cause) {
431
+ const sourceExists = (await entryKind(entry.originalSessionDir)) === 'directory'
432
+ const targetExists = await trashSessionExists(entry)
433
+ const failure = messageOf(cause)
434
+ if (sourceExists && !targetExists) {
435
+ await rollbackTrash(entry.sessionId, entry.trashId)
436
+ return undefined
437
+ }
438
+ if (!sourceExists && targetExists) {
439
+ const measured = await measureDirectory(store.sessionPath(entry.trashId))
440
+ if (!metricsMatch(entry, measured)) {
441
+ await markFailure(entry, `The moved session does not match its recorded size (${failure}).`)
442
+ throw new TrashError('MOVE_CONFLICT', 'The moved session does not match its recorded size.', { measured })
443
+ }
444
+ const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
445
+ await store.commit(upsertEntry(store.entries(), next))
446
+ await store.writeMeta(next).catch(() => {})
447
+ eligibility.invalidate(entry.sessionId)
448
+ emit('api-session/removed', entry.sessionId)
449
+ return next
450
+ }
451
+ if (sourceExists && targetExists) {
452
+ const measured = await measureDirectory(store.sessionPath(entry.trashId))
453
+ if (metricsMatch(entry, measured)) {
454
+ try {
455
+ await store.removeSessionSource(entry.originalSessionDir)
456
+ const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
457
+ await store.commit(upsertEntry(store.entries(), next))
458
+ await store.writeMeta(next).catch(() => {})
459
+ eligibility.invalidate(entry.sessionId)
460
+ emit('api-session/removed', entry.sessionId)
461
+ return next
462
+ } catch (error) {
463
+ logger.warn(`cannot remove the source after copying session ${entry.sessionId}: ${messageOf(error)}`)
464
+ }
465
+ } else {
466
+ // The source is authoritative: remove the unusable copy and roll back.
467
+ await rollbackTrash(entry.sessionId, entry.trashId)
468
+ return undefined
469
+ }
470
+ await markFailure(entry, failure)
471
+ throw new TrashError('MOVE_CONFLICT', 'The session move could not be completed; both copies were kept.', {
472
+ source: entry.originalSessionDir,
473
+ })
474
+ }
475
+ await markFailure(entry, `The session log disappeared during the move (${failure}).`)
476
+ throw new TrashError('MOVE_CONFLICT', 'The session log disappeared during the move.', {})
477
+ }
478
+
479
+ /**
480
+ * Settle a failed restore move. Returns `true` when the session directory is
481
+ * at its original location and the operation may continue.
482
+ *
483
+ * @param {object} entry `restoring` entry.
484
+ * @param {unknown} cause failure that triggered recovery.
485
+ * @returns {Promise<boolean>} whether the target now contains the restored session.
486
+ */
487
+ async function settleFailedRestoreMove(entry, cause) {
488
+ const targetExists = (await entryKind(entry.originalSessionDir)) === 'directory'
489
+ const trashExists = await trashSessionExists(entry)
490
+ const failure = messageOf(cause)
491
+ if (targetExists) {
492
+ const measured = await measureDirectory(entry.originalSessionDir)
493
+ if (metricsMatch(entry, measured)) {
494
+ if (trashExists) {
495
+ try {
496
+ await store.removeObjectDir(entry.trashId)
497
+ } catch (error) {
498
+ logger.warn(`cannot remove the leftover trash copy for ${entry.sessionId}: ${messageOf(error)}`)
499
+ }
500
+ }
501
+ return true
502
+ }
503
+ await markFailure(entry, `The restored session does not match its recorded size (${failure}).`)
504
+ return false
505
+ }
506
+ if (trashExists) {
507
+ const next = { ...entry, state: 'trashed', lastError: errorText(failure) }
508
+ await store.commit(upsertEntry(store.entries(), next))
509
+ await store.writeMeta(next).catch(() => {})
510
+ return false
511
+ }
512
+ await markFailure(entry, `Both the trash copy and the restore target are missing (${failure}).`)
513
+ return false
514
+ }
515
+
516
+ /**
517
+ * @param {object} entry index entry.
518
+ * @param {number} now current timestamp.
519
+ * @returns {boolean} whether the retention window has elapsed.
520
+ */
521
+ function isExpired(entry, now) {
522
+ if (entry.state !== 'trashed') return false
523
+ if (typeof entry.trashedAt !== 'number' || entry.trashedAt <= 0) return false
524
+ return entry.trashedAt + config.retentionDays * 86_400_000 <= now
525
+ }
526
+
527
+ /**
528
+ * Permanently remove one object directory and its index entry. The caller
529
+ * owns the trash lock.
530
+ *
531
+ * @param {object} entry `trashed` index entry to purge.
532
+ * @returns {Promise<void>} resolution once the object and entry are gone.
533
+ */
534
+ async function purgeInsideLock(entry) {
535
+ await assertSafeObject(entry)
536
+ const purging = { ...entry, state: 'purging', lastError: null }
537
+ await store.commit(upsertEntry(store.entries(), purging))
538
+ await store.writeMeta(purging).catch((error) => {
539
+ logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
540
+ })
541
+ try {
542
+ await store.removeObjectDir(entry.trashId)
543
+ } catch (error) {
544
+ await markFailure(purging, `The session log could not be deleted: ${messageOf(error)}`)
545
+ throw new TrashError('IO_ERROR', 'The session log could not be deleted.', { trashId: entry.trashId })
546
+ }
547
+ try {
548
+ await store.commit(dropEntry(store.entries(), entry.sessionId))
549
+ } catch (error) {
550
+ throw new TrashError('INDEX_WRITE_FAILED', 'The session log was deleted, but the trash index could not be updated.', {
551
+ code: error instanceof TrashError ? error.code : null,
552
+ })
553
+ }
554
+ logger.info(`permanently deleted ${entry.sessionId} (${entry.trashId})`)
555
+ }
556
+
557
+ /**
558
+ * Drop one `missing` entry from the index and remove its plugin-owned
559
+ * remnants. No session file is deleted. The caller owns the trash lock.
560
+ *
561
+ * @param {object} entry `missing` index entry to forget.
562
+ * @returns {Promise<{ sessionId: string, title: string }>} the removed identity.
563
+ */
564
+ async function forgetInsideLock(entry) {
565
+ try {
566
+ await store.commit(dropEntry(store.entries(), entry.sessionId))
567
+ } catch (error) {
568
+ throw new TrashError('INDEX_WRITE_FAILED', 'The trash index could not be updated.', {
569
+ code: error instanceof TrashError ? error.code : null,
570
+ })
571
+ }
572
+ await store.removeObjectRemnants(entry.trashId).catch((error) => {
573
+ logger.warn(`cannot remove the remnants of ${entry.trashId}: ${messageOf(error)}`)
574
+ })
575
+ return { sessionId: entry.sessionId, title: entry.title }
576
+ }
577
+
578
+ /**
579
+ * Move one cold session into the trash.
580
+ *
581
+ * @param {string} sessionId session to trash.
582
+ * @returns {Promise<object>} the committed `trashed` entry.
583
+ */
584
+ async function trash(sessionId) {
585
+ const startedAt = Date.now()
586
+ const result = await store.withLock(async () => {
587
+ if (store.isProtected()) {
588
+ throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
589
+ }
590
+ eligibility.invalidate(sessionId)
591
+ const verdict = await eligibility.evaluate(sessionId)
592
+ if (verdict.eligible !== true) {
593
+ throw new TrashError(verdict.code, verdict.message, verdict.details)
594
+ }
595
+ const existing = store.findEntry(sessionId)
596
+ if (existing !== undefined) {
597
+ if (existing.state === 'missing') {
598
+ await store.commit(dropEntry(store.entries(), sessionId))
599
+ await store.removeObjectRemnants(existing.trashId).catch((error) => {
600
+ logger.warn(`cannot remove the stale trash object ${existing.trashId}: ${messageOf(error)}`)
601
+ })
602
+ } else {
603
+ throw new TrashError('ALREADY_TRASHED', 'This session is already recorded in the trash.')
604
+ }
605
+ }
606
+ const snapshot = await stat(sessionId)
607
+ if (snapshot === undefined) throw new TrashError('SESSION_NOT_FOUND', 'This session is not in session storage.')
608
+ if (isLive(sessionId)) throw new TrashError('SESSION_LIVE', 'This session is open right now.')
609
+ const location = snapshot.location
610
+ if (location === undefined || location === null || location.kind !== 'jsonl' || typeof location.path !== 'string') {
611
+ throw new TrashError('SESSION_NOT_MATERIALIZED', 'This session has no movable JSONL log.')
612
+ }
613
+ const resolvedLogPath = path.resolve(location.path)
614
+ if (!path.isAbsolute(location.path)) {
615
+ throw new TrashError('STORAGE_UNSUPPORTED', 'The session log path is not absolute.')
616
+ }
617
+ const sessionDir = path.dirname(resolvedLogPath)
618
+ const projectDir = path.dirname(sessionDir)
619
+ if (!isSafeDirectoryPath(sessionDir) || (await entryKind(sessionDir)) !== 'directory') {
620
+ throw new TrashError('UNSAFE_SESSION_DIR', 'The session directory is not a plain directory this plugin can move.')
621
+ }
622
+
623
+ let trashId
624
+ for (let attempt = 0; attempt < 5; attempt += 1) {
625
+ const candidate = createTrashId()
626
+ const indexed = store.entries().some((entry) => entry.trashId === candidate)
627
+ if (!indexed && (await entryKind(store.objectDir(candidate))) === 'missing') {
628
+ trashId = candidate
629
+ break
630
+ }
631
+ }
632
+ if (trashId === undefined) throw new TrashError('IO_ERROR', 'Cannot allocate a unique trash identity.')
633
+
634
+ try {
635
+ await store.createObjectDir(trashId)
636
+ } catch (error) {
637
+ throw new TrashError('IO_ERROR', `Cannot create the trash object directory: ${messageOf(error)}`, {
638
+ code: error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : null,
639
+ phase: 'create-object',
640
+ })
641
+ }
642
+ let entry
643
+ try {
644
+ const measured = await measureDirectory(sessionDir)
645
+ const title = await resolveTitle(sessionId, snapshot.header)
646
+ const originalCwd = cwdOf(snapshot) === '' ? null : cwdOf(snapshot)
647
+ const workspaceInfo = findOwningWorkspace(sessionId)
648
+ entry = {
649
+ sessionId,
650
+ trashId,
651
+ state: 'trashing',
652
+ title,
653
+ trashedAt: 0,
654
+ startedAt,
655
+ originalCwd,
656
+ workspaceExistsAtDeletion: originalCwd === null ? null : await directoryExists(originalCwd),
657
+ originalWorkspaceId: workspaceInfo?.workspace?.id ?? null,
658
+ originalWorkspaceTitle: workspaceInfo?.workspace?.title ?? null,
659
+ originalNextSessionId: workspaceInfo?.nextSessionId ?? null,
660
+ originalSessionDir: sessionDir,
661
+ originalLogPath: resolvedLogPath,
662
+ persistenceProjectDirHint: projectDir,
663
+ formatStatus: snapshot.formatStatus ?? 'current',
664
+ sizeBytes: measured.sizeBytes,
665
+ fileCount: measured.fileCount,
666
+ lastError: null,
667
+ }
668
+ await store.writeMeta(entry)
669
+ await store.commit(upsertEntry(store.entries(), entry))
670
+ } catch (error) {
671
+ await rollbackTrash(sessionId, trashId)
672
+ throw error instanceof TrashError
673
+ ? error
674
+ : new TrashError('IO_ERROR', `Cannot prepare the trash entry: ${messageOf(error)}`)
675
+ }
676
+
677
+ // Another process may have opened or resumed the session between the
678
+ // preflight and the move. Re-check live state while the trash lock is
679
+ // held; the source is still untouched at this point.
680
+ if (isLive(sessionId)) {
681
+ await rollbackTrash(sessionId, trashId)
682
+ throw new TrashError('SESSION_LIVE', 'This session became live before it could be moved.')
683
+ }
684
+
685
+ try {
686
+ await moveDirectory(sessionDir, store.sessionPath(trashId), entry, { logger })
687
+ } catch (error) {
688
+ let settled
689
+ try {
690
+ settled = await settleFailedTrashMove(entry, error)
691
+ } catch (settleError) {
692
+ await markFailure(entry, messageOf(settleError))
693
+ throw settleError
694
+ }
695
+ if (settled !== undefined) return settled
696
+ throw error instanceof TrashError
697
+ ? error
698
+ : new TrashError('IO_ERROR', `Cannot move the session into the trash: ${messageOf(error)}`)
699
+ }
700
+
701
+ let measured
702
+ try {
703
+ measured = await measureDirectory(store.sessionPath(trashId))
704
+ } catch (error) {
705
+ let settled
706
+ try {
707
+ settled = await settleFailedTrashMove(entry, error)
708
+ } catch (settleError) {
709
+ await markFailure(entry, messageOf(settleError))
710
+ throw settleError
711
+ }
712
+ if (settled !== undefined) return settled
713
+ throw new TrashError('MOVE_VERIFY_FAILED', `Cannot verify the moved session: ${messageOf(error)}`)
714
+ }
715
+ if (!metricsMatch(entry, measured)) {
716
+ const movedBack = await moveBack(store.sessionPath(trashId), sessionDir, entry)
717
+ if (!movedBack) {
718
+ await markFailure(entry, 'The moved session does not match its recorded size, and moving it back failed.')
719
+ throw new TrashError('MOVE_CONFLICT', 'The session was moved but could not be verified or rolled back.')
720
+ }
721
+ await rollbackTrash(sessionId, trashId)
722
+ throw new TrashError('MOVE_VERIFY_FAILED', 'The moved session does not match its recorded size.', {
723
+ expected: { sizeBytes: entry.sizeBytes, fileCount: entry.fileCount },
724
+ measured,
725
+ })
726
+ }
727
+ const hidden = await waitForVisibility(sessionId, (current) => current === undefined)
728
+ if (!hidden) {
729
+ const movedBack = await moveBack(store.sessionPath(trashId), sessionDir, entry)
730
+ if (!movedBack) {
731
+ await markFailure(entry, 'Session storage still reports the moved session, and moving it back failed.')
732
+ throw new TrashError('MOVE_CONFLICT', 'The session was moved but session storage still reports it.')
733
+ }
734
+ await rollbackTrash(sessionId, trashId)
735
+ throw new TrashError('MOVE_VERIFY_FAILED', 'Session storage still reports the session after the move.')
736
+ }
737
+
738
+ const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
739
+ try {
740
+ await store.commit(upsertEntry(store.entries(), next))
741
+ } catch (error) {
742
+ await markFailure(entry, `The trash index could not be committed: ${messageOf(error)}`)
743
+ throw new TrashError('INDEX_WRITE_FAILED', 'The session moved, but the trash index could not be updated.')
744
+ }
745
+ await store.writeMeta(next).catch((error) => {
746
+ logger.warn(`cannot rewrite trash metadata for ${trashId}: ${messageOf(error)}`)
747
+ })
748
+ eligibility.invalidate(sessionId)
749
+ emit('api-session/removed', sessionId)
750
+ logger.info(`trashed ${sessionId} as ${trashId} in ${Date.now() - startedAt}ms`)
751
+ return next
752
+ })
753
+ return result
754
+ }
755
+
756
+ /**
757
+ * Restore one trashed session to its original directory. Phase 2 supports
758
+ * `normal` only; orphan restore arrives later.
759
+ *
760
+ * @param {string} sessionId session to restore.
761
+ * @param {'normal' | 'orphan'} [mode] restore mode; orphan restores the log and detaches the Workspace.
762
+ * @returns {Promise<object>} the restore result.
763
+ */
764
+ async function restore(sessionId, mode = 'normal') {
765
+ if (mode !== 'normal' && mode !== 'orphan') {
766
+ throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
767
+ }
768
+ const startedAt = Date.now()
769
+ return store.withLock(async () => {
770
+ if (store.isProtected()) {
771
+ throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses restores.')
772
+ }
773
+ const entry = store.findEntry(sessionId)
774
+ if (entry === undefined) throw new TrashError('TRASH_ENTRY_NOT_FOUND', 'This session is not in the trash.')
775
+ if (entry.state !== 'trashed') {
776
+ throw new TrashError('TRASH_STATE_INVALID', `The trash entry is in state "${entry.state}" and cannot be restored.`)
777
+ }
778
+ await assertSafeObject(entry)
779
+ const target = path.resolve(String(entry.originalSessionDir))
780
+ const projectDirHint = path.resolve(String(entry.persistenceProjectDirHint))
781
+ if (
782
+ !isSafeDirectoryPath(target) ||
783
+ !samePath(path.dirname(target), projectDirHint) ||
784
+ isInside(paths.root, target) ||
785
+ !isInside(paths.root, store.objectDir(entry.trashId)) ||
786
+ typeof entry.originalLogPath !== 'string' ||
787
+ !samePath(path.dirname(path.resolve(entry.originalLogPath)), target)
788
+ ) {
789
+ throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target failed the path safety check.')
790
+ }
791
+ const projectDirKind = await entryKind(projectDirHint)
792
+ if (projectDirKind !== 'missing' && projectDirKind !== 'directory') {
793
+ throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target parent is not a plain directory.')
794
+ }
795
+ const targetKind = await entryKind(target)
796
+ if (targetKind !== 'missing') {
797
+ throw new TrashError('RESTORE_TARGET_EXISTS', 'A directory already exists at the restore target.')
798
+ }
799
+ if (!(await trashSessionExists(entry))) {
800
+ const missing = { ...entry, state: 'missing', lastError: 'The trash object directory is missing.' }
801
+ await store.commit(upsertEntry(store.entries(), missing))
802
+ await store.writeMeta(missing).catch(() => {})
803
+ throw new TrashError('TRASH_DIR_MISSING', 'The trash copy of this session is missing.')
804
+ }
805
+ if (entry.originalCwd !== null && !(await directoryExists(entry.originalCwd))) {
806
+ if (mode === 'normal') {
807
+ throw new TrashError('WORKSPACE_MISSING', 'The original workspace directory no longer exists.')
808
+ }
809
+ }
810
+
811
+ const restoring = { ...entry, state: 'restoring', lastError: null }
812
+ await store.commit(upsertEntry(store.entries(), restoring))
813
+ await store.writeMeta(restoring).catch((error) => {
814
+ logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
815
+ })
816
+
817
+ try {
818
+ await moveDirectory(store.sessionPath(entry.trashId), target, entry, { logger })
819
+ } catch (error) {
820
+ // A target created between the pre-check and the move belongs to
821
+ // someone else: never treat it as the restored copy.
822
+ if (error instanceof TrashError && error.code === 'RESTORE_TARGET_EXISTS') throw error
823
+ let completed
824
+ try {
825
+ completed = await settleFailedRestoreMove(restoring, error)
826
+ } catch (settleError) {
827
+ await markFailure(restoring, messageOf(settleError))
828
+ throw settleError
829
+ }
830
+ if (!completed) {
831
+ throw error instanceof TrashError
832
+ ? error
833
+ : new TrashError('IO_ERROR', `Cannot move the session back: ${messageOf(error)}`)
834
+ }
835
+ }
836
+
837
+ let measured
838
+ try {
839
+ measured = await measureDirectory(target)
840
+ } catch (error) {
841
+ throw new TrashError('RESTORE_VERIFY_FAILED', `Cannot verify the restored session: ${messageOf(error)}`)
842
+ }
843
+ if (!metricsMatch(entry, measured)) {
844
+ const rolledBack = await moveBack(target, store.sessionPath(entry.trashId), measured)
845
+ if (!rolledBack) {
846
+ await markFailure(restoring, 'The restored session does not match its recorded size, and moving it back failed.')
847
+ throw new TrashError('MOVE_CONFLICT', 'The restored session does not match its recorded size.')
848
+ }
849
+ const next = { ...restoring, state: 'trashed', lastError: 'The restored session did not match its recorded size.' }
850
+ await store.commit(upsertEntry(store.entries(), next))
851
+ await store.writeMeta(next).catch(() => {})
852
+ throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session does not match its recorded size.', {
853
+ expected: { sizeBytes: entry.sizeBytes, fileCount: entry.fileCount },
854
+ measured,
855
+ })
856
+ }
857
+ const visible = await waitForVisibility(
858
+ sessionId,
859
+ (current) => current !== undefined && current !== null && current.location !== undefined,
860
+ )
861
+ if (!visible) {
862
+ const rolledBack = await moveBack(target, store.sessionPath(entry.trashId), measured)
863
+ if (!rolledBack) {
864
+ await markFailure(restoring, 'Session storage cannot see the restored session, and moving it back failed.')
865
+ throw new TrashError('MOVE_CONFLICT', 'Session storage cannot see the restored session.')
866
+ }
867
+ const next = { ...restoring, state: 'trashed', lastError: 'The restored session was not visible to session storage.' }
868
+ await store.commit(upsertEntry(store.entries(), next))
869
+ await store.writeMeta(next).catch(() => {})
870
+ throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session was not visible to session storage.')
871
+ }
872
+
873
+ const attachment = mode === 'orphan' ? await detachWorkspace(entry) : await reattachWorkspace(entry)
874
+ const restoredSnapshot = await stat(sessionId).catch(() => undefined)
875
+
876
+ try {
877
+ await store.commit(dropEntry(store.entries(), sessionId))
878
+ } catch (error) {
879
+ await markFailure(restoring, `The trash index could not be committed after restore: ${messageOf(error)}`)
880
+ throw new TrashError('INDEX_WRITE_FAILED', 'The session was restored, but the trash index could not be updated.')
881
+ }
882
+ await store.removeObjectDir(entry.trashId).catch((error) => {
883
+ logger.warn(`cannot remove the restored trash object ${entry.trashId}: ${messageOf(error)}`)
884
+ })
885
+ eligibility.invalidate(sessionId)
886
+ emit('api-session/added', buildSummary(sessionId, entry.title, restoredSnapshot))
887
+ logger.info(`restored ${sessionId} from ${entry.trashId} (${mode}) in ${Date.now() - startedAt}ms`)
888
+ return {
889
+ sessionId,
890
+ title: entry.title,
891
+ orphan: mode === 'orphan',
892
+ workspaceReattached: mode === 'orphan' ? false : attachment.reattached === true,
893
+ ...(attachment.warning === undefined ? {} : { warning: attachment.warning }),
894
+ }
895
+ })
896
+ }
897
+
898
+ /**
899
+ * Apply the crash matrix to every index entry. The caller must hold the trash
900
+ * lock. It never deletes an unindexed object and never removes a `missing`
901
+ * entry on its own.
902
+ *
903
+ * @returns {Promise<{ changed: number, completed: number, rolledBack: number, missing: number }>} recovery summary.
904
+ */
905
+ async function recoverInsideLock() {
906
+ let entries = [...store.entries()]
907
+ let changed = 0
908
+ let completed = 0
909
+ let rolledBack = 0
910
+ let missing = 0
911
+ const commit = (next) => {
912
+ if (next === undefined) return
913
+ entries = upsertEntry(entries, next)
914
+ }
915
+ const drop = (sessionId) => {
916
+ entries = dropEntry(entries, sessionId)
917
+ }
918
+ const fail = (entry, message) => ({ ...entry, lastError: errorText(message) })
919
+
920
+ for (const original of store.entries()) {
921
+ try {
922
+ if (original.state === 'purging') {
923
+ await assertSafeObject(original)
924
+ await store.removeObjectDir(original.trashId)
925
+ drop(original.sessionId)
926
+ completed += 1
927
+ changed += 1
928
+ continue
929
+ }
930
+
931
+ if (original.state === 'trashing') {
932
+ await assertSafeObject(original)
933
+ const sourceExists = (await entryKind(original.originalSessionDir)) === 'directory'
934
+ const targetExists = await trashSessionExists(original)
935
+ if (!sourceExists && !targetExists) {
936
+ commit({ ...original, state: 'missing', lastError: 'Both the session source and the trash copy are missing.' })
937
+ missing += 1
938
+ } else if (!sourceExists && targetExists) {
939
+ const measured = await measureDirectory(store.sessionPath(original.trashId))
940
+ if (!metricsMatch(original, measured)) {
941
+ commit(fail({ ...original, state: 'trashing' }, 'The trash copy does not match the recorded size.'))
942
+ } else {
943
+ commit({ ...original, state: 'trashed', trashedAt: original.startedAt || Date.now(), lastError: null })
944
+ completed += 1
945
+ }
946
+ } else if (sourceExists && !targetExists) {
947
+ await store.removeObjectDir(original.trashId).catch((error) => {
948
+ logger.warn(`cannot remove the rolled-back trash object ${original.trashId}: ${messageOf(error)}`)
949
+ })
950
+ drop(original.sessionId)
951
+ rolledBack += 1
952
+ } else {
953
+ const measured = await measureDirectory(store.sessionPath(original.trashId))
954
+ if (!metricsMatch(original, measured)) {
955
+ await store.removeObjectDir(original.trashId)
956
+ drop(original.sessionId)
957
+ rolledBack += 1
958
+ } else {
959
+ try {
960
+ await store.removeSessionSource(original.originalSessionDir)
961
+ commit({ ...original, state: 'trashed', trashedAt: original.startedAt || Date.now(), lastError: null })
962
+ completed += 1
963
+ } catch (error) {
964
+ commit(fail(original, `Cannot remove the source after completing the move: ${messageOf(error)}`))
965
+ }
966
+ }
967
+ }
968
+ changed += 1
969
+ continue
970
+ }
971
+
972
+ if (original.state === 'restoring') {
973
+ await assertSafeObject(original)
974
+ const sourceExists = (await entryKind(original.originalSessionDir)) === 'directory'
975
+ const targetExists = await trashSessionExists(original)
976
+ if (sourceExists) {
977
+ const measured = await measureDirectory(original.originalSessionDir)
978
+ const visible = metricsMatch(original, measured)
979
+ ? await waitForVisibility(original.sessionId, (current) => current !== undefined && current !== null && current.location !== undefined)
980
+ : false
981
+ if (!visible) {
982
+ commit(fail(original, 'The restored session is not visible to session storage.'))
983
+ changed += 1
984
+ continue
985
+ }
986
+ await store.removeObjectDir(original.trashId).catch((error) => {
987
+ logger.warn(`cannot remove the trash object ${original.trashId}: ${messageOf(error)}`)
988
+ })
989
+ drop(original.sessionId)
990
+ completed += 1
991
+ changed += 1
992
+ continue
993
+ }
994
+ if (targetExists) {
995
+ commit({ ...original, state: 'trashed', lastError: 'Recovered the trash copy after an interrupted restore.' })
996
+ rolledBack += 1
997
+ } else {
998
+ commit({ ...original, state: 'missing', lastError: 'Both the trash copy and the restore target are missing.' })
999
+ missing += 1
1000
+ }
1001
+ changed += 1
1002
+ continue
1003
+ }
1004
+
1005
+ if (original.state === 'trashed') {
1006
+ await assertSafeObject(original)
1007
+ const sourceExists = (await entryKind(original.originalSessionDir)) === 'directory'
1008
+ const targetExists = await trashSessionExists(original)
1009
+ if (!targetExists) {
1010
+ commit({
1011
+ ...original,
1012
+ state: 'missing',
1013
+ lastError: sourceExists
1014
+ ? 'The trash copy is missing; the original session directory is still present.'
1015
+ : 'The trash copy is missing.',
1016
+ })
1017
+ missing += 1
1018
+ changed += 1
1019
+ continue
1020
+ }
1021
+ if (sourceExists) {
1022
+ commit(fail(original, 'The original session directory unexpectedly exists alongside the trash copy; both were kept.'))
1023
+ changed += 1
1024
+ }
1025
+ }
1026
+ } catch (error) {
1027
+ logger.warn(`cannot reconcile trash entry ${original.sessionId}: ${messageOf(error)}`)
1028
+ commit(fail(original, messageOf(error)))
1029
+ }
1030
+ }
1031
+
1032
+ if (changed > 0) {
1033
+ await store.commit(entries)
1034
+ for (const entry of entries) {
1035
+ await store.writeMeta(entry).catch((error) => {
1036
+ logger.warn(`cannot refresh trash metadata for ${entry.trashId}: ${messageOf(error)}`)
1037
+ })
1038
+ }
1039
+ }
1040
+ return { changed, completed, rolledBack, missing }
1041
+ }
1042
+
1043
+ /**
1044
+ * Import object directories missing from the index and flag entries whose
1045
+ * object directory vanished. The caller must hold the trash lock.
1046
+ *
1047
+ * @param {number} now current timestamp.
1048
+ * @returns {Promise<object>} reconcile summary.
1049
+ */
1050
+ async function reconcileInsideLock(now) {
1051
+ try {
1052
+ const summary = await reconcileIndex({ store, logger, now })
1053
+ if (summary.imported > 0 || summary.markedMissing > 0 || summary.unindexed > 0) {
1054
+ logger.info(
1055
+ `trash index reconciled: imported ${summary.imported}, marked ${summary.markedMissing} missing, ${summary.unindexed} unindexed objects`,
1056
+ )
1057
+ }
1058
+ return summary
1059
+ } catch (error) {
1060
+ logger.warn(`trash index reconciliation failed: ${messageOf(error)}`)
1061
+ return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0 }
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Reconcile, settle unfinished operations, and optionally purge expired
1067
+ * entries. Used by startup recovery, the scheduled sweep, and manual cleanup.
1068
+ *
1069
+ * @param {{ allowPurge?: boolean, now?: number, reason?: string }} [options] sweep policy.
1070
+ * @returns {Promise<object>} the sweep summary.
1071
+ */
1072
+ async function sweep({ allowPurge = true, now = Date.now(), reason = 'scheduled' } = {}) {
1073
+ return store.withLock(async () => {
1074
+ if (store.isProtected()) {
1075
+ throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses cleanup.')
1076
+ }
1077
+ const reconcile = await reconcileInsideLock(now)
1078
+ const recovery = await recoverInsideLock()
1079
+ let purged = 0
1080
+ let failed = 0
1081
+ if (allowPurge) {
1082
+ for (const entry of [...store.entries()]) {
1083
+ if (!isExpired(entry, now)) continue
1084
+ try {
1085
+ await purgeInsideLock(entry)
1086
+ purged += 1
1087
+ } catch (error) {
1088
+ failed += 1
1089
+ logger.warn(`cannot purge the expired session ${entry.sessionId}: ${messageOf(error)}`)
1090
+ }
1091
+ }
1092
+ }
1093
+ return { ...recovery, reconcile, purged, failed, allowPurge, reason }
1094
+ })
1095
+ }
1096
+
1097
+ /**
1098
+ * Restore the index from object sidecars after the primary index and its
1099
+ * backup were both rejected.
1100
+ *
1101
+ * @returns {Promise<{ entries: number, unindexed: number }>} rebuild summary.
1102
+ */
1103
+ async function rebuild() {
1104
+ return store.withLock(async () => {
1105
+ if (!store.isProtected()) {
1106
+ throw new TrashError('INDEX_NOT_CORRUPT', 'The trash index is readable, so it does not need a rebuild.')
1107
+ }
1108
+ const summary = await rebuildIndex({ store, logger, now: Date.now() })
1109
+ const recovery = await recoverInsideLock()
1110
+ logger.info(
1111
+ `rebuilt the trash index from object metadata: ${store.entries().length} entries, ${store.unindexed} unindexed, ${recovery.changed} settled`,
1112
+ )
1113
+ return { entries: store.entries().length, unindexed: store.unindexed, recovery }
1114
+ })
1115
+ }
1116
+
1117
+ /**
1118
+ * Permanently delete one trashed session log.
1119
+ *
1120
+ * @param {string} sessionId session to purge.
1121
+ * @returns {Promise<{ sessionId: string, title: string }>} the removed identity.
1122
+ */
1123
+ async function purge(sessionId) {
1124
+ return store.withLock(async () => {
1125
+ if (store.isProtected()) {
1126
+ throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses deletion.')
1127
+ }
1128
+ const entry = store.findEntry(sessionId)
1129
+ if (entry === undefined) throw new TrashError('TRASH_ENTRY_NOT_FOUND', 'This session is not in the trash.')
1130
+ if (entry.state !== 'trashed') {
1131
+ throw new TrashError('TRASH_STATE_INVALID', `The trash entry is in state "${entry.state}" and cannot be deleted.`)
1132
+ }
1133
+ await purgeInsideLock(entry)
1134
+ return { sessionId, title: entry.title }
1135
+ })
1136
+ }
1137
+
1138
+ /**
1139
+ * Permanently delete every trashed session and forget every `missing` index
1140
+ * entry. Interrupted moves are settled first; each entry is then attempted
1141
+ * independently so one failure never aborts the rest. Protection mode refuses
1142
+ * the whole operation before anything is mutated.
1143
+ *
1144
+ * @returns {Promise<{
1145
+ * purged: number,
1146
+ * forgotten: number,
1147
+ * failed: number,
1148
+ * failures: { sessionId: string, title: string, message: string }[],
1149
+ * changed: number,
1150
+ * }>} the bulk summary; `changed` is non-zero whenever this call settled or
1151
+ * removed entries.
1152
+ */
1153
+ async function purgeAll() {
1154
+ return store.withLock(async () => {
1155
+ if (store.isProtected()) {
1156
+ throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses deletion.')
1157
+ }
1158
+ const reconcile = await reconcileInsideLock(Date.now())
1159
+ const recovery = await recoverInsideLock()
1160
+ let purged = 0
1161
+ let forgotten = 0
1162
+ let failed = 0
1163
+ const failures = []
1164
+ for (const entry of [...store.entries()]) {
1165
+ const title = typeof entry.title === 'string' ? entry.title : ''
1166
+ try {
1167
+ if (entry.state === 'trashed') {
1168
+ await purgeInsideLock(entry)
1169
+ purged += 1
1170
+ continue
1171
+ }
1172
+ if (entry.state === 'missing') {
1173
+ await forgetInsideLock(entry)
1174
+ forgotten += 1
1175
+ continue
1176
+ }
1177
+ failed += 1
1178
+ const message = `The trash entry is in state "${entry.state}" and cannot be cleaned up.`
1179
+ failures.push({ sessionId: entry.sessionId, title, message })
1180
+ logger.warn(`cannot purge the session ${entry.sessionId}: ${message}`)
1181
+ } catch (error) {
1182
+ failed += 1
1183
+ failures.push({ sessionId: entry.sessionId, title, message: messageOf(error) })
1184
+ logger.warn(`cannot purge the trashed session ${entry.sessionId}: ${messageOf(error)}`)
1185
+ }
1186
+ }
1187
+ logger.info(`bulk purge finished: purged ${purged}, forgotten ${forgotten}, failed ${failed}`)
1188
+ return {
1189
+ purged,
1190
+ forgotten,
1191
+ failed,
1192
+ failures,
1193
+ changed: reconcile.changed + recovery.changed + purged + forgotten,
1194
+ }
1195
+ })
1196
+ }
1197
+
1198
+ /**
1199
+ * Remove one `missing` entry from the index without deleting any session
1200
+ * file. Used when the object directory vanished outside this plugin.
1201
+ *
1202
+ * @param {string} sessionId missing entry to forget.
1203
+ * @returns {Promise<{ sessionId: string, title: string }>} the removed identity.
1204
+ */
1205
+ async function forget(sessionId) {
1206
+ return store.withLock(async () => {
1207
+ if (store.isProtected()) {
1208
+ throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses changes.')
1209
+ }
1210
+ const entry = store.findEntry(sessionId)
1211
+ if (entry === undefined) throw new TrashError('TRASH_ENTRY_NOT_FOUND', 'This session is not in the trash.')
1212
+ if (entry.state !== 'missing') {
1213
+ throw new TrashError('FORGET_NOT_ALLOWED', 'Only an entry whose session log is missing may be removed from the index.')
1214
+ }
1215
+ return forgetInsideLock(entry)
1216
+ })
1217
+ }
1218
+
1219
+ /**
1220
+ * Reveal the original workspace of one trashed session in the desktop file
1221
+ * manager.
1222
+ *
1223
+ * @param {string} sessionId session whose workspace to reveal.
1224
+ * @param {AbortSignal} [signal] caller lifetime.
1225
+ * @returns {Promise<{ path: string }>} the revealed directory.
1226
+ */
1227
+ async function reveal(sessionId, signal) {
1228
+ const entry = store.findEntry(sessionId)
1229
+ if (entry === undefined) throw new TrashError('TRASH_ENTRY_NOT_FOUND', 'This session is not in the trash.')
1230
+ const target = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
1231
+ if (target === null) throw new TrashError('REVEAL_UNAVAILABLE', 'This session has no original workspace to reveal.')
1232
+ if (!(await directoryExists(target))) {
1233
+ throw new TrashError('WORKSPACE_MISSING', 'The original workspace directory no longer exists.')
1234
+ }
1235
+ const controller = ctx.get('sessionController')
1236
+ if (controller === null || controller === undefined || typeof controller.openWorkspacePath !== 'function') {
1237
+ throw new TrashError('REVEAL_UNAVAILABLE', 'This desktop cannot open the original workspace.')
1238
+ }
1239
+ let canOpen = true
1240
+ try {
1241
+ if (typeof controller.canOpenWorkspacePath === 'function') canOpen = controller.canOpenWorkspacePath() !== false
1242
+ } catch (error) {
1243
+ logger.warn(`cannot probe native path opening: ${messageOf(error)}`)
1244
+ canOpen = false
1245
+ }
1246
+ if (!canOpen) throw new TrashError('REVEAL_UNAVAILABLE', 'This desktop cannot open the original workspace.')
1247
+ try {
1248
+ signal?.throwIfAborted?.()
1249
+ await controller.openWorkspacePath({ path: target, action: 'reveal' }, signal)
1250
+ } catch (error) {
1251
+ if (error instanceof TrashError) throw error
1252
+ if (error !== null && error !== undefined && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) {
1253
+ throw new TrashError('IO_ERROR', 'The reveal request was cancelled.')
1254
+ }
1255
+ throw new TrashError('REVEAL_FAILED', `The original workspace could not be opened: ${messageOf(error)}`)
1256
+ }
1257
+ return { path: target }
1258
+ }
1259
+
1260
+ /**
1261
+ * Apply the crash matrix to every index entry. Startup calls this once; it
1262
+ * never deletes an unindexed object and never touches `missing` entries.
1263
+ *
1264
+ * @returns {Promise<{ changed: number, completed: number, rolledBack: number, missing: number }>} recovery summary.
1265
+ */
1266
+ async function recover() {
1267
+ if (store.isProtected()) {
1268
+ logger.warn('trash recovery skipped because the index is in protection mode')
1269
+ return { changed: 0, completed: 0, rolledBack: 0, missing: 0 }
1270
+ }
1271
+ let summary
1272
+ try {
1273
+ summary = await store.withLock(async () => {
1274
+ if (store.isProtected()) return { changed: 0, completed: 0, rolledBack: 0, missing: 0 }
1275
+ await reconcileInsideLock(Date.now())
1276
+ return await recoverInsideLock()
1277
+ })
1278
+ } catch (error) {
1279
+ if (error instanceof TrashError && error.code === 'LOCKED') {
1280
+ logger.warn('trash recovery skipped because another instance holds the trash lock')
1281
+ return { changed: 0, completed: 0, rolledBack: 0, missing: 0 }
1282
+ }
1283
+ throw error
1284
+ }
1285
+ if (summary.changed > 0) {
1286
+ logger.info(
1287
+ `trash recovery reconciled ${summary.changed} entries (${summary.completed} completed, ${summary.rolledBack} rolled back, ${summary.missing} missing)`,
1288
+ )
1289
+ }
1290
+ return summary
1291
+ }
1292
+
1293
+ return { trash, restore, recover, purge, purgeAll, forget, sweep, rebuild, reveal }
1294
+ }