@wangxiaocuo/dsh-session-trash 0.5.1 → 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/README.en.md +16 -6
- package/README.md +16 -7
- package/client.js +62 -4
- package/lib/eligibility.js +150 -52
- package/lib/fsops.js +19 -0
- package/lib/http.js +5 -0
- package/lib/inventory.js +175 -20
- package/lib/members.js +95 -0
- package/lib/state.js +8 -0
- package/lib/store.js +69 -8
- package/lib/trash-service.js +617 -320
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
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,8 @@ import {
|
|
|
12
13
|
moveDirectory,
|
|
13
14
|
samePath,
|
|
14
15
|
} from './fsops.js'
|
|
15
|
-
import { reconcileIndex, rebuildIndex } from './inventory.js'
|
|
16
|
+
import { reconcileIndex, rebuildIndex, memberPresence } from './inventory.js'
|
|
17
|
+
import { aggregateMetrics, memberList } from './members.js'
|
|
16
18
|
|
|
17
19
|
/** How many short retries confirm a moved or restored session is visible. */
|
|
18
20
|
const VISIBILITY_RETRIES = 5
|
|
@@ -170,9 +172,34 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
170
172
|
return undefined
|
|
171
173
|
}
|
|
172
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
|
+
|
|
173
200
|
/**
|
|
174
201
|
* @param {object} entry trash index entry.
|
|
175
|
-
* @returns {Promise<void>} resolution once
|
|
202
|
+
* @returns {Promise<void>} resolution once every member path stays inside `objects/`.
|
|
176
203
|
*/
|
|
177
204
|
async function assertSafeObject(entry) {
|
|
178
205
|
if (!isSafeTrashId(entry.trashId)) {
|
|
@@ -182,14 +209,202 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
182
209
|
if (!isInside(store.objectsDir, objectDir)) {
|
|
183
210
|
throw new TrashError('IO_ERROR', 'The trash entry names an object outside the trash root.')
|
|
184
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
|
+
}
|
|
185
219
|
}
|
|
186
220
|
|
|
187
221
|
/**
|
|
188
|
-
* @param {object} entry
|
|
189
|
-
* @returns {Promise<
|
|
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.
|
|
190
328
|
*/
|
|
191
|
-
async function
|
|
192
|
-
|
|
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 }
|
|
193
408
|
}
|
|
194
409
|
|
|
195
410
|
/**
|
|
@@ -419,98 +634,185 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
419
634
|
}
|
|
420
635
|
|
|
421
636
|
/**
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
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.
|
|
425
640
|
*
|
|
426
|
-
* @param {object} entry
|
|
427
|
-
* @
|
|
428
|
-
* @returns {Promise<object | undefined>} the completed entry, or undefined after rollback.
|
|
641
|
+
* @param {object} entry `trashing` index entry.
|
|
642
|
+
* @returns {Promise<{ kind: 'ready' } | { kind: 'rolledBack' } | { kind: 'missing', entry: object }>} the settled position.
|
|
429
643
|
*/
|
|
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 })
|
|
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
|
+
},
|
|
443
655
|
}
|
|
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
656
|
}
|
|
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
|
-
}
|
|
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
|
|
465
663
|
} else {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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.')
|
|
469
679
|
}
|
|
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
680
|
}
|
|
475
|
-
|
|
476
|
-
|
|
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' }
|
|
477
690
|
}
|
|
478
691
|
|
|
479
692
|
/**
|
|
480
|
-
*
|
|
481
|
-
*
|
|
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.
|
|
482
696
|
*
|
|
483
|
-
* @param {object} entry `restoring` entry.
|
|
484
|
-
* @
|
|
485
|
-
* @returns {Promise<boolean>} whether the target now contains the restored session.
|
|
697
|
+
* @param {object} entry `restoring` index entry.
|
|
698
|
+
* @returns {Promise<{ kind: 'ready' } | { kind: 'trashed', entry: object } | { kind: 'missing', entry: object }>} the settled position.
|
|
486
699
|
*/
|
|
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
|
|
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
|
+
},
|
|
502
711
|
}
|
|
503
|
-
await markFailure(entry, `The restored session does not match its recorded size (${failure}).`)
|
|
504
|
-
return false
|
|
505
712
|
}
|
|
506
|
-
|
|
507
|
-
|
|
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 {
|
|
508
766
|
await store.commit(upsertEntry(store.entries(), next))
|
|
509
|
-
|
|
510
|
-
|
|
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.')
|
|
511
770
|
}
|
|
512
|
-
await
|
|
513
|
-
|
|
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
|
|
514
816
|
}
|
|
515
817
|
|
|
516
818
|
/**
|
|
@@ -576,14 +878,15 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
576
878
|
}
|
|
577
879
|
|
|
578
880
|
/**
|
|
579
|
-
* Move one cold session into the trash.
|
|
881
|
+
* Move one cold session tree into the trash. A session that owns only hidden
|
|
882
|
+
* `origin: 'subagent'` descendants moves together with them.
|
|
580
883
|
*
|
|
581
884
|
* @param {string} sessionId session to trash.
|
|
582
885
|
* @returns {Promise<object>} the committed `trashed` entry.
|
|
583
886
|
*/
|
|
584
887
|
async function trash(sessionId) {
|
|
585
888
|
const startedAt = Date.now()
|
|
586
|
-
|
|
889
|
+
return store.withLock(async () => {
|
|
587
890
|
if (store.isProtected()) {
|
|
588
891
|
throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
|
|
589
892
|
}
|
|
@@ -603,28 +906,65 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
603
906
|
throw new TrashError('ALREADY_TRASHED', 'This session is already recorded in the trash.')
|
|
604
907
|
}
|
|
605
908
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
+
})
|
|
621
961
|
}
|
|
622
962
|
|
|
623
963
|
let trashId
|
|
624
964
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
625
965
|
const candidate = createTrashId()
|
|
626
966
|
const indexed = store.entries().some((entry) => entry.trashId === candidate)
|
|
627
|
-
if (!indexed && (await
|
|
967
|
+
if (!indexed && (await kindOf(store.objectDir(candidate))) === 'missing') {
|
|
628
968
|
trashId = candidate
|
|
629
969
|
break
|
|
630
970
|
}
|
|
@@ -639,32 +979,34 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
639
979
|
phase: 'create-object',
|
|
640
980
|
})
|
|
641
981
|
}
|
|
642
|
-
|
|
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
|
+
}
|
|
643
1009
|
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
1010
|
await store.writeMeta(entry)
|
|
669
1011
|
await store.commit(upsertEntry(store.entries(), entry))
|
|
670
1012
|
} catch (error) {
|
|
@@ -674,88 +1016,69 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
674
1016
|
: new TrashError('IO_ERROR', `Cannot prepare the trash entry: ${messageOf(error)}`)
|
|
675
1017
|
}
|
|
676
1018
|
|
|
677
|
-
// Another process may have opened or resumed
|
|
678
|
-
// preflight and the move. Re-check
|
|
679
|
-
// held; the
|
|
680
|
-
|
|
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) {
|
|
681
1024
|
await rollbackTrash(sessionId, trashId)
|
|
682
|
-
throw new TrashError('SESSION_LIVE', '
|
|
1025
|
+
throw new TrashError('SESSION_LIVE', 'A session in this lineage became live before it could be moved.', {
|
|
1026
|
+
sessionId: raced.sessionId,
|
|
1027
|
+
})
|
|
683
1028
|
}
|
|
684
1029
|
|
|
1030
|
+
let moveError
|
|
685
1031
|
try {
|
|
686
|
-
await
|
|
1032
|
+
await moveMembersToTrash(entry)
|
|
687
1033
|
} 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)}`)
|
|
1034
|
+
moveError = error
|
|
699
1035
|
}
|
|
700
|
-
|
|
701
|
-
let measured
|
|
1036
|
+
let settled
|
|
702
1037
|
try {
|
|
703
|
-
|
|
1038
|
+
settled = await resolveTrashing(entry)
|
|
704
1039
|
} catch (error) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
} catch (settleError) {
|
|
709
|
-
await markFailure(entry, messageOf(settleError))
|
|
710
|
-
throw settleError
|
|
711
|
-
}
|
|
712
|
-
if (settled !== undefined) return settled
|
|
713
|
-
throw new TrashError('MOVE_VERIFY_FAILED', `Cannot verify the moved session: ${messageOf(error)}`)
|
|
1040
|
+
throw error instanceof TrashError
|
|
1041
|
+
? error
|
|
1042
|
+
: new TrashError('IO_ERROR', `Cannot move the session tree into the trash: ${messageOf(error)}`)
|
|
714
1043
|
}
|
|
715
|
-
if (
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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,
|
|
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,
|
|
725
1050
|
})
|
|
726
1051
|
}
|
|
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
|
-
}
|
|
1052
|
+
if (settled.kind === 'rolledBack') {
|
|
734
1053
|
await rollbackTrash(sessionId, trashId)
|
|
735
|
-
throw
|
|
1054
|
+
throw moveError instanceof TrashError
|
|
1055
|
+
? moveError
|
|
1056
|
+
: new TrashError('MOVE_VERIFY_FAILED', 'The session tree could not be moved into the trash.')
|
|
736
1057
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
await
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
+
})
|
|
744
1070
|
}
|
|
745
|
-
await
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
emit('api-session/removed', sessionId)
|
|
750
|
-
logger.info(`trashed ${sessionId} as ${trashId} in ${Date.now() - startedAt}ms`)
|
|
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
|
+
)
|
|
751
1075
|
return next
|
|
752
1076
|
})
|
|
753
|
-
return result
|
|
754
1077
|
}
|
|
755
1078
|
|
|
756
1079
|
/**
|
|
757
|
-
* Restore one trashed session to its original
|
|
758
|
-
* `normal` only; orphan restore arrives later.
|
|
1080
|
+
* Restore one trashed session tree to its original directories. Phase 2
|
|
1081
|
+
* supports `normal` only; orphan restore arrives later.
|
|
759
1082
|
*
|
|
760
1083
|
* @param {string} sessionId session to restore.
|
|
761
1084
|
* @param {'normal' | 'orphan'} [mode] restore mode; orphan restores the log and detaches the Workspace.
|
|
@@ -776,28 +1099,49 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
776
1099
|
throw new TrashError('TRASH_STATE_INVALID', `The trash entry is in state "${entry.state}" and cannot be restored.`)
|
|
777
1100
|
}
|
|
778
1101
|
await assertSafeObject(entry)
|
|
779
|
-
const
|
|
780
|
-
const
|
|
1102
|
+
const members = membersOf(entry)
|
|
1103
|
+
const parent = members[0]
|
|
781
1104
|
if (
|
|
782
|
-
!
|
|
783
|
-
!samePath(path.
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
+
)
|
|
788
1111
|
) {
|
|
789
|
-
throw new TrashError('UNSAFE_RESTORE_PATH', 'The
|
|
1112
|
+
throw new TrashError('UNSAFE_RESTORE_PATH', 'The trash entry names inconsistent restore paths.')
|
|
790
1113
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
+
}
|
|
798
1141
|
}
|
|
799
|
-
|
|
800
|
-
|
|
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.' }
|
|
801
1145
|
await store.commit(upsertEntry(store.entries(), missing))
|
|
802
1146
|
await store.writeMeta(missing).catch(() => {})
|
|
803
1147
|
throw new TrashError('TRASH_DIR_MISSING', 'The trash copy of this session is missing.')
|
|
@@ -814,76 +1158,56 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
814
1158
|
logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
|
|
815
1159
|
})
|
|
816
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
|
|
817
1166
|
try {
|
|
818
|
-
await
|
|
819
|
-
} catch (error) {
|
|
820
|
-
// A target created between the pre-check and the move belongs to
|
|
821
|
-
// someone else: never treat it as the restored copy.
|
|
822
|
-
if (error instanceof TrashError && error.code === 'RESTORE_TARGET_EXISTS') throw error
|
|
823
|
-
let completed
|
|
824
|
-
try {
|
|
825
|
-
completed = await settleFailedRestoreMove(restoring, error)
|
|
826
|
-
} catch (settleError) {
|
|
827
|
-
await markFailure(restoring, messageOf(settleError))
|
|
828
|
-
throw settleError
|
|
829
|
-
}
|
|
830
|
-
if (!completed) {
|
|
831
|
-
throw error instanceof TrashError
|
|
832
|
-
? error
|
|
833
|
-
: new TrashError('IO_ERROR', `Cannot move the session back: ${messageOf(error)}`)
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
let measured
|
|
838
|
-
try {
|
|
839
|
-
measured = await measureDirectory(target)
|
|
1167
|
+
settled = await resolveRestoring(restoring)
|
|
840
1168
|
} catch (error) {
|
|
841
|
-
throw
|
|
1169
|
+
throw error instanceof TrashError
|
|
1170
|
+
? error
|
|
1171
|
+
: new TrashError('IO_ERROR', `Cannot move the session tree back: ${messageOf(error)}`)
|
|
842
1172
|
}
|
|
843
|
-
if (
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
throw new TrashError('MOVE_CONFLICT', 'The restored session does not match its recorded size.')
|
|
848
|
-
}
|
|
849
|
-
const next = { ...restoring, state: 'trashed', lastError: 'The restored session did not match its recorded size.' }
|
|
850
|
-
await store.commit(upsertEntry(store.entries(), next))
|
|
851
|
-
await store.writeMeta(next).catch(() => {})
|
|
852
|
-
throw new TrashError('RESTORE_VERIFY_FAILED', 'The restored session does not match its recorded size.', {
|
|
853
|
-
expected: { sizeBytes: entry.sizeBytes, fileCount: entry.fileCount },
|
|
854
|
-
measured,
|
|
855
|
-
})
|
|
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.')
|
|
856
1177
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
(
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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.')
|
|
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.')
|
|
871
1184
|
}
|
|
872
1185
|
|
|
873
|
-
|
|
874
|
-
const restoredSnapshot = await stat(sessionId).catch(() => undefined)
|
|
875
|
-
|
|
1186
|
+
let attachment
|
|
876
1187
|
try {
|
|
877
|
-
await
|
|
1188
|
+
attachment = await completeRestore(restoring, { mode })
|
|
878
1189
|
} catch (error) {
|
|
879
|
-
|
|
880
|
-
|
|
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
|
|
881
1210
|
}
|
|
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
1211
|
logger.info(`restored ${sessionId} from ${entry.trashId} (${mode}) in ${Date.now() - startedAt}ms`)
|
|
888
1212
|
return {
|
|
889
1213
|
sessionId,
|
|
@@ -930,40 +1254,19 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
930
1254
|
|
|
931
1255
|
if (original.state === 'trashing') {
|
|
932
1256
|
await assertSafeObject(original)
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
commit({ ...original, state: 'missing', lastError: 'Both the session source and the trash copy are missing.' })
|
|
1257
|
+
const settled = await resolveTrashing(original)
|
|
1258
|
+
if (settled.kind === 'missing') {
|
|
1259
|
+
commit(settled.entry)
|
|
937
1260
|
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) {
|
|
1261
|
+
} else if (settled.kind === 'rolledBack') {
|
|
947
1262
|
await store.removeObjectDir(original.trashId).catch((error) => {
|
|
948
1263
|
logger.warn(`cannot remove the rolled-back trash object ${original.trashId}: ${messageOf(error)}`)
|
|
949
1264
|
})
|
|
950
1265
|
drop(original.sessionId)
|
|
951
1266
|
rolledBack += 1
|
|
952
1267
|
} 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
|
-
}
|
|
1268
|
+
commit({ ...original, state: 'trashed', trashedAt: original.startedAt || Date.now(), lastError: null })
|
|
1269
|
+
completed += 1
|
|
967
1270
|
}
|
|
968
1271
|
changed += 1
|
|
969
1272
|
continue
|
|
@@ -971,15 +1274,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
971
1274
|
|
|
972
1275
|
if (original.state === 'restoring') {
|
|
973
1276
|
await assertSafeObject(original)
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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.'))
|
|
983
1288
|
changed += 1
|
|
984
1289
|
continue
|
|
985
1290
|
}
|
|
@@ -988,15 +1293,6 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
988
1293
|
})
|
|
989
1294
|
drop(original.sessionId)
|
|
990
1295
|
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
1296
|
}
|
|
1001
1297
|
changed += 1
|
|
1002
1298
|
continue
|
|
@@ -1004,22 +1300,23 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1004
1300
|
|
|
1005
1301
|
if (original.state === 'trashed') {
|
|
1006
1302
|
await assertSafeObject(original)
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
if (!targetExists) {
|
|
1303
|
+
const presence = await memberPresence(store, original)
|
|
1304
|
+
if (presence.present < presence.total) {
|
|
1010
1305
|
commit({
|
|
1011
1306
|
...original,
|
|
1012
1307
|
state: 'missing',
|
|
1013
|
-
lastError:
|
|
1014
|
-
|
|
1015
|
-
|
|
1308
|
+
lastError:
|
|
1309
|
+
presence.present === 0
|
|
1310
|
+
? 'The trash copy is missing.'
|
|
1311
|
+
: `The trash copy is missing these member sessions: ${presence.missing.join(', ')}.`,
|
|
1016
1312
|
})
|
|
1017
1313
|
missing += 1
|
|
1018
1314
|
changed += 1
|
|
1019
1315
|
continue
|
|
1020
1316
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
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.'))
|
|
1023
1320
|
changed += 1
|
|
1024
1321
|
}
|
|
1025
1322
|
}
|