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/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,76 +862,85 @@ 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
 
756
- /** Move one cold session. Throws on any failure; the caller converts it. */
757
- async function moveOne(id, target) {
869
+ /**
870
+ * Validate one member of a move family before any byte moves.
871
+ *
872
+ * Every member is checked first, so a family with one live or running member
873
+ * is refused as a whole instead of leaving a half-moved branch behind.
874
+ *
875
+ * @param {string} id - session id.
876
+ * @param {object} header - stored header of that session.
877
+ * @param {object} target - target workspace entity.
878
+ * @returns {Promise<{id: string, header: object, source: object|undefined, sourceDir: string, targetDir: string}>}
879
+ */
880
+ async function planMember(id, header, target) {
758
881
  const store = persistence()
759
- if (store === undefined || typeof store.stat !== 'function' || typeof store.locate !== 'function') {
760
- throw new SessionMngError('unsupported-backend', 'moving a session requires the local JSONL session backend', 501)
761
- }
762
- const snapshot = await store.stat(id)
763
- const header = snapshot?.header
764
- if (header === undefined) throw new SessionMngError('not-found', `session "${id}" is not stored`, 404)
765
882
  if (typeof header.cwd !== 'string' || header.cwd === '') {
766
- throw new SessionMngError('no-cwd', 'this session has no recorded working directory, so it cannot be moved', 409)
883
+ throw new SessionMngError('no-cwd', `session "${id}" has no recorded working directory, so it cannot be moved`, 409, {
884
+ sessionId: id,
885
+ })
767
886
  }
768
887
  if (ctx.sessions?.get?.(id) !== undefined) {
769
- throw new SessionMngError('session-live', 'this session is open in the running DSH process; restart DSH and retry the move', 409)
770
- }
771
- if (serviceOf(ctx, 'agents')?.get?.(id)?.status === 'running') {
772
- throw new SessionMngError('session-running', 'this session is running; stop it before moving', 409)
773
- }
774
- if (header.origin === 'subagent' || (typeof header.delegationDepth === 'number' && header.delegationDepth >= 1)) {
775
- throw new SessionMngError('subagent-session', 'subagent sessions are not moved on their own', 409)
776
- }
777
- if (header.parentSession !== undefined && header.parentSession !== null) {
778
888
  throw new SessionMngError(
779
- 'branch-session',
780
- "a branch session must stay in its parent's workspace, so it is not moved on its own",
889
+ 'session-live',
890
+ `session "${id}" is open in the running DSH process; restart DSH and retry the move`,
781
891
  409,
892
+ { sessionId: id },
782
893
  )
783
894
  }
784
- // Only subagent descendants block a move. An ordinary fork (historical or
785
- // not) is an independent session, so it no longer blocks its source.
786
- const lineage = await descendantsOf(id)
787
- if (lineage.subagents.length > 0) {
788
- throw new SessionMngError('has-descendants', 'this session has subagent descendants; they cannot follow it yet', 409, {
789
- descendants: lineage.subagents.length,
895
+ if (serviceOf(ctx, 'agents')?.get?.(id)?.status === 'running') {
896
+ throw new SessionMngError('session-running', `session "${id}" is running; stop it before moving`, 409, {
897
+ sessionId: id,
790
898
  })
791
899
  }
792
- const source = workspaceOfId(id)
793
900
  const sourceDir = await dirsOf(id, header.cwd)
794
901
  const targetDir = sessionDirFromLocate(store, id, target.path)
795
902
  if (targetDir === undefined) {
796
903
  throw new SessionMngError('unsupported-backend', 'the session backend cannot locate a target directory', 501)
797
904
  }
905
+ return { id, header, source: workspaceOfId(id), sourceDir, targetDir }
906
+ }
798
907
 
908
+ /**
909
+ * Relocate one already-validated cold member: rewrite its log header, move
910
+ * its directory and reconcile the workspace ledger. Throws after restoring
911
+ * its own bytes and cache entry.
912
+ *
913
+ * @param {{id: string, header: object, source: object|undefined, sourceDir: string, targetDir: string}} member
914
+ * @param {object} target - target workspace entity.
915
+ * @returns {Promise<{member: object, staged: object|undefined, alreadyThere: boolean}>}
916
+ */
917
+ async function relocateMember(member, target) {
918
+ const { id, header, source, sourceDir, targetDir } = member
799
919
  if (targetDir === sourceDir) {
800
920
  // Bytes are already in the target project directory; only the ledger and
801
921
  // the cache may disagree.
802
922
  await reconcileMove({ registry: registry(), id, oldHeader: header, newHeader: header, source, target })
803
- return { sessionId: id, ok: true, moved: false, alreadyThere: true, to: target.id }
923
+ return { member, staged: undefined, alreadyThere: true }
804
924
  }
805
- if (await pathExists(targetDir)) {
806
- throw new SessionMngError('move-conflict', `destination already holds a session directory: ${targetDir}`, 409)
807
- }
808
-
925
+ const store = persistence()
809
926
  // Prove the log is readable and that no other process is writing it before
810
927
  // touching a single byte.
811
928
  let original
812
929
  try {
813
930
  original = await query().readSession(id)
814
931
  } catch (error) {
815
- throw new SessionMngError('unreadable-session', `could not replay session "${id}": ${messageOf(error)}`, 422)
932
+ throw new SessionMngError('unreadable-session', `could not replay session "${id}": ${messageOf(error)}`, 422, {
933
+ sessionId: id,
934
+ })
816
935
  }
817
936
  const originalCount = Array.isArray(original?.events) ? original.events.length : 0
818
937
  let lease
819
938
  try {
820
939
  lease = await store.open(id, 'write')
821
940
  } catch (error) {
822
- throw new SessionMngError('session-writer-held', `session "${id}" is being written by another process: ${messageOf(error)}`, 409)
941
+ throw new SessionMngError('session-writer-held', `session "${id}" is being written by another process: ${messageOf(error)}`, 409, {
942
+ sessionId: id,
943
+ })
823
944
  }
824
945
  await lease.close().catch(() => {})
825
946
 
@@ -837,6 +958,7 @@ export function createService(ctx, config) {
837
958
  const validatedCount = Array.isArray(validation?.events) ? validation.events.length : -1
838
959
  if (validatedCwd !== target.path || validatedCount !== originalCount) {
839
960
  throw new SessionMngError('move-verify-failed', 'the moved session did not read back with the expected header and event count', 500, {
961
+ sessionId: id,
840
962
  expectedCwd: target.path,
841
963
  actualCwd: validatedCwd,
842
964
  expectedEvents: originalCount,
@@ -864,6 +986,7 @@ export function createService(ctx, config) {
864
986
  await rollbackMove({ sourceDir, targetDir, backupDir: staged.backupDir })
865
987
  } catch (rollbackError) {
866
988
  throw new SessionMngError('manual-recovery', 'the move failed and the automatic rollback also failed', 500, {
989
+ sessionId: id,
867
990
  sourceDir,
868
991
  targetDir,
869
992
  backupDir: staged.backupDir,
@@ -873,22 +996,447 @@ export function createService(ctx, config) {
873
996
  }
874
997
  throw error
875
998
  }
999
+ return { member, staged, alreadyThere: false }
1000
+ }
876
1001
 
877
- let leftoverBackup
1002
+ /** Undo one completed member, restoring bytes, cache and ledger. */
1003
+ async function undoMember(done, target) {
1004
+ const { member, staged } = done
1005
+ await rollbackMoveReconcile({
1006
+ registry: registry(),
1007
+ id: member.id,
1008
+ oldHeader: member.header,
1009
+ source: member.source,
1010
+ target,
1011
+ sourcePath: member.source?.path ?? member.header.cwd,
1012
+ })
1013
+ if (staged !== undefined) {
1014
+ await rollbackMove({ sourceDir: member.sourceDir, targetDir: member.targetDir, backupDir: staged.backupDir })
1015
+ }
1016
+ }
1017
+
1018
+ /**
1019
+ * Move one cold session and every subagent descendant it owns.
1020
+ *
1021
+ * Subagent sessions exist only to serve their root, so leaving them behind
1022
+ * would strand them in the old project directory. Ordinary forks are
1023
+ * independent user sessions and stay where they are.
1024
+ *
1025
+ * Throws on any failure; the caller converts it.
1026
+ */
1027
+ async function moveOne(id, target) {
1028
+ const store = persistence()
1029
+ if (store === undefined || typeof store.stat !== 'function' || typeof store.locate !== 'function') {
1030
+ throw new SessionMngError('unsupported-backend', 'moving a session requires the local JSONL session backend', 501)
1031
+ }
1032
+ const snapshot = await store.stat(id)
1033
+ const header = snapshot?.header
1034
+ if (header === undefined) throw new SessionMngError('not-found', `session "${id}" is not stored`, 404)
1035
+ if (header.origin === 'subagent' || (typeof header.delegationDepth === 'number' && header.delegationDepth >= 1)) {
1036
+ throw new SessionMngError('subagent-session', 'subagent sessions are not moved on their own', 409)
1037
+ }
1038
+ if (header.parentSession !== undefined && header.parentSession !== null) {
1039
+ throw new SessionMngError(
1040
+ 'branch-session',
1041
+ "a branch session must stay in its parent's workspace, so it is not moved on its own",
1042
+ 409,
1043
+ )
1044
+ }
1045
+
1046
+ // Subagent descendants travel with their root; an ordinary fork (historical
1047
+ // or not) is an independent session and stays behind.
1048
+ const lineage = await descendantsOf(id)
1049
+ const members = [
1050
+ { id, header },
1051
+ ...lineage.subagents.map((record) => ({ id: record.header.id, header: record.header })),
1052
+ ]
1053
+
1054
+ // Validate the whole family before touching any bytes.
1055
+ const planned = []
1056
+ for (const member of members) planned.push(await planMember(member.id, member.header, target))
1057
+
1058
+ const completed = []
878
1059
  try {
879
- await finalizeMove(staged.backupDir)
1060
+ for (const member of planned) completed.push(await relocateMember(member, target))
880
1061
  } catch (error) {
881
- leftoverBackup = { path: staged.backupDir, reason: messageOf(error) }
1062
+ const rollbackFailures = []
1063
+ for (const done of [...completed].reverse()) {
1064
+ try {
1065
+ await undoMember(done, target)
1066
+ } catch (rollbackError) {
1067
+ rollbackFailures.push({ sessionId: done.member.id, error: messageOf(rollbackError) })
1068
+ }
1069
+ }
1070
+ if (rollbackFailures.length > 0) {
1071
+ throw new SessionMngError('manual-recovery', 'the move failed and the automatic rollback also failed', 500, {
1072
+ cause: messageOf(error),
1073
+ sessions: completed.map((done) => done.member.id),
1074
+ rollback: rollbackFailures,
1075
+ })
1076
+ }
1077
+ throw error
882
1078
  }
1079
+
1080
+ const leftover = []
1081
+ for (const done of completed) {
1082
+ if (done.staged === undefined) continue
1083
+ try {
1084
+ await finalizeMove(done.staged.backupDir)
1085
+ } catch (error) {
1086
+ leftover.push({ path: done.staged.backupDir, reason: messageOf(error) })
1087
+ }
1088
+ }
1089
+ const root = completed[0]
1090
+ const moved = completed.filter((done) => !done.alreadyThere)
883
1091
  return {
884
1092
  sessionId: id,
885
1093
  ok: true,
886
- moved: true,
887
- from: source?.id ?? null,
1094
+ moved: moved.length > 0,
1095
+ from: root.member.source?.id ?? null,
888
1096
  to: target.id,
889
1097
  cwd: { from: header.cwd, to: target.path },
890
- bytes: staged.bytes,
891
- ...(leftoverBackup === undefined ? {} : { leftoverBackup }),
1098
+ family: completed.length,
1099
+ sessions: completed.map((done) => done.member.id),
1100
+ ...(completed.every((done) => done.alreadyThere) ? { alreadyThere: true } : {}),
1101
+ ...(moved.length === 0 ? {} : { bytes: moved.reduce((sum, done) => sum + done.staged.bytes, 0) }),
1102
+ ...(leftover.length === 0 ? {} : { leftoverBackup: leftover }),
1103
+ }
1104
+ }
1105
+
1106
+ // -------------------------------------------------------------------------
1107
+ // Read surfaces (0.2.0): overview / paged sessions / search / detail / sizes
1108
+ // -------------------------------------------------------------------------
1109
+
1110
+ /** One O(n) store scan behind a timeout and an explicit size ceiling. */
1111
+ const scanSizes = async (signal) => {
1112
+ const store = persistence()
1113
+ if (store === undefined || typeof store.list !== 'function') {
1114
+ throw new SessionMngError('sizes-pending', 'this deployment has no listable session persistence, so sizes are unavailable', 503)
1115
+ }
1116
+ const epoch = sizeState.epoch
1117
+ const budget = AbortSignal.timeout(SIZE_SCAN_TIMEOUT_MS)
1118
+ let combined = budget
1119
+ if (signal !== undefined && typeof AbortSignal.any === 'function') {
1120
+ try {
1121
+ combined = AbortSignal.any([signal, budget])
1122
+ } catch {
1123
+ combined = budget
1124
+ }
1125
+ }
1126
+ let entries
1127
+ try {
1128
+ entries = await store.list({ signal: combined })
1129
+ } catch (error) {
1130
+ throw new SessionMngError('sizes-pending', `could not scan session sizes: ${messageOf(error)}`, 503)
1131
+ }
1132
+ const list = Array.isArray(entries) ? entries : []
1133
+ if (list.length > config.sizeScanMax) {
1134
+ throw new SessionMngError(
1135
+ 'sizes-pending',
1136
+ `size scan skipped: ${list.length} stored sessions exceed the configured limit of ${config.sizeScanMax}`,
1137
+ 503,
1138
+ { count: list.length, max: config.sizeScanMax },
1139
+ )
1140
+ }
1141
+ const byId = new Map()
1142
+ for (const entry of list) {
1143
+ const id = entry?.header?.id
1144
+ if (typeof id !== 'string') continue
1145
+ byId.set(id, {
1146
+ sizeBytes: Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : 0,
1147
+ formatStatus: typeof entry.formatStatus === 'string' ? entry.formatStatus : undefined,
1148
+ })
1149
+ }
1150
+ const at = Date.now()
1151
+ if (epoch === sizeState.epoch) {
1152
+ sizeState.at = at
1153
+ sizeState.byId = byId
1154
+ }
1155
+ // Reconciliation: a store that no longer lists an id we deleted means the
1156
+ // ghost marker can go, and a title override past its window has served its
1157
+ // purpose (the host projection has caught up by then).
1158
+ for (const id of [...deletedIds]) if (!byId.has(id)) deletedIds.delete(id)
1159
+ for (const [id, override] of [...titleOverrides]) {
1160
+ if (at - override.at > TITLE_OVERRIDE_TTL_MS) titleOverrides.delete(id)
1161
+ }
1162
+ return { at, byId, cached: false }
1163
+ }
1164
+
1165
+ /** The size snapshot, reusing a fresh one and de-duplicating concurrent scans. */
1166
+ const ensureSizes = (signal) => {
1167
+ if (sizeState.at !== 0 && Date.now() - sizeState.at < config.sizesTtlMs) {
1168
+ return Promise.resolve({ at: sizeState.at, byId: sizeState.byId, cached: true })
1169
+ }
1170
+ if (sizeState.inflight !== undefined) return sizeState.inflight
1171
+ const promise = scanSizes(signal).finally(() => {
1172
+ sizeState.inflight = undefined
1173
+ })
1174
+ sizeState.inflight = promise
1175
+ return promise
1176
+ }
1177
+
1178
+ /**
1179
+ * Build one request's catalog: the zero-I/O fast path when the host exposes
1180
+ * both seams, otherwise the cached `buildState` slow path. The response shape
1181
+ * is identical either way; only `source` and the cost differ.
1182
+ */
1183
+ const catalogFor = async (options = {}) => {
1184
+ const includeSubagents = options.includeSubagents === true
1185
+ // An override is only a bridge until the host projection catches up; drop
1186
+ // the ones that outlived their window so an external rename converges.
1187
+ for (const [id, override] of [...titleOverrides]) {
1188
+ if (Date.now() - override.at > TITLE_OVERRIDE_TTL_MS) titleOverrides.delete(id)
1189
+ }
1190
+ if (!catalogAvailable(ctx)) {
1191
+ return legacyCatalog.catalog(includeSubagents, options.signal)
1192
+ }
1193
+ return fromContext(ctx, config, {
1194
+ includeSubagents,
1195
+ extraIds: options.extraIds,
1196
+ deletedIds,
1197
+ titleOverrides,
1198
+ })
1199
+ }
1200
+
1201
+ /** Fill this page's missing titles within the fallback budget. */
1202
+ const applyTitles = async (rows, signal) => {
1203
+ const ids = rows.filter((row) => row.title === '').map((row) => row.id)
1204
+ if (ids.length === 0) return 0
1205
+ const titles = await readPageTitles(ctx, ids, signal)
1206
+ let pending = 0
1207
+ for (const row of rows) {
1208
+ if (row.title !== '') continue
1209
+ const title = titles.get(row.id)
1210
+ if (typeof title === 'string' && title !== '') row.title = title
1211
+ else pending += 1
1212
+ }
1213
+ return pending
1214
+ }
1215
+
1216
+ /**
1217
+ * Size/format facts for one bounded row set: from the warm `/sizes` snapshot
1218
+ * when it covers a row, otherwise one readdir+stat on that row's own log.
1219
+ *
1220
+ * `cacheOnly` keeps the per-row stat out of surfaces with an unbounded number
1221
+ * of groups (search): those read the warm snapshot and leave the rest for the
1222
+ * group the user actually expands.
1223
+ */
1224
+ const factsFor = async (rows, options = {}) => {
1225
+ const facts = new Map()
1226
+ if (options.sizes === false) return facts
1227
+ if (sizeState.at !== 0 && Date.now() - sizeState.at < config.sizesTtlMs) {
1228
+ for (const row of rows) {
1229
+ const fact = sizeState.byId.get(row.id)
1230
+ if (fact !== undefined) facts.set(row.id, fact)
1231
+ }
1232
+ }
1233
+ if (options.cacheOnly === true || options.legacy === true) return facts
1234
+ const missing = rows.filter((row) => !facts.has(row.id))
1235
+ if (missing.length === 0) return facts
1236
+ const store = persistence()
1237
+ if (store === undefined || typeof store.locate !== 'function') return facts
1238
+ const found = await readLogFactsBatch(
1239
+ store,
1240
+ missing.map((row) => ({ id: row.id, cwd: row.cwd })),
1241
+ )
1242
+ for (const [id, fact] of found) facts.set(id, fact)
1243
+ return facts
1244
+ }
1245
+
1246
+ /** The workspace descriptor the Client renders as a group header. */
1247
+ const workspaceDescriptor = (workspace) =>
1248
+ workspace === undefined
1249
+ ? null
1250
+ : {
1251
+ id: workspace.id,
1252
+ title: workspace.title,
1253
+ path: workspace.path,
1254
+ status: workspace.status ?? 'unknown',
1255
+ }
1256
+
1257
+ /** First-paint counts for every workspace, never a session row. */
1258
+ const overview = async (options = {}) => {
1259
+ const catalog = await catalogFor({
1260
+ includeSubagents: flagOf(options.includeSubagents),
1261
+ signal: options.signal,
1262
+ })
1263
+ return buildOverview(catalog)
1264
+ }
1265
+
1266
+ /** One workspace's (or the ungrouped bucket's) paged, branch-aware rows. */
1267
+ const sessions = async (params = {}) => {
1268
+ const workspaceId = workspaceSelector(params.workspaceId)
1269
+ const tab = choiceOf(params.tab, TABS, 'all', 'tab')
1270
+ const sort = choiceOf(params.sort, SORTS, 'updated', 'sort')
1271
+ const includeBlank = flagOf(params.blank)
1272
+ const includeSubagents = flagOf(params.subagents)
1273
+ const withSizes = flagOf(params.sizes, true)
1274
+ const catalog = await catalogFor({ includeSubagents, signal: params.signal })
1275
+ if (workspaceId !== UNGROUPED && !catalog.workspaceById.has(workspaceId)) {
1276
+ throw new SessionMngError('workspace-not-found', `unknown workspace "${workspaceId}"`, 404)
1277
+ }
1278
+ let sizeOf
1279
+ if (sort === 'size') {
1280
+ const snapshot = await ensureSizes(params.signal)
1281
+ sizeOf = (id) => snapshot.byId.get(id)?.sizeBytes ?? 0
1282
+ }
1283
+ const page = buildPage(catalog, {
1284
+ workspaceId,
1285
+ tab,
1286
+ query: params.query,
1287
+ includeBlank,
1288
+ sort,
1289
+ sizeOf,
1290
+ offset: params.offset,
1291
+ limit: params.limit,
1292
+ limitMax: config.pageLimitMax,
1293
+ })
1294
+ const titlesPending = await applyTitles(page.rows, params.signal)
1295
+ const facts = await factsFor(page.rows, {
1296
+ sizes: withSizes,
1297
+ legacy: catalog.source === 'legacy',
1298
+ })
1299
+ return {
1300
+ generatedAt: catalog.generatedAt,
1301
+ source: catalog.source,
1302
+ workspaceId,
1303
+ workspace: workspaceDescriptor(page.workspace),
1304
+ total: page.total,
1305
+ offset: page.offset,
1306
+ limit: page.limit,
1307
+ hasMore: page.hasMore,
1308
+ titlesPending,
1309
+ rows: decorateRows(page.rows, facts),
1310
+ }
1311
+ }
1312
+
1313
+ /** Cross-workspace title/path/id search, one bounded page per hit group. */
1314
+ const search = async (params = {}) => {
1315
+ const query = typeof params.query === 'string' ? params.query.trim() : ''
1316
+ if (query === '') throw new SessionMngError('bad-request', 'query must be a non-empty string', 400)
1317
+ if (query.length > 500) throw new SessionMngError('bad-request', 'query must be at most 500 characters', 400)
1318
+ const tab = choiceOf(params.tab, TABS, 'all', 'tab')
1319
+ const sort = choiceOf(params.sort, SORTS, 'updated', 'sort')
1320
+ const includeBlank = flagOf(params.blank)
1321
+ const includeSubagents = flagOf(params.subagents)
1322
+ const withSizes = flagOf(params.sizes, true)
1323
+ const catalog = await catalogFor({ includeSubagents, signal: params.signal })
1324
+ let sizeOf
1325
+ if (sort === 'size') {
1326
+ const snapshot = await ensureSizes(params.signal)
1327
+ sizeOf = (id) => snapshot.byId.get(id)?.sizeBytes ?? 0
1328
+ }
1329
+ const { groups, total } = buildSearchGroups(catalog, {
1330
+ query,
1331
+ tab,
1332
+ includeBlank,
1333
+ sort,
1334
+ sizeOf,
1335
+ limit: params.limitPerGroup,
1336
+ limitMax: config.pageLimitMax,
1337
+ })
1338
+ const decorated = []
1339
+ for (const group of groups) {
1340
+ // Search may span every workspace: sizes come from the warm snapshot only,
1341
+ // never from a stat per hit row across all groups.
1342
+ const facts = await factsFor(group.rows, {
1343
+ sizes: withSizes,
1344
+ cacheOnly: true,
1345
+ legacy: catalog.source === 'legacy',
1346
+ })
1347
+ decorated.push({
1348
+ workspaceId: group.workspaceId,
1349
+ workspace: workspaceDescriptor(group.workspace),
1350
+ total: group.total,
1351
+ hasMore: group.hasMore,
1352
+ rows: decorateRows(group.rows, facts),
1353
+ })
1354
+ }
1355
+ return { generatedAt: catalog.generatedAt, source: catalog.source, query, total, groups: decorated }
1356
+ }
1357
+
1358
+ /** One session's detail row, including relations and the action flags. */
1359
+ const session = async (params = {}) => {
1360
+ const id = normalizeId(params.id)
1361
+ const catalog = await catalogFor({
1362
+ includeSubagents: flagOf(params.subagents, true),
1363
+ extraIds: [id],
1364
+ signal: params.signal,
1365
+ })
1366
+ const detail = buildDetail(catalog, id)
1367
+ if (detail === undefined) throw new SessionMngError('not-found', `unknown session "${id}"`, 404)
1368
+ await applyTitles([detail.row], params.signal)
1369
+ const facts = await factsFor([detail.row], {
1370
+ sizes: flagOf(params.sizes, true),
1371
+ legacy: catalog.source === 'legacy',
1372
+ })
1373
+ return {
1374
+ generatedAt: catalog.generatedAt,
1375
+ source: catalog.source,
1376
+ row: decorateRows([detail.row], facts)[0],
1377
+ capabilities: detail.capabilities,
1378
+ limits: detail.limits,
1379
+ }
1380
+ }
1381
+
1382
+ /** The blank-session id lists behind the two Settings-page cleanup buttons. */
1383
+ const blanks = async (params = {}) => {
1384
+ const catalog = await catalogFor({ includeSubagents: flagOf(params.subagents), signal: params.signal })
1385
+ return { generatedAt: catalog.generatedAt, source: catalog.source, ...buildBlanks(catalog) }
1386
+ }
1387
+
1388
+ /**
1389
+ * Aggregated sizes for one workspace, the ungrouped bucket, or the store.
1390
+ *
1391
+ * The aggregation walks the catalog's visible rows, exactly like `/overview`
1392
+ * and `/sessions`: hidden subagents are excluded unless `subagents=1`, rows
1393
+ * this process deleted are already filtered, and blank rows still count. That
1394
+ * keeps `total.bytes` equal to the row Σ a 0.1.x `/state` reported for the
1395
+ * same subagent switch instead of silently adding every persisted agent log.
1396
+ */
1397
+ const sizesReport = async (params = {}) => {
1398
+ const raw = typeof params.workspaceId === 'string' ? params.workspaceId.trim() : ''
1399
+ const scope = raw === '' ? 'all' : raw
1400
+ const catalog = await catalogFor({ includeSubagents: flagOf(params.subagents), signal: params.signal })
1401
+ if (scope !== 'all' && scope !== UNGROUPED && !catalog.workspaceById.has(scope)) {
1402
+ throw new SessionMngError('workspace-not-found', `unknown workspace "${scope}"`, 404)
1403
+ }
1404
+ const snapshot = await ensureSizes(params.signal)
1405
+ const buckets = new Map(
1406
+ catalog.workspaces.map((workspace) => [
1407
+ workspace.id,
1408
+ { id: workspace.id, title: workspace.title, path: workspace.path, bytes: 0, count: 0 },
1409
+ ]),
1410
+ )
1411
+ const ungrouped = { bytes: 0, count: 0 }
1412
+ let totalBytes = 0
1413
+ let totalCount = 0
1414
+ for (const row of catalog.rows) {
1415
+ const fact = snapshot.byId.get(row.id)
1416
+ const bytes = Number.isFinite(fact?.sizeBytes) ? fact.sizeBytes : 0
1417
+ const bucket = (row.workspaceId === null ? undefined : buckets.get(row.workspaceId)) ?? ungrouped
1418
+ bucket.bytes += bytes
1419
+ bucket.count += 1
1420
+ totalBytes += bytes
1421
+ totalCount += 1
1422
+ }
1423
+ const workspaces = scope === 'all' ? [...buckets.values()] : scope === UNGROUPED ? [] : [buckets.get(scope)]
1424
+ const total =
1425
+ scope === 'all'
1426
+ ? { bytes: totalBytes, count: totalCount }
1427
+ : scope === UNGROUPED
1428
+ ? { bytes: ungrouped.bytes, count: ungrouped.count }
1429
+ : { bytes: buckets.get(scope).bytes, count: buckets.get(scope).count }
1430
+ return {
1431
+ generatedAt: Date.now(),
1432
+ source: 'scan',
1433
+ workspaceId: scope,
1434
+ scannedAt: snapshot.at,
1435
+ cached: snapshot.cached === true,
1436
+ expiresAt: snapshot.at + config.sizesTtlMs,
1437
+ total,
1438
+ workspaces,
1439
+ ungrouped,
892
1440
  }
893
1441
  }
894
1442
 
@@ -902,5 +1450,11 @@ export function createService(ctx, config) {
902
1450
  deletePreview,
903
1451
  remove,
904
1452
  move,
1453
+ overview,
1454
+ sessions,
1455
+ search,
1456
+ session,
1457
+ blanks,
1458
+ sizes: sizesReport,
905
1459
  }
906
1460
  }