dsh-session-mng 0.1.4 → 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/service.js CHANGED
@@ -11,6 +11,21 @@
11
11
  */
12
12
 
13
13
  import { SessionMngError, messageOf } from './errors.js'
14
+ import {
15
+ buildBlanks,
16
+ buildDetail,
17
+ buildOverview,
18
+ buildPage,
19
+ buildSearchGroups,
20
+ catalogAvailable,
21
+ createLegacyCatalogCache,
22
+ decorateRows,
23
+ fromContext,
24
+ readPageTitles,
25
+ SORTS,
26
+ TABS,
27
+ UNGROUPED,
28
+ } from './catalog.js'
14
29
  import { normalizeTitle, proposeTitle, textOfEvent } from './title.js'
15
30
  import { buildState, classifySession } from './state.js'
16
31
  import {
@@ -19,6 +34,7 @@ import {
19
34
  findSessionDirs,
20
35
  finalizeMove,
21
36
  pathExists,
37
+ readLogFactsBatch,
22
38
  removeStagedDelete,
23
39
  restoreStagedDelete,
24
40
  stageDelete,
@@ -41,6 +57,15 @@ import {
41
57
  /** Hard ceiling on one cascade delete, regardless of maxBatch. */
42
58
  const MAX_DELETE_TOTAL = 500
43
59
 
60
+ /** How long one slow-path `buildState` result may serve catalog reads. */
61
+ const LEGACY_TTL_MS = 30_000
62
+
63
+ /** How long a locally written cold title outranks the host projection cache. */
64
+ const TITLE_OVERRIDE_TTL_MS = 5 * 60_000
65
+
66
+ /** Hard budget for the one O(n) size scan behind `GET /sizes`. */
67
+ const SIZE_SCAN_TIMEOUT_MS = 20_000
68
+
44
69
  /** Load-time defaults; a patch entry's `config` overrides them field by field. */
45
70
  export const DEFAULT_CONFIG = {
46
71
  maxBatch: 50,
@@ -51,6 +76,9 @@ export const DEFAULT_CONFIG = {
51
76
  titleMaxBytes: 120,
52
77
  allowRemote: false,
53
78
  sessionsRoot: '',
79
+ sizesTtlMs: 60_000,
80
+ sizeScanMax: 20_000,
81
+ pageLimitMax: 50,
54
82
  }
55
83
 
56
84
  /** Clamp one integer config field. */
@@ -71,9 +99,38 @@ export function normalizeConfig(input) {
71
99
  titleMaxBytes: integer(src.titleMaxBytes, DEFAULT_CONFIG.titleMaxBytes, 16, 512),
72
100
  allowRemote: src.allowRemote === true,
73
101
  sessionsRoot: typeof src.sessionsRoot === 'string' ? src.sessionsRoot : '',
102
+ sizesTtlMs: integer(src.sizesTtlMs, DEFAULT_CONFIG.sizesTtlMs, 1_000, 3_600_000),
103
+ sizeScanMax: integer(src.sizeScanMax, DEFAULT_CONFIG.sizeScanMax, 100, 1_000_000),
104
+ pageLimitMax: integer(src.pageLimitMax, DEFAULT_CONFIG.pageLimitMax, 1, 200),
74
105
  }
75
106
  }
76
107
 
108
+ /** Interpret one query flag: `1`/`true`, `0`/`false`, or the fallback. */
109
+ function flagOf(value, fallback = false) {
110
+ if (value === undefined || value === null) return fallback
111
+ if (value === true || value === '1' || value === 'true') return true
112
+ if (value === false || value === '0' || value === 'false') return false
113
+ return fallback
114
+ }
115
+
116
+ /** Validate one enumerated query value against its allowed set. */
117
+ function choiceOf(value, allowed, fallback, label) {
118
+ if (value === undefined || value === null || value === '') return fallback
119
+ if (typeof value !== 'string' || !allowed.has(value)) {
120
+ throw new SessionMngError('bad-request', `${label} must be one of: ${[...allowed].join(', ')}`, 400)
121
+ }
122
+ return value
123
+ }
124
+
125
+ /** Validate the workspace selector of one page request. */
126
+ function workspaceSelector(value) {
127
+ const id = typeof value === 'string' ? value.trim() : ''
128
+ if (id === '') {
129
+ throw new SessionMngError('bad-request', 'workspaceId is required (a workspace id or "ungrouped")', 400)
130
+ }
131
+ return id
132
+ }
133
+
77
134
  /** Read a service without declaring it as a hard dependency. */
78
135
  function serviceOf(ctx, name) {
79
136
  try {
@@ -184,6 +241,41 @@ function summarize(results) {
184
241
  export function createService(ctx, config) {
185
242
  const withLock = createLock()
186
243
 
244
+ // -------------------------------------------------------------------------
245
+ // Process-local read memory
246
+ //
247
+ // `deletedIds` and `titleOverrides` exist because two host facts can lag a
248
+ // write this plugin just made: the registry keeps a deleted session's header
249
+ // until the next restart, and a cold title only reaches the projection cache
250
+ // on its next fold. They are reconciled (never authoritative) and bounded by
251
+ // a TTL or by the next `/sizes` scan.
252
+ // -------------------------------------------------------------------------
253
+
254
+ /** Ids this process deleted; filtered from the catalog until reconciliation. */
255
+ const deletedIds = new Set()
256
+ /** Titles this process wrote, preferred until the host projection catches up. */
257
+ const titleOverrides = new Map()
258
+ /** One all-store size snapshot: TTL-cached, shared by pages and `/sizes`. */
259
+ const sizeState = { at: 0, byId: new Map(), inflight: undefined, epoch: 0 }
260
+ /** Slow-path `buildState` catalogue, memoized for hosts without the seams. */
261
+ const legacyCatalog = createLegacyCatalogCache(ctx, config, LEGACY_TTL_MS)
262
+
263
+ /**
264
+ * Drop every process-local read cache after a mutation.
265
+ *
266
+ * The catalog itself holds no cache (it is rebuilt per request from memory),
267
+ * so only the two things that outlive a request are cleared: the size
268
+ * snapshot and the slow-path state. Both would otherwise answer a fresh
269
+ * request with pre-mutation numbers.
270
+ */
271
+ const invalidateReadCaches = () => {
272
+ sizeState.epoch += 1
273
+ sizeState.at = 0
274
+ sizeState.byId = new Map()
275
+ sizeState.inflight = undefined
276
+ legacyCatalog.invalidate()
277
+ }
278
+
187
279
  const persistence = () => ctx.sessionPersistence
188
280
  const registry = () => ctx.workspaceRegistry
189
281
  const query = () => ctx.sessionQuery
@@ -347,6 +439,8 @@ export function createService(ctx, config) {
347
439
  if (titles === undefined) throw new SessionMngError('unsupported-host', 'this deployment mounts no session-title service', 501)
348
440
  try {
349
441
  const accepted = titles.rename(live, normalized)
442
+ titleOverrides.set(id, { title: accepted.title, at: Date.now() })
443
+ invalidateReadCaches()
350
444
  return { title: accepted.title, seq: accepted.eventSeq, mode: 'live' }
351
445
  } catch (error) {
352
446
  if (String(error?.name).includes('TitleInvalid')) {
@@ -408,6 +502,8 @@ export function createService(ctx, config) {
408
502
  actual,
409
503
  })
410
504
  }
505
+ titleOverrides.set(id, { title: normalized, at: Date.now() })
506
+ invalidateReadCaches()
411
507
  return result
412
508
  })
413
509
  }
@@ -429,6 +525,7 @@ export function createService(ctx, config) {
429
525
  results.push({ sessionId: id, ok: false, error: resultError(error) })
430
526
  }
431
527
  }
528
+ if (results.some((item) => item.ok)) invalidateReadCaches()
432
529
  return summarize(results)
433
530
  }
434
531
 
@@ -445,6 +542,7 @@ export function createService(ctx, config) {
445
542
  results.push({ sessionId: id, ok: false, error: resultError(error) })
446
543
  }
447
544
  }
545
+ if (results.some((item) => item.ok)) invalidateReadCaches()
448
546
  return summarize(results)
449
547
  }
450
548
 
@@ -681,6 +779,20 @@ export function createService(ctx, config) {
681
779
  })
