dsh-session-mng 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +31 -11
- package/ROADMAP.md +13 -12
- package/client.js +1272 -328
- package/docs/01-design.md +49 -2
- package/docs/02-open-source-and-marketplace.md +19 -0
- package/docs/HANDOVER-2026-10-29.md +105 -0
- package/docs/PLAN-2026-10-29.md +245 -0
- package/docs/PLAN-2026-11-02-family-move.md +72 -0
- package/docs/RELEASE-CHECKLIST-0.1.4.md +6 -5
- package/docs/RELEASE-CHECKLIST-0.2.0.md +79 -0
- package/docs/RELEASE-CHECKLIST-0.2.1.md +46 -0
- package/lib/api.js +44 -11
- package/lib/catalog.js +773 -0
- package/lib/service.js +595 -41
- package/lib/state.js +46 -25
- package/lib/store-jsonl.js +76 -2
- package/package.json +1 -1
package/lib/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
|
-
/**
|
|
757
|
-
|
|
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',
|
|
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
|
-
'
|
|
780
|
-
"
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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 {
|
|
923
|
+
return { member, staged: undefined, alreadyThere: true }
|
|
804
924
|
}
|
|
805
|
-
|
|
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
|
-
|
|
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
|
|
1060
|
+
for (const member of planned) completed.push(await relocateMember(member, target))
|
|
880
1061
|
} catch (error) {
|
|
881
|
-
|
|
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:
|
|
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
|
-
|
|
891
|
-
|
|
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
|
}
|