@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/fsops.js
CHANGED
|
@@ -209,6 +209,25 @@ export function isSafeTrashId(trashId) {
|
|
|
209
209
|
)
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Whether a session identity may name one child directory inside a trash
|
|
214
|
+
* object. Session ids are `session-<uuid>` or a bare uuid today, so the
|
|
215
|
+
* accepted alphabet is deliberately conservative.
|
|
216
|
+
*
|
|
217
|
+
* @param {unknown} sessionId candidate member identity.
|
|
218
|
+
* @returns {boolean} whether the identity is one safe path segment.
|
|
219
|
+
*/
|
|
220
|
+
export function isSafeMemberId(sessionId) {
|
|
221
|
+
return (
|
|
222
|
+
typeof sessionId === 'string' &&
|
|
223
|
+
sessionId !== '' &&
|
|
224
|
+
sessionId.length <= 256 &&
|
|
225
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sessionId) &&
|
|
226
|
+
sessionId !== '.' &&
|
|
227
|
+
sessionId !== '..'
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
212
231
|
/**
|
|
213
232
|
* Reject a path that is not one safe segment below its parent.
|
|
214
233
|
*
|
package/lib/http.js
CHANGED
|
@@ -274,6 +274,11 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
|
|
|
274
274
|
if (config.debug) {
|
|
275
275
|
logger.debug(`preflight ${sessionId} -> ${verdict.eligible ? 'eligible' : verdict.code}`)
|
|
276
276
|
}
|
|
277
|
+
if (verdict.eligible === true) {
|
|
278
|
+
const members = Array.isArray(verdict.members) ? verdict.members : []
|
|
279
|
+
sendJson(res, 200, { eligible: true, descendants: Math.max(0, members.length - 1) })
|
|
280
|
+
return
|
|
281
|
+
}
|
|
277
282
|
sendJson(res, 200, verdict)
|
|
278
283
|
})
|
|
279
284
|
|
package/lib/inventory.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { mapLimit } from './concurrency.js'
|
|
4
|
+
import { entryKind, isSafeDirectoryPath, isSafeMemberId, isSafeTrashId, samePath } from './fsops.js'
|
|
5
|
+
import { memberList } from './members.js'
|
|
6
|
+
|
|
7
|
+
/** Maximum concurrent object directories inspected during a scan. */
|
|
8
|
+
const SCAN_CONCURRENCY = 4
|
|
4
9
|
|
|
5
10
|
/** Every state the durable index understands. */
|
|
6
11
|
export const ENTRY_STATES = new Set(['trashing', 'trashed', 'restoring', 'purging', 'missing'])
|
|
7
12
|
|
|
8
13
|
/** Sidecar schema version understood by this release. */
|
|
9
|
-
const META_VERSION =
|
|
14
|
+
const META_VERSION = 2
|
|
15
|
+
|
|
16
|
+
/** Maximum directories one trashed item may own. */
|
|
17
|
+
const MAX_MEMBERS = 64
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* @param {unknown} value candidate timestamp.
|
|
@@ -16,6 +24,77 @@ function isFiniteNumber(value) {
|
|
|
16
24
|
return typeof value === 'number' && Number.isFinite(value)
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @param {unknown} value candidate metric.
|
|
29
|
+
* @returns {boolean} whether the value is a usable number or absent.
|
|
30
|
+
*/
|
|
31
|
+
function isMetric(value) {
|
|
32
|
+
return value === undefined || value === null || isFiniteNumber(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate one v2 member's absolute paths. The parent's sidecar is validated
|
|
37
|
+
* field by field so a hand-edited file is rejected instead of guessed at.
|
|
38
|
+
*
|
|
39
|
+
* @param {unknown} member candidate member.
|
|
40
|
+
* @returns {boolean} whether the member names safe, coherent paths.
|
|
41
|
+
*/
|
|
42
|
+
function isValidMemberPaths(member) {
|
|
43
|
+
if (member === null || typeof member !== 'object' || Array.isArray(member)) return false
|
|
44
|
+
if (!isSafeMemberId(member.sessionId)) return false
|
|
45
|
+
if (member.origin !== undefined && member.origin !== null && member.origin !== 'subagent') return false
|
|
46
|
+
if (member.title !== undefined && typeof member.title !== 'string') return false
|
|
47
|
+
if (
|
|
48
|
+
member.formatStatus !== undefined &&
|
|
49
|
+
member.formatStatus !== 'current' &&
|
|
50
|
+
member.formatStatus !== 'migration-required'
|
|
51
|
+
) {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
if (!isMetric(member.sizeBytes) || !isMetric(member.fileCount)) return false
|
|
55
|
+
if (!isSafeDirectoryPath(member.originalSessionDir)) return false
|
|
56
|
+
if (typeof member.originalLogPath !== 'string' || !path.isAbsolute(member.originalLogPath)) return false
|
|
57
|
+
if (
|
|
58
|
+
typeof member.persistenceProjectDirHint !== 'string' ||
|
|
59
|
+
!path.isAbsolute(member.persistenceProjectDirHint)
|
|
60
|
+
) {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
if (!samePath(path.dirname(path.resolve(member.originalSessionDir)), path.resolve(member.persistenceProjectDirHint))) {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
if (!samePath(path.dirname(path.resolve(member.originalLogPath)), path.resolve(member.originalSessionDir))) {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate the v2 member list of one sidecar.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} meta candidate sidecar.
|
|
76
|
+
* @returns {boolean} whether the list is coherent with its parent fields.
|
|
77
|
+
*/
|
|
78
|
+
function isValidMetaMembers(meta) {
|
|
79
|
+
if (!Array.isArray(meta.members) || meta.members.length === 0 || meta.members.length > MAX_MEMBERS) return false
|
|
80
|
+
if (!meta.members.every((member) => isValidMemberPaths(member))) return false
|
|
81
|
+
const seen = new Set()
|
|
82
|
+
for (const member of meta.members) {
|
|
83
|
+
if (seen.has(member.sessionId)) return false
|
|
84
|
+
seen.add(member.sessionId)
|
|
85
|
+
}
|
|
86
|
+
const parent = meta.members[0]
|
|
87
|
+
if (parent.sessionId !== meta.sessionId) return false
|
|
88
|
+
if (!samePath(path.resolve(parent.originalSessionDir), path.resolve(meta.originalSessionDir))) return false
|
|
89
|
+
if (!samePath(path.resolve(parent.originalLogPath), path.resolve(meta.originalLogPath))) return false
|
|
90
|
+
if (
|
|
91
|
+
!samePath(path.resolve(parent.persistenceProjectDirHint), path.resolve(meta.persistenceProjectDirHint))
|
|
92
|
+
) {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
19
98
|
/**
|
|
20
99
|
* Validate one object sidecar strictly enough to be rebuilt into an index
|
|
21
100
|
* entry. A malformed sidecar is reported as an unindexed object instead of
|
|
@@ -27,7 +106,7 @@ function isFiniteNumber(value) {
|
|
|
27
106
|
*/
|
|
28
107
|
export function isValidMeta(meta, trashId) {
|
|
29
108
|
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return false
|
|
30
|
-
if (meta.version !== undefined && meta.version !== META_VERSION) return false
|
|
109
|
+
if (meta.version !== undefined && meta.version !== META_VERSION && meta.version !== 1) return false
|
|
31
110
|
if (typeof meta.sessionId !== 'string' || meta.sessionId === '' || meta.sessionId.length > 256) return false
|
|
32
111
|
if (meta.trashId !== trashId) return false
|
|
33
112
|
if (!ENTRY_STATES.has(meta.state)) return false
|
|
@@ -48,20 +127,58 @@ export function isValidMeta(meta, trashId) {
|
|
|
48
127
|
) {
|
|
49
128
|
return false
|
|
50
129
|
}
|
|
130
|
+
if (meta.version === META_VERSION) return isValidMetaMembers(meta)
|
|
51
131
|
return true
|
|
52
132
|
}
|
|
53
133
|
|
|
134
|
+
/**
|
|
135
|
+
* @param {Record<string, unknown>} member raw member fields.
|
|
136
|
+
* @param {string} fallbackTitle title used when the member carries none.
|
|
137
|
+
* @returns {object} one normalized v2 member.
|
|
138
|
+
*/
|
|
139
|
+
function toMember(member, fallbackTitle) {
|
|
140
|
+
return {
|
|
141
|
+
sessionId: member.sessionId,
|
|
142
|
+
origin: member.origin === 'subagent' ? 'subagent' : null,
|
|
143
|
+
title: typeof member.title === 'string' && member.title !== '' ? member.title : fallbackTitle,
|
|
144
|
+
originalSessionDir: path.resolve(member.originalSessionDir),
|
|
145
|
+
originalLogPath: path.resolve(member.originalLogPath),
|
|
146
|
+
persistenceProjectDirHint: path.resolve(member.persistenceProjectDirHint),
|
|
147
|
+
formatStatus: member.formatStatus === 'migration-required' ? 'migration-required' : 'current',
|
|
148
|
+
sizeBytes: isFiniteNumber(member.sizeBytes) ? member.sizeBytes : null,
|
|
149
|
+
fileCount: isFiniteNumber(member.fileCount) ? member.fileCount : null,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
54
153
|
/**
|
|
55
154
|
* @param {Record<string, unknown>} meta valid sidecar.
|
|
56
155
|
* @returns {object} a clean index entry with defaulted optional fields.
|
|
57
156
|
*/
|
|
58
|
-
function toEntry(meta) {
|
|
157
|
+
export function toEntry(meta) {
|
|
59
158
|
const originalCwd = typeof meta.originalCwd === 'string' && meta.originalCwd !== '' ? meta.originalCwd : null
|
|
159
|
+
const title = typeof meta.title === 'string' && meta.title !== '' ? meta.title : meta.sessionId
|
|
160
|
+
const members =
|
|
161
|
+
Array.isArray(meta.members) && meta.members.length > 0
|
|
162
|
+
? meta.members.map((member) => toMember(member, title))
|
|
163
|
+
: [
|
|
164
|
+
toMember(
|
|
165
|
+
{
|
|
166
|
+
sessionId: meta.sessionId,
|
|
167
|
+
originalSessionDir: meta.originalSessionDir,
|
|
168
|
+
originalLogPath: meta.originalLogPath,
|
|
169
|
+
persistenceProjectDirHint: meta.persistenceProjectDirHint,
|
|
170
|
+
formatStatus: meta.formatStatus,
|
|
171
|
+
sizeBytes: meta.sizeBytes,
|
|
172
|
+
fileCount: meta.fileCount,
|
|
173
|
+
},
|
|
174
|
+
title,
|
|
175
|
+
),
|
|
176
|
+
]
|
|
60
177
|
return {
|
|
61
178
|
sessionId: meta.sessionId,
|
|
62
179
|
trashId: meta.trashId,
|
|
63
180
|
state: meta.state,
|
|
64
|
-
title
|
|
181
|
+
title,
|
|
65
182
|
trashedAt: meta.trashedAt,
|
|
66
183
|
startedAt: meta.startedAt,
|
|
67
184
|
originalCwd,
|
|
@@ -75,25 +192,75 @@ function toEntry(meta) {
|
|
|
75
192
|
formatStatus: meta.formatStatus === 'migration-required' ? 'migration-required' : 'current',
|
|
76
193
|
sizeBytes: isFiniteNumber(meta.sizeBytes) ? meta.sizeBytes : null,
|
|
77
194
|
fileCount: isFiniteNumber(meta.fileCount) ? meta.fileCount : null,
|
|
195
|
+
members,
|
|
78
196
|
lastError: typeof meta.lastError === 'string' && meta.lastError !== '' ? meta.lastError : null,
|
|
79
197
|
}
|
|
80
198
|
}
|
|
81
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Which of one entry's member directories currently exist inside its object.
|
|
202
|
+
*
|
|
203
|
+
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
204
|
+
* @param {object} entry index entry, v1 or v2 shaped.
|
|
205
|
+
* @returns {Promise<{ total: number, present: number, missing: string[] }>} presence summary.
|
|
206
|
+
*/
|
|
207
|
+
export async function memberPresence(store, entry) {
|
|
208
|
+
const members =
|
|
209
|
+
Array.isArray(entry.members) && entry.members.length > 0
|
|
210
|
+
? entry.members
|
|
211
|
+
: [{ sessionId: entry.sessionId, originalSessionDir: entry.originalSessionDir }]
|
|
212
|
+
const missing = []
|
|
213
|
+
let present = 0
|
|
214
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
215
|
+
const member = members[index]
|
|
216
|
+
let kind = 'missing'
|
|
217
|
+
try {
|
|
218
|
+
kind = await entryKind(store.memberPath(entry.trashId, index, member.sessionId))
|
|
219
|
+
} catch {
|
|
220
|
+
kind = 'missing'
|
|
221
|
+
}
|
|
222
|
+
if (kind === 'directory') present += 1
|
|
223
|
+
else missing.push(typeof member.sessionId === 'string' ? member.sessionId : entry.sessionId)
|
|
224
|
+
}
|
|
225
|
+
return { total: members.length, present, missing }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Whether two member lists name the same directories in the same order, so a
|
|
230
|
+
* presence probe taken for one can be reused for the other.
|
|
231
|
+
*
|
|
232
|
+
* @param {readonly object[]} left first member list.
|
|
233
|
+
* @param {readonly object[]} right second member list.
|
|
234
|
+
* @returns {boolean} whether both lists describe the same members.
|
|
235
|
+
*/
|
|
236
|
+
function sameMembers(left, right) {
|
|
237
|
+
if (left.length !== right.length) return false
|
|
238
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
239
|
+
if (left[index]?.sessionId !== right[index]?.sessionId) return false
|
|
240
|
+
}
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
|
|
82
244
|
/**
|
|
83
245
|
* Reconcile a rebuilt entry with the object directory observed on disk.
|
|
84
246
|
*
|
|
85
247
|
* @param {object} entry index-shaped entry.
|
|
86
|
-
* @param {
|
|
248
|
+
* @param {{ total: number, present: number, missing: string[] }} presence member presence.
|
|
87
249
|
* @param {number} now current timestamp.
|
|
88
250
|
* @returns {object} the entry whose state matches the filesystem.
|
|
89
251
|
*/
|
|
90
|
-
export function deriveEntry(entry,
|
|
91
|
-
|
|
252
|
+
export function deriveEntry(entry, presence, now) {
|
|
253
|
+
const complete = presence.total > 0 && presence.present === presence.total
|
|
254
|
+
if (!complete) {
|
|
92
255
|
if (entry.state === 'purging' || entry.state === 'missing') return { ...entry }
|
|
256
|
+
const lastError =
|
|
257
|
+
presence.present === 0
|
|
258
|
+
? 'The trash copy of this session is missing.'
|
|
259
|
+
: `The trash copy is missing these member sessions: ${presence.missing.join(', ')}.`
|
|
93
260
|
return {
|
|
94
261
|
...entry,
|
|
95
262
|
state: 'missing',
|
|
96
|
-
lastError
|
|
263
|
+
lastError,
|
|
97
264
|
}
|
|
98
265
|
}
|
|
99
266
|
if (entry.state === 'missing') {
|
|
@@ -115,7 +282,7 @@ export function deriveEntry(entry, sessionExists, now) {
|
|
|
115
282
|
*
|
|
116
283
|
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
117
284
|
* @param {{ warn: Function }} logger diagnostics sink.
|
|
118
|
-
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean }[], unindexed: number }>} the inventory.
|
|
285
|
+
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean, entry?: object, presence?: object }[], unindexed: number }>} the inventory.
|
|
119
286
|
*/
|
|
120
287
|
export async function scanObjects(store, logger) {
|
|
121
288
|
let entries
|
|
@@ -125,13 +292,8 @@ export async function scanObjects(store, logger) {
|
|
|
125
292
|
if (error !== null && error !== undefined && error.code === 'ENOENT') return { objects: [], unindexed: 0 }
|
|
126
293
|
throw error
|
|
127
294
|
}
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
for (const entry of entries) {
|
|
131
|
-
if (!entry.isDirectory() || !isSafeTrashId(entry.name)) {
|
|
132
|
-
unindexed += 1
|
|
133
|
-
continue
|
|
134
|
-
}
|
|
295
|
+
const scanned = await mapLimit(entries, SCAN_CONCURRENCY, async (entry) => {
|
|
296
|
+
if (!entry.isDirectory() || !isSafeTrashId(entry.name)) return { unindexed: 1 }
|
|
135
297
|
const trashId = entry.name
|
|
136
298
|
let meta
|
|
137
299
|
try {
|
|
@@ -140,23 +302,36 @@ export async function scanObjects(store, logger) {
|
|
|
140
302
|
logger.warn(`cannot read the trash metadata for ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
141
303
|
// Keep the directory in the inventory so reconcile never mistakes an
|
|
142
304
|
// unreadable sidecar for a vanished object directory.
|
|
143
|
-
|
|
144
|
-
unindexed += 1
|
|
145
|
-
continue
|
|
305
|
+
return { object: { trashId, sessionExists: false, meta: undefined, metaValid: false }, unindexed: 1 }
|
|
146
306
|
}
|
|
147
307
|
const metaValid = meta !== undefined && isValidMeta(meta, trashId)
|
|
148
308
|
if (!metaValid) {
|
|
149
|
-
|
|
150
|
-
unindexed += 1
|
|
151
|
-
continue
|
|
309
|
+
return { object: { trashId, sessionExists: false, meta, metaValid: false }, unindexed: 1 }
|
|
152
310
|
}
|
|
153
|
-
let
|
|
311
|
+
let presence
|
|
154
312
|
try {
|
|
155
|
-
|
|
313
|
+
presence = await memberPresence(store, toEntry(meta))
|
|
156
314
|
} catch (error) {
|
|
157
315
|
logger.warn(`cannot inspect the trash copy ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
316
|
+
presence = { total: 0, present: 0, missing: [] }
|
|
158
317
|
}
|
|
159
|
-
|
|
318
|
+
return {
|
|
319
|
+
object: {
|
|
320
|
+
trashId,
|
|
321
|
+
sessionExists: presence.total > 0 && presence.present === presence.total,
|
|
322
|
+
meta,
|
|
323
|
+
metaValid,
|
|
324
|
+
entry: toEntry(meta),
|
|
325
|
+
presence,
|
|
326
|
+
},
|
|
327
|
+
unindexed: 0,
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
const objects = []
|
|
331
|
+
let unindexed = 0
|
|
332
|
+
for (const result of scanned) {
|
|
333
|
+
unindexed += result.unindexed
|
|
334
|
+
if (result.object !== undefined) objects.push(result.object)
|
|
160
335
|
}
|
|
161
336
|
return { objects, unindexed }
|
|
162
337
|
}
|
|
@@ -167,12 +342,15 @@ export async function scanObjects(store, logger) {
|
|
|
167
342
|
* directory.
|
|
168
343
|
*
|
|
169
344
|
* @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
|
|
170
|
-
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number }>} reconcile summary.
|
|
345
|
+
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, object> }>} reconcile summary plus the presence probe per object.
|
|
171
346
|
*/
|
|
172
347
|
export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
173
|
-
if (store.isProtected())
|
|
348
|
+
if (store.isProtected()) {
|
|
349
|
+
return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0, presence: new Map() }
|
|
350
|
+
}
|
|
174
351
|
const { objects, unindexed: invalidObjects } = await scanObjects(store, logger)
|
|
175
352
|
const byTrashId = new Map(objects.map((object) => [object.trashId, object]))
|
|
353
|
+
const presenceByTrashId = new Map()
|
|
176
354
|
const nextEntries = [...store.entries()]
|
|
177
355
|
const indexedTrashIds = new Set(nextEntries.map((entry) => entry.trashId))
|
|
178
356
|
const sessionIds = new Set(nextEntries.map((entry) => entry.sessionId))
|
|
@@ -197,7 +375,17 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
|
197
375
|
changed = true
|
|
198
376
|
continue
|
|
199
377
|
}
|
|
200
|
-
|
|
378
|
+
// The scan already probed every member directory of this object. Reuse that
|
|
379
|
+
// answer when the sidecar and the index entry describe the same members;
|
|
380
|
+
// otherwise probe again rather than trust a mismatched snapshot.
|
|
381
|
+
const presence =
|
|
382
|
+
object.presence !== undefined &&
|
|
383
|
+
object.entry !== undefined &&
|
|
384
|
+
sameMembers(memberList(object.entry), memberList(entry))
|
|
385
|
+
? object.presence
|
|
386
|
+
: await memberPresence(store, entry)
|
|
387
|
+
presenceByTrashId.set(object.trashId, presence)
|
|
388
|
+
if (entry.state === 'missing' && presence.total > 0 && presence.present === presence.total) {
|
|
201
389
|
nextEntries[index] = {
|
|
202
390
|
...entry,
|
|
203
391
|
state: 'trashed',
|
|
@@ -211,15 +399,15 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
|
211
399
|
|
|
212
400
|
for (const object of objects) {
|
|
213
401
|
if (indexedTrashIds.has(object.trashId)) continue
|
|
214
|
-
if (!object.metaValid || object.meta === undefined) continue
|
|
215
|
-
const entry =
|
|
402
|
+
if (!object.metaValid || object.meta === undefined || object.entry === undefined || object.presence === undefined) continue
|
|
403
|
+
const entry = object.entry
|
|
216
404
|
if (sessionIds.has(entry.sessionId)) {
|
|
217
405
|
// Two object directories claim the same session: keep the indexed one.
|
|
218
406
|
unindexed += 1
|
|
219
407
|
logger.warn(`the trash object ${object.trashId} duplicates the indexed session ${entry.sessionId}`)
|
|
220
408
|
continue
|
|
221
409
|
}
|
|
222
|
-
const derived = deriveEntry(entry, object.
|
|
410
|
+
const derived = deriveEntry(entry, object.presence, now)
|
|
223
411
|
nextEntries.push(derived)
|
|
224
412
|
indexedTrashIds.add(derived.trashId)
|
|
225
413
|
sessionIds.add(derived.sessionId)
|
|
@@ -237,7 +425,7 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
|
237
425
|
}
|
|
238
426
|
}
|
|
239
427
|
store.unindexed = unindexed
|
|
240
|
-
return { imported, markedMissing, unindexed, changed: changed ? 1 : 0 }
|
|
428
|
+
return { imported, markedMissing, unindexed, changed: changed ? 1 : 0, presence: presenceByTrashId }
|
|
241
429
|
}
|
|
242
430
|
|
|
243
431
|
/**
|
|
@@ -254,15 +442,15 @@ export async function rebuildIndex({ store, logger, now = Date.now() }) {
|
|
|
254
442
|
const seen = new Set()
|
|
255
443
|
let unindexed = invalidObjects
|
|
256
444
|
for (const object of objects) {
|
|
257
|
-
if (!object.metaValid || object.meta === undefined) continue
|
|
258
|
-
const entry =
|
|
445
|
+
if (!object.metaValid || object.meta === undefined || object.entry === undefined || object.presence === undefined) continue
|
|
446
|
+
const entry = object.entry
|
|
259
447
|
if (seen.has(entry.sessionId)) {
|
|
260
448
|
unindexed += 1
|
|
261
449
|
logger.warn(`the trash object ${object.trashId} duplicates the rebuilt session ${entry.sessionId}`)
|
|
262
450
|
continue
|
|
263
451
|
}
|
|
264
452
|
seen.add(entry.sessionId)
|
|
265
|
-
entries.push(deriveEntry(entry, object.
|
|
453
|
+
entries.push(deriveEntry(entry, object.presence, now))
|
|
266
454
|
}
|
|
267
455
|
entries.sort((left, right) => left.startedAt - right.startedAt || left.trashId.localeCompare(right.trashId))
|
|
268
456
|
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,29 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
|
+
import { mapLimit } from './concurrency.js'
|
|
3
|
+
import { memberList } from './members.js'
|
|
2
4
|
|
|
3
5
|
/** Maximum concurrent filesystem probes while describing entries. */
|
|
4
6
|
const PROBE_CONCURRENCY = 3
|
|
5
7
|
|
|
6
|
-
/**
|
|
7
|
-
* @param {readonly T[]} items input items.
|
|
8
|
-
* @param {number} limit maximum in-flight operations.
|
|
9
|
-
* @param {(item: T) => Promise<R>} worker per-item operation.
|
|
10
|
-
* @returns {Promise<R[]>} results in input order.
|
|
11
|
-
* @template T, R
|
|
12
|
-
*/
|
|
13
|
-
async function mapLimit(items, limit, worker) {
|
|
14
|
-
const results = new Array(items.length)
|
|
15
|
-
let next = 0
|
|
16
|
-
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
17
|
-
while (next < items.length) {
|
|
18
|
-
const index = next
|
|
19
|
-
next += 1
|
|
20
|
-
results[index] = await worker(items[index])
|
|
21
|
-
}
|
|
22
|
-
})
|
|
23
|
-
await Promise.all(runners)
|
|
24
|
-
return results
|
|
25
|
-
}
|
|
26
|
-
|
|
27
8
|
/**
|
|
28
9
|
* @param {string | null | undefined} directory candidate workspace directory.
|
|
29
10
|
* @returns {Promise<boolean>} whether the directory exists right now.
|
|
@@ -85,6 +66,7 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
85
66
|
const expiresAt =
|
|
86
67
|
typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
|
|
87
68
|
const trashed = entry.state === 'trashed'
|
|
69
|
+
const members = memberList(entry)
|
|
88
70
|
return {
|
|
89
71
|
sessionId: entry.sessionId,
|
|
90
72
|
title: entry.title,
|
|
@@ -103,6 +85,12 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
103
85
|
canForget: entry.state === 'missing',
|
|
104
86
|
requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
|
|
105
87
|
lastError: entry.lastError ?? null,
|
|
88
|
+
subagentCount: Math.max(0, members.length - 1),
|
|
89
|
+
members: members.map((member) => ({
|
|
90
|
+
sessionId: member.sessionId,
|
|
91
|
+
origin: member.origin === 'subagent' ? 'subagent' : null,
|
|
92
|
+
sizeBytes: typeof member.sizeBytes === 'number' ? member.sizeBytes : null,
|
|
93
|
+
})),
|
|
106
94
|
}
|
|
107
95
|
}
|
|
108
96
|
|