dsh-recall-plugin 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/snapshots.js CHANGED
@@ -42,6 +42,43 @@ export function parseChanges(text, isWin) {
42
42
  return out
43
43
  }
44
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
+
45
82
  // 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
46
83
  export function scanCutSeq(events, messageId) {
47
84
  let anchor = -1
@@ -199,8 +236,13 @@ export function createSnapshots(ctx, rt, config) {
199
236
  // 孤儿重建随时可反推兜底。4MB ≈ 每条约 100B × 4 万条,
200
237
  // maxSnapshotsPerWorkspace=0(不限)的长期工作区可能触达——上限即
201
238
  // 天花板的取舍记录于此。
239
+ // PF-5:同时记 indexTruncated——init/预热紧接的 rebuildOrphans 若不
240
+ // 知情,会把全部 tag 判成孤儿(内存为空)并全量重写索引,用残缺孤
241
+ // 儿集覆盖完好的大索引(现有代码的真实隐患)。rebuildOrphans 对该
242
+ // root 整体跳过。
202
243
  rt.recordError('recall index read truncated: ' + root + ' 的 index.json 超过读取上限,按空索引继续(原文件未改动,下次写索引自然覆盖)')
203
244
  state.indexLoaded.add(root)
245
+ state.indexTruncated.add(root)
204
246
  return
205
247
  }
206
248
  if (!raw) { state.indexLoaded.add(root); return }
@@ -251,7 +293,10 @@ export function createSnapshots(ctx, rt, config) {
251
293
  // 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
252
294
  // runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
253
295
  // 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
296
+ // PF-5:healthy 单独标记(≠indexLoaded)——只有「磁盘索引确实在场且
297
+ // 解析成功」才允许 rebuildOrphans 跳过;空索引/损坏隔离/读失败都不标。
254
298
  state.indexLoaded.add(root)
299
+ state.indexHealthy.add(root)
255
300
  }
256
301
 
257
302
  // A5:quarantine 失败告警节流(每 store 5 分钟最多一条)。rename 失败时
@@ -338,7 +383,23 @@ export function createSnapshots(ctx, rt, config) {
338
383
  }
339
384
 
340
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)→ 照跑,自愈链路完整。
341
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
+ }
342
403
  const store = state.stores.get(root)
343
404
  const gitExe = await rt.resolveGit()
344
405
  if (!store || !gitExe) return
@@ -467,11 +528,11 @@ export function createSnapshots(ctx, rt, config) {
467
528
  if (!store) return null
468
529
  // 8MB 上限:按平均每条 60 字节估算可容纳十余万条,正常项目远够;
469
530
  // 真超限时报错文案与「JSON 半截解析失败」的真实原因脱节,需显式检测
470
- const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
531
+ const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE(), MAX_CHANGES), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
471
532
  const trimmed = text.trim()
472
- if (!trimmed) return { changes: [], total: 0, truncated: false }
473
- const all = parseChanges(trimmed, rt.isWin)
474
- return { changes: all.slice(0, MAX_CHANGES), total: all.length, truncated: all.length > MAX_CHANGES }
533
+ if (!trimmed) return { changes: [], total: 0, truncated: false, treeId: null }
534
+ // PF-1:解析下沉到 parseDiffOutput(标记行剥离 + 截断 + 树指纹),纯函数单测钉住
535
+ return parseDiffOutput(trimmed, rt.isWin, MAX_CHANGES)
475
536
  }
476
537
 
