dsh-conversation-recycle-bin 0.3.11 → 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 -412
- package/README.md +8 -8
- package/client.js +75 -38
- package/lib/core/cascade.js +56 -23
- package/lib/core/file-lock.js +43 -8
- package/lib/core/policy.js +126 -119
- package/lib/core/trash.js +245 -227
- package/lib/host/api.js +182 -37
- package/lib/host/catalog.js +9 -2
- package/lib/host/gateway.js +368 -351
- package/lib/host/paths.js +136 -1
- package/lib/version.js +8 -8
- package/package.json +81 -81
package/lib/host/api.js
CHANGED
|
@@ -55,6 +55,7 @@ import { createCatalogOps, createSessionLogOps } from './catalog.js'
|
|
|
55
55
|
import { createLifecycleOps } from './lifecycle.js'
|
|
56
56
|
import { createSessionGateway } from './gateway.js'
|
|
57
57
|
import { createMemoryAdapters } from './memory.js'
|
|
58
|
+
import { encodeSessionSegment, projectKey, readSessionLogCwd } from './paths.js'
|
|
58
59
|
import { createFeedbackService } from './feedback.js'
|
|
59
60
|
import { PLUGIN_VERSION } from '../version.js'
|
|
60
61
|
import { SESSION_ID_PATTERN } from '../core/ids.js'
|
|
@@ -75,6 +76,21 @@ const MAX_BODY_BYTES = 256 * 1024
|
|
|
75
76
|
*/
|
|
76
77
|
export const TRASH_LOCK_BUDGET_MS = 180_000
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Slack added on top of an operation's lock budget before a queued request is
|
|
81
|
+
* rejected. The Host publishes the resulting number (`queueTimeoutMs`) so the
|
|
82
|
+
* Client waits for the Host's answer instead of inventing a shorter deadline of
|
|
83
|
+
* its own — a client that gave up 60 s early used to report a timeout for an
|
|
84
|
+
* operation the Host then completed.
|
|
85
|
+
*/
|
|
86
|
+
export const QUEUE_GRACE_MS = 60_000
|
|
87
|
+
|
|
88
|
+
/** The wait a caller should allow for `lockBudgetMs` plus queueing behind it. */
|
|
89
|
+
export function queueBudgetFor(lockBudgetMs) {
|
|
90
|
+
const budget = Number(lockBudgetMs)
|
|
91
|
+
return (Number.isFinite(budget) && budget > 0 ? budget : 30_000) + QUEUE_GRACE_MS
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
function isLoopbackHostname(hostname) {
|
|
79
95
|
const value = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
80
96
|
return value === 'localhost' || value === '::1' || value === '0:0:0:0:0:0:0:1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)
|
|
@@ -397,6 +413,25 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
397
413
|
return false
|
|
398
414
|
}
|
|
399
415
|
},
|
|
416
|
+
/**
|
|
417
|
+
* Reconstruct the original session-log location of an entry whose move
|
|
418
|
+
* completed but whose manifest did not record it (a crash between
|
|
419
|
+
* `moveDirectory` and `trash.update`). The log header still carries the
|
|
420
|
+
* session's cwd, which is the only durable input needed to rebuild the
|
|
421
|
+
* Host's project-directory path; a missing/unreadable header returns null
|
|
422
|
+
* and the caller keeps refusing instead of guessing.
|
|
423
|
+
*/
|
|
424
|
+
recoverSessionLog: async (entry) => {
|
|
425
|
+
if (typeof entry?.sessionId !== 'string' || !SESSION_ID_PATTERN.test(entry.sessionId)) return null
|
|
426
|
+
if (typeof entry?.trashId !== 'string' || !isTrashId(entry.trashId)) return null
|
|
427
|
+
const sessionDir = join(trash.entryDir(entry.trashId), 'session')
|
|
428
|
+
if (!(await readdir(sessionDir).catch(() => null))) return null
|
|
429
|
+
const cwd = await readSessionLogCwd(sessionDir)
|
|
430
|
+
if (cwd === null) return null
|
|
431
|
+
const from = join(paths.sessionsRoot, projectKey(cwd), encodeSessionSegment(entry.sessionId))
|
|
432
|
+
if (!isPathInside(paths.sessionsRoot, from) || !isPathInside(paths.trashDir, sessionDir)) return null
|
|
433
|
+
return { sessionId: entry.sessionId, from, to: sessionDir }
|
|
434
|
+
},
|
|
400
435
|
// Manifest paths are data, not truth: a hand-edited manifest must not turn
|
|
401
436
|
// "restore" into a proxy for stat/move on arbitrary directories.
|
|
402
437
|
isInsideSessionsRoot: (target) => typeof target === 'string' && target !== '' && isPathInside(paths.sessionsRoot, target),
|
|
@@ -451,9 +486,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
451
486
|
* finishes, and never waits forever either.
|
|
452
487
|
*/
|
|
453
488
|
function serialize(operation, { lockTimeoutMs, shouldAbort } = {}) {
|
|
454
|
-
const
|
|
455
|
-
const queueBudgetMs = lockTimeoutMs === undefined ? 30_000 + graceMs : lockTimeoutMs + graceMs
|
|
489
|
+
const queueBudgetMs = queueBudgetFor(lockTimeoutMs === undefined ? 30_000 : lockTimeoutMs)
|
|
456
490
|
let abandoned = false
|
|
491
|
+
// Once the operation itself starts, the queue timer stops: from that moment
|
|
492
|
+
// the outcome belongs to the operation, and answering "not applied" would be
|
|
493
|
+
// a lie (the purge/restore may well have happened). The budget therefore
|
|
494
|
+
// covers the wait, which is the only part this function can honestly abort.
|
|
495
|
+
let started = false
|
|
496
|
+
let timer = null
|
|
457
497
|
const aborted = () => typeof shouldAbort === 'function' && shouldAbort() === true
|
|
458
498
|
const next = mutationChain.then(async () => {
|
|
459
499
|
if (abandoned) {
|
|
@@ -479,6 +519,8 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
479
519
|
if (aborted()) {
|
|
480
520
|
throw new DeleteError('E_ABANDONED', 'The client disconnected before this request ran; nothing was changed.', {})
|
|
481
521
|
}
|
|
522
|
+
started = true
|
|
523
|
+
if (timer !== null) clearTimeout(timer)
|
|
482
524
|
return await operation()
|
|
483
525
|
} finally {
|
|
484
526
|
await release()
|
|
@@ -486,7 +528,8 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
486
528
|
})
|
|
487
529
|
mutationChain = next.then(() => undefined, () => undefined)
|
|
488
530
|
return new Promise((resolve, reject) => {
|
|
489
|
-
|
|
531
|
+
timer = setTimeout(() => {
|
|
532
|
+
if (started) return
|
|
490
533
|
abandoned = true
|
|
491
534
|
reject(
|
|
492
535
|
new DeleteError(
|
|
@@ -688,6 +731,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
688
731
|
trashRetentionDays: config.trashRetentionDays,
|
|
689
732
|
confirmTokenTtlMs: config.confirmTokenTtlMs,
|
|
690
733
|
planTimeoutMs,
|
|
734
|
+
// How long the Client should wait for the answer: the plan's own lock
|
|
735
|
+
// budget plus the queueing grace. The Client used to derive a shorter
|
|
736
|
+
// number and give up 60 s before the Host finished.
|
|
737
|
+
queueTimeoutMs: queueBudgetFor(planTimeoutMs),
|
|
691
738
|
},
|
|
692
739
|
}
|
|
693
740
|
}
|
|
@@ -782,7 +829,6 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
782
829
|
failures.push({ sessionId: session.id, error: rateError.toJSON() })
|
|
783
830
|
continue
|
|
784
831
|
}
|
|
785
|
-
if (!validated.record.used) markConfirmationUsed(validated)
|
|
786
832
|
try {
|
|
787
833
|
const outcome = await performSessionDelete({
|
|
788
834
|
session,
|
|
@@ -817,6 +863,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
817
863
|
gateway.invalidate()
|
|
818
864
|
invalidateTrashCache()
|
|
819
865
|
|
|
866
|
+
// The token is burned only once the whole plan went through. It used to be
|
|
867
|
+
// marked used before the first item, so a batch that failed halfway left the
|
|
868
|
+
// user holding a dead token: the remaining sessions could not be retried
|
|
869
|
+
// without previewing again and retyping the confirmation phrase. The token
|
|
870
|
+
// is bound to (ids, mode, includeMemory), so a retry can only replay this
|
|
871
|
+
// same plan — already-deleted sessions answer E_NOT_FOUND, never a second
|
|
872
|
+
// deletion.
|
|
873
|
+
if (failures.length === 0) markConfirmationUsed(validated)
|
|
820
874
|
if (results.length === 0 && failures.length > 0) {
|
|
821
875
|
const first = failures[0].error
|
|
822
876
|
throw new DeleteError(first.code, first.message, { ...(first.details ?? {}), failures })
|
|
@@ -902,6 +956,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
902
956
|
return {
|
|
903
957
|
enabled: config.enabled,
|
|
904
958
|
version: PLUGIN_VERSION,
|
|
959
|
+
// The Client cannot tell a broken deployment from a broken permission
|
|
960
|
+
// otherwise: with `requireConnection` on and no service mounted, every
|
|
961
|
+
// route answers 401 and the panel just looked empty.
|
|
962
|
+
connectionFence: connectionFencePresent(),
|
|
905
963
|
defaultMode: config.defaultMode,
|
|
906
964
|
memory: {
|
|
907
965
|
enabled: config.memory.enabled,
|
|
@@ -915,6 +973,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
915
973
|
rate: limiter.status(),
|
|
916
974
|
trashRetentionDays: config.trashRetentionDays,
|
|
917
975
|
confirmTokenTtlMs: config.confirmTokenTtlMs,
|
|
976
|
+
// Trash mutations take the fixed trash budget; the Client waits for it
|
|
977
|
+
// instead of using its generic request timeout.
|
|
978
|
+
queueTimeoutMs: queueBudgetFor(TRASH_LOCK_BUDGET_MS),
|
|
918
979
|
},
|
|
919
980
|
ui: config.ui,
|
|
920
981
|
feedback: feedbackService.status(),
|
|
@@ -953,13 +1014,45 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
953
1014
|
})
|
|
954
1015
|
}
|
|
955
1016
|
|
|
956
|
-
|
|
1017
|
+
/** Ordered map with at most `limit` filesystem probes in flight. */
|
|
1018
|
+
async function mapLimit(items, limit, fn) {
|
|
1019
|
+
const out = new Array(items.length)
|
|
1020
|
+
let next = 0
|
|
1021
|
+
const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
|
|
1022
|
+
while (true) {
|
|
1023
|
+
const index = next
|
|
1024
|
+
next += 1
|
|
1025
|
+
if (index >= items.length) return
|
|
1026
|
+
out[index] = await fn(items[index], index)
|
|
1027
|
+
}
|
|
1028
|
+
})
|
|
1029
|
+
await Promise.all(workers)
|
|
1030
|
+
return out
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Disk payload of one entry, for the capability flags the Client renders. */
|
|
1034
|
+
async function entryDisk(trashId) {
|
|
1035
|
+
if (!isTrashId(trashId)) return { hasLogFiles: false, hasMemoryFiles: false }
|
|
1036
|
+
let dir
|
|
1037
|
+
try {
|
|
1038
|
+
dir = trash.entryDir(trashId)
|
|
1039
|
+
} catch {
|
|
1040
|
+
return { hasLogFiles: false, hasMemoryFiles: false }
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
hasLogFiles: await ops.pathExists(join(dir, 'session')),
|
|
1044
|
+
hasMemoryFiles: await ops.hasFiles(join(dir, 'memory')),
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function serializeEntry(entry) {
|
|
957
1049
|
const retentionDays = config.trashRetentionDays
|
|
958
1050
|
// The Client must not recompute the window from `deletedAt`: a browser clock
|
|
959
1051
|
// that is a day off would shift the promise the Host actually keeps. The
|
|
960
1052
|
// countdown is therefore decided here, once, by the same predicate the sweep
|
|
961
1053
|
// uses (`daysUntilExpiry`), and `expiresAt` carries the absolute deadline.
|
|
962
1054
|
const daysLeft = daysUntilExpiry(entry, { retentionDays, now: now() })
|
|
1055
|
+
const disk = await entryDisk(entry.trashId)
|
|
963
1056
|
return {
|
|
964
1057
|
trashId: entry.trashId,
|
|
965
1058
|
sessionId: entry.sessionId,
|
|
@@ -974,13 +1067,38 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
974
1067
|
memoryAdapters: Array.isArray(entry.memory?.adapters) ? entry.memory.adapters : [],
|
|
975
1068
|
hasSessionLog: entry.sessionLog !== null && entry.sessionLog !== undefined,
|
|
976
1069
|
status: entry.status,
|
|
977
|
-
// The UI renders buttons from the same predicate the routes enforce
|
|
978
|
-
//
|
|
979
|
-
|
|
1070
|
+
// The UI renders buttons from the same predicate the routes enforce. Disk
|
|
1071
|
+
// payload is part of that predicate: a manifest can be empty while the
|
|
1072
|
+
// moved log/memory copy is still on disk, and a status-only answer
|
|
1073
|
+
// advertised actions the routes refused.
|
|
1074
|
+
capabilities: entryCapabilities(entry, { ...disk, requirePayload: true }),
|
|
980
1075
|
error: entry.error ?? null,
|
|
981
1076
|
}
|
|
982
1077
|
}
|
|
983
1078
|
|
|
1079
|
+
/** A folder whose manifest cannot be read still has a reliable disk payload. */
|
|
1080
|
+
async function serializeUnreadable(item) {
|
|
1081
|
+
const disk = await entryDisk(item.trashId)
|
|
1082
|
+
return {
|
|
1083
|
+
trashId: item.trashId,
|
|
1084
|
+
sessionId: null,
|
|
1085
|
+
title: null,
|
|
1086
|
+
mode: null,
|
|
1087
|
+
includeMemory: false,
|
|
1088
|
+
deletedAt: 0,
|
|
1089
|
+
updatedAt: 0,
|
|
1090
|
+
memory: 0,
|
|
1091
|
+
memoryAdapters: [],
|
|
1092
|
+
hasSessionLog: false,
|
|
1093
|
+
status: 'unreadable',
|
|
1094
|
+
capabilities: entryCapabilities({ status: 'unreadable' }, disk),
|
|
1095
|
+
error: {
|
|
1096
|
+
code: item.reason,
|
|
1097
|
+
message: 'The manifest could not be read; discard this folder only after confirming it holds no log or memory files.',
|
|
1098
|
+
},
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
984
1102
|
async function trashList() {
|
|
985
1103
|
// The retention sweep is kicked off, not awaited: it needs the mutation lock
|
|
986
1104
|
// (a purge in flight can hold it for minutes), and a GET must never block on
|
|
@@ -994,28 +1112,16 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
994
1112
|
// is missing/corrupt are surfaced as `unreadable` so they are not stranded
|
|
995
1113
|
// on disk; the Client renders both groups separately.
|
|
996
1114
|
const { entries: all, unreadable } = await trash.scanAll()
|
|
1115
|
+
const trashed = all.filter((entry) => entry.status === 'trashed')
|
|
1116
|
+
const incompleteEntries = all.filter((entry) => entry.status !== 'trashed')
|
|
1117
|
+
const [entries, incomplete, unreadableRows] = await Promise.all([
|
|
1118
|
+
mapLimit(trashed, 8, serializeEntry),
|
|
1119
|
+
mapLimit(incompleteEntries, 8, serializeEntry),
|
|
1120
|
+
mapLimit(unreadable, 8, serializeUnreadable),
|
|
1121
|
+
])
|
|
997
1122
|
const result = {
|
|
998
|
-
entries
|
|
999
|
-
incomplete: [
|
|
1000
|
-
...all.filter((entry) => entry.status !== 'trashed').map(serializeEntry),
|
|
1001
|
-
...unreadable.map((item) => ({
|
|
1002
|
-
trashId: item.trashId,
|
|
1003
|
-
sessionId: null,
|
|
1004
|
-
title: null,
|
|
1005
|
-
mode: null,
|
|
1006
|
-
includeMemory: false,
|
|
1007
|
-
deletedAt: 0,
|
|
1008
|
-
updatedAt: 0,
|
|
1009
|
-
memory: 0,
|
|
1010
|
-
memoryAdapters: [],
|
|
1011
|
-
hasSessionLog: false,
|
|
1012
|
-
status: 'unreadable',
|
|
1013
|
-
error: {
|
|
1014
|
-
code: item.reason,
|
|
1015
|
-
message: 'The manifest could not be read; discard this folder only after confirming it holds no log or memory files.',
|
|
1016
|
-
},
|
|
1017
|
-
})),
|
|
1018
|
-
],
|
|
1123
|
+
entries,
|
|
1124
|
+
incomplete: [...incomplete, ...unreadableRows],
|
|
1019
1125
|
// What the background sweep did last time, so entries that vanished can be
|
|
1020
1126
|
// explained instead of looking like a bug. `null` until the first sweep.
|
|
1021
1127
|
lastSweep: lastRetentionResult,
|
|
@@ -1069,11 +1175,15 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1069
1175
|
for (const candidate of expired) {
|
|
1070
1176
|
const entry = await trash.read(candidate.trashId)
|
|
1071
1177
|
if (entry === null || !entryCapabilities(entry).canPurge) continue
|
|
1072
|
-
// A
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1178
|
+
// A soft entry's log stays on disk, so "the session can still be
|
|
1179
|
+
// inspected" is its NORMAL state and must not block retention. Only a
|
|
1180
|
+
// conversation the user actually revived (unarchived) or one that is
|
|
1181
|
+
// running is left alone: purging it unattended would quiesce and
|
|
1182
|
+
// hard-delete the conversation the user is working in. Retention is a
|
|
1183
|
+
// janitor, not a killer.
|
|
1184
|
+
//
|
|
1185
|
+
// `archived` unknown (older Host shape, unreadable workspace state)
|
|
1186
|
+
// stays conservative: skip and let the user decide.
|
|
1077
1187
|
if (entry.mode === 'soft' && typeof entry.sessionId === 'string' && entry.sessionId !== '') {
|
|
1078
1188
|
let session = null
|
|
1079
1189
|
try {
|
|
@@ -1084,8 +1194,12 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1084
1194
|
session = { unreadable: true }
|
|
1085
1195
|
}
|
|
1086
1196
|
if (session !== null && session !== undefined) {
|
|
1087
|
-
|
|
1088
|
-
|
|
1197
|
+
const revived = session.archived === false
|
|
1198
|
+
const running = session.running === true || session.live === true || session.unreadable === true
|
|
1199
|
+
if (revived || running || session.archived !== true) {
|
|
1200
|
+
failed.push({ trashId: candidate.trashId, code: 'E_SESSION_ALIVE' })
|
|
1201
|
+
continue
|
|
1202
|
+
}
|
|
1089
1203
|
}
|
|
1090
1204
|
}
|
|
1091
1205
|
try {
|
|
@@ -1161,6 +1275,23 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1161
1275
|
}
|
|
1162
1276
|
}
|
|
1163
1277
|
|
|
1278
|
+
/**
|
|
1279
|
+
* True when a real `connection` service is mounted. Probed rather than
|
|
1280
|
+
* injected: a composition without it must still load.
|
|
1281
|
+
*/
|
|
1282
|
+
function connectionFencePresent() {
|
|
1283
|
+
const connection = connectionService()
|
|
1284
|
+
return connection !== undefined && typeof connection.requestRejection === 'function'
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** `/state` (or the bare prefix) as a GET — the only route readable without a connection fence. */
|
|
1288
|
+
function isStateProbe(req) {
|
|
1289
|
+
if (String(req.method ?? 'GET').toUpperCase() !== 'GET') return false
|
|
1290
|
+
const pathname = new URL(req.url ?? '/', 'http://host.invalid').pathname
|
|
1291
|
+
const path = pathname.startsWith(ROUTE_PREFIX) ? pathname.slice(ROUTE_PREFIX.length) : pathname
|
|
1292
|
+
return path === '' || path === '/state'
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1164
1295
|
/** One request → one response. Route matching stays here, not in `apply`. */
|
|
1165
1296
|
const handleRequest = async (req, res) => {
|
|
1166
1297
|
const method = String(req.method ?? 'GET').toUpperCase()
|
|
@@ -1298,8 +1429,22 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1298
1429
|
const handler = (req, res) => {
|
|
1299
1430
|
void (async () => {
|
|
1300
1431
|
const connection = connectionService()
|
|
1301
|
-
const hasConnectionFence =
|
|
1432
|
+
const hasConnectionFence = connectionFencePresent()
|
|
1302
1433
|
if (!hasConnectionFence && config.requireConnection !== false) {
|
|
1434
|
+
// `/state` stays readable: it carries no conversation data (limits,
|
|
1435
|
+
// adapters, switches) and it is how the panel explains why everything
|
|
1436
|
+
// else is refused. Every other route — including the session list and
|
|
1437
|
+
// the trash — stays 401, because the remaining fence cannot tell one
|
|
1438
|
+
// local process from another.
|
|
1439
|
+
if (isStateProbe(req)) {
|
|
1440
|
+
const origin = transportVerdict(req)
|
|
1441
|
+
if (!origin.ok) {
|
|
1442
|
+
sendJson(res, origin.error.httpStatus, toWireError(origin.error))
|
|
1443
|
+
return
|
|
1444
|
+
}
|
|
1445
|
+
sendJson(res, 200, { ok: true, state: await state() })
|
|
1446
|
+
return
|
|
1447
|
+
}
|
|
1303
1448
|
logger.error?.('[conversation-recycle-bin] no connection service; refusing request because requireConnection is enabled')
|
|
1304
1449
|
sendJson(res, 401, toWireError(new DeleteError('E_UNAUTHORIZED', 'This Host has no connection layer to authenticate the request; set requireConnection: false to allow it.', {})))
|
|
1305
1450
|
return
|
package/lib/host/catalog.js
CHANGED
|
@@ -96,21 +96,28 @@ export function createSessionLogOps({ gateway, paths, config, now = Date.now, de
|
|
|
96
96
|
return {
|
|
97
97
|
/**
|
|
98
98
|
* Move `<sessions>/<slug>/<sessionId>/` into the recycle-bin entry.
|
|
99
|
+
*
|
|
100
|
+
* `beforeMove` receives the `{ from, to }` mapping after every check has
|
|
101
|
+
* passed but before the rename. The caller persists that mapping first, so
|
|
102
|
+
* a crash between the move and the later manifest write cannot leave a log
|
|
103
|
+
* in `session/` that the manifest no longer knows how to put back.
|
|
99
104
|
* @returns {Promise<{ sessionId: string, from: string, to: string }>}
|
|
100
105
|
*/
|
|
101
|
-
async moveToTrash(session, entryDir) {
|
|
106
|
+
async moveToTrash(session, entryDir, { beforeMove } = {}) {
|
|
102
107
|
const from = await gateway.findSessionDir(session.id)
|
|
103
108
|
if (from === null) {
|
|
104
109
|
throw new DeleteError('E_NOT_FOUND', 'The conversation log was not found on disk.', { sessionId: session.id })
|
|
105
110
|
}
|
|
106
111
|
await assertNoForeignWriter(session, from)
|
|
107
112
|
const to = join(entryDir, 'session')
|
|
113
|
+
const moved = { sessionId: session.id, from, to }
|
|
114
|
+
if (typeof beforeMove === 'function') await beforeMove(moved)
|
|
108
115
|
await moveDirectory(from, to)
|
|
109
116
|
// Derived cache only: safe to lose, and removing it here prevents a stale
|
|
110
117
|
// projection row for a session that no longer exists. Best-effort by
|
|
111
118
|
// design — the cache self-heals, so this must not fail the transaction.
|
|
112
119
|
if (paths !== undefined) await removeProjectionCheckpoint(paths.storagesDir, session.id)
|
|
113
|
-
return
|
|
120
|
+
return moved
|
|
114
121
|
},
|
|
115
122
|
|
|
116
123
|
async restoreEntry(moved) {
|