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/CHANGELOG.md +34 -0
- package/README.md +22 -8
- package/ROADMAP.md +12 -11
- package/client.js +1210 -319
- package/docs/01-design.md +49 -2
- package/docs/02-open-source-and-marketplace.md +16 -0
- package/docs/HANDOVER-2026-10-29.md +105 -0
- package/docs/PLAN-2026-10-29.md +245 -0
- package/docs/RELEASE-CHECKLIST-0.1.4.md +6 -5
- package/docs/RELEASE-CHECKLIST-0.2.0.md +76 -0
- package/lib/api.js +44 -11
- package/lib/catalog.js +773 -0
- package/lib/service.js +456 -0
- package/lib/state.js +46 -25
- package/lib/store-jsonl.js +76 -2
- package/package.json +1 -1
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
|
-
/**
|
|
17
|
-
|
|
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
|
|
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:
|
|
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 ||
|
|
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:
|
|
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
|
|
315
|
-
|
|
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
|