682
780
  }
683
781
 
782
+ // The registry caches headers at startup and keeps the deleted session's
783
+ // slot until the next restart. Remember what this process removed so the
784
+ // catalog stops listing it (a ghost row would offer buttons that 404),
785
+ // and clear the cached header when that seam is writable.
786
+ for (const record of records) {
787
+ deletedIds.add(record.id)
788
+ try {
789
+ registry()?.headers?.delete?.(record.id)
790
+ } catch {
791
+ // A read-only header cache only costs a stale row until `/sizes`.
792
+ }
793
+ }
794
+ invalidateReadCaches()
795
+
684
796
  const results = []
685
797
  for (const rootId of roots) {
686
798
  const failure = failures.get(rootId)
@@ -750,6 +862,7 @@ export function createService(ctx, config) {
750
862
  }
751
863
  }
752
864
  })
865
+ if (results.some((item) => item.ok)) invalidateReadCaches()
753
866
  return summarize(results)
754
867
  }
755
868
 
@@ -892,6 +1005,343 @@ export function createService(ctx, config) {
892
1005
  }
893
1006
  }
894
1007
 
1008
+ // -------------------------------------------------------------------------
1009
+ // Read surfaces (0.2.0): overview / paged sessions / search / detail / sizes
1010
+ // -------------------------------------------------------------------------
1011
+
1012
+ /** One O(n) store scan behind a timeout and an explicit size ceiling. */
1013
+ const scanSizes = async (signal) => {
1014
+ const store = persistence()
1015
+ if (store === undefined || typeof store.list !== 'function') {
1016
+ throw new SessionMngError('sizes-pending', 'this deployment has no listable session persistence, so sizes are unavailable', 503)
1017
+ }
1018
+ const epoch = sizeState.epoch
1019
+ const budget = AbortSignal.timeout(SIZE_SCAN_TIMEOUT_MS)
1020
+ let combined = budget
1021
+ if (signal !== undefined && typeof AbortSignal.any === 'function') {
1022
+ try {
1023
+ combined = AbortSignal.any([signal, budget])
1024
+ } catch {
1025
+ combined = budget
1026
+ }
1027
+ }
1028
+ let entries
1029
+ try {
1030
+ entries = await store.list({ signal: combined })
1031
+ } catch (error) {
1032
+ throw new SessionMngError('sizes-pending', `could not scan session sizes: ${messageOf(error)}`, 503)
1033
+ }
1034
+ const list = Array.isArray(entries) ? entries : []
1035
+ if (list.length > config.sizeScanMax) {
1036
+ throw new SessionMngError(
1037
+ 'sizes-pending',
1038
+ `size scan skipped: ${list.length} stored sessions exceed the configured limit of ${config.sizeScanMax}`,
1039
+ 503,
1040
+ { count: list.length, max: config.sizeScanMax },
1041
+ )
1042
+ }
1043
+ const byId = new Map()
1044
+ for (const entry of list) {
1045
+ const id = entry?.header?.id
1046
+ if (typeof id !== 'string') continue
1047
+ byId.set(id, {
1048
+ sizeBytes: Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : 0,
1049
+ formatStatus: typeof entry.formatStatus === 'string' ? entry.formatStatus : undefined,
1050
+ })
1051
+ }
1052
+ const at = Date.now()
1053
+ if (epoch === sizeState.epoch) {
1054
+ sizeState.at = at
1055
+ sizeState.byId = byId
1056
+ }
1057
+ // Reconciliation: a store that no longer lists an id we deleted means the
1058
+ // ghost marker can go, and a title override past its window has served its
1059
+ // purpose (the host projection has caught up by then).
1060
+ for (const id of [...deletedIds]) if (!byId.has(id)) deletedIds.delete(id)
1061
+ for (const [id, override] of [...titleOverrides]) {
1062
+ if (at - override.at > TITLE_OVERRIDE_TTL_MS) titleOverrides.delete(id)
1063
+ }
1064
+ return { at, byId, cached: false }
1065
+ }
1066
+
1067
+ /** The size snapshot, reusing a fresh one and de-duplicating concurrent scans. */
1068
+ const ensureSizes = (signal) => {
1069
+ if (sizeState.at !== 0 && Date.now() - sizeState.at < config.sizesTtlMs) {
1070
+ return Promise.resolve({ at: sizeState.at, byId: sizeState.byId, cached: true })
1071
+ }
1072
+ if (sizeState.inflight !== undefined) return sizeState.inflight
1073
+ const promise = scanSizes(signal).finally(() => {
1074
+ sizeState.inflight = undefined
1075
+ })
1076
+ sizeState.inflight = promise
1077
+ return promise
1078
+ }
1079
+
1080
+ /**
1081
+ * Build one request's catalog: the zero-I/O fast path when the host exposes
1082
+ * both seams, otherwise the cached `buildState` slow path. The response shape
1083
+ * is identical either way; only `source` and the cost differ.
1084
+ */
1085
+ const catalogFor = async (options = {}) => {
1086
+ const includeSubagents = options.includeSubagents === true
1087
+ // An override is only a bridge until the host projection catches up; drop
1088
+ // the ones that outlived their window so an external rename converges.
1089
+ for (const [id, override] of [...titleOverrides]) {
1090
+ if (Date.now() - override.at > TITLE_OVERRIDE_TTL_MS) titleOverrides.delete(id)
1091
+ }
1092
+ if (!catalogAvailable(ctx)) {
1093
+ return legacyCatalog.catalog(includeSubagents, options.signal)
1094
+ }
1095
+ return fromContext(ctx, config, {
1096
+ includeSubagents,
1097
+ extraIds: options.extraIds,
1098
+ deletedIds,
1099
+ titleOverrides,
1100
+ })
1101
+ }
1102
+
1103
+ /** Fill this page's missing titles within the fallback budget. */
1104
+ const applyTitles = async (rows, signal) => {
1105
+ const ids = rows.filter((row) => row.title === '').map((row) => row.id)
1106
+ if (ids.length === 0) return 0
1107
+ const titles = await readPageTitles(ctx, ids, signal)
1108
+ let pending = 0
1109
+ for (const row of rows) {
1110
+ if (row.title !== '') continue
1111
+ const title = titles.get(row.id)
1112
+ if (typeof title === 'string' && title !== '') row.title = title
1113
+ else pending += 1
1114
+ }
1115
+ return pending
1116
+ }
1117
+
1118
+ /**
1119
+ * Size/format facts for one bounded row set: from the warm `/sizes` snapshot
1120
+ * when it covers a row, otherwise one readdir+stat on that row's own log.
1121
+ *
1122
+ * `cacheOnly` keeps the per-row stat out of surfaces with an unbounded number
1123
+ * of groups (search): those read the warm snapshot and leave the rest for the
1124
+ * group the user actually expands.
1125
+ */
1126
+ const factsFor = async (rows, options = {}) => {
1127
+ const facts = new Map()
1128
+ if (options.sizes === false) return facts
1129
+ if (sizeState.at !== 0 && Date.now() - sizeState.at < config.sizesTtlMs) {
1130
+ for (const row of rows) {
1131
+ const fact = sizeState.byId.get(row.id)
1132
+ if (fact !== undefined) facts.set(row.id, fact)
1133
+ }
1134
+ }
1135
+ if (options.cacheOnly === true || options.legacy === true) return facts
1136
+ const missing = rows.filter((row) => !facts.has(row.id))
1137
+ if (missing.length === 0) return facts
1138
+ const store = persistence()
1139
+ if (store === undefined || typeof store.locate !== 'function') return facts
1140
+ const found = await readLogFactsBatch(
1141
+ store,
1142
+ missing.map((row) => ({ id: row.id, cwd: row.cwd })),
1143
+ )
1144
+ for (const [id, fact] of found) facts.set(id, fact)
1145
+ return facts
1146
+ }
1147
+
1148
+ /** The workspace descriptor the Client renders as a group header. */
1149
+ const workspaceDescriptor = (workspace) =>
1150
+ workspace === undefined
1151
+ ? null
1152
+ : {
1153
+ id: workspace.id,
1154
+ title: workspace.title,
1155
+ path: workspace.path,
1156
+ status: workspace.status ?? 'unknown',
1157
+ }
1158
+
1159
+ /** First-paint counts for every workspace, never a session row. */
1160
+ const overview = async (options = {}) => {
1161
+ const catalog = await catalogFor({
1162
+ includeSubagents: flagOf(options.includeSubagents),
1163
+ signal: options.signal,
1164
+ })
1165
+ return buildOverview(catalog)
1166
+ }
1167
+
1168
+ /** One workspace's (or the ungrouped bucket's) paged, branch-aware rows. */
1169
+ const sessions = async (params = {}) => {
1170
+ const workspaceId = workspaceSelector(params.workspaceId)
1171
+ const tab = choiceOf(params.tab, TABS, 'all', 'tab')
1172
+ const sort = choiceOf(params.sort, SORTS, 'updated', 'sort')
1173
+ const includeBlank = flagOf(params.blank)
1174
+ const includeSubagents = flagOf(params.subagents)
1175
+ const withSizes = flagOf(params.sizes, true)
1176
+ const catalog = await catalogFor({ includeSubagents, signal: params.signal })
1177
+ if (workspaceId !== UNGROUPED && !catalog.workspaceById.has(workspaceId)) {
1178
+ throw new SessionMngError('workspace-not-found', `unknown workspace "${workspaceId}"`, 404)
1179
+ }
1180
+ let sizeOf
1181
+ if (sort === 'size') {
1182
+ const snapshot = await ensureSizes(params.signal)
1183
+ sizeOf = (id) => snapshot.byId.get(id)?.sizeBytes ?? 0
1184
+ }
1185
+ const page = buildPage(catalog, {
1186
+ workspaceId,
1187
+ tab,
1188
+ query: params.query,
1189
+ includeBlank,
1190
+ sort,
1191
+ sizeOf,
1192
+ offset: params.offset,
1193
+ limit: params.limit,
1194
+ limitMax: config.pageLimitMax,
1195
+ })
1196
+ const titlesPending = await applyTitles(page.rows, params.signal)
1197
+ const facts = await factsFor(page.rows, {
1198
+ sizes: withSizes,
1199
+ legacy: catalog.source === 'legacy',
1200
+ })
1201
+ return {
1202
+ generatedAt: catalog.generatedAt,
1203
+ source: catalog.source,
1204
+ workspaceId,
1205
+ workspace: workspaceDescriptor(page.workspace),
1206
+ total: page.total,
1207
+ offset: page.offset,
1208
+ limit: page.limit,
1209
+ hasMore: page.hasMore,
1210
+ titlesPending,
1211
+ rows: decorateRows(page.rows, facts),
1212
+ }
1213
+ }
1214
+
1215
+ /** Cross-workspace title/path/id search, one bounded page per hit group. */
1216
+ const search = async (params = {}) => {
1217
+ const query = typeof params.query === 'string' ? params.query.trim() : ''
1218
+ if (query === '') throw new SessionMngError('bad-request', 'query must be a non-empty string', 400)
1219
+ if (query.length > 500) throw new SessionMngError('bad-request', 'query must be at most 500 characters', 400)
1220
+ const tab = choiceOf(params.tab, TABS, 'all', 'tab')
1221
+ const sort = choiceOf(params.sort, SORTS, 'updated', 'sort')
1222
+ const includeBlank = flagOf(params.blank)
1223
+ const includeSubagents = flagOf(params.subagents)
1224
+ const withSizes = flagOf(params.sizes, true)
1225
+ const catalog = await catalogFor({ includeSubagents, signal: params.signal })
1226
+ let sizeOf
1227
+ if (sort === 'size') {
1228
+ const snapshot = await ensureSizes(params.signal)
1229
+ sizeOf = (id) => snapshot.byId.get(id)?.sizeBytes ?? 0
1230
+ }
1231
+ const { groups, total } = buildSearchGroups(catalog, {
1232
+ query,
1233
+ tab,
1234
+ includeBlank,
1235
+ sort,
1236
+ sizeOf,
1237
+ limit: params.limitPerGroup,
1238
+ limitMax: config.pageLimitMax,
1239
+ })
1240
+ const decorated = []
1241
+ for (const group of groups) {
1242
+ // Search may span every workspace: sizes come from the warm snapshot only,
1243
+ // never from a stat per hit row across all groups.
1244
+ const facts = await factsFor(group.rows, {
1245
+ sizes: withSizes,
1246
+ cacheOnly: true,
1247
+ legacy: catalog.source === 'legacy',
1248
+ })
1249
+ decorated.push({
1250
+ workspaceId: group.workspaceId,
1251
+ workspace: workspaceDescriptor(group.workspace),
1252
+ total: group.total,
1253
+ hasMore: group.hasMore,
1254
+ rows: decorateRows(group.rows, facts),
1255
+ })
1256
+ }
1257
+ return { generatedAt: catalog.generatedAt, source: catalog.source, query, total, groups: decorated }
1258
+ }
1259
+
1260
+ /** One session's detail row, including relations and the action flags. */
1261
+ const session = async (params = {}) => {
1262
+ const id = normalizeId(params.id)
1263
+ const catalog = await catalogFor({
1264
+ includeSubagents: flagOf(params.subagents, true),
1265
+ extraIds: [id],
1266
+ signal: params.signal,
1267
+ })
1268
+ const detail = buildDetail(catalog, id)
1269
+ if (detail === undefined) throw new SessionMngError('not-found', `unknown session "${id}"`, 404)
1270
+ await applyTitles([detail.row], params.signal)
1271
+ const facts = await factsFor([detail.row], {
1272
+ sizes: flagOf(params.sizes, true),
1273
+ legacy: catalog.source === 'legacy',
1274
+ })
1275
+ return {
1276
+ generatedAt: catalog.generatedAt,
1277
+ source: catalog.source,
1278
+ row: decorateRows([detail.row], facts)[0],
1279
+ capabilities: detail.capabilities,
1280
+ limits: detail.limits,
1281
+ }
1282
+ }
1283
+
1284
+ /** The blank-session id lists behind the two Settings-page cleanup buttons. */
1285
+ const blanks = async (params = {}) => {
1286
+ const catalog = await catalogFor({ includeSubagents: flagOf(params.subagents), signal: params.signal })
1287
+ return { generatedAt: catalog.generatedAt, source: catalog.source, ...buildBlanks(catalog) }
1288
+ }
1289
+
1290
+ /**
1291
+ * Aggregated sizes for one workspace, the ungrouped bucket, or the store.
1292
+ *
1293
+ * The aggregation walks the catalog's visible rows, exactly like `/overview`
1294
+ * and `/sessions`: hidden subagents are excluded unless `subagents=1`, rows
1295
+ * this process deleted are already filtered, and blank rows still count. That
1296
+ * keeps `total.bytes` equal to the row Σ a 0.1.x `/state` reported for the
1297
+ * same subagent switch instead of silently adding every persisted agent log.
1298
+ */
1299
+ const sizesReport = async (params = {}) => {
1300
+ const raw = typeof params.workspaceId === 'string' ? params.workspaceId.trim() : ''
1301
+ const scope = raw === '' ? 'all' : raw
1302
+ const catalog = await catalogFor({ includeSubagents: flagOf(params.subagents), signal: params.signal })
1303
+ if (scope !== 'all' && scope !== UNGROUPED && !catalog.workspaceById.has(scope)) {
1304
+ throw new SessionMngError('workspace-not-found', `unknown workspace "${scope}"`, 404)
1305
+ }
1306
+ const snapshot = await ensureSizes(params.signal)
1307
+ const buckets = new Map(
1308
+ catalog.workspaces.map((workspace) => [
1309
+ workspace.id,
1310
+ { id: workspace.id, title: workspace.title, path: workspace.path, bytes: 0, count: 0 },
1311
+ ]),
1312
+ )
1313
+ const ungrouped = { bytes: 0, count: 0 }
1314
+ let totalBytes = 0
1315
+ let totalCount = 0
1316
+ for (const row of catalog.rows) {
1317
+ const fact = snapshot.byId.get(row.id)
1318
+ const bytes = Number.isFinite(fact?.sizeBytes) ? fact.sizeBytes : 0
1319
+ const bucket = (row.workspaceId === null ? undefined : buckets.get(row.workspaceId)) ?? ungrouped
1320
+ bucket.bytes += bytes
1321
+ bucket.count += 1
1322
+ totalBytes += bytes
1323
+ totalCount += 1
1324
+ }
1325
+ const workspaces = scope === 'all' ? [...buckets.values()] : scope === UNGROUPED ? [] : [buckets.get(scope)]
1326
+ const total =
1327
+ scope === 'all'
1328
+ ? { bytes: totalBytes, count: totalCount }
1329
+ : scope === UNGROUPED
1330
+ ? { bytes: ungrouped.bytes, count: ungrouped.count }
1331
+ : { bytes: buckets.get(scope).bytes, count: buckets.get(scope).count }
1332
+ return {
1333
+ generatedAt: Date.now(),
1334
+ source: 'scan',
1335
+ workspaceId: scope,
1336
+ scannedAt: snapshot.at,
1337
+ cached: snapshot.cached === true,
1338
+ expiresAt: snapshot.at + config.sizesTtlMs,
1339
+ total,
1340
+ workspaces,
1341
+ ungrouped,
1342
+ }
1343
+ }
1344
+
895
1345
  return {
896
1346
  state,
897
1347
  preview,
@@ -902,5 +1352,11 @@ export function createService(ctx, config) {
902
1352
  deletePreview,
903
1353
  remove,
904
1354
  move,
1355
+ overview,
1356
+ sessions,
1357
+ search,
1358
+ session,
1359
+ blanks,
1360
+ sizes: sizesReport,
905
1361
  }
906
1362
  }
