dsh-gokifu-compact 1.11.7 → 1.11.8
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 +43 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/scripts/migrate-dangling.mjs +35 -3
- package/src/rooms/migrate.mjs +204 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 1.11.8 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
### sidecar 现在能**回灌**:归档掉的悬空边可以放回图里
|
|
6
|
+
|
|
7
|
+
1.11.1 起 `migrate-dangling --apply` 把 settled residue 归档到 `graph.dangling.json`(sidecar),
|
|
8
|
+
**但一直没有回灌工具** —— 报告里写「字段齐全、可回灌」,实际只能靠人手写 JSON。这一版把这条路补完。
|
|
9
|
+
|
|
10
|
+
**新增(R14 `src/rooms/migrate.mjs`)**
|
|
11
|
+
|
|
12
|
+
| 导出 | 作用 |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `readSidecar(file)` | 读 + 校验:`format` / `version` / **非空 `edges`** / 每条边必须有 `id` / 会话匹配;**坏文件一律拒绝且不动图** |
|
|
15
|
+
| `mergeSidecar(graph, sidecar, opts)` | 按 `edge.id` 合并;**同 id 跳过、不覆盖**;保留 sidecar 原字段;**原子写 `graph.json` + 重建 `index.md`** |
|
|
16
|
+
| `mergeMany(target, opts)` | 目标可以是**文件或目录**;批量 |
|
|
17
|
+
| `mergeBackupDir(opts)` | 回灌前先备份 `graph.json` / `index.md` 并落 `MERGE.json` |
|
|
18
|
+
|
|
19
|
+
- **默认 dry-run**:只有 `--apply` 才写盘;**幂等** —— 二次 merge 0 条命中且**不写盘**。
|
|
20
|
+
- CLI:`--merge-sidecar=<file|dir> [--apply] [--backup-dir=] [--json]`;有错误 **exit 1**。
|
|
21
|
+
|
|
22
|
+
**CLI 摘要(`scripts/migrate-dangling.mjs`)**
|
|
23
|
+
|
|
24
|
+
- 修掉 `main()` 里 merge 分支的**重复执行链**(只留 `mergeMany`,错误 exit 1),人类摘要移到输出链。
|
|
25
|
+
- 无 `--json` 时打印:标题 / target / `sessions|merged|skipped` / 逐会话行 / backup 目录 / errors / dry-run 或已回灌提示;
|
|
26
|
+
**`--json` 仍是纯 JSON**(可 `JSON.parse`)。
|
|
27
|
+
- `mergeMany`:目标不存在时**直接报「目标不存在」**(不再默默去读 `./graph.json`)。
|
|
28
|
+
|
|
29
|
+
**测试 +7**(486 → 493)
|
|
30
|
+
|
|
31
|
+
- M1 回灌与备份 · M2 幂等 · M3 **五类拒绝**(format/version/空 edges/边无 id/会话不匹配)· M4 同 id 不覆盖 · M5 `mergeMany` 批量
|
|
32
|
+
- C1 **真跑 CLI 六态**(人类摘要 / `--json` / apply 备份 / 幂等 / 坏 version / 目标不存在)· C2 结构回归(merge 分支恰好 2 处)
|
|
33
|
+
- 变异自检:覆盖已存在边 / dry-run 写盘 / 不校验 version / 执行链重复分支 / `--json` 走人类摘要 / 删人类摘要分支 —— 逐条红
|
|
34
|
+
|
|
35
|
+
### 验证
|
|
36
|
+
|
|
37
|
+
- 全量:**493 passed / 0 skipped / 0 failed**(37 个测试文件)
|
|
38
|
+
- `migrate` **21/0** · `rooms` 15/0 · `verify-install` **16/16**
|
|
39
|
+
- `npm pack --dry-run --json`:**71 文件**(数量不变;只改 `src/` `scripts/` `test/`)
|
|
40
|
+
|
|
41
|
+
**未验证**
|
|
42
|
+
|
|
43
|
+
- 回灌**没有对真实库跑过** —— 全部证据来自夹具;真实 `sessions/**` 的 apply 仍需主人授权 + 停插件窗口。
|
|
44
|
+
- 回灌只按 `edge.id` 去重:**同一个「边」若在 sidecar 里 id 不同(历史重复归档),会被当成两条各插一次**。
|
|
45
|
+
|
|
3
46
|
## 1.11.7 — 2026-10-31
|
|
4
47
|
|
|
5
48
|
### 真实测试与门禁都不再「靠输出猜」
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-GOkifu-compact
|
|
2
2
|
|
|
3
|
-
> 当前版本:**`1.11.
|
|
3
|
+
> 当前版本:**`1.11.8`** · npm [`dsh-gokifu-compact`](https://www.npmjs.com/package/dsh-gokifu-compact) · [GitHub Releases](https://github.com/MYCF711/dsh-GOkifu-compact/releases) · 更新记录见 [CHANGELOG.md](CHANGELOG.md)
|
|
4
4
|
|
|
5
5
|
**DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有 351 tokens 的热清单」。**
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
热清单 351 tokens (2000 边合成夹具 · L5 降级档 · 2026-10-30/10-31 两次实测同值;旧版全量 28,800)
|
|
9
9
|
失败保留 8 / 8 (历史口径 †;官方 8 段模板 4.67 / 8)
|
|
10
10
|
清单开销 未复现/待测 (原载 0.2% = 历史单会话 v1.3.1 后实测;无复跑命令、无对照组;源 evidence/真实计费核对.md)
|
|
11
|
-
验证
|
|
11
|
+
验证 493 项单测(0 skipped) · 真实包 8/8 † · 真实进程 fold 3~6/6 †
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gokifu-compact",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.8",
|
|
4
4
|
"description": "DSH context-compression backend with a go game record (kifu) DAG: node = position, edge = attempted move + result. Incremental fold (each span summarized once), failure memory that survives compaction, transposition detection (chess-engine style), and a tiny hot ledger rebuilt every step.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* node scripts/migrate-dangling.mjs --apply # 真执行(先整目录备份;120s 新鲜度守卫)
|
|
12
12
|
* node scripts/migrate-dangling.mjs --verify --backup=<dir> # 只读校验
|
|
13
13
|
* node scripts/migrate-dangling.mjs --rollback=<dir> # 从备份逐字恢复
|
|
14
|
+
* node scripts/migrate-dangling.mjs --merge-sidecar=<file|dir> # 回灌 sidecar(默认 dry-run)
|
|
15
|
+
* node scripts/migrate-dangling.mjs --merge-sidecar=<file|dir> --apply # 真回灌(备份到 --backup-dir)
|
|
14
16
|
* --root=<sessionsRoot> --backup-dir=<dir> --now=<epochMs>
|
|
15
17
|
*
|
|
16
18
|
* 退出码:0 正常;1 校验失败/执行失败;2 参数错误。
|
|
@@ -18,13 +20,13 @@
|
|
|
18
20
|
|
|
19
21
|
import nodeFs from "node:fs";
|
|
20
22
|
import nodePath from "node:path";
|
|
21
|
-
import { dryRun, apply, verify, rollback, sessionsRoot, backupsRoot } from "../src/rooms/migrate.mjs";
|
|
23
|
+
import { dryRun, apply, verify, rollback, mergeMany, sessionsRoot, backupsRoot } from "../src/rooms/migrate.mjs";
|
|
22
24
|
|
|
23
25
|
function parseArgs(argv) {
|
|
24
|
-
const opts = { mode: "dry-run", json: false, force: false, only: null, root: sessionsRoot(), backupRoot: backupsRoot(), backupDir: "", now: Date.now() };
|
|
26
|
+
const opts = { mode: "dry-run", json: false, force: false, only: null, root: sessionsRoot(), backupRoot: backupsRoot(), backupDir: "", mergeTarget: "", now: Date.now() };
|
|
25
27
|
for (const arg of argv) {
|
|
26
28
|
if (arg === "--dry-run") opts.mode = "dry-run";
|
|
27
|
-
else if (arg === "--apply") opts.mode = "apply";
|
|
29
|
+
else if (arg === "--apply") { opts.applyMerge = true; if (!opts.mergeTarget) opts.mode = "apply"; }
|
|
28
30
|
else if (arg === "--force") opts.force = true;
|
|
29
31
|
else if (arg === "--verify") opts.mode = "verify";
|
|
30
32
|
else if (arg === "--json") opts.json = true;
|
|
@@ -34,6 +36,13 @@ function parseArgs(argv) {
|
|
|
34
36
|
else if (arg.startsWith("--rollback=")) {
|
|
35
37
|
opts.mode = "rollback";
|
|
36
38
|
opts.backupDir = arg.slice("--rollback=".length);
|
|
39
|
+
} else if (arg.startsWith("--merge-sidecar=")) {
|
|
40
|
+
opts.mode = "merge";
|
|
41
|
+
opts.mergeTarget = arg.slice("--merge-sidecar=".length);
|
|
42
|
+
if (opts.mergeTarget.length === 0) {
|
|
43
|
+
console.error("--merge-sidecar= 需要一个 sidecar 文件或会话目录");
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
37
46
|
} else if (arg.startsWith("--sessions=")) {
|
|
38
47
|
opts.only = arg.slice("--sessions=".length).split(",").map((x) => x.trim()).filter(Boolean);
|
|
39
48
|
} else if (arg.startsWith("--now=")) opts.now = Number(arg.slice("--now=".length));
|
|
@@ -55,6 +64,8 @@ function printHelp() {
|
|
|
55
64
|
node scripts/migrate-dangling.mjs --apply [--force] # 执行(整目录备份 + 120s 新鲜度守卫)
|
|
56
65
|
node scripts/migrate-dangling.mjs --verify --backup=<backupDir> # 只读校验
|
|
57
66
|
node scripts/migrate-dangling.mjs --rollback=<backupDir> # 从备份恢复
|
|
67
|
+
node scripts/migrate-dangling.mjs --merge-sidecar=<file|dir> [--apply] [--backup-dir=<dir>]
|
|
68
|
+
# 回灌 sidecar:默认 dry-run(只报告将合并几条);--apply 才写(先备份 graph.json/index.md)
|
|
58
69
|
|
|
59
70
|
默认路径:<DSH_HOME>/chess-context/sessions ;备份:<DSH_HOME>/chess-context/migrate-backups`);
|
|
60
71
|
}
|
|
@@ -68,6 +79,9 @@ function main() {
|
|
|
68
79
|
result = dryRun(opts.root, { now: opts.now, only: opts.only });
|
|
69
80
|
} else if (opts.mode === "apply") {
|
|
70
81
|
result = apply(opts.root, { now: opts.now, backupRoot: opts.backupRoot, only: opts.only, maxAgeMs: opts.force ? 0 : undefined });
|
|
82
|
+
} else if (opts.mode === "merge") {
|
|
83
|
+
result = mergeMany(opts.mergeTarget, { apply: opts.applyMerge, backupRoot: opts.backupRoot, now: opts.now });
|
|
84
|
+
if (!result.ok) code = 1;
|
|
71
85
|
} else if (opts.mode === "verify") {
|
|
72
86
|
result = verify(opts.root, { now: opts.now, backupDir: opts.backupDir, only: opts.only });
|
|
73
87
|
if (!result.ok) code = 1;
|
|
@@ -81,6 +95,24 @@ function main() {
|
|
|
81
95
|
}
|
|
82
96
|
if (opts.json) {
|
|
83
97
|
console.log(JSON.stringify(result, null, 1));
|
|
98
|
+
} else if (opts.mode === "merge") {
|
|
99
|
+
// ★ task-157:merge 的人类摘要(--json 时走上一条分支给 JSON)。
|
|
100
|
+
// 旧版这里的 merge 分支被误插进**执行链**里(与同条件的 dispatch 分支重复),
|
|
101
|
+
// 结果永远不执行 ⇒ 不带 --json 时只能看到兜底的 JSON。
|
|
102
|
+
console.log(`== migrate-dangling ${result.mode} ==`);
|
|
103
|
+
console.log(`target: ${result.target}`);
|
|
104
|
+
console.log(`sessions: ${result.sessions}|merged ${result.merged}|skipped ${result.skipped}`);
|
|
105
|
+
for (const one of result.perSession ?? []) {
|
|
106
|
+
const bad = one.errors.length ? ` errors ${one.errors.length}` : "";
|
|
107
|
+
console.log(` · ${one.session}: merged ${one.merged} / skipped ${one.skipped}${bad}`);
|
|
108
|
+
for (const err of one.errors) console.log(` ✗ ${err}`);
|
|
109
|
+
}
|
|
110
|
+
if (result.backupDirs.length) console.log(`backup: ${result.backupDirs.join(", ")}`);
|
|
111
|
+
if (result.errors.length) {
|
|
112
|
+
console.log("errors:");
|
|
113
|
+
for (const err of result.errors) console.log(`- ${err}`);
|
|
114
|
+
}
|
|
115
|
+
console.log(opts.applyMerge ? "(已回灌并重建 index.md)" : "(dry-run:什么都没写;加 --apply 才回灌)");
|
|
84
116
|
} else if (opts.mode === "dry-run") {
|
|
85
117
|
console.log("== migrate-dangling dry-run ==");
|
|
86
118
|
console.log(`root: ${result.root}`);
|
package/src/rooms/migrate.mjs
CHANGED
|
@@ -410,6 +410,210 @@ export function apply(root, { now = Date.now(), backupRoot = backupsRoot(), stam
|
|
|
410
410
|
return summary;
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
+
/* ───────────────────────────── merge(sidecar 回灌) ───────────────────────────── */
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* merge 的备份目录:`<backupRoot>/merge-<iso>`(apply 且真的写了才创建)。
|
|
417
|
+
* 与 apply 的整目录备份同根(`<DSH_HOME>/chess-context/migrate-backups`),便于排障时一处找齐。
|
|
418
|
+
*/
|
|
419
|
+
export function mergeBackupDir({ backupRoot = backupsRoot(), stamp = new Date() } = {}) {
|
|
420
|
+
const dir = nodePath.join(backupRoot, `merge-${stamp.toISOString().replace(/[:.]/g, "-")}`);
|
|
421
|
+
nodeFs.mkdirSync(dir, { recursive: true });
|
|
422
|
+
return dir;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 校验 sidecar 并读出边(纯只读,不碰图)。
|
|
427
|
+
* @returns {{ok:boolean, errors:string[], sidecar:any, edges:any[]}}
|
|
428
|
+
*/
|
|
429
|
+
export function readSidecar(file, { sessionId = "" } = {}) {
|
|
430
|
+
const errors = [];
|
|
431
|
+
let sidecar = null;
|
|
432
|
+
if (!nodeFs.existsSync(file)) return { ok: false, errors: [`sidecar 不存在:${file}`], sidecar: null, edges: [] };
|
|
433
|
+
try {
|
|
434
|
+
sidecar = JSON.parse(nodeFs.readFileSync(file, "utf8"));
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return { ok: false, errors: [`sidecar 不是合法 JSON(${String(error?.message ?? error)})`], sidecar: null, edges: [] };
|
|
437
|
+
}
|
|
438
|
+
if (sidecar === null || typeof sidecar !== "object") errors.push("sidecar 顶层不是对象");
|
|
439
|
+
if (sidecar?.format !== SIDECAR_FORMAT) errors.push(`sidecar format 不对:${String(sidecar?.format)}(应为 ${SIDECAR_FORMAT})`);
|
|
440
|
+
if (sidecar?.version !== SIDECAR_VERSION) errors.push(`sidecar version 不支持:${String(sidecar?.version)}(应为 ${SIDECAR_VERSION})`);
|
|
441
|
+
if (sessionId && typeof sidecar?.sessionId === "string" && sidecar.sessionId.length > 0 && sidecar.sessionId !== sessionId) {
|
|
442
|
+
errors.push(`sidecar 属于别的会话:${sidecar.sessionId}(当前 ${sessionId})`);
|
|
443
|
+
}
|
|
444
|
+
const edges = Array.isArray(sidecar?.edges) ? sidecar.edges : null;
|
|
445
|
+
if (edges === null) errors.push("sidecar.edges 不是数组");
|
|
446
|
+
else if (edges.length === 0) errors.push("sidecar 里没有边(空边)");
|
|
447
|
+
if (Array.isArray(edges)) {
|
|
448
|
+
edges.forEach((edge, i) => {
|
|
449
|
+
if (edge === null || typeof edge !== "object") errors.push(`edges[${i}] 不是对象`);
|
|
450
|
+
else if (typeof edge.id !== "string" || edge.id.length === 0) errors.push(`edges[${i}] 缺 id`);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return { ok: errors.length === 0, errors, sidecar, edges: errors.length === 0 ? edges : [] };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* 把一个 sidecar 回灌进会话图(R14)。
|
|
458
|
+
*
|
|
459
|
+
* 语义(与 apply 对称):
|
|
460
|
+
* · 按 **edge.id** 合并;图里已有同 id ⇒ **跳过**(绝不覆盖当前边);
|
|
461
|
+
* · 保留 sidecar 里的原字段(JSON 深拷贝,不重算/不改写);
|
|
462
|
+
* · 有改动才 `updatedAt = now`、原子写 graph.json、**重建 index.md**;
|
|
463
|
+
* · **默认 dry-run**(不写任何文件);`apply:true` 才写,写之前把 graph.json / index.md
|
|
464
|
+
* 备份到 `<backupRoot>/merge-<iso>/`,并落一份 `MERGE.json` 审计;
|
|
465
|
+
* · 幂等:同一 sidecar 回灌第二次 ⇒ `merged:0`、`skipped:N`、不写盘、不建备份。
|
|
466
|
+
*
|
|
467
|
+
* @param {string} dir 会话目录(含 graph.json)
|
|
468
|
+
* @param {{sidecarFile?:string, apply?:boolean, backupRoot?:string, stamp?:Date, now?:number}} [options]
|
|
469
|
+
* @returns {{mode:string, session:string, sidecar:string, ok:boolean, dryRun:boolean, total:number, merged:number, skipped:number, duplicates:number, mergedIds:string[], errors:string[], backupDir:string, graphBytesBefore:number, graphBytesAfter:number, indexRebuilt:boolean}}
|
|
470
|
+
*/
|
|
471
|
+
export function mergeSidecar(dir, { sidecarFile = "", apply = false, backupRoot = backupsRoot(), stamp = new Date(), now = Date.now() } = {}) {
|
|
472
|
+
const session = nodePath.basename(dir);
|
|
473
|
+
const file = sidecarFile || nodePath.join(dir, SIDECAR_NAME);
|
|
474
|
+
const result = {
|
|
475
|
+
mode: apply ? "merge-apply" : "merge-dry-run",
|
|
476
|
+
session,
|
|
477
|
+
sidecar: file,
|
|
478
|
+
ok: false,
|
|
479
|
+
dryRun: !apply,
|
|
480
|
+
total: 0,
|
|
481
|
+
merged: 0,
|
|
482
|
+
skipped: 0,
|
|
483
|
+
duplicates: 0,
|
|
484
|
+
mergedIds: [],
|
|
485
|
+
errors: [],
|
|
486
|
+
backupDir: "",
|
|
487
|
+
graphBytesBefore: 0,
|
|
488
|
+
graphBytesAfter: 0,
|
|
489
|
+
indexRebuilt: false,
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const graphFile = nodePath.join(dir, "graph.json");
|
|
493
|
+
if (!nodeFs.existsSync(graphFile)) {
|
|
494
|
+
result.errors.push(`会话目录里没有 graph.json:${graphFile}`);
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
const read = readSidecar(file, { sessionId: session });
|
|
498
|
+
if (!read.ok) {
|
|
499
|
+
result.errors.push(...read.errors);
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
result.total = read.edges.length;
|
|
503
|
+
|
|
504
|
+
let text;
|
|
505
|
+
let g;
|
|
506
|
+
try {
|
|
507
|
+
text = nodeFs.readFileSync(graphFile, "utf8");
|
|
508
|
+
g = fromJSON(text);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
result.errors.push(`graph.json 读不出(${String(error?.message ?? error)})`);
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
result.graphBytesBefore = Buffer.byteLength(text);
|
|
514
|
+
|
|
515
|
+
const seen = new Set();
|
|
516
|
+
for (const edge of read.edges) {
|
|
517
|
+
if (seen.has(edge.id)) {
|
|
518
|
+
result.duplicates += 1;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
seen.add(edge.id);
|
|
522
|
+
if (g.edges && g.edges[edge.id]) {
|
|
523
|
+
result.skipped += 1;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
g.edges[edge.id] = JSON.parse(JSON.stringify(edge));
|
|
527
|
+
result.mergedIds.push(edge.id);
|
|
528
|
+
result.merged += 1;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (result.merged === 0) {
|
|
532
|
+
// 幂等:没有新边 ⇒ 一个字节都不写(不建备份、不碰 index.md)
|
|
533
|
+
result.graphBytesAfter = result.graphBytesBefore;
|
|
534
|
+
result.ok = true;
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
g.updatedAt = now;
|
|
539
|
+
const next = toJSON(g);
|
|
540
|
+
result.graphBytesAfter = Buffer.byteLength(next);
|
|
541
|
+
|
|
542
|
+
if (!apply) {
|
|
543
|
+
result.ok = true;
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ① 显式备份(graph.json + index.md);② 原子写 graph.json;③ 重建 index.md;④ MERGE.json 审计
|
|
548
|
+
const backupDir = mergeBackupDir({ backupRoot, stamp });
|
|
549
|
+
result.backupDir = backupDir;
|
|
550
|
+
nodeFs.copyFileSync(graphFile, nodePath.join(backupDir, "graph.json"));
|
|
551
|
+
const indexFile = nodePath.join(dir, "index.md");
|
|
552
|
+
if (nodeFs.existsSync(indexFile)) nodeFs.copyFileSync(indexFile, nodePath.join(backupDir, "index.md"));
|
|
553
|
+
writeFileAtomic(graphFile, next);
|
|
554
|
+
writeFileAtomic(indexFile, renderIndexMd(g));
|
|
555
|
+
result.indexRebuilt = true;
|
|
556
|
+
writeFileAtomic(
|
|
557
|
+
nodePath.join(backupDir, "MERGE.json"),
|
|
558
|
+
JSON.stringify({ ...result, appliedAt: new Date(now).toISOString(), root: dir }, null, 1),
|
|
559
|
+
);
|
|
560
|
+
result.ok = true;
|
|
561
|
+
return result;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* 回灌一批会话(CLI 用):`target` 可以是单个 sidecar 文件、单个会话目录、或会话根目录。
|
|
566
|
+
* 返回汇总 + 每会话结果。任何一条出错 ⇒ `ok:false`(但已成功的仍然写完了,逐条可查)。
|
|
567
|
+
*/
|
|
568
|
+
export function mergeMany(target, { apply = false, backupRoot = backupsRoot(), stamp = new Date(), now = Date.now() } = {}) {
|
|
569
|
+
const dirs = [];
|
|
570
|
+
const sidecars = [];
|
|
571
|
+
if (!nodeFs.existsSync(target)) {
|
|
572
|
+
return {
|
|
573
|
+
mode: apply ? "merge-apply" : "merge-dry-run",
|
|
574
|
+
target,
|
|
575
|
+
sessions: 0,
|
|
576
|
+
merged: 0,
|
|
577
|
+
skipped: 0,
|
|
578
|
+
ok: false,
|
|
579
|
+
errors: [`目标不存在:${target}`],
|
|
580
|
+
backupDirs: [],
|
|
581
|
+
perSession: [],
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
if (nodeFs.statSync(target).isDirectory()) {
|
|
585
|
+
const direct = nodePath.join(target, SIDECAR_NAME);
|
|
586
|
+
if (nodeFs.existsSync(direct) && nodeFs.existsSync(nodePath.join(target, "graph.json"))) {
|
|
587
|
+
dirs.push(target);
|
|
588
|
+
} else {
|
|
589
|
+
for (const dir of listSessions(target)) {
|
|
590
|
+
if (nodeFs.existsSync(nodePath.join(dir, SIDECAR_NAME))) dirs.push(dir);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} else {
|
|
594
|
+
sidecars.push(target);
|
|
595
|
+
}
|
|
596
|
+
const perSession = dirs.map((dir) => mergeSidecar(dir, { apply, backupRoot, stamp, now }));
|
|
597
|
+
for (const file of sidecars) {
|
|
598
|
+
// sidecar 文件 → 会话目录 = 它的父目录
|
|
599
|
+
perSession.push(mergeSidecar(nodePath.dirname(file), { sidecarFile: file, apply, backupRoot, stamp, now }));
|
|
600
|
+
}
|
|
601
|
+
const merged = perSession.reduce((n, r) => n + r.merged, 0);
|
|
602
|
+
const skipped = perSession.reduce((n, r) => n + r.skipped, 0);
|
|
603
|
+
const errors = perSession.flatMap((r) => r.errors.map((e) => `${r.session}: ${e}`));
|
|
604
|
+
return {
|
|
605
|
+
mode: apply ? "merge-apply" : "merge-dry-run",
|
|
606
|
+
target,
|
|
607
|
+
sessions: perSession.length,
|
|
608
|
+
merged,
|
|
609
|
+
skipped,
|
|
610
|
+
ok: perSession.length > 0 && errors.length === 0,
|
|
611
|
+
errors,
|
|
612
|
+
backupDirs: perSession.map((r) => r.backupDir).filter(Boolean),
|
|
613
|
+
perSession,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
413
617
|
/* ───────────────────────────── verify ───────────────────────────── */
|
|
414
618
|
|
|
415
619
|
/**
|