@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.
@@ -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
+ }