477
538
  async function rollbackFor(messageId) {
package/lib/store.js CHANGED
@@ -98,6 +98,16 @@ export function createRuntime(ctx, config) {
98
98
  snapshots: new Map(),
99
99
  queue: Promise.resolve(),
100
100
  indexLoaded: new Set(),
101
+ // PF-5 索引终态三/四档标记(rebuildOrphans 守卫的数据源):
102
+ // - indexHealthy:磁盘索引解析成功且在场(loadIndex 正常载入分支)——
103
+ // rebuildOrphans 对 healthy 且条目非空的 root 整体跳过(省 1+N 条进程)
104
+ // - indexTruncated:读截断(F-G3,内存是残缺视图)——rebuildOrphans
105
+ // 必须跳过:否则全部 tag 被判孤儿、用残缺孤儿集覆盖完好的大索引
106
+ // (feedback 全丢、数万条索引按 win32 分块写下是数百条进程的灾难)
107
+ // empty(无索引文件)/quarantined(损坏隔离)不标记 → rebuild 照跑,
108
+ // 自愈链路完整
109
+ indexHealthy: new Set(),
110
+ indexTruncated: new Set(),
101
111
  gitReady: new Set(),
102
112
  cutSeqCache: new Map(),
103
113
  homeRetryAt: new Map(),
@@ -140,11 +150,10 @@ export function createRuntime(ctx, config) {
140
150
  // 平台专属导出(homeDirScript 的 $h 链只在 pwsh 侧需要——POSIX 的 home
141
151
  // 基底走 probeHomeScript + Node 侧推导;常量与转义工具不承载命令)。
142
152
  ;(function checkScriptParity() {
143
- // fileWriteCmd pwsh 版存在:POSIX 的文本落盘走 stdin(store.js
144
- // writeTextViaShell POSIX 分支不经命令行传参),不需要该模板函数;
145
- // legacyHomeMigrateScript posix 版存在:旧容器迁移是 POSIX 漂移
146
- // (I24)专属的存量数据兜底,win32 无此问题
147
- const SKIP = new Set(['homeDirScript', 'probeHomeScript', 'fileWriteCmd', 'legacyHomeMigrateScript'])
153
+ // fileWriteStdinCmd 两平台同名(PF-2 起两平台统一走 stdin 单进程落盘,
154
+ // 编码行为由探针钉死);legacyHomeMigrateScript posix 版存在:
155
+ // 旧容器迁移是 POSIX 漂移(I24)专属的存量数据兜底,win32 无此问题
156
+ const SKIP = new Set(['homeDirScript', 'probeHomeScript', 'legacyHomeMigrateScript'])
148
157
  const pwshKeys = Object.keys(pwshScripts).filter((k) => !SKIP.has(k) && typeof pwshScripts[k] === 'function')
149
158
  const posixKeys = Object.keys(posixScripts).filter((k) => !SKIP.has(k) && typeof posixScripts[k] === 'function')
150
159
  const missing = pwshKeys.filter((k) => posixKeys.indexOf(k) < 0)
@@ -462,25 +471,27 @@ export function createRuntime(ctx, config) {
462
471
  }
463
472
  }
464
473
 
465
- // 任意长度文本落盘(index.json / exclude.txt 共用),原子写(H2):
466
- // 先写 <file>.tmp 再 rename 替换目标——多块序列中途崩溃最多留一个无害
467
- // .tmp 残留(下次写覆盖),绝不会留下截断 JSON。win32 走 base64
468
- // 分块内联(每块 20000 字符,规避 Windows 命令行 32767 上限——DSH 的
469
- // pwsh 执行器把命令串作为 -Command 的单个 argv 元素 spawn,快照攒到
470
- // 几百条就超限),首块覆盖、续块追加;POSIX 用官方 ShellExecRequest
471
- // 的 stdin 契约字段直写全文到 tmp,不经命令行传参,天然没有 argv 上限。
472
- // 空内容也落一次写(清空配置/空索引是合法状态),所以 base64 为空串
473
- // 时仍发一块空 piece,而不是整段跳过留下旧文件。rename 是同卷 O(1)
474
- // 元数据操作,索引写频率为每消息一次,额外开销可忽略。
474
+ // 任意长度文本落盘(index.json / exclude.txt / lineage.json / root.txt
475
+ // 共用),原子写(H2):先写 <file>.tmp 再 rename 替换目标——写中途崩溃
476
+ // 最多留一个无害的 .tmp 残留(下次写覆盖),绝不会留下截断 JSON。
477
+ // PF-2:两平台统一「stdin 传全文 + 单进程落盘」(fileWriteStdinCmd,同名
478
+ // 导出纪律)。win32 曾按 base64 20000 字符分块——每块一条 PowerShell 进程,
479
+ // 索引几百条时 saveIndex = 6+ 条进程,而它在每条消息快照后、每次删除、
480
+ // 每次 init 都全量重写;POSIX cat > tmp 内联命令一并迁进模板。stdin
481
+ // 不经命令行,argv 32767 上限与编码坑天然消失;pwsh 侧读取手法由探针
482
+ // 钉死(OpenStandardInput 字节流——Console.In PS 5.1 按 GBK 解码必挂,
483
+ // 见 tests/probe/stdin-write.test.js 与 plan-performance.md 实施记录)。
484
+ // 空内容也落一次写(清空配置/空索引是合法状态),stdin 空串照常发送。
485
+ // rename 是同卷 O(1) 元数据操作,索引写频率为每消息一次,额外开销可忽略。
475
486
  // rename 步的 ENOENT 容忍(WSL 双实例实弹发现):每实例写同一个
476
487
  // <file>.tmp 路径,并发时一方 rename 把 tmp 消费掉,另一方 rename 报
477
488
  // 「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 留诊断痕迹;其余错误原样抛出。
489
+ // 的前提是写侧(stdin 写)已完整成功——任一写步失败都在写侧直接抛
490
+ // POSIX set -e 对 cat 失败终止 / pwsh EAP=Stop 对 WriteAllText 抛),
491
+ // 进不到这里;所以此刻 tmp 消失只可能是同伴先把完整内容 rename 到了
492
+ // 目标——本侧写语义已被达成。Windows 侧「偶发一次 Move-Item:
493
+ // index.json.tmp does not exist」即同根。不进 recordError(用户错误列表
494
+ // 刷屏正是要消除的症状),console.error 留诊断痕迹;其余错误原样抛出。
484
495
  async function renameTmpQuietly(tmp, file) {
485
496
  try {
486
497
  await runShell(scripts.renameFileCmd(tmp, file), { stdoutMaxBytes: 4096 })
@@ -497,20 +508,8 @@ export function createRuntime(ctx, config) {
497
508
  async function writeTextViaShell(file, text) {
498
509
  const body = String(text == null ? '' : text)
499
510
  const tmp = file + '.tmp'
500
- if (isWin) {
501
- const b64 = Buffer.from(body, 'utf8').toString('base64')
502
- const chunks = b64 ? b64.match(/.{1,20000}/g) : ['']
503
- let first = true
504
- for (const chunk of chunks) {
505
- const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + chunk + "')) | "
506
- await runShell(scripts.fileWriteCmd(tmp, piece, first), { stdoutMaxBytes: 4096 })
507
- first = false
508
- }
509
- await renameTmpQuietly(tmp, file)
510
- } else {
511
- await runShell('cat > ' + scripts.psq(tmp), { stdin: body, stdoutMaxBytes: 4096 })
512
- await renameTmpQuietly(tmp, file)
513
- }
511
+ await runShell(scripts.fileWriteStdinCmd(tmp), { stdin: body, stdoutMaxBytes: 4096 })
512
+ await renameTmpQuietly(tmp, file)
514
513
  }
515
514
 
516
515
  // 建立影子仓库(幂等:gitReady 命中后直接跳过,省掉每条消息一次的
@@ -544,9 +543,19 @@ export function createRuntime(ctx, config) {
544
543
 
545
544
  // 迁移收尾:删除旧版 blobs 格式的项目内 .dsh-recall-snapshots 目录,
546
545
  // 仅在 home 存储可用时执行——降级场景下该目录就是新 store,不能删。
546
+ // PF-5 顺带:legacyCleaned 内存标记——该目录只存在于极早期版本用户的
547
+ // 降级工作区,探测成功一次后不可能再出现,同 root 多次 init 只付一条
548
+ // 进程;失败(环境性)也标记不重试:残留只占磁盘无功能影响,重置场景
549
+ // (DSH 重启)每进程一次可接受(文档 PF-5 顺带节的取舍)。
550
+ // pwsh 侧 legacyRmScript 已加 -ErrorAction SilentlyContinue:「目录本就
551
+ // 不存在」(常态)不再抛错中断,而是以成功返回被标记——否则常态下每次
552
+ // init 都白跑一条进程,标记就失去了意义。
553
+ const legacyCleaned = new Set()
547
554
  function cleanupLegacy(root) {
548
555
  const store = state.stores.get(root)
549
556
  if (!store || !store.home) return
557
+ if (legacyCleaned.has(root)) return
558
+ legacyCleaned.add(root)
550
559
  runShell(scripts.legacyRmScript(root + SEP + '.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
551
560
  }
552
561
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {