@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/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 { entryKind, isSafeDirectoryPath, isSafeTrashId, samePath } from './fsops.js'
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 = 1
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: meta.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 {boolean} sessionExists whether `objects/<trashId>/session` is a directory.
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, sessionExists, now) {
91
- if (!sessionExists) {
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: 'The trash copy of this session is missing.',
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 objects = []
129
- let unindexed = 0
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
- objects.push({ trashId, sessionExists: false, meta: undefined, metaValid: false })
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
- objects.push({ trashId, sessionExists: false, meta, metaValid: false })
150
- unindexed += 1
151
- continue
309
+ return { object: { trashId, sessionExists: false, meta, metaValid: false }, unindexed: 1 }
152
310
  }
153
- let sessionExists = false
311
+ let presence
154
312
  try {
155
- sessionExists = (await entryKind(store.sessionPath(trashId))) === 'directory'
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
- objects.push({ trashId, sessionExists, meta, metaValid })
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()) return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0 }
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
- if (entry.state === 'missing' && object.sessionExists) {
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 = toEntry(object.meta)
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.sessionExists, now)
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 = toEntry(object.meta)
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.sessionExists, now))
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