dsh-session-mng 0.1.3 → 0.2.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/lib/catalog.js ADDED
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Zero-I/O session catalog and the 0.2.0 read surfaces built on it.
3
+ *
4
+ * `lib/state.js` assembles the whole listing on every request: two O(n)
5
+ * persistence scans plus a log replay per untitled session. That is fine for a
6
+ * hundred sessions and hopeless for ten thousand. The catalog instead reads two
7
+ * in-memory seams the shipped host already maintains —
8
+ * `workspaceRegistry.headers` (every stored header, built once at startup) and
9
+ * the projection cache (`title` / `sessionListMetadata`) — so one request costs
10
+ * no session-log I/O at all.
11
+ *
12
+ * Both seams are version-sensitive, so every accessor is feature-detected and
13
+ * a missing seam degrades to {@link fromState}, which wraps the 0.1.x
14
+ * `buildState()` result in the same shape. The route surface therefore never
15
+ * changes with host version; only the cost does.
16
+ *
17
+ * The counting rules here are deliberately identical to `buildState` (visible
18
+ * rows, hidden-subagent count, fork annotations over the visible set) because
19
+ * the Client renders the same numbers from either source.
20
+ *
21
+ * @module dsh-session-mng/catalog
22
+ */
23
+
24
+ import { buildState, canOf, capabilitiesOf, classifySession, isRunningSession, serviceOf } from './state.js'
25
+ import { SessionMngError } from './errors.js'
26
+
27
+ /** The pseudo workspace id naming sessions that belong to no workspace. */
28
+ export const UNGROUPED = 'ungrouped'
29
+
30
+ /** Tab filters a page or a search may request. */
31
+ export const TABS = new Set(['all', 'active', 'archived'])
32
+
33
+ /** Sort keys, all with an `id` ascending tie-break so pages never drift. */
34
+ export const SORTS = new Set(['updated', 'created', 'title', 'size'])
35
+
36
+ /** Default page size when the client names none. */
37
+ export const DEFAULT_PAGE_LIMIT = 50
38
+
39
+ /** Per-request budget for the cold-title fallback. */
40
+ export const TITLE_FALLBACK_MS = 2_000
41
+
42
+ /** Normalize a non-negative integer query value, or throw a bad-request. */
43
+ function pageOffset(value) {
44
+ if (value === undefined || value === null || value === '') return 0
45
+ const number = Number(value)
46
+ if (!Number.isInteger(number) || number < 0) {
47
+ throw new SessionMngError('bad-request', 'offset must be a non-negative integer', 400)
48
+ }
49
+ return number
50
+ }
51
+
52
+ /** Normalize a page size into `[1, max]`, clamping instead of failing. */
53
+ function pageLimit(value, max, label = 'limit') {
54
+ if (value === undefined || value === null || value === '') return max
55
+ const number = Number(value)
56
+ if (!Number.isFinite(number) || Math.trunc(number) < 1) {
57
+ throw new SessionMngError('bad-request', `${label} must be a positive integer`, 400)
58
+ }
59
+ return Math.min(Math.trunc(number), max)
60
+ }
61
+
62
+ /** Read a registry id array (archived/pinned) read-only, tolerating a throw. */
63
+ function idArray(ctx, name) {
64
+ try {
65
+ const value = serviceOf(ctx, 'workspaceRegistry')?.[name]
66
+ return Array.isArray(value) ? value : []
67
+ } catch {
68
+ return []
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The host's in-memory header index, or undefined when this host build does
74
+ * not expose it. `headers` is a `Map<SessionId, header>` in every checked
75
+ * build; the check is structural so a similar-enough shape still works.
76
+ */
77
+ export function headerIndex(ctx) {
78
+ let headers
79
+ try {
80
+ headers = serviceOf(ctx, 'workspaceRegistry')?.headers
81
+ } catch {
82
+ return undefined
83
+ }
84
+ if (headers === null || headers === undefined) return undefined
85
+ if (typeof headers.get !== 'function' || typeof headers.forEach !== 'function') return undefined
86
+ return headers
87
+ }
88
+
89
+ /** The persisted projection cache service, when mounted. */
90
+ function projectionCache(ctx) {
91
+ const cache = serviceOf(ctx, 'sessionProjectionCache')
92
+ return typeof cache?.cachedSnapshot === 'function' ? cache : undefined
93
+ }
94
+
95
+ /** The live projection registry service, when mounted. */
96
+ function projectionRegistry(ctx) {
97
+ const registry = serviceOf(ctx, 'sessionProjections')
98
+ return typeof registry?.cachedSnapshot === 'function' ? registry : undefined
99
+ }
100
+
101
+ /**
102
+ * Whether the fast path is available: a header index plus a projection cache.
103
+ * When either is missing the caller must use the `buildState` slow path.
104
+ */
105
+ export function catalogAvailable(ctx) {
106
+ return headerIndex(ctx) !== undefined && projectionCache(ctx) !== undefined
107
+ }
108
+
109
+ /** Best-effort live sessions, creation order. */
110
+ function liveSessions(ctx) {
111
+ try {
112
+ const value = ctx.sessions?.list?.()
113
+ return Array.isArray(value) ? value : []
114
+ } catch {
115
+ return []
116
+ }
117
+ }
118
+
119
+ /** Best-effort projection block for one live session (memory only). */
120
+ function liveBlock(ctx, session) {
121
+ const registry = projectionRegistry(ctx)
122
+ if (registry === undefined) return undefined
123
+ try {
124
+ return registry.cachedSnapshot(session, ['title', 'sessionListMetadata'])
125
+ } catch {
126
+ return undefined
127
+ }
128
+ }
129
+
130
+ /** Best-effort projection block for one stored header (memory only). */
131
+ function coldBlock(cache, header) {
132
+ try {
133
+ return cache?.cachedSnapshot(header)
134
+ } catch {
135
+ return undefined
136
+ }
137
+ }
138
+
139
+ /**
140
+ * A predecessor checkpoint's title, for a session whose current checkpoint
141
+ * predates the title projection. Listing-only hint: title text survives a
142
+ * format edge, no other projection does.
143
+ */
144
+ function predecessorTitle(cache, header) {
145
+ if (typeof cache?.cachedPredecessorTitle !== 'function') return ''
146
+ try {
147
+ const block = cache.cachedPredecessorTitle(header)
148
+ return typeof block?.values?.title === 'string' ? block.values.title : ''
149
+ } catch {
150
+ return ''
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Title / blank / last-prompt facts for one session, from memory only.
156
+ *
157
+ * `blank` stays `undefined` when the projection cache has no metadata row for
158
+ * this lifecycle; the row then reports `blank: false, blankKnown: false`
159
+ * instead of claiming a session is empty.
160
+ */
161
+ function listFacts(ctx, cache, header, live) {
162
+ const block = live === undefined ? coldBlock(cache, header) : liveBlock(ctx, live)
163
+ const values = block?.values ?? {}
164
+ let title = typeof values.title === 'string' ? values.title : ''
165
+ if (title === '' && live === undefined) title = predecessorTitle(cache, header)
166
+ const metadata = values.sessionListMetadata
167
+ return {
168
+ title,
169
+ blank: typeof metadata?.blank === 'boolean' ? metadata.blank : undefined,
170
+ lastPromptAt: typeof metadata?.lastPromptAt === 'number' ? metadata.lastPromptAt : undefined,
171
+ }
172
+ }
173
+
174
+ /** Best-effort workspace rows in durable registry order. */
175
+ async function workspaceRows(ctx) {
176
+ const registry = serviceOf(ctx, 'workspaceRegistry')
177
+ if (typeof registry?.list !== 'function') return []
178
+ let entities
179
+ try {
180
+ entities = registry.list()
181
+ } catch {
182
+ return []
183
+ }
184
+ const rows = []
185
+ for (const workspace of entities ?? []) {
186
+ let status = 'unknown'
187
+ try {
188
+ status = await workspace?.status?.()
189
+ } catch {
190
+ status = 'missing-dir'
191
+ }
192
+ rows.push({
193
+ id: workspace.id,
194
+ title: typeof workspace.title === 'string' ? workspace.title : '',
195
+ path: workspace.path,
196
+ status,
197
+ sessionIds: Array.isArray(workspace.sessionIds) ? [...workspace.sessionIds] : [],
198
+ })
199
+ }
200
+ return rows
201
+ }
202
+
203
+ /** Order-independent relation annotations shared by every surface. */
204
+ function attachRelations(rows) {
205
+ const byId = new Map(rows.map((row) => [row.id, row]))
206
+ const forkCountByParent = new Map()
207
+ for (const row of rows) {
208
+ if (row.kind !== 'fork') {
209
+ row.parentTitle = ''
210
+ row.parentPresent = false
211
+ continue
212
+ }
213
+ const parent = row.parentSessionId === null ? undefined : byId.get(row.parentSessionId)
214
+ row.parentTitle = parent === undefined ? '' : parent.title
215
+ row.parentPresent = parent !== undefined
216
+ if (row.parentSessionId === null) continue
217
+ forkCountByParent.set(row.parentSessionId, (forkCountByParent.get(row.parentSessionId) ?? 0) + 1)
218
+ }
219
+ for (const row of rows) row.forkCount = forkCountByParent.get(row.id) ?? 0
220
+ return rows
221
+ }
222
+
223
+ /** One catalog row from a stored header and its in-memory facts. */
224
+ function rowFromHeader({ id, header, live, running, facts, archived, pinned, workspace, capabilities, overrides }) {
225
+ const kind = classifySession(header)
226
+ const createdAt = Number.isFinite(header?.createdAt) ? header.createdAt : 0
227
+ const override = overrides?.get(id)
228
+ const title = override !== undefined && typeof override.title === 'string' ? override.title : facts.title
229
+ const row = {
230
+ id,
231
+ title,
232
+ cwd: typeof header?.cwd === 'string' ? header.cwd : '',
233
+ workspaceId: workspace?.id ?? null,
234
+ workspaceTitle: workspace?.title ?? '',
235
+ archived,
236
+ pinned,
237
+ live: live !== undefined,
238
+ running,
239
+ blank: facts.blank === true,
240
+ blankKnown: facts.blank !== undefined,
241
+ origin: header?.origin,
242
+ kind,
243
+ isFork: kind === 'fork',
244
+ isSubagent: kind === 'subagent',
245
+ parentSessionId: header?.parentSession ?? null,
246
+ parentTitle: '',
247
+ parentPresent: false,
248
+ forkCount: 0,
249
+ agentPreset: typeof header?.agentPreset === 'string' ? header.agentPreset : '',
250
+ createdAt,
251
+ updatedAt: Math.max(createdAt, facts.lastPromptAt ?? 0),
252
+ sizeBytes: 0,
253
+ formatStatus: undefined,
254
+ }
255
+ row.can = canOf(row, capabilities)
256
+ return row
257
+ }
258
+
259
+ /**
260
+ * The catalog fast path: build every row from memory, then annotate relations.
261
+ *
262
+ * @param {object} ctx
263
+ * @param {object} config - normalized plugin config.
264
+ * @param {{ includeSubagents?: boolean, extraIds?: readonly string[], deletedIds?: Set<string>, titleOverrides?: Map<string, {title: string, at: number}> }} [options]
265
+ * @returns {Promise<object>} the catalog
266
+ */
267
+ export async function fromContext(ctx, config, options = {}) {
268
+ const includeSubagents = options.includeSubagents === true
269
+ const extraIds = new Set(options.extraIds ?? [])
270
+ const deletedIds = options.deletedIds
271
+ const cache = projectionCache(ctx)
272
+ const headers = headerIndex(ctx) ?? new Map()
273
+ const archivedIds = idArray(ctx, 'archivedSessionIds')
274
+ const archivedSet = new Set(archivedIds)
275
+ const pinnedSet = new Set(idArray(ctx, 'pinnedSessionIds'))
276
+ const workspaces = await workspaceRows(ctx)
277
+
278
+ const workspaceById = new Map()
279
+ const workspaceByPath = new Map()
280
+ const workspaceBySession = new Map()
281
+ for (const workspace of workspaces) {
282
+ workspaceById.set(workspace.id, workspace)
283
+ workspaceByPath.set(workspace.path, workspace)
284
+ for (const id of workspace.sessionIds) workspaceBySession.set(id, workspace)
285
+ }
286
+
287
+ // Content search is host-FTS-only and disabled in this deployment profile,
288
+ // so 0.2.0 reports it honestly as unavailable and the Client searches titles.
289
+ const capabilities = { ...capabilitiesOf(ctx), search: false }
290
+ const liveById = new Map()
291
+ for (const session of liveSessions(ctx)) {
292
+ if (typeof session?.id === 'string') liveById.set(session.id, session)
293
+ }
294
+
295
+ const headersById = new Map()
296
+ headers.forEach((header, id) => {
297
+ if (typeof id === 'string' && header !== null && typeof header === 'object') headersById.set(id, header)
298
+ })
299
+
300
+ const knownIds = new Set(headersById.keys())
301
+ for (const id of liveById.keys()) knownIds.add(id)
302
+
303
+ const rows = []
304
+ const entries = new Map()
305
+ const considered = new Set()
306
+ let hiddenSubagents = 0
307
+ const consider = (id) => {
308
+ if (considered.has(id)) return
309
+ considered.add(id)
310
+ const live = liveById.get(id)
311
+ const header = live?.header ?? headersById.get(id)
312
+ if (header === undefined) return
313
+ if (deletedIds?.has(id) === true && live === undefined) return
314
+ const kind = classifySession(header)
315
+ if (kind === 'subagent' && !includeSubagents && !extraIds.has(id)) {
316
+ hiddenSubagents += 1
317
+ return
318
+ }
319
+ const cwd = typeof header.cwd === 'string' ? header.cwd : ''
320
+ const workspace = workspaceBySession.get(id) ?? (cwd === '' ? undefined : workspaceByPath.get(cwd))
321
+ const facts = listFacts(ctx, cache, header, live)
322
+ const row = rowFromHeader({
323
+ id,
324
+ header,
325
+ live,
326
+ running: isRunningSession(ctx, id),
327
+ facts,
328
+ archived: archivedSet.has(id),
329
+ pinned: pinnedSet.has(id),
330
+ workspace,
331
+ capabilities,
332
+ overrides: options.titleOverrides,
333
+ })
334
+ rows.push(row)
335
+ entries.set(id, row)
336
+ }
337
+
338
+ for (const id of headersById.keys()) consider(id)
339
+ for (const id of liveById.keys()) consider(id)
340
+ attachRelations(rows)
341
+
342
+ return {
343
+ source: 'catalog',
344
+ generatedAt: Date.now(),
345
+ backend: capabilities.backend,
346
+ capabilities,
347
+ limits: {
348
+ maxBatch: config.maxBatch,
349
+ maxMoveBatch: config.maxMoveBatch,
350
+ pageLimitMax: config.pageLimitMax,
351
+ },
352
+ workspaces: workspaces.map(({ sessionIds, ...workspace }) => workspace),
353
+ workspaceById,
354
+ rows,
355
+ entries,
356
+ hiddenSubagents,
357
+ orphans: archivedIds.filter((id) => !knownIds.has(id)),
358
+ }
359
+ }
360
+
361
+ /**
362
+ * The slow-path catalog: wrap one `buildState()` result in the catalog shape.
363
+ *
364
+ * `buildState` already produced rows with relations and action flags, so this
365
+ * is a projection, not a re-derivation: counts stay identical to `/state` by
366
+ * construction.
367
+ *
368
+ * @param {object} state - result of `buildState`.
369
+ * @param {object} config - normalized plugin config.
370
+ * @returns {object} the catalog
371
+ */
372
+ export function fromState(state, config) {
373
+ const capabilities = { ...state.capabilities, search: false }
374
+ const rows = (Array.isArray(state.sessions) ? state.sessions : []).map((row) => ({
375
+ ...row,
376
+ blankKnown: true,
377
+ }))
378
+ const workspaces = (Array.isArray(state.workspaces) ? state.workspaces : []).map((workspace) => ({
379
+ id: workspace.id,
380
+ title: workspace.title,
381
+ path: workspace.path,
382
+ status: workspace.status,
383
+ }))
384
+ const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]))
385
+ return {
386
+ source: 'legacy',
387
+ generatedAt: Date.now(),
388
+ backend: state.backend ?? capabilities.backend,
389
+ capabilities,
390
+ limits: {
391
+ maxBatch: config.maxBatch,
392
+ maxMoveBatch: config.maxMoveBatch,
393
+ pageLimitMax: config.pageLimitMax,
394
+ },
395
+ workspaces,
396
+ workspaceById,
397
+ rows,
398
+ entries: new Map(rows.map((row) => [row.id, row])),
399
+ hiddenSubagents:
400
+ typeof state.totals?.hiddenSubagents === 'number' ? state.totals.hiddenSubagents : 0,
401
+ orphans: Array.isArray(state.orphans) ? [...state.orphans] : [],
402
+ }
403
+ }
404
+
405
+ /** Whether one row matches a lower-cased query, using the 0.1.3 haystack. */
406
+ export function matchesQuery(row, text) {
407
+ if (text === '') return true
408
+ const haystack = `${row.title}\n${row.cwd}\n${row.id}\n${row.parentTitle ?? ''}`.toLowerCase()
409
+ return haystack.includes(text)
410
+ }
411
+
412
+ /**
413
+ * Filter rows for one tab / workspace / query / blank switch.
414
+ *
415
+ * `workspaceId` may be a workspace id, {@link UNGROUPED}, or `'all'`.
416
+ */
417
+ export function selectRows(catalog, params = {}) {
418
+ const tab = params.tab ?? 'all'
419
+ const workspaceId = params.workspaceId ?? 'all'
420
+ const includeBlank = params.includeBlank === true
421
+ const text = typeof params.query === 'string' ? params.query.trim().toLowerCase() : ''
422
+ return catalog.rows.filter((row) => {
423
+ if (tab === 'active' && row.archived) return false
424
+ if (tab === 'archived' && !row.archived) return false
425
+ if (workspaceId === UNGROUPED) {
426
+ if (row.workspaceId !== null) return false
427
+ } else if (workspaceId !== 'all' && row.workspaceId !== workspaceId) {
428
+ return false
429
+ }
430
+ if (!includeBlank && row.blank === true) return false
431
+ return matchesQuery(row, text)
432
+ })
433
+ }
434
+
435
+ /** One row's sort value; `sizeOf` supplies bytes for `sort=size`. */
436
+ function sortKey(row, sort, sizeOf) {
437
+ if (sort === 'created') return row.createdAt
438
+ if (sort === 'size') return sizeOf(row.id)
439
+ return row.updatedAt
440
+ }
441
+
442
+ /**
443
+ * Sort rows by the requested key, always breaking ties on ascending `id`.
444
+ *
445
+ * The tie-break is what makes offset pagination safe: two rows with the same
446
+ * timestamp can no longer swap places between two requests and duplicate or
447
+ * skip a row across pages.
448
+ */
449
+ export function sortRows(rows, sort = 'updated', sizeOf) {
450
+ const keyOf = sort === 'size' ? (typeof sizeOf === 'function' ? sizeOf : () => 0) : undefined
451
+ return [...rows].sort((left, right) => {
452
+ let result
453
+ if (sort === 'title') {
454
+ result = (left.title || left.cwd).localeCompare(right.title || right.cwd)
455
+ } else {
456
+ result = sortKey(right, sort, keyOf) - sortKey(left, sort, keyOf)
457
+ }
458
+ if (result !== 0) return result
459
+ return String(left.id).localeCompare(String(right.id))
460
+ })
461
+ }
462
+
463
+ /**
464
+ * Nest branches directly under their parent, the way the sidebar shows them.
465
+ *
466
+ * Only parents present in `rows` absorb children: a branch whose parent was
467
+ * filtered out (other tab, other query, other page) stays top level and keeps
468
+ * its "from <parent>" annotation, which the page decorator turns into a
469
+ * `parentStub`.
470
+ */
471
+ export function orderBranchRows(rows) {
472
+ const ids = new Set(rows.map((row) => row.id))
473
+ const childrenOf = new Map()
474
+ const tops = []
475
+ for (const row of rows) {
476
+ const parentId = row.kind === 'fork' ? row.parentSessionId : null
477
+ if (parentId !== null && parentId !== row.id && ids.has(parentId)) {
478
+ const list = childrenOf.get(parentId) ?? []
479
+ list.push(row)
480
+ childrenOf.set(parentId, list)
481
+ } else {
482
+ tops.push(row)
483
+ }
484
+ }
485
+ const out = []
486
+ const visited = new Set()
487
+ const visit = (row, depth) => {
488
+ if (visited.has(row.id)) return
489
+ visited.add(row.id)
490
+ out.push(depth === 0 ? row : { ...row, branchDepth: depth })
491
+ for (const child of childrenOf.get(row.id) ?? []) visit(child, depth + 1)
492
+ }
493
+ for (const row of tops) visit(row, 0)
494
+ // A corrupt parent cycle must never silently drop rows from the list.
495
+ return out.length === rows.length ? out : rows
496
+ }
497
+
498
+ /**
499
+ * Filter, sort and paginate one workspace (or the ungrouped bucket, or all).
500
+ *
501
+ * @returns {{rows: object[], total: number, offset: number, limit: number, hasMore: boolean, workspace: object|undefined}}
502
+ */
503
+ export function buildPage(catalog, params = {}) {
504
+ const limit = pageLimit(params.limit, params.limitMax ?? DEFAULT_PAGE_LIMIT)
505
+ const offset = pageOffset(params.offset)
506
+ const filtered = selectRows(catalog, params)
507
+ const sorted = sortRows(filtered, params.sort ?? 'updated', params.sizeOf)
508
+ const ordered = orderBranchRows(sorted)
509
+ const rows = ordered.slice(offset, offset + limit)
510
+ const workspaceId = params.workspaceId ?? 'all'
511
+ return {
512
+ rows,
513
+ total: ordered.length,
514
+ offset,
515
+ limit,
516
+ hasMore: offset + rows.length < ordered.length,
517
+ workspace: workspaceId === UNGROUPED ? undefined : catalog.workspaceById.get(workspaceId),
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Cross-workspace search: every workspace with at least one hit, each carrying
523
+ * its own total and a bounded first page, so the Client can auto-expand them.
524
+ */
525
+ export function buildSearchGroups(catalog, params = {}) {
526
+ const limit = pageLimit(params.limit, params.limitMax ?? DEFAULT_PAGE_LIMIT, 'limitPerGroup')
527
+ const filtered = selectRows(catalog, { ...params, workspaceId: 'all' })
528
+ const sorted = sortRows(filtered, params.sort ?? 'updated', params.sizeOf)
529
+ const byWorkspace = new Map()
530
+ for (const row of sorted) {
531
+ const key = row.workspaceId === null ? UNGROUPED : row.workspaceId
532
+ const group = byWorkspace.get(key)
533
+ if (group === undefined) byWorkspace.set(key, [row])
534
+ else group.push(row)
535
+ }
536
+ const order = [...catalog.workspaces.map((workspace) => workspace.id), UNGROUPED]
537
+ // Defensive: a row naming a workspace the registry no longer lists must still
538
+ // be reachable through search instead of silently disappearing.
539
+ for (const key of byWorkspace.keys()) if (!order.includes(key)) order.push(key)
540
+ const groups = []
541
+ let total = 0
542
+ for (const key of order) {
543
+ const groupRows = byWorkspace.get(key)
544
+ if (groupRows === undefined || groupRows.length === 0) continue
545
+ total += groupRows.length
546
+ const ordered = orderBranchRows(groupRows)
547
+ groups.push({
548
+ workspaceId: key === UNGROUPED ? null : key,
549
+ workspace: key === UNGROUPED ? undefined : catalog.workspaceById.get(key),
550
+ total: ordered.length,
551
+ hasMore: ordered.length > limit,
552
+ rows: ordered.slice(0, limit),
553
+ })
554
+ }
555
+ return { groups, total }
556
+ }
557
+
558
+ /** The three blank-session id lists the Settings page's two buttons need. */
559
+ export function buildBlanks(catalog) {
560
+ const blanks = catalog.rows.filter((row) => row.blank === true)
561
+ return {
562
+ unarchivedIds: blanks.filter((row) => !row.archived).map((row) => row.id),
563
+ archivedIds: blanks.filter((row) => row.archived).map((row) => row.id),
564
+ deletableIds: blanks.filter((row) => row.archived && row.can?.delete === true).map((row) => row.id),
565
+ }
566
+ }
567
+
568
+ /** Zeroed workspace bucket used by the overview counters. */
569
+ function emptyBucket() {
570
+ return { count: 0, activeCount: 0, archivedCount: 0, blankCount: 0 }
571
+ }
572
+
573
+ /** Add one row to a counter bucket. */
574
+ function countInto(bucket, row) {
575
+ bucket.count += 1
576
+ if (row.archived) bucket.archivedCount += 1
577
+ else bucket.activeCount += 1
578
+ if (row.blank === true) bucket.blankCount += 1
579
+ }
580
+
581
+ /**
582
+ * The first-paint overview: counts only, never a session row.
583
+ *
584
+ * Byte totals stay `null` on purpose — sizes cost an O(n) store scan, so the
585
+ * Client fetches them separately and fills the line in when they arrive.
586
+ */
587
+ export function buildOverview(catalog) {
588
+ const buckets = new Map(catalog.workspaces.map((workspace) => [workspace.id, emptyBucket()]))
589
+ const ungrouped = emptyBucket()
590
+ let subagents = 0
591
+ let forks = 0
592
+ let blank = 0
593
+ for (const row of catalog.rows) {
594
+ const bucket = (row.workspaceId === null ? undefined : buckets.get(row.workspaceId)) ?? ungrouped
595
+ countInto(bucket, row)
596
+ if (row.isSubagent) subagents += 1
597
+ if (row.isFork) forks += 1
598
+ if (row.blank === true) blank += 1
599
+ }
600
+ return {
601
+ generatedAt: catalog.generatedAt,
602
+ source: catalog.source,
603
+ workspaces: catalog.workspaces.map((workspace) => ({
604
+ id: workspace.id,
605
+ title: workspace.title,
606
+ path: workspace.path,
607
+ status: workspace.status ?? 'unknown',
608
+ ...(buckets.get(workspace.id) ?? emptyBucket()),
609
+ totalBytes: null,
610
+ })),
611
+ ungrouped: { ...ungrouped, totalBytes: null },
612
+ totals: {
613
+ all: catalog.rows.length,
614
+ active: catalog.rows.length - catalog.rows.filter((row) => row.archived).length,
615
+ archived: catalog.rows.filter((row) => row.archived).length,
616
+ subagents,
617
+ hiddenSubagents: catalog.hiddenSubagents,
618
+ forks,
619
+ blank,
620
+ bytes: null,
621
+ },
622
+ orphans: [...catalog.orphans],
623
+ capabilities: catalog.capabilities,
624
+ limits: catalog.limits,
625
+ backend: catalog.backend,
626
+ }
627
+ }
628
+
629
+ /** The detail payload for one id, or undefined when the catalog never saw it. */
630
+ export function buildDetail(catalog, id) {
631
+ const row = catalog.entries.get(id)
632
+ if (row === undefined) return undefined
633
+ return { row, capabilities: catalog.capabilities, limits: catalog.limits }
634
+ }
635
+
636
+ /**
637
+ * The wire shape of one row: the 0.1.x `/state` row plus the catalog-only
638
+ * `blankKnown` flag. Page and detail decorators add `sizeBytes`,
639
+ * `formatStatus`, `branchDepth` and `parentStub`.
640
+ */
641
+ export function publicRow(row) {
642
+ const out = {
643
+ id: row.id,
644
+ title: row.title,
645
+ cwd: row.cwd,
646
+ workspaceId: row.workspaceId,
647
+ workspaceTitle: row.workspaceTitle,
648
+ archived: row.archived,
649
+ pinned: row.pinned,
650
+ live: row.live,
651
+ running: row.running,
652
+ blank: row.blank,
653
+ blankKnown: row.blankKnown === true,
654
+ origin: row.origin,
655
+ kind: row.kind,
656
+ isFork: row.isFork,
657
+ isSubagent: row.isSubagent,
658
+ parentSessionId: row.parentSessionId,
659
+ parentTitle: row.parentTitle,
660
+ parentPresent: row.parentPresent,
661
+ forkCount: row.forkCount,
662
+ agentPreset: row.agentPreset,
663
+ createdAt: row.createdAt,
664
+ updatedAt: row.updatedAt,
665
+ sizeBytes: Number.isFinite(row.sizeBytes) ? row.sizeBytes : 0,
666
+ formatStatus: row.formatStatus,
667
+ can: row.can,
668
+ }
669
+ if (row.branchDepth !== undefined) out.branchDepth = row.branchDepth
670
+ return out
671
+ }
672
+
673
+ /**
674
+ * Batch-read titles for page rows the projection cache had none for.
675
+ *
676
+ * This is the one expensive host call the 0.2.0 design keeps, so it is bounded
677
+ * twice: only the ids missing a title on this page, and a hard time budget.
678
+ * Whatever does not arrive in time stays empty and is reported as
679
+ * `titlesPending` — the Client shows a placeholder rather than blocking.
680
+ *
681
+ * @returns {Promise<Map<string, string>>} id → title
682
+ */
683
+ export async function readPageTitles(ctx, ids, signal, budgetMs = TITLE_FALLBACK_MS) {
684
+ const titles = new Map()
685
+ if (!Array.isArray(ids) || ids.length === 0) return titles
686
+ const query = serviceOf(ctx, 'sessionQuery')
687
+ if (typeof query?.readTitleSnapshots !== 'function') return titles
688
+ let budget
689
+ try {
690
+ budget = AbortSignal.timeout(budgetMs)
691
+ } catch {
692
+ return titles
693
+ }
694
+ let combined = budget
695
+ if (signal !== undefined && typeof AbortSignal.any === 'function') {
696
+ try {
697
+ combined = AbortSignal.any([signal, budget])
698
+ } catch {
699
+ combined = budget
700
+ }
701
+ }
702
+ try {
703
+ const results = await query.readTitleSnapshots(ids, combined)
704
+ for (const result of results ?? []) {
705
+ if (result?.status !== 'fulfilled') continue
706
+ const snapshot = result.value?.title
707
+ const text = typeof snapshot?.title === 'string' ? snapshot.title : ''
708
+ if (text !== '') titles.set(result.sessionId, text)
709
+ }
710
+ } catch {
711
+ // A timed-out or unavailable fallback leaves the titles pending.
712
+ }
713
+ return titles
714
+ }
715
+
716
+ /**
717
+ * Decorate one ordered row set for the wire: sizes, format status and the
718
+ * parent stub a branch needs when its parent is not on this page.
719
+ *
720
+ * @param {object[]} rows - rows already ordered for the response.
721
+ * @param {Map<string, {sizeBytes?: number, formatStatus?: string}>} facts
722
+ */
723
+ export function decorateRows(rows, facts) {
724
+ const pageIds = new Set(rows.map((row) => row.id))
725
+ return rows.map((row) => {
726
+ const out = publicRow(row)
727
+ const factsOfRow = facts?.get(row.id)
728
+ if (factsOfRow !== undefined && Number.isFinite(factsOfRow.sizeBytes)) out.sizeBytes = factsOfRow.sizeBytes
729
+ if (typeof factsOfRow?.formatStatus === 'string') out.formatStatus = factsOfRow.formatStatus
730
+ if (row.kind === 'fork' && row.parentSessionId !== null && !pageIds.has(row.parentSessionId)) {
731
+ out.parentStub = { id: row.parentSessionId, title: row.parentTitle, present: row.parentPresent === true }
732
+ }
733
+ return out
734
+ })
735
+ }
736
+
737
+ /** Build the slow-path state once; callers cache it behind their own TTL. */
738
+ export async function legacyState(ctx, config, options = {}) {
739
+ return buildState(ctx, config, {
740
+ includeSubagents: options.includeSubagents === true,
741
+ signal: options.signal,
742
+ })
743
+ }
744
+
745
+ /**
746
+ * The slow-path catalog cache: one `buildState` result per includeSubagents
747
+ * flag, served for `ttlMs` and dropped wholesale by {@link invalidate}.
748
+ *
749
+ * This is the 0.2.0 guarantee stated as a fallback: on a host whose in-memory
750
+ * seams changed shape, the routes keep answering the same shapes, and the cost
751
+ * degrades to 0.1.x plus a 30 s memo instead of failing.
752
+ *
753
+ * @param {object} ctx
754
+ * @param {object} config - normalized plugin config.
755
+ * @param {number} [ttlMs]
756
+ */
757
+ export function createLegacyCatalogCache(ctx, config, ttlMs = 30_000) {
758
+ const states = { 0: undefined, 1: undefined }
759
+ return {
760
+ async catalog(includeSubagents, signal) {
761
+ const key = includeSubagents === true ? 1 : 0
762
+ const hit = states[key]
763
+ if (hit !== undefined && Date.now() - hit.at < ttlMs) return hit.catalog
764
+ const catalog = fromState(await legacyState(ctx, config, { includeSubagents: key === 1, signal }), config)
765
+ states[key] = { at: Date.now(), catalog }
766
+ return catalog
767
+ },
768
+ invalidate() {
769
+ states[0] = undefined
770
+ states[1] = undefined
771
+ },
772
+ }
773
+ }