dsh-recall-plugin 2.0.0 → 2.1.1
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/CHANGELOG.md +63 -29
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/client.js +1459 -1479
- package/lib/diagnostics.js +59 -0
- package/lib/errors.js +73 -0
- package/lib/index.js +426 -1129
- package/lib/routes-core.js +162 -0
- package/lib/routes-manage.js +541 -0
- package/lib/scripts.posix.js +195 -27
- package/lib/scripts.pwsh.js +194 -33
- package/lib/session-info.js +71 -0
- package/lib/snapshots.js +243 -39
- package/lib/store.js +157 -19
- package/package.json +65 -61
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — 会话信息域(R2 从 index.js 拆出)
|
|
3
|
+
*
|
|
4
|
+
* 会话标题/消息文本的「两段式」读取:纯函数(titleFromEvents /
|
|
5
|
+
* messageTextFromEvents)模块级导出供单测与工厂共用;live 快速查询
|
|
6
|
+
* (liveTitleFast / liveMessageTextFast)带 apply 级跨请求缓存(sessionTitles /
|
|
7
|
+
* messageTexts Map),由 createSessionInfo 工厂生产——无模块级可变状态(HMR 假设)。
|
|
8
|
+
*
|
|
9
|
+
* 冷会话标题/文本要 readSession 整日志解压(10 秒级),列表首屏只查 live/缓存
|
|
10
|
+
* (同步、瞬时),冷数据由 Client 拿到列表后异步调 titles/messages 端点补齐。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// 从事件序列里取最新一条 session/title(倒序,标题事件通常靠后)
|
|
14
|
+
export function titleFromEvents(events) {
|
|
15
|
+
if (!Array.isArray(events)) return null
|
|
16
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
17
|
+
const e = events[i]
|
|
18
|
+
if (e && e.type === 'session/title' && e.data && typeof e.data.title === 'string' && e.data.title) return e.data.title
|
|
19
|
+
}
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 从事件序列里取指定用户消息的纯文本(text 块拼接)
|
|
24
|
+
export function messageTextFromEvents(events, messageId) {
|
|
25
|
+
if (!Array.isArray(events) || !messageId) return null
|
|
26
|
+
for (const e of events) {
|
|
27
|
+
if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
|
|
28
|
+
const blocks = Array.isArray(e.data.content) ? e.data.content : []
|
|
29
|
+
const text = blocks
|
|
30
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
31
|
+
.map((b) => b.text)
|
|
32
|
+
.join('')
|
|
33
|
+
return text || null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createSessionInfo(ctx) {
|
|
40
|
+
// 会话标题缓存:值为 null 表示「查过、确实没有」(已删除会话),同样命中缓存
|
|
41
|
+
const sessionTitles = new Map()
|
|
42
|
+
// 消息文本缓存:null 也缓存(避免无文本消息每次刷新重复解压冷日志)
|
|
43
|
+
const messageTexts = new Map()
|
|
44
|
+
|
|
45
|
+
function liveMessageTextFast(sessionId, messageId) {
|
|
46
|
+
if (!sessionId || !messageId) return null
|
|
47
|
+
const key = String(sessionId) + '\u0000' + String(messageId)
|
|
48
|
+
if (messageTexts.has(key)) return messageTexts.get(key)
|
|
49
|
+
let text = null
|
|
50
|
+
try {
|
|
51
|
+
const live = ctx.sessions.get(sessionId)
|
|
52
|
+
if (live) text = messageTextFromEvents(live.events, messageId)
|
|
53
|
+
} catch (error) { text = null }
|
|
54
|
+
if (text !== null) messageTexts.set(key, text)
|
|
55
|
+
return text
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function liveTitleFast(sessionId) {
|
|
59
|
+
if (!sessionId) return null
|
|
60
|
+
if (sessionTitles.has(sessionId)) return sessionTitles.get(sessionId)
|
|
61
|
+
let t = null
|
|
62
|
+
try {
|
|
63
|
+
const live = ctx.sessions.get(sessionId)
|
|
64
|
+
if (live) t = titleFromEvents(live.events)
|
|
65
|
+
} catch (error) { t = null }
|
|
66
|
+
if (t !== null) sessionTitles.set(sessionId, t)
|
|
67
|
+
return t
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { sessionTitles, messageTexts, liveTitleFast, liveMessageTextFast }
|
|
71
|
+
}
|
package/lib/snapshots.js
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import * as E from './errors.js'
|
|
11
|
+
import { buildFeedbackError } from './diagnostics.js'
|
|
12
|
+
|
|
10
13
|
// ---- 纯逻辑(模块级导出,供 tests/unit 直接钉住;工厂内沿用同一实现)----
|
|
11
14
|
|
|
12
15
|
// 脚本侧 fail-open 跳过的路径(--ignore-errors 下无法索引的目录,如无
|
|
@@ -57,6 +60,67 @@ export function scanCutSeq(events, messageId) {
|
|
|
57
60
|
return null
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
// H1:回退失败后的救援编排(模块级纯逻辑,deps 注入副作用,供单测钉三分支)。
|
|
64
|
+
// rollbackFor 失败(partial=true,工作区可能半回退)时,execute 已先打下
|
|
65
|
+
// pre-rollback-<ts> 安全快照(见 index.js);snapshotScript 打 tag 无条件加
|
|
66
|
+
// snap- 前缀,实际 tag 名是 snap-pre-rollback-<ts>——这里在调用侧拼出完整
|
|
67
|
+
// tag 再传给 rescueScript(F-S1:前缀知识留在唯一知道 safetyId 语义的编排
|
|
68
|
+
// 层,rescueScript 保持通用只接受完整 tag 名)。rescue 本身幂等:即使
|
|
69
|
+
// rollback 实际未动工作区,reset 到安全快照也只是把工作区恢复成回退前
|
|
70
|
+
// (≈当前)状态。无安全快照(safety 快照当时失败)时退化为现状 fail-loud,
|
|
71
|
+
// 不静默。
|
|
72
|
+
export async function rescueRollback(deps, opts) {
|
|
73
|
+
const { root, store, safetyId, safetyOk, rollbackError } = opts
|
|
74
|
+
const reason = String(rollbackError || '未知原因')
|
|
75
|
+
if (!safetyOk) {
|
|
76
|
+
deps.recordError('recall rollback failed, no rescue snapshot: ' + reason)
|
|
77
|
+
return { ok: false, code: E.RECALL_ROLLBACK_FAILED, message: '回退失败:' + reason + '(无可用安全快照,工作区可能处于半回退状态)' }
|
|
78
|
+
}
|
|
79
|
+
const tag = 'snap-' + safetyId
|
|
80
|
+
// 手动恢复命令供用户复制执行:路径加引号让含空格的工作区路径可直接跑,
|
|
81
|
+
// 目标与 rescueScript 同用完整 tag 名(两条路必须指向同一个快照)。
|
|
82
|
+
const manual = 'git --git-dir="' + store.git + '" --work-tree="' + root + '" reset --hard ' + tag
|
|
83
|
+
try {
|
|
84
|
+
const out = await deps.runShell(deps.scripts.rescueScript(root, store, deps.gitExe, tag), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
85
|
+
// RESCUE_OK 哨兵校验(与 rollbackFor 的 ROLLBACK_OK 对称):pwsh 对
|
|
86
|
+
// native 非零退出不抛,脚本模板里虽有 $LASTEXITCODE 显式 throw 兜底,
|
|
87
|
+
// 但「脚本跑完、git 静默未生效」的假成功只能靠哨兵识别——哨兵缺失按
|
|
88
|
+
// 救援失败处理,走手动命令分支,不静默。
|
|
89
|
+
if (String(out || '').indexOf('RESCUE_OK') < 0) throw new Error('rescue 脚本未输出 RESCUE_OK 哨兵')
|
|
90
|
+
deps.recordError('recall rollback failed, rescued to safety tag: ' + tag + ' — ' + reason)
|
|
91
|
+
return { ok: false, code: E.RECALL_ROLLBACK_FAILED, message: '回退失败:' + reason + ';已自动恢复到回退前的安全快照,请重新预览后重试' }
|
|
92
|
+
} catch (rescueError) {
|
|
93
|
+
const rescueReason = String(rescueError && rescueError.message ? rescueError.message : rescueError)
|
|
94
|
+
deps.recordError('recall rollback failed and rescue failed: ' + tag + ' — ' + reason + ' | rescue: ' + rescueReason)
|
|
95
|
+
return { ok: false, code: E.RECALL_ROLLBACK_FAILED, message: '回退失败:' + reason + ';自动恢复也失败,请手动执行:' + manual }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// F-G1:safety 快照 id 识别(模块级纯逻辑,rebuildOrphans 与 manage list
|
|
100
|
+
// 共用同一谓词)。安全 tag 是回退前自动打下的救援锚点(pre-rollback-<ts>,
|
|
101
|
+
// 见 routes-core.js execute),不是消息快照——不进索引、不在列表展示。
|
|
102
|
+
// 消息 ID 为系统生成 GUID,前缀碰撞概率为零(plan-competitor-fixes F-G1 风险节)。
|
|
103
|
+
export function isSafetySnapshotId(id) {
|
|
104
|
+
return typeof id === 'string' && id.indexOf('pre-rollback-') === 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// listTagsWithTimeScript 输出解析(模块级纯逻辑,便于单测):每行
|
|
108
|
+
// 「<tag名> <秒级时间戳>」,for-each-ref 的 refname 不含空格、时间戳
|
|
109
|
+
// 恒为行尾整数。时间解析失败/缺省回退 null——调用方以 0 兜底(保留
|
|
110
|
+
// 旧「无时间」行为),绝不因格式漂移丢 tag。
|
|
111
|
+
export function parseTagsWithTime(text) {
|
|
112
|
+
const out = []
|
|
113
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
114
|
+
const t = line.trim()
|
|
115
|
+
if (!t) continue
|
|
116
|
+
const sp = t.lastIndexOf(' ')
|
|
117
|
+
const name = sp > 0 ? t.slice(0, sp) : t
|
|
118
|
+
const ts = sp > 0 ? parseInt(t.slice(sp + 1), 10) : NaN
|
|
119
|
+
out.push({ name, time: Number.isFinite(ts) && ts > 0 ? ts * 1000 : null })
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
60
124
|
// ---- 配置工厂 ----
|
|
61
125
|
|
|
62
126
|
export function createSnapshots(ctx, rt, config) {
|
|
@@ -114,35 +178,144 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
114
178
|
if (state.indexLoaded.has(root)) return
|
|
115
179
|
const store = state.stores.get(root)
|
|
116
180
|
if (!store) return
|
|
181
|
+
let raw = ''
|
|
182
|
+
let truncated = false
|
|
117
183
|
try {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
184
|
+
const meta = await rt.runShellMeta(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })
|
|
185
|
+
raw = S.stripBom(meta.text).trim()
|
|
186
|
+
truncated = Boolean(meta.truncated)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// 读索引失败(shell 未就绪等):不标记已载入,下次自然重试(既有语义)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
if (truncated) {
|
|
192
|
+
// F-G3:读截断 ≠ 索引损坏。stdout 超 4MB 上限时官方 shell 只回传流
|
|
193
|
+
// 尾部(runShellMeta 暴露的 CollectedOutput.truncated 可判定),JSON
|
|
194
|
+
// 头已丢,parse 必失败——若走下方损坏分支会把好文件改名 .corrupt,
|
|
195
|
+
// 索引记录(time/sessionId/feedback)全丢、rebuild 后变 time=0 条目
|
|
196
|
+
// 又触发清理链。改为:不隔离、原文件原样保留,按空索引继续(标记
|
|
197
|
+
// indexLoaded 防止重试循环刷错误环),本次绝不写回(防用残缺内存
|
|
198
|
+
// 覆盖好文件);下一次自然写索引时按当下内存状态覆盖,tag 是真相源,
|
|
199
|
+
// 孤儿重建随时可反推兜底。4MB ≈ 每条约 100B × 4 万条,
|
|
200
|
+
// maxSnapshotsPerWorkspace=0(不限)的长期工作区可能触达——上限即
|
|
201
|
+
// 天花板的取舍记录于此。
|
|
202
|
+
rt.recordError('recall index read truncated: ' + root + ' 的 index.json 超过读取上限,按空索引继续(原文件未改动,下次写索引自然覆盖)')
|
|
203
|
+
state.indexLoaded.add(root)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (!raw) { state.indexLoaded.add(root); return }
|
|
207
|
+
let entries = null
|
|
208
|
+
try {
|
|
209
|
+
entries = JSON.parse(raw)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
// H2:索引损坏 fail-loud——坏文件改名 .corrupt-<ts> 保留现场 + 记错误,
|
|
212
|
+
// 按空索引继续(rebuildOrphans 从 tag 名反推重建,数据不丢),不再静默当空。
|
|
213
|
+
if (await quarantineCorruptIndex(store)) state.indexLoaded.add(root)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (!Array.isArray(entries)) {
|
|
217
|
+
// H2:整体形状非法(非数组)同样按损坏处理,保留现场。
|
|
218
|
+
if (await quarantineCorruptIndex(store)) state.indexLoaded.add(root)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
let invalid = 0
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
// H2:逐条过滤非法条目(非对象 / 缺 string id / 空 id——A6:空串 id
|
|
224
|
+
// 是垃圾条目,进索引会让 snapshot-info 与回退按空主键查找)并计数告警,
|
|
225
|
+
// 整体不判死;root/time 的宽松兼容保留(root 以参数为准、time 缺省回退
|
|
226
|
+
// now)——那是旧索引双向兼容策略,不属「损坏」。
|
|
227
|
+
if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string' || !entry.id) { invalid++; continue }
|
|
228
|
+
state.snapshots.set(entry.id, {
|
|
229
|
+
root,
|
|
230
|
+
time: typeof entry.time === 'number' ? entry.time : Date.now(),
|
|
231
|
+
sessionId: entry.sessionId || sessionId
|
|
232
|
+
})
|
|
233
|
+
// feedback 回填(P1-2):重启后仍能解释「这条消息为什么没有/缺了
|
|
234
|
+
// 快照」。复用 setFeedback 落内存(保持 FIFO 上限),只回填「需要
|
|
235
|
+
// 解释」的记录;旧版索引无 feedback 字段时天然跳过。kind(M1 环境错误
|
|
236
|
+
// 分类)随对象保留——序列化是整体对象,但这里的重建是字段白名单,
|
|
237
|
+
// 漏 kind 会让重启后的失败条目丢失分类、status hint 失效。
|
|
238
|
+
const fb = entry.feedback
|
|
239
|
+
if (fb && typeof fb === 'object') {
|
|
240
|
+
const rec = {}
|
|
241
|
+
if (fb.failed) {
|
|
242
|
+
rec.failed = true
|
|
243
|
+
if (typeof fb.error === 'string') rec.error = fb.error
|
|
244
|
+
if (typeof fb.kind === 'string') rec.kind = fb.kind
|
|
138
245
|
}
|
|
246
|
+
if (Array.isArray(fb.skipped)) rec.skipped = fb.skipped.filter((p) => typeof p === 'string')
|
|
247
|
+
if (rec.failed || (Array.isArray(rec.skipped) && rec.skipped.length)) setFeedback(entry.id, rec)
|
|
139
248
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
249
|
+
}
|
|
250
|
+
if (invalid > 0) rt.recordError('recall index has ' + invalid + ' invalid entries for: ' + root)
|
|
251
|
+
// 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
|
|
252
|
+
// runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
|
|
253
|
+
// 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
|
|
254
|
+
state.indexLoaded.add(root)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// A5:quarantine 失败告警节流(每 store 5 分钟最多一条)。rename 失败时
|
|
258
|
+
// indexLoaded 不标记 → 下次 loadIndex 重试 → 再失败会再告警,同一环境性
|
|
259
|
+
// 故障(权限/磁盘)会把最近错误环(20 条)瞬间刷满;节流保留告警存在性
|
|
260
|
+
// 同时防刷屏。Map 挂工厂闭包而非模块级(HMR 假设)。
|
|
261
|
+
const quarantineThrottle = new Map()
|
|
262
|
+
function quarantineErrorThrottled(store, text) {
|
|
263
|
+
const last = quarantineThrottle.get(store.dir) || 0
|
|
264
|
+
if (Date.now() - last < 5 * 60 * 1000) return
|
|
265
|
+
quarantineThrottle.set(store.dir, Date.now())
|
|
266
|
+
rt.recordError(text)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// H2:损坏索引现场保留——改名 index.json.corrupt-<ts> 而非删除,供排障;
|
|
270
|
+
// 改完名原路径即空,下次 loadIndex 读到空按「无索引」处理,不重复告警。
|
|
271
|
+
// 返回是否改名成功:失败时不标记 indexLoaded,下次重试而非让坏文件被跳过。
|
|
272
|
+
async function quarantineCorruptIndex(store) {
|
|
273
|
+
const sep = rt.isWin ? '\\' : '/'
|
|
274
|
+
const corrupt = store.dir + sep + 'index.json.corrupt-' + Date.now()
|
|
275
|
+
try {
|
|
276
|
+
await rt.runShell(S.renameFileCmd(store.dir + sep + 'index.json', corrupt), { stdoutMaxBytes: 4096 })
|
|
277
|
+
rt.recordError('recall index corrupt: 已按空索引继续,坏文件保留为 ' + corrupt)
|
|
278
|
+
return true
|
|
279
|
+
} catch (error) {
|
|
280
|
+
quarantineErrorThrottled(store, 'recall index quarantine failed: ' + String(error))
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- F1 fork lineage 持久化 ----
|
|
286
|
+
// 撤回多次产生 A→B→C 链,但中间版本归档后从 sessions.list 隐藏,client 侧
|
|
287
|
+
// 拿不到完整父链——Host 在 client fork 上报时记录 childId↔parentId 到
|
|
288
|
+
// store 目录的 lineage.json(原子写),快照管理树据此聚族展示「版本家族」。
|
|
289
|
+
// lineage.json 损坏不致命(与 index.json 损坏 fail-loud 语义区分):按无
|
|
290
|
+
// lineage 处理,快照树退化为现有「工作区 → 会话」分组。
|
|
291
|
+
async function loadLineage(root) {
|
|
292
|
+
const store = state.stores.get(root)
|
|
293
|
+
if (!store) return []
|
|
294
|
+
try {
|
|
295
|
+
const raw = S.stripBom(await rt.runShell(S.lineageReadCmd(store.dir), { stdoutMaxBytes: 1048576 })).trim()
|
|
296
|
+
if (!raw) return []
|
|
297
|
+
const arr = JSON.parse(raw)
|
|
298
|
+
return Array.isArray(arr)
|
|
299
|
+
? arr.filter((e) => e && typeof e.childId === 'string' && typeof e.parentId === 'string')
|
|
300
|
+
: []
|
|
144
301
|
} catch (error) {
|
|
145
|
-
|
|
302
|
+
return []
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function recordLineage(root, childId, parentId) {
|
|
307
|
+
const store = state.stores.get(root)
|
|
308
|
+
if (!store) return
|
|
309
|
+
const sep = rt.isWin ? '\\' : '/'
|
|
310
|
+
const existing = await loadLineage(root)
|
|
311
|
+
// 去重:同一 (childId, parentId) 只记一次(fork 幂等)
|
|
312
|
+
if (!existing.some((e) => e.childId === childId && e.parentId === parentId)) {
|
|
313
|
+
existing.push({ childId, parentId, time: Date.now() })
|
|
314
|
+
try {
|
|
315
|
+
await rt.writeTextViaShell(store.dir + sep + 'lineage.json', JSON.stringify(existing))
|
|
316
|
+
} catch (error) {
|
|
317
|
+
rt.recordError('recall recordLineage failed: ' + String(error))
|
|
318
|
+
}
|
|
146
319
|
}
|
|
147
320
|
}
|
|
148
321
|
|
|
@@ -170,12 +343,20 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
170
343
|
const gitExe = await rt.resolveGit()
|
|
171
344
|
if (!store || !gitExe) return
|
|
172
345
|
try {
|
|
173
|
-
|
|
346
|
+
// 带时间戳清单(listTagsWithTimeScript):重建条目从 tag 的
|
|
347
|
+
// creatordate 恢复 time——此前只列 tag 名,重建条目一律 time=0,
|
|
348
|
+
// 管理列表时间前缀缺失、retention/limits 按「最旧」误清真实快照。
|
|
349
|
+
const listing = S.stripBom(await rt.runShell(S.listTagsWithTimeScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
|
|
174
350
|
if (!listing) return
|
|
175
|
-
for (const name of listing
|
|
176
|
-
const id = name.
|
|
177
|
-
|
|
178
|
-
|
|
351
|
+
for (const { name, time } of parseTagsWithTime(listing)) {
|
|
352
|
+
const id = name.replace(/^snap-/, '')
|
|
353
|
+
// F-G1:安全 tag(snap-pre-rollback-<ts>)只作救援锚点,不进索引——
|
|
354
|
+
// 否则被 rebuild 成 time=0 条目后会进快照管理列表、占
|
|
355
|
+
// maxSnapshotsPerWorkspace 配额、被 retention/limits 当「最旧」优先
|
|
356
|
+
// 清掉,H1 的救援点在重度使用下会随 purge 消失(与 routes-core.js
|
|
357
|
+
// execute「不进 index.json、列表不展示」的设计承诺对齐)。
|
|
358
|
+
if (!id || isSafetySnapshotId(id) || state.snapshots.has(id)) continue
|
|
359
|
+
state.snapshots.set(id, { root, time: time || 0, sessionId })
|
|
179
360
|
}
|
|
180
361
|
await saveIndex(root, sessionId)
|
|
181
362
|
} catch (error) {
|
|
@@ -192,8 +373,18 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
192
373
|
if (fused && Date.now() < fused.skipUntil) return
|
|
193
374
|
let store = await rt.resolveStore(root)
|
|
194
375
|
store = await rt.tryUpgradeToHome(root)
|
|
195
|
-
|
|
196
|
-
|
|
376
|
+
// ensureGit 失败(issue #11 主线缺口):原先静默 return,不进
|
|
377
|
+
// snapFeedback,客户端空轮询 20 次后放弃、用户零感知。现在走与
|
|
378
|
+
// snapshotScript 失败相同的反馈通道——buildFeedbackError 把原始
|
|
379
|
+
// stderr 分类成可行动提示(锁冲突/磁盘满等),客户端轮询到 failed
|
|
380
|
+
// 即弹「快照失败:<提示>」并停止轮询。不接熔断:环境类失败常可自愈
|
|
381
|
+
// (清磁盘/退锁后下一条消息即恢复),保持按消息重试,刷屏由
|
|
382
|
+
// recordError 尾部去重与 toast 10min 节流缓解。
|
|
383
|
+
const g = await rt.ensureGit(root, store)
|
|
384
|
+
if (!g.ok) {
|
|
385
|
+
setFeedback(messageId, { failed: true, ...buildFeedbackError(g.error || '未知原因') })
|
|
386
|
+
return
|
|
387
|
+
}
|
|
197
388
|
await loadIndex(root, sessionId)
|
|
198
389
|
try {
|
|
199
390
|
const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
@@ -203,7 +394,9 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
203
394
|
setFeedback(messageId, { skipped: parseSkipped(out) })
|
|
204
395
|
} catch (error) {
|
|
205
396
|
rt.recordError('recall snapshot failed: ' + String(error))
|
|
206
|
-
|
|
397
|
+
// 分类后的提示替代原始 stderr 直传(M1-D4):识别为环境错误时给
|
|
398
|
+
// 可行动文案,未识别时 buildFeedbackError 内部回落原文截断(保现状)
|
|
399
|
+
setFeedback(messageId, { failed: true, ...buildFeedbackError(String(error)) })
|
|
207
400
|
await handleSnapshotFailure(root, store)
|
|
208
401
|
}
|
|
209
402
|
}
|
|
@@ -286,11 +479,22 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
286
479
|
if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
|
|
287
480
|
const store = state.stores.get(snap.root)
|
|
288
481
|
if (!store) return { ok: false, error: '快照存储不可用' }
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
482
|
+
try {
|
|
483
|
+
const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
|
|
484
|
+
const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
|
|
485
|
+
if (!m) {
|
|
486
|
+
// 无 ROLLBACK_OK 哨兵:脚本在输出哨兵前终止,工作区状态不可知——
|
|
487
|
+
// 一律按「可能半回退」处理,交给 execute 侧救援(H1)。
|
|
488
|
+
return { ok: false, partial: true, error: '回退脚本未正常完成(工作区可能处于半回退状态):' + text.slice(0, 300) }
|
|
489
|
+
}
|
|
490
|
+
const deleted = parseInt(m[1], 10)
|
|
491
|
+
const restored = parseInt(m[2], 10)
|
|
492
|
+
return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
|
|
493
|
+
} catch (error) {
|
|
494
|
+
// runShell 抛错(脚本异常终止):工作区同样可能半回退,交 execute 救援。
|
|
495
|
+
const msg = String(error && error.message ? error.message : error)
|
|
496
|
+
return { ok: false, partial: true, error: msg }
|
|
497
|
+
}
|
|
294
498
|
}
|
|
295
499
|
|
|
296
500
|
// 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
|
|
@@ -319,5 +523,5 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
319
523
|
return result
|
|
320
524
|
}
|
|
321
525
|
|
|
322
|
-
return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, feedbackFor }
|
|
526
|
+
return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, feedbackFor, loadLineage, recordLineage }
|
|
323
527
|
}
|