package/lib/state.js CHANGED
@@ -13,8 +13,13 @@
13
13
  import { zstdAvailable } from './store-jsonl.js'
14
14
  import { canReconcileMove } from './workspace-sync.js'
15
15
 
16
- /** Read a service without declaring it as a hard dependency. */
17
- function serviceOf(ctx, name) {
16
+ /**
17
+ * Read a service without declaring it as a hard dependency.
18
+ *
19
+ * `ctx.get(name)` may throw for an unknown or not-yet-started service; an
20
+ * absent service must degrade one feature, never fail the request.
21
+ */
22
+ export function serviceOf(ctx, name) {
18
23
  try {
19
24
  return ctx.get?.(name)
20
25
  } catch {
@@ -32,7 +37,7 @@ function liveSession(ctx, id) {
32
37
  }
33
38
 
34
39
  /** Best-effort running state for one id. */
35
- function isRunning(ctx, id) {
40
+ export function isRunningSession(ctx, id) {
36
41
  try {
37
42
  const agent = ctx.get?.('agents')?.get?.(id)
38
43
  return agent?.status === 'running' || agent?.running === true
@@ -41,6 +46,39 @@ function isRunning(ctx, id) {
41
46
  }
42
47
  }
43
48
 
49
+ /**
50
+ * Capabilities this host currently offers, shared by `/state` and the 0.2.0
51
+ * catalog so both surfaces answer the same question the same way.
52
+ */
53
+ export function capabilitiesOf(ctx) {
54
+ const jsonl =
55
+ typeof ctx.sessionPersistence?.locate === 'function' && typeof ctx.sessionPersistence?.stat === 'function'
56
+ return {
57
+ title: Boolean(ctx.llm),
58
+ move: jsonl && zstdAvailable() && canReconcileMove(serviceOf(ctx, 'workspaceRegistry')),
59
+ delete: jsonl,
60
+ backend: jsonl ? 'jsonl' : 'other',
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Per-row action flags derived from one row's state and the host capabilities.
66
+ *
67
+ * Keeping this shared is what makes `/state` rows and catalog rows offer the
68
+ * same buttons: a live or running session is never moved or deleted, a branch
69
+ * is never moved on its own, and deletion stays archived-only.
70
+ */
71
+ export function canOf(row, capabilities) {
72
+ return {
73
+ title: capabilities.title,
74
+ archive: !row.archived,
75
+ restore: row.archived,
76
+ move: capabilities.move && !row.live && !row.running && row.kind === 'root',
77
+ delete: capabilities.delete && row.archived && !row.live,
78
+ open: true,
79
+ }
80
+ }
81
+
44
82
  /** Best-effort snapshot list; an unavailable backend yields no snapshots. */
45
83
  async function persistenceSnapshots(ctx, signal) {
46
84
  try {
@@ -69,7 +107,7 @@ async function sessionSummaries(ctx, signal) {
69
107
  sessionId: record.header.id,
70
108
  updatedAt: record.header.createdAt,
71
109
  agentAvailable: false,
72
- running: isRunning(ctx, record.header.id),
110
+ running: isRunningSession(ctx, record.header.id),
73
111
  blank: false,
74
112
  cwd: record.header.cwd,
75
113
  origin: record.header.origin,
@@ -241,7 +279,7 @@ export async function buildState(ctx, config, options = {}) {
241
279
  workspaceBySession.get(id) ??
242
280
  (typeof cwd === 'string' && cwd !== '' ? workspaceByPath.get(cwd) : undefined)
243
281
  const live = liveSession(ctx, id) !== undefined
244
- const running = summary.running === true || isRunning(ctx, id)
282
+ const running = summary.running === true || isRunningSession(ctx, id)
245
283
  const title =
246
284
  typeof summary.projections?.values?.title === 'string' ? summary.projections.values.title : ''
247
285
  if (title === '') missingTitles.push(id)
@@ -284,7 +322,7 @@ export async function buildState(ctx, config, options = {}) {
284
322
  cwd: header.cwd,
285
323
  origin: header.origin,
286
324
  parentSessionId: header.parentSession,
287
- running: isRunning(ctx, header.id),
325
+ running: isRunningSession(ctx, header.id),
288
326
  blank: false,
289
327
  formatStatus: snapshot.formatStatus,
290
328
  })
@@ -311,25 +349,8 @@ export async function buildState(ctx, config, options = {}) {
311
349
  row.forkCount = forkCountByParent.get(row.id) ?? 0
312
350
  }
313
351
 
314
- const jsonl =
315
- typeof ctx.sessionPersistence?.locate === 'function' && typeof ctx.sessionPersistence?.stat === 'function'
316
- const capabilities = {
317
- title: Boolean(ctx.llm),
318
- move: jsonl && zstdAvailable() && canReconcileMove(serviceOf(ctx, 'workspaceRegistry')),
319
- delete: jsonl,
320
- backend: jsonl ? 'jsonl' : 'other',
321
- }
322
-
323
- for (const row of rows) {
324
- row.can = {
325
- title: capabilities.title,
326
- archive: !row.archived,
327
- restore: row.archived,
328
- move: capabilities.move && !row.live && !row.running && row.kind === 'root',
329
- delete: capabilities.delete && row.archived && !row.live,
330
- open: true,
331
- }
332
- }
352
+ const capabilities = capabilitiesOf(ctx)
353
+ for (const row of rows) row.can = canOf(row, capabilities)
333
354
 
334
355
  // Archived ids whose log is gone can never be rendered; surface them so the
335
356
  // UI can offer to drop the stale archive marker. Every id the corpus knows