dsh-recall-plugin 1.6.1 → 1.7.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.
@@ -177,7 +177,24 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
177
177
  '$root = ' + psq(root),
178
178
  dropGitlinksBlock(),
179
179
  excludeSyncBlock(store.excludeFile, base),
180
- '& $git --git-dir=$g --work-tree=$root add -A',
180
+ // fail-open add(issue #7 加固):--ignore-errors 让「个别路径无法索引」
181
+ // (无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘,
182
+ // 快照缺个别路径可接受,好过整条快照 fatal。退出码 ≥2 才是真 fatal
183
+ // (磁盘满、index.lock 等),必须显式 throw:pwsh 对原生命令非零退出
184
+ // 不抛(EAP 不作用于 native),不检查就会带着未更新的旧索引走完
185
+ // write-tree/commit/tag,产出「空树假成功」快照(实测 PS 5.1/pwsh 7
186
+ // 均如此)。add 输出临时降到 Continue 再 2>&1 捕获:合并进管道会把
187
+ // native stderr 包装成 ErrorRecord,EAP=Stop 下直接抛 NativeCommandError,
188
+ // 而 PS 5.1 的 SilentlyContinue 会把合并流里的记录整个丢弃(实测 LOG
189
+ // 为空)——Continue 是两个版本下唯一都能拿到 stderr 文本的取值。捕获
190
+ // 后按 "unable to index file 'X'" 提取被跳过的路径,以 SNAP_SKIP 行
191
+ // 回传 JS 侧做用户可见提示。
192
+ "$ErrorActionPreference = 'Continue'",
193
+ "$addLog = (@(& $git --git-dir=$g --work-tree=$root add -A --ignore-errors 2>&1) | ForEach-Object { [string]$_ }) -join \"`n\"",
194
+ '$addRc = $LASTEXITCODE',
195
+ "$ErrorActionPreference = 'Stop'",
196
+ 'if ($addRc -ge 2) { throw ("git add fatal (exit " + $addRc + "): " + $addLog) }',
197
+ "foreach ($m in [regex]::Matches($addLog, \"unable to index file '([^']+)'\") ) { Write-Output ('SNAP_SKIP ' + $m.Groups[1].Value) }",
181
198
  dropGitlinksBlock(),
182
199
  oversizeBlock(store.maxFileBytes),
183
200
  '$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()',
