dsh-recall-plugin 2.3.1 → 2.3.3

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/snapshots.js CHANGED
@@ -1,588 +1,400 @@
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
- import * as E from './errors.js'
11
- import { buildFeedbackError } from './diagnostics.js'
12
-
13
- // ---- 纯逻辑(模块级导出,供 tests/unit 直接钉住;工厂内沿用同一实现)----
14
-
15
- // 脚本侧 fail-open 跳过的路径(--ignore-errors 下无法索引的目录,如无
16
- // 提交的嵌入式仓库)以「SNAP_SKIP <path>」行回传:这些路径不进快照,
17
- // 撤回时既不恢复也不会被删,用户应当知道快照少了什么。
18
- export function parseSkipped(out) {
19
- const skipped = []
20
- for (const line of String(out || '').split(/\r?\n/)) {
21
- if (line.indexOf('SNAP_SKIP ') === 0) skipped.push(line.slice('SNAP_SKIP '.length))
22
- }
23
- return skipped
24
- }
25
-
26
- // POSIX 侧 diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
27
- // 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
28
- export function parseChanges(text, isWin) {
29
- if (isWin) {
30
- const parsed = JSON.parse(text)
31
- if (Array.isArray(parsed)) return parsed
32
- if (parsed && typeof parsed === 'object') return [parsed]
33
- return []
34
- }
35
- const out = []
36
- for (const line of text.split(/\r?\n/)) {
37
- if (!line) continue
38
- const tab = line.indexOf('\t')
39
- if (tab < 0) continue
40
- out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
41
- }
42
- return out
43
- }
44
-
45
- // PF-1:snapshotScript 输出中的 TREE 行(add -A 后的 index 树指纹)。
46
- // execute 拿安全快照输出的指纹与 preview 指纹比对判 STALE;无该行的旧输出
47
- // (脚本/Host 版本错位的理论场景)返回 null,调用侧跳过指纹校验。
48
- export function parseTreeId(out) {
49
- for (const line of String(out || '').split(/\r?\n/)) {
50
- if (line.indexOf('TREE ') === 0) return line.slice(5).trim() || null
51
- }
52
- return null
53
- }
54
-
55
- // PF-1:diff 输出统一解析。脚本侧协议(见 scripts.*.js diffScript 注释):
56
- // win32 输出「TOTAL <n>」/「前 maxChanges 条 JSON」/「TREE <hash>」三段;
57
- // POSIX 输出 TSV 逐行 + 末行「TREE <hash>」。标记行必须先剥离再交给
58
- // parseChanges——win32 分支对整段文本 JSON.parse,标记行混入直接抛错。
59
- // total 语义不变(全量条数):win32 TOTAL 行回传(截断前移脚本侧),
60
- // POSIX 无 TOTAL 行则取解析条数(不截断)。treeId 为 index 树指纹。
61
- export function parseDiffOutput(text, isWin, maxChanges) {
62
- const lines = String(text || '').split(/\r?\n/)
63
- let total = null
64
- let treeId = null
65
- const body = []
66
- for (const line of lines) {
67
- if (line.indexOf('TREE ') === 0) { treeId = line.slice(5).trim() || null; continue }
68
- if (line.indexOf('TOTAL ') === 0) {
69
- const n = parseInt(line.slice(6), 10)
70
- if (Number.isFinite(n) && n >= 0) total = n
71
- continue
72
- }
73
- body.push(line)
74
- }
75
- const raw = body.join('\n').trim()
76
- const all = raw ? parseChanges(raw, isWin) : []
77
- const finalTotal = total !== null ? total : all.length
78
- const changes = all.slice(0, maxChanges)
79
- return { changes, total: finalTotal, truncated: finalTotal > changes.length, treeId }
80
- }
81
-
82
- // 在事件序列里找“该消息之前最近一次 turn/end seq”。
83
- export function scanCutSeq(events, messageId) {
84
- let anchor = -1
85
- for (let i = 0; i < events.length; i++) {
86
- const e = events[i]
87
- if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
88
- anchor = i
89
- break
90
- }
91
- }
92
- if (anchor < 0) return null
93
- for (let i = anchor - 1; i >= 0; i--) {
94
- const e = events[i]
95
- if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
96
- }
97
- return null
98
- }
99
-
100
- // H1:回退失败后的救援编排(模块级纯逻辑,deps 注入副作用,供单测钉三分支)。
101
- // rollbackFor 失败(partial=true,工作区可能半回退)时,execute 已先打下
102
- // pre-rollback-<ts> 安全快照(见 index.js);snapshotScript tag 无条件加
103
- // snap- 前缀,实际 tag 名是 snap-pre-rollback-<ts>——这里在调用侧拼出完整
104
- // tag 再传给 rescueScript(F-S1:前缀知识留在唯一知道 safetyId 语义的编排
105
- // 层,rescueScript 保持通用只接受完整 tag 名)。rescue 本身幂等:即使
106
- // rollback 实际未动工作区,reset 到安全快照也只是把工作区恢复成回退前
107
- // (≈当前)状态。无安全快照(safety 快照当时失败)时退化为现状 fail-loud,
108
- // 不静默。
109
- export async function rescueRollback(deps, opts) {
110
- const { root, store, safetyId, safetyOk, rollbackError } = opts
111
- const reason = String(rollbackError || '未知原因')
112
- if (!safetyOk) {
113
- deps.recordError('recall rollback failed, no rescue snapshot: ' + reason)
114
- return { ok: false, code: E.RECALL_ROLLBACK_FAILED, message: '回退失败:' + reason + '(无可用安全快照,工作区可能处于半回退状态)' }
115
- }
116
- const tag = 'snap-' + safetyId
117
- // 手动恢复命令供用户复制执行:路径加引号让含空格的工作区路径可直接跑,
118
- // 目标与 rescueScript 同用完整 tag 名(两条路必须指向同一个快照)。
119
- const manual = 'git --git-dir="' + store.git + '" --work-tree="' + root + '" reset --hard ' + tag
120
- try {
121
- const out = await deps.runShell(deps.scripts.rescueScript(root, store, deps.gitExe, tag), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
122
- // RESCUE_OK 哨兵校验(与 rollbackFor ROLLBACK_OK 对称):pwsh
123
- // native 非零退出不抛,脚本模板里虽有 $LASTEXITCODE 显式 throw 兜底,
124
- // 但「脚本跑完、git 静默未生效」的假成功只能靠哨兵识别——哨兵缺失按
125
- // 救援失败处理,走手动命令分支,不静默。
126
- if (String(out || '').indexOf('RESCUE_OK') < 0) throw new Error('rescue 脚本未输出 RESCUE_OK 哨兵')
127
- deps.recordError('recall rollback failed, rescued to safety tag: ' + tag + ' — ' + reason)
128
- return { ok: false, code: E.RECALL_ROLLBACK_FAILED, message: '回退失败:' + reason + ';已自动恢复到回退前的安全快照,请重新预览后重试' }
129
- } catch (rescueError) {
130
- const rescueReason = String(rescueError && rescueError.message ? rescueError.message : rescueError)
131
- deps.recordError('recall rollback failed and rescue failed: ' + tag + ' — ' + reason + ' | rescue: ' + rescueReason)
132
- return { ok: false, code: E.RECALL_ROLLBACK_FAILED, message: '回退失败:' + reason + ';自动恢复也失败,请手动执行:' + manual }
133
- }
134
- }
135
-
136
- // F-G1:safety 快照 id 识别(模块级纯逻辑,rebuildOrphans 与 manage list
137
- // 共用同一谓词)。安全 tag 是回退前自动打下的救援锚点(pre-rollback-<ts>,
138
- // routes-core.js execute),不是消息快照——不进索引、不在列表展示。
139
- // 消息 ID 为系统生成 GUID,前缀碰撞概率为零(plan-competitor-fixes F-G1 风险节)。
140
- export function isSafetySnapshotId(id) {
141
- return typeof id === 'string' && id.indexOf('pre-rollback-') === 0
142
- }
143
-
144
- // listTagsWithTimeScript 输出解析(模块级纯逻辑,便于单测):每行
145
- // 「<tag名> <秒级时间戳>」,for-each-ref refname 不含空格、时间戳
146
- // 恒为行尾整数。时间解析失败/缺省回退 null——调用方以 0 兜底(保留
147
- // 旧「无时间」行为),绝不因格式漂移丢 tag。
148
- export function parseTagsWithTime(text) {
149
- const out = []
150
- for (const line of String(text || '').split(/\r?\n/)) {
151
- const t = line.trim()
152
- if (!t) continue
153
- const sp = t.lastIndexOf(' ')
154
- const name = sp > 0 ? t.slice(0, sp) : t
155
- const ts = sp > 0 ? parseInt(t.slice(sp + 1), 10) : NaN
156
- out.push({ name, time: Number.isFinite(ts) && ts > 0 ? ts * 1000 : null })
157
- }
158
- return out
159
- }
160
-
161
- // ---- 配置工厂 ----
162
-
163
- export function createSnapshots(ctx, rt, config) {
164
- const sessions = ctx.sessions
165
- const state = rt.state
166
- // 平台选择的脚本模板(rt.scripts = scripts.pwsh.js / scripts.posix.js):
167
- // 两套导出同名接口但实现分属 pwsh/bash,所有调用统一走 S.*
168
- const S = rt.scripts
169
- // 基础排除表随调用透传给脚本模板(用户 config 可调,即时生效)
170
- // 运行时读取而非创建时快照:设置卡片热更新 baseExcludes 后,
171
- // 下一次快照/diff/回退立即按新排除表执行
172
- const BASE = () => config.baseExcludes
173
-
174
- // 连续失败熔断(issue #7):失败快照的重试既无谓地全量扫描工作区,
175
- // 又持续写入无 tag 可达的残骸对象(实测一个下午 127GB)。连续
176
- // FUSE_AFTER 次失败后按 BASE 起步、指数翻倍、CAP 封顶的退避跳过
177
- // 快照——冷却期内的消息不产生快照点,换来磁盘与 CPU 止血;冷却期满
178
- // 自动重试,成功一次即全部复位,用户修好环境(如补排除配置)后
179
- // 无需任何手动干预。
180
- const FUSE_AFTER = 3
181
- const FUSE_BACKOFF_BASE_MS = 5 * 60 * 1000
182
- const FUSE_BACKOFF_CAP_MS = 60 * 60 * 1000
183
- const snapFailures = new Map()
184
-
185
- // 索引落盘:任意长度文本统一走 rt.writeTextViaShell(win32 base64
186
- // 分块 / POSIX stdin,实现见 store.js)——saveIndex 与 writeExclude
187
- // 曾逐字重复这套平台分叉,改一处漏一处的风险随合并消失。
188
- async function saveIndex(root, sessionId) {
189
- const store = state.stores.get(root)
190
- if (!store) return
191
- // 每条带 root:设置页「快照管理」要跨工作区展示列表,而 store 目录名
192
- // root 的单向哈希、反解不了——index.json 是唯一能持久「哈希↔工作区
193
- // 路径」对应关系的地方。loadIndex 忽略 entry.root(以参数为准),
194
- // 旧版本插件读新索引也只取已知字段,双向兼容。
195
- // feedback 落盘(P1-2):只对「需要解释」的消息写 feedback 字段(失败/
196
- // 有跳过),正常快照不带——省空间;重启后 snapshot-info 仍能解释
197
- // 「这条消息为什么没有/缺了快照」。与 root 字段当年的兼容策略一致:
198
- // 老版本插件读新索引忽略未知字段。
199
- const entries = Array.from(state.snapshots.entries())
200
- .filter(([, s]) => s.root === root)
201
- .map(([id, s]) => {
202
- const rec = { id, time: s.time, root: s.root, sessionId: s.sessionId }
203
- const fb = state.snapFeedback.get(id)
204
- if (fb && (fb.failed || (Array.isArray(fb.skipped) && fb.skipped.length))) rec.feedback = fb
205
- return rec
206
- })
207
- try {
208
- await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', JSON.stringify(entries))
209
- } catch (error) {
210
- rt.recordError('recall saveIndex failed: ' + String(error))
211
- }
212
- }
213
-
214
- async function loadIndex(root, sessionId) {
215
- if (state.indexLoaded.has(root)) return
216
- const store = state.stores.get(root)
217
- if (!store) return
218
- let raw = ''
219
- let truncated = false
220
- try {
221
- const meta = await rt.runShellMeta(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })
222
- raw = S.stripBom(meta.text).trim()
223
- truncated = Boolean(meta.truncated)
224
- } catch (error) {
225
- // 读索引失败(shell 未就绪等):不标记已载入,下次自然重试(既有语义)
226
- return
227
- }
228
- if (truncated) {
229
- // F-G3:读截断 索引损坏。stdout 4MB 上限时官方 shell 只回传流
230
- // 尾部(runShellMeta 暴露的 CollectedOutput.truncated 可判定),JSON
231
- // 头已丢,parse 必失败——若走下方损坏分支会把好文件改名 .corrupt,
232
- // 索引记录(time/sessionId/feedback)全丢、rebuild 后变 time=0 条目
233
- // 又触发清理链。改为:不隔离、原文件原样保留,按空索引继续(标记
234
- // indexLoaded 防止重试循环刷错误环),本次绝不写回(防用残缺内存
235
- // 覆盖好文件);下一次自然写索引时按当下内存状态覆盖,tag 是真相源,
236
- // 孤儿重建随时可反推兜底。4MB ≈ 每条约 100B × 4 万条,
237
- // maxSnapshotsPerWorkspace=0(不限)的长期工作区可能触达——上限即
238
- // 天花板的取舍记录于此。
239
- // PF-5:同时记 indexTruncated——init/预热紧接的 rebuildOrphans 若不
240
- // 知情,会把全部 tag 判成孤儿(内存为空)并全量重写索引,用残缺孤
241
- // 儿集覆盖完好的大索引(现有代码的真实隐患)。rebuildOrphans 对该
242
- // root 整体跳过。
243
- rt.recordError('recall index read truncated: ' + root + ' 的 index.json 超过读取上限,按空索引继续(原文件未改动,下次写索引自然覆盖)')
244
- state.indexLoaded.add(root)
245
- state.indexTruncated.add(root)
246
- return
247
- }
248
- if (!raw) { state.indexLoaded.add(root); return }
249
- let entries = null
250
- try {
251
- entries = JSON.parse(raw)
252
- } catch (error) {
253
- // H2:索引损坏 fail-loud——坏文件改名 .corrupt-<ts> 保留现场 + 记错误,
254
- // 按空索引继续(rebuildOrphans tag 名反推重建,数据不丢),不再静默当空。
255
- if (await quarantineCorruptIndex(store)) state.indexLoaded.add(root)
256
- return
257
- }
258
- if (!Array.isArray(entries)) {
259
- // H2:整体形状非法(非数组)同样按损坏处理,保留现场。
260
- if (await quarantineCorruptIndex(store)) state.indexLoaded.add(root)
261
- return
262
- }
263
- let invalid = 0
264
- for (const entry of entries) {
265
- // H2:逐条过滤非法条目(非对象 / string id / id——A6:空串 id
266
- // 是垃圾条目,进索引会让 snapshot-info 与回退按空主键查找)并计数告警,
267
- // 整体不判死;root/time 的宽松兼容保留(root 以参数为准、time 缺省回退
268
- // now)——那是旧索引双向兼容策略,不属「损坏」。
269
- if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string' || !entry.id) { invalid++; continue }
270
- state.snapshots.set(entry.id, {
271
- root,
272
- time: typeof entry.time === 'number' ? entry.time : Date.now(),
273
- sessionId: entry.sessionId || sessionId
274
- })
275
- // feedback 回填(P1-2):重启后仍能解释「这条消息为什么没有/缺了
276
- // 快照」。复用 setFeedback 落内存(保持 FIFO 上限),只回填「需要
277
- // 解释」的记录;旧版索引无 feedback 字段时天然跳过。kind(M1 环境错误
278
- // 分类)随对象保留——序列化是整体对象,但这里的重建是字段白名单,
279
- // kind 会让重启后的失败条目丢失分类、status hint 失效。
280
- const fb = entry.feedback
281
- if (fb && typeof fb === 'object') {
282
- const rec = {}
283
- if (fb.failed) {
284
- rec.failed = true
285
- if (typeof fb.error === 'string') rec.error = fb.error
286
- if (typeof fb.kind === 'string') rec.kind = fb.kind
287
- }
288
- if (Array.isArray(fb.skipped)) rec.skipped = fb.skipped.filter((p) => typeof p === 'string')
289
- if (rec.failed || (Array.isArray(rec.skipped) && rec.skipped.length)) setFeedback(entry.id, rec)
290
- }
291
- }
292
- if (invalid > 0) rt.recordError('recall index has ' + invalid + ' invalid entries for: ' + root)
293
- // 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
294
- // runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
295
- // 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
296
- // PF-5:healthy 单独标记(≠indexLoaded)——只有「磁盘索引确实在场且
297
- // 解析成功」才允许 rebuildOrphans 跳过;空索引/损坏隔离/读失败都不标。
298
- state.indexLoaded.add(root)
299
- state.indexHealthy.add(root)
300
- }
301
-
302
- // A5:quarantine 失败告警节流(每 store 5 分钟最多一条)。rename 失败时
303
- // indexLoaded 不标记 → 下次 loadIndex 重试 → 再失败会再告警,同一环境性
304
- // 故障(权限/磁盘)会把最近错误环(20 条)瞬间刷满;节流保留告警存在性
305
- // 同时防刷屏。Map 挂工厂闭包而非模块级(HMR 假设)。
306
- const quarantineThrottle = new Map()
307
- function quarantineErrorThrottled(store, text) {
308
- const last = quarantineThrottle.get(store.dir) || 0
309
- if (Date.now() - last < 5 * 60 * 1000) return
310
- quarantineThrottle.set(store.dir, Date.now())
311
- rt.recordError(text)
312
- }
313
-
314
- // H2:损坏索引现场保留——改名 index.json.corrupt-<ts> 而非删除,供排障;
315
- // 改完名原路径即空,下次 loadIndex 读到空按「无索引」处理,不重复告警。
316
- // 返回是否改名成功:失败时不标记 indexLoaded,下次重试而非让坏文件被跳过。
317
- async function quarantineCorruptIndex(store) {
318
- const sep = rt.isWin ? '\\' : '/'
319
- const corrupt = store.dir + sep + 'index.json.corrupt-' + Date.now()
320
- try {
321
- await rt.runShell(S.renameFileCmd(store.dir + sep + 'index.json', corrupt), { stdoutMaxBytes: 4096 })
322
- rt.recordError('recall index corrupt: 已按空索引继续,坏文件保留为 ' + corrupt)
323
- return true
324
- } catch (error) {
325
- quarantineErrorThrottled(store, 'recall index quarantine failed: ' + String(error))
326
- return false
327
- }
328
- }
329
-
330
- // ---- F1 fork lineage 持久化 ----
331
- // 撤回多次产生 A→B→C 链,但中间版本归档后从 sessions.list 隐藏,client
332
- // 拿不到完整父链——Host 在 client fork 上报时记录 childId↔parentId 到
333
- // store 目录的 lineage.json(原子写),快照管理树据此聚族展示「版本家族」。
334
- // lineage.json 损坏不致命(与 index.json 损坏 fail-loud 语义区分):按无
335
- // lineage 处理,快照树退化为现有「工作区 → 会话」分组。
336
- async function loadLineage(root) {
337
- const store = state.stores.get(root)
338
- if (!store) return []
339
- try {
340
- const raw = S.stripBom(await rt.runShell(S.lineageReadCmd(store.dir), { stdoutMaxBytes: 1048576 })).trim()
341
- if (!raw) return []
342
- const arr = JSON.parse(raw)
343
- return Array.isArray(arr)
344
- ? arr.filter((e) => e && typeof e.childId === 'string' && typeof e.parentId === 'string')
345
- : []
346
- } catch (error) {
347
- return []
348
- }
349
- }
350
-
351
- async function recordLineage(root, childId, parentId) {
352
- const store = state.stores.get(root)
353
- if (!store) return
354
- const sep = rt.isWin ? '\\' : '/'
355
- const existing = await loadLineage(root)
356
- // 去重:同一 (childId, parentId) 只记一次(fork 幂等)
357
- if (!existing.some((e) => e.childId === childId && e.parentId === parentId)) {
358
- existing.push({ childId, parentId, time: Date.now() })
359
- try {
360
- await rt.writeTextViaShell(store.dir + sep + 'lineage.json', JSON.stringify(existing))
361
- } catch (error) {
362
- rt.recordError('recall recordLineage failed: ' + String(error))
363
- }
364
- }
365
- }
366
-
367
- // exclude.txt 原文读取(设置页编辑用):stripBom 剥掉 PS 5.1 Set-Content
368
- // 写入的 UTF-8 BOM,避免设置页首行出现不可见的 \uFEFF;两套模板对缺失
369
- // 文件都输出空串,这里不用区分「没配过」和「配了空」。
370
- async function readExclude(store) {
371
- return S.stripBom(await rt.runShell(S.excludeReadCmd(store.excludeFile), { stdoutMaxBytes: 1048576 }))
372
- }
373
-
374
- // exclude.txt 原文写入(设置页保存):先 mkdir 父目录兜底(home 根目录
375
- // /降级 store 目录被用户手滑删掉时,保存不该因此失败),写本体统一走
376
- // rt.writeTextViaShell,与 saveIndex 共用同一套平台分叉原语。
377
- async function writeExclude(store, text) {
378
- const body = String(text == null ? '' : text)
379
- const sep = rt.isWin ? '\\' : '/'
380
- const parent = store.excludeFile.slice(0, store.excludeFile.lastIndexOf(sep))
381
- await rt.runShell(S.mkdirScript(parent), { stdoutMaxBytes: 4096 })
382
- await rt.writeTextViaShell(store.excludeFile, body)
383
- }
384
-
385
- // 索引丢失时从仓库 tag 重建:tag 名 snap-<messageId> 本身就是快照主键
386
- // PF-5 四档守卫(init/预热提速,每 root 省 1+N 条进程):
387
- // - truncated → 整体跳过且不写盘:内存是残缺视图,rebuild 会把全部 tag
388
- // 判成孤儿并用残缺孤儿集覆盖完好的大索引(F-G3 隐患,本守卫根治);
389
- // - healthy 且该 root 条目 > 0 → 跳过:索引确实在场,tag↔index 脱节的
390
- // 窗口只在 saveIndex 落盘失败时出现,该失败本身走 recordError 且下次
391
- // saveIndex 自然补写(文档语义安全论证);条目为 0 的 healthy 不跳
392
- // (合法「磁盘有 tag、索引为空」态仍需重建);
393
- // - empty/quarantined/读失败(都不标 healthy)→ 照跑,自愈链路完整。
394
- async function rebuildOrphans(root, sessionId) {
395
- if (state.indexTruncated.has(root)) return
396
- if (state.indexHealthy.has(root)) {
397
- let count = 0
398
- for (const s of state.snapshots.values()) {
399
- if (s && s.root === root) count++
400
- }
401
- if (count > 0) return
402
- }
403
- const store = state.stores.get(root)
404
- const gitExe = await rt.resolveGit()
405
- if (!store || !gitExe) return
406
- try {
407
- // 带时间戳清单(listTagsWithTimeScript):重建条目从 tag 的
408
- // creatordate 恢复 time——此前只列 tag 名,重建条目一律 time=0,
409
- // 管理列表时间前缀缺失、retention/limits 按「最旧」误清真实快照。
410
- const listing = S.stripBom(await rt.runShell(S.listTagsWithTimeScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
411
- if (!listing) return
412
- for (const { name, time } of parseTagsWithTime(listing)) {
413
- const id = name.replace(/^snap-/, '')
414
- // F-G1:安全 tag(snap-pre-rollback-<ts>)只作救援锚点,不进索引——
415
- // 否则被 rebuild 成 time=0 条目后会进快照管理列表、占
416
- // maxSnapshotsPerWorkspace 配额、被 retention/limits 当「最旧」优先
417
- // 清掉,H1 的救援点在重度使用下会随 purge 消失(与 routes-core.js
418
- // execute「不进 index.json、列表不展示」的设计承诺对齐)。
419
- if (!id || isSafetySnapshotId(id) || state.snapshots.has(id)) continue
420
- state.snapshots.set(id, { root, time: time || 0, sessionId })
421
- }
422
- await saveIndex(root, sessionId)
423
- } catch (error) {
424
- rt.recordError('recall rebuildOrphans failed: ' + String(error))
425
- }
426
- }
427
-
428
- async function captureSnapshot(sessionId, messageId, time) {
429
- const root = await rt.resolveRoot(sessionId)
430
- if (!root) return
431
- // 熔断检查要在任何解析/建仓动作之前:冷却期内连 resolveStore 都
432
- // 不必跑,把失败重试的开销也一并止住
433
- const fused = snapFailures.get(root)
434
- if (fused && Date.now() < fused.skipUntil) return
435
- let store = await rt.resolveStore(root)
436
- store = await rt.tryUpgradeToHome(root)
437
- // ensureGit 失败(issue #11 主线缺口):原先静默 return,不进
438
- // snapFeedback,客户端空轮询 20 次后放弃、用户零感知。现在走与
439
- // snapshotScript 失败相同的反馈通道——buildFeedbackError 把原始
440
- // stderr 分类成可行动提示(锁冲突/磁盘满等),客户端轮询到 failed
441
- // 即弹「快照失败:<提示>」并停止轮询。不接熔断:环境类失败常可自愈
442
- // (清磁盘/退锁后下一条消息即恢复),保持按消息重试,刷屏由
443
- // recordError 尾部去重与 toast 10min 节流缓解。
444
- const g = await rt.ensureGit(root, store)
445
- if (!g.ok) {
446
- setFeedback(messageId, { failed: true, ...buildFeedbackError(g.error || '未知原因') })
447
- return
448
- }
449
- await loadIndex(root, sessionId)
450
- try {
451
- const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
452
- snapFailures.delete(root)
453
- state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId })
454
- await saveIndex(root, sessionId)
455
- setFeedback(messageId, { skipped: parseSkipped(out) })
456
- } catch (error) {
457
- rt.recordError('recall snapshot failed: ' + String(error))
458
- // 分类后的提示替代原始 stderr 直传(M1-D4):识别为环境错误时给
459
- // 可行动文案,未识别时 buildFeedbackError 内部回落原文截断(保现状)
460
- setFeedback(messageId, { failed: true, ...buildFeedbackError(String(error)) })
461
- await handleSnapshotFailure(root, store)
462
- }
463
- }
464
-
465
- // 逐消息反馈写入(issue #7 失败可见性):成功无跳过 → 清除(重试成功
466
- // 自愈);失败/有跳过 → 记录。上限防泄漏:交替成功失败的长会话可以无限
467
- // 积累,Map 保插入序做 FIFO 淘汰。
468
- function setFeedback(messageId, rec) {
469
- const id = String(messageId)
470
- const keep = rec && ((rec.failed) || (Array.isArray(rec.skipped) && rec.skipped.length))
471
- if (keep) state.snapFeedback.set(id, rec)
472
- else state.snapFeedback.delete(id)
473
- if (state.snapFeedback.size > 200) state.snapFeedback.delete(state.snapFeedback.keys().next().value)
474
- }
475
-
476
- // snapshot-info 端点的反馈查询:优先逐消息记录;无记录但该 root 熔断中
477
- // 时反馈熔断状态(冷却期内的消息快照被静默跳过,客户端需要知道「不是
478
- // 还没好,是暂停了」)。客户端只对近 5 分钟的消息弹提示,历史消息查询
479
- // 不受影响。
480
- async function feedbackFor(sessionId, messageId) {
481
- const rec = state.snapFeedback.get(String(messageId || ''))
482
- if (rec) return rec
483
- if (!sessionId) return {}
484
- const root = await rt.resolveRoot(sessionId)
485
- if (root) {
486
- const f = snapFailures.get(root)
487
- if (f && Date.now() < f.skipUntil) {
488
- return { failed: true, error: '快照连续失败已暂停(熔断),约 ' + Math.ceil((f.skipUntil - Date.now()) / 60000) + ' 分钟后自动重试,详情见设置 · 插件配置 · 最近错误' }
489
- }
490
- }
491
- return {}
492
- }
493
-
494
- // 失败善后 = 清残骸 + 推进熔断。captureSnapshot 的调用方就是串行队列
495
- // (见 index.js 事件接线),这两个动作留在 catch 里顺势排队执行,
496
- // 与下一次快照天然互斥,无 git 锁竞态;自身整体 best-effort,
497
- // 清理失败不该让队列任务以异常收场。
498
- async function handleSnapshotFailure(root, store) {
499
- if (store && state.gitExe) {
500
- try {
501
- await rt.runShell(S.pruneScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
502
- } catch (error) {
503
- rt.recordError('recall prune after snapshot failure failed: ' + String(error))
504
- }
505
- }
506
- const f = snapFailures.get(root) || { count: 0, skipUntil: 0 }
507
- f.count++
508
- if (f.count >= FUSE_AFTER) {
509
- const backoff = Math.min(FUSE_BACKOFF_BASE_MS * 2 ** (f.count - FUSE_AFTER), FUSE_BACKOFF_CAP_MS)
510
- const wasFused = Date.now() < f.skipUntil
511
- f.skipUntil = Date.now() + backoff
512
- // 只在「未熔断→熔断」的跳变沿记录:熔断期间每条消息都会走上面的
513
- // 静默跳过分支,逐条记录会把最近错误环形缓冲刷成同一条目
514
- if (!wasFused) rt.recordError('recall snapshot fused after ' + f.count + ' consecutive failures, backoff ' + Math.round(backoff / 60000) + 'min for: ' + root)
515
- }
516
- snapFailures.set(root, f)
517
- }
518
-
519
- // 变更清单截断上限:防止超大工作区(几千个文件)把 DOM 与 JSON
520
- // 双双撑爆。清单对用户的价值集中在前若干条,其余以 truncated 计数
521
- // 汇总展示;total 保留完整计数让面板文案仍准确。
522
- const MAX_CHANGES = 500
523
-
524
- async function diffFor(messageId) {
525
- const snap = state.snapshots.get(String(messageId))
526
- if (!snap) return null
527
- const store = state.stores.get(snap.root)
528
- if (!store) return null
529
- // 8MB 上限:按平均每条 60 字节估算可容纳十余万条,正常项目远够;
530
- // 真超限时报错文案与「JSON 半截解析失败」的真实原因脱节,需显式检测
531
- const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE(), MAX_CHANGES), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
532
- const trimmed = text.trim()
533
- if (!trimmed) return { changes: [], total: 0, truncated: false, treeId: null }
534
- // PF-1:解析下沉到 parseDiffOutput(标记行剥离 + 截断 + 树指纹),纯函数单测钉住
535
- return parseDiffOutput(trimmed, rt.isWin, MAX_CHANGES)
536
- }
537
-
538
- async function rollbackFor(messageId) {
539
- const snap = state.snapshots.get(String(messageId))
540
- if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
541
- const store = state.stores.get(snap.root)
542
- if (!store) return { ok: false, error: '快照存储不可用' }
543
- try {
544
- const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
545
- const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
546
- if (!m) {
547
- // 无 ROLLBACK_OK 哨兵:脚本在输出哨兵前终止,工作区状态不可知——
548
- // 一律按「可能半回退」处理,交给 execute 侧救援(H1)。
549
- return { ok: false, partial: true, error: '回退脚本未正常完成(工作区可能处于半回退状态):' + text.slice(0, 300) }
550
- }
551
- const deleted = parseInt(m[1], 10)
552
- const restored = parseInt(m[2], 10)
553
- return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
554
- } catch (error) {
555
- // runShell 抛错(脚本异常终止):工作区同样可能半回退,交 execute 救援。
556
- const msg = String(error && error.message ? error.message : error)
557
- return { ok: false, partial: true, error: msg }
558
- }
559
- }
560
-
561
- // 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
562
- // 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
563
- // 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
564
- async function resolveCutSeq(sessionId, messageId) {
565
- if (!sessionId || !messageId) return null
566
- const cacheKey = String(sessionId) + '\u0000' + String(messageId)
567
- if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
568
- let result = null
569
- const live = sessions.get(sessionId)
570
- if (live && Array.isArray(live.events)) {
571
- result = scanCutSeq(live.events, messageId)
572
- } else {
573
- const query = ctx.get('sessionQuery')
574
- if (query) {
575
- try {
576
- const log = await query.readSession(sessionId)
577
- result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
578
- } catch (error) {
579
- result = null
580
- }
581
- }
582
- }
583
- state.cutSeqCache.set(cacheKey, result)
584
- return result
585
- }
586
-
587
- return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, feedbackFor, loadLineage, recordLineage }
588
- }
1
+ import * as E from "./errors.js";
2
+ import { buildFeedbackError } from "./diagnostics.js";
3
+ function parseSkipped(out) {
4
+ const skipped = [];
5
+ for (const line of String(out || "").split(/\r?\n/)) {
6
+ if (line.indexOf("SNAP_SKIP ") === 0) skipped.push(line.slice("SNAP_SKIP ".length));
7
+ }
8
+ return skipped;
9
+ }
10
+ function parseChanges(text, isWin) {
11
+ if (isWin) {
12
+ const parsed = JSON.parse(text);
13
+ if (Array.isArray(parsed)) return parsed;
14
+ if (parsed && typeof parsed === "object") return [parsed];
15
+ return [];
16
+ }
17
+ const out = [];
18
+ for (const line of text.split(/\r?\n/)) {
19
+ if (!line) continue;
20
+ const tab = line.indexOf(" ");
21
+ if (tab < 0) continue;
22
+ out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) });
23
+ }
24
+ return out;
25
+ }
26
+ function parseTreeId(out) {
27
+ for (const line of String(out || "").split(/\r?\n/)) {
28
+ if (line.indexOf("TREE ") === 0) return line.slice(5).trim() || null;
29
+ }
30
+ return null;
31
+ }
32
+ function parseDiffOutput(text, isWin, maxChanges) {
33
+ const lines = String(text || "").split(/\r?\n/);
34
+ let total = null;
35
+ let treeId = null;
36
+ const body = [];
37
+ for (const line of lines) {
38
+ if (line.indexOf("TREE ") === 0) {
39
+ treeId = line.slice(5).trim() || null;
40
+ continue;
41
+ }
42
+ if (line.indexOf("TOTAL ") === 0) {
43
+ const n = parseInt(line.slice(6), 10);
44
+ if (Number.isFinite(n) && n >= 0) total = n;
45
+ continue;
46
+ }
47
+ body.push(line);
48
+ }
49
+ const raw = body.join("\n").trim();
50
+ const all = raw ? parseChanges(raw, isWin) : [];
51
+ const finalTotal = total !== null ? total : all.length;
52
+ const changes = all.slice(0, maxChanges);
53
+ return { changes, total: finalTotal, truncated: finalTotal > changes.length, treeId };
54
+ }
55
+ function scanCutSeq(events, messageId) {
56
+ let anchor = -1;
57
+ for (let i = 0; i < events.length; i++) {
58
+ const e = events[i];
59
+ if (e && e.type === "user/message" && e.data && String(e.data.id) === String(messageId)) {
60
+ anchor = i;
61
+ break;
62
+ }
63
+ }
64
+ if (anchor < 0) return null;
65
+ for (let i = anchor - 1; i >= 0; i--) {
66
+ const e = events[i];
67
+ if (e && e.type === "turn/end" && typeof e.seq === "number") return e.seq;
68
+ }
69
+ return null;
70
+ }
71
+ async function rescueRollback(deps, opts) {
72
+ const { root, store, safetyId, safetyOk, rollbackError } = opts;
73
+ const reason = String(rollbackError || "\u672A\u77E5\u539F\u56E0");
74
+ if (!safetyOk) {
75
+ deps.recordError("recall rollback failed, no rescue snapshot: " + reason);
76
+ return { ok: false, code: E.RECALL_ROLLBACK_FAILED, message: "\u56DE\u9000\u5931\u8D25\uFF1A" + reason + "\uFF08\u65E0\u53EF\u7528\u5B89\u5168\u5FEB\u7167\uFF0C\u5DE5\u4F5C\u533A\u53EF\u80FD\u5904\u4E8E\u534A\u56DE\u9000\u72B6\u6001\uFF09" };
77
+ }
78
+ const tag = "snap-" + safetyId;
79
+ const manual = 'git --git-dir="' + store.git + '" --work-tree="' + root + '" reset --hard ' + tag;
80
+ try {
81
+ const out = await deps.runShell(deps.scripts.rescueScript(root, store, deps.gitExe, tag), { timeoutMs: 6e5, stdoutMaxBytes: 65536 });
82
+ if (String(out || "").indexOf("RESCUE_OK") < 0) throw new Error("rescue \u811A\u672C\u672A\u8F93\u51FA RESCUE_OK \u54E8\u5175");
83
+ deps.recordError("recall rollback failed, rescued to safety tag: " + tag + " \u2014 " + reason);
84
+ return { ok: false, code: E.RECALL_ROLLBACK_FAILED, message: "\u56DE\u9000\u5931\u8D25\uFF1A" + reason + "\uFF1B\u5DF2\u81EA\u52A8\u6062\u590D\u5230\u56DE\u9000\u524D\u7684\u5B89\u5168\u5FEB\u7167\uFF0C\u8BF7\u91CD\u65B0\u9884\u89C8\u540E\u91CD\u8BD5" };
85
+ } catch (rescueError) {
86
+ const re = rescueError;
87
+ const rescueReason = String(re && re.message ? re.message : rescueError);
88
+ deps.recordError("recall rollback failed and rescue failed: " + tag + " \u2014 " + reason + " | rescue: " + rescueReason);
89
+ return { ok: false, code: E.RECALL_ROLLBACK_FAILED, message: "\u56DE\u9000\u5931\u8D25\uFF1A" + reason + "\uFF1B\u81EA\u52A8\u6062\u590D\u4E5F\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u6267\u884C\uFF1A" + manual };
90
+ }
91
+ }
92
+ function isSafetySnapshotId(id) {
93
+ return typeof id === "string" && id.indexOf("pre-rollback-") === 0;
94
+ }
95
+ function parseTagsWithTime(text) {
96
+ const out = [];
97
+ for (const line of String(text || "").split(/\r?\n/)) {
98
+ const t = line.trim();
99
+ if (!t) continue;
100
+ const sp = t.lastIndexOf(" ");
101
+ const name = sp > 0 ? t.slice(0, sp) : t;
102
+ const ts = sp > 0 ? parseInt(t.slice(sp + 1), 10) : NaN;
103
+ out.push({ name, time: Number.isFinite(ts) && ts > 0 ? ts * 1e3 : null });
104
+ }
105
+ return out;
106
+ }
107
+ function createSnapshots(ctx, rt, config) {
108
+ const sessions = ctx.sessions;
109
+ const state = rt.state;
110
+ const S = rt.scripts;
111
+ const BASE = () => config.baseExcludes;
112
+ const FUSE_AFTER = 3;
113
+ const FUSE_BACKOFF_BASE_MS = 5 * 60 * 1e3;
114
+ const FUSE_BACKOFF_CAP_MS = 60 * 60 * 1e3;
115
+ const snapFailures = /* @__PURE__ */ new Map();
116
+ async function saveIndex(root, sessionId) {
117
+ const store = state.stores.get(root);
118
+ if (!store) return;
119
+ const entries = Array.from(state.snapshots.entries()).filter(([, s]) => s.root === root).map(([id, s]) => {
120
+ const rec = { id, time: s.time, root: s.root, sessionId: s.sessionId };
121
+ const fb = state.snapFeedback.get(id);
122
+ if (fb && (fb.failed || Array.isArray(fb.skipped) && fb.skipped.length)) rec.feedback = fb;
123
+ return rec;
124
+ });
125
+ try {
126
+ await rt.writeTextViaShell(store.dir + (rt.isWin ? "\\" : "/") + "index.json", JSON.stringify(entries));
127
+ } catch (error) {
128
+ rt.recordError("recall saveIndex failed: " + String(error));
129
+ }
130
+ }
131
+ async function loadIndex(root, sessionId) {
132
+ if (state.indexLoaded.has(root)) return;
133
+ const store = state.stores.get(root);
134
+ if (!store) return;
135
+ let raw = "";
136
+ let truncated = false;
137
+ try {
138
+ const meta = await rt.runShellMeta(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 });
139
+ raw = S.stripBom(meta.text).trim();
140
+ truncated = Boolean(meta.truncated);
141
+ } catch (error) {
142
+ return;
143
+ }
144
+ if (truncated) {
145
+ rt.recordError("recall index read truncated: " + root + " \u7684 index.json \u8D85\u8FC7\u8BFB\u53D6\u4E0A\u9650\uFF0C\u6309\u7A7A\u7D22\u5F15\u7EE7\u7EED\uFF08\u539F\u6587\u4EF6\u672A\u6539\u52A8\uFF0C\u4E0B\u6B21\u5199\u7D22\u5F15\u81EA\u7136\u8986\u76D6\uFF09");
146
+ state.indexLoaded.add(root);
147
+ state.indexTruncated.add(root);
148
+ return;
149
+ }
150
+ if (!raw) {
151
+ state.indexLoaded.add(root);
152
+ return;
153
+ }
154
+ let entries = null;
155
+ try {
156
+ entries = JSON.parse(raw);
157
+ } catch (error) {
158
+ if (await quarantineCorruptIndex(store)) state.indexLoaded.add(root);
159
+ return;
160
+ }
161
+ if (!Array.isArray(entries)) {
162
+ if (await quarantineCorruptIndex(store)) state.indexLoaded.add(root);
163
+ return;
164
+ }
165
+ let invalid = 0;
166
+ for (const entry of entries) {
167
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || !entry.id) {
168
+ invalid++;
169
+ continue;
170
+ }
171
+ state.snapshots.set(entry.id, {
172
+ root,
173
+ time: typeof entry.time === "number" ? entry.time : Date.now(),
174
+ sessionId: entry.sessionId || sessionId || ""
175
+ });
176
+ const fb = entry.feedback;
177
+ if (fb && typeof fb === "object") {
178
+ const rec = {};
179
+ if (fb.failed) {
180
+ rec.failed = true;
181
+ if (typeof fb.error === "string") rec.error = fb.error;
182
+ if (typeof fb.kind === "string") rec.kind = fb.kind;
183
+ }
184
+ if (Array.isArray(fb.skipped)) rec.skipped = fb.skipped.filter((p) => typeof p === "string");
185
+ if (rec.failed || Array.isArray(rec.skipped) && rec.skipped.length) setFeedback(entry.id, rec);
186
+ }
187
+ }
188
+ if (invalid > 0) rt.recordError("recall index has " + invalid + " invalid entries for: " + root);
189
+ state.indexLoaded.add(root);
190
+ state.indexHealthy.add(root);
191
+ }
192
+ const quarantineThrottle = /* @__PURE__ */ new Map();
193
+ function quarantineErrorThrottled(store, text) {
194
+ const last = quarantineThrottle.get(store.dir) || 0;
195
+ if (Date.now() - last < 5 * 60 * 1e3) return;
196
+ quarantineThrottle.set(store.dir, Date.now());
197
+ rt.recordError(text);
198
+ }
199
+ async function quarantineCorruptIndex(store) {
200
+ const sep = rt.isWin ? "\\" : "/";
201
+ const corrupt = store.dir + sep + "index.json.corrupt-" + Date.now();
202
+ try {
203
+ await rt.runShell(S.renameFileCmd(store.dir + sep + "index.json", corrupt), { stdoutMaxBytes: 4096 });
204
+ rt.recordError("recall index corrupt: \u5DF2\u6309\u7A7A\u7D22\u5F15\u7EE7\u7EED\uFF0C\u574F\u6587\u4EF6\u4FDD\u7559\u4E3A " + corrupt);
205
+ return true;
206
+ } catch (error) {
207
+ quarantineErrorThrottled(store, "recall index quarantine failed: " + String(error));
208
+ return false;
209
+ }
210
+ }
211
+ async function loadLineage(root) {
212
+ const store = state.stores.get(root);
213
+ if (!store) return [];
214
+ try {
215
+ const raw = S.stripBom(await rt.runShell(S.lineageReadCmd(store.dir), { stdoutMaxBytes: 1048576 })).trim();
216
+ if (!raw) return [];
217
+ const arr = JSON.parse(raw);
218
+ return Array.isArray(arr) ? arr.filter((e) => e && typeof e.childId === "string" && typeof e.parentId === "string") : [];
219
+ } catch (error) {
220
+ return [];
221
+ }
222
+ }
223
+ async function recordLineage(root, childId, parentId) {
224
+ const store = state.stores.get(root);
225
+ if (!store) return;
226
+ const sep = rt.isWin ? "\\" : "/";
227
+ const existing = await loadLineage(root);
228
+ if (!existing.some((e) => e.childId === childId && e.parentId === parentId)) {
229
+ existing.push({ childId, parentId, time: Date.now() });
230
+ try {
231
+ await rt.writeTextViaShell(store.dir + sep + "lineage.json", JSON.stringify(existing));
232
+ } catch (error) {
233
+ rt.recordError("recall recordLineage failed: " + String(error));
234
+ }
235
+ }
236
+ }
237
+ async function readExclude(store) {
238
+ return S.stripBom(await rt.runShell(S.excludeReadCmd(store.excludeFile), { stdoutMaxBytes: 1048576 }));
239
+ }
240
+ async function writeExclude(store, text) {
241
+ const body = String(text == null ? "" : text);
242
+ const sep = rt.isWin ? "\\" : "/";
243
+ const parent = store.excludeFile.slice(0, store.excludeFile.lastIndexOf(sep));
244
+ await rt.runShell(S.mkdirScript(parent), { stdoutMaxBytes: 4096 });
245
+ await rt.writeTextViaShell(store.excludeFile, body);
246
+ }
247
+ async function rebuildOrphans(root, sessionId) {
248
+ if (state.indexTruncated.has(root)) return;
249
+ if (state.indexHealthy.has(root)) {
250
+ let count = 0;
251
+ for (const s of state.snapshots.values()) {
252
+ if (s && s.root === root) count++;
253
+ }
254
+ if (count > 0) return;
255
+ }
256
+ const store = state.stores.get(root);
257
+ const gitExe = await rt.resolveGit();
258
+ if (!store || !gitExe) return;
259
+ try {
260
+ const listing = S.stripBom(await rt.runShell(S.listTagsWithTimeScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim();
261
+ if (!listing) return;
262
+ for (const { name, time } of parseTagsWithTime(listing)) {
263
+ const id = name.replace(/^snap-/, "");
264
+ if (!id || isSafetySnapshotId(id) || state.snapshots.has(id)) continue;
265
+ state.snapshots.set(id, { root, time: time || 0, sessionId });
266
+ }
267
+ await saveIndex(root, sessionId);
268
+ } catch (error) {
269
+ rt.recordError("recall rebuildOrphans failed: " + String(error));
270
+ }
271
+ }
272
+ async function captureSnapshot(sessionId, messageId, time) {
273
+ const root = await rt.resolveRoot(sessionId);
274
+ if (!root) return;
275
+ const fused = snapFailures.get(root);
276
+ if (fused && Date.now() < fused.skipUntil) return;
277
+ let store = await rt.resolveStore(root);
278
+ store = await rt.tryUpgradeToHome(root);
279
+ const g = await rt.ensureGit(root, store);
280
+ if (!g.ok) {
281
+ setFeedback(messageId, { failed: true, ...buildFeedbackError(g.error || "\u672A\u77E5\u539F\u56E0") });
282
+ return;
283
+ }
284
+ await loadIndex(root, sessionId);
285
+ try {
286
+ const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe || "", messageId, BASE()), { timeoutMs: 6e5, stdoutMaxBytes: 65536 });
287
+ snapFailures.delete(root);
288
+ state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId });
289
+ await saveIndex(root, sessionId);
290
+ setFeedback(messageId, { skipped: parseSkipped(out) });
291
+ } catch (error) {
292
+ rt.recordError("recall snapshot failed: " + String(error));
293
+ setFeedback(messageId, { failed: true, ...buildFeedbackError(String(error)) });
294
+ await handleSnapshotFailure(root, store);
295
+ }
296
+ }
297
+ function setFeedback(messageId, rec) {
298
+ const id = String(messageId);
299
+ const keep = rec && (rec.failed || Array.isArray(rec.skipped) && rec.skipped.length);
300
+ if (keep) state.snapFeedback.set(id, rec);
301
+ else state.snapFeedback.delete(id);
302
+ if (state.snapFeedback.size > 200) state.snapFeedback.delete(state.snapFeedback.keys().next().value);
303
+ }
304
+ async function feedbackFor(sessionId, messageId) {
305
+ const rec = state.snapFeedback.get(String(messageId || ""));
306
+ if (rec) return rec;
307
+ if (!sessionId) return {};
308
+ const root = await rt.resolveRoot(sessionId);
309
+ if (root) {
310
+ const f = snapFailures.get(root);
311
+ if (f && Date.now() < f.skipUntil) {
312
+ return { failed: true, error: "\u5FEB\u7167\u8FDE\u7EED\u5931\u8D25\u5DF2\u6682\u505C\uFF08\u7194\u65AD\uFF09\uFF0C\u7EA6 " + Math.ceil((f.skipUntil - Date.now()) / 6e4) + " \u5206\u949F\u540E\u81EA\u52A8\u91CD\u8BD5\uFF0C\u8BE6\u60C5\u89C1\u8BBE\u7F6E \xB7 \u63D2\u4EF6\u914D\u7F6E \xB7 \u6700\u8FD1\u9519\u8BEF" };
313
+ }
314
+ }
315
+ return {};
316
+ }
317
+ async function handleSnapshotFailure(root, store) {
318
+ if (store && state.gitExe) {
319
+ try {
320
+ await rt.runShell(S.pruneScript(store, state.gitExe), { timeoutMs: 6e5, stdoutMaxBytes: 4096 });
321
+ } catch (error) {
322
+ rt.recordError("recall prune after snapshot failure failed: " + String(error));
323
+ }
324
+ }
325
+ const f = snapFailures.get(root) || { count: 0, skipUntil: 0 };
326
+ f.count++;
327
+ if (f.count >= FUSE_AFTER) {
328
+ const backoff = Math.min(FUSE_BACKOFF_BASE_MS * 2 ** (f.count - FUSE_AFTER), FUSE_BACKOFF_CAP_MS);
329
+ const wasFused = Date.now() < f.skipUntil;
330
+ f.skipUntil = Date.now() + backoff;
331
+ if (!wasFused) rt.recordError("recall snapshot fused after " + f.count + " consecutive failures, backoff " + Math.round(backoff / 6e4) + "min for: " + root);
332
+ }
333
+ snapFailures.set(root, f);
334
+ }
335
+ const MAX_CHANGES = 500;
336
+ async function diffFor(messageId) {
337
+ const snap = state.snapshots.get(String(messageId));
338
+ if (!snap) return null;
339
+ const store = state.stores.get(snap.root);
340
+ if (!store) return null;
341
+ const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe || "", "snap-" + messageId, BASE(), MAX_CHANGES), { timeoutMs: 6e5, stdoutMaxBytes: 8388608 }));
342
+ const trimmed = text.trim();
343
+ if (!trimmed) return { changes: [], total: 0, truncated: false, treeId: null };
344
+ return parseDiffOutput(trimmed, rt.isWin, MAX_CHANGES);
345
+ }
346
+ async function rollbackFor(messageId) {
347
+ const snap = state.snapshots.get(String(messageId));
348
+ if (!snap) return { ok: false, error: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
349
+ const store = state.stores.get(snap.root);
350
+ if (!store) return { ok: false, error: "\u5FEB\u7167\u5B58\u50A8\u4E0D\u53EF\u7528" };
351
+ try {
352
+ const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe || "", "snap-" + messageId, BASE()), { timeoutMs: 6e5, stdoutMaxBytes: 65536 }));
353
+ const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/);
354
+ if (!m) {
355
+ return { ok: false, partial: true, error: "\u56DE\u9000\u811A\u672C\u672A\u6B63\u5E38\u5B8C\u6210\uFF08\u5DE5\u4F5C\u533A\u53EF\u80FD\u5904\u4E8E\u534A\u56DE\u9000\u72B6\u6001\uFF09\uFF1A" + text.slice(0, 300) };
356
+ }
357
+ const deleted = parseInt(m[1], 10);
358
+ const restored = parseInt(m[2], 10);
359
+ return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) };
360
+ } catch (error) {
361
+ const re = error;
362
+ const msg = String(re && re.message ? re.message : error);
363
+ return { ok: false, partial: true, error: msg };
364
+ }
365
+ }
366
+ async function resolveCutSeq(sessionId, messageId) {
367
+ if (!sessionId || !messageId) return null;
368
+ const cacheKey = String(sessionId) + "\0" + String(messageId);
369
+ if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey) || null;
370
+ let result = null;
371
+ const live = sessions.get(sessionId);
372
+ if (live && Array.isArray(live.events)) {
373
+ result = scanCutSeq(live.events, messageId);
374
+ } else {
375
+ const query = ctx.get("sessionQuery");
376
+ if (query) {
377
+ try {
378
+ const log = await query.readSession(sessionId);
379
+ result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId);
380
+ } catch (error) {
381
+ result = null;
382
+ }
383
+ }
384
+ }
385
+ state.cutSeqCache.set(cacheKey, result);
386
+ return result;
387
+ }
388
+ return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, feedbackFor, loadLineage, recordLineage };
389
+ }
390
+ export {
391
+ createSnapshots,
392
+ isSafetySnapshotId,
393
+ parseChanges,
394
+ parseDiffOutput,
395
+ parseSkipped,
396
+ parseTagsWithTime,
397
+ parseTreeId,
398
+ rescueRollback,
399
+ scanCutSeq
400
+ };