@wangxiaocuo/dsh-session-trash 0.5.1 → 0.6.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/README.en.md +21 -16
- package/README.md +21 -17
- package/client.js +62 -4
- package/lib/concurrency.js +30 -0
- package/lib/eligibility.js +150 -52
- package/lib/fsops.js +19 -0
- package/lib/http.js +5 -0
- package/lib/inventory.js +224 -36
- package/lib/members.js +95 -0
- package/lib/state.js +9 -21
- package/lib/store.js +69 -8
- package/lib/trash-service.js +832 -382
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +7 -2
package/lib/trash-service.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
import { TrashError } from './errors.js'
|
|
3
4
|
import {
|
|
@@ -12,7 +13,9 @@ import {
|
|
|
12
13
|
moveDirectory,
|
|
13
14
|
samePath,
|
|
14
15
|
} from './fsops.js'
|
|
15
|
-
import {
|
|
16
|
+
import { mapLimit } from './concurrency.js'
|
|
17
|
+
import { reconcileIndex, rebuildIndex, memberPresence } from './inventory.js'
|
|
18
|
+
import { aggregateMetrics, memberList } from './members.js'
|
|
16
19
|
|
|
17
20
|
/** How many short retries confirm a moved or restored session is visible. */
|
|
18
21
|
const VISIBILITY_RETRIES = 5
|
|
@@ -20,6 +23,9 @@ const VISIBILITY_RETRIES = 5
|
|
|
20
23
|
/** Delay between visibility probes. */
|
|
21
24
|
const VISIBILITY_DELAY_MS = 100
|
|
22
25
|
|
|
26
|
+
/** Maximum concurrent object deletions and sidecar rewrites during a purge. */
|
|
27
|
+
const PURGE_CONCURRENCY = 4
|
|
28
|
+
|
|
23
29
|
/**
|
|
24
30
|
* @param {number} milliseconds delay length.
|
|
25
31
|
* @returns {Promise<void>} resolution after the delay.
|
|
@@ -170,9 +176,34 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
170
176
|
return undefined
|
|
171
177
|
}
|
|
172
178
|
|
|
179
|
+
/**
|
|
180
|
+
* @param {unknown} target path to probe.
|
|
181
|
+
* @returns {Promise<string>} the entry kind, treating an absent path as missing.
|
|
182
|
+
*/
|
|
183
|
+
async function kindOf(target) {
|
|
184
|
+
return typeof target === 'string' && target !== '' ? entryKind(target) : 'missing'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {object} entry index entry.
|
|
189
|
+
* @returns {object[]} every owned directory, parent first.
|
|
190
|
+
*/
|
|
191
|
+
function membersOf(entry) {
|
|
192
|
+
return memberList(entry)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {object} entry index entry.
|
|
197
|
+
* @param {number} index member position.
|
|
198
|
+
* @returns {string} this member's directory inside the trash object.
|
|
199
|
+
*/
|
|
200
|
+
function memberTrashPath(entry, index) {
|
|
201
|
+
return store.memberPath(entry.trashId, index, membersOf(entry)[index].sessionId)
|
|
202
|
+
}
|
|
203
|
+
|
|
173
204
|
/**
|
|
174
205
|
* @param {object} entry trash index entry.
|
|
175
|
-
* @returns {Promise<void>} resolution once
|
|
206
|
+
* @returns {Promise<void>} resolution once every member path stays inside `objects/`.
|
|
176
207
|
*/
|
|
177
208
|
async function assertSafeObject(entry) {
|
|
178
209
|
if (!isSafeTrashId(entry.trashId)) {
|
|
@@ -182,14 +213,229 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
182
213
|
if (!isInside(store.objectsDir, objectDir)) {
|
|
183
214
|
throw new TrashError('IO_ERROR', 'The trash entry names an object outside the trash root.')
|
|
184
215
|
}
|
|
216
|
+
const members = membersOf(entry)
|
|
217
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
218
|
+
const memberPath = path.resolve(memberTrashPath(entry, index))
|
|
219
|
+
if (!isInside(objectDir, memberPath)) {
|
|
220
|
+
throw new TrashError('IO_ERROR', 'The trash entry names a member outside its object.')
|
|
221
|
+
}
|
|
222
|
+
}
|
|
185
223
|
}
|
|
186
224
|
|
|
187
225
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
226
|
+
* Probe every member directory in parallel: where the source copy and the
|
|
227
|
+
* trash copy currently live.
|
|
228
|
+
*
|
|
229
|
+
* @param {object} entry index entry.
|
|
230
|
+
* @returns {Promise<{ index: number, member: object, source: boolean, trash: boolean }[]>} member positions.
|
|
190
231
|
*/
|
|
191
|
-
async function
|
|
192
|
-
|
|
232
|
+
async function memberRows(entry) {
|
|
233
|
+
const members = membersOf(entry)
|
|
234
|
+
return Promise.all(
|
|
235
|
+
members.map(async (member, index) => {
|
|
236
|
+
const [sourceKind, trashKind] = await Promise.all([
|
|
237
|
+
kindOf(member.originalSessionDir),
|
|
238
|
+
kindOf(memberTrashPath(entry, index)),
|
|
239
|
+
])
|
|
240
|
+
return {
|
|
241
|
+
index,
|
|
242
|
+
member,
|
|
243
|
+
source: sourceKind === 'directory',
|
|
244
|
+
trash: trashKind === 'directory',
|
|
245
|
+
}
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Probe only the original session directories of every member, in parallel.
|
|
252
|
+
* Used by recovery after the trash copies were already proven present.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} entry index entry.
|
|
255
|
+
* @returns {Promise<{ index: number, member: object, source: boolean }[]>} member source positions.
|
|
256
|
+
*/
|
|
257
|
+
async function sourceRows(entry) {
|
|
258
|
+
const members = membersOf(entry)
|
|
259
|
+
return Promise.all(
|
|
260
|
+
members.map(async (member, index) => ({
|
|
261
|
+
index,
|
|
262
|
+
member,
|
|
263
|
+
source: (await kindOf(member.originalSessionDir)) === 'directory',
|
|
264
|
+
})),
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {object} entry index entry.
|
|
270
|
+
* @param {number} index member position.
|
|
271
|
+
* @returns {Promise<void>} resolution once the member directory moved into its object.
|
|
272
|
+
*/
|
|
273
|
+
async function moveMemberToTrash(entry, index) {
|
|
274
|
+
const member = membersOf(entry)[index]
|
|
275
|
+
await moveDirectory(member.originalSessionDir, memberTrashPath(entry, index), member, { logger })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @param {object} entry index entry.
|
|
280
|
+
* @param {number} index member position.
|
|
281
|
+
* @returns {Promise<void>} resolution once the member directory moved back to its original location.
|
|
282
|
+
*/
|
|
283
|
+
async function moveMemberBack(entry, index) {
|
|
284
|
+
const member = membersOf(entry)[index]
|
|
285
|
+
await moveDirectory(memberTrashPath(entry, index), member.originalSessionDir, member, { logger })
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @param {object} entry index entry.
|
|
290
|
+
* @param {readonly { index: number }[]} rows members to move into the object.
|
|
291
|
+
* @returns {Promise<{ index: number, error: unknown }[]>} per-member failures.
|
|
292
|
+
*/
|
|
293
|
+
async function moveMembersIntoTrash(entry, rows) {
|
|
294
|
+
const failures = []
|
|
295
|
+
for (const row of [...rows].sort((left, right) => right.index - left.index)) {
|
|
296
|
+
try {
|
|
297
|
+
await moveMemberToTrash(entry, row.index)
|
|
298
|
+
} catch (error) {
|
|
299
|
+
failures.push({ index: row.index, error })
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return failures
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* @param {object} entry index entry.
|
|
307
|
+
* @param {readonly { index: number }[]} rows members to move back to their original locations.
|
|
308
|
+
* @returns {Promise<{ index: number, error: unknown }[]>} per-member failures.
|
|
309
|
+
*/
|
|
310
|
+
async function moveMembersBackFromTrash(entry, rows) {
|
|
311
|
+
const failures = []
|
|
312
|
+
for (const row of [...rows].sort((left, right) => left.index - right.index)) {
|
|
313
|
+
try {
|
|
314
|
+
await moveMemberBack(entry, row.index)
|
|
315
|
+
} catch (error) {
|
|
316
|
+
failures.push({ index: row.index, error })
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return failures
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Move every member directory into the trash object, children first and the
|
|
324
|
+
* visible parent last so the sidebar keeps the row until the move settles.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} entry index entry.
|
|
327
|
+
* @returns {Promise<void>} resolution once every member directory moved.
|
|
328
|
+
*/
|
|
329
|
+
async function moveMembersToTrash(entry) {
|
|
330
|
+
const rows = membersOf(entry).map((_, index) => ({ index }))
|
|
331
|
+
const failures = await moveMembersIntoTrash(entry, rows)
|
|
332
|
+
if (failures.length === 0) return
|
|
333
|
+
const failure = failures[0]
|
|
334
|
+
throw failure.error instanceof TrashError
|
|
335
|
+
? failure.error
|
|
336
|
+
: new TrashError('IO_ERROR', `Cannot move a session directory into the trash: ${messageOf(failure.error)}`)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @param {object} entry index entry.
|
|
341
|
+
* @param {number} index member position.
|
|
342
|
+
* @returns {Promise<void>} resolution once the unusable trash copy is gone.
|
|
343
|
+
*/
|
|
344
|
+
async function removeTrashMember(entry, index) {
|
|
345
|
+
const target = path.resolve(memberTrashPath(entry, index))
|
|
346
|
+
const objectDir = path.resolve(store.objectDir(entry.trashId))
|
|
347
|
+
if (samePath(objectDir, target) || !isInside(objectDir, target)) {
|
|
348
|
+
throw new TrashError('IO_ERROR', 'Refusing to remove a member directory outside its trash object.')
|
|
349
|
+
}
|
|
350
|
+
await fs.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @param {object} member planned member whose restored copy failed verification.
|
|
355
|
+
* @returns {Promise<void>} resolution once the unusable restored copy is gone.
|
|
356
|
+
*/
|
|
357
|
+
async function removeRestoredMember(member) {
|
|
358
|
+
const target = path.resolve(String(member.originalSessionDir))
|
|
359
|
+
if (!isSafeDirectoryPath(target) || isInside(paths.root, target)) {
|
|
360
|
+
throw new TrashError('IO_ERROR', 'Refusing to remove an unsafe session directory.')
|
|
361
|
+
}
|
|
362
|
+
await fs.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* @param {object} entry index entry.
|
|
367
|
+
* @returns {Promise<{ ok: boolean, index?: number, message?: string }>} whether every member landed with its recorded metrics.
|
|
368
|
+
*/
|
|
369
|
+
async function verifyTrashedMembers(entry) {
|
|
370
|
+
const members = membersOf(entry)
|
|
371
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
372
|
+
let measured
|
|
373
|
+
try {
|
|
374
|
+
measured = await measureDirectory(memberTrashPath(entry, index))
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return { ok: false, index, message: messageOf(error) }
|
|
377
|
+
}
|
|
378
|
+
if (!metricsMatch(members[index], measured)) {
|
|
379
|
+
return { ok: false, index, message: 'the recorded size does not match' }
|
|
380
|
+
}
|
|
381
|
+
if ((await kindOf(members[index].originalSessionDir)) !== 'missing') {
|
|
382
|
+
return { ok: false, index, message: 'the original directory still exists' }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return { ok: true }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* @param {object} entry index entry.
|
|
390
|
+
* @returns {Promise<{ ok: boolean, index?: number, message?: string }>} whether every member returned with its recorded metrics.
|
|
391
|
+
*/
|
|
392
|
+
async function verifyRestoredMembers(entry) {
|
|
393
|
+
const members = membersOf(entry)
|
|
394
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
395
|
+
let measured
|
|
396
|
+
try {
|
|
397
|
+
measured = await measureDirectory(members[index].originalSessionDir)
|
|
398
|
+
} catch (error) {
|
|
399
|
+
return { ok: false, index, message: messageOf(error) }
|
|
400
|
+
}
|
|
401
|
+
if (!metricsMatch(members[index], measured)) {
|
|
402
|
+
return { ok: false, index, message: 'the recorded size does not match' }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { ok: true }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* @param {object} entry index entry.
|
|
410
|
+
* @returns {Promise<{ hidden: boolean, sessionId?: string }>} whether session storage stopped reporting every member.
|
|
411
|
+
*/
|
|
412
|
+
async function waitForMembersHidden(entry) {
|
|
413
|
+
const results = await Promise.all(
|
|
414
|
+
membersOf(entry).map(async (member) => ({
|
|
415
|
+
sessionId: member.sessionId,
|
|
416
|
+
hidden: await waitForVisibility(member.sessionId, (current) => current === undefined || current === null),
|
|
417
|
+
})),
|
|
418
|
+
)
|
|
419
|
+
const failed = results.find((result) => !result.hidden)
|
|
420
|
+
return failed === undefined ? { hidden: true } : { hidden: false, sessionId: failed.sessionId }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* @param {object} entry index entry.
|
|
425
|
+
* @returns {Promise<{ visible: boolean, sessionId?: string }>} whether session storage reports every member again.
|
|
426
|
+
*/
|
|
427
|
+
async function waitForMembersVisible(entry) {
|
|
428
|
+
const results = await Promise.all(
|
|
429
|
+
membersOf(entry).map(async (member) => ({
|
|
430
|
+
sessionId: member.sessionId,
|
|
431
|
+
visible: await waitForVisibility(
|
|
432
|
+
member.sessionId,
|
|
433
|
+
(current) => current !== undefined && current !== null && current.location !== undefined,
|
|
434
|
+
),
|
|
435
|
+
})),
|
|
436
|
+
)
|
|
437
|
+
const failed = results.find((result) => !result.visible)
|
|
438
|
+
return failed === undefined ? { visible: true } : { visible: false, sessionId: failed.sessionId }
|
|
193
439
|
}
|
|
194
440
|
|
|
195
441
|
/**
|
|
@@ -419,98 +665,185 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
419
665
|
}
|
|
420
666
|
|
|
421
667
|
/**
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
668
|
+
* Resolve every member directory of a `trashing` entry to either the trash
|
|
669
|
+
* object or its original location. Never writes the index: the caller owns
|
|
670
|
+
* the commit so startup recovery can accumulate one write.
|
|
425
671
|
*
|
|
426
|
-
* @param {object} entry
|
|
427
|
-
* @
|
|
428
|
-
* @returns {Promise<object | undefined>} the completed entry, or undefined after rollback.
|
|
672
|
+
* @param {object} entry `trashing` index entry.
|
|
673
|
+
* @returns {Promise<{ kind: 'ready' } | { kind: 'rolledBack' } | { kind: 'missing', entry: object }>} the settled position.
|
|
429
674
|
*/
|
|
430
|
-
async function
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 })
|
|
675
|
+
async function resolveTrashing(entry) {
|
|
676
|
+
const rows = await memberRows(entry)
|
|
677
|
+
const lost = rows.filter((row) => !row.source && !row.trash)
|
|
678
|
+
if (lost.length > 0) {
|
|
679
|
+
return {
|
|
680
|
+
kind: 'missing',
|
|
681
|
+
entry: {
|
|
682
|
+
...entry,
|
|
683
|
+
state: 'missing',
|
|
684
|
+
lastError: errorText(`The trash copy is missing for: ${lost.map((row) => row.member.sessionId).join(', ')}`),
|
|
685
|
+
},
|
|
443
686
|
}
|
|
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
687
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
}
|
|
688
|
+
for (const row of rows) {
|
|
689
|
+
if (!row.source || !row.trash) continue
|
|
690
|
+
const measured = await measureDirectory(memberTrashPath(entry, row.index))
|
|
691
|
+
if (metricsMatch(row.member, measured)) {
|
|
692
|
+
await store.removeSessionSource(row.member.originalSessionDir)
|
|
693
|
+
row.source = false
|
|
465
694
|
} else {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
695
|
+
await removeTrashMember(entry, row.index)
|
|
696
|
+
row.trash = false
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const inTrash = rows.filter((row) => row.trash)
|
|
700
|
+
const inSource = rows.filter((row) => row.source)
|
|
701
|
+
if (inTrash.length === 0) return { kind: 'rolledBack' }
|
|
702
|
+
if (inSource.length > 0) {
|
|
703
|
+
const failures = await moveMembersIntoTrash(entry, inSource)
|
|
704
|
+
if (failures.length > 0) {
|
|
705
|
+
const current = await memberRows(entry)
|
|
706
|
+
const rollback = await moveMembersBackFromTrash(entry, current.filter((row) => row.trash))
|
|
707
|
+
if (rollback.length === 0) return { kind: 'rolledBack' }
|
|
708
|
+
await markFailure(entry, `The session tree could not be completed or rolled back: ${messageOf(failures[0].error)}`)
|
|
709
|
+
throw new TrashError('MOVE_CONFLICT', 'The session tree could not be moved or rolled back; both copies were kept.')
|
|
469
710
|
}
|
|
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
711
|
}
|
|
475
|
-
|
|
476
|
-
|
|
712
|
+
const verified = await verifyTrashedMembers(entry)
|
|
713
|
+
if (!verified.ok) {
|
|
714
|
+
const current = await memberRows(entry)
|
|
715
|
+
const rollback = await moveMembersBackFromTrash(entry, current.filter((row) => row.trash))
|
|
716
|
+
if (rollback.length === 0) return { kind: 'rolledBack' }
|
|
717
|
+
await markFailure(entry, `The moved session tree does not match its recorded size (${verified.message}).`)
|
|
718
|
+
throw new TrashError('MOVE_VERIFY_FAILED', 'The moved session tree does not match its recorded size.')
|
|
719
|
+
}
|
|
720
|
+
return { kind: 'ready' }
|
|
477
721
|
}
|
|
478
722
|
|
|
479
723
|
/**
|
|
480
|
-
*
|
|
481
|
-
*
|
|
724
|
+
* Resolve every member directory of a `restoring` entry back to its original
|
|
725
|
+
* location, or back into the trash object when the restore cannot complete.
|
|
726
|
+
* Never writes the index: the caller owns the commit.
|
|
482
727
|
*
|
|
483
|
-
* @param {object} entry `restoring` entry.
|
|
484
|
-
* @
|
|
485
|
-
* @returns {Promise<boolean>} whether the target now contains the restored session.
|
|
728
|
+
* @param {object} entry `restoring` index entry.
|
|
729
|
+
* @returns {Promise<{ kind: 'ready' } | { kind: 'trashed', entry: object } | { kind: 'missing', entry: object }>} the settled position.
|
|
486
730
|
*/
|
|
487
|
-
async function
|
|
488
|
-
const
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
logger.warn(`cannot remove the leftover trash copy for ${entry.sessionId}: ${messageOf(error)}`)
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
return true
|
|
731
|
+
async function resolveRestoring(entry) {
|
|
732
|
+
const rows = await memberRows(entry)
|
|
733
|
+
const lost = rows.filter((row) => !row.source && !row.trash)
|
|
734
|
+
if (lost.length > 0) {
|
|
735
|
+
return {
|
|
736
|
+
kind: 'missing',
|
|
737
|
+
entry: {
|
|
738
|
+
...entry,
|
|
739
|
+
state: 'missing',
|
|
740
|
+
lastError: errorText(`Both copies are missing for: ${lost.map((row) => row.member.sessionId).join(', ')}`),
|
|
741
|
+
},
|
|
502
742
|
}
|
|
503
|
-
await markFailure(entry, `The restored session does not match its recorded size (${failure}).`)
|
|
504
|
-
return false
|
|
505
743
|
}
|
|
506
|
-
|
|
507
|
-
|
|
744
|
+
for (const row of rows) {
|
|
745
|
+
if (!row.source || !row.trash) continue
|
|
746
|
+
const measured = await measureDirectory(row.member.originalSessionDir)
|
|
747
|
+
if (metricsMatch(row.member, measured)) {
|
|
748
|
+
await removeTrashMember(entry, row.index)
|
|
749
|
+
row.trash = false
|
|
750
|
+
} else {
|
|
751
|
+
await removeRestoredMember(row.member)
|
|
752
|
+
row.source = false
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const rolledBack = () => ({
|
|
756
|
+
kind: 'trashed',
|
|
757
|
+
entry: {
|
|
758
|
+
...entry,
|
|
759
|
+
state: 'trashed',
|
|
760
|
+
trashedAt:
|
|
761
|
+
typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt : entry.startedAt || Date.now(),
|
|
762
|
+
lastError: 'Recovered the trash copy after an interrupted restore.',
|
|
763
|
+
},
|
|
764
|
+
})
|
|
765
|
+
const inSource = rows.filter((row) => row.source)
|
|
766
|
+
const inTrash = rows.filter((row) => row.trash)
|
|
767
|
+
if (inSource.length === 0) return rolledBack()
|
|
768
|
+
if (inTrash.length > 0) {
|
|
769
|
+
const failures = await moveMembersBackFromTrash(entry, inTrash)
|
|
770
|
+
if (failures.length > 0) {
|
|
771
|
+
const current = await memberRows(entry)
|
|
772
|
+
const rollback = await moveMembersIntoTrash(entry, current.filter((row) => row.source && !row.trash))
|
|
773
|
+
if (rollback.length === 0) return rolledBack()
|
|
774
|
+
await markFailure(entry, `The restore could not be completed or rolled back: ${messageOf(failures[0].error)}`)
|
|
775
|
+
throw new TrashError('MOVE_CONFLICT', 'The restore could not be completed or rolled back; both copies were kept.')
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const verified = await verifyRestoredMembers(entry)
|
|
779
|
+
if (!verified.ok) {
|
|
780
|
+
const current = await memberRows(entry)
|
|
781
|
+
const rollback = await moveMembersIntoTrash(entry, current.filter((row) => row.source))
|
|
782
|
+
if (rollback.length === 0) return rolledBack()
|
|
783
|
+
await markFailure(entry, `The restored session tree does not match its recorded size (${verified.message}).`)
|
|
784
|
+
throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session tree does not match its recorded size.')
|
|
785
|
+
}
|
|
786
|
+
return { kind: 'ready' }
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* @param {object} entry settled `trashing` entry.
|
|
791
|
+
* @param {{ emitRemoval?: boolean }} [options] whether to announce the removal to clients.
|
|
792
|
+
* @returns {Promise<object>} the committed `trashed` entry.
|
|
793
|
+
*/
|
|
794
|
+
async function commitTrashed(entry, options = {}) {
|
|
795
|
+
const next = { ...entry, state: 'trashed', trashedAt: Date.now(), lastError: null }
|
|
796
|
+
try {
|
|
508
797
|
await store.commit(upsertEntry(store.entries(), next))
|
|
509
|
-
|
|
510
|
-
|
|
798
|
+
} catch (error) {
|
|
799
|
+
await markFailure(entry, `The trash index could not be committed: ${messageOf(error)}`)
|
|
800
|
+
throw new TrashError('INDEX_WRITE_FAILED', 'The session moved, but the trash index could not be updated.')
|
|
511
801
|
}
|
|
512
|
-
await
|
|
513
|
-
|
|
802
|
+
await store.writeMeta(next).catch((error) => {
|
|
803
|
+
logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
|
|
804
|
+
})
|
|
805
|
+
eligibility.invalidate(entry.sessionId)
|
|
806
|
+
if (options.emitRemoval === true) emit('api-session/removed', entry.sessionId)
|
|
807
|
+
return next
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Finish a settled restore: wait for visibility, restore workspace
|
|
812
|
+
* accounting, drop the entry, and optionally announce the session.
|
|
813
|
+
*
|
|
814
|
+
* @param {object} entry settled `restoring` entry.
|
|
815
|
+
* @param {{ mode?: 'normal' | 'orphan', emitAdded?: boolean, reattach?: boolean }} [options] finishing policy.
|
|
816
|
+
* @returns {Promise<object>} the workspace attachment outcome.
|
|
817
|
+
*/
|
|
818
|
+
async function completeRestore(entry, options = {}) {
|
|
819
|
+
const mode = options.mode ?? 'normal'
|
|
820
|
+
const visible = await waitForMembersVisible(entry)
|
|
821
|
+
if (!visible.visible) {
|
|
822
|
+
throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session tree is not visible to session storage.', {
|
|
823
|
+
sessionId: visible.sessionId,
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
const attachment =
|
|
827
|
+
options.reattach === false
|
|
828
|
+
? { reattached: false }
|
|
829
|
+
: mode === 'orphan'
|
|
830
|
+
? await detachWorkspace(entry)
|
|
831
|
+
: await reattachWorkspace(entry)
|
|
832
|
+
try {
|
|
833
|
+
await store.commit(dropEntry(store.entries(), entry.sessionId))
|
|
834
|
+
} catch (error) {
|
|
835
|
+
await markFailure(entry, `The trash index could not be committed after restore: ${messageOf(error)}`)
|
|
836
|
+
throw new TrashError('INDEX_WRITE_FAILED', 'The session was restored, but the trash index could not be updated.')
|
|
837
|
+
}
|
|
838
|
+
await store.removeObjectDir(entry.trashId).catch((error) => {
|
|
839
|
+
logger.warn(`cannot remove the restored trash object ${entry.trashId}: ${messageOf(error)}`)
|
|
840
|
+
})
|
|
841
|
+
eligibility.invalidate(entry.sessionId)
|
|
842
|
+
if (options.emitAdded !== false) {
|
|
843
|
+
const restoredSnapshot = await stat(entry.sessionId).catch(() => undefined)
|
|
844
|
+
emit('api-session/added', buildSummary(entry.sessionId, entry.title, restoredSnapshot))
|
|
845
|
+
}
|
|
846
|
+
return attachment
|
|
514
847
|
}
|
|
515
848
|
|
|
516
849
|
/**
|
|
@@ -524,6 +857,99 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
524
857
|
return entry.trashedAt + config.retentionDays * 86_400_000 <= now
|
|
525
858
|
}
|
|
526
859
|
|
|
860
|
+
/**
|
|
861
|
+
* Permanently remove a batch of object directories and their index entries in
|
|
862
|
+
* a bounded number of index writes. Every entry is first committed as
|
|
863
|
+
* `purging` in one write, so a crash still settles forward on the next
|
|
864
|
+
* recovery; successes are dropped and failures annotated in one final write.
|
|
865
|
+
* The caller owns the trash lock.
|
|
866
|
+
*
|
|
867
|
+
* @param {readonly object[]} targets `trashed` entries to purge.
|
|
868
|
+
* @returns {Promise<{ purged: number, failures: { entry: object, error: unknown, phase: string }[] }>} per-entry outcome.
|
|
869
|
+
*/
|
|
870
|
+
async function purgeManyInsideLock(targets) {
|
|
871
|
+
const failures = []
|
|
872
|
+
const purging = []
|
|
873
|
+
for (const entry of targets) {
|
|
874
|
+
try {
|
|
875
|
+
await assertSafeObject(entry)
|
|
876
|
+
purging.push({ ...entry, state: 'purging', lastError: null })
|
|
877
|
+
} catch (error) {
|
|
878
|
+
failures.push({ entry, error, phase: 'validate' })
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (purging.length === 0) return { purged: 0, failures }
|
|
882
|
+
|
|
883
|
+
let marked = store.entries()
|
|
884
|
+
for (const entry of purging) marked = upsertEntry(marked, entry)
|
|
885
|
+
try {
|
|
886
|
+
await store.commit(marked)
|
|
887
|
+
} catch (error) {
|
|
888
|
+
const failure = new TrashError(
|
|
889
|
+
'INDEX_WRITE_FAILED',
|
|
890
|
+
'The session logs were not deleted because the trash index could not be updated.',
|
|
891
|
+
{ code: error instanceof TrashError ? error.code : null },
|
|
892
|
+
)
|
|
893
|
+
for (const entry of purging) failures.push({ entry, error: failure, phase: 'mark' })
|
|
894
|
+
return { purged: 0, failures }
|
|
895
|
+
}
|
|
896
|
+
await mapLimit(purging, PURGE_CONCURRENCY, async (entry) => {
|
|
897
|
+
await store.writeMeta(entry).catch((error) => {
|
|
898
|
+
logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
|
|
899
|
+
})
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
const deleted = await mapLimit(purging, PURGE_CONCURRENCY, async (entry) => {
|
|
903
|
+
try {
|
|
904
|
+
await store.removeObjectDir(entry.trashId)
|
|
905
|
+
return { entry, deleted: true }
|
|
906
|
+
} catch (error) {
|
|
907
|
+
return { entry, deleted: false, error }
|
|
908
|
+
}
|
|
909
|
+
})
|
|
910
|
+
const completed = []
|
|
911
|
+
const annotated = []
|
|
912
|
+
for (const result of deleted) {
|
|
913
|
+
if (result.deleted) {
|
|
914
|
+
completed.push(result.entry)
|
|
915
|
+
logger.info(`permanently deleted ${result.entry.sessionId} (${result.entry.trashId})`)
|
|
916
|
+
continue
|
|
917
|
+
}
|
|
918
|
+
annotated.push({
|
|
919
|
+
...result.entry,
|
|
920
|
+
lastError: errorText(`The session log could not be deleted: ${messageOf(result.error)}`),
|
|
921
|
+
})
|
|
922
|
+
failures.push({
|
|
923
|
+
entry: result.entry,
|
|
924
|
+
error: new TrashError('IO_ERROR', 'The session log could not be deleted.', { trashId: result.entry.trashId }),
|
|
925
|
+
phase: 'delete',
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
let settled = store.entries()
|
|
930
|
+
for (const entry of completed) settled = dropEntry(settled, entry.sessionId)
|
|
931
|
+
for (const entry of annotated) settled = upsertEntry(settled, entry)
|
|
932
|
+
try {
|
|
933
|
+
await store.commit(settled)
|
|
934
|
+
} catch (error) {
|
|
935
|
+
const failure = new TrashError(
|
|
936
|
+
'INDEX_WRITE_FAILED',
|
|
937
|
+
'The session logs were deleted, but the trash index could not be updated.',
|
|
938
|
+
{ code: error instanceof TrashError ? error.code : null },
|
|
939
|
+
)
|
|
940
|
+
for (const entry of completed) failures.push({ entry, error: failure, phase: 'drop' })
|
|
941
|
+
// The deleted objects no longer match the purging entries; the next
|
|
942
|
+
// recovery drops them, so nothing is reported as successfully purged.
|
|
943
|
+
return { purged: 0, failures }
|
|
944
|
+
}
|
|
945
|
+
await mapLimit(annotated, PURGE_CONCURRENCY, async (entry) => {
|
|
946
|
+
await store.writeMeta(entry).catch((error) => {
|
|
947
|
+
logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
|
|
948
|
+
})
|
|
949
|
+
})
|
|
950
|
+
return { purged: completed.length, failures }
|
|
951
|
+
}
|
|
952
|
+
|
|
527
953
|
/**
|
|
528
954
|
* Permanently remove one object directory and its index entry. The caller
|
|
529
955
|
* owns the trash lock.
|
|
@@ -532,26 +958,35 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
532
958
|
* @returns {Promise<void>} resolution once the object and entry are gone.
|
|
533
959
|
*/
|
|
534
960
|
async function purgeInsideLock(entry) {
|
|
535
|
-
await
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
961
|
+
const result = await purgeManyInsideLock([entry])
|
|
962
|
+
if (result.failures.length > 0) throw result.failures[0].error
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Drop a batch of `missing` entries from the index in one write and remove
|
|
967
|
+
* their plugin-owned remnants. No session file is deleted. The caller owns
|
|
968
|
+
* the trash lock.
|
|
969
|
+
*
|
|
970
|
+
* @param {readonly object[]} entries `missing` index entries to forget.
|
|
971
|
+
* @returns {Promise<number>} how many entries were removed from the index.
|
|
972
|
+
*/
|
|
973
|
+
async function forgetManyInsideLock(entries) {
|
|
974
|
+
if (entries.length === 0) return 0
|
|
975
|
+
let next = store.entries()
|
|
976
|
+
for (const entry of entries) next = dropEntry(next, entry.sessionId)
|
|
547
977
|
try {
|
|
548
|
-
await store.commit(
|
|
978
|
+
await store.commit(next)
|
|
549
979
|
} catch (error) {
|
|
550
|
-
throw new TrashError('INDEX_WRITE_FAILED', 'The
|
|
980
|
+
throw new TrashError('INDEX_WRITE_FAILED', 'The trash index could not be updated.', {
|
|
551
981
|
code: error instanceof TrashError ? error.code : null,
|
|
552
982
|
})
|
|
553
983
|
}
|
|
554
|
-
|
|
984
|
+
for (const entry of entries) {
|
|
985
|
+
await store.removeObjectRemnants(entry.trashId).catch((error) => {
|
|
986
|
+
logger.warn(`cannot remove the remnants of ${entry.trashId}: ${messageOf(error)}`)
|
|
987
|
+
})
|
|
988
|
+
}
|
|
989
|
+
return entries.length
|
|
555
990
|
}
|
|
556
991
|
|
|
557
992
|
/**
|
|
@@ -562,28 +997,20 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
562
997
|
* @returns {Promise<{ sessionId: string, title: string }>} the removed identity.
|
|
563
998
|
*/
|
|
564
999
|
async function forgetInsideLock(entry) {
|
|
565
|
-
|
|
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
|
-
})
|
|
1000
|
+
await forgetManyInsideLock([entry])
|
|
575
1001
|
return { sessionId: entry.sessionId, title: entry.title }
|
|
576
1002
|
}
|
|
577
1003
|
|
|
578
1004
|
/**
|
|
579
|
-
* Move one cold session into the trash.
|
|
1005
|
+
* Move one cold session tree into the trash. A session that owns only hidden
|
|
1006
|
+
* `origin: 'subagent'` descendants moves together with them.
|
|
580
1007
|
*
|
|
581
1008
|
* @param {string} sessionId session to trash.
|
|
582
1009
|
* @returns {Promise<object>} the committed `trashed` entry.
|
|
583
1010
|
*/
|
|
584
1011
|
async function trash(sessionId) {
|
|
585
1012
|
const startedAt = Date.now()
|
|
586
|
-
|
|
1013
|
+
return store.withLock(async () => {
|
|
587
1014
|
if (store.isProtected()) {
|
|
588
1015
|
throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
|
|
589
1016
|
}
|
|
@@ -603,28 +1030,65 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
603
1030
|
throw new TrashError('ALREADY_TRASHED', 'This session is already recorded in the trash.')
|
|
604
1031
|
}
|
|
605
1032
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
1033
|
+
|
|
1034
|
+
const plan = Array.isArray(verdict.members) ? verdict.members : []
|
|
1035
|
+
if (plan.length === 0) throw new TrashError('SESSION_NOT_FOUND', 'This session is not in session storage.')
|
|
1036
|
+
const members = []
|
|
1037
|
+
let parentSnapshot
|
|
1038
|
+
for (let index = 0; index < plan.length; index += 1) {
|
|
1039
|
+
const planned = plan[index]
|
|
1040
|
+
const snapshot = await stat(planned.sessionId)
|
|
1041
|
+
if (snapshot === undefined || snapshot === null) {
|
|
1042
|
+
throw new TrashError('SESSION_NOT_FOUND', 'A session in this lineage is not in session storage.', {
|
|
1043
|
+
sessionId: planned.sessionId,
|
|
1044
|
+
})
|
|
1045
|
+
}
|
|
1046
|
+
if (index === 0) parentSnapshot = snapshot
|
|
1047
|
+
const location = snapshot.location
|
|
1048
|
+
if (
|
|
1049
|
+
location === undefined ||
|
|
1050
|
+
location === null ||
|
|
1051
|
+
typeof location.path !== 'string' ||
|
|
1052
|
+
location.kind !== 'jsonl' ||
|
|
1053
|
+
!path.isAbsolute(location.path)
|
|
1054
|
+
) {
|
|
1055
|
+
throw new TrashError('SESSION_NOT_MATERIALIZED', 'A session in this lineage has no movable JSONL log.', {
|
|
1056
|
+
sessionId: planned.sessionId,
|
|
1057
|
+
})
|
|
1058
|
+
}
|
|
1059
|
+
const resolvedLogPath = path.resolve(location.path)
|
|
1060
|
+
const sessionDir = path.dirname(resolvedLogPath)
|
|
1061
|
+
const projectDir = path.dirname(sessionDir)
|
|
1062
|
+
if (!isSafeDirectoryPath(sessionDir) || (await kindOf(sessionDir)) !== 'directory') {
|
|
1063
|
+
throw new TrashError('UNSAFE_SESSION_DIR', 'A session directory is not a plain directory this plugin can move.', {
|
|
1064
|
+
sessionId: planned.sessionId,
|
|
1065
|
+
})
|
|
1066
|
+
}
|
|
1067
|
+
if (isLive(planned.sessionId)) {
|
|
1068
|
+
throw new TrashError('SESSION_LIVE', 'A session in this lineage became live before it could be moved.', {
|
|
1069
|
+
sessionId: planned.sessionId,
|
|
1070
|
+
})
|
|
1071
|
+
}
|
|
1072
|
+
const measured = await measureDirectory(sessionDir)
|
|
1073
|
+
const title = await resolveTitle(planned.sessionId, snapshot.header)
|
|
1074
|
+
members.push({
|
|
1075
|
+
sessionId: planned.sessionId,
|
|
1076
|
+
origin: index === 0 ? null : 'subagent',
|
|
1077
|
+
title,
|
|
1078
|
+
originalSessionDir: sessionDir,
|
|
1079
|
+
originalLogPath: resolvedLogPath,
|
|
1080
|
+
persistenceProjectDirHint: projectDir,
|
|
1081
|
+
formatStatus: snapshot.formatStatus === 'migration-required' ? 'migration-required' : 'current',
|
|
1082
|
+
sizeBytes: measured.sizeBytes,
|
|
1083
|
+
fileCount: measured.fileCount,
|
|
1084
|
+
})
|
|
621
1085
|
}
|
|
622
1086
|
|
|
623
1087
|
let trashId
|
|
624
1088
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
625
1089
|
const candidate = createTrashId()
|
|
626
1090
|
const indexed = store.entries().some((entry) => entry.trashId === candidate)
|
|
627
|
-
if (!indexed && (await
|
|
1091
|
+
if (!indexed && (await kindOf(store.objectDir(candidate))) === 'missing') {
|
|
628
1092
|
trashId = candidate
|
|
629
1093
|
break
|
|
630
1094
|
}
|
|
@@ -639,32 +1103,34 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
639
1103
|
phase: 'create-object',
|
|
640
1104
|
})
|
|
641
1105
|
}
|
|
642
|
-
|
|
1106
|
+
|
|
1107
|
+
const totals = aggregateMetrics(members)
|
|
1108
|
+
const parent = members[0]
|
|
1109
|
+
const header = parentSnapshot?.header ?? {}
|
|
1110
|
+
const originalCwd = typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : null
|
|
1111
|
+
const workspaceInfo = findOwningWorkspace(sessionId)
|
|
1112
|
+
const entry = {
|
|
1113
|
+
sessionId,
|
|
1114
|
+
trashId,
|
|
1115
|
+
state: 'trashing',
|
|
1116
|
+
title: parent.title,
|
|
1117
|
+
trashedAt: 0,
|
|
1118
|
+
startedAt,
|
|
1119
|
+
originalCwd,
|
|
1120
|
+
workspaceExistsAtDeletion: originalCwd === null ? null : await directoryExists(originalCwd),
|
|
1121
|
+
originalWorkspaceId: workspaceInfo?.workspace?.id ?? null,
|
|
1122
|
+
originalWorkspaceTitle: workspaceInfo?.workspace?.title ?? null,
|
|
1123
|
+
originalNextSessionId: workspaceInfo?.nextSessionId ?? null,
|
|
1124
|
+
originalSessionDir: parent.originalSessionDir,
|
|
1125
|
+
originalLogPath: parent.originalLogPath,
|
|
1126
|
+
persistenceProjectDirHint: parent.persistenceProjectDirHint,
|
|
1127
|
+
formatStatus: parent.formatStatus,
|
|
1128
|
+
sizeBytes: totals.sizeBytes,
|
|
1129
|
+
fileCount: totals.fileCount,
|
|
1130
|
+
members,
|
|
1131
|
+
lastError: null,
|
|
1132
|
+
}
|
|
643
1133
|
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
1134
|
await store.writeMeta(entry)
|
|
669
1135
|
await store.commit(upsertEntry(store.entries(), entry))
|
|
670
1136
|
} catch (error) {
|
|
@@ -674,88 +1140,69 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
674
1140
|
: new TrashError('IO_ERROR', `Cannot prepare the trash entry: ${messageOf(error)}`)
|
|
675
1141
|
}
|
|
676
1142
|
|
|
677
|
-
// Another process may have opened or resumed
|
|
678
|
-
// preflight and the move. Re-check
|
|
679
|
-
// held; the
|
|
680
|
-
|
|
1143
|
+
// Another process may have opened or resumed a member between the
|
|
1144
|
+
// preflight and the move. Re-check every member while the trash lock is
|
|
1145
|
+
// held; the sources are still untouched at this point.
|
|
1146
|
+
const raced = members.find((member) => isLive(member.sessionId))
|
|
1147
|
+
if (raced !== undefined) {
|
|
681
1148
|
await rollbackTrash(sessionId, trashId)
|
|
682
|
-
throw new TrashError('SESSION_LIVE', '
|
|
1149
|
+
throw new TrashError('SESSION_LIVE', 'A session in this lineage became live before it could be moved.', {
|
|
1150
|
+
sessionId: raced.sessionId,
|
|
1151
|
+
})
|
|
683
1152
|
}
|
|
684
1153
|
|
|
1154
|
+
let moveError
|
|
685
1155
|
try {
|
|
686
|
-
await
|
|
1156
|
+
await moveMembersToTrash(entry)
|
|
687
1157
|
} catch (error) {
|
|
688
|
-
|
|
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)}`)
|
|
1158
|
+
moveError = error
|
|
699
1159
|
}
|
|
700
|
-
|
|
701
|
-
let measured
|
|
1160
|
+
let settled
|
|
702
1161
|
try {
|
|
703
|
-
|
|
1162
|
+
settled = await resolveTrashing(entry)
|
|
704
1163
|
} catch (error) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
|
|
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,
|
|
1164
|
+
throw error instanceof TrashError
|
|
1165
|
+
? error
|
|
1166
|
+
: new TrashError('IO_ERROR', `Cannot move the session tree into the trash: ${messageOf(error)}`)
|
|
1167
|
+
}
|
|
1168
|
+
if (settled.kind === 'missing') {
|
|
1169
|
+
await store.commit(upsertEntry(store.entries(), settled.entry))
|
|
1170
|
+
await store.writeMeta(settled.entry).catch(() => {})
|
|
1171
|
+
throw new TrashError('MOVE_CONFLICT', 'A session directory disappeared during the move.', {
|
|
1172
|
+
sessionId: settled.entry.sessionId,
|
|
1173
|
+
lastError: settled.entry.lastError,
|
|
725
1174
|
})
|
|
726
1175
|
}
|
|
727
|
-
|
|
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
|
-
}
|
|
1176
|
+
if (settled.kind === 'rolledBack') {
|
|
734
1177
|
await rollbackTrash(sessionId, trashId)
|
|
735
|
-
throw
|
|
1178
|
+
throw moveError instanceof TrashError
|
|
1179
|
+
? moveError
|
|
1180
|
+
: new TrashError('MOVE_VERIFY_FAILED', 'The session tree could not be moved into the trash.')
|
|
736
1181
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
await
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1182
|
+
const hidden = await waitForMembersHidden(entry)
|
|
1183
|
+
if (!hidden.hidden) {
|
|
1184
|
+
const rows = await memberRows(entry)
|
|
1185
|
+
const rollback = await moveMembersBackFromTrash(entry, rows.filter((row) => row.trash))
|
|
1186
|
+
if (rollback.length > 0) {
|
|
1187
|
+
await markFailure(entry, 'Session storage still reports the moved session tree, and moving it back failed.')
|
|
1188
|
+
throw new TrashError('MOVE_CONFLICT', 'The session tree was moved but session storage still reports it.')
|
|
1189
|
+
}
|
|
1190
|
+
await rollbackTrash(sessionId, trashId)
|
|
1191
|
+
throw new TrashError('MOVE_VERIFY_FAILED', 'Session storage still reports the session after the move.', {
|
|
1192
|
+
sessionId: hidden.sessionId,
|
|
1193
|
+
})
|
|
744
1194
|
}
|
|
745
|
-
await
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
emit('api-session/removed', sessionId)
|
|
750
|
-
logger.info(`trashed ${sessionId} as ${trashId} in ${Date.now() - startedAt}ms`)
|
|
1195
|
+
const next = await commitTrashed(entry, { emitRemoval: true })
|
|
1196
|
+
logger.info(
|
|
1197
|
+
`trashed ${sessionId} as ${trashId} with ${members.length - 1} child sessions in ${Date.now() - startedAt}ms`,
|
|
1198
|
+
)
|
|
751
1199
|
return next
|
|
752
1200
|
})
|
|
753
|
-
return result
|
|
754
1201
|
}
|
|
755
1202
|
|
|
756
1203
|
/**
|
|
757
|
-
* Restore one trashed session to its original
|
|
758
|
-
* `normal` only; orphan restore arrives later.
|
|
1204
|
+
* Restore one trashed session tree to its original directories. Phase 2
|
|
1205
|
+
* supports `normal` only; orphan restore arrives later.
|
|
759
1206
|
*
|
|
760
1207
|
* @param {string} sessionId session to restore.
|
|
761
1208
|
* @param {'normal' | 'orphan'} [mode] restore mode; orphan restores the log and detaches the Workspace.
|
|
@@ -776,28 +1223,49 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
776
1223
|
throw new TrashError('TRASH_STATE_INVALID', `The trash entry is in state "${entry.state}" and cannot be restored.`)
|
|
777
1224
|
}
|
|
778
1225
|
await assertSafeObject(entry)
|
|
779
|
-
const
|
|
780
|
-
const
|
|
1226
|
+
const members = membersOf(entry)
|
|
1227
|
+
const parent = members[0]
|
|
781
1228
|
if (
|
|
782
|
-
!
|
|
783
|
-
!samePath(path.
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1229
|
+
!samePath(path.resolve(String(entry.originalSessionDir)), path.resolve(String(parent.originalSessionDir))) ||
|
|
1230
|
+
!samePath(path.resolve(String(entry.originalLogPath)), path.resolve(String(parent.originalLogPath))) ||
|
|
1231
|
+
!samePath(
|
|
1232
|
+
path.resolve(String(entry.persistenceProjectDirHint)),
|
|
1233
|
+
path.resolve(String(parent.persistenceProjectDirHint)),
|
|
1234
|
+
)
|
|
788
1235
|
) {
|
|
789
|
-
throw new TrashError('UNSAFE_RESTORE_PATH', 'The
|
|
1236
|
+
throw new TrashError('UNSAFE_RESTORE_PATH', 'The trash entry names inconsistent restore paths.')
|
|
790
1237
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1238
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
1239
|
+
const member = members[index]
|
|
1240
|
+
const target = path.resolve(String(member.originalSessionDir))
|
|
1241
|
+
const projectDirHint = path.resolve(String(member.persistenceProjectDirHint))
|
|
1242
|
+
if (
|
|
1243
|
+
!isSafeDirectoryPath(target) ||
|
|
1244
|
+
!samePath(path.dirname(target), projectDirHint) ||
|
|
1245
|
+
isInside(paths.root, target) ||
|
|
1246
|
+
typeof member.originalLogPath !== 'string' ||
|
|
1247
|
+
!samePath(path.dirname(path.resolve(member.originalLogPath)), target)
|
|
1248
|
+
) {
|
|
1249
|
+
throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target failed the path safety check.', {
|
|
1250
|
+
sessionId: member.sessionId,
|
|
1251
|
+
})
|
|
1252
|
+
}
|
|
1253
|
+
const projectDirKind = await kindOf(projectDirHint)
|
|
1254
|
+
if (projectDirKind !== 'missing' && projectDirKind !== 'directory') {
|
|
1255
|
+
throw new TrashError('UNSAFE_RESTORE_PATH', 'The restore target parent is not a plain directory.', {
|
|
1256
|
+
sessionId: member.sessionId,
|
|
1257
|
+
})
|
|
1258
|
+
}
|
|
1259
|
+
const targetKind = await kindOf(target)
|
|
1260
|
+
if (targetKind !== 'missing') {
|
|
1261
|
+
throw new TrashError('RESTORE_TARGET_EXISTS', 'A directory already exists at the restore target.', {
|
|
1262
|
+
sessionId: member.sessionId,
|
|
1263
|
+
})
|
|
1264
|
+
}
|
|
798
1265
|
}
|
|
799
|
-
|
|
800
|
-
|
|
1266
|
+
const presence = await memberPresence(store, entry)
|
|
1267
|
+
if (presence.present !== presence.total) {
|
|
1268
|
+
const missing = { ...entry, state: 'missing', lastError: 'The trash copy of this session is missing.' }
|
|
801
1269
|
await store.commit(upsertEntry(store.entries(), missing))
|
|
802
1270
|
await store.writeMeta(missing).catch(() => {})
|
|
803
1271
|
throw new TrashError('TRASH_DIR_MISSING', 'The trash copy of this session is missing.')
|
|
@@ -814,76 +1282,56 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
814
1282
|
logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
|
|
815
1283
|
})
|
|
816
1284
|
|
|
1285
|
+
let moveError
|
|
1286
|
+
const rows = members.map((_, index) => ({ index }))
|
|
1287
|
+
const moveFailures = await moveMembersBackFromTrash(restoring, rows)
|
|
1288
|
+
if (moveFailures.length > 0) moveError = moveFailures[0].error
|
|
1289
|
+
let settled
|
|
817
1290
|
try {
|
|
818
|
-
await
|
|
1291
|
+
settled = await resolveRestoring(restoring)
|
|
819
1292
|
} catch (error) {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
}
|
|
1293
|
+
throw error instanceof TrashError
|
|
1294
|
+
? error
|
|
1295
|
+
: new TrashError('IO_ERROR', `Cannot move the session tree back: ${messageOf(error)}`)
|
|
835
1296
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
} catch (error) {
|
|
841
|
-
throw new TrashError('RESTORE_VERIFY_FAILED', `Cannot verify the restored session: ${messageOf(error)}`)
|
|
1297
|
+
if (settled.kind === 'missing') {
|
|
1298
|
+
await store.commit(upsertEntry(store.entries(), settled.entry))
|
|
1299
|
+
await store.writeMeta(settled.entry).catch(() => {})
|
|
1300
|
+
throw new TrashError('RESTORE_VERIFY_FAILED', settled.entry.lastError ?? 'The restored session tree is missing.')
|
|
842
1301
|
}
|
|
843
|
-
if (
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
})
|
|
1302
|
+
if (settled.kind === 'trashed') {
|
|
1303
|
+
await store.commit(upsertEntry(store.entries(), settled.entry))
|
|
1304
|
+
await store.writeMeta(settled.entry).catch(() => {})
|
|
1305
|
+
throw moveError instanceof TrashError
|
|
1306
|
+
? moveError
|
|
1307
|
+
: new TrashError('RESTORE_VERIFY_FAILED', 'The session tree could not be restored.')
|
|
856
1308
|
}
|
|
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
1309
|
|
|
1310
|
+
let attachment
|
|
876
1311
|
try {
|
|
877
|
-
await
|
|
1312
|
+
attachment = await completeRestore(restoring, { mode })
|
|
878
1313
|
} catch (error) {
|
|
879
|
-
|
|
880
|
-
|
|
1314
|
+
if (error instanceof TrashError && error.code === 'RESTORE_VERIFY_FAILED') {
|
|
1315
|
+
const current = await memberRows(restoring)
|
|
1316
|
+
const rollback = await moveMembersIntoTrash(
|
|
1317
|
+
restoring,
|
|
1318
|
+
current.filter((row) => row.source && !row.trash),
|
|
1319
|
+
)
|
|
1320
|
+
if (rollback.length > 0) {
|
|
1321
|
+
await markFailure(restoring, 'The restored session tree is not visible, and rolling it back failed.')
|
|
1322
|
+
throw new TrashError('MOVE_CONFLICT', 'The restored session tree is not visible, and rolling it back failed.')
|
|
1323
|
+
}
|
|
1324
|
+
const next = {
|
|
1325
|
+
...restoring,
|
|
1326
|
+
state: 'trashed',
|
|
1327
|
+
trashedAt: entry.trashedAt,
|
|
1328
|
+
lastError: 'The restored session was not visible to session storage.',
|
|
1329
|
+
}
|
|
1330
|
+
await store.commit(upsertEntry(store.entries(), next))
|
|
1331
|
+
await store.writeMeta(next).catch(() => {})
|
|
1332
|
+
}
|
|
1333
|
+
throw error
|
|
881
1334
|
}
|
|
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
1335
|
logger.info(`restored ${sessionId} from ${entry.trashId} (${mode}) in ${Date.now() - startedAt}ms`)
|
|
888
1336
|
return {
|
|
889
1337
|
sessionId,
|
|
@@ -898,12 +1346,16 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
898
1346
|
/**
|
|
899
1347
|
* Apply the crash matrix to every index entry. The caller must hold the trash
|
|
900
1348
|
* lock. It never deletes an unindexed object and never removes a `missing`
|
|
901
|
-
* entry on its own.
|
|
1349
|
+
* entry on its own. An optional presence map lets the caller reuse the probe
|
|
1350
|
+
* results from the preceding reconcile instead of stat-ing every member
|
|
1351
|
+
* directory again.
|
|
902
1352
|
*
|
|
1353
|
+
* @param {Map<string, { total: number, present: number, missing: string[] }>} [presenceByTrashId] reconcile probe results keyed by object identity.
|
|
903
1354
|
* @returns {Promise<{ changed: number, completed: number, rolledBack: number, missing: number }>} recovery summary.
|
|
904
1355
|
*/
|
|
905
|
-
async function recoverInsideLock() {
|
|
1356
|
+
async function recoverInsideLock(presenceByTrashId = new Map()) {
|
|
906
1357
|
let entries = [...store.entries()]
|
|
1358
|
+
const metaUpdates = []
|
|
907
1359
|
let changed = 0
|
|
908
1360
|
let completed = 0
|
|
909
1361
|
let rolledBack = 0
|
|
@@ -911,6 +1363,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
911
1363
|
const commit = (next) => {
|
|
912
1364
|
if (next === undefined) return
|
|
913
1365
|
entries = upsertEntry(entries, next)
|
|
1366
|
+
metaUpdates.push(next)
|
|
914
1367
|
}
|
|
915
1368
|
const drop = (sessionId) => {
|
|
916
1369
|
entries = dropEntry(entries, sessionId)
|
|
@@ -930,40 +1383,19 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
930
1383
|
|
|
931
1384
|
if (original.state === 'trashing') {
|
|
932
1385
|
await assertSafeObject(original)
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
commit({ ...original, state: 'missing', lastError: 'Both the session source and the trash copy are missing.' })
|
|
1386
|
+
const settled = await resolveTrashing(original)
|
|
1387
|
+
if (settled.kind === 'missing') {
|
|
1388
|
+
commit(settled.entry)
|
|
937
1389
|
missing += 1
|
|
938
|
-
} else if (
|
|
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) {
|
|
1390
|
+
} else if (settled.kind === 'rolledBack') {
|
|
947
1391
|
await store.removeObjectDir(original.trashId).catch((error) => {
|
|
948
1392
|
logger.warn(`cannot remove the rolled-back trash object ${original.trashId}: ${messageOf(error)}`)
|
|
949
1393
|
})
|
|
950
1394
|
drop(original.sessionId)
|
|
951
1395
|
rolledBack += 1
|
|
952
1396
|
} else {
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
}
|
|
1397
|
+
commit({ ...original, state: 'trashed', trashedAt: original.startedAt || Date.now(), lastError: null })
|
|
1398
|
+
completed += 1
|
|
967
1399
|
}
|
|
968
1400
|
changed += 1
|
|
969
1401
|
continue
|
|
@@ -971,15 +1403,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
971
1403
|
|
|
972
1404
|
if (original.state === 'restoring') {
|
|
973
1405
|
await assertSafeObject(original)
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1406
|
+
const settled = await resolveRestoring(original)
|
|
1407
|
+
if (settled.kind === 'missing') {
|
|
1408
|
+
commit(settled.entry)
|
|
1409
|
+
missing += 1
|
|
1410
|
+
} else if (settled.kind === 'trashed') {
|
|
1411
|
+
commit(settled.entry)
|
|
1412
|
+
rolledBack += 1
|
|
1413
|
+
} else {
|
|
1414
|
+
const visible = await waitForMembersVisible(original)
|
|
1415
|
+
if (!visible.visible) {
|
|
1416
|
+
commit(fail(original, 'The restored session tree is not visible to session storage.'))
|
|
983
1417
|
changed += 1
|
|
984
1418
|
continue
|
|
985
1419
|
}
|
|
@@ -988,15 +1422,6 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
988
1422
|
})
|
|
989
1423
|
drop(original.sessionId)
|
|
990
1424
|
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
1425
|
}
|
|
1001
1426
|
changed += 1
|
|
1002
1427
|
continue
|
|
@@ -1004,22 +1429,23 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1004
1429
|
|
|
1005
1430
|
if (original.state === 'trashed') {
|
|
1006
1431
|
await assertSafeObject(original)
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
if (!targetExists) {
|
|
1432
|
+
const presence = presenceByTrashId.get(original.trashId) ?? (await memberPresence(store, original))
|
|
1433
|
+
if (presence.present < presence.total) {
|
|
1010
1434
|
commit({
|
|
1011
1435
|
...original,
|
|
1012
1436
|
state: 'missing',
|
|
1013
|
-
lastError:
|
|
1014
|
-
|
|
1015
|
-
|
|
1437
|
+
lastError:
|
|
1438
|
+
presence.present === 0
|
|
1439
|
+
? 'The trash copy is missing.'
|
|
1440
|
+
: `The trash copy is missing these member sessions: ${presence.missing.join(', ')}.`,
|
|
1016
1441
|
})
|
|
1017
1442
|
missing += 1
|
|
1018
1443
|
changed += 1
|
|
1019
1444
|
continue
|
|
1020
1445
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1446
|
+
const sources = await sourceRows(original)
|
|
1447
|
+
if (sources.some((row) => row.source)) {
|
|
1448
|
+
commit(fail(original, 'An original session directory unexpectedly exists alongside the trash copy; both were kept.'))
|
|
1023
1449
|
changed += 1
|
|
1024
1450
|
}
|
|
1025
1451
|
}
|
|
@@ -1031,7 +1457,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1031
1457
|
|
|
1032
1458
|
if (changed > 0) {
|
|
1033
1459
|
await store.commit(entries)
|
|
1034
|
-
for (const entry of
|
|
1460
|
+
for (const entry of metaUpdates) {
|
|
1035
1461
|
await store.writeMeta(entry).catch((error) => {
|
|
1036
1462
|
logger.warn(`cannot refresh trash metadata for ${entry.trashId}: ${messageOf(error)}`)
|
|
1037
1463
|
})
|
|
@@ -1058,7 +1484,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1058
1484
|
return summary
|
|
1059
1485
|
} catch (error) {
|
|
1060
1486
|
logger.warn(`trash index reconciliation failed: ${messageOf(error)}`)
|
|
1061
|
-
return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0 }
|
|
1487
|
+
return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0, presence: new Map() }
|
|
1062
1488
|
}
|
|
1063
1489
|
}
|
|
1064
1490
|
|
|
@@ -1075,18 +1501,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1075
1501
|
throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses cleanup.')
|
|
1076
1502
|
}
|
|
1077
1503
|
const reconcile = await reconcileInsideLock(now)
|
|
1078
|
-
const recovery = await recoverInsideLock()
|
|
1504
|
+
const recovery = await recoverInsideLock(reconcile.presence)
|
|
1079
1505
|
let purged = 0
|
|
1080
1506
|
let failed = 0
|
|
1081
1507
|
if (allowPurge) {
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
logger.warn(`cannot purge the expired session ${entry.sessionId}: ${messageOf(error)}`)
|
|
1508
|
+
const expired = [...store.entries()].filter((entry) => isExpired(entry, now))
|
|
1509
|
+
if (expired.length > 0) {
|
|
1510
|
+
const result = await purgeManyInsideLock(expired)
|
|
1511
|
+
purged = result.purged
|
|
1512
|
+
failed = result.failures.length
|
|
1513
|
+
for (const failure of result.failures) {
|
|
1514
|
+
logger.warn(`cannot purge the expired session ${failure.entry.sessionId}: ${messageOf(failure.error)}`)
|
|
1090
1515
|
}
|
|
1091
1516
|
}
|
|
1092
1517
|
}
|
|
@@ -1156,34 +1581,59 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1156
1581
|
throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses deletion.')
|
|
1157
1582
|
}
|
|
1158
1583
|
const reconcile = await reconcileInsideLock(Date.now())
|
|
1159
|
-
const recovery = await recoverInsideLock()
|
|
1160
|
-
let purged = 0
|
|
1161
|
-
let forgotten = 0
|
|
1162
|
-
let failed = 0
|
|
1584
|
+
const recovery = await recoverInsideLock(reconcile.presence)
|
|
1163
1585
|
const failures = []
|
|
1586
|
+
const purgeTargets = []
|
|
1587
|
+
const forgetTargets = []
|
|
1588
|
+
let failed = 0
|
|
1164
1589
|
for (const entry of [...store.entries()]) {
|
|
1590
|
+
if (entry.state === 'trashed') {
|
|
1591
|
+
purgeTargets.push(entry)
|
|
1592
|
+
continue
|
|
1593
|
+
}
|
|
1594
|
+
if (entry.state === 'missing') {
|
|
1595
|
+
forgetTargets.push(entry)
|
|
1596
|
+
continue
|
|
1597
|
+
}
|
|
1165
1598
|
const title = typeof entry.title === 'string' ? entry.title : ''
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1599
|
+
const message = `The trash entry is in state "${entry.state}" and cannot be cleaned up.`
|
|
1600
|
+
failed += 1
|
|
1601
|
+
failures.push({ sessionId: entry.sessionId, title, message })
|
|
1602
|
+
logger.warn(`cannot purge the session ${entry.sessionId}: ${message}`)
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
let purged = 0
|
|
1606
|
+
if (purgeTargets.length > 0) {
|
|
1607
|
+
const result = await purgeManyInsideLock(purgeTargets)
|
|
1608
|
+
purged = result.purged
|
|
1609
|
+
for (const failure of result.failures) {
|
|
1177
1610
|
failed += 1
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1611
|
+
failures.push({
|
|
1612
|
+
sessionId: failure.entry.sessionId,
|
|
1613
|
+
title: typeof failure.entry.title === 'string' ? failure.entry.title : '',
|
|
1614
|
+
message: messageOf(failure.error),
|
|
1615
|
+
})
|
|
1616
|
+
logger.warn(`cannot purge the trashed session ${failure.entry.sessionId}: ${messageOf(failure.error)}`)
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
let forgotten = 0
|
|
1621
|
+
if (forgetTargets.length > 0) {
|
|
1622
|
+
try {
|
|
1623
|
+
forgotten = await forgetManyInsideLock(forgetTargets)
|
|
1181
1624
|
} catch (error) {
|
|
1182
|
-
failed +=
|
|
1183
|
-
|
|
1184
|
-
|
|
1625
|
+
failed += forgetTargets.length
|
|
1626
|
+
for (const entry of forgetTargets) {
|
|
1627
|
+
failures.push({
|
|
1628
|
+
sessionId: entry.sessionId,
|
|
1629
|
+
title: typeof entry.title === 'string' ? entry.title : '',
|
|
1630
|
+
message: messageOf(error),
|
|
1631
|
+
})
|
|
1632
|
+
logger.warn(`cannot forget the missing session ${entry.sessionId}: ${messageOf(error)}`)
|
|
1633
|
+
}
|
|
1185
1634
|
}
|
|
1186
1635
|
}
|
|
1636
|
+
|
|
1187
1637
|
logger.info(`bulk purge finished: purged ${purged}, forgotten ${forgotten}, failed ${failed}`)
|
|
1188
1638
|
return {
|
|
1189
1639
|
purged,
|
|
@@ -1272,8 +1722,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1272
1722
|
try {
|
|
1273
1723
|
summary = await store.withLock(async () => {
|
|
1274
1724
|
if (store.isProtected()) return { changed: 0, completed: 0, rolledBack: 0, missing: 0 }
|
|
1275
|
-
await reconcileInsideLock(Date.now())
|
|
1276
|
-
return await recoverInsideLock()
|
|
1725
|
+
const reconcile = await reconcileInsideLock(Date.now())
|
|
1726
|
+
return await recoverInsideLock(reconcile.presence)
|
|
1277
1727
|
})
|
|
1278
1728
|
} catch (error) {
|
|
1279
1729
|
if (error instanceof TrashError && error.code === 'LOCKED') {
|