dsh-sessions-manager 3.5.4 → 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 +778 -134
- 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 +406 -14
- 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 {
|
|
@@ -716,7 +735,11 @@ export function apply(ctx) {
|
|
|
716
735
|
purged = true
|
|
717
736
|
})
|
|
718
737
|
if (!purged) throw new Error('彻底删除失败')
|
|
719
|
-
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) { /* 星标清理失败不阻塞删除结果 */ }
|
|
720
743
|
return { ok: true, purged: true }
|
|
721
744
|
}
|
|
722
745
|
|
|
@@ -778,7 +801,10 @@ export function apply(ctx) {
|
|
|
778
801
|
// crash-safe (backup + rollback) and re-syncs the live object.
|
|
779
802
|
const activeId = getActiveSessionId(ctx)
|
|
780
803
|
if (activeId != null && String(activeId) === String(sid)) {
|
|
781
|
-
|
|
804
|
+
const error = new Error('会话正被 DSH 打开,暂时无法移动;请重启 DSH 后再试。')
|
|
805
|
+
error.status = 409
|
|
806
|
+
error.code = 'DSM_SESSION_BUSY'
|
|
807
|
+
throw error
|
|
782
808
|
}
|
|
783
809
|
const r = await persistence.readSession(sid, 0)
|
|
784
810
|
if (!r || !r.meta) throw new Error('无法读取该会话的日志')
|
|
@@ -798,6 +824,10 @@ export function apply(ctx) {
|
|
|
798
824
|
|
|
799
825
|
const newHeader = Object.assign({}, meta, { cwd: canonical })
|
|
800
826
|
|
|
827
|
+
// 移动过程的非致命提示(如源目录仍有旧代日志未能清理),随成功结果一并
|
|
828
|
+
// 回报给客户端展示。
|
|
829
|
+
const moveNotes = []
|
|
830
|
+
|
|
801
831
|
// 1) Decide relocation strategy. `sessionPersistence.create()` rejects
|
|
802
832
|
// ("already exists in this backend") for ANY session the host has
|
|
803
833
|
// instantiated into its in-memory `states` — and DSH instantiates *every*
|
|
@@ -850,6 +880,8 @@ export function apply(ctx) {
|
|
|
850
880
|
await rename(stagedPath, newPath)
|
|
851
881
|
destinationInstalled = true
|
|
852
882
|
await unlink(backupPath)
|
|
883
|
+
const keptInSource = await cleanupMovedSourceDir(dirname(oldPath))
|
|
884
|
+
if (keptInSource.length > 0) moveNotes.push(`源目录未能清理干净(残留 ${keptInSource.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
|
|
853
885
|
} catch (e) {
|
|
854
886
|
try { await unlink(stagedPath) } catch (_) {}
|
|
855
887
|
if (destinationInstalled) { try { await unlink(newPath) } catch (_) {} }
|
|
@@ -873,6 +905,17 @@ export function apply(ctx) {
|
|
|
873
905
|
return null
|
|
874
906
|
}
|
|
875
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
|
+
|
|
876
919
|
// Rewriting frame0's cwd now lives in src/zstd-frame.js so it can be
|
|
877
920
|
// regression-tested directly. See that module for why frame boundaries are
|
|
878
921
|
// validated by decompression and why a non-session frame0 is rejected
|
|
@@ -890,7 +933,13 @@ export function apply(ctx) {
|
|
|
890
933
|
}
|
|
891
934
|
}
|
|
892
935
|
try {
|
|
893
|
-
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
|
+
}
|
|
894
943
|
} catch (e) {
|
|
895
944
|
if (e && e.status) throw e
|
|
896
945
|
throw new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
@@ -937,6 +986,10 @@ export function apply(ctx) {
|
|
|
937
986
|
throw new Error('移动后校验失败:会话工作目录未正确更新')
|
|
938
987
|
}
|
|
939
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
|
+
}
|
|
940
993
|
} catch (e) {
|
|
941
994
|
if (ALREADY_EXISTS_RE.test(String((e && e.message) || e))) {
|
|
942
995
|
// Collision: the session is already materialized in states (archived
|
|
@@ -990,9 +1043,109 @@ export function apply(ctx) {
|
|
|
990
1043
|
workspaceId: target.id,
|
|
991
1044
|
workspaceTitle: target.title,
|
|
992
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] }
|
|
993
1072
|
}
|
|
994
1073
|
}
|
|
995
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
|
+
|
|
996
1149
|
// Force the host's WorkspaceRegistry to rebuild its in-memory sessionPath
|
|
997
1150
|
// index from the durable persistence headers. DSH's WorkspaceEntity.sessionIds
|
|
998
1151
|
// is a *getter* that filters record.sessionIds by `host.sessionPath(id) ===
|
|
@@ -1119,7 +1272,7 @@ export function apply(ctx) {
|
|
|
1119
1272
|
}))
|
|
1120
1273
|
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
1121
1274
|
}
|
|
1122
|
-
persistDecoded(decoded, statsById)
|
|
1275
|
+
await persistDecoded(decoded, statsById)
|
|
1123
1276
|
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
1124
1277
|
// failing sp.list() can never wipe the whole index.
|
|
1125
1278
|
let starredSet = new Set()
|
|
@@ -1237,12 +1390,75 @@ export function apply(ctx) {
|
|
|
1237
1390
|
}
|
|
1238
1391
|
if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
|
|
1239
1392
|
}
|
|
1240
|
-
persistDecoded(decoded, statsById)
|
|
1393
|
+
await persistDecoded(decoded, statsById)
|
|
1241
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)
|
|
1242
1404
|
return {
|
|
1243
1405
|
titles: Object.fromEntries(authorityTitleCache),
|
|
1244
1406
|
trashedSessionIds: store.items.map((item) => String(item.sessionId)),
|
|
1245
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)
|
|
1246
1462
|
}
|
|
1247
1463
|
}
|
|
1248
1464
|
|
|
@@ -1393,7 +1609,7 @@ export function apply(ctx) {
|
|
|
1393
1609
|
disposers.push(ctx.webServer.register({
|
|
1394
1610
|
kind: 'exact',
|
|
1395
1611
|
path: '/archived-sessions/capabilities',
|
|
1396
|
-
handler: async (req, res) => json(res, capabilities),
|
|
1612
|
+
handler: async (req, res) => json(res, { ...capabilities, buildStamp: BUILD_STAMP }),
|
|
1397
1613
|
}))
|
|
1398
1614
|
|
|
1399
1615
|
disposers.push(ctx.webServer.register({
|
|
@@ -1597,7 +1813,7 @@ export function apply(ctx) {
|
|
|
1597
1813
|
const out = await purgeFromTrash(sid)
|
|
1598
1814
|
metaCache.invalidate(sid)
|
|
1599
1815
|
// 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
|
|
1600
|
-
titleIndex.remove([sid])
|
|
1816
|
+
try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
|
|
1601
1817
|
json(res, out)
|
|
1602
1818
|
} catch (e) {
|
|
1603
1819
|
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
@@ -1615,7 +1831,7 @@ export function apply(ctx) {
|
|
|
1615
1831
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1616
1832
|
const results = []
|
|
1617
1833
|
for (const sid of ids) {
|
|
1618
|
-
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(() => {}) }
|
|
1619
1835
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1620
1836
|
}
|
|
1621
1837
|
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
@@ -1715,6 +1931,110 @@ export function apply(ctx) {
|
|
|
1715
1931
|
},
|
|
1716
1932
|
}))
|
|
1717
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
|
+
|
|
1718
2038
|
// Available target workspaces (for the move picker).
|
|
1719
2039
|
disposers.push(ctx.webServer.register({
|
|
1720
2040
|
kind: 'exact',
|
|
@@ -1739,7 +2059,8 @@ export function apply(ctx) {
|
|
|
1739
2059
|
const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
|
|
1740
2060
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1741
2061
|
if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
|
|
1742
|
-
const moved = await
|
|
2062
|
+
const moved = await moveOrQueue(sid, target)
|
|
2063
|
+
if (moved.queued) return json(res, { sessionId: sid, ...moved })
|
|
1743
2064
|
// 移动会改写日志 frame0 的 cwd:元数据(cwd)已变,主动丢弃缓存条目,
|
|
1744
2065
|
// 不等 mtime 指纹自然失效(Windows 上 mtime 精度较粗,指纹可能不变)。
|
|
1745
2066
|
metaCache.invalidate(sid)
|
|
@@ -1756,6 +2077,77 @@ export function apply(ctx) {
|
|
|
1756
2077
|
},
|
|
1757
2078
|
}))
|
|
1758
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
|
+
|
|
1759
2151
|
// Archive (hide) one session.
|
|
1760
2152
|
disposers.push(ctx.webServer.register({
|
|
1761
2153
|
kind: 'exact',
|
package/src/lineage.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Lineage classification for sidebar grouping (issue #6).
|
|
2
|
+
//
|
|
3
|
+
// All inputs come from the public list() snapshot — header + sizeBytes — so
|
|
4
|
+
// classification costs zero log decoding, keeping the zero-decode listing
|
|
5
|
+
// design intact. Three kinds:
|
|
6
|
+
// - subagent: official header.origin === 'subagent' (with parentSession and
|
|
7
|
+
// a delegationDepth recursion budget, both persisted by the backend)
|
|
8
|
+
// - fork branch: header.parentSession set without the subagent origin
|
|
9
|
+
// - empty: the log holds only the generation header
|
|
10
|
+
//
|
|
11
|
+
// The backend list snapshot carries NO eventCount, so the size test below is
|
|
12
|
+
// only a cheap PRE-FILTER: since 0.1.3 it can no longer decide emptiness by
|
|
13
|
+
// itself (bigger headers + a constant lifecycle-metadata frame push every
|
|
14
|
+
// new log past the old baseline). The authoritative verdict comes from
|
|
15
|
+
// refineEmptyLineage in index.js — an event-type scan (see below). Keep the
|
|
16
|
+
// size test: it flags candidates cheaply and its false positives get
|
|
17
|
+
// corrected by the precise pass.
|
|
18
|
+
|
|
19
|
+
const EMPTY_BASE = 190
|
|
20
|
+
|
|
21
|
+
export function isEmptyLogSize(sizeBytes, cwd) {
|
|
22
|
+
if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return false
|
|
23
|
+
const cwdLen = typeof cwd === 'string' ? cwd.length : 0
|
|
24
|
+
return sizeBytes <= EMPTY_BASE + cwdLen
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---- 0.1.3 空会话判定(事件类型法)---------------------------------------
|
|
28
|
+
// 0.1.3 起日志结构变了:头部帧之后恒定跟一帧「生命周期元数据」
|
|
29
|
+
// (permission/preset / sandbox/mode / approval/policy,实测约 150B 压缩),
|
|
30
|
+
// 且头部新增 isSeeded / delegationDepth / agentPreset 等字段,压缩后整本
|
|
31
|
+
// 约 300 + cwd.length 字节——上面的体积阈值(190 + cwd,alpha.2 基线)对
|
|
32
|
+
// 新格式永远判不中,空白会话全部漏判。压缩体积对头部扩容/压缩器参数天生
|
|
33
|
+
// 敏感,不再扩阈值,改为按事件类型判定:
|
|
34
|
+
// 空会话 = 头部之外只出现生命周期元数据事件,没有任何内容事件。
|
|
35
|
+
// 解码在宿主侧进行(index.js 的 refineEmptyLineage:只对压缩体积 ≤
|
|
36
|
+
// EMPTY_DECODE_LIMIT 的候选调官方 inspectSession),这里只放纯函数与常量。
|
|
37
|
+
//
|
|
38
|
+
// 已知的生命周期事件(0.1.3-alpha.1 实测,会话创建时一次性写入)。'session'
|
|
39
|
+
// 是头部行自身——inspectSession 若把头部当事件回传,它恒存在于每本日志,
|
|
40
|
+
// 放行不削弱判定;任何未知类型都按内容处理(宁可漏判,不可错杀)。
|
|
41
|
+
export const EMPTY_DECODE_LIMIT = 8192
|
|
42
|
+
|
|
43
|
+
const LIFECYCLE_EVENT_TYPES = new Set([
|
|
44
|
+
'session',
|
|
45
|
+
'permission/preset',
|
|
46
|
+
'sandbox/mode',
|
|
47
|
+
'approval/policy',
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {Array<string|undefined>} types 事件类型序列(不含头部也可,含头部
|
|
52
|
+
* 也可——'session' 在放行清单里)。0 条 = 头部之外无任何事件,视为空。
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function isEmptyEventTypes(types) {
|
|
56
|
+
if (!Array.isArray(types)) return false
|
|
57
|
+
for (const t of types) {
|
|
58
|
+
if (typeof t !== 'string' || !LIFECYCLE_EVENT_TYPES.has(t)) return false
|
|
59
|
+
}
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Returns a lineage record, or null when the session is an ordinary
|
|
64
|
+
// top-level, non-empty session (the overwhelmingly common case — keeps the
|
|
65
|
+
// sidebar-state payload small).
|
|
66
|
+
export function classifyLineage(header, sizeBytes) {
|
|
67
|
+
if (!header || typeof header !== 'object') return null
|
|
68
|
+
const origin = header.origin === 'subagent' ? 'subagent' : null
|
|
69
|
+
const parentSession = typeof header.parentSession === 'string' && header.parentSession
|
|
70
|
+
? header.parentSession
|
|
71
|
+
: null
|
|
72
|
+
const delegationDepth = Number.isSafeInteger(header.delegationDepth) && header.delegationDepth > 0
|
|
73
|
+
? header.delegationDepth
|
|
74
|
+
: 0
|
|
75
|
+
const empty = isEmptyLogSize(sizeBytes, header.cwd)
|
|
76
|
+
if (!origin && !parentSession && !empty) return null
|
|
77
|
+
return { origin, parentSession, delegationDepth, empty }
|
|
78
|
+
}
|