dsh-sessions-manager 3.5.3 → 3.6.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/README.en.md +49 -9
- package/README.md +50 -9
- package/assets/screenshot-session-empty.png +0 -0
- package/assets/screenshot-session-multimove.png +0 -0
- package/assets/screenshot-session-settings.png +0 -0
- package/assets/screenshot-session-subagent.png +0 -0
- package/lib/client.js +890 -79
- package/lib/client.js.map +3 -3
- package/lib/index.js +779 -135
- package/lib/index.js.map +4 -4
- package/package.json +3 -2
- package/src/client/index.jsx +887 -60
- package/src/client/logic.js +79 -0
- package/src/compat/persistence.js +16 -2
- package/src/handle-era-ops.js +178 -18
- package/src/handle-era-paths.js +22 -9
- package/src/index.js +409 -15
- package/src/lineage.js +78 -0
- package/src/pending-moves.js +126 -0
- package/src/state-temp-sweep.js +42 -0
package/src/index.js
CHANGED
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
// (workspaceRegistry + storageDomain), folds titles/dates/workspace tags from
|
|
8
8
|
// session persistence, physically removes a session's log file on delete, and
|
|
9
9
|
// relocates a conversation (session) between workspaces on move.
|
|
10
|
-
import { mkdir, readFile, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
11
11
|
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { rewriteFrame0CwdInMemory, scanZstdFrames } from './zstd-frame.js'
|
|
15
15
|
import { createSessionMarkdownBuilder } from './markdown.js'
|
|
16
16
|
import { createStarIndex } from './star-index.js'
|
|
17
|
+
import { createPendingMoveStore } from './pending-moves.js'
|
|
18
|
+
import { sweepStaleStateTemps } from './state-temp-sweep.js'
|
|
17
19
|
import { aggregateStorage } from './storage-stats.js'
|
|
18
20
|
import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
|
|
19
21
|
import { createSessionMetaCache, fingerprintOf, isPersistableFingerprint } from './session-meta-cache.js'
|
|
@@ -22,12 +24,21 @@ import { createPersistenceAdapter } from './compat/persistence.js'
|
|
|
22
24
|
import { detectCapabilities, requireCapability } from './compat/capabilities.js'
|
|
23
25
|
import { pathOwnsSession } from './path-guard.js'
|
|
24
26
|
import { purgeSessionArtifacts, moveSessionToCwd } from './handle-era-ops.js'
|
|
27
|
+
import { classifyLineage, isEmptyEventTypes, EMPTY_DECODE_LIMIT } from './lineage.js'
|
|
28
|
+
|
|
29
|
+
// 构建指纹:build.mjs 以 define 在编译期注入;直接以源码运行(测试)时为 'dev'。
|
|
30
|
+
// 用途:capabilities 带出 buildStamp,一眼判断「运行中的 host 是不是这份构建」——
|
|
31
|
+
// 插件 host 端只在宿主启动时加载一次,改完 lib 不重启宿主等于白改。
|
|
32
|
+
const BUILD_STAMP = typeof __BUILD_STAMP__ !== 'undefined' ? __BUILD_STAMP__ : 'dev'
|
|
25
33
|
|
|
26
34
|
|
|
27
35
|
export const name = 'dsh-sessions-manager'
|
|
28
36
|
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
29
37
|
|
|
30
38
|
const MAX_TITLE = 80
|
|
39
|
+
// 插件自有状态目录(星标 / 自动归档 / 标题索引共用)。与 star-index.js、auto-archive.js
|
|
40
|
+
// 的默认目录保持一致,环境变量同名,便于测试注入。
|
|
41
|
+
const STATE_DIR = process.env.DSH_SESSIONS_MANAGER_STAR_DIR || join(homedir(), '.dsh', 'sessions-manager')
|
|
31
42
|
// Recycle bin (回收站): normal deletes land here instead of being erased.
|
|
32
43
|
const TRASH_DIR = process.env.DSH_SESSIONS_MANAGER_TRASH_DIR || join(homedir(), '.dsh', 'sessions-manager-trash')
|
|
33
44
|
const TRASH_INDEX = join(TRASH_DIR, 'index.json')
|
|
@@ -177,11 +188,13 @@ export function apply(ctx) {
|
|
|
177
188
|
return hits
|
|
178
189
|
}
|
|
179
190
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
191
|
+
// 把本批真正解码出的元数据回写持久索引(索引只是加速器:写失败不影响响应,
|
|
192
|
+
// 队列内部已串行化 + 原子替换)。**必须 await** —— 它是持久写入,浮动写入会在
|
|
193
|
+
// 调用方/测试 teardown 已开始清理状态目录时落地(`ENOTEMPTY: rmdir .../state`),
|
|
194
|
+
// 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
|
|
182
195
|
// ⚠️ revision 指纹(SessionHandle 世代)绝不落盘:它只在当前 service
|
|
183
196
|
// 实例内有意义,跨进程比较无意义,误用会把陈旧数据当新鲜数据。
|
|
184
|
-
function persistDecoded(decoded, statsById) {
|
|
197
|
+
async function persistDecoded(decoded, statsById) {
|
|
185
198
|
if (!decoded || !decoded.size) return
|
|
186
199
|
const batch = {}
|
|
187
200
|
const now = Date.now()
|
|
@@ -191,7 +204,7 @@ export function apply(ctx) {
|
|
|
191
204
|
batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
|
|
192
205
|
}
|
|
193
206
|
if (!Object.keys(batch).length) return
|
|
194
|
-
titleIndex.merge(batch)
|
|
207
|
+
try { await titleIndex.merge(batch) } catch (e) { /* 索引写失败不影响本次响应 */ }
|
|
195
208
|
}
|
|
196
209
|
|
|
197
210
|
// 从投影快照里抽出元数据;快照缺失/异常时返回零值 meta(调用方决定兜底)。
|
|
@@ -431,8 +444,14 @@ export function apply(ctx) {
|
|
|
431
444
|
// when the session is really gone (purge, or externally removed; the latter
|
|
432
445
|
// is caught by gcStars during list builds).
|
|
433
446
|
const stars = createStarIndex()
|
|
447
|
+
// 待移动队列:会话被占用(DSH 打开中)时登记,等它释放后自动完成移动。
|
|
448
|
+
// 见 src/pending-moves.js 的模块注释(0.1.5 单写者锁没有公共释放 API)。
|
|
449
|
+
const pendingMoves = createPendingMoveStore()
|
|
434
450
|
// Auto-archive settings live in their own schema-v4 store, off by default.
|
|
435
451
|
const autoArchive = createAutoArchiveStore()
|
|
452
|
+
// 启动清扫:进程在「写临时文件 → rename」之间被杀会留下 .<name>-<pid>-<ts>.tmp
|
|
453
|
+
// 孤儿(实测一次累积 153 个)。这里把 1 小时前的旧临时文件收掉,不碰正在写的。
|
|
454
|
+
sweepStaleStateTemps([STATE_DIR, TRASH_DIR], {}).catch(() => {})
|
|
436
455
|
|
|
437
456
|
async function gcStars(validIds) {
|
|
438
457
|
try {
|
|
@@ -554,7 +573,9 @@ export function apply(ctx) {
|
|
|
554
573
|
const entry = store.items.find((t) => String(t.sessionId) === sid)
|
|
555
574
|
let originalPath = entry && typeof entry.originalPath === 'string' ? entry.originalPath : null
|
|
556
575
|
if (!originalPath && header) {
|
|
557
|
-
|
|
576
|
+
// handle 时代官方收走了 locate,用守卫式推导(root → 目录结构 → id 归属)
|
|
577
|
+
// 代替:推导成功且文件在盘 = 真核验通过,而不是标注 unverified 放行。
|
|
578
|
+
const loc = await persistence.locateVerified(header).catch(() => null)
|
|
558
579
|
if (loc && typeof loc.path === 'string') originalPath = loc.path
|
|
559
580
|
}
|
|
560
581
|
if (originalPath) {
|
|
@@ -714,7 +735,11 @@ export function apply(ctx) {
|
|
|
714
735
|
purged = true
|
|
715
736
|
})
|
|
716
737
|
if (!purged) throw new Error('彻底删除失败')
|
|
717
|
-
stars.removeIds([sid]).catch(
|
|
738
|
+
// 星标清理必须 await。原先这里是浮动写入(`stars.removeIds([sid]).catch(...)`):
|
|
739
|
+
// 删除结果先返回、星标文件随后才落地,正好会撞上调用方(或测试 teardown)已开始的
|
|
740
|
+
// 状态目录清理 → `ENOTEMPTY: rmdir .../state`(全量测试里约 1/4 概率偶发失败)。
|
|
741
|
+
// 而且"删除成功"不该早于状态落盘:进程恰好在此刻退出就会留下指向已删会话的星标。
|
|
742
|
+
try { await stars.removeIds([sid]) } catch (e) { /* 星标清理失败不阻塞删除结果 */ }
|
|
718
743
|
return { ok: true, purged: true }
|
|
719
744
|
}
|
|
720
745
|
|
|
@@ -776,7 +801,10 @@ export function apply(ctx) {
|
|
|
776
801
|
// crash-safe (backup + rollback) and re-syncs the live object.
|
|
777
802
|
const activeId = getActiveSessionId(ctx)
|
|
778
803
|
if (activeId != null && String(activeId) === String(sid)) {
|
|
779
|
-
|
|
804
|
+
const error = new Error('会话正被 DSH 打开,暂时无法移动;请重启 DSH 后再试。')
|
|
805
|
+
error.status = 409
|
|
806
|
+
error.code = 'DSM_SESSION_BUSY'
|
|
807
|
+
throw error
|
|
780
808
|
}
|
|
781
809
|
const r = await persistence.readSession(sid, 0)
|
|
782
810
|
if (!r || !r.meta) throw new Error('无法读取该会话的日志')
|
|
@@ -796,6 +824,10 @@ export function apply(ctx) {
|
|
|
796
824
|
|
|
797
825
|
const newHeader = Object.assign({}, meta, { cwd: canonical })
|
|
798
826
|
|
|
827
|
+
// 移动过程的非致命提示(如源目录仍有旧代日志未能清理),随成功结果一并
|
|
828
|
+
// 回报给客户端展示。
|
|
829
|
+
const moveNotes = []
|
|
830
|
+
|
|
799
831
|
// 1) Decide relocation strategy. `sessionPersistence.create()` rejects
|
|
800
832
|
// ("already exists in this backend") for ANY session the host has
|
|
801
833
|
// instantiated into its in-memory `states` — and DSH instantiates *every*
|
|
@@ -848,6 +880,8 @@ export function apply(ctx) {
|
|
|
848
880
|
await rename(stagedPath, newPath)
|
|
849
881
|
destinationInstalled = true
|
|
850
882
|
await unlink(backupPath)
|
|
883
|
+
const keptInSource = await cleanupMovedSourceDir(dirname(oldPath))
|
|
884
|
+
if (keptInSource.length > 0) moveNotes.push(`源目录未能清理干净(残留 ${keptInSource.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
|
|
851
885
|
} catch (e) {
|
|
852
886
|
try { await unlink(stagedPath) } catch (_) {}
|
|
853
887
|
if (destinationInstalled) { try { await unlink(newPath) } catch (_) {} }
|
|
@@ -871,6 +905,17 @@ export function apply(ctx) {
|
|
|
871
905
|
return null
|
|
872
906
|
}
|
|
873
907
|
|
|
908
|
+
// 移动成功后的源目录收尾(legacy 路径):**整体移除**。目标侧已写入校验通过的
|
|
909
|
+
// 权威日志,源目录只要还留着任何一代日志(legacy 旧代 session.jsonl.zstd 或
|
|
910
|
+
// session.vN.jsonl.zstd),就会让同 id 出现在两个项目目录,后端 list/create
|
|
911
|
+
// 立刻报 duplicate。返回未能清理时的残留文件名(供如实回报)。
|
|
912
|
+
const cleanupMovedSourceDir = async (dir) => {
|
|
913
|
+
let entries = null
|
|
914
|
+
try { entries = await readdir(dir) } catch (e) { return [] }
|
|
915
|
+
try { await rm(dir, { recursive: true, force: true }) } catch (e) { return entries }
|
|
916
|
+
return []
|
|
917
|
+
}
|
|
918
|
+
|
|
874
919
|
// Rewriting frame0's cwd now lives in src/zstd-frame.js so it can be
|
|
875
920
|
// regression-tested directly. See that module for why frame boundaries are
|
|
876
921
|
// validated by decompression and why a non-session frame0 is rejected
|
|
@@ -888,7 +933,13 @@ export function apply(ctx) {
|
|
|
888
933
|
}
|
|
889
934
|
}
|
|
890
935
|
try {
|
|
891
|
-
await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
|
|
936
|
+
const movedHandle = await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
|
|
937
|
+
if (movedHandle && Array.isArray(movedHandle.reclaimedSiblings) && movedHandle.reclaimedSiblings.length > 0) {
|
|
938
|
+
moveNotes.push(`已顺带回收 ${movedHandle.reclaimedSiblings.length} 处被取代的旧代日志副本(历史移动残留),避免同一会话在多目录重复。`)
|
|
939
|
+
}
|
|
940
|
+
if (movedHandle && movedHandle.sourceCleanup && movedHandle.sourceCleanup.cleaned === false) {
|
|
941
|
+
moveNotes.push(`源目录未能清理干净(残留 ${movedHandle.sourceCleanup.leftover.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
|
|
942
|
+
}
|
|
892
943
|
} catch (e) {
|
|
893
944
|
if (e && e.status) throw e
|
|
894
945
|
throw new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
@@ -935,6 +986,10 @@ export function apply(ctx) {
|
|
|
935
986
|
throw new Error('移动后校验失败:会话工作目录未正确更新')
|
|
936
987
|
}
|
|
937
988
|
if (backupPath) { try { await unlink(backupPath) } catch (e) {} }
|
|
989
|
+
if (oldPath) {
|
|
990
|
+
const keptInSource = await cleanupMovedSourceDir(dirname(oldPath))
|
|
991
|
+
if (keptInSource.length > 0) moveNotes.push(`源目录未能清理干净(残留 ${keptInSource.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
|
|
992
|
+
}
|
|
938
993
|
} catch (e) {
|
|
939
994
|
if (ALREADY_EXISTS_RE.test(String((e && e.message) || e))) {
|
|
940
995
|
// Collision: the session is already materialized in states (archived
|
|
@@ -988,9 +1043,109 @@ export function apply(ctx) {
|
|
|
988
1043
|
workspaceId: target.id,
|
|
989
1044
|
workspaceTitle: target.title,
|
|
990
1045
|
workspacePath: canonical,
|
|
1046
|
+
...(moveNotes.length > 0 ? { notes: moveNotes } : {}),
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// ---- 待移动队列(活跃 / 被占用的会话)-----------------------------------
|
|
1051
|
+
// 0.1.5 的单写者锁由持有写句柄的进程掌控,插件既拿不到句柄也没有释放它的公共
|
|
1052
|
+
// API;在占用期硬搬目录会让在写者继续写旧 inode 并在旧路径重建空壳。因此占用时
|
|
1053
|
+
// 只登记排队,等宿主释放(session/disposed)或下次插件
|
|
1054
|
+
// 启动时(会话尚未被打开)自动完成。
|
|
1055
|
+
const QUEUE_NOTE = '已排队:该会话正被 DSH 打开(写所有权只在 DSH 退出时释放)。重启 DSH 时会自动完成,请先别打开它。'
|
|
1056
|
+
|
|
1057
|
+
function isBusyError(e) {
|
|
1058
|
+
if (!e) return false
|
|
1059
|
+
if (e.code === 'DSM_SESSION_BUSY') return true
|
|
1060
|
+
const text = String((e && e.message) || e)
|
|
1061
|
+
return /正被 DSH 打开|already owned/i.test(text)
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// 移动入口:占用 → 排队;其它错误照旧抛出。返回成功结果或排队结果。
|
|
1065
|
+
async function moveOrQueue(sid, targetPath) {
|
|
1066
|
+
try {
|
|
1067
|
+
return await moveOne(sid, targetPath)
|
|
1068
|
+
} catch (e) {
|
|
1069
|
+
if (!isBusyError(e)) throw e
|
|
1070
|
+
await pendingMoves.queue(sid, targetPath)
|
|
1071
|
+
return { ok: true, moved: false, queued: true, notes: [QUEUE_NOTE] }
|
|
991
1072
|
}
|
|
992
1073
|
}
|
|
993
1074
|
|
|
1075
|
+
// 启动后的「学步期」:这段时间内 moveOne 的非占用类失败只当环境未就绪,不计入
|
|
1076
|
+
// 「多次失败即放弃」的计数(否则 boot 竞态会把用户的排队项白白丢掉)。
|
|
1077
|
+
const BOOT_WARMUP_MS = 30000
|
|
1078
|
+
const bootedAt = Date.now()
|
|
1079
|
+
|
|
1080
|
+
let queueRunning = false
|
|
1081
|
+
async function runPendingMoves(reason) {
|
|
1082
|
+
if (queueRunning) return { ran: false, moved: 0, kept: 0 }
|
|
1083
|
+
queueRunning = true
|
|
1084
|
+
let moved = 0
|
|
1085
|
+
const notes = []
|
|
1086
|
+
try {
|
|
1087
|
+
const items = await pendingMoves.list()
|
|
1088
|
+
for (const item of items) {
|
|
1089
|
+
try {
|
|
1090
|
+
await moveOne(item.sessionId, item.targetPath)
|
|
1091
|
+
await pendingMoves.remove([item.sessionId])
|
|
1092
|
+
metaCache.invalidate(item.sessionId)
|
|
1093
|
+
moved++
|
|
1094
|
+
notes.push(`排队中的移动已完成:${String(item.sessionId).slice(0, 18)}…`)
|
|
1095
|
+
} catch (e) {
|
|
1096
|
+
if (isBusyError(e)) continue // 仍被占用:留在队列里,等下一次触发
|
|
1097
|
+
if (Date.now() - bootedAt < BOOT_WARMUP_MS) continue // boot 期未就绪:不记失败
|
|
1098
|
+
const bumped = await pendingMoves.bumpAttempts(item.sessionId)
|
|
1099
|
+
if (bumped && bumped.dropped) notes.push(`排队中的移动多次失败已放弃:${String(item.sessionId).slice(0, 18)}…(${String((e && e.message) || e)})`)
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (moved > 0) { try { await reindexRegistry() } catch (e) { /* best-effort */ } }
|
|
1103
|
+
} finally {
|
|
1104
|
+
queueRunning = false
|
|
1105
|
+
}
|
|
1106
|
+
if (notes.length > 0) {
|
|
1107
|
+
try { for (const n of notes) console.warn('[dsh-sessions-manager] ' + n) } catch (e) { /* ignore */ }
|
|
1108
|
+
}
|
|
1109
|
+
void reason
|
|
1110
|
+
return { ran: true, moved, kept: (await pendingMoves.list()).length }
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// 排队项的补跑时机。**关键前提**:0.1.5 的单写者所有权一经 open 就持有到**进程退出**
|
|
1114
|
+
// 为止——官方没有任何「关闭会话 / 释放所有权」的公开入口(`dsh-commands` 只有
|
|
1115
|
+
// compact/feedback/goal;宿主无 unloadSession/closeSession/evict),而浏览器一连上就会
|
|
1116
|
+
// 自动打开/新建会话,一旦被打开就再也搬不动。所以唯一可靠窗口是**宿主刚起来、浏览器
|
|
1117
|
+
// 还没连上**的那一两秒 ⇒ 启动后立刻试,并在前 30s 内密集重试;之后每 120s 兜底;
|
|
1118
|
+
// 会话真被释放(session/disposed)时 500ms 后再试一次。
|
|
1119
|
+
const queueEffect = typeof ctx.effect === 'function' ? ctx.effect.bind(ctx) : ((fn) => { fn() })
|
|
1120
|
+
if (typeof ctx.on === 'function') {
|
|
1121
|
+
queueEffect(() => ctx.on('session/disposed', (session) => {
|
|
1122
|
+
const id = session && session.id != null ? String(session.id) : null
|
|
1123
|
+
if (!id) return
|
|
1124
|
+
pendingMoves.has(id).then((queued) => {
|
|
1125
|
+
if (!queued) return
|
|
1126
|
+
const t = setTimeout(() => { runPendingMoves('session-disposed').catch(() => {}) }, 500)
|
|
1127
|
+
if (t && typeof t.unref === 'function') t.unref()
|
|
1128
|
+
}).catch(() => {})
|
|
1129
|
+
}))
|
|
1130
|
+
}
|
|
1131
|
+
queueEffect(() => {
|
|
1132
|
+
// 每个启动补跑定时器都要 unref:宿主自己不会闲下来无所谓,但测试里未 unref 的
|
|
1133
|
+
// 30s 定时器会让 node:test 多等 30s 才退出(整套测试被无谓拖长,实测见报告)。
|
|
1134
|
+
const timers = [0, 1000, 3000, 6000, 12000, 30000].map((ms) => {
|
|
1135
|
+
const t = setTimeout(() => {
|
|
1136
|
+
runPendingMoves(ms === 0 ? 'startup' : `startup+${ms}`).catch(() => {})
|
|
1137
|
+
}, ms)
|
|
1138
|
+
if (t && typeof t.unref === 'function') t.unref()
|
|
1139
|
+
return t
|
|
1140
|
+
})
|
|
1141
|
+
// 轻量周期重试:只在队列非空时干活(队列空 = 一次本地 JSON 读取)。
|
|
1142
|
+
const tickTimer = setInterval(() => {
|
|
1143
|
+
pendingMoves.list().then((items) => { if (items.length > 0) return runPendingMoves('tick') }).catch(() => {})
|
|
1144
|
+
}, 120000)
|
|
1145
|
+
if (tickTimer && typeof tickTimer.unref === 'function') tickTimer.unref()
|
|
1146
|
+
return () => { for (const t of timers) clearTimeout(t); clearInterval(tickTimer) }
|
|
1147
|
+
})
|
|
1148
|
+
|
|
994
1149
|
// Force the host's WorkspaceRegistry to rebuild its in-memory sessionPath
|
|
995
1150
|
// index from the durable persistence headers. DSH's WorkspaceEntity.sessionIds
|
|
996
1151
|
// is a *getter* that filters record.sessionIds by `host.sessionPath(id) ===
|
|
@@ -1117,7 +1272,7 @@ export function apply(ctx) {
|
|
|
1117
1272
|
}))
|
|
1118
1273
|
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
1119
1274
|
}
|
|
1120
|
-
persistDecoded(decoded, statsById)
|
|
1275
|
+
await persistDecoded(decoded, statsById)
|
|
1121
1276
|
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
1122
1277
|
// failing sp.list() can never wipe the whole index.
|
|
1123
1278
|
let starredSet = new Set()
|
|
@@ -1235,12 +1390,75 @@ export function apply(ctx) {
|
|
|
1235
1390
|
}
|
|
1236
1391
|
if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
|
|
1237
1392
|
}
|
|
1238
|
-
persistDecoded(decoded, statsById)
|
|
1393
|
+
await persistDecoded(decoded, statsById)
|
|
1239
1394
|
}
|
|
1395
|
+
// 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list snapshot
|
|
1396
|
+
// 的 header,零解码;空白判定见下方 refineEmptyLineage(0.1.3 起体积法
|
|
1397
|
+
// 失效,改为事件类型精判)。普通顶层非空会话不产生条目,payload 最小。
|
|
1398
|
+
const lineage = {}
|
|
1399
|
+
for (const entry of entries) {
|
|
1400
|
+
const info = classifyLineage(entry.header, entry.sizeBytes)
|
|
1401
|
+
if (info) lineage[entry.id] = info
|
|
1402
|
+
}
|
|
1403
|
+
await refineEmptyLineage(lineage, entries)
|
|
1240
1404
|
return {
|
|
1241
1405
|
titles: Object.fromEntries(authorityTitleCache),
|
|
1242
1406
|
trashedSessionIds: store.items.map((item) => String(item.sessionId)),
|
|
1243
1407
|
purgedSessionIds: activeTombstones,
|
|
1408
|
+
lineage,
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// 空会话判定缓存:id → { sizeBytes, empty }。日志没变(sizeBytes 相同)
|
|
1413
|
+
// 就不重复解码——authority 会被侧栏高频轮询,缓存让它保持在近零成本。
|
|
1414
|
+
const emptyScanCache = new Map()
|
|
1415
|
+
|
|
1416
|
+
// 0.1.3 空会话精判(事件类型法,见 lineage.js):体积阈值对新格式失效
|
|
1417
|
+
// (头部扩容 + 恒存生命周期帧),改为对「压缩体积 ≤ EMPTY_DECODE_LIMIT」
|
|
1418
|
+
// 的候选走官方 inspectSession 解码,按事件类型判定。超过上限的日志必有
|
|
1419
|
+
// 内容,直接跳过(维持体积法/结构分类的结果)。解码失败按「非空」处理:
|
|
1420
|
+
// 宁可漏判一个空会话,不能把有内容的会话错标成空。
|
|
1421
|
+
async function refineEmptyLineage(lineage, entries) {
|
|
1422
|
+
if (!persistence || typeof persistence.inspectSession !== 'function') return
|
|
1423
|
+
for (const entry of entries) {
|
|
1424
|
+
const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
|
|
1425
|
+
if (size === null || size > EMPTY_DECODE_LIMIT) continue
|
|
1426
|
+
const id = String(entry.id)
|
|
1427
|
+
let scanned = emptyScanCache.get(id)
|
|
1428
|
+
if (!scanned || scanned.sizeBytes !== size) {
|
|
1429
|
+
let isEmpty = false
|
|
1430
|
+
try {
|
|
1431
|
+
const types = []
|
|
1432
|
+
await persistence.inspectSession(id, { onEvents: (batch) => {
|
|
1433
|
+
for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
|
|
1434
|
+
} })
|
|
1435
|
+
isEmpty = isEmptyEventTypes(types)
|
|
1436
|
+
} catch (e) { isEmpty = false }
|
|
1437
|
+
scanned = { sizeBytes: size, empty: isEmpty }
|
|
1438
|
+
emptyScanCache.set(id, scanned)
|
|
1439
|
+
}
|
|
1440
|
+
if (scanned.empty) {
|
|
1441
|
+
// 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
|
|
1442
|
+
// classifyLineage 对它们返回 null)。
|
|
1443
|
+
const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
|
|
1444
|
+
info.empty = true
|
|
1445
|
+
} else if (lineage[id]) {
|
|
1446
|
+
// 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
|
|
1447
|
+
// 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
|
|
1448
|
+
const info = lineage[id]
|
|
1449
|
+
info.empty = false
|
|
1450
|
+
if (!info.origin && !info.parentSession) delete lineage[id]
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
// 缓存只保留「本轮仍参与判定」的会话:已彻底删除或日志膨胀超过解码
|
|
1454
|
+
// 上限的条目不再有用,淘汰掉防止 Map 随历史会话无限增长。
|
|
1455
|
+
const live = new Set()
|
|
1456
|
+
for (const entry of entries) {
|
|
1457
|
+
const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
|
|
1458
|
+
if (size !== null && size <= EMPTY_DECODE_LIMIT) live.add(String(entry.id))
|
|
1459
|
+
}
|
|
1460
|
+
for (const key of emptyScanCache.keys()) {
|
|
1461
|
+
if (!live.has(key)) emptyScanCache.delete(key)
|
|
1244
1462
|
}
|
|
1245
1463
|
}
|
|
1246
1464
|
|
|
@@ -1391,7 +1609,7 @@ export function apply(ctx) {
|
|
|
1391
1609
|
disposers.push(ctx.webServer.register({
|
|
1392
1610
|
kind: 'exact',
|
|
1393
1611
|
path: '/archived-sessions/capabilities',
|
|
1394
|
-
handler: async (req, res) => json(res, capabilities),
|
|
1612
|
+
handler: async (req, res) => json(res, { ...capabilities, buildStamp: BUILD_STAMP }),
|
|
1395
1613
|
}))
|
|
1396
1614
|
|
|
1397
1615
|
disposers.push(ctx.webServer.register({
|
|
@@ -1595,7 +1813,7 @@ export function apply(ctx) {
|
|
|
1595
1813
|
const out = await purgeFromTrash(sid)
|
|
1596
1814
|
metaCache.invalidate(sid)
|
|
1597
1815
|
// 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
|
|
1598
|
-
titleIndex.remove([sid])
|
|
1816
|
+
try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
|
|
1599
1817
|
json(res, out)
|
|
1600
1818
|
} catch (e) {
|
|
1601
1819
|
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
@@ -1613,7 +1831,7 @@ export function apply(ctx) {
|
|
|
1613
1831
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1614
1832
|
const results = []
|
|
1615
1833
|
for (const sid of ids) {
|
|
1616
|
-
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); titleIndex.remove([sid]).catch(() => {}) }
|
|
1834
|
+
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); await titleIndex.remove([sid]).catch(() => {}) }
|
|
1617
1835
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1618
1836
|
}
|
|
1619
1837
|
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
@@ -1713,6 +1931,110 @@ export function apply(ctx) {
|
|
|
1713
1931
|
},
|
|
1714
1932
|
}))
|
|
1715
1933
|
|
|
1934
|
+
// 子代理血缘树(管理视图数据面):给定父会话,返回 origin==='subagent'
|
|
1935
|
+
// 的递归子树。DSH 原生目录(ui-subagent)只读,这里补管理能力的数据来源。
|
|
1936
|
+
// 只读官方 header 血缘字段(parentSession/origin/delegationDepth/createdAt),
|
|
1937
|
+
// 标题走既有批量投影(readTitleSnapshots),零整本日志解码。
|
|
1938
|
+
// live 标记 = 会话在内存里活着(正在运行或已打开):批量清理必须跳过。
|
|
1939
|
+
disposers.push(ctx.webServer.register({
|
|
1940
|
+
kind: 'exact',
|
|
1941
|
+
path: '/archived-sessions/lineage-tree',
|
|
1942
|
+
handler: async (req, res) => {
|
|
1943
|
+
try {
|
|
1944
|
+
const body = await readJsonBody(req)
|
|
1945
|
+
const rootId = body && typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
1946
|
+
if (!rootId || !isSafeSessionId(rootId)) return json(res, { error: 'missing sessionId' }, 400)
|
|
1947
|
+
const headerById = new Map()
|
|
1948
|
+
const sizeById = new Map()
|
|
1949
|
+
// 与会话列表同源的可见性判定:回收站项目一律不参与血缘树。
|
|
1950
|
+
const trashedIds = new Set()
|
|
1951
|
+
let purgedIds = []
|
|
1952
|
+
const persistedIds = new Set()
|
|
1953
|
+
try {
|
|
1954
|
+
const store = await readTrashStore()
|
|
1955
|
+
for (const item of store.items) trashedIds.add(String(item.sessionId))
|
|
1956
|
+
purgedIds = store.purgedSessionIds.map(String)
|
|
1957
|
+
} catch (e) { /* best-effort */ }
|
|
1958
|
+
const addHeader = (h, sizeBytes) => {
|
|
1959
|
+
if (!h || h.id == null) return
|
|
1960
|
+
const id = String(h.id)
|
|
1961
|
+
// 回收站里的会话(软删除后日志已搬走)不能出现在血缘树里,否则
|
|
1962
|
+
// 会出现「删掉了侧栏还在」。彻底删除的墓碑在下面按「当前已不存在」
|
|
1963
|
+
// 统一剔除,避免压住后来同 id 重建的会话。
|
|
1964
|
+
if (headerById.has(id) || trashedIds.has(id)) return
|
|
1965
|
+
headerById.set(id, {
|
|
1966
|
+
parentSession: h.parentSession != null ? String(h.parentSession) : null,
|
|
1967
|
+
subagent: h.origin === 'subagent',
|
|
1968
|
+
delegationDepth: Number.isFinite(h.delegationDepth) ? h.delegationDepth : null,
|
|
1969
|
+
createdAt: typeof h.createdAt === 'number' ? h.createdAt : null,
|
|
1970
|
+
})
|
|
1971
|
+
if (Number.isFinite(sizeBytes)) sizeById.set(id, sizeBytes)
|
|
1972
|
+
}
|
|
1973
|
+
try {
|
|
1974
|
+
for (const entry of await persistence.listEntries()) {
|
|
1975
|
+
addHeader(entry.header, entry.sizeBytes)
|
|
1976
|
+
if (entry && entry.id != null) persistedIds.add(String(entry.id))
|
|
1977
|
+
}
|
|
1978
|
+
} catch (e) { /* best-effort */ }
|
|
1979
|
+
const liveIds = new Set()
|
|
1980
|
+
const live = ctx.get('sessions')
|
|
1981
|
+
try {
|
|
1982
|
+
if (live && typeof live.list === 'function') {
|
|
1983
|
+
live.list().forEach((s) => { liveIds.add(String(s.id)); addHeader(s.header, undefined) })
|
|
1984
|
+
}
|
|
1985
|
+
} catch (e) { /* best-effort */ }
|
|
1986
|
+
// 墓碑只对「持久层里确实没了」的 id 生效:同 id 会话后来重新出现时
|
|
1987
|
+
// 必须让位,不能永久压住新会话(与会话列表同一套规则)。
|
|
1988
|
+
for (const id of purgedIds) {
|
|
1989
|
+
if (!persistedIds.has(id)) headerById.delete(id)
|
|
1990
|
+
}
|
|
1991
|
+
// 子代理索引:parentSession → 直接子代理 id 列表(只收 origin==='subagent')。
|
|
1992
|
+
const kidsOf = new Map()
|
|
1993
|
+
for (const [id, h] of headerById) {
|
|
1994
|
+
if (!h.subagent || !h.parentSession) continue
|
|
1995
|
+
if (!kidsOf.has(h.parentSession)) kidsOf.set(h.parentSession, [])
|
|
1996
|
+
kidsOf.get(h.parentSession).push(id)
|
|
1997
|
+
}
|
|
1998
|
+
// 递归展开:visited 防环,节点总数封顶防病态数据。
|
|
1999
|
+
const MAX_NODES = 500
|
|
2000
|
+
let nodeCount = 0
|
|
2001
|
+
const build = (id, depth, visited) => {
|
|
2002
|
+
nodeCount++
|
|
2003
|
+
const h = headerById.get(id) || {}
|
|
2004
|
+
const childVisited = new Set(visited)
|
|
2005
|
+
childVisited.add(id)
|
|
2006
|
+
const kidIds = nodeCount >= MAX_NODES ? [] : (kidsOf.get(id) || []).filter((k) => !childVisited.has(k))
|
|
2007
|
+
return {
|
|
2008
|
+
sessionId: id,
|
|
2009
|
+
parentSession: h.parentSession || null,
|
|
2010
|
+
delegationDepth: h.delegationDepth,
|
|
2011
|
+
depth,
|
|
2012
|
+
createdAt: h.createdAt || null,
|
|
2013
|
+
sizeBytes: sizeById.has(id) ? sizeById.get(id) : null,
|
|
2014
|
+
live: liveIds.has(id),
|
|
2015
|
+
title: null,
|
|
2016
|
+
children: kidIds.map((k) => build(k, depth + 1, childVisited)),
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
const nodes = (kidsOf.get(rootId) || []).map((k) => build(k, 1, new Set([rootId])))
|
|
2020
|
+
const allIds = [rootId]
|
|
2021
|
+
const walk = (n) => { allIds.push(n.sessionId); n.children.forEach(walk) }
|
|
2022
|
+
nodes.forEach(walk)
|
|
2023
|
+
const titles = await projectTitles(allIds)
|
|
2024
|
+
const titleOf = (id) => {
|
|
2025
|
+
const snap = titles.get(id)
|
|
2026
|
+
const m = snap ? metaFromSnapshot(snap) : null
|
|
2027
|
+
return (m && m.title) || null
|
|
2028
|
+
}
|
|
2029
|
+
const fill = (n) => { n.title = titleOf(n.sessionId); n.children.forEach(fill) }
|
|
2030
|
+
nodes.forEach(fill)
|
|
2031
|
+
json(res, { parent: { sessionId: rootId, title: titleOf(rootId) }, nodes })
|
|
2032
|
+
} catch (e) {
|
|
2033
|
+
json(res, { error: String((e && e.message) || e) }, errorStatus(e))
|
|
2034
|
+
}
|
|
2035
|
+
},
|
|
2036
|
+
}))
|
|
2037
|
+
|
|
1716
2038
|
// Available target workspaces (for the move picker).
|
|
1717
2039
|
disposers.push(ctx.webServer.register({
|
|
1718
2040
|
kind: 'exact',
|
|
@@ -1737,7 +2059,8 @@ export function apply(ctx) {
|
|
|
1737
2059
|
const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
|
|
1738
2060
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1739
2061
|
if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
|
|
1740
|
-
const moved = await
|
|
2062
|
+
const moved = await moveOrQueue(sid, target)
|
|
2063
|
+
if (moved.queued) return json(res, { sessionId: sid, ...moved })
|
|
1741
2064
|
// 移动会改写日志 frame0 的 cwd:元数据(cwd)已变,主动丢弃缓存条目,
|
|
1742
2065
|
// 不等 mtime 指纹自然失效(Windows 上 mtime 精度较粗,指纹可能不变)。
|
|
1743
2066
|
metaCache.invalidate(sid)
|
|
@@ -1754,6 +2077,77 @@ export function apply(ctx) {
|
|
|
1754
2077
|
},
|
|
1755
2078
|
}))
|
|
1756
2079
|
|
|
2080
|
+
// Move many conversations to one target workspace (issue #7). Sequential
|
|
2081
|
+
// on purpose: each move rewrites frame0 (file IO), and one bad session
|
|
2082
|
+
// must not block the rest. Single reindex at the end covers all moves.
|
|
2083
|
+
disposers.push(ctx.webServer.register({
|
|
2084
|
+
kind: 'exact',
|
|
2085
|
+
path: '/archived-sessions/move-many',
|
|
2086
|
+
handler: async (req, res) => {
|
|
2087
|
+
try {
|
|
2088
|
+
const body = await readJsonBody(req)
|
|
2089
|
+
const ids = Array.isArray(body && body.sessionIds)
|
|
2090
|
+
? [...new Set(body.sessionIds.filter((x) => typeof x === 'string' && x))]
|
|
2091
|
+
: []
|
|
2092
|
+
const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
|
|
2093
|
+
if (!ids.length) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
2094
|
+
if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
|
|
2095
|
+
let moved = 0
|
|
2096
|
+
const failed = []
|
|
2097
|
+
const queued = []
|
|
2098
|
+
for (const sid of ids) {
|
|
2099
|
+
try {
|
|
2100
|
+
const r = await moveOrQueue(sid, target)
|
|
2101
|
+
if (r && r.queued) { queued.push(sid); continue }
|
|
2102
|
+
// frame0 的 cwd 被改写:与单移动同理由,主动丢缓存不等指纹失效。
|
|
2103
|
+
metaCache.invalidate(sid)
|
|
2104
|
+
moved++
|
|
2105
|
+
} catch (e) {
|
|
2106
|
+
const err = e && e.code ? { sessionId: sid, error: String((e && e.message) || e), code: e.code } : { sessionId: sid, error: String((e && e.message) || e) }
|
|
2107
|
+
failed.push(err)
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
try { await reindexRegistry() } catch (e) { /* best-effort */ }
|
|
2111
|
+
json(res, {
|
|
2112
|
+
moved,
|
|
2113
|
+
failed,
|
|
2114
|
+
...(queued.length > 0 ? { queued, notes: [`${queued.length} 个会话已排队(正被 DSH 打开),重启 DSH 后会自动完成;重启后请先别打开它们。`] } : {}),
|
|
2115
|
+
})
|
|
2116
|
+
} catch (e) {
|
|
2117
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2118
|
+
}
|
|
2119
|
+
},
|
|
2120
|
+
}))
|
|
2121
|
+
|
|
2122
|
+
// 待移动队列:查看 / 取消(会话被占用时排队,释放后自动完成)。
|
|
2123
|
+
disposers.push(ctx.webServer.register({
|
|
2124
|
+
kind: 'exact',
|
|
2125
|
+
path: '/archived-sessions/pending-moves',
|
|
2126
|
+
handler: async (req, res) => {
|
|
2127
|
+
try {
|
|
2128
|
+
const items = await pendingMoves.list()
|
|
2129
|
+
json(res, { items: items.map((i) => ({ sessionId: i.sessionId, targetPath: i.targetPath, queuedAt: i.queuedAt, attempts: i.attempts })) })
|
|
2130
|
+
} catch (e) {
|
|
2131
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
2132
|
+
}
|
|
2133
|
+
},
|
|
2134
|
+
}))
|
|
2135
|
+
disposers.push(ctx.webServer.register({
|
|
2136
|
+
kind: 'exact',
|
|
2137
|
+
path: '/archived-sessions/pending-moves/cancel',
|
|
2138
|
+
handler: async (req, res) => {
|
|
2139
|
+
try {
|
|
2140
|
+
const body = await readJsonBody(req)
|
|
2141
|
+
const ids = parseIds(body)
|
|
2142
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
2143
|
+
const removed = await pendingMoves.remove(ids)
|
|
2144
|
+
json(res, { ok: true, removed })
|
|
2145
|
+
} catch (e) {
|
|
2146
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2147
|
+
}
|
|
2148
|
+
},
|
|
2149
|
+
}))
|
|
2150
|
+
|
|
1757
2151
|
// Archive (hide) one session.
|
|
1758
2152
|
disposers.push(ctx.webServer.register({
|
|
1759
2153
|
kind: 'exact',
|