dsh-recall-plugin 1.0.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,603 +1,39 @@
1
1
  /**
2
- * dsh-recall-plugin — Host 半(持久插件形态,bundle 行挂载)
2
+ * dsh-recall-plugin — Host 半入口(持久插件形态,bundle 行挂载)
3
3
  *
4
- * 职责:监听 session/event,为每条用户消息创建项目快照(独立影子 git 仓库,
5
- * 默认存于 DSH home,受限会话自动降级项目内并可迁回);
6
- * 通过 webServer 注册 /api/recall/* HTTP API,供 Client 半调用
7
- * (init / snapshot-info / preview / execute)。
4
+ * 职责:装配各域模块(store 执行存储层 / snapshots 快照域 /
5
+ * maintenance 维护域),通过 webServer 注册 /api/recall/* HTTP API
6
+ * Client 半调用(init / snapshot-info / preview / execute),
7
+ * 并接线 session/event 快照触发与启动预热。
8
8
  *
9
9
  * 这是持久 npm 插件包的主入口(exports["."]),由 cordis.patch.yml 的
10
10
  * insert 行挂载进 profile composition,DSH 重启后自动生效。
11
+ * 文件拆分见 lib/ 下各模块头注释;本文件只做接线,不承载业务逻辑。
11
12
  */
12
13
 
14
+ import { createRuntime } from './store.js'
15
+ import { createSnapshots } from './snapshots.js'
16
+ import { createMaintenance } from './maintenance.js'
17
+
13
18
  export const name = 'dsh-recall-plugin'
14
19
 
15
20
  // 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
16
21
  // webServer(Client 半的 HTTP API 通道)。其余服务按需 ctx.get。
17
22
  export const inject = ['shell', 'sessions', 'webServer']
18
23
 
