dsh-gokifu-compact 1.11.0 → 1.11.2
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 +108 -0
- package/README.md +2 -2
- package/cordis.patch.yml +28 -0
- package/docs//346/236/266/346/236/204-/346/210/277/351/227/264/345/245/221/347/272/246.md +15 -8
- package/docs//346/236/266/346/236/204-/350/277/201/347/247/273/350/256/241/345/210/222.md +15 -1
- package/package.json +8 -2
- package/scripts/check-maintenance.mjs +13 -0
- package/scripts/cli.mjs +9 -0
- package/scripts/install.mjs +131 -3
- package/scripts/migrate-dangling.mjs +110 -0
- package/scripts/release.mjs +2 -2
- package/src/rooms/ledger.mjs +64 -12
- package/src/rooms/migrate.mjs +491 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,113 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 1.11.2 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
### 本包改成 DSH bundle(UI 挂载不再需要用户手改 YAML)
|
|
6
|
+
|
|
7
|
+
**这是第一个 UI bundle 版。** 以前装完包要**手改 profile 的 `cordis.patch.yml`** 加一条
|
|
8
|
+
`- insert: [{id: gokifu-ui, name: 'dsh-gokifu-compact'}]` 才能挂上 UI。手改有两个后患:
|
|
9
|
+
|
|
10
|
+
1. **双挂载**:用户升级包后手工行还留着,和 bundle 通道同时挂 ⇒ 两个 host 半注册同一个 `/gokifu/api` 前缀 ⇒ **启动即失败**(duplicate prefix route);
|
|
11
|
+
2. **没挂载**:`dsh plugin add` 不会自己补挂载行 ⇒ 「装好了但没生效」,且**没有任何报错**。
|
|
12
|
+
|
|
13
|
+
**改法**
|
|
14
|
+
|
|
15
|
+
- 新增 **`cordis.patch.yml`(28 行)**:只做一件事 —— `- insert: [{id: gokifu-ui, name: 'dsh-gokifu-compact'}]`。
|
|
16
|
+
**引擎不在这里**:压缩后端必须挂在 **agent preset 的 compaction group 内部**,profile/bundle 级 patch 打不到 `preset.config.plugins`;那一行仍由 `install.mjs` 改 preset。
|
|
17
|
+
`id` 保持 `gokifu-ui`(与历史手工行一致)⇒ 从手工通道切过来时,日志/文档/doctor 口径都不用改。
|
|
18
|
+
- `package.json`:`dsh.bundle.patch = "./cordis.patch.yml"` · `dsh.manifestVersion = 1` · `files`/`exports` 补 `cordis.patch.yml`;`main` 与 `dsh.client` 不变。
|
|
19
|
+
- **`scripts/install.mjs` 改 bundle 优先**:
|
|
20
|
+
- `findManualUiRow` / `removeManualUiRow`:定位并(带备份)删除 profile 里的旧手工行
|
|
21
|
+
- `patchProfileBundles()`:把本包**追加**进 `dsh.profile.bundles`,保持既有顺序;写前备份 profile/`package.json`
|
|
22
|
+
- `ensureBundleChannel()` = **先加 bundle、再删手工行** —— 顺序保证任何中断都不会出现「两条都没有」的空窗
|
|
23
|
+
- `git`/`registry` 规格**不改写成 `link`**
|
|
24
|
+
- **`test/verify-install.mjs` 判据升级**:旧判据「bundles 里没有它」→ **「UI 挂载通道二选一,不双挂载」**:两条都在 = 红(双挂载)、都没有 = 红(没挂载);检查项总数仍 **16**。
|
|
25
|
+
- **`test/ws-install.test.mjs`**:健康夹具改走 bundle 通道;**新增 S11 四种态**(bundle-only 绿 / manual-only 绿 / both 红 / neither 红)。
|
|
26
|
+
- **`test/install-link.test.mjs`** 夹具同步。
|
|
27
|
+
- **新增 `test/bundle.test.mjs`(6 条)**:B-1 声明齐全 · B-2 patch 只插 UI 一行 · B-3 `findManualUiRow` 三态 · B-4 bundle 优先+备份+幂等 · B-5 dry-run 零写 · B-6 registry 规格不被改写。
|
|
28
|
+
- **变异自检(4 条,逐条实测红)**:删 `dsh.bundle` ⇒ B-1;patch 塞引擎行 ⇒ B-2;安装器不删手工行 ⇒ B-4;判据退回「bundle 里不许有」⇒ S11 + S10e-1。
|
|
29
|
+
|
|
30
|
+
### 验证
|
|
31
|
+
|
|
32
|
+
- 全量:**443 passed / 0 failed**(36 个测试文件;436 + 6 bundle + 1)
|
|
33
|
+
- `rooms` **15/0** · `ws-install` **14/0** · `install-link` **29/0** · `bundle` **6/0** · `verify-install` **16/16**
|
|
34
|
+
- `bench 2000`:**351 tokens** / 1.423 ms(81,260 / 386,945 字符、1.41 MB、2001 节点)
|
|
35
|
+
- `npm pack --dry-run --json`:**70 文件**(69 + `cordis.patch.yml`)/ 558,113 B;`test/`·`tmp/` 命中 0
|
|
36
|
+
- live 当前仍是**手工通道**(`bundle=无|手工行=有`)—— `verify-install` 按「二选一」判据通过
|
|
37
|
+
|
|
38
|
+
**未验证(必须知道)**
|
|
39
|
+
|
|
40
|
+
- **未跑 live install**:`--apply` 到真实 profile 的 bundle 切换没有执行过(只在 dry-run 与夹具上验证)。
|
|
41
|
+
- **未启 DSH**:bundle 通道在真实插件树里的加载行为未观测。
|
|
42
|
+
- 从手工通道切换到 bundle 的**中断恢复**只在单测里模拟(顺序保证 + 备份),真机未演练。
|
|
43
|
+
|
|
44
|
+
## 1.11.1 — 2026-10-30
|
|
45
|
+
|
|
46
|
+
### P6-C 落地:未落地尝试不再被当成残留(**热清单新增 OPTIONS 段**)
|
|
47
|
+
|
|
48
|
+
1.11.0 之前,**超过 10 分钟仍没落地的尝试**(`to===null && result==="untested"`)被归进 `residue` 一起从热清单里丢掉。
|
|
49
|
+
这是错的:它不是残留,而是**「你可以继续,也可以放弃」的选项** —— 模型有权知道它存在。
|
|
50
|
+
|
|
51
|
+
**P6C-1 · 悬空边从四类变五类**
|
|
52
|
+
|
|
53
|
+
| 类 | 判据 | 进热清单? |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `residue` | `to===null` 且**已结算**(`result !== "untested"`)且 >10 分钟 | 不进(真残留,可归档) |
|
|
56
|
+
| **`candidate`(新)** | `to===null` 且 `result === "untested"` 且 >10 分钟 | **进 OPTIONS 段**,陈旧标 `[stale 42m]` |
|
|
57
|
+
| `live-unsettled` | `to===null` 且 `untested` 且 ≤10 分钟 | 进 OPTIONS(新鲜) |
|
|
58
|
+
| `from-broken` | `from` 指向不存在的节点 | 保留可见 |
|
|
59
|
+
| `none` | 其余 | — |
|
|
60
|
+
|
|
61
|
+
- 新增导出 `isDanglingOption(edge)`:只认 `result==="untested"` 的无终点边;**刚结算的失败边不算选项**
|
|
62
|
+
- `renderLedger` 新增 `OPTIONS (not yet landed — you may continue or abandon):` 区段;**UNVERIFIED 不再包含未落地的边**
|
|
63
|
+
- `renderFacts` 同样拆出 OPTIONS 段,并加 `STALE_UNSETTLED: N edges are untested and older than 10min — listed as OPTIONS …, NOT residue.` 计数行
|
|
64
|
+
- 测试 +3(`[stale 42m]` 标记 / settled 仍归 residue 的回归 / renderFacts 拆分与计数)
|
|
65
|
+
|
|
66
|
+
### P6C-2 · 迁移工具晋升为房间 R14 + 独立 CLI
|
|
67
|
+
|
|
68
|
+
- **`src/rooms/migrate.mjs`(20,913 B / 21 导出)**:`planSession` / `buildSidecar` / `removeEdges` / `listSessions` / `dryRun` / `freshSessions` / `backupTree` / `rollback` / `apply` / `verify` + 路径规则
|
|
69
|
+
- **`scripts/migrate-dangling.mjs`** 新 CLI(默认 dry-run)+ `scripts/cli.mjs` 增 `migrate-dangling` 子命令;参数 `--apply` / `--verify` / `--rollback` / `--backup` / `--root` / `--backup-dir` / `--sessions=` / `--force` / `--now` / `--json` / `--help`;退出码 0/1/2
|
|
70
|
+
- **只归档 settled residue** —— `candidate` 一条都不动,且 `planSession` 有硬断言:`residue` 集合里出现 `result==="untested"` 就抛错(防判据回退时悄悄归档)
|
|
71
|
+
- **路径经 R01**:`sessionsRoot()` = `join(graphRoot({...}), "sessions")`,不自己拼
|
|
72
|
+
- **120s 新鲜度守卫**:`--apply` 前扫会话目录,任一 `graph.json` 的 `|now − mtime| < 120s` ⇒ **拒绝 apply 且不备份、不写任何文件**(用绝对值:NTFS 时间戳可能比 `Date.now()` 超前几毫秒);`--force` 才跳过
|
|
73
|
+
- **备份写到 `sessions/` 之外**(`<DSH_HOME>/chess-context/migrate-backups/backup-<ts>`),否则会被自身 `cpSync` 递归
|
|
74
|
+
- **坏图逐会话汇总**:`dryRun`/`apply`/`verify` 都返回 `badGraphs:[{session, ok:false, reason}]`(旧原型静默跳过)
|
|
75
|
+
- 原型 `test/archive-dangling.mjs` 删除,测试迁为 `test/migrate.test.mjs`(**10/0**:7 条原有 + 路径经 R01 + 守卫 + 坏图)
|
|
76
|
+
|
|
77
|
+
**P6C-3 · 隔离目录端到端演练(PASS)**
|
|
78
|
+
|
|
79
|
+
夹具 2 会话 × 5 边(residue / candidate / live / from-broken / 真失败各一),跑前跑后 `git status` = 0:
|
|
80
|
+
|
|
81
|
+
| 阶段 | 结果 |
|
|
82
|
+
|---|---|
|
|
83
|
+
| dry-run | `residue:2 / candidates:2 / liveUnsettled:2 / fromBroken:2`,`deltaBytes:-68`;**整树 sha 前后完全相同** ⇒ 真的没写 |
|
|
84
|
+
| apply | 归档 **2 条 residue**、**`candidate` 一条未动**(重算 residue=0 / candidate=1)、`-68 B` |
|
|
85
|
+
| sidecar | 每会话 `graph.dangling.json`:源图 `sha256`/`bytes`/`criteria` + 归档边**全字段** ⇒ 可回灌 |
|
|
86
|
+
| verify | `ok:true`,抽样边与备份图逐字段相同,`problems:[]` |
|
|
87
|
+
| rollback | 整树 sha **回到原值** ⇒ 逐字恢复 |
|
|
88
|
+
|
|
89
|
+
### 发布流程修复:`release.mjs` 现在打 **annotated tag**
|
|
90
|
+
|
|
91
|
+
1.10.0 / 1.11.0 的 tag 是**轻量 tag**(`git cat-file -t` 只看到 `commit`)—— 没有作者、日期、消息,
|
|
92
|
+
GitHub Release 的 Tag 一栏是空的,「这一版改了什么」在 tag 上查不回来。
|
|
93
|
+
**修复**:`scripts/release.mjs` 的计划文本与真做命令统一为 `git tag -a <tag> -m "release <tag>"`;
|
|
94
|
+
`scripts/check-maintenance.mjs` 增加静态断言 ⑦ 盯着这条,谁改回 lightweight 立刻红。
|
|
95
|
+
⚠ **已发布的 `v1.11.0` 不追改**(tag 是对外契约);规则从**本版起**生效。
|
|
96
|
+
|
|
97
|
+
### 验证
|
|
98
|
+
|
|
99
|
+
- 全量:**436 passed / 0 failed**(36 个测试文件;A3 收口 430 + P6C-1 的 3 + P6C-2 的 10 − 原型 7)
|
|
100
|
+
- `rooms` 架构门禁 **15/0** · `migrate` **10/0** · `verify-install` **16/16**
|
|
101
|
+
- `bench 2000`:**351 tokens** / 1.333 ms(81,260 / 386,945 字符、1.41 MB、2001 节点)—— 与基线逐字一致
|
|
102
|
+
- `npm pack --dry-run --json`:**69 文件**(67 + `rooms/migrate.mjs` + `scripts/migrate-dangling.mjs`)/ 552,403 B;`test/`·`tmp/`·harness 命中 **0**
|
|
103
|
+
|
|
104
|
+
**未验证(必须知道)**
|
|
105
|
+
|
|
106
|
+
- **对 live `sessions/**` 的 apply 从未执行** —— P6C-3 是隔离夹具(2 会话),真实库是 150 个会话 / 13,043 个 agent 节点;规模与真实判据分布都没经历过。
|
|
107
|
+
- **停插件窗口流程一次都没跑**(隔离目录压根没碰运行中的插件)。
|
|
108
|
+
- **sidecar 回灌未实现**(字段齐全 ≠ 有回灌工具)。
|
|
109
|
+
- P6C-2 有一处**契约文字偏差**已披露:契约 R14 的允许依赖未列 R06,但判据必须单一来源(`classifyDangling` 只在 R06)⇒ 房间实际 import `./ledger.mjs` 的判据函数与 `DANGLING_STALE_MS`,已补进 ROOMS 的 allowed 表。
|
|
110
|
+
|
|
3
111
|
## 1.11.0 — 2026-10-30
|
|
4
112
|
|
|
5
113
|
### 最小交付房间架构收口:A2 + A3 全部完成(**首个把 16 房间落到底的正式版**)
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-GOkifu-compact
|
|
2
2
|
|
|
3
|
-
> 当前版本:**`1.11.
|
|
3
|
+
> 当前版本:**`1.11.2`** · 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 + 每步只有 323 tokens 的热清单」。**
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
热清单 323 tokens (2000 边压力测试;旧版全量渲染 28,800)
|
|
9
9
|
失败保留 8 / 8 (真实模型对照;官方 8 段模板 4.67 / 8)
|
|
10
10
|
清单开销 0.2% of prompt (v1.3.1 起;事故会话的旧版是 21.3%)
|
|
11
|
-
验证
|
|
11
|
+
验证 443 项单测 · 真实包 8/8 · 真实进程 fold 3~6/6
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# dsh-gokifu-compact bundle patch
|
|
2
|
+
#
|
|
3
|
+
# 这是本包的 `dsh.bundle.patch` 层(`package.json` 的 `dsh.bundle.patch` 指向本文件)。
|
|
4
|
+
# 它只做一件事:**把 UI 的 host 半插进插件树**(`/gokifu/api` + 详情页数据/写配置)。
|
|
5
|
+
#
|
|
6
|
+
# 为什么现在改成 bundle:以前靠 `dsh plugin add` 之后**手改 profile 的 cordis.patch.yml**
|
|
7
|
+
# 加一条 `- insert: [{id: gokifu-ui, name: 'dsh-gokifu-compact'}]`。手改有两个后患:
|
|
8
|
+
# ① 用户升级包之后,这条手工行留在 profile 里,和 bundle 通道**同时**挂载 ⇒ 两个 host 半
|
|
9
|
+
# 注册同一个 `/gokifu/api` 前缀 ⇒ 启动即失败(duplicate prefix route);
|
|
10
|
+
# ② `dsh plugin add` 不会自己补挂载行 ⇒ 「装好了但没生效」,且没有任何报错。
|
|
11
|
+
# 声明成本包的 patch 层之后,profile 只要把本包写进 `dsh.profile.bundles`,补丁会被自动合并,
|
|
12
|
+
# 与手工行**语义完全等价**(同样是 `- insert:` 一行),但不再需要用户改任何 YAML。
|
|
13
|
+
#
|
|
14
|
+
# ⚠ 两条通道**只能二选一**:
|
|
15
|
+
# · bundle 通道:`dsh.profile.bundles` 含 `dsh-gokifu-compact`,profile 自己的
|
|
16
|
+
# `cordis.patch.yml` 里**不得**再有 `id: gokifu-ui` 的手工行;
|
|
17
|
+
# · 手工通道:profile 自己的 `cordis.patch.yml` 有那一行,且 bundles 里**不得**含本包。
|
|
18
|
+
# `scripts/install.mjs` 切到 bundle 通道时会**备份并删除**旧手工行;
|
|
19
|
+
# `test/verify-install.mjs` 有「二选一、不双挂载」的机器判据(两条都在 = 红)。
|
|
20
|
+
#
|
|
21
|
+
# 引擎(压缩后端)不在这里:它必须挂在 **agent preset 的 compaction group 内部**,
|
|
22
|
+
# profile/bundle 级 patch 打不到 preset 的 `config.plugins`(见 docs/DSH-技术测绘.md §7.6)。
|
|
23
|
+
# 那一行由 `scripts/install.mjs` 改 preset 完成;本文件只管 UI。
|
|
24
|
+
#
|
|
25
|
+
# id 保持 `gokifu-ui`(与历史手工行一致):从手工通道切过来时,日志/文档/doctor 都不用改口径。
|
|
26
|
+
- insert:
|
|
27
|
+
- id: gokifu-ui
|
|
28
|
+
name: 'dsh-gokifu-compact'
|
|
@@ -55,6 +55,7 @@ export const ROOMS = {
|
|
|
55
55
|
|
|
56
56
|
> **A3 已完成(2026-10-30)**:11 个房间全在 `src/rooms/<id>.mjs`;`src/` 顶层只剩 `src/engine.mjs`(R09)与 `src/ui.mjs`(R11)。`src/engine.mjs`、`src/ui.mjs`、`lib/client.js`、`scripts/*` **永久不动**(`package.json` 的 `main`/`exports`/`dsh.client` 与 `bin` 已把它们钉成公共契约)。
|
|
57
57
|
> **「命名导出(实测)」= `Object.keys(await import(path)).length` 的运行时值**(`src/engine.mjs` 因 peer 依赖不可直接 import,用静态扫描得 5;`lib/client.js` 是浏览器模块、无 ESM export;`scripts/cli.mjs` 是 `bin` 入口、无命名导出)。
|
|
58
|
+
> **P6-C 之后**:`src/rooms/` 共 **12 个文件**(A3 收口 11 + P6C-2 的 `migrate.mjs`);`R14` 是**唯一有「纯逻辑房间 + CLI 外壳」两层**的房间。
|
|
58
59
|
> **⇒ 与 §2.3 名字列表的差**:R00 45→**43**、R01 14→**10**、R03 12→**11**、R06 15→**14**、R15 8→**9** —— **运行时导出面才是事实**,§2.3 列表是设计意图;两者不一致时**以本节实测为准,并回头修 §2.3**。
|
|
59
60
|
|
|
60
61
|
| id | 房间 | A3 前原位置 | **最终路径(A3 已完成)** | 命名导出(实测) | 允许依赖(房间 id) | 墓碑 filter |
|
|
@@ -73,7 +74,7 @@ export const ROOMS = {
|
|
|
73
74
|
| R11 | ui-host | `src/ui.mjs`(809) | `src/ui.mjs`(**永久**) | **21** | R13.`preset-format`、R15 | `ui` |
|
|
74
75
|
| R12 | ui-client | `lib/client.js`(272) | `lib/client.js`(**永久**) | **0(浏览器模块,无 ESM export)** | `react` | `ui`(装载用例) |
|
|
75
76
|
| R13 | install | `scripts/install.mjs`(497)+ `link-path.mjs`(333)+ `cli.mjs`(49) | `scripts/`(**永久**)+ `src/rooms/preset-format.mjs` | **13 + 12 + 0** | R15 | `install-link`、`ws-install` |
|
|
76
|
-
| R14 | migrate | `test/archive-dangling.mjs`(474
|
|
77
|
+
| R14 | migrate | `test/archive-dangling.mjs`(474,**原型已删**) | **`src/rooms/migrate.mjs`** + `scripts/migrate-dangling.mjs` + `scripts/cli.mjs` 子命令(**P6C-2 已晋升**) | **21** | R00、R01、R06(判据)· `node:fs/path/crypto` | `migrate`(**10/0**) |
|
|
77
78
|
| R15 | config-schema | 无(5 处散落,见 A1 §3.6) | `src/rooms/config-schema.mjs` | **9** | — | `ui-config`、`config-schema` |
|
|
78
79
|
|
|
79
80
|
### 2.2 目录与命名(一个决定,不并列)
|
|
@@ -304,18 +305,24 @@ src/rooms/<room-id>.mjs ← 其余全部房间,**单文件一房间,
|
|
|
304
305
|
- **墓碑**:`test/install-link.test.mjs`(D2-D8 六组变异必红)+ `test/ui.test.mjs:55/66`(preset 往返)→ 删掉后「一条命令接管压缩后端 + 可回滚」消失。
|
|
305
306
|
|
|
306
307
|
#### R14 `migrate` — 数据迁移(P6-C)
|
|
307
|
-
- **职责**:把
|
|
308
|
-
|
|
309
|
-
-
|
|
310
|
-
-
|
|
308
|
+
- **职责**:把 **`classifyDangling === "residue"`**(`to=null` **且** `result !== "untested"` 且超 10 分钟)的**已结算残留**归档到 sidecar 并从主图移除;**只读判据来自 R06**。
|
|
309
|
+
**⚠ 陈旧 `untested` 边(`candidate`)不是残留,一条都不动** —— 它是「你可以继续,也可以放弃」的 **OPTIONS**,由 R06 渲染进热清单(P6C-1)。
|
|
310
|
+
- **入口**:`src/rooms/migrate.mjs`(**P6C-2 已晋升,20,913 B**)+ `scripts/migrate-dangling.mjs`(CLI 外壳,5,545 B)+ `scripts/cli.mjs` 的 `migrate-dangling` 子命令。原型 `test/archive-dangling.mjs` **已删除**(git 识别为 rename)。
|
|
311
|
+
- **export(21,实测)**:`SIDECAR_FORMAT`、`SIDECAR_NAME`、`SIDECAR_VERSION`、`CRITERIA`、`APPLY_FRESHNESS_MS`、`listSessions`、`planSession`、`buildSidecar`、`dryRun`、`apply`、`verify`、`rollback`、`backupTree`、`freshSessions`、`hashTree`、`sha256`、`writeFileAtomic`、`removeEdges`、`dshHome`、`sessionsRoot`、`backupsRoot`。
|
|
312
|
+
(相对计划稿的 13 个:合并掉的 `planMigration/applyMigration/verifyMigration/rollbackMigration` 改为 `dryRun/apply/verify/rollback`,并新增 `CRITERIA`、`APPLY_FRESHNESS_MS`、`freshSessions`、`backupTree`、三个路径函数。)
|
|
313
|
+
- **输入→输出**:`<sessionsRoot>` 文件系统 → `{mode, sessions, affectedSessions, residue, **candidates**, liveUnsettled, fromBroken, sidecarEdges, graphBytesBefore/After, deltaBytes, badGraphs[], perSession[]}`;`apply` 额外 `backupDir`;`verify` 额外 `ok` / `problems[]`。
|
|
314
|
+
**`candidates` 是一个必须存在且必须不变的字段** —— `verify` 与 `APPLY.json` 都比对它,变了即 `problem`。
|
|
311
315
|
- **错误语义**:单会话失败不中断,逐会话 `{ok:false,reason}` 汇总;`verify` 有问题 → 退出码 1;参数错 → 退出码 2;**默认 dry-run,一个字节都不写**(现有 `archive-dangling.test.mjs:103-126` 已锁)。
|
|
312
316
|
- **配置键**:**只有 CLI 参数**,不进 preset:`--root=<sessionsRoot>`、`--dry-run`(默认)/`--apply`、`--verify --backup=<dir>`、`--rollback=<dir>`、`--backup-dir=<dir>`、`--now=<epochMs>`、`--json`。新增 `--sessions=<csv>` 与 `--force`(见迁移计划 P6C-1)。
|
|
313
|
-
- **测试入口**:`node test/run.mjs migrate
|
|
317
|
+
- **测试入口**:`node test/run.mjs migrate` → **10/0**(7 条等价迁移 + **P6C-8 路径经 R01 `graphRoot`** + **P6C-9 120s 新鲜度守卫**)。
|
|
314
318
|
- **允许依赖**:R00(只读 `fromJSON/toJSON/renderIndexMd`)、R01(`graphRoot/sessionPaths` 取路径)、R06(`classifyDangling`/`DANGLING_STALE_MS`——**判据只有这一份,禁止复制**)、`node:fs/path/crypto`。
|
|
315
319
|
- **禁止依赖**:R09(engine 不参与)、R11、R12、R05。
|
|
316
320
|
- **兼容策略**:sidecar `format:"dsh-gokifu-dangling-sidecar"`、`version:1`、`source.sha256`、`criteria` 字段冻结;备份目录 `MANIFEST.json` 的 `{createdAt,source,files[{rel,bytes,sha256}],fileCount,totalBytes}` 冻结;回滚必须逐字节还原并**删除 apply 之后新增的文件**(`archive-dangling.mjs:235-242`)。
|
|
317
321
|
- **状态归属**:无。
|
|
318
322
|
- **墓碑**:`test/migrate.test.mjs::P6C-4`(apply 后 sidecar 数 = 删边数、备份逐字可恢复)→ 删掉后「5,567 条残留边能被安全归档且可回滚」消失。
|
|
323
|
+
- **两条不可放宽的安全约束(P6C-2 实测换来的)**:
|
|
324
|
+
1. **`planSession` 硬断言 residue 里不许有 `untested`** —— 判据一旦回退(`classifyDangling` 被改回四类),这里**直接抛错**,而不是悄悄把 candidate archive 掉;
|
|
325
|
+
2. **`freshSessions` 用 `|now − mtime| < 120_000` 的绝对值** —— NTFS 时间戳可能比 `Date.now()` 超前几毫秒,只判 `age >= 0` 会漏判;命中即**拒绝 apply 且不写任何文件**(`--force` = `maxAgeMs: 0` 可跳过)。
|
|
319
326
|
|
|
320
327
|
#### R15 `config-schema` — 配置的单一来源
|
|
321
328
|
- **职责**:把 5 份来源(23/20/16/15/15 键)收敛成 1 张表;`resolve` 是「生效值 + 来源」的唯一实现。
|
|
@@ -450,7 +457,7 @@ DSH compactIfNeeded ──► R09 ──► R02.planFold(surface/priced/eventA
|
|
|
450
457
|
| R-B | R00-R08 与 R15 的文件不得 import `@deepseek-ai/*`、R09、R11、`scripts/` | 绿 |
|
|
451
458
|
| R-C | R09 之外任何文件不得 import `@deepseek-ai/*`(保证 harness 的 4 条替换够用) | 绿 |
|
|
452
459
|
| R-D | `lib/client.js` 不得 import 仓库内任何文件;R10/R14 不得 import R09 | 绿 |
|
|
453
|
-
| R-E(新) | `src/engine.mjs` 不得 import R14(**P6-C 禁止 engine 启动钩子**的机器判据);R14 不得 import R09 |
|
|
460
|
+
| R-E(新) | `src/engine.mjs` 不得 import R14(**P6-C 禁止 engine 启动钩子**的机器判据);R14 不得 import R09 | **绿(R14 已有代码,判据真正生效)** |
|
|
454
461
|
| R-F(新) | `src/**`、`scripts/**`、`lib/**` 中不得出现 `allowedModels` 字样(宿主全局配置,插件不读不写) | 绿 |
|
|
455
462
|
| A3-2-fix | `src/` 顶层只允许 `engine.mjs` / `ui.mjs`;`DELETED_FACADES` 的 8 个名字不得在顶层出现、不得被任何文件 import | 绿 |
|
|
456
463
|
| A3-3 | `ROOMS` 每项 `file === <id>.mjs` 且真实存在;房间集合恰为契约 11 个;C1/R-C 的宿主说明符集合恰为 4 个 | 绿 |
|
|
@@ -607,7 +614,7 @@ DSH compactIfNeeded ──► R09 ──► R02.planFold(surface/priced/eventA
|
|
|
607
614
|
| R11 | `ui`、`ui-security`、`ui-config`、`ui-perf`、`version-path` | ✓ | — |
|
|
608
615
|
| R12 | `ui`(源码文本) | ✓(弱) | Node 装载用例 |
|
|
609
616
|
| R13 | `install-link`、`ws-install`、`ui` | ✓ | — |
|
|
610
|
-
| R14 |
|
|
617
|
+
| R14 | `migrate`、`dangling-exclusion` | ✅ **10/0**(7 等价迁移 + P6C-8 路径经 R01 + P6C-9 120s 守卫) | — |
|
|
611
618
|
| R15 | `ui-config` | ✓(只比 label) | `config-schema`(表驱动全键) |
|
|
612
619
|
|
|
613
620
|
## 附录 B · 对 A1 的勘误(读代码发现)
|
|
@@ -438,7 +438,21 @@ npm pack --dry-run --json # 文件数 == 基线 +4(A2 的 4 个 rooms
|
|
|
438
438
|
|
|
439
439
|
### 5.1 P6-C:归 `migrate` 房间 + 显式 CLI,**禁止** engine 启动钩子
|
|
440
440
|
|
|
441
|
-
|
|
441
|
+
> ## ✅ **P6-C 已落地(2026-10-30)—— 本节其余内容是**立项时的计划稿**,保留作决策留痕**
|
|
442
|
+
>
|
|
443
|
+
> | 刀 | 内容 | 结果 |
|
|
444
|
+
> |---|---|---|
|
|
445
|
+
> | **P6C-1** | **候选语义**(`462a814`) | `classifyDangling` **四类 → 五类**:陈旧 `untested` 从 `residue` **拆出为 `candidate`**;新增 `isDanglingOption`;`danglingStats` = `{residue, candidates, liveUnsettled, fromBroken}`;`renderLedger`/`renderFacts` 新增 `OPTIONS (not yet landed — you may continue or abandon):` 段 + `STALE_UNSETTLED` 计数行。全量 433/0(430+3)· pack 67 不变 |
|
|
446
|
+
> | **P6C-2** | **R14 + CLI 晋升**(`20ffa94`) | `src/rooms/migrate.mjs`(**21 导出**,20,913 B)+ `scripts/migrate-dangling.mjs`(5,545 B)+ `scripts/cli.mjs` 子命令;原型 `test/archive-dangling.mjs` **删除**(git 识别为 rename)。**436/0** · `rooms` 15/0 · `migrate` **10/0** · pack **69**(+2) |
|
|
447
|
+
> | **P6C-3** | **隔离目录端到端**(task-115) | **PASS**:dry-run 整树 sha `dc547cb8…` 前后相同(真的没写);apply 归档 2 条 residue、**candidate 2 条一条未动**、residue 清零、−68 B;verify `ok=true`;**rollback 逐字恢复** |
|
|
448
|
+
> | **真实 apply** | — | **未做** —— 隔离夹具(2 会话 × 5 边)≠ 真实 150 个会话;**需主人授权 + 停插件窗口 + 先 dry-run 对账** |
|
|
449
|
+
>
|
|
450
|
+
> **⇒ 语义收窄(这条最重要)**:立项时写的是「把 `to=null` 且超 10 分钟的**历史残留边**归档」。落地后**只归档 `residue`**(`result !== "untested"` 的**已结算**边);**陈旧 `untested` 边改称 `candidate`,一律保留**,作为「继续或放弃」的 OPTIONS 渲染进热清单 —— **它不是脏数据,是可行动的选择**。
|
|
451
|
+
> **⇒ 守卫**:`planSession` 硬断言 residue 里不许出现 `untested`(判据回退时抛错,不悄悄归档);`--apply` 前 `freshSessions` 用 **`|now − mtime| < 120s` 绝对值**拒绝(NTFS 时间戳可能超前几毫秒),命中即不写任何文件,`--force` 才跳过。
|
|
452
|
+
>
|
|
453
|
+
> ---
|
|
454
|
+
>
|
|
455
|
+
> **立项时的现状(历史记录,已被上表取代)**:`test/archive-dangling.mjs`(474 行)+ `test/archive-dangling.test.mjs`(221 行 / 7 用例)**未跟踪**。它已经是 C 方案(sidecar)的可运行原型:`planSession`/`buildSidecar`/`dryRun`/`apply`/`verify`/`rollback`/`hashTree`/`sha256`/`listSessions`,判据复用 `ledger.classifyDangling`(`:30`),默认 dry-run,apply 先整目录备份(`backupTree` 逐文件 sha256 比对)。
|
|
442
456
|
|
|
443
457
|
**立项边界**
|
|
444
458
|
- 归属:**R14 `migrate`**(`src/rooms/migrate.mjs`)+ **显式 CLI**(`scripts/migrate-dangling.mjs` + `scripts/cli.mjs` 增 `migrate-dangling` 子命令)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gokifu-compact",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.2",
|
|
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",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"./engine": "./src/engine.mjs",
|
|
21
21
|
"./client": "./lib/client.js",
|
|
22
|
-
"./package.json": "./package.json"
|
|
22
|
+
"./package.json": "./package.json",
|
|
23
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
23
24
|
},
|
|
24
25
|
"engines": {
|
|
25
26
|
"node": ">=20"
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"docs",
|
|
34
35
|
"evidence",
|
|
35
36
|
"scripts",
|
|
37
|
+
"cordis.patch.yml",
|
|
36
38
|
"CHANGELOG.md",
|
|
37
39
|
"QUICKSTART.md",
|
|
38
40
|
"LICENSE"
|
|
@@ -79,6 +81,10 @@
|
|
|
79
81
|
"dsh-gokifu-compact": "./scripts/cli.mjs"
|
|
80
82
|
},
|
|
81
83
|
"dsh": {
|
|
84
|
+
"bundle": {
|
|
85
|
+
"patch": "./cordis.patch.yml"
|
|
86
|
+
},
|
|
87
|
+
"manifestVersion": 1,
|
|
82
88
|
"client": {
|
|
83
89
|
"platform": "web",
|
|
84
90
|
"inject": []
|
|
@@ -128,6 +128,19 @@ if (from && to) {
|
|
|
128
128
|
"补 tag:git tag <tag> <commit>(补历史版本是可以的,不改代码)")
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// ── ⑦ release.mjs 必须打 annotated tag ───────────────────────────────
|
|
132
|
+
// lightweight tag 只指向 commit,没有作者/日期/消息:GitHub Release 的「Tag」一栏是空的,
|
|
133
|
+
// `git cat-file -t` 只看到 commit —— 「这一版到底改了什么」在 tag 上查不回来。
|
|
134
|
+
// 1.11.0 就是 lightweight tag(`v1.11.0`),事后不可改;规则从下一版起生效。
|
|
135
|
+
const releaseSrc = existsSync(join(ROOT, "scripts", "release.mjs"))
|
|
136
|
+
? readFileSync(join(ROOT, "scripts", "release.mjs"), "utf8")
|
|
137
|
+
: ""
|
|
138
|
+
const usesAnnotated = releaseSrc.includes("git tag -a") && !/git tag \$\{tag\} /.test(releaseSrc)
|
|
139
|
+
ok(usesAnnotated,
|
|
140
|
+
"release.mjs 打的是 annotated tag(git tag -a … -m …)",
|
|
141
|
+
"用 lightweight tag ⇒ Release 页没有 tag 说明、`git cat-file -t` 只看到 commit,历史无法解释",
|
|
142
|
+
'把 scripts/release.mjs 里的 "git tag <tag>" 改成 "git tag -a <tag> -m \\"release <tag>\\""')
|
|
143
|
+
|
|
131
144
|
// ── 输出 ──────────────────────────────────────────────────────────────
|
|
132
145
|
|
|
133
146
|
report()
|
package/scripts/cli.mjs
CHANGED
|
@@ -31,6 +31,13 @@ if (command === "install" || command === "revert") {
|
|
|
31
31
|
} catch (error) {
|
|
32
32
|
process.exitCode = typeof error?.status === "number" ? error.status : 1;
|
|
33
33
|
}
|
|
34
|
+
} else if (command === "migrate-dangling") {
|
|
35
|
+
const migrate = nodePath.join(HERE, "migrate-dangling.mjs");
|
|
36
|
+
try {
|
|
37
|
+
execFileSync(process.execPath, [migrate, ...argv.slice(1)], { stdio: "inherit" });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
process.exitCode = typeof error?.status === "number" ? error.status : 1;
|
|
40
|
+
}
|
|
34
41
|
} else {
|
|
35
42
|
console.log(`dsh-gokifu-compact — DSH 的棋谱式上下文压缩后端
|
|
36
43
|
|
|
@@ -41,6 +48,8 @@ if (command === "install" || command === "revert") {
|
|
|
41
48
|
还原上一次的 preset 备份
|
|
42
49
|
dsh-gokifu-compact doctor
|
|
43
50
|
体检:安装 / 摘要模型 / 压缩成败 / 棋谱 / 树新鲜度
|
|
51
|
+
dsh-gokifu-compact migrate-dangling [--apply] [--verify --backup=<dir>]
|
|
52
|
+
P6-C 悬空边 sidecar 归档(默认 dry-run;apply 需停插件窗口)
|
|
44
53
|
|
|
45
54
|
装包(先做这一步):
|
|
46
55
|
dsh plugin --profile <name> add dsh-gokifu-compact
|
package/scripts/install.mjs
CHANGED
|
@@ -158,6 +158,127 @@ export function patchProfileForUi(profileDir, log = () => {}, dryRun = false) {
|
|
|
158
158
|
return { changed: true, backup };
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
|
|
162
|
+
/* ─────────────── ②·b bundle 通道(task-118) ─────────────── */
|
|
163
|
+
|
|
164
|
+
/** 手工行的 id(历史值,保持兼容:日志/doctor/文档都用这个口径)。 */
|
|
165
|
+
const MANUAL_UI_ID = "gokifu-ui";
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 在 profile 的 cordis.patch.yml 里定位手工 UI 行(`- insert:` + `- id: gokifu-ui`)。
|
|
169
|
+
*
|
|
170
|
+
* 纯函数:只算行号,不改任何东西(方便测试与 dry-run)。找不到返回 null。
|
|
171
|
+
* 结构不认识时返回 `{ unsupported: true }` —— 调用方必须**拒绝改**,绝不能猜着删。
|
|
172
|
+
* @returns {{startLine:number,endLine:number,start:number,end:number,wholeBlock:boolean}|{unsupported:true,reason:string}|null}
|
|
173
|
+
*/
|
|
174
|
+
export function findManualUiRow(text) {
|
|
175
|
+
const lines = String(text).split("\n");
|
|
176
|
+
const childIdx = lines.findIndex((l) => new RegExp("^\\s*- id:\\s*" + MANUAL_UI_ID + "\\s*$").test(l));
|
|
177
|
+
if (childIdx < 0) return null;
|
|
178
|
+
// 往上找父级 `- insert:`(最多 12 行内;缩进必须比子行浅)
|
|
179
|
+
const childIndent = lines[childIdx].match(/^\s*/)[0].length;
|
|
180
|
+
let parentIdx = -1;
|
|
181
|
+
for (let i = childIdx - 1; i >= Math.max(0, childIdx - 12); i -= 1) {
|
|
182
|
+
const m = /^(\s*)- insert:\s*$/.exec(lines[i]);
|
|
183
|
+
if (m && m[1].length < childIndent) {
|
|
184
|
+
parentIdx = i;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (parentIdx < 0) return { unsupported: true, reason: "找不到 `- insert:` 父行" };
|
|
189
|
+
// 父块范围 = 从父行到下一条**缩进 ≤ 父缩进**的非空行
|
|
190
|
+
const parentIndent = lines[parentIdx].match(/^\s*/)[0].length;
|
|
191
|
+
let endIdx = lines.length;
|
|
192
|
+
for (let i = parentIdx + 1; i < lines.length; i += 1) {
|
|
193
|
+
if (lines[i].trim().length === 0) continue;
|
|
194
|
+
if (lines[i].match(/^\s*/)[0].length <= parentIndent && /^\s*- /.test(lines[i])) {
|
|
195
|
+
endIdx = i;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// 这个 insert 块里还有别的子行吗?(有 ⇒ 只删本行;没有 ⇒ 整块删)
|
|
200
|
+
const otherChildren = lines
|
|
201
|
+
.slice(parentIdx + 1, endIdx)
|
|
202
|
+
.some((l) => l.match(/^\s*/)[0].length === childIndent && /^\s*- /.test(l) && !new RegExp("^\\s*- id:\\s*" + MANUAL_UI_ID + "\\s*$").test(l));
|
|
203
|
+
return { startLine: childIdx, endLine: childIdx, start: parentIdx, end: endIdx - 1, wholeBlock: !otherChildren };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 从 profile 的 cordis.patch.yml 删除手工 UI 行(**带备份**)。
|
|
208
|
+
* 只在 `wholeBlock` 时连注释一起删:注释是与那一行配套的说明,留着会变成误导性死注释。
|
|
209
|
+
*/
|
|
210
|
+
export function removeManualUiRow(profileDir, log = () => {}, dryRun = false) {
|
|
211
|
+
const file = nodePath.join(profileDir, "cordis.patch.yml");
|
|
212
|
+
if (!nodeFs.existsSync(file)) return { changed: false, reason: "没有 profile cordis.patch.yml" };
|
|
213
|
+
const text = nodeFs.readFileSync(file, "utf8");
|
|
214
|
+
const found = findManualUiRow(text);
|
|
215
|
+
if (found === null) return { changed: false, reason: "没有手工 UI 行" };
|
|
216
|
+
if (found.unsupported) return { changed: false, reason: `手工行结构不认识(${found.reason}),拒绝改` };
|
|
217
|
+
const lines = text.split("\n");
|
|
218
|
+
let start = found.start;
|
|
219
|
+
let end = found.end;
|
|
220
|
+
if (found.wholeBlock) {
|
|
221
|
+
// 连带删除紧贴其上、提到 gokifu 的连续注释行
|
|
222
|
+
while (start - 1 >= 0 && /^\s*#/.test(lines[start - 1]) && /gokifu|上下文压缩/i.test(lines[start - 1])) start -= 1;
|
|
223
|
+
} else {
|
|
224
|
+
start = found.startLine;
|
|
225
|
+
end = found.endLine;
|
|
226
|
+
}
|
|
227
|
+
let next = [...lines.slice(0, start), ...lines.slice(end + 1)].join("\n");
|
|
228
|
+
next = next.replace(/\n{3,}/g, "\n\n");
|
|
229
|
+
if (dryRun) {
|
|
230
|
+
log(" [dry] profile cordis.patch.yml 会删掉手工 gokifu-ui 行(改走 bundle 通道)");
|
|
231
|
+
return { changed: true, dryRun: true };
|
|
232
|
+
}
|
|
233
|
+
const backup = `${file}.bak-before-gokifu-ui-${Date.now()}`;
|
|
234
|
+
nodeFs.copyFileSync(file, backup);
|
|
235
|
+
nodeFs.writeFileSync(file, next, "utf8");
|
|
236
|
+
log(` ✓ profile cordis.patch.yml 已删手工 gokifu-ui 行(备份 ${nodePath.basename(backup)})`);
|
|
237
|
+
return { changed: true, backup };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 确保 profile 的 `dsh.profile.bundles` 含本包(**追加在末尾**,保持既有顺序)。
|
|
242
|
+
* 为什么这是主通道:bundle 声明了 patch 层 ⇒ 装载器自动合并本包的 cordis.patch.yml,
|
|
243
|
+
* 与手写那一行语义等价,但用户不用改 YAML,升级也不会漏。
|
|
244
|
+
*/
|
|
245
|
+
export function patchProfileBundles(profileDir, log = () => {}, dryRun = false) {
|
|
246
|
+
const file = nodePath.join(profileDir, "package.json");
|
|
247
|
+
if (!nodeFs.existsSync(file)) return { changed: false, reason: "没有 profile/package.json" };
|
|
248
|
+
let pkg;
|
|
249
|
+
try {
|
|
250
|
+
pkg = JSON.parse(nodeFs.readFileSync(file, "utf8"));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return { changed: false, reason: `profile/package.json 不是合法 JSON(${error.message})` };
|
|
253
|
+
}
|
|
254
|
+
const dsh = pkg.dsh && typeof pkg.dsh === "object" ? pkg.dsh : {};
|
|
255
|
+
const profile = dsh.profile && typeof dsh.profile === "object" ? dsh.profile : {};
|
|
256
|
+
const bundles = Array.isArray(profile.bundles) ? profile.bundles : [];
|
|
257
|
+
if (bundles.includes(PKG_NAME)) return { changed: false, reason: "已在 dsh.profile.bundles" };
|
|
258
|
+
const next = [...bundles, PKG_NAME];
|
|
259
|
+
if (dryRun) {
|
|
260
|
+
log(` [dry] profile/package.json 的 dsh.profile.bundles 会追加 ${PKG_NAME}(现 ${bundles.length} 项)`);
|
|
261
|
+
return { changed: true, dryRun: true };
|
|
262
|
+
}
|
|
263
|
+
nodeFs.copyFileSync(file, `${file}.bak-before-gokifu-${Date.now()}`);
|
|
264
|
+
pkg.dsh = { ...dsh, profile: { ...profile, bundles: next } };
|
|
265
|
+
nodeFs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
266
|
+
log(` ✓ profile/package.json 的 dsh.profile.bundles 已追加 ${PKG_NAME}(${bundles.length} → ${next.length} 项)`);
|
|
267
|
+
return { changed: true, bundles: next };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* **bundle 优先**:先把本包写进 `dsh.profile.bundles`,再删掉旧的手工 gokifu-ui 行(带备份)。
|
|
272
|
+
* 顺序不能反:先加 bundle 再删手工行,任何一步中断都**不会**出现「两条通道都没有」的空窗。
|
|
273
|
+
*/
|
|
274
|
+
export function ensureBundleChannel(profileDir, log = () => {}, dryRun = false) {
|
|
275
|
+
const bundle = patchProfileBundles(profileDir, log, dryRun);
|
|
276
|
+
if (bundle.changed === false) log(` · UI(bundle 通道):${bundle.reason}`);
|
|
277
|
+
const manual = removeManualUiRow(profileDir, log, dryRun);
|
|
278
|
+
if (manual.changed === false && manual.reason !== "没有手工 UI 行") log(` · 手工行:${manual.reason}`);
|
|
279
|
+
return { bundle, manual };
|
|
280
|
+
}
|
|
281
|
+
|
|
161
282
|
export function run(argv = process.argv.slice(2)) {
|
|
162
283
|
const opts = parseArgs(argv);
|
|
163
284
|
const { dryRun, profile, dshHome, log } = opts;
|
|
@@ -234,6 +355,11 @@ export function run(argv = process.argv.slice(2)) {
|
|
|
234
355
|
samePath(spec.target.realPath, PROJECT_DIR);
|
|
235
356
|
if (correct) {
|
|
236
357
|
log(` · profile/package.json 的 ${PKG_NAME} 已指向本目录:${declared}`);
|
|
358
|
+
} else if (spec !== null && spec.kind === "other") {
|
|
359
|
+
// ★ task-118:用户把手上的依赖写成 registry / git / tarball 规格时**不许改写成 link**。
|
|
360
|
+
// 那是用户的选择(例如从 npm 装正式版),改写会让「装的是哪个」不可追溯;
|
|
361
|
+
// 本地调试请自己显式写 file:/link:,安装器不替用户做这个决定。
|
|
362
|
+
log(` · profile/package.json 的 ${PKG_NAME} 是 ${declared} 规格(非 file:/link:)—— 保持原样,不改写成 link`);
|
|
237
363
|
} else if (dryRun) {
|
|
238
364
|
log(` [dry] 会${declared === null ? "加" : "修"} profile/package.json 的 ${PKG_NAME}:${declared ?? "(无)"} → ${want}`);
|
|
239
365
|
} else {
|
|
@@ -299,9 +425,11 @@ export function run(argv = process.argv.slice(2)) {
|
|
|
299
425
|
log(` ✓ ${t.file} 已换后端(备份 ${nodePath.basename(backup)})`);
|
|
300
426
|
}
|
|
301
427
|
|
|
302
|
-
/* ③ 挂 UI 的 host
|
|
303
|
-
|
|
304
|
-
|
|
428
|
+
/* ③ 挂 UI 的 host 半:**bundle 通道优先**(本包自带 cordis.patch.yml,声明在 dsh.bundle.patch)。
|
|
429
|
+
旧做法是手改 profile 的 cordis.patch.yml 加 `- insert: {id: gokifu-ui}`;那条路已废弃 ——
|
|
430
|
+
升级后手工行与 bundle 会同时挂载,两个 host 半抢同一个 /gokifu/api 前缀,启动直接失败。
|
|
431
|
+
⇒ 这里把本包写进 dsh.profile.bundles,并**备份后删除**旧手工行,保证任何时候只有一条通道。 */
|
|
432
|
+
ensureBundleChannel(profileDir, (m) => log(m), dryRun);
|
|
305
433
|
|
|
306
434
|
if (dryRun) {
|
|
307
435
|
log("\n这是预演,什么都没改。真装去掉 --dry-run。");
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* migrate-dangling.mjs —— P6-C 悬空边 sidecar 归档 CLI(R10 调用 R14)
|
|
4
|
+
*
|
|
5
|
+
* 逻辑全在 `src/rooms/migrate.mjs`(R14);本文件只做参数解析、输出与退出码。
|
|
6
|
+
* 也可以经 `dsh-gokifu-compact migrate-dangling …` 进入(`scripts/cli.mjs` 转发)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node scripts/migrate-dangling.mjs # dry-run(默认,不写任何文件)
|
|
10
|
+
* node scripts/migrate-dangling.mjs --json # dry-run + JSON
|
|
11
|
+
* node scripts/migrate-dangling.mjs --apply # 真执行(先整目录备份;120s 新鲜度守卫)
|
|
12
|
+
* node scripts/migrate-dangling.mjs --verify --backup=<dir> # 只读校验
|
|
13
|
+
* node scripts/migrate-dangling.mjs --rollback=<dir> # 从备份逐字恢复
|
|
14
|
+
* --root=<sessionsRoot> --backup-dir=<dir> --now=<epochMs>
|
|
15
|
+
*
|
|
16
|
+
* 退出码:0 正常;1 校验失败/执行失败;2 参数错误。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import nodeFs from "node:fs";
|
|
20
|
+
import nodePath from "node:path";
|
|
21
|
+
import { dryRun, apply, verify, rollback, sessionsRoot, backupsRoot } from "../src/rooms/migrate.mjs";
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const opts = { mode: "dry-run", json: false, force: false, only: null, root: sessionsRoot(), backupRoot: backupsRoot(), backupDir: "", now: Date.now() };
|
|
25
|
+
for (const arg of argv) {
|
|
26
|
+
if (arg === "--dry-run") opts.mode = "dry-run";
|
|
27
|
+
else if (arg === "--apply") opts.mode = "apply";
|
|
28
|
+
else if (arg === "--force") opts.force = true;
|
|
29
|
+
else if (arg === "--verify") opts.mode = "verify";
|
|
30
|
+
else if (arg === "--json") opts.json = true;
|
|
31
|
+
else if (arg.startsWith("--root=")) opts.root = arg.slice("--root=".length);
|
|
32
|
+
else if (arg.startsWith("--backup-dir=")) opts.backupRoot = arg.slice("--backup-dir=".length);
|
|
33
|
+
else if (arg.startsWith("--backup=")) opts.backupDir = arg.slice("--backup=".length);
|
|
34
|
+
else if (arg.startsWith("--rollback=")) {
|
|
35
|
+
opts.mode = "rollback";
|
|
36
|
+
opts.backupDir = arg.slice("--rollback=".length);
|
|
37
|
+
} else if (arg.startsWith("--sessions=")) {
|
|
38
|
+
opts.only = arg.slice("--sessions=".length).split(",").map((x) => x.trim()).filter(Boolean);
|
|
39
|
+
} else if (arg.startsWith("--now=")) opts.now = Number(arg.slice("--now=".length));
|
|
40
|
+
else if (arg === "--help" || arg === "-h") {
|
|
41
|
+
printHelp();
|
|
42
|
+
process.exit(0);
|
|
43
|
+
} else {
|
|
44
|
+
console.error(`未知参数:${arg}`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return opts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function printHelp() {
|
|
52
|
+
console.log(`migrate-dangling —— P6-C 悬空边 sidecar 归档工具(R14)
|
|
53
|
+
node scripts/migrate-dangling.mjs [--dry-run] [--root=<sessionsRoot>] [--json] # dry-run(默认)
|
|
54
|
+
--sessions=<a,b> 只处理指定会话;--force 跳过 apply 的 120s 新鲜度守卫(危险)
|
|
55
|
+
node scripts/migrate-dangling.mjs --apply [--force] # 执行(整目录备份 + 120s 新鲜度守卫)
|
|
56
|
+
node scripts/migrate-dangling.mjs --verify --backup=<backupDir> # 只读校验
|
|
57
|
+
node scripts/migrate-dangling.mjs --rollback=<backupDir> # 从备份恢复
|
|
58
|
+
|
|
59
|
+
默认路径:<DSH_HOME>/chess-context/sessions ;备份:<DSH_HOME>/chess-context/migrate-backups`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function main() {
|
|
63
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
64
|
+
let result;
|
|
65
|
+
let code = 0;
|
|
66
|
+
try {
|
|
67
|
+
if (opts.mode === "dry-run") {
|
|
68
|
+
result = dryRun(opts.root, { now: opts.now, only: opts.only });
|
|
69
|
+
} else if (opts.mode === "apply") {
|
|
70
|
+
result = apply(opts.root, { now: opts.now, backupRoot: opts.backupRoot, only: opts.only, maxAgeMs: opts.force ? 0 : undefined });
|
|
71
|
+
} else if (opts.mode === "verify") {
|
|
72
|
+
result = verify(opts.root, { now: opts.now, backupDir: opts.backupDir, only: opts.only });
|
|
73
|
+
if (!result.ok) code = 1;
|
|
74
|
+
} else {
|
|
75
|
+
result = rollback(opts.backupDir);
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error(`✗ ${String(error?.message ?? error)}`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (opts.json) {
|
|
83
|
+
console.log(JSON.stringify(result, null, 1));
|
|
84
|
+
} else if (opts.mode === "dry-run") {
|
|
85
|
+
console.log("== migrate-dangling dry-run ==");
|
|
86
|
+
console.log(`root: ${result.root}`);
|
|
87
|
+
console.log(`sessions: ${result.sessions}(affected ${result.affectedSessions})`);
|
|
88
|
+
console.log(`residue(将归档): ${result.residue}`);
|
|
89
|
+
console.log(`candidates(保留,P6C-1): ${result.candidates}`);
|
|
90
|
+
console.log(`liveUnsettled: ${result.liveUnsettled}(保留)`);
|
|
91
|
+
console.log(`fromBroken: ${result.fromBroken}(保留)`);
|
|
92
|
+
console.log(`sidecar edges: ${result.sidecarEdges}`);
|
|
93
|
+
console.log(`graph bytes: ${result.graphBytesBefore} → ${result.graphBytesAfter}(${result.deltaBytes})`);
|
|
94
|
+
if (result.badGraphs.length) console.log(`坏图 ${result.badGraphs.length} 个:${result.badGraphs.map((b) => `${b.session}(${b.reason})`).join("; ")}`);
|
|
95
|
+
} else if (opts.mode === "verify") {
|
|
96
|
+
console.log(
|
|
97
|
+
result.ok
|
|
98
|
+
? `✓ verify ok:sidecar ${result.sidecarEdges} 条 / residue ${result.residue} / candidates ${result.candidates} / fromBroken ${result.fromBroken}`
|
|
99
|
+
: `✗ verify 失败:${result.problems.join("; ")}`,
|
|
100
|
+
);
|
|
101
|
+
if (result.badGraphs.length) console.log(`坏图 ${result.badGraphs.length} 个:${result.badGraphs.map((b) => b.session).join(", ")}`);
|
|
102
|
+
} else {
|
|
103
|
+
console.log(JSON.stringify(result, null, 1));
|
|
104
|
+
}
|
|
105
|
+
process.exit(code);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 只在被直接执行时跑 main(被 import 时保持安静)。 */
|
|
109
|
+
const direct = process.argv[1] && nodePath.basename(process.argv[1]) === "migrate-dangling.mjs" && nodeFs.existsSync(process.argv[1]);
|
|
110
|
+
if (direct) main();
|
package/scripts/release.mjs
CHANGED
|
@@ -206,7 +206,7 @@ for (const n of notes) console.log(C.yellow(` ! ${n}`))
|
|
|
206
206
|
|
|
207
207
|
const plan = []
|
|
208
208
|
plan.push({ what: `git push origin ${branch}`, why: "第 4 步:git 源检测看默认分支 HEAD" })
|
|
209
|
-
plan.push({ what: `git tag ${tag} && git push origin ${tag}`, why: "第 6 步:tag
|
|
209
|
+
plan.push({ what: `git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, why: "第 6 步:annotated tag(带消息)让「某 bug 出在哪版」可查,且 GitHub Release 才有说明" })
|
|
210
210
|
plan.push({
|
|
211
211
|
what: `POST /repos/${gh.owner}/${gh.repo}/releases`,
|
|
212
212
|
why: `第 6 步:Release 正文 = CHANGELOG 的 ${version} 段(${releaseBody.split("\n").length} 行,已去掉 > 起因)`,
|
|
@@ -247,7 +247,7 @@ function run(cmd, label) {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
const pushed = run(`git push origin ${branch}`, `push ${branch}`)
|
|
250
|
-
const tagged = run(`git tag ${tag} && git push origin ${tag}`, `tag ${tag}`)
|
|
250
|
+
const tagged = run(`git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, `tag ${tag}`)
|
|
251
251
|
|
|
252
252
|
let released = false
|
|
253
253
|
|
package/src/rooms/ledger.mjs
CHANGED
|
@@ -32,18 +32,23 @@ import { weakKindOf } from "./verify.mjs";
|
|
|
32
32
|
* · 另有 214 条 `from` 悬空(全在 session-5acafe42,mergeNodeInto 老 bug)——**另一类**,
|
|
33
33
|
* 判据必须以 `to == null` 开头,绝不能写成「任一端为空」(会误删)。
|
|
34
34
|
*
|
|
35
|
-
* 不变量(报告 §2
|
|
36
|
-
* residue(e) := e.to == null &&
|
|
37
|
-
* live_unsettled(e) := e.to == null && e.result === "untested" && age <= 10min
|
|
35
|
+
* 不变量(报告 §2,P6C-1 起四类):
|
|
36
|
+
* residue(e) := e.to == null && e.result !== "untested" && age > 10min (已结算却没终点 = 不可能)
|
|
37
|
+
* live_unsettled(e) := e.to == null && e.result === "untested" && age <= 10min (正在执行)
|
|
38
|
+
* candidate(e) := e.to == null && e.result === "untested" && age > 10min (未落地:可继续、也可放弃)
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* ★ P6C-1(2026-10-30):**超过 10 分钟的 untested 不再是 residue,而是 candidate。**
|
|
41
|
+
* 旧判据把它当「死边」整条丢掉 —— 但模型崩溃/中断留下的未结算边恰恰是「下一步还能接着做」
|
|
42
|
+
* 的信息,丢掉等于把线索一起丢了。现在它进热清单的 OPTIONS 区段(标 `[stale Xm]`),
|
|
43
|
+
* 明确写成「not yet landed — you may continue or abandon」,不混进 UNVERIFIED(那是「跑了但没验证」)。
|
|
44
|
+
*
|
|
45
|
+
* 为什么 residue 只留「已结算」:结算过(success/failure/unverified)的边必须有终点,
|
|
46
|
+
* 无终点只可能是 1.9.1 rewash 留下的历史残留(数据事实 5,564/5,567)。
|
|
42
47
|
*/
|
|
43
48
|
export const DANGLING_STALE_MS = 10 * 60 * 1000;
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
|
-
* @returns {"residue"|"live-unsettled"|"from-broken"|"none"}
|
|
51
|
+
* @returns {"residue"|"candidate"|"live-unsettled"|"from-broken"|"none"}
|
|
47
52
|
*/
|
|
48
53
|
export function classifyDangling(edge, { graph = null, now = Date.now() } = {}) {
|
|
49
54
|
if (!edge) return "none";
|
|
@@ -57,22 +62,36 @@ export function classifyDangling(edge, { graph = null, now = Date.now() } = {})
|
|
|
57
62
|
// 生产数字不受影响:全库 5,564 条 settled 残留的 ts 全部是 10-29 的老边,远超 10 分钟。
|
|
58
63
|
const ts = Number(edge.ts);
|
|
59
64
|
if (Number.isFinite(ts) && now - ts <= DANGLING_STALE_MS) return "live-unsettled";
|
|
60
|
-
|
|
65
|
+
// 陈旧(或没有时间戳、无法证明新鲜)的 untested = candidate(P6C-1)
|
|
66
|
+
if (edge.result === "untested") return "candidate";
|
|
61
67
|
return "residue";
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* 该边是否属于「OPTIONS」区段(未落地的尝试:新鲜 live 或陈旧 candidate)。
|
|
72
|
+
* ★ 只认 `result === "untested"`:刚结算的失败边也可能因年龄护栏落进 live-unsettled,
|
|
73
|
+
* 但它是**已发生的失败**(已在 LEARNED 里),不是「还没落地、可以继续或放弃」的选项。
|
|
74
|
+
*/
|
|
75
|
+
export function isDanglingOption(edge, { graph = null, now = Date.now() } = {}) {
|
|
76
|
+
if (!edge || edge.result !== "untested" || edge.to != null) return false;
|
|
77
|
+
const kind = classifyDangling(edge, { graph, now });
|
|
78
|
+
return kind === "live-unsettled" || kind === "candidate";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 全图悬空统计:{residue, candidates, liveUnsettled, fromBroken}。只读,不改图。 */
|
|
65
82
|
export function danglingStats(g, { now = Date.now() } = {}) {
|
|
66
83
|
let residue = 0;
|
|
84
|
+
let candidates = 0;
|
|
67
85
|
let liveUnsettled = 0;
|
|
68
86
|
let fromBroken = 0;
|
|
69
87
|
for (const e of Object.values(g?.edges ?? {})) {
|
|
70
88
|
const kind = classifyDangling(e, { graph: g, now });
|
|
71
89
|
if (kind === "residue") residue += 1;
|
|
90
|
+
else if (kind === "candidate") candidates += 1;
|
|
72
91
|
else if (kind === "live-unsettled") liveUnsettled += 1;
|
|
73
92
|
else if (kind === "from-broken") fromBroken += 1;
|
|
74
93
|
}
|
|
75
|
-
return { residue, liveUnsettled, fromBroken };
|
|
94
|
+
return { residue, candidates, liveUnsettled, fromBroken };
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
/** 该边是否应从「失败边分析 / 热清单」排除。live_unsettled 永远保留。 */
|
|
@@ -90,6 +109,19 @@ export function isDanglingResidue(edge, { graph = null, now = Date.now() } = {})
|
|
|
90
109
|
// 「k of n hops 未验证 = 不构成进展证据」。
|
|
91
110
|
const WEAK_LEGEND = "# weak k/n = k of n hops unverified — not proof of progress";
|
|
92
111
|
|
|
112
|
+
/**
|
|
113
|
+
* OPTIONS 行尾的陈旧标记(P6C-1):只有超过 10 分钟的 untested 才加。
|
|
114
|
+
* 没有时间戳 ⇒ 无法证明新鲜,标 `[stale ?]`(宁可让模型知道「这条来路不明」)。
|
|
115
|
+
*/
|
|
116
|
+
function staleTagOf(edge, now) {
|
|
117
|
+
if (edge?.result !== "untested") return "";
|
|
118
|
+
const ts = Number(edge.ts);
|
|
119
|
+
if (!Number.isFinite(ts)) return " [stale ?]";
|
|
120
|
+
const age = now - ts;
|
|
121
|
+
if (age <= DANGLING_STALE_MS) return "";
|
|
122
|
+
return ` [stale ${Math.floor(age / 60000)}m]`;
|
|
123
|
+
}
|
|
124
|
+
|
|
93
125
|
/** 保守 token 估算(CJK 1 字≈1 token,英文 ~4 字/token;取 3 折中偏保守)。 */
|
|
94
126
|
export function estimateTokens(text) {
|
|
95
127
|
return Math.ceil(String(text ?? "").length / 3);
|
|
@@ -423,7 +455,11 @@ export function renderLedger(g, { maxTokens = 500, tailFailures = 20, markers =
|
|
|
423
455
|
// ★ P6-A:UNVERIFIED_CLAIMS 里的 rewash 残留(to=null 的 unverified)同样是噪声 ——
|
|
424
456
|
// 它们既没有终点、也不可能再被结算(result 已写死 unverified),展示出来只会占行数。
|
|
425
457
|
// live_unsettled 必须保留(正在执行、还没结算)。
|
|
426
|
-
|
|
458
|
+
// ★ P6C-1(2026-10-30):**未落地(untested + to=null)的边不再混进 UNVERIFIED** ——
|
|
459
|
+
// 「跑了但没外部验证信号」和「还没落地、可以继续也可以放弃」是两类信息,混在一起模型会当成
|
|
460
|
+
// 「已验证过但不可信」,于是不敢用也不敢弃。现在它们单独进 OPTIONS 区段。
|
|
461
|
+
const options = Object.values(g.edges).filter((e) => isDanglingOption(e, { graph: g, now }));
|
|
462
|
+
const open = openEdges(g).filter((e) => !isDanglingResidue(e, { graph: g, now }) && !isDanglingOption(e, { graph: g, now }));
|
|
427
463
|
const latest = latestAgentNode(g);
|
|
428
464
|
const path = latest ? currentPath(g, latest.id) : [];
|
|
429
465
|
const root = g.rootNode ?? g.rootUserNodeIds.map((id) => g.nodes[id]).filter(Boolean)[0];
|
|
@@ -569,6 +605,12 @@ export function renderLedger(g, { maxTokens = 500, tailFailures = 20, markers =
|
|
|
569
605
|
}
|
|
570
606
|
for (const e of shown) tailLines.push(renderFailureLine(e, level, tewariMap.get(e.id) ?? null, markers));
|
|
571
607
|
}
|
|
608
|
+
// ★ P6C-1:OPTIONS 区段(新鲜 + 陈旧,陈旧标 `[stale Xm]`)。只在 L0/L1 出现,
|
|
609
|
+
// 与 UNVERIFIED 同档 —— 它们是「下一步要不要接着做」的决策信息,预算紧张时先让位给失败记忆。
|
|
610
|
+
if (options.length && level <= 1) {
|
|
611
|
+
tailLines.push("OPTIONS (not yet landed — you may continue or abandon):");
|
|
612
|
+
for (const e of options.slice(-6)) tailLines.push(`- ${e.id} "${clip(e.intent || e.action, 50)}"${staleTagOf(e, now)}`);
|
|
613
|
+
}
|
|
572
614
|
if (open.length && level <= 1) {
|
|
573
615
|
tailLines.push("UNVERIFIED (ran, but no external signal — treat as unproven):");
|
|
574
616
|
for (const e of open.slice(-6)) tailLines.push(`- ${e.id} "${clip(e.intent || e.action, 50)}"`);
|
|
@@ -859,8 +901,18 @@ export function renderFacts(g, { maxFailures = 800, headFailures = 80, range = n
|
|
|
859
901
|
if (stats.liveUnsettled > 0) {
|
|
860
902
|
lines.push(`LIVE_UNSETTLED: ${stats.liveUnsettled} edges are newly unsettled (untested, <=10min) — kept visible on purpose.`);
|
|
861
903
|
}
|
|
904
|
+
if (stats.candidates > 0) {
|
|
905
|
+
lines.push(`STALE_UNSETTLED: ${stats.candidates} edges are untested and older than 10min — listed as OPTIONS (continue or abandon), NOT residue.`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// ★ P6C-1:未落地的尝试(untested + to=null)单独成段,不再混进 UNVERIFIED_CLAIMS。
|
|
909
|
+
const openAll = openEdges(g);
|
|
910
|
+
const options = openAll.filter((e) => isDanglingOption(e, { graph: g, now }));
|
|
911
|
+
const open = openAll.filter((e) => !isDanglingOption(e, { graph: g, now }));
|
|
912
|
+
if (options.length) {
|
|
913
|
+
lines.push("OPTIONS (not yet landed — you may continue or abandon):");
|
|
914
|
+
for (const e of options.slice(-40)) lines.push(`* ${e.id} "${clip(e.intent || e.action, 90)}"${staleTagOf(e, now)}`);
|
|
862
915
|
}
|
|
863
|
-
const open = openEdges(g);
|
|
864
916
|
if (open.length) {
|
|
865
917
|
lines.push("UNVERIFIED_CLAIMS (no external verification signal):");
|
|
866
918
|
for (const e of open.slice(-40)) lines.push(`* ${e.id} "${clip(e.intent || e.action, 90)}"`);
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate.mjs —— R14 数据迁移房间(P6-C:悬空边 sidecar 归档)
|
|
3
|
+
*
|
|
4
|
+
* 从 `test/archive-dangling.mjs` 原型晋升(task-112 / P6C-2)。职责分层:
|
|
5
|
+
* · 本房间:**纯逻辑 + 文件操作**(plan / dry-run / apply / verify / rollback / 备份)
|
|
6
|
+
* · `scripts/migrate-dangling.mjs`:CLI 参数与输出
|
|
7
|
+
* · `scripts/cli.mjs`:`migrate-dangling` 子命令转发
|
|
8
|
+
*
|
|
9
|
+
* ★ 硬约束(契约 §2.5):
|
|
10
|
+
* 1. **禁止被 engine import**(P6-C 禁止 engine 启动钩子)—— `test/rooms.test.mjs` 的 R-E 直接断言;
|
|
11
|
+
* 2. 默认 **dry-run**;`apply` 必须先整目录备份 + 校验,且要求图**至少 120 秒没被写过**
|
|
12
|
+
* (运行中的插件会 flush 覆盖磁盘清理,实测 netCleaned=0);
|
|
13
|
+
* 3. 判据**只复用 R06**(`classifyDangling`)—— 判据集中一处,本房间不另写;
|
|
14
|
+
* 4. `candidates`(未落地的 untested,P6C-1)**一条都不许归档**,只归档 settled residue。
|
|
15
|
+
*
|
|
16
|
+
* 为什么是 sidecar 而不是直接删:主图 −2.99MB / flush −21.4%,但失败边是历史证据,不能丢。
|
|
17
|
+
* sidecar(`graph.dangling.json`)保留全部字段 + 源图 sha256 + 生成时间 + 判据,随时可 merge 回来。
|
|
18
|
+
*
|
|
19
|
+
* 允许依赖:R00(fromJSON/toJSON/renderIndexMd)· R06(classifyDangling/DANGLING_STALE_MS)
|
|
20
|
+
* · `node:fs`/`node:path`/`node:crypto`。**禁止** `@deepseek-ai/*`、engine、scripts、lib。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import nodeFs from "node:fs";
|
|
24
|
+
import nodePath from "node:path";
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import { fromJSON, toJSON, renderIndexMd } from "./kifu-model.mjs";
|
|
27
|
+
import { graphRoot } from "./kifu-persist.mjs";
|
|
28
|
+
import { classifyDangling, DANGLING_STALE_MS } from "./ledger.mjs";
|
|
29
|
+
|
|
30
|
+
export const SIDECAR_NAME = "graph.dangling.json";
|
|
31
|
+
export const SIDECAR_FORMAT = "dsh-gokifu-dangling-sidecar";
|
|
32
|
+
export const SIDECAR_VERSION = 1;
|
|
33
|
+
|
|
34
|
+
/** apply 的新鲜度守卫:图在这么多毫秒内被写过 ⇒ 认为插件还在跑,拒绝 apply。 */
|
|
35
|
+
export const APPLY_FRESHNESS_MS = 120_000;
|
|
36
|
+
|
|
37
|
+
export const CRITERIA = Object.freeze({
|
|
38
|
+
residue: "to==null && result!=='untested' && age>10min (classifyDangling==='residue') → archived",
|
|
39
|
+
candidates: "to==null && result==='untested' && age>10min (classifyDangling==='candidate') → KEPT (P6C-1)",
|
|
40
|
+
excludedLive: "to==null && untested && age<=10min — kept in graph",
|
|
41
|
+
excludedFromBroken: "edge.from not in g.nodes — separate class, kept in graph",
|
|
42
|
+
staleMs: DANGLING_STALE_MS,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/* ───────────────────────────── 路径规则(R01 口径) ───────────────────────────── */
|
|
46
|
+
|
|
47
|
+
/** `DSH_HOME`(缺省 `~/.dsh`,与 engine 的 resolveHome 同口径)。 */
|
|
48
|
+
export function dshHome(env = process.env) {
|
|
49
|
+
if (env.DSH_HOME && String(env.DSH_HOME).length > 0) return env.DSH_HOME;
|
|
50
|
+
return nodePath.join(env.USERPROFILE ?? env.HOME ?? ".", ".dsh");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 会话根:`<DSH_HOME>/chess-context/sessions`(路径规则复用 R01 `graphRoot`)。 */
|
|
54
|
+
export function sessionsRoot(home = dshHome()) {
|
|
55
|
+
return nodePath.join(graphRoot({ graphDir: "", dshHome: home }), "sessions");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 备份根:`<DSH_HOME>/chess-context/migrate-backups`(P6C-2 定稿;不再是 tmp/)。 */
|
|
59
|
+
export function backupsRoot(home = dshHome()) {
|
|
60
|
+
return nodePath.join(home, "chess-context", "migrate-backups");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ───────────────────────────── 基础工具 ───────────────────────────── */
|
|
64
|
+
|
|
65
|
+
export function sha256(text) {
|
|
66
|
+
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 同目录 tmp + rename(Windows 上 rename 覆盖已存在文件是原子的)。 */
|
|
70
|
+
export function writeFileAtomic(file, text) {
|
|
71
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
72
|
+
nodeFs.writeFileSync(tmp, text, "utf8");
|
|
73
|
+
try {
|
|
74
|
+
nodeFs.renameSync(tmp, file);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
try {
|
|
77
|
+
nodeFs.rmSync(tmp, { force: true });
|
|
78
|
+
} catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 递归列文件(相对路径、字节、sha256),用于备份清单。 */
|
|
86
|
+
export function hashTree(root) {
|
|
87
|
+
const out = [];
|
|
88
|
+
const walk = (dir, rel) => {
|
|
89
|
+
for (const ent of nodeFs.readdirSync(dir, { withFileTypes: true })) {
|
|
90
|
+
const abs = nodePath.join(dir, ent.name);
|
|
91
|
+
const r = rel ? `${rel}/${ent.name}` : ent.name;
|
|
92
|
+
if (ent.isDirectory()) walk(abs, r);
|
|
93
|
+
else if (ent.isFile()) {
|
|
94
|
+
const buf = nodeFs.readFileSync(abs);
|
|
95
|
+
out.push({ rel: r, bytes: buf.length, sha256: crypto.createHash("sha256").update(buf).digest("hex") });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
walk(root, "");
|
|
100
|
+
out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ───────────────────────────── 纯函数层 ───────────────────────────── */
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 计算一个会话图里要归档的边、要保留的边、以及 from 悬空计数。**不修改图**。
|
|
108
|
+
*
|
|
109
|
+
* ★ P6C-2:`residue` **只含 settled**(`result !== "untested"`);`candidates` 单独列出且**永远不归档**。
|
|
110
|
+
* 这里再断言一次 —— 判据在 R06,但「迁移只动 settled」是迁移自己的责任。
|
|
111
|
+
*
|
|
112
|
+
* @returns {{residue: object[], candidates: object[], live: object[], fromBroken: object[]}}
|
|
113
|
+
*/
|
|
114
|
+
export function planSession(graph, { now = Date.now() } = {}) {
|
|
115
|
+
const residue = [];
|
|
116
|
+
const candidates = [];
|
|
117
|
+
const live = [];
|
|
118
|
+
const fromBroken = [];
|
|
119
|
+
for (const edge of Object.values(graph.edges ?? {})) {
|
|
120
|
+
const kind = classifyDangling(edge, { graph, now });
|
|
121
|
+
if (kind === "residue") {
|
|
122
|
+
if (edge.result === "untested") throw new Error(`migrate: untested 边 ${edge.id} 被判 residue —— 判据与 P6C-1 冲突,拒绝归档`);
|
|
123
|
+
residue.push(edge);
|
|
124
|
+
} else if (kind === "candidate") candidates.push(edge);
|
|
125
|
+
else if (kind === "live-unsettled") live.push(edge);
|
|
126
|
+
else if (kind === "from-broken") fromBroken.push(edge);
|
|
127
|
+
}
|
|
128
|
+
return { residue, candidates, live, fromBroken };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 构造 sidecar 对象(含源图 sha256 / 生成时间 / 判据 / 全部字段的 edges)。 */
|
|
132
|
+
export function buildSidecar(graph, residueEdges, { now = Date.now(), sourceSha = "", sessionId = "" } = {}) {
|
|
133
|
+
return {
|
|
134
|
+
format: SIDECAR_FORMAT,
|
|
135
|
+
version: SIDECAR_VERSION,
|
|
136
|
+
sessionId: sessionId || graph.sessionId || "",
|
|
137
|
+
source: { graph: "graph.json", sha256: sourceSha, bytes: 0 },
|
|
138
|
+
generatedAt: new Date(now).toISOString(),
|
|
139
|
+
criteria: CRITERIA,
|
|
140
|
+
edges: residueEdges.map((e) => JSON.parse(JSON.stringify(e))),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 从图里删除给定 id 的边(原地)。返回删除条数。 */
|
|
145
|
+
export function removeEdges(graph, ids) {
|
|
146
|
+
let n = 0;
|
|
147
|
+
for (const id of ids) {
|
|
148
|
+
if (graph.edges && graph.edges[id]) {
|
|
149
|
+
delete graph.edges[id];
|
|
150
|
+
n += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (n > 0) graph.updatedAt = Date.now();
|
|
154
|
+
return n;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ───────────────────────────── 扫描 / dry-run ───────────────────────────── */
|
|
158
|
+
|
|
159
|
+
/** 扫描 `<root>` 下所有会话目录(每个含 graph.json)。 */
|
|
160
|
+
export function listSessions(root) {
|
|
161
|
+
if (!nodeFs.existsSync(root)) return [];
|
|
162
|
+
return nodeFs
|
|
163
|
+
.readdirSync(root, { withFileTypes: true })
|
|
164
|
+
.filter((e) => e.isDirectory())
|
|
165
|
+
.map((e) => nodePath.join(root, e.name))
|
|
166
|
+
.filter((dir) => nodeFs.existsSync(nodePath.join(dir, "graph.json")))
|
|
167
|
+
.sort();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 坏图记录(P6C-2 定稿形状):`{session, ok:false, reason}`。 */
|
|
171
|
+
function recordBad(session, err) {
|
|
172
|
+
return { session, ok: false, reason: String(err?.message ?? err) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 会话选择器:`only` 为 null/空表示全量;否则只认列表里的目录名。 */
|
|
176
|
+
function selectedSessions(root, only) {
|
|
177
|
+
const all = listSessions(root);
|
|
178
|
+
if (!only || only.length === 0) return all;
|
|
179
|
+
const set = new Set(only);
|
|
180
|
+
return all.filter((dir) => set.has(nodePath.basename(dir)));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* dry-run:只读扫描,返回全部数字。**绝不写文件**。
|
|
185
|
+
* ★ P6C-2:坏图不再静默 `continue` —— 逐会话汇总进 `badGraphs`(谁的图坏了必须能查)。
|
|
186
|
+
*/
|
|
187
|
+
export function dryRun(root, { now = Date.now(), only = null } = {}) {
|
|
188
|
+
const sessions = selectedSessions(root, only);
|
|
189
|
+
let residue = 0;
|
|
190
|
+
let candidates = 0;
|
|
191
|
+
let live = 0;
|
|
192
|
+
let fromBroken = 0;
|
|
193
|
+
let bytesBefore = 0;
|
|
194
|
+
let bytesAfter = 0;
|
|
195
|
+
let affected = 0;
|
|
196
|
+
const perSession = [];
|
|
197
|
+
const badGraphs = [];
|
|
198
|
+
for (const dir of sessions) {
|
|
199
|
+
const text = nodeFs.readFileSync(nodePath.join(dir, "graph.json"), "utf8");
|
|
200
|
+
let g;
|
|
201
|
+
try {
|
|
202
|
+
g = fromJSON(text);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
badGraphs.push(recordBad(nodePath.basename(dir), err));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const plan = planSession(g, { now });
|
|
208
|
+
candidates += plan.candidates.length;
|
|
209
|
+
live += plan.live.length;
|
|
210
|
+
fromBroken += plan.fromBroken.length;
|
|
211
|
+
bytesBefore += Buffer.byteLength(text);
|
|
212
|
+
if (plan.residue.length === 0) {
|
|
213
|
+
bytesAfter += Buffer.byteLength(text);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
affected += 1;
|
|
217
|
+
residue += plan.residue.length;
|
|
218
|
+
const clone = fromJSON(text);
|
|
219
|
+
removeEdges(clone, plan.residue.map((e) => e.id));
|
|
220
|
+
bytesAfter += Buffer.byteLength(toJSON(clone));
|
|
221
|
+
perSession.push({ session: nodePath.basename(dir), residue: plan.residue.length, bytesBefore: Buffer.byteLength(text) });
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
mode: "dry-run",
|
|
225
|
+
root,
|
|
226
|
+
sessions: sessions.length,
|
|
227
|
+
affectedSessions: affected,
|
|
228
|
+
residue,
|
|
229
|
+
candidates,
|
|
230
|
+
liveUnsettled: live,
|
|
231
|
+
fromBroken,
|
|
232
|
+
sidecarEdges: residue,
|
|
233
|
+
graphBytesBefore: bytesBefore,
|
|
234
|
+
graphBytesAfter: bytesAfter,
|
|
235
|
+
deltaBytes: bytesAfter - bytesBefore,
|
|
236
|
+
badGraphs,
|
|
237
|
+
perSession,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 新鲜度守卫:返回「最近 maxAgeMs 内被写过」的会话(apply 前必须为空)。
|
|
243
|
+
* 为什么需要:运行中的插件会在内存 flush 时把清理结果覆盖回去(实测 netCleaned=0)。
|
|
244
|
+
* @returns {string[]} 会话目录名
|
|
245
|
+
*/
|
|
246
|
+
export function freshSessions(root, { now = Date.now(), maxAgeMs = APPLY_FRESHNESS_MS, only = null } = {}) {
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const dir of selectedSessions(root, only)) {
|
|
249
|
+
const st = nodeFs.statSync(nodePath.join(dir, "graph.json"));
|
|
250
|
+
// 用绝对值:NTFS 的时间戳可能比 Date.now() 超前几毫秒(时钟粒度/回拨),
|
|
251
|
+
// 只看 age>=0 会把「刚写过」误判成「旧文件」,守卫就失效了。
|
|
252
|
+
const age = now - st.mtimeMs;
|
|
253
|
+
if (Math.abs(age) < maxAgeMs) out.push(nodePath.basename(dir));
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ───────────────────────────── 备份 ───────────────────────────── */
|
|
259
|
+
|
|
260
|
+
/** 整目录备份到 `<backupRoot>/backup-<ts>/`,返回备份目录与清单。 */
|
|
261
|
+
export function backupTree(root, { backupRoot = backupsRoot(), stamp = new Date() } = {}) {
|
|
262
|
+
const ts = stamp.toISOString().replace(/[:.]/g, "-");
|
|
263
|
+
const dest = nodePath.join(backupRoot, `backup-${ts}`);
|
|
264
|
+
if (nodeFs.existsSync(dest)) throw new Error(`备份目录已存在:${dest}`);
|
|
265
|
+
nodeFs.mkdirSync(nodePath.dirname(dest), { recursive: true });
|
|
266
|
+
nodeFs.cpSync(root, dest, { recursive: true });
|
|
267
|
+
const files = hashTree(dest);
|
|
268
|
+
const sourceFiles = hashTree(root);
|
|
269
|
+
if (files.length !== sourceFiles.length) throw new Error(`备份文件数不一致:源 ${sourceFiles.length} / 备份 ${files.length}`);
|
|
270
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
271
|
+
if (files[i].rel !== sourceFiles[i].rel || files[i].sha256 !== sourceFiles[i].sha256) {
|
|
272
|
+
throw new Error(`备份校验失败:${files[i].rel}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const manifest = { createdAt: stamp.toISOString(), source: root, files, fileCount: files.length, totalBytes: files.reduce((n, f) => n + f.bytes, 0) };
|
|
276
|
+
writeFileAtomic(nodePath.join(dest, "MANIFEST.json"), JSON.stringify(manifest, null, 1));
|
|
277
|
+
return { dir: dest, manifest };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 从备份恢复(覆盖回 source),返回恢复文件数。 */
|
|
281
|
+
export function rollback(backupDir) {
|
|
282
|
+
const manifestFile = nodePath.join(backupDir, "MANIFEST.json");
|
|
283
|
+
if (!nodeFs.existsSync(manifestFile)) throw new Error(`备份缺少 MANIFEST.json:${backupDir}`);
|
|
284
|
+
const manifest = JSON.parse(nodeFs.readFileSync(manifestFile, "utf8"));
|
|
285
|
+
const wanted = new Set(manifest.files.map((f) => f.rel));
|
|
286
|
+
let restored = 0;
|
|
287
|
+
for (const f of manifest.files) {
|
|
288
|
+
if (f.rel === "MANIFEST.json") continue;
|
|
289
|
+
const src = nodePath.join(backupDir, f.rel);
|
|
290
|
+
const dst = nodePath.join(manifest.source, f.rel);
|
|
291
|
+
nodeFs.mkdirSync(nodePath.dirname(dst), { recursive: true });
|
|
292
|
+
nodeFs.copyFileSync(src, dst);
|
|
293
|
+
const sha = sha256(nodeFs.readFileSync(dst, "utf8"));
|
|
294
|
+
if (sha !== f.sha256) throw new Error(`恢复后哈希不一致:${f.rel}`);
|
|
295
|
+
restored += 1;
|
|
296
|
+
}
|
|
297
|
+
let removed = 0;
|
|
298
|
+
for (const cur of hashTree(manifest.source)) {
|
|
299
|
+
if (wanted.has(cur.rel)) continue;
|
|
300
|
+
nodeFs.rmSync(nodePath.join(manifest.source, cur.rel), { force: true });
|
|
301
|
+
removed += 1;
|
|
302
|
+
}
|
|
303
|
+
return { restored, removed, source: manifest.source };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ───────────────────────────── apply ───────────────────────────── */
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 真执行:新鲜度守卫 → 整目录备份 → 逐会话写 sidecar + 删 residue + 重建 index.md。
|
|
310
|
+
* @throws 有「最近 120s 内被写过」的会话时拒绝执行(插件可能正在跑)
|
|
311
|
+
*/
|
|
312
|
+
export function apply(root, { now = Date.now(), backupRoot = backupsRoot(), stamp = new Date(), maxAgeMs = APPLY_FRESHNESS_MS, only = null } = {}) {
|
|
313
|
+
const fresh = freshSessions(root, { now, maxAgeMs, only });
|
|
314
|
+
if (fresh.length > 0) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`migrate: 拒绝 apply —— ${fresh.length} 个会话的 graph.json 在 ${maxAgeMs / 1000}s 内被写过(${fresh.slice(0, 3).join(", ")}${fresh.length > 3 ? " …" : ""})。` +
|
|
317
|
+
" 运行中的插件会用内存图 flush 覆盖磁盘清理(实测 netCleaned=0);先停插件窗口再重试。",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const dry = dryRun(root, { now, only });
|
|
321
|
+
const backup = backupTree(root, { backupRoot, stamp });
|
|
322
|
+
let archived = 0;
|
|
323
|
+
let deleted = 0;
|
|
324
|
+
let bytesBefore = 0;
|
|
325
|
+
let bytesAfter = 0;
|
|
326
|
+
const perSession = [];
|
|
327
|
+
const badGraphs = [...dry.badGraphs];
|
|
328
|
+
for (const dir of selectedSessions(root, only)) {
|
|
329
|
+
const graphFile = nodePath.join(dir, "graph.json");
|
|
330
|
+
const text = nodeFs.readFileSync(graphFile, "utf8");
|
|
331
|
+
let g;
|
|
332
|
+
try {
|
|
333
|
+
g = fromJSON(text);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
if (!badGraphs.some((b) => b.session === nodePath.basename(dir))) badGraphs.push(recordBad(nodePath.basename(dir), err));
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const plan = planSession(g, { now });
|
|
339
|
+
if (plan.residue.length === 0) continue;
|
|
340
|
+
const before = Buffer.byteLength(text);
|
|
341
|
+
const sourceSha = sha256(text);
|
|
342
|
+
const sidecar = buildSidecar(g, plan.residue, { now, sourceSha, sessionId: nodePath.basename(dir) });
|
|
343
|
+
sidecar.source.bytes = before;
|
|
344
|
+
writeFileAtomic(nodePath.join(dir, SIDECAR_NAME), JSON.stringify(sidecar, null, 1));
|
|
345
|
+
const n = removeEdges(g, plan.residue.map((e) => e.id));
|
|
346
|
+
const next = toJSON(g);
|
|
347
|
+
writeFileAtomic(graphFile, next);
|
|
348
|
+
writeFileAtomic(nodePath.join(dir, "index.md"), renderIndexMd(g));
|
|
349
|
+
archived += plan.residue.length;
|
|
350
|
+
deleted += n;
|
|
351
|
+
bytesBefore += before;
|
|
352
|
+
bytesAfter += Buffer.byteLength(next);
|
|
353
|
+
perSession.push({ session: nodePath.basename(dir), archived: plan.residue.length, deleted: n, bytesBefore: before, bytesAfter: Buffer.byteLength(next) });
|
|
354
|
+
}
|
|
355
|
+
const summary = {
|
|
356
|
+
mode: "apply",
|
|
357
|
+
root,
|
|
358
|
+
backupDir: backup.dir,
|
|
359
|
+
sessions: dry.sessions,
|
|
360
|
+
affectedSessions: perSession.length,
|
|
361
|
+
archived,
|
|
362
|
+
deleted,
|
|
363
|
+
candidates: dry.candidates,
|
|
364
|
+
liveUnsettled: dry.liveUnsettled,
|
|
365
|
+
fromBroken: dry.fromBroken,
|
|
366
|
+
graphBytesBefore: bytesBefore,
|
|
367
|
+
graphBytesAfter: bytesAfter,
|
|
368
|
+
deltaBytes: bytesAfter - bytesBefore,
|
|
369
|
+
badGraphs,
|
|
370
|
+
perSession,
|
|
371
|
+
};
|
|
372
|
+
writeFileAtomic(nodePath.join(backup.dir, "APPLY.json"), JSON.stringify(summary, null, 1));
|
|
373
|
+
return summary;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* ───────────────────────────── verify ───────────────────────────── */
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 只读校验:
|
|
380
|
+
* ① sidecar 行数 == 被删边数(sidecar 存在 ⇒ graph 里该会话 residue 必须 0);
|
|
381
|
+
* ② fromBroken 与 apply 时一致;
|
|
382
|
+
* ③ residue = 0;candidates 必须与 apply 时一致(P6C-2:candidates 一条都不许动);
|
|
383
|
+
* ④ 固定种子抽 10 条 sidecar 边与**备份图**里的同 id 边逐字段相同。
|
|
384
|
+
*/
|
|
385
|
+
export function verify(root, { now = Date.now(), backupDir = "", seed = 20261030, only = null } = {}) {
|
|
386
|
+
const problems = [];
|
|
387
|
+
const sessions = selectedSessions(root, only);
|
|
388
|
+
let sidecarEdges = 0;
|
|
389
|
+
let residue = 0;
|
|
390
|
+
let candidates = 0;
|
|
391
|
+
let fromBroken = 0;
|
|
392
|
+
let checkedSessions = 0;
|
|
393
|
+
const samples = [];
|
|
394
|
+
const badGraphs = [];
|
|
395
|
+
const backupGraphs = new Map();
|
|
396
|
+
if (backupDir && nodeFs.existsSync(backupDir)) {
|
|
397
|
+
for (const dir of listSessions(backupDir)) {
|
|
398
|
+
const p = nodePath.join(dir, "graph.json");
|
|
399
|
+
try {
|
|
400
|
+
backupGraphs.set(nodePath.basename(dir), JSON.parse(nodeFs.readFileSync(p, "utf8")));
|
|
401
|
+
} catch {
|
|
402
|
+
/* ignore */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const dir of sessions) {
|
|
407
|
+
const sid = nodePath.basename(dir);
|
|
408
|
+
let g;
|
|
409
|
+
try {
|
|
410
|
+
g = fromJSON(nodeFs.readFileSync(nodePath.join(dir, "graph.json"), "utf8"));
|
|
411
|
+
} catch (err) {
|
|
412
|
+
badGraphs.push(recordBad(sid, err));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const plan = planSession(g, { now });
|
|
416
|
+
residue += plan.residue.length;
|
|
417
|
+
candidates += plan.candidates.length;
|
|
418
|
+
fromBroken += plan.fromBroken.length;
|
|
419
|
+
const sidecarFile = nodePath.join(dir, SIDECAR_NAME);
|
|
420
|
+
if (!nodeFs.existsSync(sidecarFile)) continue;
|
|
421
|
+
checkedSessions += 1;
|
|
422
|
+
let sidecar;
|
|
423
|
+
try {
|
|
424
|
+
sidecar = JSON.parse(nodeFs.readFileSync(sidecarFile, "utf8"));
|
|
425
|
+
} catch (err) {
|
|
426
|
+
problems.push(`${sid}: sidecar 不是合法 JSON(${err.message})`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (sidecar.format !== SIDECAR_FORMAT || sidecar.version !== SIDECAR_VERSION) problems.push(`${sid}: sidecar 格式/版本不对`);
|
|
430
|
+
if (!Array.isArray(sidecar.edges)) problems.push(`${sid}: sidecar.edges 不是数组`);
|
|
431
|
+
const ids = new Set((sidecar.edges ?? []).map((e) => e.id));
|
|
432
|
+
sidecarEdges += ids.size;
|
|
433
|
+
for (const id of ids) if (g.edges[id]) problems.push(`${sid}: 归档后 graph 仍含 ${id}`);
|
|
434
|
+
if (plan.residue.length !== 0) problems.push(`${sid}: 归档后仍有 ${plan.residue.length} 条 residue`);
|
|
435
|
+
if (backupGraphs.has(sid)) {
|
|
436
|
+
const before = backupGraphs.get(sid);
|
|
437
|
+
for (const e of sidecar.edges ?? []) {
|
|
438
|
+
if (!before.edges[e.id]) problems.push(`${sid}: 备份图里找不到 ${e.id}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (backupDir) {
|
|
443
|
+
const applyFile = nodePath.join(backupDir, "APPLY.json");
|
|
444
|
+
if (nodeFs.existsSync(applyFile)) {
|
|
445
|
+
const applyInfo = JSON.parse(nodeFs.readFileSync(applyFile, "utf8"));
|
|
446
|
+
if (applyInfo.fromBroken !== fromBroken) problems.push(`fromBroken 变了:apply 时 ${applyInfo.fromBroken},现在 ${fromBroken}`);
|
|
447
|
+
if (typeof applyInfo.candidates === "number" && applyInfo.candidates !== candidates) {
|
|
448
|
+
problems.push(`candidates 变了:apply 时 ${applyInfo.candidates},现在 ${candidates}(P6C-2 要求候选一条不动)`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
let state = seed >>> 0;
|
|
453
|
+
const rand = () => {
|
|
454
|
+
state = (state * 1664525 + 1013904223) >>> 0;
|
|
455
|
+
return state / 0x100000000;
|
|
456
|
+
};
|
|
457
|
+
const all = [];
|
|
458
|
+
for (const dir of sessions) {
|
|
459
|
+
const f = nodePath.join(dir, SIDECAR_NAME);
|
|
460
|
+
if (!nodeFs.existsSync(f)) continue;
|
|
461
|
+
const sc = JSON.parse(nodeFs.readFileSync(f, "utf8"));
|
|
462
|
+
for (const e of sc.edges ?? []) all.push({ sid: nodePath.basename(dir), edge: e });
|
|
463
|
+
}
|
|
464
|
+
for (let i = 0; i < Math.min(10, all.length); i += 1) {
|
|
465
|
+
const pick = all[Math.floor(rand() * all.length)];
|
|
466
|
+
const before = backupGraphs.get(pick.sid);
|
|
467
|
+
const same = before?.edges?.[pick.edge.id];
|
|
468
|
+
if (!same) {
|
|
469
|
+
problems.push(`抽样 ${pick.sid}/${pick.edge.id}: 备份里找不到`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const a = JSON.stringify(same);
|
|
473
|
+
const b = JSON.stringify(pick.edge);
|
|
474
|
+
if (a !== b) problems.push(`抽样 ${pick.sid}/${pick.edge.id}: 字段不一致`);
|
|
475
|
+
samples.push({ session: pick.sid, edge: pick.edge.id, identical: a === b });
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
mode: "verify",
|
|
479
|
+
root,
|
|
480
|
+
sessions: sessions.length,
|
|
481
|
+
withSidecar: checkedSessions,
|
|
482
|
+
sidecarEdges,
|
|
483
|
+
residue,
|
|
484
|
+
candidates,
|
|
485
|
+
fromBroken,
|
|
486
|
+
samples,
|
|
487
|
+
badGraphs,
|
|
488
|
+
problems,
|
|
489
|
+
ok: problems.length === 0 && residue === 0,
|
|
490
|
+
};
|
|
491
|
+
}
|