dsh-conversation-recycle-bin 0.3.12 → 0.3.13

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.
@@ -1,658 +1,682 @@
1
- /**
2
- * Conversation deletion cascades.
3
- *
4
- * Order is fixed and documented in README:
5
- *
6
- * memory → messages → session/catalog
7
- *
8
- * Why exactly this order: memory is the only stage that can be restored from a
9
- * portable bundle; the message log is the session payload itself (DSH stores a
10
- * conversation's messages as events inside its append-only session log), and
11
- * the catalog stage removes the durable workspace/archive bookkeeping last, so
12
- * an interruption before it leaves the conversation recoverable in the normal
13
- * list. A failure at ANY stage rolls the completed stages back in reverse order
14
- * (see `transaction.js`).
15
- *
16
- * `soft` mode intentionally does not move the session log: it archives the
17
- * conversation through the Host's durable archive set, which removes it from
18
- * the sidebar while keeping every message on disk until an explicit purge.
19
- * `hard` mode moves the log directory into the plugin's recycle bin, so even a
20
- * hard delete remains recoverable until the entry is purged.
21
- *
22
- * All effects are injected (`ops`) so the pure cascade logic is unit-testable
23
- * against fakes, while the Host file (`lib/index.js`) supplies the real DSH
24
- * service adapters.
25
- */
26
- import { DeleteError } from './errors.js'
27
- import { runSteps } from './transaction.js'
28
- import { join } from 'node:path'
29
-
30
- function toDeleteError(result, context) {
31
- const cause = result.error instanceof Error ? result.error.message : String(result.error)
32
- return new DeleteError(
33
- 'E_TRANSACTION_FAILED',
34
- result.rolledBack
35
- ? `Deleting “${context.title ?? context.sessionId}” failed at the ${result.failedStep} step; all completed steps were rolled back.`
36
- : `Deleting “${context.title ?? context.sessionId}” failed at the ${result.failedStep} step and the rollback was incomplete. Inspect the audit trail.`,
37
- {
38
- sessionId: context.sessionId,
39
- failedStep: result.failedStep,
40
- rolledBack: result.rolledBack,
41
- rollbackErrors: result.rollbackErrors.map((item) => ({
42
- step: item.step,
43
- message: item.error instanceof Error ? item.error.message : String(item.error),
44
- })),
45
- cause,
46
- },
47
- { cause: result.error },
48
- )
49
- }
50
-
51
- function emptyMemorySummary() {
52
- return { adapters: [], total: 0, protected: 0, skipped: [] }
53
- }
54
-
55
- /**
56
- * Delete ONE conversation transactionally.
57
- *
58
- * @param {object} input
59
- * @param {{ id: string, title?: string, cwd?: string, blank?: boolean, archived?: boolean, workspaceId?: string | null }} input.session
60
- * @param {'soft'|'hard'} input.mode
61
- * @param {boolean} input.includeMemory
62
- * @param {object} input.ops `{ memory, sessionLog, catalog }`
63
- * @param {object} input.trash trash store (`core/trash.js`)
64
- * @param {object} input.audit audit log (`core/audit.js`)
65
- * @param {object} input.config normalized config
66
- * @param {() => number} [input.now]
67
- * @param {string} [input.actor]
68
- * @param {string} [input.requestId]
69
- * @returns {Promise<{ entry: object, memory: object, durationMs: number }>}
70
- */
71
- export async function performSessionDelete({
72
- session,
73
- mode,
74
- includeMemory,
75
- ops,
76
- trash,
77
- audit,
78
- config,
79
- now = Date.now,
80
- actor = 'local-user',
81
- source = 'web-ui',
82
- requestId = null,
83
- }) {
84
- const startedAt = now()
85
- const { trashId, dir } = await trash.allocate({ sessionId: session.id })
86
-
87
- /** @type {object} */
88
- const entry = {
89
- format: 1,
90
- trashId,
91
- sessionId: session.id,
92
- title: session.title ?? '',
93
- cwd: session.cwd ?? null,
94
- workspaceId: session.workspaceId ?? null,
95
- mode,
96
- includeMemory: includeMemory === true,
97
- deletedAt: startedAt,
98
- updatedAt: startedAt,
99
- actor,
100
- requestId,
101
- status: 'pending',
102
- sessionLog: null,
103
- catalog: null,
104
- memory: emptyMemorySummary(),
105
- error: null,
106
- }
107
- await trash.write(entry)
108
-
109
- const memoryEnabled = includeMemory === true && config.memory.enabled && config.memory.deleteMode === 'trash'
110
- const steps = []
111
-
112
- // A live Agent keeps a session log writer attached; remove its log only after
113
- // the session has been stopped and its in-flight turn has drained. This step
114
- // is a no-op for cold sessions, so the common path is unchanged.
115
- if (mode === 'hard' && session.live === true) {
116
- steps.push({
117
- name: 'quiesce',
118
- run: async () => {
119
- if (typeof ops.lifecycle?.quiesce !== 'function') {
120
- throw new DeleteError('E_INTERNAL', 'The live-session lifecycle service is unavailable.', { sessionId: session.id })
121
- }
122
- return ops.lifecycle.quiesce(session)
123
- },
124
- })
125
- }
126
-
127
- if (memoryEnabled) {
128
- steps.push({
129
- name: 'memory',
130
- run: async () => {
131
- const bundle = await ops.memory.exportForSession(session.id, dir)
132
- try {
133
- // Persist the summary immediately. The bundle files are already on
134
- // disk at this point; without this write a crash before the final
135
- // manifest left a `pending` entry whose manifest claimed
136
- // `memory.total: 0`, and restore/purge/discard then all refused it —
137
- // the memory stayed stranded in the entry directory forever.
138
- await trash.update(entry.trashId, { memory: bundle })
139
- } catch (error) {
140
- await ops.memory.restoreEntry(bundle).catch(() => {})
141
- throw error
142
- }
143
- entry.memory = bundle
144
- return async () => {
145
- await ops.memory.restoreEntry(bundle)
146
- entry.memory = emptyMemorySummary()
147
- await trash.update(entry.trashId, { memory: emptyMemorySummary() }).catch(() => {})
148
- }
149
- },
150
- })
151
- }
152
-
153
- if (mode === 'hard') {
154
- steps.push({
155
- name: 'messages',
156
- run: async () => {
157
- const moved = await ops.sessionLog.moveToTrash(session, dir)
158
- try {
159
- // Record the move immediately. A crash between the move and the
160
- // final manifest write used to leave a `pending` entry that knew
161
- // nothing about the log sitting in its directory: discard refused
162
- // it, restore had nothing to restore, and the payload was stranded.
163
- await trash.update(entry.trashId, { sessionLog: moved })
164
- } catch (error) {
165
- await ops.sessionLog.restoreEntry(moved).catch(() => {})
166
- throw error
167
- }
168
- entry.sessionLog = moved
169
- return async () => {
170
- await ops.sessionLog.restoreEntry(moved)
171
- entry.sessionLog = null
172
- await trash.update(entry.trashId, { sessionLog: null }).catch(() => {})
173
- }
174
- },
175
- })
176
- }
177
-
178
- steps.push({
179
- name: 'session',
180
- run: async () => {
181
- const snapshot = await ops.catalog.capture(session)
182
- entry.catalog = snapshot
183
- const undo = await ops.catalog.remove(session, mode, snapshot)
184
- return async () => {
185
- await undo()
186
- entry.catalog = null
187
- }
188
- },
189
- })
190
-
191
- steps.push({
192
- name: 'manifest',
193
- run: async () => {
194
- await trash.write({ ...entry, status: 'trashed', updatedAt: now() })
195
- return async () => {
196
- await trash.write({ ...entry, status: 'pending', updatedAt: now() })
197
- }
198
- },
199
- })
200
-
201
- steps.push({
202
- name: 'audit',
203
- run: async () => {
204
- await audit.append({
205
- id: `${trashId}:delete`,
206
- action: 'delete',
207
- result: 'success',
208
- sessionId: session.id,
209
- title: entry.title,
210
- mode,
211
- includeMemory: entry.includeMemory,
212
- memory: { total: entry.memory.total, adapters: entry.memory.adapters.map((adapter) => ({ id: adapter.id, count: adapter.count })) },
213
- trashId,
214
- actor,
215
- requestId,
216
- durationMs: now() - startedAt,
217
- source,
218
- })
219
- },
220
- })
221
-
222
- const result = await runSteps(steps)
223
- if (!result.ok) {
224
- entry.status = 'failed'
225
- entry.error = {
226
- failedStep: result.failedStep,
227
- cause: result.error instanceof Error ? result.error.message : String(result.error),
228
- rolledBack: result.rolledBack,
229
- }
230
- // Best-effort bookkeeping; the original failure is the one reported.
231
- await trash.write({ ...entry, updatedAt: now() }).catch(() => {})
232
- await audit
233
- .append({
234
- id: `${trashId}:delete:failed`,
235
- action: 'delete',
236
- result: result.rolledBack ? 'rolled-back' : 'rollback-incomplete',
237
- sessionId: session.id,
238
- title: entry.title,
239
- mode,
240
- includeMemory: entry.includeMemory,
241
- trashId,
242
- actor,
243
- requestId,
244
- failedStep: result.failedStep,
245
- rollback: {
246
- ok: result.rolledBack,
247
- errors: result.rollbackErrors.map((item) => ({
248
- step: item.step,
249
- message: item.error instanceof Error ? item.error.message : String(item.error),
250
- })),
251
- },
252
- durationMs: now() - startedAt,
253
- source,
254
- })
255
- .catch(() => {})
256
- throw toDeleteError(result, { sessionId: session.id, title: entry.title })
257
- }
258
-
259
- return { entry, memory: entry.memory, durationMs: now() - startedAt }
260
- }
261
-
262
- /**
263
- * Statuses a restore may start from.
264
- *
265
- * `trashed` is the normal recycle-bin case. The others are the crash/rollback
266
- * leftovers that `GET /trash` reports as `incomplete`: an entry whose manifest
267
- * still points at moved payload (a log, a catalog snapshot, a memory bundle) is
268
- * exactly what a restore is for, and refusing it left the "restore or purge it
269
- * instead of discarding it" advice from `performDiscard` impossible to follow.
270
- */
271
- const RESTORABLE_STATUSES = new Set(['trashed', 'pending', 'failed', 'restoring', 'purging'])
272
-
273
- /** Statuses a purge may start from (`restored` = an interrupted restore). */
274
- const PURGEABLE_STATUSES = new Set(['trashed', 'restored'])
275
-
276
- /**
277
- * The single answer to "what may be done with this entry".
278
- *
279
- * The UI used to decide from `status !== 'trashed'` while restore/purge/discard
280
- * each re-derived their own predicate, which is how a row could be rendered
281
- * with a button the route then refused. Status-only questions live here; the
282
- * disk-dependent half (`canDiscard` while payload is still present) stays with
283
- * the caller, which is the only side that can stat the entry directory.
284
- *
285
- * @param {{ status?: unknown }} entry
286
- * @param {{ hasLogFiles?: boolean|null, hasMemoryFiles?: boolean|null }} [disk]
287
- */
288
- export function entryCapabilities(entry, disk = {}) {
289
- const status = typeof entry?.status === 'string' && entry.status !== '' ? entry.status : 'unknown'
290
- const hasLogFiles = disk.hasLogFiles ?? null
291
- const hasMemoryFiles = disk.hasMemoryFiles ?? null
292
- return {
293
- status,
294
- canRestore: RESTORABLE_STATUSES.has(status),
295
- canPurge: PURGEABLE_STATUSES.has(status),
296
- // Discard is the escape hatch for entries that never committed: it is
297
- // refused while a payload copy still exists, because deleting the folder
298
- // would destroy the only copy of a log or a memory bundle.
299
- canDiscard: status !== 'trashed' && hasLogFiles !== true && hasMemoryFiles !== true,
300
- }
301
- }
302
-
303
- /**
304
- * Restore ONE recycle-bin entry.
305
- *
306
- * Restore order is the reverse of delete (payload first, bookkeeping second,
307
- * memory last), because the workspace entity validates that the session it
308
- * re-attaches still exists. Only the steps whose payload the manifest still
309
- * records are run, so a partially rolled-back entry restores what it can.
310
- *
311
- * @param {object} input
312
- * @param {object} input.entry manifest returned by the trash store
313
- * @param {object} input.ops
314
- * @param {object} input.trash
315
- * @param {object} input.audit
316
- * @param {() => number} [input.now]
317
- * @param {string} [input.actor]
318
- * @param {string} [input.requestId]
319
- */
320
- export async function performRestore({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
321
- const previousStatus = entry.status
322
- if (!entryCapabilities(entry).canRestore) {
323
- throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry is not in a restorable state.', { trashId: entry.trashId, status: previousStatus })
324
- }
325
- const sessionLog = entry.sessionLog !== null && entry.sessionLog !== undefined && typeof entry.sessionLog === 'object'
326
- ? entry.sessionLog
327
- : null
328
- const catalog = entry.catalog !== null && entry.catalog !== undefined && typeof entry.catalog === 'object' ? entry.catalog : null
329
- let memorySummary = entry.memory !== null && entry.memory !== undefined && typeof entry.memory === 'object' ? entry.memory : null
330
- let hasMemory = entry.includeMemory === true && memorySummary !== null && (memorySummary.total ?? 0) > 0
331
- // A delete that crashed between "memory bundle exported" and "manifest
332
- // written" leaves the bundles on disk while the manifest still says
333
- // `total: 0`. Rebuild the summary from the directory instead of claiming
334
- // there is nothing to restore (which made the entry impossible to finish).
335
- if (!hasMemory && entry.includeMemory === true && typeof ops.memory?.readExported === 'function') {
336
- const entryDir = typeof trash?.entryDir === 'function' ? trash.entryDir(entry.trashId) : null
337
- if (typeof entryDir === 'string') {
338
- const recovered = await ops.memory.readExported(entryDir).catch(() => null)
339
- if (recovered !== null && (recovered.total ?? 0) > 0) {
340
- memorySummary = recovered
341
- hasMemory = true
342
- }
343
- }
344
- }
345
- const canStat = typeof ops.pathExists === 'function'
346
- // Manifest paths are untrusted input: `restore` moves `from` → `to`, so a
347
- // hand-edited manifest could otherwise make this plugin a confused proxy
348
- // that stats and moves arbitrary directories. Both sides must stay inside
349
- // their own storage roots.
350
- if (sessionLog !== null) {
351
- // A caller that cannot answer "is this inside our own storage?" gets a
352
- // refusal, not the benefit of the doubt: the default used to be `true`,
353
- // which made the whole guard optional.
354
- const fromOk = typeof ops.isInsideSessionsRoot === 'function'
355
- ? ops.isInsideSessionsRoot(sessionLog.from) === true
356
- : false
357
- const toOk = typeof ops.isInsideTrashDir === 'function'
358
- ? ops.isInsideTrashDir(sessionLog.to) === true
359
- : false
360
- if (!fromOk || !toOk) {
361
- throw new DeleteError(
362
- 'E_TRASH_CONFLICT',
363
- 'This recycle-bin entry points outside its own storage; restore is unsafe.',
364
- { trashId: entry.trashId, fromInsideSessions: fromOk, toInsideTrash: toOk },
365
- )
366
- }
367
- }
368
- const logInTrash = sessionLog !== null && (!canStat || await ops.pathExists(sessionLog.to))
369
- const logInPlace = sessionLog !== null && canStat && await ops.pathExists(sessionLog.from)
370
- const logMissing = sessionLog !== null && !logInTrash && !logInPlace
371
- // Re-attaching a hard-deleted conversation whose log is nowhere would leave a
372
- // conversation with no messages on disk, so that combination stays refused.
373
- // A log that is already back in place is fine: an interrupted purge can leave
374
- // exactly that state.
375
- if (catalog !== null && entry.mode === 'hard' && (sessionLog === null || logMissing)) {
376
- throw new DeleteError('E_TRASH_CONFLICT', 'The recycle-bin entry has no session-log location; restore is unsafe.', { trashId: entry.trashId })
377
- }
378
-
379
- const steps = []
380
- if (entry.mode === 'hard' && logInTrash) {
381
- steps.push({
382
- name: 'messages',
383
- run: async () => {
384
- const undo = await ops.sessionLog.restoreEntry(sessionLog)
385
- return async () => {
386
- await undo()
387
- }
388
- },
389
- })
390
- }
391
- if (catalog !== null) {
392
- steps.push({
393
- name: 'session',
394
- run: async () => {
395
- const undo = await ops.catalog.restore(catalog)
396
- return async () => {
397
- await undo()
398
- }
399
- },
400
- })
401
- }
402
- if (hasMemory) {
403
- steps.push({
404
- name: 'memory',
405
- run: async () => {
406
- // `restoreEntry` is a historical API that does not produce an undo;
407
- // calling an undefined undo during a later rollback used to throw.
408
- const undo = await ops.memory.restoreEntry(memorySummary)
409
- return async () => {
410
- if (typeof undo === 'function') await undo()
411
- }
412
- },
413
- })
414
- }
415
- if (steps.length === 0) {
416
- // The log is already back on disk (an interrupted purge can leave the
417
- // manifest pointing at a log that was moved back): clearing the entry is
418
- // the only remaining action, and it is safe.
419
- if (!(entry.mode === 'hard' && logInPlace)) {
420
- throw new DeleteError('E_TRASH_CONFLICT', 'The recycle-bin entry has nothing left to restore; discard it to reclaim the space.', {
421
- trashId: entry.trashId,
422
- status: previousStatus,
423
- })
424
- }
425
- }
426
-
427
- const startedAt = now()
428
- await trash.update(entry.trashId, { status: 'restoring' })
429
-
430
- const result = await runSteps(steps)
431
- if (!result.ok) {
432
- // Back to the status it came from: a failed restore of an incomplete entry
433
- // must not promote it to a restorable `trashed` recycle-bin row.
434
- await trash.update(entry.trashId, { status: previousStatus, updatedAt: now() }).catch(() => {})
435
- await audit
436
- .append({
437
- id: `${entry.trashId}:restore:failed`,
438
- action: 'restore',
439
- result: result.rolledBack ? 'rolled-back' : 'rollback-incomplete',
440
- sessionId: entry.sessionId,
441
- title: entry.title,
442
- trashId: entry.trashId,
443
- actor,
444
- requestId,
445
- failedStep: result.failedStep,
446
- rollback: { ok: result.rolledBack, errors: result.rollbackErrors.map((item) => ({ step: item.step, message: String(item.error) })) },
447
- source,
448
- })
449
- .catch(() => {})
450
- throw toDeleteError(result, { sessionId: entry.sessionId, title: entry.title })
451
- }
452
-
453
- await audit.append({
454
- id: `${entry.trashId}:restore`,
455
- action: 'restore',
456
- result: 'success',
457
- sessionId: entry.sessionId,
458
- title: entry.title,
459
- trashId: entry.trashId,
460
- actor,
461
- requestId,
462
- durationMs: now() - startedAt,
463
- source,
464
- })
465
- // Only clear the entry directory once its memory bundles are actually gone.
466
- // `restoreEntry` removes the bundles it consumed; if memory files are still
467
- // there (an adapter was uninstalled, a bundle was unreadable) removing the
468
- // directory here would silently destroy the only remaining copy.
469
- const memoryFilesLeft = typeof ops.hasFiles === 'function' && typeof trash?.entryDir === 'function'
470
- ? await ops.hasFiles(join(trash.entryDir(entry.trashId), 'memory')).catch(() => true)
471
- : false
472
- if (!memoryFilesLeft) await trash.remove(entry.trashId).catch(() => {})
473
- return { trashId: entry.trashId, sessionId: entry.sessionId, memoryLeftOnDisk: memoryFilesLeft }
474
- }
475
-
476
- /**
477
- * Permanently delete ONE recycle-bin entry.
478
- *
479
- * A soft-deleted entry still owns a live (archived) session, so purging it
480
- * first stops that session (cancel + archive `stopActivity` + bounded drain)
481
- * and then performs the missing hard stages — unless the session is already
482
- * gone, in which case only the bundle remains to be removed.
483
- *
484
- * @param {object} input
485
- * @param {object} input.entry
486
- * @param {object} input.ops
487
- * @param {object} input.trash
488
- * @param {object} input.audit
489
- * @param {() => number} [input.now]
490
- * @param {string} [input.actor]
491
- * @param {string} [input.requestId]
492
- */
493
- export async function performPurge({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
494
- if (!entryCapabilities(entry).canPurge) {
495
- throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry cannot be purged in its current state.', { trashId: entry.trashId, status: entry.status })
496
- }
497
- const startedAt = now()
498
- await trash.update(entry.trashId, { status: 'purging' })
499
- const steps = []
500
-
501
- if (entry.mode === 'soft') {
502
- const session = await ops.session.inspect(entry.sessionId)
503
- if (session !== null) {
504
- if (session.live === true) {
505
- steps.push({
506
- name: 'quiesce',
507
- run: async () => {
508
- if (typeof ops.lifecycle?.quiesce !== 'function') {
509
- throw new DeleteError('E_INTERNAL', 'The live-session lifecycle service is unavailable.', { sessionId: session.id })
510
- }
511
- return ops.lifecycle.quiesce(session)
512
- },
513
- })
514
- }
515
- steps.push({
516
- name: 'messages',
517
- run: async () => {
518
- const moved = await ops.sessionLog.moveToTrash(session, trash.entryDir(entry.trashId))
519
- try {
520
- await trash.update(entry.trashId, { sessionLog: moved })
521
- } catch (error) {
522
- await ops.sessionLog.restoreEntry(moved).catch(() => {})
523
- throw error
524
- }
525
- entry.sessionLog = moved
526
- return async () => {
527
- await ops.sessionLog.restoreEntry(moved)
528
- entry.sessionLog = null
529
- await trash.update(entry.trashId, { sessionLog: null }).catch(() => {})
530
- }
531
- },
532
- })
533
- steps.push({
534
- name: 'session',
535
- run: async () => {
536
- const snapshot = await ops.catalog.capture(session)
537
- const undo = await ops.catalog.remove(session, 'hard', snapshot)
538
- return async () => {
539
- await undo()
540
- }
541
- },
542
- })
543
- }
544
- }
545
-
546
- steps.push({
547
- name: 'purge',
548
- run: async () => {
549
- await trash.remove(entry.trashId)
550
- },
551
- })
552
-
553
- const result = await runSteps(steps)
554
- if (!result.ok) {
555
- await trash.update(entry.trashId, { status: 'trashed', updatedAt: now() }).catch(() => {})
556
- await audit
557
- .append({
558
- id: `${entry.trashId}:purge:failed`,
559
- action: 'purge',
560
- result: result.rolledBack ? 'rolled-back' : 'rollback-incomplete',
561
- sessionId: entry.sessionId,
562
- trashId: entry.trashId,
563
- // Terminal actions drop the title on purpose (a purged conversation
564
- // should not live on in the audit trail), but the key stays present so
565
- // consumers do not have to special-case `delete` versus `purge`.
566
- title: null,
567
- actor,
568
- requestId,
569
- failedStep: result.failedStep,
570
- rollback: { ok: result.rolledBack, errors: result.rollbackErrors.map((item) => ({ step: item.step, message: String(item.error) })) },
571
- source,
572
- })
573
- .catch(() => {})
574
- throw toDeleteError(result, { sessionId: entry.sessionId, title: entry.title })
575
- }
576
-
577
- await audit.append({
578
- id: `${entry.trashId}:purge`,
579
- action: 'purge',
580
- result: 'success',
581
- sessionId: entry.sessionId,
582
- trashId: entry.trashId,
583
- title: null,
584
- actor,
585
- requestId,
586
- durationMs: now() - startedAt,
587
- source,
588
- })
589
- return { trashId: entry.trashId, sessionId: entry.sessionId }
590
- }
591
-
592
- /**
593
- * Remove one entry that never reached `trashed` (failed, pending, or an
594
- * interrupted restore/purge).
595
- *
596
- * Restore and purge both refuse these statuses, and `trash.list()` hides them,
597
- * so before this the only way out was manually deleting the directory. The
598
- * discard is deliberately conservative: when the entry still contains a moved
599
- * session log, the user is told to restore or purge instead of losing it.
600
- *
601
- * @param {object} input
602
- * @param {object} input.entry
603
- * @param {object} input.ops
604
- * @param {object} input.trash
605
- * @param {object} input.audit
606
- * @param {() => number} [input.now]
607
- * @param {string} [input.actor]
608
- * @param {string} [input.requestId]
609
- */
610
- export async function performDiscard({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
611
- if (entry.status === 'trashed') {
612
- throw new DeleteError('E_TRASH_CONFLICT', 'This entry is a committed deletion; restore or purge it instead of discarding it.', {
613
- trashId: entry.trashId,
614
- status: entry.status,
615
- })
616
- }
617
- // Check the DISK, not only the manifest: a crash can land between a move and
618
- // the manifest write, and the `session/` or `memory/` copy is the only copy.
619
- const entryDir = trash.entryDir(entry.trashId)
620
- const holdsLog = typeof ops.pathExists === 'function'
621
- ? await ops.pathExists(join(entryDir, 'session'))
622
- : entry.sessionLog !== null && entry.sessionLog !== undefined
623
- if (holdsLog) {
624
- throw new DeleteError('E_TRASH_CONFLICT', 'This incomplete entry still holds the conversation log; restore or purge it instead of discarding it.', {
625
- trashId: entry.trashId,
626
- status: entry.status,
627
- })
628
- }
629
- const holdsMemory = typeof ops.hasFiles === 'function' ? await ops.hasFiles(join(entryDir, 'memory')) : false
630
- // The status half of the decision is shared with the UI; the two refusals
631
- // below stay separate because their advice differs (purge vs restore).
632
- const capabilities = entryCapabilities(entry, { hasLogFiles: holdsLog, hasMemoryFiles: holdsMemory })
633
- if (holdsMemory || !capabilities.canDiscard) {
634
- const which = holdsMemory ? 'memory files' : 'payload'
635
- throw new DeleteError('E_TRASH_CONFLICT', `This incomplete entry still holds ${which}; restore or purge it instead of discarding it.`, {
636
- trashId: entry.trashId,
637
- status: entry.status,
638
- })
639
- }
640
- const startedAt = now()
641
- await trash.remove(entry.trashId)
642
- await audit
643
- .append({
644
- id: `${entry.trashId}:discard`,
645
- action: 'discard',
646
- result: 'success',
647
- sessionId: entry.sessionId,
648
- trashId: entry.trashId,
649
- status: entry.status,
650
- title: null,
651
- actor,
652
- requestId,
653
- durationMs: now() - startedAt,
654
- source,
655
- })
656
- .catch(() => {})
657
- return { trashId: entry.trashId, sessionId: entry.sessionId }
658
- }
1
+ /**
2
+ * Conversation deletion cascades.
3
+ *
4
+ * Order is fixed and documented in README:
5
+ *
6
+ * memory → messages → session/catalog
7
+ *
8
+ * Why exactly this order: memory is the only stage that can be restored from a
9
+ * portable bundle; the message log is the session payload itself (DSH stores a
10
+ * conversation's messages as events inside its append-only session log), and
11
+ * the catalog stage removes the durable workspace/archive bookkeeping last, so
12
+ * an interruption before it leaves the conversation recoverable in the normal
13
+ * list. A failure at ANY stage rolls the completed stages back in reverse order
14
+ * (see `transaction.js`).
15
+ *
16
+ * `soft` mode intentionally does not move the session log: it archives the
17
+ * conversation through the Host's durable archive set, which removes it from
18
+ * the sidebar while keeping every message on disk until an explicit purge.
19
+ * `hard` mode moves the log directory into the plugin's recycle bin, so even a
20
+ * hard delete remains recoverable until the entry is purged.
21
+ *
22
+ * All effects are injected (`ops`) so the pure cascade logic is unit-testable
23
+ * against fakes, while the Host file (`lib/index.js`) supplies the real DSH
24
+ * service adapters.
25
+ */
26
+ import { DeleteError } from './errors.js'
27
+ import { runSteps } from './transaction.js'
28
+ import { join } from 'node:path'
29
+
30
+ function toDeleteError(result, context) {
31
+ const cause = result.error instanceof Error ? result.error.message : String(result.error)
32
+ return new DeleteError(
33
+ 'E_TRANSACTION_FAILED',
34
+ result.rolledBack
35
+ ? `Deleting “${context.title ?? context.sessionId}” failed at the ${result.failedStep} step; all completed steps were rolled back.`
36
+ : `Deleting “${context.title ?? context.sessionId}” failed at the ${result.failedStep} step and the rollback was incomplete. Inspect the audit trail.`,
37
+ {
38
+ sessionId: context.sessionId,
39
+ failedStep: result.failedStep,
40
+ rolledBack: result.rolledBack,
41
+ rollbackErrors: result.rollbackErrors.map((item) => ({
42
+ step: item.step,
43
+ message: item.error instanceof Error ? item.error.message : String(item.error),
44
+ })),
45
+ cause,
46
+ },
47
+ { cause: result.error },
48
+ )
49
+ }
50
+
51
+ function emptyMemorySummary() {
52
+ return { adapters: [], total: 0, protected: 0, skipped: [] }
53
+ }
54
+
55
+ /**
56
+ * Delete ONE conversation transactionally.
57
+ *
58
+ * @param {object} input
59
+ * @param {{ id: string, title?: string, cwd?: string, blank?: boolean, archived?: boolean, workspaceId?: string | null }} input.session
60
+ * @param {'soft'|'hard'} input.mode
61
+ * @param {boolean} input.includeMemory
62
+ * @param {object} input.ops `{ memory, sessionLog, catalog }`
63
+ * @param {object} input.trash trash store (`core/trash.js`)
64
+ * @param {object} input.audit audit log (`core/audit.js`)
65
+ * @param {object} input.config normalized config
66
+ * @param {() => number} [input.now]
67
+ * @param {string} [input.actor]
68
+ * @param {string} [input.requestId]
69
+ * @returns {Promise<{ entry: object, memory: object, durationMs: number }>}
70
+ */
71
+ export async function performSessionDelete({
72
+ session,
73
+ mode,
74
+ includeMemory,
75
+ ops,
76
+ trash,
77
+ audit,
78
+ config,
79
+ now = Date.now,
80
+ actor = 'local-user',
81
+ source = 'web-ui',
82
+ requestId = null,
83
+ }) {
84
+ const startedAt = now()
85
+ const { trashId, dir } = await trash.allocate({ sessionId: session.id })
86
+
87
+ /** @type {object} */
88
+ const entry = {
89
+ format: 1,
90
+ trashId,
91
+ sessionId: session.id,
92
+ title: session.title ?? '',
93
+ cwd: session.cwd ?? null,
94
+ workspaceId: session.workspaceId ?? null,
95
+ mode,
96
+ includeMemory: includeMemory === true,
97
+ deletedAt: startedAt,
98
+ updatedAt: startedAt,
99
+ actor,
100
+ requestId,
101
+ status: 'pending',
102
+ sessionLog: null,
103
+ catalog: null,
104
+ memory: emptyMemorySummary(),
105
+ error: null,
106
+ }
107
+ await trash.write(entry)
108
+
109
+ const memoryEnabled = includeMemory === true && config.memory.enabled && config.memory.deleteMode === 'trash'
110
+ const steps = []
111
+
112
+ // A live Agent keeps a session log writer attached; remove its log only after
113
+ // the session has been stopped and its in-flight turn has drained. This step
114
+ // is a no-op for cold sessions, so the common path is unchanged.
115
+ if (mode === 'hard' && session.live === true) {
116
+ steps.push({
117
+ name: 'quiesce',
118
+ run: async () => {
119
+ if (typeof ops.lifecycle?.quiesce !== 'function') {
120
+ throw new DeleteError('E_INTERNAL', 'The live-session lifecycle service is unavailable.', { sessionId: session.id })
121
+ }
122
+ return ops.lifecycle.quiesce(session)
123
+ },
124
+ })
125
+ }
126
+
127
+ if (memoryEnabled) {
128
+ steps.push({
129
+ name: 'memory',
130
+ run: async () => {
131
+ const bundle = await ops.memory.exportForSession(session.id, dir)
132
+ try {
133
+ // Persist the summary immediately. The bundle files are already on
134
+ // disk at this point; without this write a crash before the final
135
+ // manifest left a `pending` entry whose manifest claimed
136
+ // `memory.total: 0`, and restore/purge/discard then all refused it —
137
+ // the memory stayed stranded in the entry directory forever.
138
+ await trash.update(entry.trashId, { memory: bundle })
139
+ } catch (error) {
140
+ await ops.memory.restoreEntry(bundle).catch(() => {})
141
+ throw error
142
+ }
143
+ entry.memory = bundle
144
+ return async () => {
145
+ await ops.memory.restoreEntry(bundle)
146
+ entry.memory = emptyMemorySummary()
147
+ await trash.update(entry.trashId, { memory: emptyMemorySummary() }).catch(() => {})
148
+ }
149
+ },
150
+ })
151
+ }
152
+
153
+ if (mode === 'hard') {
154
+ steps.push({
155
+ name: 'messages',
156
+ run: async () => {
157
+ // Persist the mapping BEFORE the rename. A crash between the move and
158
+ // the final manifest write used to leave a `pending` entry whose
159
+ // `sessionLog` was null while `session/` held the only copy: discard
160
+ // refused it, restore had "nothing" to restore, and the log was
161
+ // stranded. With the mapping recorded first, either crash side is
162
+ // recoverable (log still in place, or log in the bin with a known
163
+ // destination).
164
+ const moved = await ops.sessionLog.moveToTrash(session, dir, {
165
+ beforeMove: async (mapping) => {
166
+ await trash.update(entry.trashId, { sessionLog: mapping })
167
+ entry.sessionLog = mapping
168
+ },
169
+ })
170
+ entry.sessionLog = moved
171
+ return async () => {
172
+ await ops.sessionLog.restoreEntry(moved)
173
+ entry.sessionLog = null
174
+ await trash.update(entry.trashId, { sessionLog: null }).catch(() => {})
175
+ }
176
+ },
177
+ })
178
+ }
179
+
180
+ steps.push({
181
+ name: 'session',
182
+ run: async () => {
183
+ // Capture is read-only; persist it before the first catalog mutation so
184
+ // a crash after a detach can still restore the workspace position.
185
+ const snapshot = await ops.catalog.capture(session)
186
+ entry.catalog = snapshot
187
+ await trash.update(entry.trashId, { catalog: snapshot })
188
+ const undo = await ops.catalog.remove(session, mode, snapshot)
189
+ return async () => {
190
+ await undo()
191
+ entry.catalog = null
192
+ }
193
+ },
194
+ })
195
+
196
+ steps.push({
197
+ name: 'manifest',
198
+ run: async () => {
199
+ await trash.write({ ...entry, status: 'trashed', updatedAt: now() })
200
+ return async () => {
201
+ await trash.write({ ...entry, status: 'pending', updatedAt: now() })
202
+ }
203
+ },
204
+ })
205
+
206
+ steps.push({
207
+ name: 'audit',
208
+ run: async () => {
209
+ await audit.append({
210
+ id: `${trashId}:delete`,
211
+ action: 'delete',
212
+ result: 'success',
213
+ sessionId: session.id,
214
+ title: entry.title,
215
+ mode,
216
+ includeMemory: entry.includeMemory,
217
+ memory: { total: entry.memory.total, adapters: entry.memory.adapters.map((adapter) => ({ id: adapter.id, count: adapter.count })) },
218
+ trashId,
219
+ actor,
220
+ requestId,
221
+ durationMs: now() - startedAt,
222
+ source,
223
+ })
224
+ },
225
+ })
226
+
227
+ const result = await runSteps(steps)
228
+ if (!result.ok) {
229
+ entry.status = 'failed'
230
+ entry.error = {
231
+ failedStep: result.failedStep,
232
+ cause: result.error instanceof Error ? result.error.message : String(result.error),
233
+ rolledBack: result.rolledBack,
234
+ }
235
+ // Best-effort bookkeeping; the original failure is the one reported.
236
+ await trash.write({ ...entry, updatedAt: now() }).catch(() => {})
237
+ await audit
238
+ .append({
239
+ id: `${trashId}:delete:failed`,
240
+ action: 'delete',
241
+ result: result.rolledBack ? 'rolled-back' : 'rollback-incomplete',
242
+ sessionId: session.id,
243
+ title: entry.title,
244
+ mode,
245
+ includeMemory: entry.includeMemory,
246
+ trashId,
247
+ actor,
248
+ requestId,
249
+ failedStep: result.failedStep,
250
+ rollback: {
251
+ ok: result.rolledBack,
252
+ errors: result.rollbackErrors.map((item) => ({
253
+ step: item.step,
254
+ message: item.error instanceof Error ? item.error.message : String(item.error),
255
+ })),
256
+ },
257
+ durationMs: now() - startedAt,
258
+ source,
259
+ })
260
+ .catch(() => {})
261
+ throw toDeleteError(result, { sessionId: session.id, title: entry.title })
262
+ }
263
+
264
+ return { entry, memory: entry.memory, durationMs: now() - startedAt }
265
+ }
266
+
267
+ /**
268
+ * Statuses a restore may start from.
269
+ *
270
+ * `trashed` is the normal recycle-bin case. The others are the crash/rollback
271
+ * leftovers that `GET /trash` reports as `incomplete`: an entry whose manifest
272
+ * still points at moved payload (a log, a catalog snapshot, a memory bundle) is
273
+ * exactly what a restore is for, and refusing it left the "restore or purge it
274
+ * instead of discarding it" advice from `performDiscard` impossible to follow.
275
+ */
276
+ const RESTORABLE_STATUSES = new Set(['trashed', 'pending', 'failed', 'restoring', 'purging'])
277
+
278
+ /** Statuses a purge may start from (`restored` = an interrupted restore). */
279
+ const PURGEABLE_STATUSES = new Set(['trashed', 'restored'])
280
+
281
+ /**
282
+ * The single answer to "what may be done with this entry".
283
+ *
284
+ * The UI used to decide from `status !== 'trashed'` while restore/purge/discard
285
+ * each re-derived their own predicate, which is how a row could be rendered
286
+ * with a button the route then refused. Status-only questions live here; the
287
+ * disk-dependent half (`canDiscard` while payload is still present) stays with
288
+ * the caller, which is the only side that can stat the entry directory.
289
+ *
290
+ * @param {{ status?: unknown }} entry
291
+ * @param {{ hasLogFiles?: boolean|null, hasMemoryFiles?: boolean|null, requirePayload?: boolean }} [disk]
292
+ * `requirePayload` makes `canRestore` false for entry states that have no
293
+ * payload at all (nothing in the manifest and nothing on disk), which is the
294
+ * answer the list endpoint publishes to the Client.
295
+ */
296
+ export function entryCapabilities(entry, disk = {}) {
297
+ const status = typeof entry?.status === 'string' && entry.status !== '' ? entry.status : 'unknown'
298
+ const hasLogFiles = disk.hasLogFiles ?? null
299
+ const hasMemoryFiles = disk.hasMemoryFiles ?? null
300
+ const hasSessionLog = entry?.sessionLog !== null && entry?.sessionLog !== undefined
301
+ const hasCatalog = entry?.catalog !== null && entry?.catalog !== undefined
302
+ const memoryTotal = typeof entry?.memory?.total === 'number' ? entry.memory.total : 0
303
+ // `requirePayload` is set by the list endpoint, which is allowed to stat the
304
+ // entry directory. `performRestore` itself keeps the status-only gate: it can
305
+ // rebuild an unrecorded memory bundle or a moved log from disk, so refusing
306
+ // that state at the door would break the very recovery it implements.
307
+ const hasPayload = hasSessionLog || hasCatalog || memoryTotal > 0 || hasLogFiles === true || hasMemoryFiles === true
308
+ return {
309
+ status,
310
+ canRestore: RESTORABLE_STATUSES.has(status) && (disk.requirePayload !== true || hasPayload),
311
+ canPurge: PURGEABLE_STATUSES.has(status),
312
+ // Discard is the escape hatch for entries that never committed: it is
313
+ // refused while a payload copy still exists, because deleting the folder
314
+ // would destroy the only copy of a log or a memory bundle.
315
+ canDiscard: status !== 'trashed' && hasLogFiles !== true && hasMemoryFiles !== true,
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Restore ONE recycle-bin entry.
321
+ *
322
+ * Restore order is the reverse of delete (payload first, bookkeeping second,
323
+ * memory last), because the workspace entity validates that the session it
324
+ * re-attaches still exists. Only the steps whose payload the manifest still
325
+ * records are run, so a partially rolled-back entry restores what it can.
326
+ *
327
+ * @param {object} input
328
+ * @param {object} input.entry manifest returned by the trash store
329
+ * @param {object} input.ops
330
+ * @param {object} input.trash
331
+ * @param {object} input.audit
332
+ * @param {() => number} [input.now]
333
+ * @param {string} [input.actor]
334
+ * @param {string} [input.requestId]
335
+ */
336
+ export async function performRestore({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
337
+ const previousStatus = entry.status
338
+ if (!entryCapabilities(entry).canRestore) {
339
+ throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry is not in a restorable state.', { trashId: entry.trashId, status: previousStatus })
340
+ }
341
+ let sessionLog = entry.sessionLog !== null && entry.sessionLog !== undefined && typeof entry.sessionLog === 'object'
342
+ ? entry.sessionLog
343
+ : null
344
+ // A crash between the log move and its manifest write leaves the log in
345
+ // `session/` while the manifest still says `null`. Rebuild the original path
346
+ // from the log header so restore can put it back instead of answering
347
+ // "nothing left to restore" (the discard route refuses that folder too, so
348
+ // without this the only copy was stranded).
349
+ if (sessionLog === null && typeof ops.recoverSessionLog === 'function') {
350
+ sessionLog = await ops.recoverSessionLog(entry).catch(() => null)
351
+ }
352
+ const catalog = entry.catalog !== null && entry.catalog !== undefined && typeof entry.catalog === 'object' ? entry.catalog : null
353
+ let memorySummary = entry.memory !== null && entry.memory !== undefined && typeof entry.memory === 'object' ? entry.memory : null
354
+ let hasMemory = entry.includeMemory === true && memorySummary !== null && (memorySummary.total ?? 0) > 0
355
+ // A delete that crashed between "memory bundle exported" and "manifest
356
+ // written" leaves the bundles on disk while the manifest still says
357
+ // `total: 0`. Rebuild the summary from the directory instead of claiming
358
+ // there is nothing to restore (which made the entry impossible to finish).
359
+ if (!hasMemory && entry.includeMemory === true && typeof ops.memory?.readExported === 'function') {
360
+ const entryDir = typeof trash?.entryDir === 'function' ? trash.entryDir(entry.trashId) : null
361
+ if (typeof entryDir === 'string') {
362
+ const recovered = await ops.memory.readExported(entryDir).catch(() => null)
363
+ if (recovered !== null && (recovered.total ?? 0) > 0) {
364
+ memorySummary = recovered
365
+ hasMemory = true
366
+ }
367
+ }
368
+ }
369
+ const canStat = typeof ops.pathExists === 'function'
370
+ // Manifest paths are untrusted input: `restore` moves `from` → `to`, so a
371
+ // hand-edited manifest could otherwise make this plugin a confused proxy
372
+ // that stats and moves arbitrary directories. Both sides must stay inside
373
+ // their own storage roots.
374
+ if (sessionLog !== null) {
375
+ // A caller that cannot answer "is this inside our own storage?" gets a
376
+ // refusal, not the benefit of the doubt: the default used to be `true`,
377
+ // which made the whole guard optional.
378
+ const fromOk = typeof ops.isInsideSessionsRoot === 'function'
379
+ ? ops.isInsideSessionsRoot(sessionLog.from) === true
380
+ : false
381
+ const toOk = typeof ops.isInsideTrashDir === 'function'
382
+ ? ops.isInsideTrashDir(sessionLog.to) === true
383
+ : false
384
+ if (!fromOk || !toOk) {
385
+ throw new DeleteError(
386
+ 'E_TRASH_CONFLICT',
387
+ 'This recycle-bin entry points outside its own storage; restore is unsafe.',
388
+ { trashId: entry.trashId, fromInsideSessions: fromOk, toInsideTrash: toOk },
389
+ )
390
+ }
391
+ }
392
+ const logInTrash = sessionLog !== null && (!canStat || await ops.pathExists(sessionLog.to))
393
+ const logInPlace = sessionLog !== null && canStat && await ops.pathExists(sessionLog.from)
394
+ const logMissing = sessionLog !== null && !logInTrash && !logInPlace
395
+ // Re-attaching a hard-deleted conversation whose log is nowhere would leave a
396
+ // conversation with no messages on disk, so that combination stays refused.
397
+ // A log that is already back in place is fine: an interrupted purge can leave
398
+ // exactly that state.
399
+ if (catalog !== null && entry.mode === 'hard' && (sessionLog === null || logMissing)) {
400
+ throw new DeleteError('E_TRASH_CONFLICT', 'The recycle-bin entry has no session-log location; restore is unsafe.', { trashId: entry.trashId })
401
+ }
402
+
403
+ const steps = []
404
+ if (entry.mode === 'hard' && logInTrash) {
405
+ steps.push({
406
+ name: 'messages',
407
+ run: async () => {
408
+ const undo = await ops.sessionLog.restoreEntry(sessionLog)
409
+ return async () => {
410
+ await undo()
411
+ }
412
+ },
413
+ })
414
+ }
415
+ if (catalog !== null) {
416
+ steps.push({
417
+ name: 'session',
418
+ run: async () => {
419
+ const undo = await ops.catalog.restore(catalog)
420
+ return async () => {
421
+ await undo()
422
+ }
423
+ },
424
+ })
425
+ }
426
+ if (hasMemory) {
427
+ steps.push({
428
+ name: 'memory',
429
+ run: async () => {
430
+ // `restoreEntry` is a historical API that does not produce an undo;
431
+ // calling an undefined undo during a later rollback used to throw.
432
+ const undo = await ops.memory.restoreEntry(memorySummary)
433
+ return async () => {
434
+ if (typeof undo === 'function') await undo()
435
+ }
436
+ },
437
+ })
438
+ }
439
+ if (steps.length === 0) {
440
+ // The log is already back on disk (an interrupted purge can leave the
441
+ // manifest pointing at a log that was moved back): clearing the entry is
442
+ // the only remaining action, and it is safe.
443
+ if (!(entry.mode === 'hard' && logInPlace)) {
444
+ throw new DeleteError('E_TRASH_CONFLICT', 'The recycle-bin entry has nothing left to restore; discard it to reclaim the space.', {
445
+ trashId: entry.trashId,
446
+ status: previousStatus,
447
+ })
448
+ }
449
+ }
450
+
451
+ const startedAt = now()
452
+ await trash.update(entry.trashId, { status: 'restoring' })
453
+
454
+ const result = await runSteps(steps)
455
+ if (!result.ok) {
456
+ // Back to the status it came from: a failed restore of an incomplete entry
457
+ // must not promote it to a restorable `trashed` recycle-bin row.
458
+ await trash.update(entry.trashId, { status: previousStatus, updatedAt: now() }).catch(() => {})
459
+ await audit
460
+ .append({
461
+ id: `${entry.trashId}:restore:failed`,
462
+ action: 'restore',
463
+ result: result.rolledBack ? 'rolled-back' : 'rollback-incomplete',
464
+ sessionId: entry.sessionId,
465
+ title: entry.title,
466
+ trashId: entry.trashId,
467
+ actor,
468
+ requestId,
469
+ failedStep: result.failedStep,
470
+ rollback: { ok: result.rolledBack, errors: result.rollbackErrors.map((item) => ({ step: item.step, message: String(item.error) })) },
471
+ source,
472
+ })
473
+ .catch(() => {})
474
+ throw toDeleteError(result, { sessionId: entry.sessionId, title: entry.title })
475
+ }
476
+
477
+ await audit.append({
478
+ id: `${entry.trashId}:restore`,
479
+ action: 'restore',
480
+ result: 'success',
481
+ sessionId: entry.sessionId,
482
+ title: entry.title,
483
+ trashId: entry.trashId,
484
+ actor,
485
+ requestId,
486
+ durationMs: now() - startedAt,
487
+ source,
488
+ })
489
+ // Only clear the entry directory once its memory bundles are actually gone.
490
+ // `restoreEntry` removes the bundles it consumed; if memory files are still
491
+ // there (an adapter was uninstalled, a bundle was unreadable) removing the
492
+ // directory here would silently destroy the only remaining copy.
493
+ const memoryFilesLeft = typeof ops.hasFiles === 'function' && typeof trash?.entryDir === 'function'
494
+ ? await ops.hasFiles(join(trash.entryDir(entry.trashId), 'memory')).catch(() => true)
495
+ : false
496
+ if (!memoryFilesLeft) await trash.remove(entry.trashId).catch(() => {})
497
+ return { trashId: entry.trashId, sessionId: entry.sessionId, memoryLeftOnDisk: memoryFilesLeft }
498
+ }
499
+
500
+ /**
501
+ * Permanently delete ONE recycle-bin entry.
502
+ *
503
+ * A soft-deleted entry still owns a live (archived) session, so purging it
504
+ * first stops that session (cancel + archive `stopActivity` + bounded drain)
505
+ * and then performs the missing hard stages — unless the session is already
506
+ * gone, in which case only the bundle remains to be removed.
507
+ *
508
+ * @param {object} input
509
+ * @param {object} input.entry
510
+ * @param {object} input.ops
511
+ * @param {object} input.trash
512
+ * @param {object} input.audit
513
+ * @param {() => number} [input.now]
514
+ * @param {string} [input.actor]
515
+ * @param {string} [input.requestId]
516
+ */
517
+ export async function performPurge({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
518
+ if (!entryCapabilities(entry).canPurge) {
519
+ throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry cannot be purged in its current state.', { trashId: entry.trashId, status: entry.status })
520
+ }
521
+ const startedAt = now()
522
+ await trash.update(entry.trashId, { status: 'purging' })
523
+ const steps = []
524
+
525
+ if (entry.mode === 'soft') {
526
+ const session = await ops.session.inspect(entry.sessionId)
527
+ if (session !== null) {
528
+ if (session.live === true) {
529
+ steps.push({
530
+ name: 'quiesce',
531
+ run: async () => {
532
+ if (typeof ops.lifecycle?.quiesce !== 'function') {
533
+ throw new DeleteError('E_INTERNAL', 'The live-session lifecycle service is unavailable.', { sessionId: session.id })
534
+ }
535
+ return ops.lifecycle.quiesce(session)
536
+ },
537
+ })
538
+ }
539
+ steps.push({
540
+ name: 'messages',
541
+ run: async () => {
542
+ const moved = await ops.sessionLog.moveToTrash(session, trash.entryDir(entry.trashId), {
543
+ beforeMove: async (mapping) => {
544
+ await trash.update(entry.trashId, { sessionLog: mapping })
545
+ entry.sessionLog = mapping
546
+ },
547
+ })
548
+ entry.sessionLog = moved
549
+ return async () => {
550
+ await ops.sessionLog.restoreEntry(moved)
551
+ entry.sessionLog = null
552
+ await trash.update(entry.trashId, { sessionLog: null }).catch(() => {})
553
+ }
554
+ },
555
+ })
556
+ steps.push({
557
+ name: 'session',
558
+ run: async () => {
559
+ const snapshot = await ops.catalog.capture(session)
560
+ await trash.update(entry.trashId, { catalog: snapshot }).catch(() => {})
561
+ const undo = await ops.catalog.remove(session, 'hard', snapshot)
562
+ return async () => {
563
+ await undo()
564
+ }
565
+ },
566
+ })
567
+ }
568
+ }
569
+
570
+ steps.push({
571
+ name: 'purge',
572
+ run: async () => {
573
+ await trash.remove(entry.trashId)
574
+ },
575
+ })
576
+
577
+ const result = await runSteps(steps)
578
+ if (!result.ok) {
579
+ await trash.update(entry.trashId, { status: 'trashed', updatedAt: now() }).catch(() => {})
580
+ await audit
581
+ .append({
582
+ id: `${entry.trashId}:purge:failed`,
583
+ action: 'purge',
584
+ result: result.rolledBack ? 'rolled-back' : 'rollback-incomplete',
585
+ sessionId: entry.sessionId,
586
+ trashId: entry.trashId,
587
+ // Terminal actions drop the title on purpose (a purged conversation
588
+ // should not live on in the audit trail), but the key stays present so
589
+ // consumers do not have to special-case `delete` versus `purge`.
590
+ title: null,
591
+ actor,
592
+ requestId,
593
+ failedStep: result.failedStep,
594
+ rollback: { ok: result.rolledBack, errors: result.rollbackErrors.map((item) => ({ step: item.step, message: String(item.error) })) },
595
+ source,
596
+ })
597
+ .catch(() => {})
598
+ throw toDeleteError(result, { sessionId: entry.sessionId, title: entry.title })
599
+ }
600
+
601
+ await audit.append({
602
+ id: `${entry.trashId}:purge`,
603
+ action: 'purge',
604
+ result: 'success',
605
+ sessionId: entry.sessionId,
606
+ trashId: entry.trashId,
607
+ title: null,
608
+ actor,
609
+ requestId,
610
+ durationMs: now() - startedAt,
611
+ source,
612
+ })
613
+ return { trashId: entry.trashId, sessionId: entry.sessionId }
614
+ }
615
+
616
+ /**
617
+ * Remove one entry that never reached `trashed` (failed, pending, or an
618
+ * interrupted restore/purge).
619
+ *
620
+ * Restore and purge both refuse these statuses, and `trash.list()` hides them,
621
+ * so before this the only way out was manually deleting the directory. The
622
+ * discard is deliberately conservative: when the entry still contains a moved
623
+ * session log, the user is told to restore or purge instead of losing it.
624
+ *
625
+ * @param {object} input
626
+ * @param {object} input.entry
627
+ * @param {object} input.ops
628
+ * @param {object} input.trash
629
+ * @param {object} input.audit
630
+ * @param {() => number} [input.now]
631
+ * @param {string} [input.actor]
632
+ * @param {string} [input.requestId]
633
+ */
634
+ export async function performDiscard({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
635
+ if (entry.status === 'trashed') {
636
+ throw new DeleteError('E_TRASH_CONFLICT', 'This entry is a committed deletion; restore or purge it instead of discarding it.', {
637
+ trashId: entry.trashId,
638
+ status: entry.status,
639
+ })
640
+ }
641
+ // Check the DISK, not only the manifest: a crash can land between a move and
642
+ // the manifest write, and the `session/` or `memory/` copy is the only copy.
643
+ const entryDir = trash.entryDir(entry.trashId)
644
+ const holdsLog = typeof ops.pathExists === 'function'
645
+ ? await ops.pathExists(join(entryDir, 'session'))
646
+ : entry.sessionLog !== null && entry.sessionLog !== undefined
647
+ if (holdsLog) {
648
+ throw new DeleteError('E_TRASH_CONFLICT', 'This incomplete entry still holds the conversation log; restore or purge it instead of discarding it.', {
649
+ trashId: entry.trashId,
650
+ status: entry.status,
651
+ })
652
+ }
653
+ const holdsMemory = typeof ops.hasFiles === 'function' ? await ops.hasFiles(join(entryDir, 'memory')) : false
654
+ // The status half of the decision is shared with the UI; the two refusals
655
+ // below stay separate because their advice differs (purge vs restore).
656
+ const capabilities = entryCapabilities(entry, { hasLogFiles: holdsLog, hasMemoryFiles: holdsMemory })
657
+ if (holdsMemory || !capabilities.canDiscard) {
658
+ const which = holdsMemory ? 'memory files' : 'payload'
659
+ throw new DeleteError('E_TRASH_CONFLICT', `This incomplete entry still holds ${which}; restore or purge it instead of discarding it.`, {
660
+ trashId: entry.trashId,
661
+ status: entry.status,
662
+ })
663
+ }
664
+ const startedAt = now()
665
+ await trash.remove(entry.trashId)
666
+ await audit
667
+ .append({
668
+ id: `${entry.trashId}:discard`,
669
+ action: 'discard',
670
+ result: 'success',
671
+ sessionId: entry.sessionId,
672
+ trashId: entry.trashId,
673
+ status: entry.status,
674
+ title: null,
675
+ actor,
676
+ requestId,
677
+ durationMs: now() - startedAt,
678
+ source,
679
+ })
680
+ .catch(() => {})
681
+ return { trashId: entry.trashId, sessionId: entry.sessionId }
682
+ }