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/CHANGELOG.md +31 -0
- package/README.en.md +57 -31
- package/README.md +46 -21
- package/lib/client.js +14 -1
- package/lib/dump-parse.js +78 -0
- package/lib/index.js +19 -34
- package/lib/maintenance.js +30 -18
- package/lib/routes-core.js +25 -7
- package/lib/routes-manage.js +168 -90
- package/lib/scripts.posix.js +87 -9
- package/lib/scripts.pwsh.js +192 -36
- package/lib/snapshots.js +65 -4
- package/lib/store.js +44 -35
- package/package.json +1 -1
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
|
-
|
|
474
|
-
return
|
|
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
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
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
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
// 的
|
|
472
|
-
//
|
|
473
|
-
//
|
|
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
|
-
//
|
|
479
|
-
//
|
|
480
|
-
// tmp 消失只可能是同伴先把完整内容 rename
|
|
481
|
-
//
|
|
482
|
-
// exist」即同根。不进 recordError
|
|
483
|
-
//
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|