dsh-recall-plugin 2.0.0 → 2.1.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/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 raw = S.stripBom(await rt.runShell(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })).trim()
119
- if (!raw) { state.indexLoaded.add(root); return }
120
- const entries = JSON.parse(raw)
121
- if (!Array.isArray(entries)) { state.indexLoaded.add(root); return }
122
- for (const entry of entries) {
123
- if (!entry || typeof entry.id !== 'string') continue
124
- state.snapshots.set(entry.id, {
125
- root,
126
- time: typeof entry.time === 'number' ? entry.time : Date.now(),
127
- sessionId: entry.sessionId || sessionId
128
- })
129
- // feedback 回填(P1-2):重启后仍能解释「这条消息为什么没有/缺了
130
- // 快照」。复用 setFeedback 落内存(保持 FIFO 上限),只回填「需要
131
- // 解释」的记录;旧版索引无 feedback 字段时天然跳过。
132
- const fb = entry.feedback
133
- if (fb && typeof fb === 'object') {
134
- const rec = {}
135
- if (fb.failed) { rec.failed = true; if (typeof fb.error === 'string') rec.error = fb.error }
136
- if (Array.isArray(fb.skipped)) rec.skipped = fb.skipped.filter((p) => typeof p === 'string')
137
- if (rec.failed || (Array.isArray(rec.skipped) && rec.skipped.length)) setFeedback(entry.id, rec)
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
- // 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
141
- // runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
142
- // 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
143
- state.indexLoaded.add(root)
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
- const listing = S.stripBom(await rt.runShell(S.listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
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.split(/\r?\n/)) {
176
- const id = name.trim().replace(/^snap-/, '')
177
- if (!id || state.snapshots.has(id)) continue
178
- state.snapshots.set(id, { root, time: 0, sessionId })
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
- const ok = await rt.ensureGit(root, store)
196
- if (!ok) return
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
- setFeedback(messageId, { failed: true, error: String(error).slice(0, 300) })
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
- const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
290
- const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
291
- const deleted = m ? parseInt(m[1], 10) : 0
292
- const restored = m ? parseInt(m[2], 10) : 0
293
- return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
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
  }
package/lib/store.js CHANGED
@@ -13,6 +13,7 @@ import os from 'node:os'
13
13
  import crypto from 'node:crypto'
14
14
  import * as pwshScripts from './scripts.pwsh.js'
15
15
  import * as posixScripts from './scripts.posix.js'
16
+ import { classifyEnvError } from './diagnostics.js'
16
17
 
17
18
  // home 不可写时迁移重试的节流间隔:避免每条消息都白试一次注定失败的迁移
18
19
  const HOME_RETRY_MS = 300000
@@ -20,6 +21,69 @@ const HOME_RETRY_MS = 300000
20
21
  // 最近错误环形缓冲容量:设置页排障用,20 条足够回溯一轮快照/gc 的失败
21
22
  const ERROR_BUFFER_MAX = 20
22
23
 
24
+ // ---- POSIX home 基底解析(模块级纯逻辑,单测直测;工厂内 posixHomeBaseResolve
25
+ // 委托到这里)。放模块级而非工厂闭包:分支行为要在 win32 CI 上可测,而
26
+ // posixHomeBaseResolve 只在 POSIX 运行时被触达。----
27
+
28
+ // 三档回退选择:bash env $DSH_HOME → Node 主进程 DSH_HOME → os.homedir()。
29
+ // 第三档必须补 /.dsh 子目录(I24):win32 版第三档是 Join-Path USERPROFILE .dsh
30
+ // (scripts.pwsh.js homeDirScript),POSIX 版曾直接用裸 homedir,快照落
31
+ // ~/dsh-recall-snapshots 而非 ~/.dsh/dsh-recall-snapshots(issue #11 实证)。
32
+ // 返回 third 标记是否走了第三档——只有第三档才涉及旧容器迁移(存量用户
33
+ // 的数据在旧位,改 base 前要先搬)。
34
+ export function selectPosixHomeBase({ probed, envHome, homedir }) {
35
+ if (probed) return { base: probed, third: false }
36
+ if (envHome) return { base: envHome, third: false }
37
+ return { base: homedir + '/.dsh', third: true }
38
+ }
39
+
40
+ // 第三档命中时的一次性旧容器迁移编排(best-effort,数据安全优先):
41
+ // legacyHomeMigrateScript 只在「旧容器存在且新容器不存在」时整容器 mv,
42
+ // 输出四态由这里裁决——MIGRATE_OK / OLD_ABSENT 落规范位置(~/.dsh/…);
43
+ // BOTH_PRESENT(双容器并存)/ MIGRATE_FAIL(mv 失败)沿用旧位并 recordError,
44
+ // 与 tryUpgradeToHome 的非致命迁移哲学一致:数据不丢永远优先于路径规范。
45
+ // 探测命令自身失败按同策略回落旧位——此刻无法判断旧容器是否存在,选新位
46
+ // 会让存量用户「看不到」历史快照,选旧位对新装机只是维持修复前的行为。
47
+ export async function resolvePosixHomeBase(deps, { probed, envHome, homedir }) {
48
+ const sel = selectPosixHomeBase({ probed, envHome, homedir })
49
+ if (!sel.third) return sel.base
50
+ try {
51
+ const out = String(await deps.runShell(deps.scripts.legacyHomeMigrateScript(homedir), { timeoutMs: 300000, stdoutMaxBytes: 4096 })).trim()
52
+ if (out === 'MIGRATE_OK' || out === 'OLD_ABSENT') return sel.base
53
+ deps.recordError(
54
+ out === 'BOTH_PRESENT'
55
+ ? 'recall home store 新旧容器并存(' + homedir + '/dsh-recall-snapshots 与 ' + homedir + '/.dsh/dsh-recall-snapshots),沿用旧位,未做任何改动'
56
+ : 'recall 旧快照容器迁移失败(MIGRATE_FAIL),沿用旧位 ' + homedir + '/dsh-recall-snapshots'
57
+ )
58
+ return homedir
59
+ } catch (error) {
60
+ deps.recordError('recall 旧快照容器迁移探测失败,沿用旧位: ' + String(error))
61
+ return homedir
62
+ }
63
+ }
64
+
65
+ // 失败清扫脚本输出解读(M3 纯逻辑,供单测;cleanupAfterGitFailure 消费)。
66
+ // killOrphansScript 的三级出口:CLEANUP_OTHER_INSTANCE <pid> = 检测到另一个
67
+ // 活实例正在使用同一快照库(心跳有效且进程存活),清扫已让路;
68
+ // CLEANUP_SKIPPED_FRESH_LOCK = 存在 5 分钟内的新锁(疑似 git 操作进行中),
69
+ // 清扫未触碰;CLEANUP_DONE = 原有清扫路径执行完毕。解析按标记行匹配,
70
+ // 与模板输出逐字对应(改标记必须两侧同步)。
71
+ export function parseCleanupResult(out) {
72
+ const m = String(out || '').match(/CLEANUP_OTHER_INSTANCE\s+(\d+)/)
73
+ if (m) return { otherPid: parseInt(m[1], 10), skippedFresh: false }
74
+ if (String(out || '').indexOf('CLEANUP_SKIPPED_FRESH_LOCK') >= 0) return { otherPid: null, skippedFresh: true }
75
+ return { otherPid: null, skippedFresh: false }
76
+ }
77
+
78
+ // rename 步 ENOENT 判定(模块级纯逻辑,单测直接覆盖):POSIX mv 与
79
+ // pwsh Move-Item 的「目标不存在」文案集合,且错误必须提到 tmp 文件名
80
+ //(basename)——只认 rename 步的错误形态,误吞面最小。
81
+ export function isTmpConsumedError(error, basename) {
82
+ const s = String(error || '')
83
+ if (!basename || s.indexOf(basename) < 0) return false
84
+ return /No such file/i.test(s) || /does not exist/i.test(s) || /cannot find path/i.test(s)
85
+ }
86
+
23
87
  export function createRuntime(ctx, config) {
24
88
  const shell = ctx.shell
25
89
  const sessions = ctx.sessions
@@ -53,9 +117,19 @@ export function createRuntime(ctx, config) {
53
117
  // 最近错误环形缓冲:Host 侧所有失败原本只进 console.error(宿主进程
54
118
  // 日志,用户在页面上不可见),这里留最近 20 条经 /api/recall/status
55
119
  // 下发给设置页展示。同时转发 console.error 保持原有宿主日志不变。
120
+ // 尾部去重(issue #11):环境性错误随每条消息重复抛出,逐条 push 会把
121
+ // 20 条环形缓冲刷成同一条目、console.error 同步刷屏,其他诊断信息全被
122
+ // 挤掉。相邻重复只更新 time/count——间隔其他错误的重复仍新建条目,错误
123
+ // 时序不丢;kind 随条目富集(classifyEnvError),供 status 端点机器分流。
56
124
  function recordError(text) {
57
125
  const message = String(text)
58
- state.errors.push({ time: Date.now(), message })
126
+ const last = state.errors[state.errors.length - 1]
127
+ if (last && last.message === message) {
128
+ last.time = Date.now()
129
+ last.count += 1
130
+ return
131
+ }
132
+ state.errors.push({ time: Date.now(), message, count: 1, kind: classifyEnvError(message) })
59
133
  if (state.errors.length > ERROR_BUFFER_MAX) state.errors.splice(0, state.errors.length - ERROR_BUFFER_MAX)
60
134
  console.error(message)
61
135
  }
@@ -67,8 +141,10 @@ export function createRuntime(ctx, config) {
67
141
  // 基底走 probeHomeScript + Node 侧推导;常量与转义工具不承载命令)。
68
142
  ;(function checkScriptParity() {
69
143
  // fileWriteCmd 仅 pwsh 版存在:POSIX 的文本落盘走 stdin(store.js
70
- // writeTextViaShell 的 POSIX 分支不经命令行传参),不需要该模板函数
71
- const SKIP = new Set(['homeDirScript', 'probeHomeScript', 'fileWriteCmd'])
144
+ // writeTextViaShell 的 POSIX 分支不经命令行传参),不需要该模板函数;
145
+ // legacyHomeMigrateScript posix 版存在:旧容器迁移是 POSIX 漂移
146
+ // (I24)专属的存量数据兜底,win32 无此问题
147
+ const SKIP = new Set(['homeDirScript', 'probeHomeScript', 'fileWriteCmd', 'legacyHomeMigrateScript'])
72
148
  const pwshKeys = Object.keys(pwshScripts).filter((k) => !SKIP.has(k) && typeof pwshScripts[k] === 'function')
73
149
  const posixKeys = Object.keys(posixScripts).filter((k) => !SKIP.has(k) && typeof posixScripts[k] === 'function')
74
150
  const missing = pwshKeys.filter((k) => posixKeys.indexOf(k) < 0)
@@ -84,7 +160,12 @@ export function createRuntime(ctx, config) {
84
160
  // home,快照被迫降级进项目目录(污染);read-only 会话连项目都写不了,
85
161
  // 回退恢复直接失败。pwsh-sandbox / bash-sandbox 对 danger-full-access
86
162
  // 直接不约束(等价本地执行器),无沙箱后端的部署则忽略该字段,两边都成立。
87
- async function runShell(command, opts) {
163
+ // F-G3:runShell 的元数据变体——stdout 截断可判定(官方 ShellRunResult.stdout
164
+ // 是 CollectedOutput{text, truncated, spillPath?},见 dsh-shell 与
165
+ // dsh-subprocess 的 lib/types/types.d.ts;截断时 text 只剩流尾部)。需要
166
+ // 「解析完整 stdout」的调用方(loadIndex)用它区分「读截断」与「内容损坏」;
167
+ // 其余调用方继续用 runShell 拿纯文本,签名不变。
168
+ async function runShellMeta(command, opts) {
88
169
  const sp = ctx.get('sandboxPolicy')
89
170
  const spec = shell.resolve({
90
171
  // 编码前导:pwsh 侧统一 UTF-8 输出(中文机器 GBK 代码页不再乱码);
@@ -108,7 +189,14 @@ export function createRuntime(ctx, config) {
108
189
  const err = ((res && res.stderr && res.stderr.text) || '').trim() || ('exit ' + String(res.exitCode))
109
190
  throw new Error(err.slice(0, 1500))
110
191
  }
111
- return out
192
+ return {
193
+ text: out,
194
+ truncated: Boolean(res && res.stdout && res.stdout.truncated),
195
+ }
196
+ }
197
+
198
+ async function runShell(command, opts) {
199
+ return (await runShellMeta(command, opts)).text
112
200
  }
113
201
 
114
202
  // 从脚本文本提取影子仓库 git-dir:两套模板的 git 命令脚本都以
@@ -129,7 +217,13 @@ export function createRuntime(ctx, config) {
129
217
  const gitDir = extractGitDir(command)
130
218
  if (!gitDir) return
131
219
  try {
132
- await runShell(scripts.killOrphansScript(gitDir), { timeoutMs: 60000, stdoutMaxBytes: 4096 })
220
+ const out = await runShell(scripts.killOrphansScript(gitDir), { timeoutMs: 60000, stdoutMaxBytes: 4096 })
221
+ // M3:清扫让路的两种情形都值得一条记录——前者把 issue #11 的「疑似
222
+ // 多实例」升级为「确认」(点名 PID),后者解释了环境为何没有被自动
223
+ // 清理。recordError 的尾部去重保证逐消息重复失败不会刷屏。
224
+ const r = parseCleanupResult(out)
225
+ if (r.otherPid !== null) recordError('recall 检测到另一个 DSH 实例(PID ' + r.otherPid + ')正在使用此快照库,失败清扫已让路:未杀进程、未动锁')
226
+ else if (r.skippedFresh) recordError('recall 检测到 5 分钟内的新锁文件,疑似 git 操作正在进行,失败清扫已让路(锁陈旧后会自动清理)')
133
227
  } catch (error) { /* best-effort:清扫失败不影响原始错误的抛出 */ }
134
228
  }
135
229
 
@@ -210,8 +304,11 @@ export function createRuntime(ctx, config) {
210
304
 
211
305
  // POSIX:shell 侧只探 bash env 里显式的 $DSH_HOME(DSH 执行器洗刷
212
306
  // DSH_* 变量后通常为空);为空时依次回退 Node 主进程的 DSH_HOME
213
- // (宿主进程 env,用户导出可见)与 os.homedir()。哈希用 Node crypto
214
- // 统一算,规避 Linux sha256sum / macOS shasum 的二选一移植成本。
307
+ // (宿主进程 env,用户导出可见)与 os.homedir()(补 /.dsh 层,见
308
+ // selectPosixHomeBase I24 注释)。哈希用 Node crypto 统一算,规避
309
+ // Linux sha256sum / macOS shasum 的二选一移植成本。三档选择与旧容器
310
+ // 迁移编排都委托模块级纯函数(resolvePosixHomeBase),本方法只负责探测
311
+ // 输入与结果缓存(迁移随缓存每进程至多跑一次)。
215
312
  async function posixHomeBaseResolve() {
216
313
  if (state.posixHomeBase === null) {
217
314
  let probed = ''
@@ -220,7 +317,10 @@ export function createRuntime(ctx, config) {
220
317
  } catch (error) {
221
318
  probed = ''
222
319
  }
223
- state.posixHomeBase = probed || process.env.DSH_HOME || os.homedir()
320
+ state.posixHomeBase = await resolvePosixHomeBase(
321
+ { runShell, scripts, recordError },
322
+ { probed, envHome: (process.env && process.env.DSH_HOME) || '', homedir: os.homedir() }
323
+ )
224
324
  }
225
325
  return state.posixHomeBase
226
326
  }
@@ -362,45 +462,83 @@ export function createRuntime(ctx, config) {
362
462
  }
363
463
  }
364
464
 
365
- // 任意长度文本落盘(index.json / exclude.txt 共用):win32 走 base64
465
+ // 任意长度文本落盘(index.json / exclude.txt 共用),原子写(H2):
466
+ // 先写 <file>.tmp 再 rename 替换目标——多块序列中途崩溃最多留一个无害
467
+ // 的 .tmp 残留(下次写覆盖),绝不会留下截断 JSON。win32 走 base64
366
468
  // 分块内联(每块 20000 字符,规避 Windows 命令行 32767 上限——DSH 的
367
469
  // pwsh 执行器把命令串作为 -Command 的单个 argv 元素 spawn,快照攒到
368
470
  // 几百条就超限),首块覆盖、续块追加;POSIX 用官方 ShellExecRequest
369
- // 的 stdin 契约字段直写全文,不经命令行传参,天然没有 argv 上限。
471
+ // 的 stdin 契约字段直写全文到 tmp,不经命令行传参,天然没有 argv 上限。
370
472
  // 空内容也落一次写(清空配置/空索引是合法状态),所以 base64 为空串
371
- // 时仍发一块空 piece,而不是整段跳过留下旧文件。
473
+ // 时仍发一块空 piece,而不是整段跳过留下旧文件。rename 是同卷 O(1)
474
+ // 元数据操作,索引写频率为每消息一次,额外开销可忽略。
475
+ // rename 步的 ENOENT 容忍(WSL 双实例实弹发现):每实例写同一个
476
+ // <file>.tmp 路径,并发时一方 rename 把 tmp 消费掉,另一方 rename 报
477
+ // 「No such file / does not exist」。容忍是安全的,因为能走到 rename
478
+ // 的前提是写侧(分块 Add-Content / cat)已完整成功——任一写步失败都
479
+ // 在写侧直接抛(POSIX set -e / pwsh EAP=Stop),进不到这里;所以此刻
480
+ // tmp 消失只可能是同伴先把完整内容 rename 到了目标——本侧写语义已被
481
+ // 达成。Windows 侧「偶发一次 Move-Item: index.json.tmp does not
482
+ // exist」即同根。不进 recordError(用户错误列表刷屏正是要消除的
483
+ // 症状),console.error 留诊断痕迹;其余错误原样抛出。
484
+ async function renameTmpQuietly(tmp, file) {
485
+ try {
486
+ await runShell(scripts.renameFileCmd(tmp, file), { stdoutMaxBytes: 4096 })
487
+ } catch (error) {
488
+ const basename = tmp.slice(tmp.lastIndexOf(SEP) + 1)
489
+ if (isTmpConsumedError(error, basename)) {
490
+ console.error('recall writeTextViaShell: ' + basename + ' 已被并发写者 rename 消费,视同成功')
491
+ return
492
+ }
493
+ throw error
494
+ }
495
+ }
496
+
372
497
  async function writeTextViaShell(file, text) {
373
498
  const body = String(text == null ? '' : text)
499
+ const tmp = file + '.tmp'
374
500
  if (isWin) {
375
501
  const b64 = Buffer.from(body, 'utf8').toString('base64')
376
502
  const chunks = b64 ? b64.match(/.{1,20000}/g) : ['']
377
503
  let first = true
378
504
  for (const chunk of chunks) {
379
505
  const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + chunk + "')) | "
380
- await runShell(scripts.fileWriteCmd(file, piece, first), { stdoutMaxBytes: 4096 })
506
+ await runShell(scripts.fileWriteCmd(tmp, piece, first), { stdoutMaxBytes: 4096 })
381
507
  first = false
382
508
  }
509
+ await renameTmpQuietly(tmp, file)
383
510
  } else {
384
- await runShell('cat > ' + scripts.psq(file), { stdin: body, stdoutMaxBytes: 4096 })
511
+ await runShell('cat > ' + scripts.psq(tmp), { stdin: body, stdoutMaxBytes: 4096 })
512
+ await renameTmpQuietly(tmp, file)
385
513
  }
386
514
  }
387
515
 
388
516
  // 建立影子仓库(幂等:gitReady 命中后直接跳过,省掉每条消息一次的
389
517
  // config/exclude 重写)。同时回读 gc.stamp 种子化 gc 节流:让「上次 gc
390
518
  // 时间」跨重启续存,避免天天重启的机器每开机都来一次全量 gc。
519
+ // 返回 {ok, error}(M1-D2):失败原因必须传出——captureSnapshot 要把它
520
+ // 分类成 snapFeedback 的可行动提示(此前吞成布尔,客户端空轮询 20 次、
521
+ // 用户零感知,issue #11 主线缺口);init/预热调用方忽略返回值,不受形状
522
+ // 变化影响。
391
523
  async function ensureGit(root, store) {
392
- if (state.gitReady.has(store.git)) return true
524
+ if (state.gitReady.has(store.git)) return { ok: true }
393
525
  const gitExe = await resolveGit()
394
- if (!gitExe) return false
526
+ if (!gitExe) {
527
+ // git 缺失分支:原先静默 return false,连 recordError 都没有(用户
528
+ // 重启也查不到原因的盲区)。进错误环靠上方尾部去重天然免刷屏。
529
+ const error = '未检测到 git CLI,快照不可用'
530
+ recordError('recall ensureGit: ' + error + ':请安装 git 或检查其是否在 PATH 中')
531
+ return { ok: false, error }
532
+ }
395
533
  try {
396
534
  const out = scripts.stripBom(await runShell(scripts.ensureGitScript(store, gitExe, config.baseExcludes), { stdoutMaxBytes: 4096 }))
397
535
  state.gitReady.add(store.git)
398
536
  const m = out.match(/GIT_OK\s+(\d+)/)
399
537
  state.gcLastAt.set(store.git, m ? parseInt(m[1], 10) * 1000 : Date.now())
400
- return true
538
+ return { ok: true }
401
539
  } catch (error) {
402
540
  recordError('recall ensureGit failed: ' + String(error))
403
- return false
541
+ return { ok: false, error: String(error) }
404
542
  }
405
543
  }
406
544
 
@@ -412,5 +550,5 @@ export function createRuntime(ctx, config) {
412
550
  runShell(scripts.legacyRmScript(root + SEP + '.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
413
551
  }
414
552
 
415
- return { state, isWin, scripts, recordError, runShell, writeTextViaShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, storeFromDir, tryUpgradeToHome, ensureGit, cleanupLegacy }
553
+ return { state, isWin, scripts, recordError, runShell, runShellMeta, writeTextViaShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, storeFromDir, tryUpgradeToHome, ensureGit, cleanupLegacy, cleanupAfterGitFailure }
416
554
  }