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/client/logic.js
CHANGED
|
@@ -69,3 +69,82 @@ export function workspaceForNodes(nodes) {
|
|
|
69
69
|
export function starredOf(items) {
|
|
70
70
|
return (items || []).filter((item) => item && item.starred)
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
// 血缘折叠:把 origin === 'subagent' 的会话挂到父会话下,返回顶层列表 + 父子
|
|
74
|
+
// 映射 + 折叠数量。
|
|
75
|
+
//
|
|
76
|
+
// 关键约束:从顶层移除的是「子代理自己」,父会话必须保留在顶层——折叠按钮就
|
|
77
|
+
// 渲染在父会话卡片里,父一旦被一起过滤掉,界面上就只剩平铺的子代理,看起来
|
|
78
|
+
// 像「折叠完全没生效」。
|
|
79
|
+
// 父会话不在当前列表(被筛选掉 / 已删除)时子代理保持在顶层,保证搜得到就
|
|
80
|
+
// 看得见。
|
|
81
|
+
export function foldSubagents(items, lineage) {
|
|
82
|
+
const list = items || []
|
|
83
|
+
const table = lineage || {}
|
|
84
|
+
const kidsOf = new Map()
|
|
85
|
+
const foldedIds = new Set()
|
|
86
|
+
const byId = new Map()
|
|
87
|
+
for (const item of list) byId.set(String(item.sessionId), item)
|
|
88
|
+
for (const item of list) {
|
|
89
|
+
const id = String(item.sessionId)
|
|
90
|
+
const info = table[id]
|
|
91
|
+
const parentId = info && info.origin === 'subagent' && info.parentSession ? String(info.parentSession) : null
|
|
92
|
+
if (!parentId || parentId === id || !byId.has(parentId)) continue
|
|
93
|
+
if (!kidsOf.has(parentId)) kidsOf.set(parentId, [])
|
|
94
|
+
kidsOf.get(parentId).push(item)
|
|
95
|
+
foldedIds.add(id)
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
topList: foldedIds.size ? list.filter((item) => !foldedIds.has(String(item.sessionId))) : list,
|
|
99
|
+
kidsOf,
|
|
100
|
+
foldedCount: foldedIds.size,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 列表里只显示会话 ID 的短片段。完整 UUID 有 36 个字符,塞进 meta 行会把整
|
|
105
|
+
// 行撑到换行、卡片高度跟着浮动——短 ID 是「列表定高」的前提之一。完整值保留
|
|
106
|
+
// 在 title 属性与详情面板里,信息不丢。
|
|
107
|
+
export function shortId(id) {
|
|
108
|
+
const raw = String(id == null ? '' : id)
|
|
109
|
+
const tail = raw.startsWith('session-') ? raw.slice(8) : raw
|
|
110
|
+
if (!tail) return ''
|
|
111
|
+
return tail.length <= 10 ? tail : tail.slice(0, 8) + '…'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 打开子代理之后的提示文案。三种结果要传达三件不同的事,混成一句「打不开」
|
|
115
|
+
// 会让人既不知道原因、也不知道下一步:
|
|
116
|
+
// ok → 已经切过去了;在设置面板里会话区被挡着,得提醒关掉才看得到
|
|
117
|
+
// no-service → 这个 runtime 没开放会话切换接口,只能手动切
|
|
118
|
+
// failed → 自动切换没成功,给出确定可走的替代路径(侧栏父会话头部的
|
|
119
|
+
// 「/ N」官方子代理目录)
|
|
120
|
+
// where: 'panel'(设置面板内)| 'sidebar'(侧栏注入行)
|
|
121
|
+
export function openSubagentToast(result, name, where) {
|
|
122
|
+
const label = name || '子代理'
|
|
123
|
+
if (result === 'ok') {
|
|
124
|
+
return {
|
|
125
|
+
kind: 'ok',
|
|
126
|
+
text: where === 'sidebar' ? `已打开「${label}」` : `已打开「${label}」— 关掉设置即可看到`,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (result === 'no-service') {
|
|
130
|
+
return { kind: 'err', text: '这个 DSH 版本没有开放会话切换接口,请在侧边栏手动切换' }
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
kind: 'err',
|
|
134
|
+
text: where === 'sidebar'
|
|
135
|
+
? '打不开这个子代理:请选中它的父会话,点标题栏的「/ N」子代理目录'
|
|
136
|
+
: '打不开:请在侧边栏选中它的父会话,点标题栏的「/ N」子代理目录',
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 提示(toast)停留时长:按**可读字数**给,而不是固定 2.4s / 2.6s。
|
|
141
|
+
// 中文舒适阅读约 6 字/秒,留 40% 余量后夹在 [2.6s, 11s];失败类再 ×1.25(同样受上限约束)。
|
|
142
|
+
// 背景(2026-09-10 用户反馈):排队/失败类文案动辄 40~100 字,"还没读完提示就不见了"。
|
|
143
|
+
export const TOAST_MIN_MS = 2600
|
|
144
|
+
export const TOAST_MAX_MS = 11000
|
|
145
|
+
export function toastDurationFor(text, kind) {
|
|
146
|
+
const chars = String(text == null ? '' : text).length
|
|
147
|
+
const readingMs = Math.round((chars / 6) * 1000 * 1.4)
|
|
148
|
+
const scaled = kind === 'err' ? Math.round(readingMs * 1.25) : readingMs
|
|
149
|
+
return Math.min(TOAST_MAX_MS, Math.max(TOAST_MIN_MS, scaled))
|
|
150
|
+
}
|
|
@@ -19,10 +19,24 @@ const DEFAULT_CHUNK = 400
|
|
|
19
19
|
// 防御上限:一次 inspect 的分块循环绝不能无限自旋(后端 read 行为异常时快速失败)。
|
|
20
20
|
const MAX_CHUNKS = 20000
|
|
21
21
|
|
|
22
|
+
// SessionHandle.read() 的返回值在 0.1.3 / 0.1.5 之间换过形态,这里统一成事件数组:
|
|
23
|
+
// - 0.1.3:直接是事件数组(或可迭代对象)
|
|
24
|
+
// - 0.1.5:**{ eventState: 'shared-frozen' | …, events: [...] }**(不再是数组!
|
|
25
|
+
// 末尾读取同样是这个形状,只是 events: [])
|
|
26
|
+
// - 未知形态**抛错而不是当空数组**:静默返回 [] 的代价是灾难性的——
|
|
27
|
+
// 2026-09-10 实测,0.1.5 升级后读取恒为空,移动"用空事件重建日志"把两个会话
|
|
28
|
+
// 削成只剩头部的空壳。任何无法识别的返回值都必须让调用方看到错误。
|
|
22
29
|
function normalizeReadResult(events) {
|
|
30
|
+
if (events === undefined || events === null) return []
|
|
23
31
|
if (Array.isArray(events)) return events
|
|
24
|
-
if (
|
|
25
|
-
return []
|
|
32
|
+
if (typeof events === 'object' && Array.isArray(events.events)) return events.events
|
|
33
|
+
if (typeof events[Symbol.iterator] === 'function') return [...events]
|
|
34
|
+
const error = new Error(
|
|
35
|
+
'DSH 会话读取返回了无法识别的结构(runtime 接口可能已变更),已停止解析以免误判为空:' +
|
|
36
|
+
Object.prototype.toString.call(events),
|
|
37
|
+
)
|
|
38
|
+
error.code = 'DSM_READ_SHAPE_UNKNOWN'
|
|
39
|
+
throw error
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
async function closeQuietly(handle) {
|
package/src/handle-era-ops.js
CHANGED
|
@@ -14,17 +14,110 @@
|
|
|
14
14
|
// (活跃写者 → 409 拒绝),然后整目录删除会话目录(basename 已由路径
|
|
15
15
|
// 守卫验证),最后以官方 stat 复核该 id 已消失。
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// 活跃写者策略:两个操作都拒绝「被 DSH 打开、写权限已被占用」的会话,而不是照
|
|
18
|
+
// legacy 那样改写活跃对象——handle 时代的写句柄所有权在官方 tracker 内部,
|
|
19
|
+
// 与其打补丁不如如实拒绝,风险面更小。
|
|
20
|
+
//
|
|
21
|
+
// 注意这与 legacy 时代的可用范围不同:legacy 时代没有单写者所有权,插件直接改
|
|
22
|
+
// 文件 + 同步 live 对象就能移动任意会话;0.1.3 引入 handle 后,agent-loop 在
|
|
23
|
+
// resume 会话时先取写所有权(core/agent-loop resumeWith: "Taking write ownership
|
|
24
|
+
// FIRST")并持有到 agent 卸载,于是「在 DSH 里点开过的会话」一律处于占用态。
|
|
25
|
+
// 这是宿主机制决定的硬约束,不是插件能绕开的判定,提示必须如实说明。
|
|
20
26
|
|
|
21
|
-
import { mkdir, readFile, rename, rm,
|
|
22
|
-
import { join } from 'node:path'
|
|
27
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
28
|
+
import { basename, dirname, join } from 'node:path'
|
|
23
29
|
import { rewriteFrame0CwdInMemory, scanZstdFrames } from './zstd-frame.js'
|
|
24
|
-
import { deriveSessionDir, locateSessionArtifacts } from './handle-era-paths.js'
|
|
30
|
+
import { deriveSessionDir, encodeSegmentFor, generationVersionOf, locateSessionArtifacts } from './handle-era-paths.js'
|
|
25
31
|
|
|
26
32
|
const MOVE_BATCH = 400
|
|
27
33
|
|
|
34
|
+
// 真实日志判定:官方命名是 session.vN.jsonl[.zstd],legacy 时代是
|
|
35
|
+
// session.jsonl[.zstd]——锚定结尾,其他后缀(如测试残渣 *.jsonl.zstd.test)
|
|
36
|
+
// 不算真日志;只有本插件移动过程的临时名(.move-backup- / .move-stage-)
|
|
37
|
+
// 也不算。
|
|
38
|
+
const REAL_LOG_RE = /^session.*\.jsonl(\.zst(d)?)?$/
|
|
39
|
+
const MOVE_TEMP_RE = /\.move-(backup|stage)-/
|
|
40
|
+
// 会话目录里"本会话自己的"非日志文件(lease / flock / 官方临时产物)。用于判定
|
|
41
|
+
// 一个仅剩旧代的目录是不是可以安全回收——夹带别的文件就交人工裁决。
|
|
42
|
+
const SESSION_LOCAL_JUNK_RE = /^(\.?session\.lock.*|\.lock.*|.*\.lock|.*\.tmp|.*\.stage|.*\.bak|\.DS_Store)$/
|
|
43
|
+
|
|
44
|
+
// 同 id 跨目录残留处理。后端在 create/list 时扫全盘:同一会话 id 出现在多个
|
|
45
|
+
// 项目目录即报 “duplicate JSONL session id ... appears in multiple project
|
|
46
|
+
// directories”。三类残留区别对待(判定顺序即风险从低到高):
|
|
47
|
+
//
|
|
48
|
+
// 1) 空壳:目录里没有任何真实日志 → 纯垃圾,直接删。
|
|
49
|
+
// 2) 只含**被取代的旧代**日志(最高代版本号严格低于当前目录的最高代)→ 也删。
|
|
50
|
+
// 0.1.5 起官方「发布新代但不删旧代」(实测同一目录里 session.v2 与
|
|
51
|
+
// session.v3 并存),而历史版本的插件移动时只搬走当前代,留下的旧代会让该
|
|
52
|
+
// id 永远处于"跨目录重复"状态,之后所有移动/彻底删除都被拒。旧代内容已被
|
|
53
|
+
// 新代取代(官方读取恒定取最高代),删除不丢数据,且会以 note 如实回报。
|
|
54
|
+
// 为稳妥只删"纯日志目录"(除日志外只允许 lock/临时文件),夹带未知文件时
|
|
55
|
+
// 降级为第 3 类人工裁决。
|
|
56
|
+
// 3) 版本相同或更高 → 真重复,无法判断哪份权威 → 拒绝移动并给出清单。
|
|
57
|
+
async function cleanSiblingCopies(root, sid, keepDir, keepVersion) {
|
|
58
|
+
let projects
|
|
59
|
+
try { projects = await readdir(root, { withFileTypes: true }) } catch (e) { return { removed: 0, superseded: [], real: [] } }
|
|
60
|
+
let segment
|
|
61
|
+
try { segment = encodeSegmentFor(sid) } catch (e) { return { removed: 0, superseded: [], real: [] } }
|
|
62
|
+
let removed = 0
|
|
63
|
+
const superseded = []
|
|
64
|
+
const real = []
|
|
65
|
+
for (const ent of projects) {
|
|
66
|
+
if (!ent.isDirectory()) continue
|
|
67
|
+
const candidate = join(root, ent.name, segment)
|
|
68
|
+
if (keepDir && candidate === keepDir) continue
|
|
69
|
+
let entries
|
|
70
|
+
try {
|
|
71
|
+
const st = await stat(candidate)
|
|
72
|
+
if (!st.isDirectory()) continue
|
|
73
|
+
entries = await readdir(candidate)
|
|
74
|
+
} catch (e) { continue }
|
|
75
|
+
const logs = entries.filter((name) => REAL_LOG_RE.test(name) && !MOVE_TEMP_RE.test(name))
|
|
76
|
+
if (logs.length === 0) {
|
|
77
|
+
try { await rm(candidate, { recursive: true, force: true }); removed++ } catch (e) { /* 清不掉的壳由真重复检查兜底 */ }
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
const highest = Math.max(...logs.map(generationVersionOf))
|
|
81
|
+
const onlyLogsAndLocalJunk = entries.every((name) =>
|
|
82
|
+
(REAL_LOG_RE.test(name) && !MOVE_TEMP_RE.test(name)) || SESSION_LOCAL_JUNK_RE.test(name))
|
|
83
|
+
if (Number.isSafeInteger(keepVersion) && highest < keepVersion && onlyLogsAndLocalJunk) {
|
|
84
|
+
try {
|
|
85
|
+
await rm(candidate, { recursive: true, force: true })
|
|
86
|
+
superseded.push(candidate)
|
|
87
|
+
} catch (e) { real.push(candidate) }
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
real.push(candidate)
|
|
91
|
+
}
|
|
92
|
+
return { removed, superseded, real }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 移动成功后的源目录收尾。目标侧已经 create + append + 校验出一份完整权威日志,
|
|
96
|
+
// 源目录必须**整体移除**:
|
|
97
|
+
// - 0.1.5 起官方发布新代不删旧代,若只搬走当前代,源目录里的旧代会让同 id 出现
|
|
98
|
+
// 在两个项目目录 → 官方 listArtifacts/stat 立刻报 duplicate(实测根因);
|
|
99
|
+
// - 目录本身已由 locateSessionArtifacts 校验过 basename === encodeSegment(id)
|
|
100
|
+
// 且 id 归属正确,删的是该会话自己的目录,不是"疑似别人的数据"。
|
|
101
|
+
// 返回 { cleaned, leftover }:cleaned=false + leftover 名单用于如实回报清理失败。
|
|
102
|
+
async function removeSourceDir(sessionDir) {
|
|
103
|
+
let entries
|
|
104
|
+
try { entries = await readdir(sessionDir) } catch (e) { return { cleaned: true, leftover: [] } }
|
|
105
|
+
try {
|
|
106
|
+
await rm(sessionDir, { recursive: true, force: true })
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return { cleaned: false, leftover: entries }
|
|
109
|
+
}
|
|
110
|
+
return { cleaned: true, leftover: [] }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 活跃写者(409)提示。0.1.3 起官方 session handle 是「单写者」:agent-loop
|
|
114
|
+
// 在 resume 一个会话时先 open(id,'write') 取写所有权并持有到 agent 卸载,所以
|
|
115
|
+
// 在 DSH 里点开过的会话都处于被占用状态。runtime 未提供任何能远程释放该所有权
|
|
116
|
+
// 的公共 API(无 unloadSession / closeSession / evict),因此插件不能替用户解锁,
|
|
117
|
+
// 只能如实说明可操作的自救路径,并靠「重试」让用户切走会话后原地再试一次。
|
|
118
|
+
const BUSY_MOVE_MESSAGE = '会话正被 DSH 打开,暂时无法移动;请重启 DSH 后再试。'
|
|
119
|
+
const BUSY_PURGE_MESSAGE = '会话正被 DSH 打开,暂时无法彻底删除;请重启 DSH 后再试。'
|
|
120
|
+
|
|
28
121
|
function conflictError(message) {
|
|
29
122
|
const error = new Error(message)
|
|
30
123
|
error.status = 409
|
|
@@ -56,7 +149,7 @@ export async function ensureNoActiveWriter(sp, sid) {
|
|
|
56
149
|
try {
|
|
57
150
|
handle = await sp.open(sid, 'write')
|
|
58
151
|
} catch (e) {
|
|
59
|
-
if (isAlreadyOwned(e)) throw conflictError(
|
|
152
|
+
if (isAlreadyOwned(e)) throw conflictError(BUSY_MOVE_MESSAGE)
|
|
60
153
|
throw e
|
|
61
154
|
}
|
|
62
155
|
await closeQuietly(handle)
|
|
@@ -75,7 +168,7 @@ export async function purgeSessionArtifacts(sp, sid, header) {
|
|
|
75
168
|
try {
|
|
76
169
|
writer = await sp.open(sid, 'write')
|
|
77
170
|
} catch (e) {
|
|
78
|
-
if (isAlreadyOwned(e)) throw conflictError(
|
|
171
|
+
if (isAlreadyOwned(e)) throw conflictError(BUSY_PURGE_MESSAGE)
|
|
79
172
|
throw e
|
|
80
173
|
}
|
|
81
174
|
// 句柄从未 append 过,先释放再删目录(Windows 上打开中的文件无法删除)。
|
|
@@ -104,8 +197,8 @@ export async function purgeSessionArtifacts(sp, sid, header) {
|
|
|
104
197
|
|
|
105
198
|
// frame0 改写回退:把备份日志的 frame0 cwd 改写为目标工作区后搬入目标会话目录。
|
|
106
199
|
// 校验与 legacy relocateLog 完全一致:帧数不变 + frame0 之外字节逐位相等。
|
|
107
|
-
async function relocateRewrittenBackup({ sid, canonical,
|
|
108
|
-
const original = await readFile(
|
|
200
|
+
async function relocateRewrittenBackup({ sid, canonical, backupLogPath, artifacts }) {
|
|
201
|
+
const original = await readFile(backupLogPath)
|
|
109
202
|
const frames = scanZstdFrames(original).frames
|
|
110
203
|
if (frames.length === 0) throw new Error('移动前校验失败:会话日志没有完整 zstd 帧')
|
|
111
204
|
const rewritten = rewriteFrame0CwdInMemory(original, canonical)
|
|
@@ -126,22 +219,82 @@ async function relocateRewrittenBackup({ sid, canonical, backupPath, artifacts }
|
|
|
126
219
|
// seeded(fork 溯源)日志的物理事件 seq 不从 0 起步时,官方 assertContiguous
|
|
127
220
|
// 会拒绝直录——此时把副本日志的 seq 重排为 0 起步(仅副本的存储序,事件内容
|
|
128
221
|
// 不变),并在 create 时如实携带 inheritedEventCount 溯源。
|
|
222
|
+
//
|
|
223
|
+
// 0.1.5 关键约束(实测):官方在 open(id,'write') 时会为历史代**发布当前代**
|
|
224
|
+
// (v0/v2 → v3)且**不删旧代**,一个会话目录里可同时有 v0/v2/v3;而官方
|
|
225
|
+
// listArtifacts/findLog 会跨项目目录解析同一个 id,**任意两个目录同时持有该 id
|
|
226
|
+
// 的日志(无论哪一代)都会抛 duplicate**。所以搬运必须是:
|
|
227
|
+
// 探测(会发布新代,必须先做)→ 定位 → 同 id 残留清理
|
|
228
|
+
// → **把整个源会话目录改名移开**(对官方彻底不可见,而不是只搬单个文件)
|
|
229
|
+
// → 目标侧 create+append+校验 → 成功则删除移开的源目录,失败则整体改回。
|
|
129
230
|
export async function moveSessionToCwd({ sp, sid, header, canonical, events = [], inheritedEventCount = 0 }) {
|
|
231
|
+
// 1) 写所有权探测放最前:它可能发布新代,之后定位到的才是权威路径。
|
|
232
|
+
// 非「已被占用」的探测失败(后端异常等)不在此处立即上抛——先看能否定位,
|
|
233
|
+
// 定位也失败时如实回报定位错误(提示更可操作),能定位时才报告写状态未知。
|
|
234
|
+
let probeError = null
|
|
235
|
+
try {
|
|
236
|
+
await ensureNoActiveWriter(sp, sid)
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (e && e.status === 409) throw e
|
|
239
|
+
probeError = e
|
|
240
|
+
}
|
|
130
241
|
const artifacts = await locateSessionArtifacts(sp, header)
|
|
131
242
|
if (!artifacts) {
|
|
132
243
|
const error = new Error('无法定位该会话的物理日志,已停止移动')
|
|
133
244
|
error.status = 409
|
|
134
245
|
throw error
|
|
135
246
|
}
|
|
136
|
-
|
|
247
|
+
if (probeError) {
|
|
248
|
+
const error = new Error('无法确认该会话是否正被写入,已停止移动:' + String((probeError && probeError.message) || probeError))
|
|
249
|
+
error.status = 409
|
|
250
|
+
throw error
|
|
251
|
+
}
|
|
252
|
+
// 数据安全守卫(最后一层保险):源日志明显不只是头部(> 4KB),但我们拿到的事件
|
|
253
|
+
// 是空的 —— 说明读取链路出了问题。此时若继续,就是"用空事件重建日志"再删掉源目录,
|
|
254
|
+
// 等于清空会话。2026-09-10 实测过一次(0.1.5 读取返回结构变更),绝不能再发生。
|
|
255
|
+
const HEADER_ONLY_MAX = 4096
|
|
256
|
+
if (events.length === 0) {
|
|
257
|
+
let sourceBytes = null
|
|
258
|
+
try { sourceBytes = (await stat(artifacts.logPath)).size } catch (e) { sourceBytes = null }
|
|
259
|
+
if (sourceBytes !== null && sourceBytes > HEADER_ONLY_MAX) {
|
|
260
|
+
const error = new Error(
|
|
261
|
+
`移动前校验失败:源日志有 ${sourceBytes} 字节但读到 0 条事件(读取链路异常),已停止移动以免丢失会话内容。`,
|
|
262
|
+
)
|
|
263
|
+
error.status = 409
|
|
264
|
+
error.code = 'DSM_MOVE_EMPTY_READ'
|
|
265
|
+
throw error
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// 2) 同 id 跨目录残留:空壳与被取代的旧代直接回收(会以 note 回报),
|
|
269
|
+
// 版本相同/更高的真重复拒绝移动并给出清单。必须在 rename 备份之前做——
|
|
270
|
+
// rename 后源目录暂时变成壳,会干扰判定。
|
|
271
|
+
const siblings = await cleanSiblingCopies(artifacts.root, sid, artifacts.sessionDir, artifacts.generationVersion)
|
|
272
|
+
if (siblings.real.length > 0) {
|
|
273
|
+
const error = new Error(
|
|
274
|
+
'无法移动:在其他工作区目录发现同一会话的日志副本,请先确认保留哪一份:\n' +
|
|
275
|
+
siblings.real.map((p) => '· ' + p).join('\n')
|
|
276
|
+
)
|
|
277
|
+
error.status = 409
|
|
278
|
+
error.code = 'DSM_SESSION_DUP_LOG'
|
|
279
|
+
throw error
|
|
280
|
+
}
|
|
137
281
|
const newHeader = Object.assign({}, header, { cwd: canonical })
|
|
138
282
|
const firstSeq = events.length ? Number(events[0].seq) : 0
|
|
139
283
|
const replay = firstSeq !== 0 ? events.map((event, index) => ({ ...event, seq: index })) : events
|
|
140
284
|
const createOptions = header.isSeeded && Number.isSafeInteger(inheritedEventCount) && inheritedEventCount > 0
|
|
141
285
|
? { inheritedEventCount }
|
|
142
286
|
: undefined
|
|
143
|
-
|
|
144
|
-
|
|
287
|
+
// 3) 把**整个源会话目录**移出 sessions 根,而不是只 rename 当前代那一个文件:
|
|
288
|
+
// - 只搬单个文件会留下并存的其他代(0.1.5 的 v0/v2/v3 多代共存),
|
|
289
|
+
// - 在本项目目录里改名也不够:官方 listSessionDirs 枚举项目目录下的**所有**
|
|
290
|
+
// 子目录(不按名字过滤),listArtifacts 会读每个目录里的日志头,仍会看到
|
|
291
|
+
// 同一个 id → duplicate。必须移到 root 之外(与回收站同级)才对官方扫描
|
|
292
|
+
// 彻底不可见。
|
|
293
|
+
const backupRoot = join(dirname(artifacts.root), 'sessions-manager-move-backup')
|
|
294
|
+
const backupDir = join(backupRoot, `${encodeSegmentFor(sid)}-${process.pid}-${Date.now()}`)
|
|
295
|
+
await mkdir(backupRoot, { recursive: true })
|
|
296
|
+
const backupLogPath = join(backupDir, basename(artifacts.logPath))
|
|
297
|
+
await rename(artifacts.sessionDir, backupDir)
|
|
145
298
|
let writer = null
|
|
146
299
|
try {
|
|
147
300
|
try {
|
|
@@ -156,7 +309,7 @@ export async function moveSessionToCwd({ sp, sid, header, canonical, events = []
|
|
|
156
309
|
if (isAlreadyExists(e)) {
|
|
157
310
|
// 后端内存里已有同 id 记录(created-but-unmaterialized 幽灵等):
|
|
158
311
|
// 回退到 frame0 改写搬运,不再走 create。
|
|
159
|
-
await relocateRewrittenBackup({ sid, canonical,
|
|
312
|
+
await relocateRewrittenBackup({ sid, canonical, backupLogPath, artifacts })
|
|
160
313
|
} else {
|
|
161
314
|
throw e
|
|
162
315
|
}
|
|
@@ -171,15 +324,22 @@ export async function moveSessionToCwd({ sp, sid, header, canonical, events = []
|
|
|
171
324
|
throw new Error(`移动后校验失败:事件数不一致(源 ${events.length},副本 ${after.eventCount})`)
|
|
172
325
|
}
|
|
173
326
|
} catch (e) {
|
|
174
|
-
//
|
|
327
|
+
// 回滚:清掉目标目录里的半成品,把整个源目录移回原位(多代日志一起复原)。
|
|
175
328
|
await closeQuietly(writer)
|
|
176
329
|
try { await rm(deriveSessionDir(artifacts.root, canonical, sid), { recursive: true, force: true }) } catch (_) {}
|
|
177
|
-
try { await
|
|
330
|
+
try { await mkdir(dirname(artifacts.sessionDir), { recursive: true }) } catch (_) {}
|
|
331
|
+
try { await rename(backupDir, artifacts.sessionDir) } catch (_) {}
|
|
178
332
|
if (e && e.status) throw e
|
|
179
333
|
const error = new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
180
334
|
error.status = 500
|
|
181
335
|
throw error
|
|
182
336
|
}
|
|
183
|
-
|
|
184
|
-
|
|
337
|
+
// 4) 成功:删掉移开的源目录(含全部旧代日志)。留着它等于让同一个 id 出现在
|
|
338
|
+
// 两个项目目录,下一次 create/list 立刻被后端 duplicate 检查拒绝。
|
|
339
|
+
const sourceCleanup = await removeSourceDir(backupDir)
|
|
340
|
+
return {
|
|
341
|
+
sessionDir: deriveSessionDir(artifacts.root, canonical, sid),
|
|
342
|
+
sourceCleanup,
|
|
343
|
+
reclaimedSiblings: siblings.superseded,
|
|
344
|
+
}
|
|
185
345
|
}
|
package/src/handle-era-paths.js
CHANGED
|
@@ -26,7 +26,13 @@ import { readdir, stat } from 'node:fs/promises'
|
|
|
26
26
|
import { basename, join } from 'node:path'
|
|
27
27
|
import { pathOwnsSession } from './path-guard.js'
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// 规范 generation 文件:官方 `session.vN.jsonl[.zstd]`,**v0 代无 `.vN.` 分量**
|
|
30
|
+
// (即 `session.jsonl[.zstd]`,legacy 时代产物)。官方
|
|
31
|
+
// `parseSessionFormatLogFilename` 把 v0 当 version 0 参与「最高代优先」,
|
|
32
|
+
// 所以这里也必须收 v0,否则「只有 v0 代」的会话会被误判为不可定位
|
|
33
|
+
// (移动/彻底删除静默禁用)。临时备份名(`.move-backup-…`)不以
|
|
34
|
+
// `.jsonl`/`.zstd` 结尾,天然不匹配。
|
|
35
|
+
const GENERATION_LOG_RE = /^session(\.v\d+)?\.jsonl(\.zst(d)?)?$/
|
|
30
36
|
|
|
31
37
|
function isSafeChar(ch) {
|
|
32
38
|
return ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)
|
|
@@ -80,8 +86,16 @@ export function deriveSessionDir(root, cwd, id) {
|
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
// 纯推导(不做磁盘校验)。供测试与上层组合使用。
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
// `generation` 只是名字里的 `.vN` 分量:0 = legacy 的 `session.jsonl`,2 = `session.v2.jsonl`。
|
|
90
|
+
// 实际落盘版本由 runtime 的格式目录决定,此处不假设“当前版本是几”。
|
|
91
|
+
export function deriveGenerationLogPath(root, cwd, id, { compression = 'zstd', generation = 2 } = {}) {
|
|
92
|
+
const base = generation === 0 ? 'session.jsonl' : `session.v${generation}.jsonl`
|
|
93
|
+
return join(deriveSessionDir(root, cwd, id), `${base}${compression === 'zstd' ? '.zstd' : ''}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 名字里的代版本号(`session.v3.jsonl.zstd` → 3;`session.jsonl.zstd` → 0)。
|
|
97
|
+
export function generationVersionOf(name) {
|
|
98
|
+
return Number((String(name).match(/^session\.v(\d+)\./) || [])[1] || 0)
|
|
85
99
|
}
|
|
86
100
|
|
|
87
101
|
// 定位一个已落盘会话的全部物理坐标;三层守卫在此汇合。返回
|
|
@@ -108,12 +122,8 @@ export async function locateSessionArtifacts(sp, header) {
|
|
|
108
122
|
}
|
|
109
123
|
const generationFiles = entries.filter((name) => GENERATION_LOG_RE.test(name))
|
|
110
124
|
if (generationFiles.length === 0) return null
|
|
111
|
-
// 优先 current generation(
|
|
112
|
-
generationFiles.sort((a, b) =>
|
|
113
|
-
const va = Number((a.match(/^session\.v(\d+)\./) || [])[1] || 0)
|
|
114
|
-
const vb = Number((b.match(/^session\.v(\d+)\./) || [])[1] || 0)
|
|
115
|
-
return vb - va
|
|
116
|
-
})
|
|
125
|
+
// 优先 current generation(v0/legacy 也是候选,版本号最小),保持确定性。
|
|
126
|
+
generationFiles.sort((a, b) => generationVersionOf(b) - generationVersionOf(a))
|
|
117
127
|
const logPath = join(sessionDir, generationFiles[0])
|
|
118
128
|
if (!pathOwnsSession(logPath, sid)) return null
|
|
119
129
|
return {
|
|
@@ -122,5 +132,8 @@ export async function locateSessionArtifacts(sp, header) {
|
|
|
122
132
|
sessionDir,
|
|
123
133
|
logPath,
|
|
124
134
|
generationFiles,
|
|
135
|
+
// 最高代版本号:0 = legacy `session.jsonl[.zstd]`,≥1 = `session.vN.jsonl[.zstd]`。
|
|
136
|
+
// 上层用它判断「另一个目录里的同 id 副本是不是被取代的旧代」。
|
|
137
|
+
generationVersion: generationVersionOf(generationFiles[0]),
|
|
125
138
|
}
|
|
126
139
|
}
|