@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/inventory.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { entryKind, isSafeDirectoryPath, isSafeTrashId, samePath } from './fsops.js'
|
|
3
|
+
import { entryKind, isSafeDirectoryPath, isSafeMemberId, isSafeTrashId, samePath } from './fsops.js'
|
|
4
4
|
|
|
5
5
|
/** Every state the durable index understands. */
|
|
6
6
|
export const ENTRY_STATES = new Set(['trashing', 'trashed', 'restoring', 'purging', 'missing'])
|
|
7
7
|
|
|
8
8
|
/** Sidecar schema version understood by this release. */
|
|
9
|
-
const META_VERSION =
|
|
9
|
+
const META_VERSION = 2
|
|
10
|
+
|
|
11
|
+
/** Maximum directories one trashed item may own. */
|
|
12
|
+
const MAX_MEMBERS = 64
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* @param {unknown} value candidate timestamp.
|
|
@@ -16,6 +19,77 @@ function isFiniteNumber(value) {
|
|
|
16
19
|
return typeof value === 'number' && Number.isFinite(value)
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* @param {unknown} value candidate metric.
|
|
24
|
+
* @returns {boolean} whether the value is a usable number or absent.
|
|
25
|
+
*/
|
|
26
|
+
function isMetric(value) {
|
|
27
|
+
return value === undefined || value === null || isFiniteNumber(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate one v2 member's absolute paths. The parent's sidecar is validated
|
|
32
|
+
* field by field so a hand-edited file is rejected instead of guessed at.
|
|
33
|
+
*
|
|
34
|
+
* @param {unknown} member candidate member.
|
|
35
|
+
* @returns {boolean} whether the member names safe, coherent paths.
|
|
36
|
+
*/
|
|
37
|
+
function isValidMemberPaths(member) {
|
|
38
|
+
if (member === null || typeof member !== 'object' || Array.isArray(member)) return false
|
|
39
|
+
if (!isSafeMemberId(member.sessionId)) return false
|
|
40
|
+
if (member.origin !== undefined && member.origin !== null && member.origin !== 'subagent') return false
|
|
41
|
+
if (member.title !== undefined && typeof member.title !== 'string') return false
|
|
42
|
+
if (
|
|
43
|
+
member.formatStatus !== undefined &&
|
|
44
|
+
member.formatStatus !== 'current' &&
|
|
45
|
+
member.formatStatus !== 'migration-required'
|
|
46
|
+
) {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
if (!isMetric(member.sizeBytes) || !isMetric(member.fileCount)) return false
|
|
50
|
+
if (!isSafeDirectoryPath(member.originalSessionDir)) return false
|
|
51
|
+
if (typeof member.originalLogPath !== 'string' || !path.isAbsolute(member.originalLogPath)) return false
|
|
52
|
+
if (
|
|
53
|
+
typeof member.persistenceProjectDirHint !== 'string' ||
|
|
54
|
+
!path.isAbsolute(member.persistenceProjectDirHint)
|
|
55
|
+
) {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
if (!samePath(path.dirname(path.resolve(member.originalSessionDir)), path.resolve(member.persistenceProjectDirHint))) {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
if (!samePath(path.dirname(path.resolve(member.originalLogPath)), path.resolve(member.originalSessionDir))) {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate the v2 member list of one sidecar.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} meta candidate sidecar.
|
|
71
|
+
* @returns {boolean} whether the list is coherent with its parent fields.
|
|
72
|
+
*/
|
|
73
|
+
function isValidMetaMembers(meta) {
|
|
74
|
+
if (!Array.isArray(meta.members) || meta.members.length === 0 || meta.members.length > MAX_MEMBERS) return false
|
|
75
|
+
if (!meta.members.every((member) => isValidMemberPaths(member))) return false
|
|
76
|
+
const seen = new Set()
|
|
77
|
+
for (const member of meta.members) {
|
|
78
|
+
if (seen.has(member.sessionId)) return false
|
|
79
|
+
seen.add(member.sessionId)
|
|
80
|
+
}
|
|
81
|
+
const parent = meta.members[0]
|
|
82
|
+
if (parent.sessionId !== meta.sessionId) return false
|
|
83
|
+
if (!samePath(path.resolve(parent.originalSessionDir), path.resolve(meta.originalSessionDir))) return false
|
|
84
|
+
if (!samePath(path.resolve(parent.originalLogPath), path.resolve(meta.originalLogPath))) return false
|
|
85
|
+
if (
|
|
86
|
+
!samePath(path.resolve(parent.persistenceProjectDirHint), path.resolve(meta.persistenceProjectDirHint))
|
|
87
|
+
) {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
19
93
|
/**
|
|
20
94
|
* Validate one object sidecar strictly enough to be rebuilt into an index
|
|
21
95
|
* entry. A malformed sidecar is reported as an unindexed object instead of
|
|
@@ -27,7 +101,7 @@ function isFiniteNumber(value) {
|
|
|
27
101
|
*/
|
|
28
102
|
export function isValidMeta(meta, trashId) {
|
|
29
103
|
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return false
|
|
30
|
-
if (meta.version !== undefined && meta.version !== META_VERSION) return false
|
|
104
|
+
if (meta.version !== undefined && meta.version !== META_VERSION && meta.version !== 1) return false
|
|
31
105
|
if (typeof meta.sessionId !== 'string' || meta.sessionId === '' || meta.sessionId.length > 256) return false
|
|
32
106
|
if (meta.trashId !== trashId) return false
|
|
33
107
|
if (!ENTRY_STATES.has(meta.state)) return false
|
|
@@ -48,20 +122,58 @@ export function isValidMeta(meta, trashId) {
|
|
|
48
122
|
) {
|
|
49
123
|
return false
|
|
50
124
|
}
|
|
125
|
+
if (meta.version === META_VERSION) return isValidMetaMembers(meta)
|
|
51
126
|
return true
|
|
52
127
|
}
|
|
53
128
|
|
|
129
|
+
/**
|
|
130
|
+
* @param {Record<string, unknown>} member raw member fields.
|
|
131
|
+
* @param {string} fallbackTitle title used when the member carries none.
|
|
132
|
+
* @returns {object} one normalized v2 member.
|
|
133
|
+
*/
|
|
134
|
+
function toMember(member, fallbackTitle) {
|
|
135
|
+
return {
|
|
136
|
+
sessionId: member.sessionId,
|
|
137
|
+
origin: member.origin === 'subagent' ? 'subagent' : null,
|
|
138
|
+
title: typeof member.title === 'string' && member.title !== '' ? member.title : fallbackTitle,
|
|
139
|
+
originalSessionDir: path.resolve(member.originalSessionDir),
|
|
140
|
+
originalLogPath: path.resolve(member.originalLogPath),
|
|
141
|
+
persistenceProjectDirHint: path.resolve(member.persistenceProjectDirHint),
|
|
142
|
+
formatStatus: member.formatStatus === 'migration-required' ? 'migration-required' : 'current',
|
|
143
|
+
sizeBytes: isFiniteNumber(member.sizeBytes) ? member.sizeBytes : null,
|
|
144
|
+
fileCount: isFiniteNumber(member.fileCount) ? member.fileCount : null,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
54
148
|
/**
|
|
55
149
|
* @param {Record<string, unknown>} meta valid sidecar.
|
|
56
150
|
* @returns {object} a clean index entry with defaulted optional fields.
|
|
57
151
|
*/
|
|
58
|
-
function toEntry(meta) {
|
|
152
|
+
export function toEntry(meta) {
|
|
59
153
|
const originalCwd = typeof meta.originalCwd === 'string' && meta.originalCwd !== '' ? meta.originalCwd : null
|
|
154
|
+
const title = typeof meta.title === 'string' && meta.title !== '' ? meta.title : meta.sessionId
|
|
155
|
+
const members =
|
|
156
|
+
Array.isArray(meta.members) && meta.members.length > 0
|
|
157
|
+
? meta.members.map((member) => toMember(member, title))
|
|
158
|
+
: [
|
|
159
|
+
toMember(
|
|
160
|
+
{
|
|
161
|
+
sessionId: meta.sessionId,
|
|
162
|
+
originalSessionDir: meta.originalSessionDir,
|
|
163
|
+
originalLogPath: meta.originalLogPath,
|
|
164
|
+
persistenceProjectDirHint: meta.persistenceProjectDirHint,
|
|
165
|
+
formatStatus: meta.formatStatus,
|
|
166
|
+
sizeBytes: meta.sizeBytes,
|
|
167
|
+
fileCount: meta.fileCount,
|
|
168
|
+
},
|
|
169
|
+
title,
|
|
170
|
+
),
|
|
171
|
+
]
|
|
60
172
|
return {
|
|
61
173
|
sessionId: meta.sessionId,
|
|
62
174
|
trashId: meta.trashId,
|
|
63
175
|
state: meta.state,
|
|
64
|
-
title
|
|
176
|
+
title,
|
|
65
177
|
trashedAt: meta.trashedAt,
|
|
66
178
|
startedAt: meta.startedAt,
|
|
67
179
|
originalCwd,
|
|
@@ -75,25 +187,59 @@ function toEntry(meta) {
|
|
|
75
187
|
formatStatus: meta.formatStatus === 'migration-required' ? 'migration-required' : 'current',
|
|
76
188
|
sizeBytes: isFiniteNumber(meta.sizeBytes) ? meta.sizeBytes : null,
|
|
77
189
|
fileCount: isFiniteNumber(meta.fileCount) ? meta.fileCount : null,
|
|
190
|
+
members,
|
|
78
191
|
lastError: typeof meta.lastError === 'string' && meta.lastError !== '' ? meta.lastError : null,
|
|
79
192
|
}
|
|
80
193
|
}
|
|
81
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Which of one entry's member directories currently exist inside its object.
|
|
197
|
+
*
|
|
198
|
+
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
199
|
+
* @param {object} entry index entry, v1 or v2 shaped.
|
|
200
|
+
* @returns {Promise<{ total: number, present: number, missing: string[] }>} presence summary.
|
|
201
|
+
*/
|
|
202
|
+
export async function memberPresence(store, entry) {
|
|
203
|
+
const members =
|
|
204
|
+
Array.isArray(entry.members) && entry.members.length > 0
|
|
205
|
+
? entry.members
|
|
206
|
+
: [{ sessionId: entry.sessionId, originalSessionDir: entry.originalSessionDir }]
|
|
207
|
+
const missing = []
|
|
208
|
+
let present = 0
|
|
209
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
210
|
+
const member = members[index]
|
|
211
|
+
let kind = 'missing'
|
|
212
|
+
try {
|
|
213
|
+
kind = await entryKind(store.memberPath(entry.trashId, index, member.sessionId))
|
|
214
|
+
} catch {
|
|
215
|
+
kind = 'missing'
|
|
216
|
+
}
|
|
217
|
+
if (kind === 'directory') present += 1
|
|
218
|
+
else missing.push(typeof member.sessionId === 'string' ? member.sessionId : entry.sessionId)
|
|
219
|
+
}
|
|
220
|
+
return { total: members.length, present, missing }
|
|
221
|
+
}
|
|
222
|
+
|
|
82
223
|
/**
|
|
83
224
|
* Reconcile a rebuilt entry with the object directory observed on disk.
|
|
84
225
|
*
|
|
85
226
|
* @param {object} entry index-shaped entry.
|
|
86
|
-
* @param {
|
|
227
|
+
* @param {{ total: number, present: number, missing: string[] }} presence member presence.
|
|
87
228
|
* @param {number} now current timestamp.
|
|
88
229
|
* @returns {object} the entry whose state matches the filesystem.
|
|
89
230
|
*/
|
|
90
|
-
export function deriveEntry(entry,
|
|
91
|
-
|
|
231
|
+
export function deriveEntry(entry, presence, now) {
|
|
232
|
+
const complete = presence.total > 0 && presence.present === presence.total
|
|
233
|
+
if (!complete) {
|
|
92
234
|
if (entry.state === 'purging' || entry.state === 'missing') return { ...entry }
|
|
235
|
+
const lastError =
|
|
236
|
+
presence.present === 0
|
|
237
|
+
? 'The trash copy of this session is missing.'
|
|
238
|
+
: `The trash copy is missing these member sessions: ${presence.missing.join(', ')}.`
|
|
93
239
|
return {
|
|
94
240
|
...entry,
|
|
95
241
|
state: 'missing',
|
|
96
|
-
lastError
|
|
242
|
+
lastError,
|
|
97
243
|
}
|
|
98
244
|
}
|
|
99
245
|
if (entry.state === 'missing') {
|
|
@@ -115,7 +261,7 @@ export function deriveEntry(entry, sessionExists, now) {
|
|
|
115
261
|
*
|
|
116
262
|
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
117
263
|
* @param {{ warn: Function }} logger diagnostics sink.
|
|
118
|
-
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean }[], unindexed: number }>} the inventory.
|
|
264
|
+
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean, entry?: object, presence?: object }[], unindexed: number }>} the inventory.
|
|
119
265
|
*/
|
|
120
266
|
export async function scanObjects(store, logger) {
|
|
121
267
|
let entries
|
|
@@ -150,13 +296,21 @@ export async function scanObjects(store, logger) {
|
|
|
150
296
|
unindexed += 1
|
|
151
297
|
continue
|
|
152
298
|
}
|
|
153
|
-
let
|
|
299
|
+
let presence
|
|
154
300
|
try {
|
|
155
|
-
|
|
301
|
+
presence = await memberPresence(store, toEntry(meta))
|
|
156
302
|
} catch (error) {
|
|
157
303
|
logger.warn(`cannot inspect the trash copy ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
304
|
+
presence = { total: 0, present: 0, missing: [] }
|
|
158
305
|
}
|
|
159
|
-
objects.push({
|
|
306
|
+
objects.push({
|
|
307
|
+
trashId,
|
|
308
|
+
sessionExists: presence.total > 0 && presence.present === presence.total,
|
|
309
|
+
meta,
|
|
310
|
+
metaValid,
|
|
311
|
+
entry: toEntry(meta),
|
|
312
|
+
presence,
|
|
313
|
+
})
|
|
160
314
|
}
|
|
161
315
|
return { objects, unindexed }
|
|
162
316
|
}
|
|
@@ -197,7 +351,8 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
|
197
351
|
changed = true
|
|
198
352
|
continue
|
|
199
353
|
}
|
|
200
|
-
|
|
354
|
+
const presence = await memberPresence(store, entry)
|
|
355
|
+
if (entry.state === 'missing' && presence.total > 0 && presence.present === presence.total) {
|
|
201
356
|
nextEntries[index] = {
|
|
202
357
|
...entry,
|
|
203
358
|
state: 'trashed',
|
|
@@ -211,15 +366,15 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
|
211
366
|
|
|
212
367
|
for (const object of objects) {
|
|
213
368
|
if (indexedTrashIds.has(object.trashId)) continue
|
|
214
|
-
if (!object.metaValid || object.meta === undefined) continue
|
|
215
|
-
const entry =
|
|
369
|
+
if (!object.metaValid || object.meta === undefined || object.entry === undefined || object.presence === undefined) continue
|
|
370
|
+
const entry = object.entry
|
|
216
371
|
if (sessionIds.has(entry.sessionId)) {
|
|
217
372
|
// Two object directories claim the same session: keep the indexed one.
|
|
218
373
|
unindexed += 1
|
|
219
374
|
logger.warn(`the trash object ${object.trashId} duplicates the indexed session ${entry.sessionId}`)
|
|
220
375
|
continue
|
|
221
376
|
}
|
|
222
|
-
const derived = deriveEntry(entry, object.
|
|
377
|
+
const derived = deriveEntry(entry, object.presence, now)
|
|
223
378
|
nextEntries.push(derived)
|
|
224
379
|
indexedTrashIds.add(derived.trashId)
|
|
225
380
|
sessionIds.add(derived.sessionId)
|
|
@@ -254,15 +409,15 @@ export async function rebuildIndex({ store, logger, now = Date.now() }) {
|
|
|
254
409
|
const seen = new Set()
|
|
255
410
|
let unindexed = invalidObjects
|
|
256
411
|
for (const object of objects) {
|
|
257
|
-
if (!object.metaValid || object.meta === undefined) continue
|
|
258
|
-
const entry =
|
|
412
|
+
if (!object.metaValid || object.meta === undefined || object.entry === undefined || object.presence === undefined) continue
|
|
413
|
+
const entry = object.entry
|
|
259
414
|
if (seen.has(entry.sessionId)) {
|
|
260
415
|
unindexed += 1
|
|
261
416
|
logger.warn(`the trash object ${object.trashId} duplicates the rebuilt session ${entry.sessionId}`)
|
|
262
417
|
continue
|
|
263
418
|
}
|
|
264
419
|
seen.add(entry.sessionId)
|
|
265
|
-
entries.push(deriveEntry(entry, object.
|
|
420
|
+
entries.push(deriveEntry(entry, object.presence, now))
|
|
266
421
|
}
|
|
267
422
|
entries.sort((left, right) => left.startedAt - right.startedAt || left.trashId.localeCompare(right.trashId))
|
|
268
423
|
await store.commit(entries)
|
package/lib/members.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One trashed item may own several session directories: the visible parent and
|
|
3
|
+
* every hidden `origin: 'subagent'` descendant. These helpers keep the v1
|
|
4
|
+
* single-directory shape readable while the durable index uses the v2
|
|
5
|
+
* `members` array.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-session-trash/members
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {unknown} entry index entry (v1 or v2 shape).
|
|
12
|
+
* @returns {object|null} the parent member, or null when the entry has no directory data.
|
|
13
|
+
*/
|
|
14
|
+
export function parentMember(entry) {
|
|
15
|
+
if (entry === null || typeof entry !== 'object') return null
|
|
16
|
+
if (typeof entry.sessionId !== 'string' || entry.sessionId === '') return null
|
|
17
|
+
return {
|
|
18
|
+
sessionId: entry.sessionId,
|
|
19
|
+
origin: null,
|
|
20
|
+
title: typeof entry.title === 'string' ? entry.title : entry.sessionId,
|
|
21
|
+
originalSessionDir: entry.originalSessionDir,
|
|
22
|
+
originalLogPath: entry.originalLogPath,
|
|
23
|
+
persistenceProjectDirHint: entry.persistenceProjectDirHint,
|
|
24
|
+
formatStatus: entry.formatStatus,
|
|
25
|
+
sizeBytes: entry.sizeBytes,
|
|
26
|
+
fileCount: entry.fileCount,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {unknown} entry index entry (v1 or v2 shape).
|
|
32
|
+
* @returns {object[]} every owned directory, parent first.
|
|
33
|
+
*/
|
|
34
|
+
export function memberList(entry) {
|
|
35
|
+
if (entry !== null && typeof entry === 'object' && Array.isArray(entry.members) && entry.members.length > 0) {
|
|
36
|
+
return entry.members
|
|
37
|
+
}
|
|
38
|
+
const parent = parentMember(entry)
|
|
39
|
+
return parent === null ? [] : [parent]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {readonly object[]} members member list.
|
|
44
|
+
* @returns {{ sizeBytes: number, fileCount: number }} summed measurable totals.
|
|
45
|
+
*/
|
|
46
|
+
export function aggregateMetrics(members) {
|
|
47
|
+
let sizeBytes = 0
|
|
48
|
+
let fileCount = 0
|
|
49
|
+
for (const member of members) {
|
|
50
|
+
if (typeof member?.sizeBytes === 'number') sizeBytes += member.sizeBytes
|
|
51
|
+
if (typeof member?.fileCount === 'number') fileCount += member.fileCount
|
|
52
|
+
}
|
|
53
|
+
return { sizeBytes, fileCount }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Shape-level validation shared by the durable index and object sidecars.
|
|
58
|
+
* Path strictness belongs to the sidecar validator.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} member candidate member.
|
|
61
|
+
* @returns {boolean} whether the member carries the required v2 identity.
|
|
62
|
+
*/
|
|
63
|
+
export function isValidMemberShape(member) {
|
|
64
|
+
if (member === null || typeof member !== 'object' || Array.isArray(member)) return false
|
|
65
|
+
if (typeof member.sessionId !== 'string' || member.sessionId === '' || member.sessionId.length > 256) return false
|
|
66
|
+
if (member.origin !== undefined && member.origin !== null && member.origin !== 'subagent') return false
|
|
67
|
+
if (member.title !== undefined && typeof member.title !== 'string') return false
|
|
68
|
+
if (member.formatStatus !== undefined && member.formatStatus !== 'current' && member.formatStatus !== 'migration-required') {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
if (member.sizeBytes !== undefined && member.sizeBytes !== null && typeof member.sizeBytes !== 'number') return false
|
|
72
|
+
if (member.fileCount !== undefined && member.fileCount !== null && typeof member.fileCount !== 'number') return false
|
|
73
|
+
for (const key of ['originalSessionDir', 'originalLogPath', 'persistenceProjectDirHint']) {
|
|
74
|
+
if (member[key] !== undefined && member[key] !== null && typeof member[key] !== 'string') return false
|
|
75
|
+
}
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether the v2 member list is internally consistent with its parent entry.
|
|
81
|
+
*
|
|
82
|
+
* @param {unknown} entry index entry carrying `members`.
|
|
83
|
+
* @returns {boolean} whether the list is acceptable.
|
|
84
|
+
*/
|
|
85
|
+
export function isValidMemberList(entry) {
|
|
86
|
+
if (entry === null || typeof entry !== 'object') return false
|
|
87
|
+
if (!Array.isArray(entry.members) || entry.members.length === 0 || entry.members.length > 64) return false
|
|
88
|
+
const seen = new Set()
|
|
89
|
+
for (const member of entry.members) {
|
|
90
|
+
if (!isValidMemberShape(member)) return false
|
|
91
|
+
if (seen.has(member.sessionId)) return false
|
|
92
|
+
seen.add(member.sessionId)
|
|
93
|
+
}
|
|
94
|
+
return entry.members[0].sessionId === entry.sessionId
|
|
95
|
+
}
|
package/lib/state.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
|
+
import { memberList } from './members.js'
|
|
2
3
|
|
|
3
4
|
/** Maximum concurrent filesystem probes while describing entries. */
|
|
4
5
|
const PROBE_CONCURRENCY = 3
|
|
@@ -85,6 +86,7 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
85
86
|
const expiresAt =
|
|
86
87
|
typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
|
|
87
88
|
const trashed = entry.state === 'trashed'
|
|
89
|
+
const members = memberList(entry)
|
|
88
90
|
return {
|
|
89
91
|
sessionId: entry.sessionId,
|
|
90
92
|
title: entry.title,
|
|
@@ -103,6 +105,12 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
103
105
|
canForget: entry.state === 'missing',
|
|
104
106
|
requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
|
|
105
107
|
lastError: entry.lastError ?? null,
|
|
108
|
+
subagentCount: Math.max(0, members.length - 1),
|
|
109
|
+
members: members.map((member) => ({
|
|
110
|
+
sessionId: member.sessionId,
|
|
111
|
+
origin: member.origin === 'subagent' ? 'subagent' : null,
|
|
112
|
+
sizeBytes: typeof member.sizeBytes === 'number' ? member.sizeBytes : null,
|
|
113
|
+
})),
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
|
package/lib/store.js
CHANGED
|
@@ -2,10 +2,11 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { TrashError } from './errors.js'
|
|
5
|
-
import { isInside, removePathWithRetry, renameWithRetry, samePath } from './fsops.js'
|
|
5
|
+
import { isInside, isSafeMemberId, removePathWithRetry, renameWithRetry, samePath } from './fsops.js'
|
|
6
|
+
import { isValidMemberList, parentMember } from './members.js'
|
|
6
7
|
|
|
7
8
|
/** On-disk index schema version understood by this release. */
|
|
8
|
-
export const INDEX_VERSION =
|
|
9
|
+
export const INDEX_VERSION = 2
|
|
9
10
|
|
|
10
11
|
/** A lock older than this is assumed abandoned and may be taken over. */
|
|
11
12
|
export const LOCK_STALE_MS = 10 * 60 * 1000
|
|
@@ -21,6 +22,20 @@ export function emptyIndex(now = Date.now()) {
|
|
|
21
22
|
return { version: INDEX_VERSION, updatedAt: now, entries: [] }
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Coerce one durable entry to the v2 shape. A v1 entry names a single
|
|
27
|
+
* directory, which becomes the parent member of a one-member tree.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} entry entry from either durable shape.
|
|
30
|
+
* @returns {object} the v2 entry.
|
|
31
|
+
*/
|
|
32
|
+
function normalizeEntry(entry) {
|
|
33
|
+
if (entry === null || typeof entry !== 'object') return entry
|
|
34
|
+
if (Array.isArray(entry.members) && entry.members.length > 0) return entry
|
|
35
|
+
const parent = parentMember(entry)
|
|
36
|
+
return parent === null ? entry : { ...entry, members: [parent] }
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
/**
|
|
25
40
|
* Validate the persisted index shape strictly enough that a truncated or
|
|
26
41
|
* hand-edited file enters protection mode instead of silently vanishing.
|
|
@@ -30,7 +45,7 @@ export function emptyIndex(now = Date.now()) {
|
|
|
30
45
|
*/
|
|
31
46
|
export function isValidIndex(value) {
|
|
32
47
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
|
33
|
-
if (value.version !== INDEX_VERSION) return false
|
|
48
|
+
if (value.version !== INDEX_VERSION && value.version !== 1) return false
|
|
34
49
|
if (typeof value.updatedAt !== 'number' || !Number.isFinite(value.updatedAt)) return false
|
|
35
50
|
if (!Array.isArray(value.entries)) return false
|
|
36
51
|
const seen = new Set()
|
|
@@ -42,6 +57,7 @@ export function isValidIndex(value) {
|
|
|
42
57
|
if (typeof entry.trashedAt !== 'number' || !Number.isFinite(entry.trashedAt)) return false
|
|
43
58
|
if (seen.has(entry.sessionId)) return false
|
|
44
59
|
seen.add(entry.sessionId)
|
|
60
|
+
if (value.version === INDEX_VERSION && !isValidMemberList(entry)) return false
|
|
45
61
|
}
|
|
46
62
|
return true
|
|
47
63
|
}
|
|
@@ -90,6 +106,8 @@ export class TrashStore {
|
|
|
90
106
|
this.lockOwned = false
|
|
91
107
|
this.lockTimer = null
|
|
92
108
|
this.queue = Promise.resolve()
|
|
109
|
+
/** Whether the last load read an older index version that must be re-committed. */
|
|
110
|
+
this.needsUpgrade = false
|
|
93
111
|
}
|
|
94
112
|
|
|
95
113
|
/**
|
|
@@ -108,6 +126,10 @@ export class TrashStore {
|
|
|
108
126
|
} else if (this.status === 'recovered') {
|
|
109
127
|
this.logger.warn(`trash index recovered from backup: ${this.error}`)
|
|
110
128
|
}
|
|
129
|
+
if (this.needsUpgrade) {
|
|
130
|
+
await this.writeIndex(this.index.entries)
|
|
131
|
+
this.logger.info(`upgraded the trash index to version ${INDEX_VERSION}`)
|
|
132
|
+
}
|
|
111
133
|
return this.describe()
|
|
112
134
|
}
|
|
113
135
|
|
|
@@ -150,12 +172,34 @@ export class TrashStore {
|
|
|
150
172
|
|
|
151
173
|
/**
|
|
152
174
|
* @param {string} trashId object identity.
|
|
153
|
-
* @returns {string} the moved session directory inside the object directory.
|
|
175
|
+
* @returns {string} the moved parent session directory inside the object directory.
|
|
154
176
|
*/
|
|
155
177
|
sessionPath(trashId) {
|
|
156
178
|
return path.join(this.objectDir(trashId), 'session')
|
|
157
179
|
}
|
|
158
180
|
|
|
181
|
+
/**
|
|
182
|
+
* @param {string} trashId object identity.
|
|
183
|
+
* @param {string} sessionId descendant session identity.
|
|
184
|
+
* @returns {string} the moved descendant directory inside the object directory.
|
|
185
|
+
*/
|
|
186
|
+
childPath(trashId, sessionId) {
|
|
187
|
+
if (!isSafeMemberId(sessionId)) {
|
|
188
|
+
throw new TrashError('IO_ERROR', 'Refusing an unsafe member directory name inside the trash object.')
|
|
189
|
+
}
|
|
190
|
+
return path.join(this.objectDir(trashId), 'children', sessionId)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} trashId object identity.
|
|
195
|
+
* @param {number} index member position; `0` is the visible parent.
|
|
196
|
+
* @param {string} sessionId member identity.
|
|
197
|
+
* @returns {string} the directory that owns this member inside the object.
|
|
198
|
+
*/
|
|
199
|
+
memberPath(trashId, index, sessionId) {
|
|
200
|
+
return index === 0 ? this.sessionPath(trashId) : this.childPath(trashId, sessionId)
|
|
201
|
+
}
|
|
202
|
+
|
|
159
203
|
/**
|
|
160
204
|
* @param {string} trashId object identity.
|
|
161
205
|
* @returns {string} the self-describing metadata path inside the object directory.
|
|
@@ -309,15 +353,17 @@ export class TrashStore {
|
|
|
309
353
|
}
|
|
310
354
|
|
|
311
355
|
/**
|
|
312
|
-
* Load the index, falling back to the previous committed backup.
|
|
356
|
+
* Load the index, falling back to the previous committed backup. An older
|
|
357
|
+
* schema is normalized in memory and re-committed by {@link init}.
|
|
313
358
|
*
|
|
314
359
|
* @returns {Promise<void>} resolution once `index` and `status` are set.
|
|
315
360
|
*/
|
|
316
361
|
async load() {
|
|
362
|
+
this.needsUpgrade = false
|
|
317
363
|
try {
|
|
318
364
|
const data = await this.readJson(this.indexPath)
|
|
319
365
|
if (!isValidIndex(data)) throw new Error('unsupported index shape or version')
|
|
320
|
-
this.index = data
|
|
366
|
+
this.index = this.normalizeIndex(data)
|
|
321
367
|
this.status = 'ok'
|
|
322
368
|
this.error = null
|
|
323
369
|
return
|
|
@@ -332,7 +378,7 @@ export class TrashStore {
|
|
|
332
378
|
try {
|
|
333
379
|
const backup = await this.readJson(this.bakPath)
|
|
334
380
|
if (!isValidIndex(backup)) throw new Error('unsupported backup shape or version')
|
|
335
|
-
this.index = backup
|
|
381
|
+
this.index = this.normalizeIndex(backup)
|
|
336
382
|
this.status = 'recovered'
|
|
337
383
|
this.error = primary
|
|
338
384
|
return
|
|
@@ -345,6 +391,19 @@ export class TrashStore {
|
|
|
345
391
|
}
|
|
346
392
|
}
|
|
347
393
|
|
|
394
|
+
/**
|
|
395
|
+
* @param {{ version: number, updatedAt: number, entries: readonly object[] }} data parsed index document.
|
|
396
|
+
* @returns {{ version: number, updatedAt: number, entries: object[] }} the v2 document.
|
|
397
|
+
*/
|
|
398
|
+
normalizeIndex(data) {
|
|
399
|
+
if (data.version !== INDEX_VERSION) this.needsUpgrade = true
|
|
400
|
+
return {
|
|
401
|
+
version: INDEX_VERSION,
|
|
402
|
+
updatedAt: data.updatedAt,
|
|
403
|
+
entries: data.entries.map((entry) => normalizeEntry(entry)),
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
348
407
|
/**
|
|
349
408
|
* Atomically commit the index: write a temporary file, preserve the previous
|
|
350
409
|
* commit as `.bak`, then rename over the live index.
|
|
@@ -353,7 +412,8 @@ export class TrashStore {
|
|
|
353
412
|
* @returns {Promise<{ version: number, updatedAt: number, entries: readonly object[] }>} the committed document.
|
|
354
413
|
*/
|
|
355
414
|
async writeIndex(entries) {
|
|
356
|
-
const
|
|
415
|
+
const normalized = entries.map((entry) => normalizeEntry(entry))
|
|
416
|
+
const data = { version: INDEX_VERSION, updatedAt: Date.now(), entries: normalized }
|
|
357
417
|
const json = `${JSON.stringify(data, null, 2)}\n`
|
|
358
418
|
try {
|
|
359
419
|
const handle = await fs.open(this.tmpPath, 'w', 0o600)
|
|
@@ -379,6 +439,7 @@ export class TrashStore {
|
|
|
379
439
|
this.index = data
|
|
380
440
|
this.status = 'ok'
|
|
381
441
|
this.error = null
|
|
442
|
+
this.needsUpgrade = false
|
|
382
443
|
return data
|
|
383
444
|
}
|
|
384
445
|
|