dsh-session-mng 0.1.4 → 0.2.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/CHANGELOG.md +43 -0
- package/README.md +31 -11
- package/ROADMAP.md +13 -12
- package/client.js +1272 -328
- package/docs/01-design.md +49 -2
- package/docs/02-open-source-and-marketplace.md +19 -0
- package/docs/HANDOVER-2026-10-29.md +105 -0
- package/docs/PLAN-2026-10-29.md +245 -0
- package/docs/PLAN-2026-11-02-family-move.md +72 -0
- package/docs/RELEASE-CHECKLIST-0.1.4.md +6 -5
- package/docs/RELEASE-CHECKLIST-0.2.0.md +79 -0
- package/docs/RELEASE-CHECKLIST-0.2.1.md +46 -0
- package/lib/api.js +44 -11
- package/lib/catalog.js +773 -0
- package/lib/service.js +595 -41
- package/lib/state.js +46 -25
- package/lib/store-jsonl.js +76 -2
- package/package.json +1 -1
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
|
+
}
|