19
- const MAX_FILE_BYTES = 104857600
20
- const HOME_RETRY_MS = 300000
21
-
22
- // 统一 UTF-8 输出前导:中文等非 ASCII 机器的默认代码页(如 GBK)下,
23
- // PowerShell 重定向 stdout 按 [Console]::OutputEncoding 编码,而 DSH 按
24
- // UTF-8 解码——不强制时 homeDirFor/resolveGit 输出里含中文的用户名/路径
25
- // 会变乱码,store 会指向错误目录。diff/rollback 脚本里早有同款设置,
26
- // 这里提到 runShell 全局注入一次,其余脚本不改自动受益(PS 5.1 / 7 均支持)。
27
- const UTF8_PRELUDE = '$OutputEncoding = [Text.UTF8Encoding]::new($false)\ntry { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}'
28
-
29
24
  export function apply(ctx) {
30
- const shell = ctx.shell
31
- const sessions = ctx.sessions
32
25
  const webServer = ctx.webServer
33
26
 
34
- const state = {
35
- roots: new Map(),
36
- stores: new Map(),
37
- snapshots: new Map(),
38
- queue: Promise.resolve(),
39
- indexLoaded: new Set(),
40
- gitReady: new Set(),
41
- cutSeqCache: new Map(),
42
- homeRetryAt: new Map(),
43
- gitExe: null
44
- }
27
+ const rt = createRuntime(ctx)
28
+ const snaps = createSnapshots(ctx, rt)
29
+ const maint = createMaintenance(ctx, rt, snaps)
30
+ const state = rt.state
45
31
 
46
- // Windows 平台干净降级:脚本体系(反斜杠路径、Windows git 安装候选、
47
- // Expand-Archive)绑定 Windows,硬跑不报错但会在项目里创建名字带反斜杠的
48
- // 垃圾目录。这里整体短路:init 返回 unsupported,Client 弹一次性提示;
32
+ // 平台门控:win32 PowerShell 模板,linux/darwin 走 bash 模板
33
+ // (ctx.shell 由 DSH 平台层单选挂载 pwsh/bash 执行器,见 dsh-shell README)。
34
+ // 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示;
49
35
  // 其余端点因无快照自然返回「没有可用快照」,全程零文件副作用。
50
- const supported = process.platform === 'win32'
51
-
52
- function psq(value) {
53
- return "'" + String(value).replace(/'/g, "''") + "'"
54
- }
55
-
56
- // 所有 shell 调用都以宿主身份(danger-full-access)执行,不借用会话沙箱。
57
- // 为什么安全:DSH 沙箱约束的是「模型驱动」的文件效果,而本插件的命令全部
58
- // 是宿主侧固定模板(建仓/快照/索引/回退),命令串里唯一变量是插件自己
59
- // 推导的路径(会话 cwd、哈希出的 store 路径、消息 ID),模型无法注入任何
60
- // 内容;快照落盘的也只是会话本就有权读取的工作区文件副本,不扩大能力。
61
- // 为什么必须如此:若按会话解析策略,workspace-write/read-only 会话写不了
62
- // home,快照被迫降级进项目目录(污染);read-only 会话连项目都写不了,
63
- // 回退恢复直接失败。pwsh-sandbox 对 danger-full-access 直接不约束(等价
64
- // 本地执行器),无沙箱后端的部署则忽略该字段,两种环境都成立。
65
- async function runShell(command, opts) {
66
- const sp = ctx.get('sandboxPolicy')
67
- const spec = shell.resolve({
68
- command: UTF8_PRELUDE + '\n' + command,
69
- timeoutMs: (opts && opts.timeoutMs) || 300000,
70
- stdoutMaxBytes: (opts && opts.stdoutMaxBytes) || 4194304,
71
- sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: (sp && sp.workspaceRoot) || process.cwd() }
72
- })
73
- const res = await shell.run(spec)
74
- const out = (res && res.stdout && res.stdout.text) || ''
75
- if (res && res.exitCode !== 0) {
76
- const err = ((res && res.stderr && res.stderr.text) || '').trim() || ('exit ' + String(res.exitCode))
77
- throw new Error(err.slice(0, 1500))
78
- }
79
- return out
80
- }
81
-
82
- function stripBom(text) {
83
- return text.replace(/^\uFEFF/, '')
84
- }
85
-
86
- async function resolveRoot(sessionId) {
87
- const key = sessionId ? String(sessionId) : 'fallback'
88
- const cached = state.roots.get(key)
89
- if (cached) return cached
90
- let root = null
91
- if (sessionId) {
92
- const session = sessions.get(sessionId)
93
- if (session && session.header && session.header.cwd) root = session.header.cwd
94
- }
95
- if (!root) {
96
- const sp = ctx.get('sandboxPolicy')
97
- if (sp && sp.workspaceRoot) root = sp.workspaceRoot
98
- }
99
- // 规范化尾部反斜杠(保留 "D:\" 三字符盘根形态,去掉会变成无效的 "D:"):
100
- // cwd 是否带尾斜杠由上游决定,不归一会让哈希输入不一致(换 store 目录),
101
- // 也会让大文件排除扫描的 Substring($root.Length + 1) 相对路径错一位。
102
- if (root && root.length > 3) root = root.replace(/\\+$/, '')
103
- if (root) state.roots.set(key, root)
104
- return root
105
- }
106
-
107
- // 解析 git 可执行文件路径:DSH 进程 PATH 可能不含 git,
108
- // 求值一次并缓存,脚本里用绝对路径调用,避免每条命令依赖 PATH。
109
- // 候选覆盖 PATH / 64 位 / 32 位 / 用户级(LocalAppData)四类安装位置,
110
- // 并用 -PathType Leaf 挡掉 PATH 里恰有名为 git 的目录这种极端情形。
111
- async function resolveGit() {
112
- if (state.gitExe !== null) return state.gitExe
113
- try {
114
- // 逐项判空再 Join-Path:个别 env 在特殊环境(32 位系统无
115
- // ProgramFiles(x86))取到 null,EAP=Stop 下 Join-Path 抛错会
116
- // 让整个探测失败、误报 gitMissing。
117
- const script = [
118
- '$candidates = @()',
119
- '$g = (Get-Command git -ErrorAction SilentlyContinue).Source',
120
- 'if ($g) { $candidates += $g }',
121
- "if (${env:ProgramFiles}) { $candidates += (Join-Path ${env:ProgramFiles} 'Git\\cmd\\git.exe') }",
122
- "if (${env:ProgramFiles(x86)}) { $candidates += (Join-Path ${env:ProgramFiles(x86)} 'Git\\cmd\\git.exe') }",
123
- "if (${env:LocalAppData}) { $candidates += (Join-Path ${env:LocalAppData} 'Programs\\Git\\cmd\\git.exe') }",
124
- "$g = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -First 1",
125
- 'if ($g) { Write-Output $g }'
126
- ].join('\n')
127
- const path = stripBom(await runShell(script, { stdoutMaxBytes: 4096 })).trim()
128
- state.gitExe = path || ''
129
- } catch (error) {
130
- state.gitExe = ''
131
- }
132
- return state.gitExe
133
- }
134
-
135
- // 计算项目对应的 home 存储目录(DSH_HOME 优先,否则 ~/.dsh)。
136
- // 哈希用 Create()+ComputeHash+BitConverter 而不是 HashData+ToHexString:
137
- // 后两者是 .NET 5+(仅 PS 7)API,别人机器的 shell 若是 Windows PowerShell
138
- // 5.1 会抛错,导致 home 存储永远降级到项目内;前者两个版本都可用。
139
- async function homeDirFor(root, sessionId) {
140
- // DSH 的 pwsh 子进程按白名单重建 env,会话级导出的 DSH_HOME 传不进去;
141
- // 主进程(node)仍能看到它,作为字面量回退注入,保证「DSH_HOME 指到哪、
142
- // 快照就存哪」在任意导出层级下都成立。
143
- const envHome = (process.env && process.env.DSH_HOME) || ''
144
- const dirScript = [
145
- '$r = ' + psq(root),
146
- "$h = if ($env:DSH_HOME) { $env:DSH_HOME } elseif (" + psq(envHome) + ") { " + psq(envHome) + " } else { Join-Path $env:USERPROFILE \".dsh\" }",
147
- '$sha = [Security.Cryptography.SHA256]::Create()',
148
- "$hex = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($r))) -replace '-','').ToLower()",
149
- "Write-Output (Join-Path $h ('dsh-recall-snapshots\\' + $hex))"
150
- ].join('\n')
151
- const text = stripBom(await runShell(dirScript, { stdoutMaxBytes: 4096 })).trim()
152
- if (!text) return null
153
- // PS 的 Join-Path 可能带出连续反斜杠(旧版脚本遗留 "…\\<hex>" 形态);
154
- // 折叠成单反斜杠。Windows 把中间的重复分隔符视为同一个目录,
155
- // 所以与既有快照数据(index.json、影子 git 仓库)路径完全兼容。
156
- // 开头的双反斜杠是 UNC 前缀(DSH_HOME/用户主目录指到网络盘),
157
- // 折叠掉会把 \\server\share 变成无效的 \server\share,必须原样保留。
158
- if (/^\\\\/.test(text)) return '\\\\' + text.slice(2).replace(/\\{2,}/g, '\\')
159
- return text.replace(/\\{2,}/g, '\\')
160
- }
161
-
162
- // 存储根:优先放 DSH home(保持项目目录干净)。shell 以宿主身份执行,
163
- // 受限会话(workspace-write/read-only)也能写 home;只有 home 本身不可写
164
- // (如 DSH_HOME 指向只读/网络盘)才降级到项目内(功能优先于干净)。
165
- // git init <dir> 会把真实 git-dir 建在 <dir>\.git,所以 store.repo 是
166
- // 仓库工作目录、store.git 是真实 git-dir——冒烟测试踩过的坑。
167
- async function resolveStore(root, sessionId) {
168
- const cached = state.stores.get(root)
169
- if (cached) return cached
170
- let homeDir = null
171
- try {
172
- homeDir = await homeDirFor(root, sessionId)
173
- } catch (error) {
174
- homeDir = null
175
- }
176
- if (homeDir) {
177
- try {
178
- await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 })
179
- const store = { dir: homeDir, repo: homeDir + '\\git', git: homeDir + '\\git\\.git', home: true }
180
- state.stores.set(root, store)
181
- return store
182
- } catch (error) {
183
- console.error('recall home store unavailable, falling back to workspace:', String(error))
184
- }
185
- }
186
- const fallback = root + '\\.dsh-recall-snapshots'
187
- await runShell('New-Item -ItemType Directory -Force -Path ' + psq(fallback) + ' | Out-Null', { stdoutMaxBytes: 4096 })
188
- const store = { dir: fallback, repo: fallback + '\\git', git: fallback + '\\git\\.git', home: false }
189
- state.stores.set(root, store)
190
- return store
191
- }
192
-
193
- // 旧版迁移:宿主身份执行前的版本在受限会话里会把影子仓库降级到项目内,
194
- // 这里在下一条消息快照前把它整体迁回 home 并删除项目内目录,恢复
195
- // 「项目目录干净」。失败节流 5 分钟,避免 home 不可写时每条消息白试。
196
- async function tryUpgradeToHome(root, sessionId) {
197
- const store = state.stores.get(root)
198
- if (!store || store.home) return store
199
- const now = Date.now()
200
- const last = state.homeRetryAt.get(root) || 0
201
- if (now - last < HOME_RETRY_MS) return store
202
- state.homeRetryAt.set(root, now)
203
- let homeDir = null
204
- try {
205
- homeDir = await homeDirFor(root, sessionId)
206
- } catch (error) {
207
- homeDir = null
208
- }
209
- if (!homeDir) return store
210
- try {
211
- await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 })
212
- const migrate = [
213
- "$ErrorActionPreference = 'Stop'",
214
- '$src = ' + psq(store.dir),
215
- '$dst = ' + psq(homeDir),
216
- "if (Test-Path -LiteralPath (Join-Path $src 'git')) { Move-Item -LiteralPath (Join-Path $src 'git') -Destination (Join-Path $dst 'git') -Force }",
217
- "if (Test-Path -LiteralPath (Join-Path $src 'index.json')) { Move-Item -LiteralPath (Join-Path $src 'index.json') -Destination (Join-Path $dst 'index.json') -Force }",
218
- 'Remove-Item -Recurse -Force -LiteralPath $src -ErrorAction SilentlyContinue',
219
- "Write-Output 'MIGRATE_OK'"
220
- ].join('\n')
221
- await runShell(migrate, { timeoutMs: 300000, stdoutMaxBytes: 4096 })
222
- const upgraded = { dir: homeDir, repo: homeDir + '\\git', git: homeDir + '\\git\\.git', home: true }
223
- state.stores.set(root, upgraded)
224
- state.gitReady.delete(store.git)
225
- console.error('recall store upgraded to home:', root)
226
- return upgraded
227
- } catch (error) {
228
- console.error('recall home upgrade failed:', String(error))
229
- return store
230
- }
231
- }
232
-
233
- // 建立影子仓库:普通 init(index 留在仓库内跨快照复用,git add 的 stat 缓存
234
- // 让未变文件近乎零成本),core.longpaths 放开 Windows 深路径,
235
- // info/exclude 排除 .git(项目是 git 仓库时)、node_modules、降级时的自目录。
236
- // 幂等:gitReady 命中后直接跳过,省掉每条消息一次的 config/exclude 重写。
237
- async function ensureGit(root, store, sessionId) {
238
- if (state.gitReady.has(store.git)) return true
239
- const gitExe = await resolveGit()
240
- if (!gitExe) return false
241
- const script = [
242
- "$ErrorActionPreference = 'Stop'",
243
- // 不设 PSNativeCommandUseErrorActionPreference:git 的 stderr 警告(hint 等)
244
- // 在 DSH shell 注入方式下会被包成 ErrorRecord,配上 EAP=Stop 直接终止脚本;
245
- // 真正的 git 失败由 runShell 统一按 exitCode 检测抛错,不依赖这里。
246
- '$git = ' + psq(gitExe),
247
- '$repo = ' + psq(store.repo),
248
- '$g = ' + psq(store.git),
249
- 'if (-not (Test-Path -LiteralPath $g)) {',
250
- ' & $git init $repo | Out-Null',
251
- '}',
252
- '& $git --git-dir=$g config core.longpaths true',
253
- // autocrlf=false:按原始字节入快照(回退时逐字节还原),也避免
254
- // 用户全局 autocrlf=true 时的 LF/CRLF stderr 警告;addEmbeddedRepo=false:
255
- // 嵌套仓库 hint/warning 走 stderr,在 DSH shell(EAP=Stop)下会让
256
- // 整条脚本非零退出,必须在仓库级配置里静默掉。
257
- '& $git --git-dir=$g config core.autocrlf false',
258
- '& $git --git-dir=$g config advice.addEmbeddedRepo false',
259
- "Set-Content -LiteralPath (Join-Path $g 'info\\exclude') -Value \"`n.git`nnode_modules/`n.dsh-recall-snapshots/\" -Encoding utf8 -NoNewline",
260
- "Write-Output 'GIT_OK'"
261
- ].join('\n')
262
- try {
263
- await runShell(script, { stdoutMaxBytes: 4096 })
264
- state.gitReady.add(store.git)
265
- return true
266
- } catch (error) {
267
- console.error('recall ensureGit failed:', String(error))
268
- return false
269
- }
270
- }
271
-
272
- // 快照:git add -A 增量同步 index(.gitignore/exclude 语义由 git 统一处理),
273
- // 剔除超大文件(参考 TraeWork update_snapshot_file_over_size 的跳过策略),
274
- // write-tree 生成树、commit-tree 生成无父孤儿提交、tag 保对象可达。
275
- // 不做 parent 链、不修剪:像 TraeWork 一样保留全量历史,tag 永远可查。
276
- //
277
- // 嵌套 git 仓库(工作区里的子项目自带 .git)会被 add -A 记成 gitlink(160000);
278
- // gitlink 残留在 index 时 add -A 会 fatal "in unpopulated submodule",
279
- // 且 gitlink 对文件回退毫无意义——所以 add 前后各清一次,子仓库内容不进快照。
280
- // 依赖外层脚本已定义的 $git/$g;返回的行片段嵌入 snapshot/diff/rollback 三处
281
- function dropGitlinksScript() {
282
- return [
283
- "& $git --git-dir=$g ls-files --stage | Where-Object { $_ -like '160000*' } | ForEach-Object {",
284
- " $p = ($_ -split \"`t\")[1]",
285
- ' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $p',
286
- '}'
287
- ].join('\n')
288
- }
289
-
290
- function snapshotScript(root, store, gitExe, messageId) {
291
- const sync = [
292
- "$ErrorActionPreference = 'Stop'",
293
- // 不设 PSNativeCommandUseErrorActionPreference:git 的 stderr 警告(hint 等)
294
- // 在 DSH shell 注入方式下会被包成 ErrorRecord,配上 EAP=Stop 直接终止脚本;
295
- // 真正的 git 失败由 runShell 统一按 exitCode 检测抛错,不依赖这里。
296
- '$git = ' + psq(gitExe),
297
- '$g = ' + psq(store.git),
298
- '$root = ' + psq(root),
299
- dropGitlinksScript(),
300
- '& $git --git-dir=$g --work-tree=$root add -A',
301
- dropGitlinksScript(),
302
- // 扫描加 SilentlyContinue:EAP=Stop 下个别不可访问子目录(杀软锁定、
303
- // 异常 ACL、损坏 junction)的非致命错误会被升级为终止,整条快照作废;
304
- // 本扫描只用于排除超大文件,漏看个别文件是 fail-open,可接受。
305
- 'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
306
- " $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
307
- ' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
308
- '}',
309
- '$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()',
310
- "$commit = (& $git --git-dir=$g -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree $tree -m ('snapshot ' + " + psq(messageId) + ")).Trim()",
311
- '& $git --git-dir=$g tag ' + psq('snap-' + messageId) + ' $commit | Out-Null',
312
- "Write-Output 'SNAP_OK'"
313
- ]
314
- return sync.join('\n')
315
- }
316
-
317
- // diff:把当前状态 add 进 index 后用 ls-files --stage 取当前清单,
318
- // 与目标 tag 的 ls-tree 对比——ignore/exclude 语义两侧一致,不会把
319
- // node_modules 等误报为“新增”。
320
- // 不用 -z:PowerShell 捕获原生命令输出会丢弃含 NUL 的行(实测整段变 null),
321
- // 改用 core.quotePath=false 让非 ASCII 路径原样输出,逐行按 TAB 解析;
322
- // [Console]::OutputEncoding=UTF8 保证中文路径正确解码。
323
- // 代价是文件名含换行的极端情况会解析错乱——概率可忽略,记录为已知限制。
324
- function diffScript(root, store, gitExe, tag) {
325
- const script = [
326
- "$ErrorActionPreference = 'Stop'",
327
- '$OutputEncoding = [Text.UTF8Encoding]::new($false)',
328
- 'try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}',
329
- '$git = ' + psq(gitExe),
330
- '$g = ' + psq(store.git),
331
- '$root = ' + psq(root),
332
- dropGitlinksScript(),
333
- '& $git --git-dir=$g --work-tree=$root add -A',
334
- dropGitlinksScript(),
335
- 'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
336
- " $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
337
- ' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
338
- '}',
339
- '$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
340
- // 旧 tag 的树里可能仍有 gitlink(修复前留下的),从目标侧一并剔除,
341
- // 否则 diff 会报出“恢复 dsh-recall-plugin”这类幻影条目
342
- "$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
343
- '$curMap = @{}',
344
- 'foreach ($r in @($curOut)) {',
345
- ' if (-not $r) { continue }',
346
- ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
347
- ' $sha = ($r.Substring(0, $tab) -split " ")[1]',
348
- ' $curMap[$path] = $sha',
349
- '}',
350
- '$targetMap = @{}',
351
- 'foreach ($r in @($targetOut)) {',
352
- ' if (-not $r) { continue }',
353
- ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
354
- ' $sha = ($r.Substring(0, $tab) -split " ")[2]',
355
- ' $targetMap[$path] = $sha',
356
- '}',
357
- '$result = @()',
358
- 'foreach ($k in $curMap.Keys) {',
359
- ' if (-not $targetMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "added" } }',
360
- ' elseif ($targetMap[$k] -ne $curMap[$k]) { $result += [pscustomobject]@{ rel = $k; kind = "modified" } }',
361
- '}',
362
- 'foreach ($k in $targetMap.Keys) {',
363
- ' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" } }',
364
- '}',
365
- '$sorted = @($result | Sort-Object rel)',
366
- 'Write-Output (ConvertTo-Json -InputObject $sorted -Depth 3 -Compress)'
367
- ]
368
- return script.join('\n')
369
- }
370
-
371
- // 回退:archive 生成 zip 直接落盘(二进制不经 shell 文本管道),
372
- // Expand-Archive 覆盖回工作区;再删除“当前有、目标无”的文件。
373
- // 空树跳过 archive(空 zip 会让 Expand-Archive 报错),只执行删除。
374
- // 回退后保留快照 tag 与索引:git delta 空间便宜,保留历史可再次
375
- // 用该快照恢复(幂等),也避免误回退后无法找回。
376
- function rollbackScript(root, store, gitExe, tag) {
377
- const script = [
378
- "$ErrorActionPreference = 'Stop'",
379
- '$OutputEncoding = [Text.UTF8Encoding]::new($false)',
380
- 'try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}',
381
- '$git = ' + psq(gitExe),
382
- '$g = ' + psq(store.git),
383
- '$root = ' + psq(root),
384
- dropGitlinksScript(),
385
- '& $git --git-dir=$g --work-tree=$root add -A',
386
- dropGitlinksScript(),
387
- 'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
388
- " $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
389
- ' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
390
- '}',
391
- // 同 diffScript:-z 的 NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
392
- '$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
393
- // 旧 tag 的树里可能仍有 gitlink(修复前留下的),从目标侧一并剔除,
394
- // 否则 diff 会报出“恢复 dsh-recall-plugin”这类幻影条目
395
- "$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
396
- '$targetMap = @{}',
397
- 'foreach ($r in @($targetOut)) {',
398
- ' if (-not $r) { continue }',
399
- ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
400
- ' $targetMap[$path] = $true',
401
- '}',
402
- '$restored = $targetMap.Count',
403
- 'if ($restored -gt 0) {',
404
- ' $zip = ' + psq(store.dir + '\\restore-tmp.zip'),
405
- ' & $git --git-dir=$g archive --format=zip --output=$zip ' + psq(tag),
406
- ' Expand-Archive -LiteralPath $zip -DestinationPath $root -Force',
407
- ' Remove-Item -LiteralPath $zip -Force',
408
- '}',
409
- '$deleted = 0',
410
- 'foreach ($r in @($curOut)) {',
411
- ' if (-not $r) { continue }',
412
- ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
413
- ' if (-not $targetMap.ContainsKey($path)) {',
414
- " $full = Join-Path $root ($path.Replace('/','\\'))",
415
- ' if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Force; $deleted++ }',
416
- ' }',
417
- '}',
418
- "Write-Output ('ROLLBACK_OK ' + $deleted + ' ' + $restored)"
419
- ]
420
- return script.join('\n')
421
- }
422
-
423
- function listTagsScript(store, gitExe) {
424
- return [
425
- "$ErrorActionPreference = 'Stop'",
426
- // 不设 PSNativeCommandUseErrorActionPreference:git 的 stderr 警告(hint 等)
427
- // 在 DSH shell 注入方式下会被包成 ErrorRecord,配上 EAP=Stop 直接终止脚本;
428
- // 真正的 git 失败由 runShell 统一按 exitCode 检测抛错,不依赖这里。
429
- '$git = ' + psq(gitExe),
430
- '$g = ' + psq(store.git),
431
- '& $git --git-dir=$g tag -l "snap-*"'
432
- ].join('\n')
433
- }
434
-
435
- // 索引写入合并为单次 shell 调用:pwsh 进程启动是主要耗时,能省一次是一次。
436
- // base64 以内联字面量传递时受 Windows 命令行 32767 字符硬上限约束(DSH 的
437
- // pwsh 执行器把命令串作为 -Command 的单个 argv 元素 spawn),快照攒到几百条
438
- // 就会超限 spawn 失败——按 20000 字符分块,首块 Set-Content、续块 Add-Content,
439
- // 常规体量仍是单次调用,超限后自动多写几块。
440
- async function saveIndex(root, sessionId) {
441
- const store = state.stores.get(root)
442
- if (!store) return
443
- const entries = Array.from(state.snapshots.entries())
444
- .filter(([, s]) => s.root === root)
445
- .map(([id, s]) => ({ id, time: s.time, count: s.count, sessionId: s.sessionId }))
446
- const json = JSON.stringify(entries)
447
- try {
448
- const b64 = Buffer.from(json, 'utf8').toString('base64')
449
- const file = psq(store.dir + '\\index.json')
450
- let first = true
451
- for (let i = 0; i < b64.length; i += 20000) {
452
- const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64.slice(i, i + 20000) + "')) | "
453
- const cmd = first
454
- ? 'New-Item -ItemType Directory -Force -Path ' + psq(store.dir) + ' | Out-Null; ' + piece + 'Set-Content -LiteralPath ' + file + ' -Encoding utf8 -NoNewline'
455
- : piece + 'Add-Content -LiteralPath ' + file + ' -Encoding utf8 -NoNewline'
456
- await runShell(cmd, { stdoutMaxBytes: 4096 })
457
- first = false
458
- }
459
- } catch (error) {
460
- console.error('recall saveIndex failed:', String(error))
461
- }
462
- }
463
-
464
- async function loadIndex(root, sessionId) {
465
- if (state.indexLoaded.has(root)) return
466
- state.indexLoaded.add(root)
467
- const store = state.stores.get(root)
468
- if (!store) return
469
- try {
470
- const raw = stripBom(await runShell('Get-Content -LiteralPath ' + psq(store.dir + '\\index.json') + ' -Raw -ErrorAction SilentlyContinue', { stdoutMaxBytes: 4194304 })).trim()
471
- if (!raw) return
472
- const entries = JSON.parse(raw)
473
- if (!Array.isArray(entries)) return
474
- for (const entry of entries) {
475
- if (!entry || typeof entry.id !== 'string') continue
476
- state.snapshots.set(entry.id, {
477
- root,
478
- time: typeof entry.time === 'number' ? entry.time : Date.now(),
479
- count: typeof entry.count === 'number' ? entry.count : 0,
480
- sessionId: entry.sessionId || sessionId
481
- })
482
- }
483
- } catch (error) {
484
- /* 索引缺失或损坏时按空历史处理 */
485
- }
486
- }
487
-
488
- // 索引丢失时从仓库 tag 重建:tag 名 snap-<messageId> 本身就是快照主键
489
- async function rebuildOrphans(root, sessionId) {
490
- const store = state.stores.get(root)
491
- const gitExe = await resolveGit()
492
- if (!store || !gitExe) return
493
- try {
494
- const listing = stripBom(await runShell(listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
495
- if (!listing) return
496
- for (const name of listing.split(/\r?\n/)) {
497
- const id = name.trim().replace(/^snap-/, '')
498
- if (!id || state.snapshots.has(id)) continue
499
- state.snapshots.set(id, { root, time: 0, count: 0, sessionId })
500
- }
501
- await saveIndex(root, sessionId)
502
- } catch (error) {
503
- console.error('recall rebuildOrphans failed:', String(error))
504
- }
505
- }
506
-
507
- // 迁移收尾:删除旧版 blobs 格式的项目内 .dsh-recall-snapshots 目录,
508
- // 仅在 home 存储可用时执行——降级场景下该目录就是新 store,不能删。
509
- function cleanupLegacy(root, sessionId) {
510
- const store = state.stores.get(root)
511
- if (!store || !store.home) return
512
- runShell('Remove-Item -Recurse -Force -LiteralPath ' + psq(root + '\\.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
513
- }
514
-
515
- async function captureSnapshot(sessionId, messageId, time) {
516
- const root = await resolveRoot(sessionId)
517
- if (!root) return
518
- let store = await resolveStore(root, sessionId)
519
- store = await tryUpgradeToHome(root, sessionId)
520
- const ok = await ensureGit(root, store, sessionId)
521
- if (!ok) return
522
- await loadIndex(root, sessionId)
523
- try {
524
- await runShell(snapshotScript(root, store, state.gitExe, messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
525
- state.snapshots.set(String(messageId), { root, time: time || Date.now(), count: 0, sessionId })
526
- await saveIndex(root, sessionId)
527
- } catch (error) {
528
- console.error('recall snapshot failed:', String(error))
529
- }
530
- }
531
-
532
- async function diffFor(messageId) {
533
- const snap = state.snapshots.get(String(messageId))
534
- if (!snap) return null
535
- const store = state.stores.get(snap.root)
536
- if (!store) return null
537
- const text = stripBom(await runShell(diffScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 4194304 }))
538
- const trimmed = text.trim()
539
- if (!trimmed) return []
540
- const parsed = JSON.parse(trimmed)
541
- if (Array.isArray(parsed)) return parsed
542
- if (parsed && typeof parsed === 'object') return [parsed]
543
- return []
544
- }
545
-
546
- async function rollbackFor(messageId) {
547
- const snap = state.snapshots.get(String(messageId))
548
- if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
549
- const store = state.stores.get(snap.root)
550
- if (!store) return { ok: false, error: '快照存储不可用' }
551
- const text = stripBom(await runShell(rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
552
- const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
553
- const deleted = m ? parseInt(m[1], 10) : 0
554
- const restored = m ? parseInt(m[2], 10) : 0
555
- return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
556
- }
557
-
558
- // 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
559
- function scanCutSeq(events, messageId) {
560
- let anchor = -1
561
- for (let i = 0; i < events.length; i++) {
562
- const e = events[i]
563
- if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
564
- anchor = i
565
- break
566
- }
567
- }
568
- if (anchor < 0) return null
569
- for (let i = anchor - 1; i >= 0; i--) {
570
- const e = events[i]
571
- if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
572
- }
573
- return null
574
- }
575
-
576
- // 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
577
- // 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
578
- // 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
579
- async function resolveCutSeq(sessionId, messageId) {
580
- if (!sessionId || !messageId) return null
581
- const cacheKey = String(sessionId) + '\u0000' + String(messageId)
582
- if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
583
- let result = null
584
- const live = sessions.get(sessionId)
585
- if (live && Array.isArray(live.events)) {
586
- result = scanCutSeq(live.events, messageId)
587
- } else {
588
- const query = ctx.get('sessionQuery')
589
- if (query) {
590
- try {
591
- const log = await query.readSession(sessionId)
592
- result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
593
- } catch (error) {
594
- result = null
595
- }
596
- }
597
- }
598
- state.cutSeqCache.set(cacheKey, result)
599
- return result
600
- }
36
+ const supported = process.platform === 'win32' || process.platform === 'linux' || process.platform === 'darwin'
601
37
 
602
38
  // ---- HTTP API(Client 半经由 fetch 调用;动态插件的 harness RPC 在此换成 webServer 路由)----
603
39
 
@@ -628,15 +64,15 @@ export function apply(ctx) {
628
64
  return
629
65
  }
630
66
  const sessionId = args && args.sessionId ? String(args.sessionId) : null
631
- const root = await resolveRoot(sessionId)
67
+ const root = await rt.resolveRoot(sessionId)
632
68
  let notice = null
633
69
  if (root) {
634
- let store = await resolveStore(root, sessionId)
635
- store = await tryUpgradeToHome(root, sessionId)
636
- await ensureGit(root, store, sessionId)
637
- await loadIndex(root, sessionId)
638
- await rebuildOrphans(root, sessionId)
639
- cleanupLegacy(root, sessionId)
70
+ let store = await rt.resolveStore(root)
71
+ store = await rt.tryUpgradeToHome(root)
72
+ await rt.ensureGit(root, store)
73
+ await snaps.loadIndex(root, sessionId)
74
+ await snaps.rebuildOrphans(root, sessionId)
75
+ rt.cleanupLegacy(root)
640
76
  // 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
641
77
  // gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
642
78
  // 不可写,快照降级存进项目内 .dsh-recall-snapshots。
@@ -659,10 +95,10 @@ export function apply(ctx) {
659
95
  const id = args && args.messageId ? String(args.messageId) : ''
660
96
  const sessionId = args && args.sessionId ? String(args.sessionId) : null
661
97
  try {
662
- const changes = await diffFor(id)
98
+ const changes = await snaps.diffFor(id)
663
99
  if (changes === null) { sendJson(res, 200, { ok: false, error: '该消息没有可用的项目快照' }); return }
664
100
  const snap = state.snapshots.get(id)
665
- const cutSeq = await resolveCutSeq(sessionId, id)
101
+ const cutSeq = await snaps.resolveCutSeq(sessionId, id)
666
102
  sendJson(res, 200, { ok: true, changes, time: snap ? snap.time : null, root: snap ? snap.root : null, cutSeq })
667
103
  } catch (error) {
668
104
  sendJson(res, 200, { ok: false, error: String(error) })
@@ -673,10 +109,10 @@ export function apply(ctx) {
673
109
  const id = args && args.messageId ? String(args.messageId) : ''
674
110
  const sessionId = args && args.sessionId ? String(args.sessionId) : null
675
111
  try {
676
- const result = await rollbackFor(id)
112
+ const result = await snaps.rollbackFor(id)
677
113
  if (!result.ok) { sendJson(res, 200, result); return }
678
114
  // 文件回退后再解析切点:切点只依赖会话日志,与快照是否删除无关(命中缓存,瞬时)
679
- const cutSeq = await resolveCutSeq(sessionId, id)
115
+ const cutSeq = await snaps.resolveCutSeq(sessionId, id)
680
116
  sendJson(res, 200, { ok: true, count: result.count, cutSeq })
681
117
  } catch (error) {
682
118
  sendJson(res, 200, { ok: false, error: String(error) })
@@ -693,7 +129,8 @@ export function apply(ctx) {
693
129
  // 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
694
130
  if (!supported) return
695
131
 
696
- // 每条用户消息触发快照(子代理会话跳过)
132
+ // 每条用户消息触发快照(子代理会话跳过);快照完成后串行接一次维护
133
+ // (定期 gc / 会话清理)——排在同一条队列里,与快照天然互斥,无 git 锁竞态
697
134
  ctx.on('session/event', (session, event) => {
698
135
  if (!event || event.type !== 'user/message') return
699
136
  const data = event.data
@@ -704,22 +141,24 @@ export function apply(ctx) {
704
141
  const messageId = data.id
705
142
  const time = event.time
706
143
  state.queue = state.queue
707
- .then(() => captureSnapshot(session.id, messageId, time))
144
+ .then(() => snaps.captureSnapshot(session.id, messageId, time))
145
+ .then(() => maint.maybeMaintain(session.id))
708
146
  .catch((error) => console.error('recall snapshot error:', String(error)))
709
147
  })
710
148
 
711
149
  // 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,
712
- // 并清理旧版项目内 blobs 目录(home 可用时)
713
- for (const session of sessions.list()) {
150
+ // 并清理旧版项目内 blobs 目录(home 可用时)。
151
+ // 不触发维护(gc/清理):开机预热应尽量轻,重活等第一条消息再按节流来。
152
+ for (const session of ctx.sessions.list()) {
714
153
  const cwd = session && session.header && session.header.cwd
715
154
  if (!cwd) continue
716
155
  const sessionId = session.id
717
- Promise.resolve(resolveStore(cwd, sessionId))
718
- .then(() => tryUpgradeToHome(cwd, sessionId))
719
- .then((store) => ensureGit(cwd, store, sessionId))
720
- .then(() => loadIndex(cwd, sessionId))
721
- .then(() => rebuildOrphans(cwd, sessionId))
722
- .then(() => cleanupLegacy(cwd, sessionId))
156
+ Promise.resolve(rt.resolveStore(cwd))
157
+ .then(() => rt.tryUpgradeToHome(cwd))
158
+ .then((store) => rt.ensureGit(cwd, store))
159
+ .then(() => snaps.loadIndex(cwd, sessionId))
160
+ .then(() => snaps.rebuildOrphans(cwd, sessionId))
161
+ .then(() => rt.cleanupLegacy(cwd))
723
162
  .catch(() => {})
724
163
  }
725
164
  }