@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +204 -0
- package/README.md +202 -2
- package/client.js +2231 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +10 -0
- package/index.js +129 -0
- package/lib/cleanup.js +342 -0
- package/lib/eligibility.js +310 -0
- package/lib/errors.js +91 -0
- package/lib/fsops.js +433 -0
- package/lib/http.js +351 -0
- package/lib/inventory.js +426 -0
- package/lib/members.js +95 -0
- package/lib/paths.js +39 -0
- package/lib/state.js +155 -0
- package/lib/store.js +606 -0
- package/lib/trash-service.js +1591 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
package/lib/inventory.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { entryKind, isSafeDirectoryPath, isSafeMemberId, isSafeTrashId, samePath } from './fsops.js'
|
|
4
|
+
|
|
5
|
+
/** Every state the durable index understands. */
|
|
6
|
+
export const ENTRY_STATES = new Set(['trashing', 'trashed', 'restoring', 'purging', 'missing'])
|
|
7
|
+
|
|
8
|
+
/** Sidecar schema version understood by this release. */
|
|
9
|
+
const META_VERSION = 2
|
|
10
|
+
|
|
11
|
+
/** Maximum directories one trashed item may own. */
|
|
12
|
+
const MAX_MEMBERS = 64
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {unknown} value candidate timestamp.
|
|
16
|
+
* @returns {boolean} whether the value is a finite number.
|
|
17
|
+
*/
|
|
18
|
+
function isFiniteNumber(value) {
|
|
19
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
20
|
+
}
|
|
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
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate one object sidecar strictly enough to be rebuilt into an index
|
|
95
|
+
* entry. A malformed sidecar is reported as an unindexed object instead of
|
|
96
|
+
* being guessed at.
|
|
97
|
+
*
|
|
98
|
+
* @param {unknown} meta parsed `meta.json`.
|
|
99
|
+
* @param {string} trashId object directory identity.
|
|
100
|
+
* @returns {boolean} whether the sidecar can rebuild one entry.
|
|
101
|
+
*/
|
|
102
|
+
export function isValidMeta(meta, trashId) {
|
|
103
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return false
|
|
104
|
+
if (meta.version !== undefined && meta.version !== META_VERSION && meta.version !== 1) return false
|
|
105
|
+
if (typeof meta.sessionId !== 'string' || meta.sessionId === '' || meta.sessionId.length > 256) return false
|
|
106
|
+
if (meta.trashId !== trashId) return false
|
|
107
|
+
if (!ENTRY_STATES.has(meta.state)) return false
|
|
108
|
+
if (!isFiniteNumber(meta.trashedAt) || !isFiniteNumber(meta.startedAt)) return false
|
|
109
|
+
if (typeof meta.title !== 'string' || meta.title.trim() === '') return false
|
|
110
|
+
if (!isSafeDirectoryPath(meta.originalSessionDir)) return false
|
|
111
|
+
if (typeof meta.persistenceProjectDirHint !== 'string' || !path.isAbsolute(meta.persistenceProjectDirHint)) return false
|
|
112
|
+
if (!samePath(path.dirname(path.resolve(meta.originalSessionDir)), path.resolve(meta.persistenceProjectDirHint))) return false
|
|
113
|
+
if (typeof meta.originalLogPath !== 'string' || !path.isAbsolute(meta.originalLogPath)) return false
|
|
114
|
+
if (!samePath(path.dirname(path.resolve(meta.originalLogPath)), path.resolve(meta.originalSessionDir))) return false
|
|
115
|
+
if (meta.originalCwd !== null && meta.originalCwd !== undefined) {
|
|
116
|
+
if (typeof meta.originalCwd !== 'string' || !path.isAbsolute(meta.originalCwd)) return false
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
meta.workspaceExistsAtDeletion !== null &&
|
|
120
|
+
meta.workspaceExistsAtDeletion !== undefined &&
|
|
121
|
+
typeof meta.workspaceExistsAtDeletion !== 'boolean'
|
|
122
|
+
) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
if (meta.version === META_VERSION) return isValidMetaMembers(meta)
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
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
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {Record<string, unknown>} meta valid sidecar.
|
|
150
|
+
* @returns {object} a clean index entry with defaulted optional fields.
|
|
151
|
+
*/
|
|
152
|
+
export function toEntry(meta) {
|
|
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
|
+
]
|
|
172
|
+
return {
|
|
173
|
+
sessionId: meta.sessionId,
|
|
174
|
+
trashId: meta.trashId,
|
|
175
|
+
state: meta.state,
|
|
176
|
+
title,
|
|
177
|
+
trashedAt: meta.trashedAt,
|
|
178
|
+
startedAt: meta.startedAt,
|
|
179
|
+
originalCwd,
|
|
180
|
+
workspaceExistsAtDeletion: typeof meta.workspaceExistsAtDeletion === 'boolean' ? meta.workspaceExistsAtDeletion : null,
|
|
181
|
+
originalWorkspaceId: typeof meta.originalWorkspaceId === 'string' ? meta.originalWorkspaceId : null,
|
|
182
|
+
originalWorkspaceTitle: typeof meta.originalWorkspaceTitle === 'string' ? meta.originalWorkspaceTitle : null,
|
|
183
|
+
originalNextSessionId: typeof meta.originalNextSessionId === 'string' ? meta.originalNextSessionId : null,
|
|
184
|
+
originalSessionDir: path.resolve(meta.originalSessionDir),
|
|
185
|
+
originalLogPath: path.resolve(meta.originalLogPath),
|
|
186
|
+
persistenceProjectDirHint: path.resolve(meta.persistenceProjectDirHint),
|
|
187
|
+
formatStatus: meta.formatStatus === 'migration-required' ? 'migration-required' : 'current',
|
|
188
|
+
sizeBytes: isFiniteNumber(meta.sizeBytes) ? meta.sizeBytes : null,
|
|
189
|
+
fileCount: isFiniteNumber(meta.fileCount) ? meta.fileCount : null,
|
|
190
|
+
members,
|
|
191
|
+
lastError: typeof meta.lastError === 'string' && meta.lastError !== '' ? meta.lastError : null,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
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
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Reconcile a rebuilt entry with the object directory observed on disk.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} entry index-shaped entry.
|
|
227
|
+
* @param {{ total: number, present: number, missing: string[] }} presence member presence.
|
|
228
|
+
* @param {number} now current timestamp.
|
|
229
|
+
* @returns {object} the entry whose state matches the filesystem.
|
|
230
|
+
*/
|
|
231
|
+
export function deriveEntry(entry, presence, now) {
|
|
232
|
+
const complete = presence.total > 0 && presence.present === presence.total
|
|
233
|
+
if (!complete) {
|
|
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(', ')}.`
|
|
239
|
+
return {
|
|
240
|
+
...entry,
|
|
241
|
+
state: 'missing',
|
|
242
|
+
lastError,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (entry.state === 'missing') {
|
|
246
|
+
return {
|
|
247
|
+
...entry,
|
|
248
|
+
state: 'trashed',
|
|
249
|
+
trashedAt: isFiniteNumber(entry.trashedAt) && entry.trashedAt > 0 ? entry.trashedAt : entry.startedAt || now,
|
|
250
|
+
lastError: null,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (entry.state === 'trashed' && (!isFiniteNumber(entry.trashedAt) || entry.trashedAt <= 0)) {
|
|
254
|
+
return { ...entry, trashedAt: entry.startedAt || now }
|
|
255
|
+
}
|
|
256
|
+
return { ...entry }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read every object directory and its sidecar without following symlinks.
|
|
261
|
+
*
|
|
262
|
+
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
263
|
+
* @param {{ warn: Function }} logger diagnostics sink.
|
|
264
|
+
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean, entry?: object, presence?: object }[], unindexed: number }>} the inventory.
|
|
265
|
+
*/
|
|
266
|
+
export async function scanObjects(store, logger) {
|
|
267
|
+
let entries
|
|
268
|
+
try {
|
|
269
|
+
entries = await fs.readdir(store.objectsDir, { withFileTypes: true })
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error !== null && error !== undefined && error.code === 'ENOENT') return { objects: [], unindexed: 0 }
|
|
272
|
+
throw error
|
|
273
|
+
}
|
|
274
|
+
const objects = []
|
|
275
|
+
let unindexed = 0
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
if (!entry.isDirectory() || !isSafeTrashId(entry.name)) {
|
|
278
|
+
unindexed += 1
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
const trashId = entry.name
|
|
282
|
+
let meta
|
|
283
|
+
try {
|
|
284
|
+
meta = await store.readMeta(trashId)
|
|
285
|
+
} catch (error) {
|
|
286
|
+
logger.warn(`cannot read the trash metadata for ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
287
|
+
// Keep the directory in the inventory so reconcile never mistakes an
|
|
288
|
+
// unreadable sidecar for a vanished object directory.
|
|
289
|
+
objects.push({ trashId, sessionExists: false, meta: undefined, metaValid: false })
|
|
290
|
+
unindexed += 1
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
const metaValid = meta !== undefined && isValidMeta(meta, trashId)
|
|
294
|
+
if (!metaValid) {
|
|
295
|
+
objects.push({ trashId, sessionExists: false, meta, metaValid: false })
|
|
296
|
+
unindexed += 1
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
let presence
|
|
300
|
+
try {
|
|
301
|
+
presence = await memberPresence(store, toEntry(meta))
|
|
302
|
+
} catch (error) {
|
|
303
|
+
logger.warn(`cannot inspect the trash copy ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
304
|
+
presence = { total: 0, present: 0, missing: [] }
|
|
305
|
+
}
|
|
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
|
+
})
|
|
314
|
+
}
|
|
315
|
+
return { objects, unindexed }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Import object directories the index does not know about and mark entries
|
|
320
|
+
* whose object directory vanished. Never deletes a session file or an object
|
|
321
|
+
* directory.
|
|
322
|
+
*
|
|
323
|
+
* @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
|
|
324
|
+
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number }>} reconcile summary.
|
|
325
|
+
*/
|
|
326
|
+
export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
327
|
+
if (store.isProtected()) return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0 }
|
|
328
|
+
const { objects, unindexed: invalidObjects } = await scanObjects(store, logger)
|
|
329
|
+
const byTrashId = new Map(objects.map((object) => [object.trashId, object]))
|
|
330
|
+
const nextEntries = [...store.entries()]
|
|
331
|
+
const indexedTrashIds = new Set(nextEntries.map((entry) => entry.trashId))
|
|
332
|
+
const sessionIds = new Set(nextEntries.map((entry) => entry.sessionId))
|
|
333
|
+
const metaUpdates = []
|
|
334
|
+
let unindexed = invalidObjects
|
|
335
|
+
let markedMissing = 0
|
|
336
|
+
let imported = 0
|
|
337
|
+
let changed = false
|
|
338
|
+
|
|
339
|
+
for (let index = 0; index < nextEntries.length; index += 1) {
|
|
340
|
+
const entry = nextEntries[index]
|
|
341
|
+
const object = byTrashId.get(entry.trashId)
|
|
342
|
+
if (object === undefined) {
|
|
343
|
+
if (entry.state !== 'trashed') continue
|
|
344
|
+
nextEntries[index] = {
|
|
345
|
+
...entry,
|
|
346
|
+
state: 'missing',
|
|
347
|
+
lastError: entry.lastError ?? 'The trash object directory is missing.',
|
|
348
|
+
}
|
|
349
|
+
metaUpdates.push(nextEntries[index])
|
|
350
|
+
markedMissing += 1
|
|
351
|
+
changed = true
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
const presence = await memberPresence(store, entry)
|
|
355
|
+
if (entry.state === 'missing' && presence.total > 0 && presence.present === presence.total) {
|
|
356
|
+
nextEntries[index] = {
|
|
357
|
+
...entry,
|
|
358
|
+
state: 'trashed',
|
|
359
|
+
trashedAt: entry.trashedAt > 0 ? entry.trashedAt : entry.startedAt || now,
|
|
360
|
+
lastError: null,
|
|
361
|
+
}
|
|
362
|
+
metaUpdates.push(nextEntries[index])
|
|
363
|
+
changed = true
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const object of objects) {
|
|
368
|
+
if (indexedTrashIds.has(object.trashId)) continue
|
|
369
|
+
if (!object.metaValid || object.meta === undefined || object.entry === undefined || object.presence === undefined) continue
|
|
370
|
+
const entry = object.entry
|
|
371
|
+
if (sessionIds.has(entry.sessionId)) {
|
|
372
|
+
// Two object directories claim the same session: keep the indexed one.
|
|
373
|
+
unindexed += 1
|
|
374
|
+
logger.warn(`the trash object ${object.trashId} duplicates the indexed session ${entry.sessionId}`)
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
const derived = deriveEntry(entry, object.presence, now)
|
|
378
|
+
nextEntries.push(derived)
|
|
379
|
+
indexedTrashIds.add(derived.trashId)
|
|
380
|
+
sessionIds.add(derived.sessionId)
|
|
381
|
+
metaUpdates.push(derived)
|
|
382
|
+
imported += 1
|
|
383
|
+
changed = true
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (changed) {
|
|
387
|
+
await store.commit(nextEntries)
|
|
388
|
+
for (const entry of metaUpdates) {
|
|
389
|
+
await store.writeMeta(entry).catch((error) => {
|
|
390
|
+
logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
store.unindexed = unindexed
|
|
395
|
+
return { imported, markedMissing, unindexed, changed: changed ? 1 : 0 }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Rebuild the complete index from one `meta.json` sidecar per object directory.
|
|
400
|
+
* Used only after the primary index and its backup were both rejected;
|
|
401
|
+
* unindexed directories stay untouched and are reported.
|
|
402
|
+
*
|
|
403
|
+
* @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
|
|
404
|
+
* @returns {Promise<{ entries: number, unindexed: number }>} rebuild summary.
|
|
405
|
+
*/
|
|
406
|
+
export async function rebuildIndex({ store, logger, now = Date.now() }) {
|
|
407
|
+
const { objects, unindexed: invalidObjects } = await scanObjects(store, logger)
|
|
408
|
+
const entries = []
|
|
409
|
+
const seen = new Set()
|
|
410
|
+
let unindexed = invalidObjects
|
|
411
|
+
for (const object of objects) {
|
|
412
|
+
if (!object.metaValid || object.meta === undefined || object.entry === undefined || object.presence === undefined) continue
|
|
413
|
+
const entry = object.entry
|
|
414
|
+
if (seen.has(entry.sessionId)) {
|
|
415
|
+
unindexed += 1
|
|
416
|
+
logger.warn(`the trash object ${object.trashId} duplicates the rebuilt session ${entry.sessionId}`)
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
seen.add(entry.sessionId)
|
|
420
|
+
entries.push(deriveEntry(entry, object.presence, now))
|
|
421
|
+
}
|
|
422
|
+
entries.sort((left, right) => left.startedAt - right.startedAt || left.trashId.localeCompare(right.trashId))
|
|
423
|
+
await store.commit(entries)
|
|
424
|
+
store.unindexed = unindexed
|
|
425
|
+
return { entries: entries.length, unindexed }
|
|
426
|
+
}
|
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/paths.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { resolveDshHome, dshHomePath, dshHomeDisplay } from '@deepseek-ai/dsh-home-paths'
|
|
3
|
+
import { TrashError } from './errors.js'
|
|
4
|
+
|
|
5
|
+
/** Trash directory name under the DSH home. */
|
|
6
|
+
export const TRASH_DIRNAME = '.session-trash'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve every trash path from the harness home.
|
|
10
|
+
*
|
|
11
|
+
* The host refuses to activate when the shared path library cannot produce an
|
|
12
|
+
* absolute home, so no caller falls back to an environment guess of its own.
|
|
13
|
+
*
|
|
14
|
+
* @returns {{ home: string, root: string, display: string }} resolved absolute paths plus the symbolic display form.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveTrashPaths() {
|
|
17
|
+
let home
|
|
18
|
+
try {
|
|
19
|
+
home = resolveDshHome()
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
throw new TrashError(
|
|
22
|
+
'HOME_UNRESOLVED',
|
|
23
|
+
`Cannot resolve the DeepSeek Harness home: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
if (typeof home !== 'string' || home === '' || !path.isAbsolute(home)) {
|
|
27
|
+
throw new TrashError(
|
|
28
|
+
'HOME_UNRESOLVED',
|
|
29
|
+
'@deepseek-ai/dsh-home-paths resolved a non-absolute harness home; the trash plugin refuses to activate.',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
const root = dshHomePath(TRASH_DIRNAME)
|
|
33
|
+
if (typeof root !== 'string' || !path.isAbsolute(root)) {
|
|
34
|
+
throw new TrashError('HOME_UNRESOLVED', '@deepseek-ai/dsh-home-paths did not produce an absolute trash root.')
|
|
35
|
+
}
|
|
36
|
+
const homeLabel = dshHomeDisplay(home)
|
|
37
|
+
const display = `${homeLabel === undefined || homeLabel === null || homeLabel === '' ? home : homeLabel}/${TRASH_DIRNAME}`
|
|
38
|
+
return { home, root, display }
|
|
39
|
+
}
|