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