@@ -202,7 +219,12 @@ export function diffScript(root, store, gitExe, tag, base) {
202
219
  '$root = ' + psq(root),
203
220
  dropGitlinksBlock(),
204
221
  excludeSyncBlock(store.excludeFile, base),
205
- '& $git --git-dir=$g --work-tree=$root add -A',
222
+ // fail-open add:语义同 snapshotScript(--ignore-errors 跳过无法索引的
223
+ // 路径、≥2 显式 throw 防「旧索引假成功」);此处不提取 SNAP_SKIP——
224
+ // 被跳过的路径不进索引,diff 天然不显示、rollback 的删除清单来自当前
225
+ // 索引也天然不会误删它们
226
+ '& $git --git-dir=$g --work-tree=$root add -A --ignore-errors',
227
+ 'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
206
228
  dropGitlinksBlock(),
207
229
  oversizeBlock(store.maxFileBytes),
208
230
  '$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
@@ -253,7 +275,9 @@ export function rollbackScript(root, store, gitExe, tag, base) {
253
275
  '$root = ' + psq(root),
254
276
  dropGitlinksBlock(),
255
277
  excludeSyncBlock(store.excludeFile, base),
256
- '& $git --git-dir=$g --work-tree=$root add -A',
278
+ // fail-open add:语义同 snapshotScript 同款注释(diff/rollback 复用)
279
+ '& $git --git-dir=$g --work-tree=$root add -A --ignore-errors',
280
+ 'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
257
281
  dropGitlinksBlock(),
258
282
  oversizeBlock(store.maxFileBytes),
259
283
  // 同 diffScript:-z 的 NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
@@ -314,6 +338,52 @@ export function gcScript(store, gitExe) {
314
338
  ].join('\n')
315
339
  }
316
340
 
341
+ // 快照失败后的残骸清理(issue #7 实测:失败重试一个下午可积累 127GB
342
+ // dangling 对象)。失败的 add 已把部分 blob 写进对象库,但 write-tree/
343
+ // commit/tag 未发生——这些对象无引用可达;git prune 以 refs + 暂存 index
344
+ // 为根做可达性删除,正好只清掉这批无主对象,不碰任何 tag 快照。不做 gc:
345
+ // gc 是全量 repack 重活,失败重试场景下对象库往往已被残骸撑大,代价过高。
346
+ // 调用点在 captureSnapshot 的 catch 里,与快照同走一条串行队列,无锁竞态。
347
+ export function pruneScript(store, gitExe) {
348
+ return [
349
+ "$ErrorActionPreference = 'Stop'",
350
+ '$git = ' + psq(gitExe),
351
+ '$g = ' + psq(store.git),
352
+ '& $git --git-dir=$g prune',
353
+ "Write-Output 'PRUNE_OK'"
354
+ ].join('\n')
355
+ }
356
+
357
+ // 失败后的孤儿进程清扫 + stale 锁清理(issue #7 实测:超时被杀的 shell 留下
358
+ // 孤儿 git 继续持有 index.lock 30+ 分钟)。DSH 的 subprocess 服务本身已做
359
+ // 树级终止(taskkill /T /F),这里是竞态窗口与旧版本漏网的兜底:按「命令行
360
+ // 含 --git-dir=<本仓库>」定位孤儿——该标记只出现在本插件派生的 git 进程
361
+ // 参数里,编辑器等无关进程不会命中;再删掉被硬杀的 git 来不及清理的锁文件
362
+ // (git 对强杀不做锁回收,残留 index.lock 会让后续每条快照持续 fatal)。
363
+ // 全程 SilentlyContinue + best-effort:调用点在 runShell 的失败路径上,
364
+ // 清扫自身再抛错只会掩盖原始错误。首行哨兵注释供 runShell 识别本脚本、
365
+ // 防止「清扫失败 → 再清扫」的递归。
366
+ export function killOrphansScript(gitDir) {
367
+ return [
368
+ '# RECALL_CLEANUP',
369
+ "$ErrorActionPreference = 'SilentlyContinue'",
370
+ '$g = ' + psq(gitDir),
371
+ // 标记用变量拼接而非字面量:本脚本进程的命令行(-Command 全文)只有
372
+ // 未展开的 '$g',Where-Object 不会匹配到自己
373
+ "$marker = '--git-dir=' + $g",
374
+ "Get-CimInstance Win32_Process -Filter 'CommandLine IS NOT NULL' | Where-Object { $_.CommandLine.Contains($marker) } | ForEach-Object {",
375
+ ' & taskkill /T /F /PID $_.ProcessId | Out-Null',
376
+ '}',
377
+ // 锁清单:index.lock 是 add/checkout 的持久锁,其余是 gc/tag/pack 链路
378
+ // 可能残留的;refs 下的 per-ref 锁用递归兜底
379
+ "foreach ($n in @('index.lock','config.lock','HEAD.lock','gc.pid','packed-refs.lock','shallow.lock')) {",
380
+ ' Remove-Item -LiteralPath (Join-Path $g $n) -Force',
381
+ '}',
382
+ "Get-ChildItem -LiteralPath (Join-Path $g 'refs') -Recurse -File -Filter '*.lock' | Remove-Item -Force",
383
+ "Write-Output 'CLEANUP_DONE'"
384
+ ].join('\n')
385
+ }
386
+
317
387
  // 删除指定快照 tag(会话已删联动清理用)。best-effort:个别 tag 已不存在时
318
388
  // git 非零退出,但其余 tag 已被删除——所以显式 exit 0 吞掉退出码,
319
389
  // 残留的由下一次清理幂等地收尾;JS 侧无论脚本结果都会同步索引。
package/lib/snapshots.js CHANGED
@@ -1,216 +1,300 @@
1
- /**
2
- * dsh-recall-plugin — 快照域(ctx 绑定的工厂,无模块级副作用)
3
- *
4
- * 职责:快照捕获(captureSnapshot)、索引落盘/载入/孤儿重建、
5
- * diff 清单(diffFor)、回退执行(rollbackFor)、会话切点解析
6
- * (resolveCutSeq)。依赖 store.js 的执行与存储层,脚本文本全部
7
- * 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
8
- */
9
-
10
- export function createSnapshots(ctx, rt, config) {
11
- const sessions = ctx.sessions
12
- const state = rt.state
13
- // 平台选择的脚本模板(rt.scripts = scripts.pwsh.js / scripts.posix.js):
14
- // 两套导出同名接口但实现分属 pwsh/bash,所有调用统一走 S.*
15
- const S = rt.scripts
16
- // 基础排除表随调用透传给脚本模板(用户 config 可调,即时生效)
17
- // 运行时读取而非创建时快照:设置卡片热更新 baseExcludes 后,
18
- // 下一次快照/diff/回退立即按新排除表执行
19
- const BASE = () => config.baseExcludes
20
-
21
- // 索引落盘:任意长度文本统一走 rt.writeTextViaShell(win32 base64
22
- // 分块 / POSIX stdin,实现见 store.js)——saveIndex 与 writeExclude
23
- // 曾逐字重复这套平台分叉,改一处漏一处的风险随合并消失。
24
- async function saveIndex(root, sessionId) {
25
- const store = state.stores.get(root)
26
- if (!store) return
27
- // 每条带 root:设置页「快照管理」要跨工作区展示列表,而 store 目录名
28
- // 是 root 的单向哈希、反解不了——index.json 是唯一能持久「哈希↔工作区
29
- // 路径」对应关系的地方。loadIndex 忽略 entry.root(以参数为准),
30
- // 旧版本插件读新索引也只取已知字段,双向兼容。
31
- const entries = Array.from(state.snapshots.entries())
32
- .filter(([, s]) => s.root === root)
33
- .map(([id, s]) => ({ id, time: s.time, root: s.root, sessionId: s.sessionId }))
34
- try {
35
- await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', JSON.stringify(entries))
36
- } catch (error) {
37
- rt.recordError('recall saveIndex failed: ' + String(error))
38
- }
39
- }
40
-
41
- async function loadIndex(root, sessionId) {
42
- if (state.indexLoaded.has(root)) return
43
- const store = state.stores.get(root)
44
- if (!store) return
45
- try {
46
- const raw = S.stripBom(await rt.runShell(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })).trim()
47
- if (!raw) { state.indexLoaded.add(root); return }
48
- const entries = JSON.parse(raw)
49
- if (!Array.isArray(entries)) { state.indexLoaded.add(root); return }
50
- for (const entry of entries) {
51
- if (!entry || typeof entry.id !== 'string') continue
52
- state.snapshots.set(entry.id, {
53
- root,
54
- time: typeof entry.time === 'number' ? entry.time : Date.now(),
55
- sessionId: entry.sessionId || sessionId
56
- })
57
- }
58
- // 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
59
- // runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
60
- // 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
61
- state.indexLoaded.add(root)
62
- } catch (error) {
63
- /* 索引缺失或损坏时按空历史处理;不标记已载入,下次自然重试 */
64
- }
65
- }
66
-
67
- // exclude.txt 原文读取(设置页编辑用):stripBom 剥掉 PS 5.1 Set-Content
68
- // 写入的 UTF-8 BOM,避免设置页首行出现不可见的 \uFEFF;两套模板对缺失
69
- // 文件都输出空串,这里不用区分「没配过」和「配了空」。
70
- async function readExclude(store) {
71
- return S.stripBom(await rt.runShell(S.excludeReadCmd(store.excludeFile), { stdoutMaxBytes: 1048576 }))
72
- }
73
-
74
- // exclude.txt 原文写入(设置页保存):先 mkdir 父目录兜底(home 根目录
75
- // /降级 store 目录被用户手滑删掉时,保存不该因此失败),写本体统一走
76
- // rt.writeTextViaShell,与 saveIndex 共用同一套平台分叉原语。
77
- async function writeExclude(store, text) {
78
- const body = String(text == null ? '' : text)
79
- const sep = rt.isWin ? '\\' : '/'
80
- const parent = store.excludeFile.slice(0, store.excludeFile.lastIndexOf(sep))
81
- await rt.runShell(S.mkdirScript(parent), { stdoutMaxBytes: 4096 })
82
- await rt.writeTextViaShell(store.excludeFile, body)
83
- }
84
-
85
- // 索引丢失时从仓库 tag 重建:tag 名 snap-<messageId> 本身就是快照主键
86
- async function rebuildOrphans(root, sessionId) {
87
- const store = state.stores.get(root)
88
- const gitExe = await rt.resolveGit()
89
- if (!store || !gitExe) return
90
- try {
91
- const listing = S.stripBom(await rt.runShell(S.listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
92
- if (!listing) return
93
- for (const name of listing.split(/\r?\n/)) {
94
- const id = name.trim().replace(/^snap-/, '')
95
- if (!id || state.snapshots.has(id)) continue
96
- state.snapshots.set(id, { root, time: 0, sessionId })
97
- }
98
- await saveIndex(root, sessionId)
99
- } catch (error) {
100
- rt.recordError('recall rebuildOrphans failed: ' + String(error))
101
- }
102
- }
103
-
104
- async function captureSnapshot(sessionId, messageId, time) {
105
- const root = await rt.resolveRoot(sessionId)
106
- if (!root) return
107
- let store = await rt.resolveStore(root)
108
- store = await rt.tryUpgradeToHome(root)
109
- const ok = await rt.ensureGit(root, store)
110
- if (!ok) return
111
- await loadIndex(root, sessionId)
112
- try {
113
- await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
114
- state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId })
115
- await saveIndex(root, sessionId)
116
- } catch (error) {
117
- rt.recordError('recall snapshot failed: ' + String(error))
118
- }
119
- }
120
-
121
- // POSIX 侧 diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
122
- // 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
123
- function parseChanges(text) {
124
- if (rt.isWin) {
125
- const parsed = JSON.parse(text)
126
- if (Array.isArray(parsed)) return parsed
127
- if (parsed && typeof parsed === 'object') return [parsed]
128
- return []
129
- }
130
- const out = []
131
- for (const line of text.split(/\r?\n/)) {
132
- if (!line) continue
133
- const tab = line.indexOf('\t')
134
- if (tab < 0) continue
135
- out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
136
- }
137
- return out
138
- }
139
-
140
- // 变更清单截断上限:防止超大工作区(几千个文件)把 DOM 与 JSON
141
- // 双双撑爆。清单对用户的价值集中在前若干条,其余以 truncated 计数
142
- // 汇总展示;total 保留完整计数让面板文案仍准确。
143
- const MAX_CHANGES = 500
144
-
145
- async function diffFor(messageId) {
146
- const snap = state.snapshots.get(String(messageId))
147
- if (!snap) return null
148
- const store = state.stores.get(snap.root)
149
- if (!store) return null
150
- // 8MB 上限:按平均每条 60 字节估算可容纳十余万条,正常项目远够;
151
- // 真超限时报错文案与「JSON 半截解析失败」的真实原因脱节,需显式检测
152
- const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
153
- const trimmed = text.trim()
154
- if (!trimmed) return { changes: [], total: 0, truncated: false }
155
- const all = parseChanges(trimmed)
156
- return { changes: all.slice(0, MAX_CHANGES), total: all.length, truncated: all.length > MAX_CHANGES }
157
- }
158
-
159
- async function rollbackFor(messageId) {
160
- const snap = state.snapshots.get(String(messageId))
161
- if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
162
- const store = state.stores.get(snap.root)
163
- if (!store) return { ok: false, error: '快照存储不可用' }
164
- const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
165
- const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
166
- const deleted = m ? parseInt(m[1], 10) : 0
167
- const restored = m ? parseInt(m[2], 10) : 0
168
- return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
169
- }
170
-
171
- // 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
172
- function scanCutSeq(events, messageId) {
173
- let anchor = -1
174
- for (let i = 0; i < events.length; i++) {
175
- const e = events[i]
176
- if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
177
- anchor = i
178
- break
179
- }
180
- }
181
- if (anchor < 0) return null
182
- for (let i = anchor - 1; i >= 0; i--) {
183
- const e = events[i]
184
- if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
185
- }
186
- return null
187
- }
188
-
189
- // 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
190
- // 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
191
- // 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
192
- async function resolveCutSeq(sessionId, messageId) {
193
- if (!sessionId || !messageId) return null
194
- const cacheKey = String(sessionId) + '\u0000' + String(messageId)
195
- if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
196
- let result = null
197
- const live = sessions.get(sessionId)
198
- if (live && Array.isArray(live.events)) {
199
- result = scanCutSeq(live.events, messageId)
200
- } else {
201
- const query = ctx.get('sessionQuery')
202
- if (query) {
203
- try {
204
- const log = await query.readSession(sessionId)
205
- result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
206
- } catch (error) {
207
- result = null
208
- }
209
- }
210
- }
211
- state.cutSeqCache.set(cacheKey, result)
212
- return result
213
- }
214
-
215
- return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
216
- }
1
+ /**
2
+ * dsh-recall-plugin — 快照域(ctx 绑定的工厂,无模块级副作用)
3
+ *
4
+ * 职责:快照捕获(captureSnapshot)、索引落盘/载入/孤儿重建、
5
+ * diff 清单(diffFor)、回退执行(rollbackFor)、会话切点解析
6
+ * (resolveCutSeq)。依赖 store.js 的执行与存储层,脚本文本全部
7
+ * 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
8
+ */
9
+
10
+ export function createSnapshots(ctx, rt, config) {
11
+ const sessions = ctx.sessions
12
+ const state = rt.state
13
+ // 平台选择的脚本模板(rt.scripts = scripts.pwsh.js / scripts.posix.js):
14
+ // 两套导出同名接口但实现分属 pwsh/bash,所有调用统一走 S.*
15
+ const S = rt.scripts
16
+ // 基础排除表随调用透传给脚本模板(用户 config 可调,即时生效)
17
+ // 运行时读取而非创建时快照:设置卡片热更新 baseExcludes 后,
18
+ // 下一次快照/diff/回退立即按新排除表执行
19
+ const BASE = () => config.baseExcludes
20
+
21
+ // 连续失败熔断(issue #7):失败快照的重试既无谓地全量扫描工作区,
22
+ // 又持续写入无 tag 可达的残骸对象(实测一个下午 127GB)。连续
23
+ // FUSE_AFTER 次失败后按 BASE 起步、指数翻倍、CAP 封顶的退避跳过
24
+ // 快照——冷却期内的消息不产生快照点,换来磁盘与 CPU 止血;冷却期满
25
+ // 自动重试,成功一次即全部复位,用户修好环境(如补排除配置)后
26
+ // 无需任何手动干预。
27
+ const FUSE_AFTER = 3
28
+ const FUSE_BACKOFF_BASE_MS = 5 * 60 * 1000
29
+ const FUSE_BACKOFF_CAP_MS = 60 * 60 * 1000
30
+ const snapFailures = new Map()
31
+
32
+ // 索引落盘:任意长度文本统一走 rt.writeTextViaShell(win32 base64
33
+ // 分块 / POSIX stdin,实现见 store.js)——saveIndex 与 writeExclude
34
+ // 曾逐字重复这套平台分叉,改一处漏一处的风险随合并消失。
35
+ async function saveIndex(root, sessionId) {
36
+ const store = state.stores.get(root)
37
+ if (!store) return
38
+ // 每条带 root:设置页「快照管理」要跨工作区展示列表,而 store 目录名
39
+ // 是 root 的单向哈希、反解不了——index.json 是唯一能持久「哈希↔工作区
40
+ // 路径」对应关系的地方。loadIndex 忽略 entry.root(以参数为准),
41
+ // 旧版本插件读新索引也只取已知字段,双向兼容。
42
+ const entries = Array.from(state.snapshots.entries())
43
+ .filter(([, s]) => s.root === root)
44
+ .map(([id, s]) => ({ id, time: s.time, root: s.root, sessionId: s.sessionId }))
45
+ try {
46
+ await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', JSON.stringify(entries))
47
+ } catch (error) {
48
+ rt.recordError('recall saveIndex failed: ' + String(error))
49
+ }
50
+ }
51
+
52
+ async function loadIndex(root, sessionId) {
53
+ if (state.indexLoaded.has(root)) return
54
+ const store = state.stores.get(root)
55
+ if (!store) return
56
+ try {
57
+ const raw = S.stripBom(await rt.runShell(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })).trim()
58
+ if (!raw) { state.indexLoaded.add(root); return }
59
+ const entries = JSON.parse(raw)
60
+ if (!Array.isArray(entries)) { state.indexLoaded.add(root); return }
61
+ for (const entry of entries) {
62
+ if (!entry || typeof entry.id !== 'string') continue
63
+ state.snapshots.set(entry.id, {
64
+ root,
65
+ time: typeof entry.time === 'number' ? entry.time : Date.now(),
66
+ sessionId: entry.sessionId || sessionId
67
+ })
68
+ }
69
+ // 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
70
+ // runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
71
+ // 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
72
+ state.indexLoaded.add(root)
73
+ } catch (error) {
74
+ /* 索引缺失或损坏时按空历史处理;不标记已载入,下次自然重试 */
75
+ }
76
+ }
77
+
78
+ // exclude.txt 原文读取(设置页编辑用):stripBom 剥掉 PS 5.1 Set-Content
79
+ // 写入的 UTF-8 BOM,避免设置页首行出现不可见的 \uFEFF;两套模板对缺失
80
+ // 文件都输出空串,这里不用区分「没配过」和「配了空」。
81
+ async function readExclude(store) {
82
+ return S.stripBom(await rt.runShell(S.excludeReadCmd(store.excludeFile), { stdoutMaxBytes: 1048576 }))
83
+ }
84
+
85
+ // exclude.txt 原文写入(设置页保存):先 mkdir 父目录兜底(home 根目录
86
+ // /降级 store 目录被用户手滑删掉时,保存不该因此失败),写本体统一走
87
+ // rt.writeTextViaShell,与 saveIndex 共用同一套平台分叉原语。
88
+ async function writeExclude(store, text) {
89
+ const body = String(text == null ? '' : text)
90
+ const sep = rt.isWin ? '\\' : '/'
91
+ const parent = store.excludeFile.slice(0, store.excludeFile.lastIndexOf(sep))
92
+ await rt.runShell(S.mkdirScript(parent), { stdoutMaxBytes: 4096 })
93
+ await rt.writeTextViaShell(store.excludeFile, body)
94
+ }
95
+
96
+ // 索引丢失时从仓库 tag 重建:tag 名 snap-<messageId> 本身就是快照主键
97
+ async function rebuildOrphans(root, sessionId) {
98
+ const store = state.stores.get(root)
99
+ const gitExe = await rt.resolveGit()
100
+ if (!store || !gitExe) return
101
+ try {
102
+ const listing = S.stripBom(await rt.runShell(S.listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
103
+ if (!listing) return
104
+ for (const name of listing.split(/\r?\n/)) {
105
+ const id = name.trim().replace(/^snap-/, '')
106
+ if (!id || state.snapshots.has(id)) continue
107
+ state.snapshots.set(id, { root, time: 0, sessionId })
108
+ }
109
+ await saveIndex(root, sessionId)
110
+ } catch (error) {
111
+ rt.recordError('recall rebuildOrphans failed: ' + String(error))
112
+ }
113
+ }
114
+
115
+ async function captureSnapshot(sessionId, messageId, time) {
116
+ const root = await rt.resolveRoot(sessionId)
117
+ if (!root) return
118
+ // 熔断检查要在任何解析/建仓动作之前:冷却期内连 resolveStore 都
119
+ // 不必跑,把失败重试的开销也一并止住
120
+ const fused = snapFailures.get(root)
121
+ if (fused && Date.now() < fused.skipUntil) return
122
+ let store = await rt.resolveStore(root)
123
+ store = await rt.tryUpgradeToHome(root)
124
+ const ok = await rt.ensureGit(root, store)
125
+ if (!ok) return
126
+ await loadIndex(root, sessionId)
127
+ try {
128
+ const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
129
+ snapFailures.delete(root)
130
+ state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId })
131
+ await saveIndex(root, sessionId)
132
+ setFeedback(messageId, { skipped: parseSkipped(out) })
133
+ } catch (error) {
134
+ rt.recordError('recall snapshot failed: ' + String(error))
135
+ setFeedback(messageId, { failed: true, error: String(error).slice(0, 300) })
136
+ await handleSnapshotFailure(root, store)
137
+ }
138
+ }
139
+
140
+ // 脚本侧 fail-open 跳过的路径(--ignore-errors 下无法索引的目录,如无
141
+ // 提交的嵌入式仓库)以「SNAP_SKIP <path>」行回传:这些路径不进快照,
142
+ // 撤回时既不恢复也不会被删,用户应当知道快照少了什么。
143
+ function parseSkipped(out) {
144
+ const skipped = []
145
+ for (const line of String(out || '').split(/\r?\n/)) {
146
+ if (line.indexOf('SNAP_SKIP ') === 0) skipped.push(line.slice('SNAP_SKIP '.length))
147
+ }
148
+ return skipped
149
+ }
150
+
151
+ // 逐消息反馈写入(issue #7 失败可见性):成功无跳过 → 清除(重试成功
152
+ // 自愈);失败/有跳过 → 记录。上限防泄漏:交替成功失败的长会话可以无限
153
+ // 积累,Map 保插入序做 FIFO 淘汰。
154
+ function setFeedback(messageId, rec) {
155
+ const id = String(messageId)
156
+ const keep = rec && ((rec.failed) || (Array.isArray(rec.skipped) && rec.skipped.length))
157
+ if (keep) state.snapFeedback.set(id, rec)
158
+ else state.snapFeedback.delete(id)
159
+ if (state.snapFeedback.size > 200) state.snapFeedback.delete(state.snapFeedback.keys().next().value)
160
+ }
161
+
162
+ // snapshot-info 端点的反馈查询:优先逐消息记录;无记录但该 root 熔断中
163
+ // 时反馈熔断状态(冷却期内的消息快照被静默跳过,客户端需要知道「不是
164
+ // 还没好,是暂停了」)。客户端只对近 5 分钟的消息弹提示,历史消息查询
165
+ // 不受影响。
166
+ async function feedbackFor(sessionId, messageId) {
167
+ const rec = state.snapFeedback.get(String(messageId || ''))
168
+ if (rec) return rec
169
+ if (!sessionId) return {}
170
+ const root = await rt.resolveRoot(sessionId)
171
+ if (root) {
172
+ const f = snapFailures.get(root)
173
+ if (f && Date.now() < f.skipUntil) {
174
+ return { failed: true, error: '快照连续失败已暂停(熔断),约 ' + Math.ceil((f.skipUntil - Date.now()) / 60000) + ' 分钟后自动重试,详情见设置 · 插件配置 · 最近错误' }
175
+ }
176
+ }
177
+ return {}
178
+ }
179
+
180
+ // 失败善后 = 清残骸 + 推进熔断。captureSnapshot 的调用方就是串行队列
181
+ // (见 index.js 事件接线),这两个动作留在 catch 里顺势排队执行,
182
+ // 与下一次快照天然互斥,无 git 锁竞态;自身整体 best-effort,
183
+ // 清理失败不该让队列任务以异常收场。
184
+ async function handleSnapshotFailure(root, store) {
185
+ if (store && state.gitExe) {
186
+ try {
187
+ await rt.runShell(S.pruneScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
188
+ } catch (error) {
189
+ rt.recordError('recall prune after snapshot failure failed: ' + String(error))
190
+ }
191
+ }
192
+ const f = snapFailures.get(root) || { count: 0, skipUntil: 0 }
193
+ f.count++
194
+ if (f.count >= FUSE_AFTER) {
195
+ const backoff = Math.min(FUSE_BACKOFF_BASE_MS * 2 ** (f.count - FUSE_AFTER), FUSE_BACKOFF_CAP_MS)
196
+ const wasFused = Date.now() < f.skipUntil
197
+ f.skipUntil = Date.now() + backoff
198
+ // 只在「未熔断→熔断」的跳变沿记录:熔断期间每条消息都会走上面的
199
+ // 静默跳过分支,逐条记录会把最近错误环形缓冲刷成同一条目
200
+ if (!wasFused) rt.recordError('recall snapshot fused after ' + f.count + ' consecutive failures, backoff ' + Math.round(backoff / 60000) + 'min for: ' + root)
201
+ }
202
+ snapFailures.set(root, f)
203
+ }
204
+
205
+ // POSIX 侧 diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
206
+ // 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
207
+ function parseChanges(text) {
208
+ if (rt.isWin) {
209
+ const parsed = JSON.parse(text)
210
+ if (Array.isArray(parsed)) return parsed
211
+ if (parsed && typeof parsed === 'object') return [parsed]
212
+ return []
213
+ }
214
+ const out = []
215
+ for (const line of text.split(/\r?\n/)) {
216
+ if (!line) continue
217
+ const tab = line.indexOf('\t')
218
+ if (tab < 0) continue
219
+ out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
220
+ }
221
+ return out
222
+ }
223
+
224
+ // 变更清单截断上限:防止超大工作区(几千个文件)把 DOM 与 JSON
225
+ // 双双撑爆。清单对用户的价值集中在前若干条,其余以 truncated 计数
226
+ // 汇总展示;total 保留完整计数让面板文案仍准确。
227
+ const MAX_CHANGES = 500
228
+
229
+ async function diffFor(messageId) {
230
+ const snap = state.snapshots.get(String(messageId))
231
+ if (!snap) return null
232
+ const store = state.stores.get(snap.root)
233
+ if (!store) return null
234
+ // 8MB 上限:按平均每条 60 字节估算可容纳十余万条,正常项目远够;
235
+ // 真超限时报错文案与「JSON 半截解析失败」的真实原因脱节,需显式检测
236
+ const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
237
+ const trimmed = text.trim()
238
+ if (!trimmed) return { changes: [], total: 0, truncated: false }
239
+ const all = parseChanges(trimmed)
240
+ return { changes: all.slice(0, MAX_CHANGES), total: all.length, truncated: all.length > MAX_CHANGES }
241
+ }
242
+
243
+ async function rollbackFor(messageId) {
244
+ const snap = state.snapshots.get(String(messageId))
245
+ if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
246
+ const store = state.stores.get(snap.root)
247
+ if (!store) return { ok: false, error: '快照存储不可用' }
248
+ const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
249
+ const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
250
+ const deleted = m ? parseInt(m[1], 10) : 0
251
+ const restored = m ? parseInt(m[2], 10) : 0
252
+ return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
253
+ }
254
+
255
+ // 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
256
+ function scanCutSeq(events, messageId) {
257
+ let anchor = -1
258
+ for (let i = 0; i < events.length; i++) {
259
+ const e = events[i]
260
+ if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
261
+ anchor = i
262
+ break
263
+ }
264
+ }
265
+ if (anchor < 0) return null
266
+ for (let i = anchor - 1; i >= 0; i--) {
267
+ const e = events[i]
268
+ if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
269
+ }
270
+ return null
271
+ }
272
+
273
+ // 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
274
+ // 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
275
+ // 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
276
+ async function resolveCutSeq(sessionId, messageId) {
277
+ if (!sessionId || !messageId) return null
278
+ const cacheKey = String(sessionId) + '\u0000' + String(messageId)
279
+ if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
280
+ let result = null
281
+ const live = sessions.get(sessionId)
282
+ if (live && Array.isArray(live.events)) {
283
+ result = scanCutSeq(live.events, messageId)
284
+ } else {
285
+ const query = ctx.get('sessionQuery')
286
+ if (query) {
287
+ try {
288
+ const log = await query.readSession(sessionId)
289
+ result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
290
+ } catch (error) {
291
+ result = null
292
+ }
293
+ }
294
+ }
295
+ state.cutSeqCache.set(cacheKey, result)
296
+ return result
297
+ }
298
+
299
+ return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, feedbackFor }
300
+ }