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