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.
- package/CHANGELOG.md +454 -437
- package/README.md +8 -8
- package/client.js +2237 -2221
- package/lib/core/cascade.js +682 -658
- package/lib/host/api.js +1508 -1435
- package/lib/host/catalog.js +9 -2
- package/lib/host/paths.js +136 -1
- package/lib/version.js +8 -8
- package/package.json +81 -81
package/lib/core/cascade.js
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
await
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
})
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const undo = await ops.
|
|
409
|
-
return async () => {
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
},
|
|
413
|
-
})
|
|
414
|
-
}
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
.
|
|
574
|
-
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
await
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
+
}
|