dsh-config-manager 0.1.72 → 0.1.76

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.
Files changed (153) hide show
  1. package/README.md +40 -4
  2. package/README.zh-CN.md +40 -4
  3. package/lib/adapters/index.d.ts +1 -0
  4. package/lib/adapters/index.js +5 -0
  5. package/lib/adapters/messages.d.ts +3 -0
  6. package/lib/adapters/messages.js +7 -0
  7. package/lib/adapters/plugin-data.d.ts +114 -0
  8. package/lib/adapters/plugin-data.js +309 -0
  9. package/lib/adapters/self.d.ts +1 -1
  10. package/lib/adapters/self.js +2 -1
  11. package/lib/adapters/test-helpers.d.ts +9 -0
  12. package/lib/adapters/test-helpers.js +14 -0
  13. package/lib/client.d.ts +103 -10
  14. package/lib/client.js +10748 -10119
  15. package/lib/core/analyzer.d.ts +11 -2
  16. package/lib/core/analyzer.js +31 -8
  17. package/lib/core/backup-plan.d.ts +10 -0
  18. package/lib/core/backup-plan.js +21 -3
  19. package/lib/core/backup.d.ts +20 -1
  20. package/lib/core/backup.js +48 -3
  21. package/lib/core/command-rollback.d.ts +6 -2
  22. package/lib/core/command-rollback.js +6 -2
  23. package/lib/core/messages-core.d.ts +2 -0
  24. package/lib/core/messages-core.js +4 -0
  25. package/lib/core/messages.d.ts +2 -1
  26. package/lib/core/messages.js +4 -2
  27. package/lib/core/plugin-data-registry.d.ts +124 -0
  28. package/lib/core/plugin-data-registry.js +198 -0
  29. package/lib/core/rollback-gate.d.ts +33 -0
  30. package/lib/core/rollback-gate.js +57 -0
  31. package/lib/core/rollback-ledger.d.ts +274 -0
  32. package/lib/core/rollback-ledger.js +1400 -0
  33. package/lib/core/rollback-point.d.ts +406 -5
  34. package/lib/core/rollback-point.js +1453 -306
  35. package/lib/core/rollback.js +6 -4
  36. package/lib/core/types.d.ts +19 -0
  37. package/lib/foreign/registry.d.ts +1 -1
  38. package/lib/foreign/registry.js +1 -1
  39. package/lib/index.d.ts +5 -1
  40. package/lib/index.js +83 -13
  41. package/lib/routes/messages.d.ts +1 -0
  42. package/lib/routes/messages.js +2 -0
  43. package/lib/routes/prefs.js +26 -3
  44. package/lib/routes/recovery.d.ts +21 -0
  45. package/lib/routes/recovery.js +127 -7
  46. package/lib/routes/sync.js +11 -1
  47. package/lib/schema/section-registry.d.ts +1 -1
  48. package/lib/schema/section-registry.js +32 -3
  49. package/lib/schema/types.d.ts +1 -1
  50. package/lib/sync/messages.d.ts +1 -0
  51. package/lib/sync/messages.js +3 -0
  52. package/lib/sync/sync-engine.d.ts +21 -7
  53. package/lib/sync/sync-engine.js +108 -18
  54. package/lib/sync/ui-prefs.d.ts +22 -0
  55. package/lib/sync/ui-prefs.js +28 -0
  56. package/lib/ui/diff-view.d.ts +10 -0
  57. package/lib/ui/diff-view.js +17 -0
  58. package/lib/ui/export-flow.js +2 -0
  59. package/lib/ui/i18n.d.ts +5 -3
  60. package/lib/ui/i18n.js +10 -6
  61. package/lib/ui/rollback-view.d.ts +59 -0
  62. package/lib/ui/rollback-view.js +14 -0
  63. package/lib/ui/test-helpers.js +2 -1
  64. package/lib/utils/bundle-scan.d.ts +5 -3
  65. package/lib/utils/bundle-scan.js +5 -3
  66. package/lib/utils/line-diff.d.ts +7 -1
  67. package/lib/utils/line-diff.js +8 -2
  68. package/package.json +1 -1
  69. package/src/adapters/index.ts +5 -0
  70. package/src/adapters/messages.ts +7 -0
  71. package/src/adapters/plugin-data.test.ts +225 -0
  72. package/src/adapters/plugin-data.ts +351 -0
  73. package/src/adapters/self.ts +2 -1
  74. package/src/adapters/test-helpers.ts +15 -0
  75. package/src/client/chat/ChatEntryView.tsx +59 -10
  76. package/src/client/chat/RollbackDiffTab.tsx +49 -2
  77. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  78. package/src/client/chat/RollbackReviewDialog.tsx +20 -4
  79. package/src/client/chat/chat-entry-view.test.ts +164 -1
  80. package/src/client/chat/chat-entry-view.ts +127 -10
  81. package/src/client/chat/chat-node-registration.ts +40 -6
  82. package/src/client/chat/chat-point-window.ts +11 -1
  83. package/src/client/chat/rollback-diff-tab.ts +13 -3
  84. package/src/client/chat/rollback-entry-view.ts +5 -0
  85. package/src/client/chat/sidebar-slots.ts +24 -2
  86. package/src/client/common/DiffTable.tsx +54 -29
  87. package/src/client/common/client-route-parity.test.ts +6 -0
  88. package/src/client/common/host-primitives.ts +78 -0
  89. package/src/client/common/routes.ts +10 -0
  90. package/src/client/common/section-labels.test.ts +1 -0
  91. package/src/client/common/section-labels.ts +2 -0
  92. package/src/client/config-manager.module.css +138 -14
  93. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  94. package/src/client/index.ts +54 -2
  95. package/src/client/library/RestorePlanView.tsx +0 -1
  96. package/src/client/locales.ts +8 -6
  97. package/src/client/recovery/RollbackCard.tsx +201 -7
  98. package/src/client/recovery/RollbackDiffPane.tsx +26 -8
  99. package/src/client/recovery/recovery-api.ts +28 -0
  100. package/src/client/recovery/recovery-locales.ts +36 -6
  101. package/src/client/recovery/rollback-enabled.ts +41 -0
  102. package/src/client/recovery/rollback-file-changes.test.ts +77 -0
  103. package/src/client/sync/PushPreviewCard.tsx +57 -0
  104. package/src/client/sync/SyncPanel.tsx +2 -37
  105. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  106. package/src/client/sync/sync-push-preview.test.ts +14 -0
  107. package/src/client/sync/sync-view.test.ts +1 -1
  108. package/src/client/sync/sync-view.ts +9 -1
  109. package/src/core/analyzer.ts +31 -9
  110. package/src/core/backup-plan.test.ts +20 -5
  111. package/src/core/backup-plan.ts +22 -3
  112. package/src/core/backup.ts +50 -4
  113. package/src/core/command-rollback.ts +6 -2
  114. package/src/core/messages-core.ts +4 -0
  115. package/src/core/messages.ts +4 -2
  116. package/src/core/plugin-data-registry.test.ts +113 -0
  117. package/src/core/plugin-data-registry.ts +261 -0
  118. package/src/core/rollback-gate.test.ts +66 -0
  119. package/src/core/rollback-gate.ts +61 -0
  120. package/src/core/rollback-ledger.test.ts +655 -0
  121. package/src/core/rollback-ledger.ts +1430 -0
  122. package/src/core/rollback-point.test.ts +866 -41
  123. package/src/core/rollback-point.ts +1582 -291
  124. package/src/core/rollback.ts +5 -3
  125. package/src/core/types.ts +19 -0
  126. package/src/foreign/registry.test.ts +1 -1
  127. package/src/foreign/registry.ts +1 -1
  128. package/src/index.ts +73 -10
  129. package/src/routes/messages.ts +2 -0
  130. package/src/routes/prefs.ts +31 -2
  131. package/src/routes/recovery.ts +122 -7
  132. package/src/routes/sync.ts +15 -1
  133. package/src/schema/manifest-schema.test.ts +4 -4
  134. package/src/schema/registry.test.ts +9 -2
  135. package/src/schema/section-registry.ts +32 -3
  136. package/src/schema/types.ts +2 -1
  137. package/src/sync/messages.ts +3 -0
  138. package/src/sync/sync-credentials.test.ts +119 -0
  139. package/src/sync/sync-engine.ts +106 -18
  140. package/src/sync/sync-preview-push.test.ts +158 -0
  141. package/src/sync/transport.test.ts +1 -0
  142. package/src/sync/ui-prefs.test.ts +44 -1
  143. package/src/sync/ui-prefs.ts +39 -0
  144. package/src/ui/diff-view.test.ts +9 -1
  145. package/src/ui/diff-view.ts +22 -0
  146. package/src/ui/export-flow.ts +2 -0
  147. package/src/ui/i18n.ts +10 -6
  148. package/src/ui/rollback-view.test.ts +17 -0
  149. package/src/ui/rollback-view.ts +66 -0
  150. package/src/ui/test-helpers.ts +2 -1
  151. package/src/utils/bundle-scan.ts +5 -3
  152. package/src/utils/line-diff.test.ts +12 -0
  153. package/src/utils/line-diff.ts +9 -3
@@ -0,0 +1,1400 @@
1
+ /**
2
+ * 回滚点台账**存储层**(issue #89)。
3
+ *
4
+ * ## 为什么要有这一层
5
+ * v2 台账是**单文件** index.json:每回合「读全文 -> JSON.parse -> 合并 -> JSON.stringify -> 原子写」。
6
+ * 本机实测该文件涨到 473 285 138 字节(50 条记录)后宿主反复 V8 heap OOM(exit 134)——
7
+ * 根因不是条数(50 条是上限)而是**字节**:ROLLBACK_MAX_RECORDS 只限条数、不限字节,
8
+ * 而每条记录的 workspace.payload.files 数组可达数万项(一次整份读入 = 数百 MB 常驻)。
9
+ *
10
+ * ## v3 布局(清单 + 单条记录 + 内容寻址载荷)
11
+ * rollback-points/
12
+ * index.json # **小清单**:{ schemaVersion: 3, records: LedgerManifestEntry[] }
13
+ * index.v2.backup.json # v2 单文件迁移成功后的改名保留(人要退回旧版仍有原文件)
14
+ * records/<id>.json # 单条完整记录(workspace.payload 换成一个载荷哈希引用)
15
+ * payloads/<sha256 of JSON>.json # 内容寻址的 RollbackWorkspacePayload —— 同一工作区态只落一份
16
+ * objects/<sha256> # 文件内容对象仓(口径不变)
17
+ * path-cache.json # 路径身份缓存(口径不变)
18
+ *
19
+ * 三条硬性质:
20
+ * 1. **清单里绝不含大数组** —— 每条只有元数据 + 段摘要(纯标量)⇒ 序列化字节数与文件条目数无关。
21
+ * 2. **按条解析** —— readRecord(id) 才去读那一条记录文件与它引用的载荷,「一个回合」的内存峰值
22
+ * ∝ 单份载荷,而**不是** ∝ 台账总字节。
23
+ * 3. **载荷去重** —— 内容寻址:同一工作区态只写一次,连续多个回合工作区未变时零额外字节。
24
+ *
25
+ * ## 不静默
26
+ * 载荷文件缺失 / 哈希不符 / 形状不符一律**如实失败**(返回 error,绝不拼半个记录出来);
27
+ * 调用方据此走既有的 record-incomplete / ledger-unreadable 路径。
28
+ *
29
+ * ## 为什么用注入的 summarize / isLedgerRecord
30
+ * 摘要口径(summarizeRollbackPoint)与记录校验(isRollbackPointRecord)是 rollback-point.ts 的
31
+ * **冻结单一事实源**;本模块只做存储。用注入而不是 import,是为了让依赖方向保持单向
32
+ * (rollback-point.ts -> rollback-ledger.ts),避免两个模块互相 import 形成运行期循环。
33
+ */
34
+ import fs from 'node:fs/promises';
35
+ import { createReadStream } from 'node:fs';
36
+ import path from 'node:path';
37
+ import { StringDecoder } from 'node:string_decoder';
38
+ import { atomicWriteFile } from '../utils/atomic-write.js';
39
+ import { isRecord } from '../utils/guards.js';
40
+ import { sha256Hex } from '../utils/hashing.js';
41
+ /* ------------------------------------------------------------------ 常量 */
42
+ /** 清单格式版本(与**记录**的 schemaVersion 无关:后者是记录内容的版本,恒为 2) */
43
+ export const ROLLBACK_LEDGER_SCHEMA_VERSION = 3;
44
+ /** 单条记录目录名 */
45
+ export const ROLLBACK_RECORDS_DIR_NAME = 'records';
46
+ /** 内容寻址载荷目录名 */
47
+ export const ROLLBACK_PAYLOADS_DIR_NAME = 'payloads';
48
+ /**
49
+ * 回退前像暂存目录名(`<root>/undo/<pid>-<ts36>-<8hex>/`)。
50
+ * 名字以 pid 开头是**清理判据的一部分**(见 cleanupOrphanTempFiles);改命名必须同步改 `UNDO_DIR_RE`。
51
+ */
52
+ export const ROLLBACK_UNDO_DIR_NAME = 'undo';
53
+ /** v2 单文件迁移成功后的保留名(改名,不是删除) */
54
+ export const ROLLBACK_LEDGER_BACKUP_NAME = 'index.v2.backup.json';
55
+ /**
56
+ * 台账(records/ + payloads/)缺省字节预算。
57
+ *
58
+ * 为什么需要它:ROLLBACK_MAX_RECORDS(50)只限**条数**,一条记录的工作区载荷可以到几十 MB
59
+ * (本机实测 50 条 = 473 MB,直接把宿主撑爆)。字节预算与条数预算**互相独立**,两者都生效。
60
+ */
61
+ export const ROLLBACK_DEFAULT_LEDGER_BYTES = 256 * 1024 * 1024;
62
+ /** 迁移锁名(同一 dataDir 被多个引擎实例并发初始化时的互斥) */
63
+ const MIGRATION_LOCK_NAME = '.dshcm-ledger-migrating';
64
+ /** 迁移锁视为陈旧(持锁进程已崩)的阈值 */
65
+ const MIGRATION_LOCK_STALE_MS = 5 * 60 * 1000;
66
+ /** 探测清单 schemaVersion 时只读文件头这么多字节(**绝不**为探测而整份读) */
67
+ const SCHEMA_SNIFF_BYTES = 8 * 1024;
68
+ /** 迁移时单条记录允许的最大字符数(异常输入的保护;正常记录远小于它) */
69
+ const MIGRATE_MAX_ITEM_CHARS = 512 * 1024 * 1024;
70
+ /** v2 头部里 records 数组起始 '[' 的搜索上限(超出 = 形状不符,如实失败) */
71
+ const MIGRATE_MAX_HEAD_CHARS = 64 * 1024;
72
+ const OBJECT_KEY_RE = /^[0-9a-f]{64}$/;
73
+ /** 记录文件名 = 记录 id;只允许可自证安全的字符(防篡改清单把路径引出去) */
74
+ export const LEDGER_RECORD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
75
+ /**
76
+ * atomic-write.ts 产生的临时文件名形态:
77
+ * .dshcm.<safeBase>.<pid>.<16 位 hex>.tmp(见该文件 tmpPath():TMP_PREFIX + base + '.' + pid + '.' + randomHex(8) + '.tmp')。
78
+ * **先读实现再写判据** —— 判不出来就一个都不删(宁可留垃圾也不删用户数据)。
79
+ */
80
+ const TEMP_FILE_RE = /^\.dshcm\.(.+)\.(\d+)\.([0-9a-f]{16})\.tmp$/;
81
+ /** 前像暂存目录名:`<pid>-<ts36>-<8hex>`(pid 必须在最前,清理判据要读它) */
82
+ const UNDO_DIR_RE = /^(\d+)-[0-9a-z]+-[0-9a-f]{8}$/;
83
+ /** 清单条目 -> 列表投影(逐字等于旧的 summarizeRollbackPoint(record),因为它是写入时算好的) */
84
+ export function entrySummaryOf(entry) {
85
+ return {
86
+ id: entry.id,
87
+ createdAt: entry.createdAt,
88
+ sessionId: entry.sessionId,
89
+ unitId: entry.unitId,
90
+ turn: entry.turnSeq,
91
+ boundarySeq: entry.boundarySeq,
92
+ kind: entry.kind,
93
+ protected: entry.protected,
94
+ incomplete: entry.incomplete,
95
+ note: entry.note,
96
+ spreadMs: entry.spreadMs,
97
+ segments: entry.segments,
98
+ };
99
+ }
100
+ /* ----------------------------------------------------- 迁移:流式切片 */
101
+ /**
102
+ * 从 v2 单文件里**流式**切出 records 数组的每个顶层对象,逐个交给 onRecord。
103
+ *
104
+ * 为什么必须流式:451 MiB 的文件 readFile + 整份 JSON.parse 必然 OOM(这正是本 issue 的现场)。
105
+ * 状态机只在一个对象内累积文本(一个对象 = 一条记录),其余字节掠过即丢。
106
+ */
107
+ async function streamLedgerRecords(file, onRecord) {
108
+ const stream = createReadStream(file);
109
+ const decoder = new StringDecoder('utf8');
110
+ let phase = 'head';
111
+ let head = '';
112
+ let rest = '';
113
+ let depth = 0;
114
+ let inString = false;
115
+ let item = [];
116
+ let itemChars = 0;
117
+ let count = 0;
118
+ let skipped = 0;
119
+ const STRUCT_RE = /[{}[\]"]/g;
120
+ /** 从头里找 'records' 键之后的数组起始 '['(v2 文件头部的一小段,有界) */
121
+ const tryFindArray = () => {
122
+ const key = head.indexOf('"records"');
123
+ if (key < 0)
124
+ return false;
125
+ const open = head.indexOf('[', key + 9);
126
+ if (open < 0)
127
+ return false;
128
+ rest = head.slice(open + 1);
129
+ head = '';
130
+ phase = 'array';
131
+ return true;
132
+ };
133
+ for await (const raw of stream) {
134
+ const text = typeof raw === 'string' ? raw : decoder.write(raw);
135
+ if (phase === 'head') {
136
+ head += text;
137
+ if (!tryFindArray()) {
138
+ if (head.length > MIGRATE_MAX_HEAD_CHARS) {
139
+ // v2 文件一定在头部就有 records 键;找不到 = 形状不符(如实失败,不猜)
140
+ stream.destroy();
141
+ throw new Error('ledger records array not found in head');
142
+ }
143
+ continue;
144
+ }
145
+ }
146
+ else {
147
+ rest += text;
148
+ }
149
+ let i = 0;
150
+ while (i < rest.length) {
151
+ if (itemChars > MIGRATE_MAX_ITEM_CHARS) {
152
+ stream.destroy();
153
+ throw new Error('ledger record too large while migrating');
154
+ }
155
+ if (depth === 0 && !inString) {
156
+ // 数组顶层:两个对象之间的空白 / 逗号是**分隔符**,绝不能混进下一条记录的文本
157
+ // (真机踩过:pretty-print 的 v2 文件里 ,\n 会被拼到下一个 { 前面,导致 JSON.parse 失败并把记录当垃圾跳过)
158
+ if (itemChars > 0) {
159
+ item = [];
160
+ itemChars = 0;
161
+ }
162
+ }
163
+ if (inString) {
164
+ // 字符串内:跳到结束引号(快路径 = indexOf + 反斜杠奇偶判据)
165
+ const end = findStringEnd(rest, i);
166
+ if (end < 0) {
167
+ item.push(rest.slice(i));
168
+ itemChars += rest.length - i;
169
+ i = rest.length;
170
+ break;
171
+ }
172
+ item.push(rest.slice(i, end + 1));
173
+ itemChars += end + 1 - i;
174
+ i = end + 1;
175
+ inString = false;
176
+ continue;
177
+ }
178
+ STRUCT_RE.lastIndex = i;
179
+ const m = STRUCT_RE.exec(rest);
180
+ if (m === null) {
181
+ if (depth > 0) {
182
+ item.push(rest.slice(i));
183
+ itemChars += rest.length - i;
184
+ }
185
+ i = rest.length;
186
+ break;
187
+ }
188
+ const at = m.index;
189
+ const ch = m[0];
190
+ // 顶层(depth === 0)的空白 / 逗号只是分隔符:push 进去会把下一条记录的 JSON 弄坏
191
+ if (at > i && depth > 0) {
192
+ item.push(rest.slice(i, at));
193
+ itemChars += at - i;
194
+ }
195
+ i = at + 1;
196
+ if (ch === '"') {
197
+ inString = true;
198
+ item.push('"');
199
+ continue;
200
+ }
201
+ if (ch === '{' || ch === '[') {
202
+ if (depth === 0 && ch !== '{') {
203
+ // records 数组里的顶层项必须是对象;不是就跳过并计数(形状不符,不猜)
204
+ skipped += 1;
205
+ item = [];
206
+ itemChars = 0;
207
+ continue;
208
+ }
209
+ depth += 1;
210
+ item.push(ch);
211
+ continue;
212
+ }
213
+ if (ch === '}' || ch === ']') {
214
+ if (depth === 0) {
215
+ if (ch === ']') {
216
+ phase = 'done';
217
+ break;
218
+ }
219
+ skipped += 1;
220
+ continue;
221
+ }
222
+ depth -= 1;
223
+ item.push(ch);
224
+ if (depth === 0) {
225
+ const text2 = item.join('');
226
+ item = [];
227
+ itemChars = 0;
228
+ await onRecord(text2);
229
+ count += 1;
230
+ }
231
+ continue;
232
+ }
233
+ }
234
+ if (phase === 'done')
235
+ break;
236
+ rest = rest.slice(i);
237
+ }
238
+ return { count, skipped };
239
+ }
240
+ /** 在 s 的 from 之后找**未被转义**的结束引号;找不到返回 -1 */
241
+ function findStringEnd(s, from) {
242
+ let i = from;
243
+ for (;;) {
244
+ const q = s.indexOf('"', i);
245
+ if (q < 0)
246
+ return -1;
247
+ let backslashes = 0;
248
+ let k = q - 1;
249
+ while (k >= 0 && s.charCodeAt(k) === 92) {
250
+ backslashes += 1;
251
+ k -= 1;
252
+ }
253
+ if (backslashes % 2 === 0)
254
+ return q;
255
+ i = q + 1;
256
+ }
257
+ }
258
+ /** 统计一棵目录树的字节数(只用于清理统计;读不到按 0 计,绝不因为统计失败而不删) */
259
+ async function dirBytes(dir) {
260
+ let total = 0;
261
+ let entries;
262
+ try {
263
+ entries = await fs.readdir(dir, { withFileTypes: true });
264
+ }
265
+ catch {
266
+ return 0;
267
+ }
268
+ for (const entry of entries) {
269
+ const full = path.join(dir, entry.name);
270
+ try {
271
+ if (entry.isDirectory())
272
+ total += await dirBytes(full);
273
+ else
274
+ total += (await fs.stat(full)).size;
275
+ }
276
+ catch {
277
+ /* 读不到就不计 */
278
+ }
279
+ }
280
+ return total;
281
+ }
282
+ function isProcessAlive(pid) {
283
+ if (!Number.isInteger(pid) || pid <= 0)
284
+ return false;
285
+ try {
286
+ process.kill(pid, 0);
287
+ return true;
288
+ }
289
+ catch (error) {
290
+ // EPERM = 进程存在但没权限发信号(Windows 上很常见)⇒ 仍然算活着
291
+ return error.code === 'EPERM';
292
+ }
293
+ }
294
+ /**
295
+ * 删除 rollback-points/ 下**形状可自证**的崩溃残留原子写临时文件。
296
+ *
297
+ * 判据(三条全中才删,判不准一律不删):
298
+ * 1. 名字逐字符合 atomic-write.ts 的 tmpPath() 形态:.dshcm.<base>.<pid>.<16hex>.tmp;
299
+ * 2. 该 pid **不是本进程**(本进程的 tmp 可能正在写);
300
+ * 3. 该 pid 进程已不存在(崩溃残留)—— 仍然活着的进程可能正在写,绝不抢。
301
+ * 只扫根目录与它的一级子目录(records/ payloads/ objects/),不递归。
302
+ */
303
+ export async function cleanupOrphanTempFiles(root) {
304
+ const out = { removed: 0, bytes: 0, skipped: 0 };
305
+ let dirs;
306
+ try {
307
+ const entries = await fs.readdir(root, { withFileTypes: true });
308
+ dirs = [root];
309
+ for (const entry of entries) {
310
+ if (entry.isDirectory())
311
+ dirs.push(path.join(root, entry.name));
312
+ }
313
+ }
314
+ catch {
315
+ return out;
316
+ }
317
+ for (const dir of dirs) {
318
+ let names;
319
+ try {
320
+ names = await fs.readdir(dir);
321
+ }
322
+ catch {
323
+ continue;
324
+ }
325
+ for (const name of names) {
326
+ const m = TEMP_FILE_RE.exec(name);
327
+ if (m === null)
328
+ continue;
329
+ const base = m[1] ?? '';
330
+ const pid = Number(m[2]);
331
+ if (base === '' || base.includes('/') || base.includes('\\')) {
332
+ out.skipped += 1;
333
+ continue;
334
+ }
335
+ if (pid === process.pid || isProcessAlive(pid)) {
336
+ out.skipped += 1;
337
+ continue;
338
+ }
339
+ const target = path.join(dir, name);
340
+ let bytes = 0;
341
+ try {
342
+ const info = await fs.stat(target);
343
+ if (!info.isFile()) {
344
+ out.skipped += 1;
345
+ continue;
346
+ }
347
+ bytes = info.size;
348
+ await fs.rm(target, { force: true });
349
+ }
350
+ catch {
351
+ out.skipped += 1;
352
+ continue;
353
+ }
354
+ out.removed += 1;
355
+ out.bytes += bytes;
356
+ }
357
+ }
358
+ // 回退前像暂存目录(<root>/undo/<pid>-<ts>-<hex>/):**进程已死的残留整目录删除**。
359
+ // 判据与 tmp 文件同源:pid 必须不是本进程、且已不存在;判不准一律不碰(skipped)。
360
+ try {
361
+ const undoRoot = path.join(root, ROLLBACK_UNDO_DIR_NAME);
362
+ const names = await fs.readdir(undoRoot);
363
+ for (const name of names) {
364
+ const m = UNDO_DIR_RE.exec(name);
365
+ if (m === null) {
366
+ out.skipped += 1;
367
+ continue;
368
+ }
369
+ const pid = Number(m[1]);
370
+ if (pid === process.pid || isProcessAlive(pid)) {
371
+ out.skipped += 1;
372
+ continue;
373
+ }
374
+ const target = path.join(undoRoot, name);
375
+ try {
376
+ const bytes = await dirBytes(target);
377
+ await fs.rm(target, { recursive: true, force: true });
378
+ out.removed += 1;
379
+ out.bytes += bytes;
380
+ }
381
+ catch {
382
+ out.skipped += 1;
383
+ }
384
+ }
385
+ }
386
+ catch {
387
+ /* undo 目录不存在(正常状态) */
388
+ }
389
+ return out;
390
+ }
391
+ /* ------------------------------------------------------------ 校验工具 */
392
+ function byteLength(text) {
393
+ return Buffer.byteLength(text, 'utf8');
394
+ }
395
+ function asReasonCode(value) {
396
+ return typeof value === 'string' && value !== '' ? value : null;
397
+ }
398
+ function asNullableString(value) {
399
+ return typeof value === 'string' ? value : null;
400
+ }
401
+ function asNullableNumber(value) {
402
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
403
+ }
404
+ function normalizeTrackHeader(value, kind) {
405
+ if (!isRecord(value))
406
+ return null;
407
+ if (value['kind'] !== kind)
408
+ return null;
409
+ const capturedAt = value['capturedAt'];
410
+ if (typeof capturedAt !== 'number' || !Number.isFinite(capturedAt))
411
+ return null;
412
+ if (typeof value['ok'] !== 'boolean')
413
+ return null;
414
+ return {
415
+ kind,
416
+ capturedAt,
417
+ ok: value['ok'],
418
+ degraded: value['degraded'] === true,
419
+ fingerprint: asNullableString(value['fingerprint']),
420
+ reasonCode: typeof value['reasonCode'] === 'string' ? value['reasonCode'] : null,
421
+ reasonDetail: asNullableString(value['reasonDetail']),
422
+ };
423
+ }
424
+ function normalizeSegment(value, state) {
425
+ if (!isRecord(value))
426
+ return null;
427
+ if (value['state'] !== state)
428
+ return null;
429
+ const capturedAt = value['capturedAt'];
430
+ const entries = value['entries'];
431
+ const bytes = value['bytes'];
432
+ const skippedCount = value['skippedCount'];
433
+ if (typeof capturedAt !== 'number' || !Number.isFinite(capturedAt))
434
+ return null;
435
+ if (typeof entries !== 'number' || !Number.isFinite(entries))
436
+ return null;
437
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes))
438
+ return null;
439
+ if (typeof skippedCount !== 'number' || !Number.isFinite(skippedCount))
440
+ return null;
441
+ return {
442
+ state,
443
+ ok: value['ok'] === true,
444
+ degraded: value['degraded'] === true,
445
+ capturedAt,
446
+ fingerprint: asNullableString(value['fingerprint']),
447
+ reasonCode: asReasonCode(value['reasonCode']),
448
+ entries,
449
+ bytes,
450
+ cursorBytes: asNullableNumber(value['cursorBytes']),
451
+ restorable: value['restorable'] === true,
452
+ skippedCount,
453
+ truncated: value['truncated'] === true,
454
+ };
455
+ }
456
+ /** 清单条目校验:形状不对就**丢掉并计数**(与 v2 的 records.filter(isRollbackPointRecord) 同一条路) */
457
+ export function normalizeManifestEntry(value) {
458
+ if (!isRecord(value))
459
+ return null;
460
+ const id = value['id'];
461
+ if (typeof id !== 'string' || !LEDGER_RECORD_ID_RE.test(id))
462
+ return null;
463
+ const kind = value['kind'];
464
+ if (kind !== 'auto' && kind !== 'guard')
465
+ return null;
466
+ const createdAt = value['createdAt'];
467
+ if (typeof createdAt !== 'number' || !Number.isFinite(createdAt))
468
+ return null;
469
+ const spreadMs = value['spreadMs'];
470
+ if (typeof spreadMs !== 'number' || !Number.isFinite(spreadMs))
471
+ return null;
472
+ const workspace = normalizeTrackHeader(value['workspace'], 'workspace');
473
+ const session = normalizeTrackHeader(value['session'], 'session');
474
+ if (workspace === null || session === null)
475
+ return null;
476
+ const rawSegments = value['segments'];
477
+ if (!Array.isArray(rawSegments) || rawSegments.length !== 2)
478
+ return null;
479
+ const first = normalizeSegment(rawSegments[0], 'workspace');
480
+ const second = normalizeSegment(rawSegments[1], 'session');
481
+ if (first === null || second === null)
482
+ return null;
483
+ const workspaceRaw = value['workspace'];
484
+ const sessionRaw = value['session'];
485
+ const wsPayload = isRecord(workspaceRaw) ? workspaceRaw['payload'] : null;
486
+ if (wsPayload !== null && (typeof wsPayload !== 'string' || !OBJECT_KEY_RE.test(wsPayload)))
487
+ return null;
488
+ const sessionObject = isRecord(sessionRaw) ? sessionRaw['object'] : null;
489
+ if (sessionObject !== null && (typeof sessionObject !== 'string' || !OBJECT_KEY_RE.test(sessionObject)))
490
+ return null;
491
+ const rawRecordBytes = value['recordBytes'];
492
+ const rawPayloadBytes = isRecord(workspaceRaw) ? workspaceRaw['payloadBytes'] : 0;
493
+ const rawPayloadChain = isRecord(workspaceRaw) ? workspaceRaw['payloadChain'] : null;
494
+ return {
495
+ id,
496
+ kind,
497
+ createdAt,
498
+ spreadMs,
499
+ protected: value['protected'] === true,
500
+ incomplete: value['incomplete'] === true,
501
+ note: asNullableString(value['note']),
502
+ sessionId: asNullableString(value['sessionId']),
503
+ unitId: asNullableString(value['unitId']),
504
+ turnSeq: asNullableNumber(value['turnSeq']),
505
+ boundarySeq: asNullableNumber(value['boundarySeq']),
506
+ recordBytes: typeof rawRecordBytes === 'number' && Number.isFinite(rawRecordBytes) && rawRecordBytes >= 0 ? rawRecordBytes : 0,
507
+ workspace: {
508
+ ...workspace,
509
+ payload: typeof wsPayload === 'string' ? wsPayload : null,
510
+ payloadBytes: typeof rawPayloadBytes === 'number' && Number.isFinite(rawPayloadBytes) && rawPayloadBytes >= 0 ? rawPayloadBytes : 0,
511
+ // 只保留形状合法的哈希;读不回来的链由解析器如实失败(绝不猜)
512
+ ...(Array.isArray(rawPayloadChain)
513
+ ? { payloadChain: rawPayloadChain.filter((h) => typeof h === 'string' && OBJECT_KEY_RE.test(h)) }
514
+ : {}),
515
+ },
516
+ session: { ...session, object: typeof sessionObject === 'string' ? sessionObject : null },
517
+ segments: [first, second],
518
+ };
519
+ }
520
+ /** 载荷形状校验:**严格**(形状不符 = readRecord 失败,绝不拼半个 payload) */
521
+ function normalizeWorkspacePayload(value) {
522
+ if (!isRecord(value))
523
+ return null;
524
+ const arrays = ['chunks', 'files', 'skipped', 'excludedDirs', 'unreadableDirs', 'unreadableFiles', 'ignoredDirs'];
525
+ for (const key of arrays) {
526
+ if (!Array.isArray(value[key]))
527
+ return null;
528
+ }
529
+ const numbers = ['skippedCount', 'ignoredDirCount', 'bytes'];
530
+ for (const key of numbers) {
531
+ if (typeof value[key] !== 'number' || !Number.isFinite(value[key]))
532
+ return null;
533
+ }
534
+ if (typeof value['skippedTruncated'] !== 'boolean')
535
+ return null;
536
+ if (typeof value['truncated'] !== 'boolean')
537
+ return null;
538
+ return value;
539
+ }
540
+ /**
541
+ * **存盘**形状校验:完整清单(`files` 是数组)或增量视图(`delta` 是对象、`files` 故意留 null)。
542
+ * 与 {@link normalizeWorkspacePayload}(消费侧,要求物化后的完整形状)分开:增量载荷**绝不会**被当成完整清单读出去。
543
+ */
544
+ function normalizeStoredPayload(value) {
545
+ const base = normalizeWorkspacePayload(value);
546
+ if (base !== null)
547
+ return base;
548
+ if (!isRecord(value))
549
+ return null;
550
+ const delta = value['delta'];
551
+ if (!isRecord(delta))
552
+ return null;
553
+ if (typeof delta['base'] !== 'string')
554
+ return null;
555
+ if (!Array.isArray(delta['added']) || !Array.isArray(delta['removed']))
556
+ return null;
557
+ const clone = { ...value, files: [] };
558
+ return normalizeWorkspacePayload(clone);
559
+ }
560
+ function normalizeSessionPayload(value) {
561
+ if (!isRecord(value))
562
+ return null;
563
+ if (value['sessionId'] !== null && typeof value['sessionId'] !== 'string')
564
+ return null;
565
+ if (typeof value['bytes'] !== 'number' || !Number.isFinite(value['bytes']))
566
+ return null;
567
+ if (typeof value['fingerprint'] !== 'string')
568
+ return null;
569
+ return value;
570
+ }
571
+ /* ---------------------------------------------------------------- 存储层 */
572
+ export class RollbackLedger {
573
+ root;
574
+ manifestPath;
575
+ backupPath;
576
+ recordsDir;
577
+ payloadsDir;
578
+ objectsDir;
579
+ bytesBudget;
580
+ summarize;
581
+ isLedgerRecord;
582
+ nowFn;
583
+ log;
584
+ onFileRead;
585
+ writeSeam;
586
+ /**
587
+ * 被**字节预算**淘汰的自动点累计数(可观测;与「条数上限」淘汰分开计数)。
588
+ *
589
+ * 为什么必须**持久化**而不是只记在实例上:宿主每个请求都新建一个引擎(路由侧),
590
+ * 只记在实例上的话这个数字永远是 0 —— 界面就永远看不到「已经因为预算淘汰过 N 个」。
591
+ * 值随清单落盘(`evicted` 字段),读清单时取「磁盘值与内存值的大者」,
592
+ * 这样「先 markEvicted 再读清单」的时序也不会把刚加上的计数抹掉。
593
+ */
594
+ evicted = 0;
595
+ constructor(options) {
596
+ this.root = path.resolve(options.root);
597
+ this.manifestPath = path.join(this.root, 'index.json');
598
+ this.backupPath = path.join(this.root, ROLLBACK_LEDGER_BACKUP_NAME);
599
+ this.recordsDir = path.join(this.root, ROLLBACK_RECORDS_DIR_NAME);
600
+ this.payloadsDir = path.join(this.root, ROLLBACK_PAYLOADS_DIR_NAME);
601
+ this.objectsDir = path.join(this.root, 'objects');
602
+ this.summarize = options.summarize;
603
+ this.isLedgerRecord = options.isLedgerRecord;
604
+ this.bytesBudget = options.bytesBudget ?? ROLLBACK_DEFAULT_LEDGER_BYTES;
605
+ this.nowFn = options.now ?? (() => Date.now());
606
+ this.log = options.log;
607
+ this.onFileRead = options.onFileRead;
608
+ this.writeSeam = options.writeFile;
609
+ }
610
+ get evictedCount() {
611
+ return this.evicted;
612
+ }
613
+ markEvicted(count) {
614
+ if (count > 0)
615
+ this.evicted += count;
616
+ }
617
+ warn(line) {
618
+ try {
619
+ this.log?.(line);
620
+ }
621
+ catch { /* 日志失败绝不影响主流程 */ }
622
+ }
623
+ async write(target, bytes) {
624
+ if (this.writeSeam !== undefined) {
625
+ await this.writeSeam(target, bytes);
626
+ return;
627
+ }
628
+ await atomicWriteFile(target, bytes);
629
+ }
630
+ async readText(file) {
631
+ this.onFileRead?.(file);
632
+ return await fs.readFile(file, 'utf8');
633
+ }
634
+ async exists(target) {
635
+ try {
636
+ await fs.access(target);
637
+ return true;
638
+ }
639
+ catch {
640
+ return false;
641
+ }
642
+ }
643
+ recordPath(id) {
644
+ if (!LEDGER_RECORD_ID_RE.test(id))
645
+ return null;
646
+ const target = path.join(this.recordsDir, id + '.json');
647
+ if (!target.startsWith(this.recordsDir + path.sep))
648
+ return null;
649
+ return target;
650
+ }
651
+ payloadPath(hash) {
652
+ if (!OBJECT_KEY_RE.test(hash))
653
+ return null;
654
+ return path.join(this.payloadsDir, hash + '.json');
655
+ }
656
+ /**
657
+ * 清单文件的**代际戳**(mtimeMs + 字节数)。跨进程乐观并发用:留点侧在读清单前后各取一次,
658
+ * 不一致说明另一个实例写过 ⇒ 重读重试,绝不拿自己的快照覆盖别人的清单。
659
+ * 读不到返回 null = **不参与判定**(可选探针失败绝不阻塞留点)。
660
+ */
661
+ async manifestStamp() {
662
+ try {
663
+ const info = await fs.stat(this.manifestPath);
664
+ return String(info.mtimeMs) + ':' + String(info.size);
665
+ }
666
+ catch {
667
+ return null;
668
+ }
669
+ }
670
+ /** 这份载荷是否已经落过盘(内容寻址 ⇒ 已存在就是同一份,可零写入;也是「本次新增字节」的判据) */
671
+ async hasPayload(hash) {
672
+ const target = this.payloadPath(hash);
673
+ if (target === null)
674
+ return false;
675
+ return await this.exists(target);
676
+ }
677
+ /* ------------------------------------------------------------ 清单 */
678
+ async readManifest() {
679
+ let raw;
680
+ try {
681
+ raw = await this.readText(this.manifestPath);
682
+ }
683
+ catch (error) {
684
+ const code = error.code;
685
+ // 不存在 = 首次使用(不是错误);其它读失败如实回报
686
+ if (code === 'ENOENT')
687
+ return { entries: [] };
688
+ return { entries: [], error: 'ledger unreadable: ' + errorDetail(error) };
689
+ }
690
+ let parsed;
691
+ try {
692
+ parsed = JSON.parse(raw);
693
+ }
694
+ catch (error) {
695
+ return { entries: [], error: 'ledger unreadable: ' + errorDetail(error) };
696
+ }
697
+ if (!isRecord(parsed))
698
+ return { entries: [], error: 'ledger shape invalid' };
699
+ if (parsed['schemaVersion'] !== ROLLBACK_LEDGER_SCHEMA_VERSION) {
700
+ // v2 单文件(迁移没跑成)或未来版本:绝不拿它当清单用(否则会把记录当垃圾丢掉)
701
+ return { entries: [], error: 'ledger unreadable: unsupported ledger schemaVersion ' + String(parsed['schemaVersion']) };
702
+ }
703
+ // 预算淘汰累计:取磁盘值与内存值的大者(markEvicted 后立刻读清单的时序不得回退计数)
704
+ const persistedEvicted = parsed['evicted'];
705
+ if (typeof persistedEvicted === 'number' && Number.isFinite(persistedEvicted) && persistedEvicted > this.evicted) {
706
+ this.evicted = Math.floor(persistedEvicted);
707
+ }
708
+ const list = parsed['records'];
709
+ if (!Array.isArray(list))
710
+ return { entries: [], error: 'ledger.records invalid' };
711
+ const entries = [];
712
+ let dropped = list.length;
713
+ for (const value of list) {
714
+ const entry = normalizeManifestEntry(value);
715
+ if (entry === null)
716
+ continue;
717
+ entries.push(entry);
718
+ dropped -= 1;
719
+ }
720
+ // 自证:清单里的每个 id 都必须在 records/ 里真的有一份(找不到就跳过并计数)
721
+ const missing = await this.findMissingRecordFiles(entries);
722
+ if (missing.size > 0) {
723
+ const errors = [];
724
+ if (dropped > 0)
725
+ errors.push('ledger dropped ' + String(dropped) + ' invalid record(s)');
726
+ errors.push('ledger missing ' + String(missing.size) + ' record file(s)');
727
+ return { entries: entries.filter((entry) => !missing.has(entry.id)), error: errors.join('; ') };
728
+ }
729
+ return dropped > 0
730
+ ? { entries, error: 'ledger dropped ' + String(dropped) + ' invalid record(s)' }
731
+ : { entries };
732
+ }
733
+ async findMissingRecordFiles(entries) {
734
+ const missing = new Set();
735
+ if (entries.length === 0)
736
+ return missing;
737
+ let names;
738
+ try {
739
+ names = await fs.readdir(this.recordsDir);
740
+ }
741
+ catch {
742
+ // 目录都没有 ⇒ 所有条目都缺文件(诚实计数)
743
+ for (const entry of entries)
744
+ missing.add(entry.id);
745
+ return missing;
746
+ }
747
+ const present = new Set();
748
+ for (const name of names) {
749
+ if (name.endsWith('.json'))
750
+ present.add(name.slice(0, -5));
751
+ }
752
+ for (const entry of entries) {
753
+ if (!present.has(entry.id))
754
+ missing.add(entry.id);
755
+ }
756
+ return missing;
757
+ }
758
+ /** 原子写清单(小文件;条目由调用方决定) */
759
+ async writeManifest(entries) {
760
+ await fs.mkdir(this.root, { recursive: true });
761
+ const body = JSON.stringify({
762
+ schemaVersion: ROLLBACK_LEDGER_SCHEMA_VERSION,
763
+ records: entries,
764
+ // 预算淘汰累计(持久化:见 evicted 字段的说明 —— 每请求新实例的场景下必须靠它才看得见)
765
+ evicted: this.evicted,
766
+ }, null, 2);
767
+ await this.write(this.manifestPath, Buffer.from(body, 'utf8'));
768
+ }
769
+ /* -------------------------------------------------------- 单条记录 */
770
+ /** 把完整记录拆成「清单条目 + 记录文件内容 + 载荷文件内容」(**不做 IO**,纯函数) */
771
+ prepareRecord(record, reuse) {
772
+ const summary = this.summarize(record);
773
+ const workspaceTrack = record.workspace;
774
+ const sessionTrack = record.session;
775
+ const payload = workspaceTrack.payload;
776
+ let payloadJson = null;
777
+ let payloadHash = null;
778
+ let payloadBytes = 0;
779
+ if (payload !== null && reuse !== undefined) {
780
+ // **复用**:这份工作区态与基准点逐条相同 ⇒ 直接引用它已经落盘的载荷(零额外字节,链不增长)
781
+ payloadHash = reuse.hash;
782
+ payloadBytes = reuse.bytes;
783
+ }
784
+ else if (payload !== null) {
785
+ payloadJson = JSON.stringify(payload);
786
+ payloadHash = sha256Hex(payloadJson);
787
+ payloadBytes = byteLength(payloadJson);
788
+ }
789
+ const recordJson = JSON.stringify({
790
+ ledgerSchemaVersion: ROLLBACK_LEDGER_SCHEMA_VERSION,
791
+ schemaVersion: record.schemaVersion,
792
+ id: record.id,
793
+ kind: record.kind,
794
+ createdAt: record.createdAt,
795
+ spreadMs: record.spreadMs,
796
+ protected: record.protected,
797
+ incomplete: record.incomplete,
798
+ note: record.note,
799
+ sessionId: record.sessionId,
800
+ unitId: record.unitId,
801
+ turnSeq: record.turnSeq,
802
+ boundarySeq: record.boundarySeq,
803
+ workspace: { ...trackHeader(workspaceTrack), payload: payloadHash },
804
+ session: { ...trackHeader(sessionTrack), payload: sessionTrack.payload },
805
+ });
806
+ // 增量视图 ⇒ 清单条目带上「解析所需的全部载荷哈希」(自身 + 基准链),回收按链保活
807
+ const payloadChain = payloadHash === null
808
+ ? undefined
809
+ : reuse !== undefined
810
+ ? (reuse.chain !== undefined && reuse.chain.length > 0 ? [...reuse.chain] : [payloadHash])
811
+ : payload?.delta === undefined
812
+ ? [payloadHash]
813
+ : [payloadHash, ...payload.delta.baseChain];
814
+ const entry = {
815
+ id: record.id,
816
+ kind: record.kind,
817
+ createdAt: record.createdAt,
818
+ spreadMs: record.spreadMs,
819
+ protected: record.protected,
820
+ incomplete: record.incomplete,
821
+ note: record.note,
822
+ sessionId: record.sessionId,
823
+ unitId: record.unitId,
824
+ turnSeq: record.turnSeq,
825
+ boundarySeq: record.boundarySeq,
826
+ recordBytes: byteLength(recordJson),
827
+ workspace: { ...trackHeader(workspaceTrack), payload: payloadHash, payloadBytes, ...(payloadChain === undefined ? {} : { payloadChain }) },
828
+ session: { ...trackHeader(sessionTrack), object: sessionTrack.payload?.object ?? null },
829
+ segments: summary.segments,
830
+ };
831
+ return { entry, recordJson, payloadJson, payloadHash };
832
+ }
833
+ /**
834
+ * 落盘:载荷(内容寻址;同一哈希已存在则**零写入**)+ 记录文件。
835
+ * 先载荷后记录:中途失败只留一份没人引用的载荷(垃圾,不是坏数据),下次 prune 回收。
836
+ */
837
+ async writePrepared(prepared) {
838
+ if (prepared.payloadJson !== null && prepared.payloadHash !== null) {
839
+ const target = this.payloadPath(prepared.payloadHash);
840
+ if (target === null)
841
+ throw new Error('ledger payload key invalid');
842
+ await fs.mkdir(this.payloadsDir, { recursive: true });
843
+ if (!(await this.exists(target))) {
844
+ await this.write(target, Buffer.from(prepared.payloadJson, 'utf8'));
845
+ }
846
+ }
847
+ const recordFile = this.recordPath(prepared.entry.id);
848
+ if (recordFile === null)
849
+ throw new Error('ledger record id invalid');
850
+ await fs.mkdir(this.recordsDir, { recursive: true });
851
+ await this.write(recordFile, Buffer.from(prepared.recordJson, 'utf8'));
852
+ }
853
+ /** 读校验过的载荷(哈希不符 / 形状不符 -> error,绝不返回半个) */
854
+ /**
855
+ * **原始**读取一份载荷(形状校验 + 内容寻址自证)。
856
+ * `files` 可能只是「相对基准的增量子集」(`payload.delta` 非空)—— **只给解析器用**,
857
+ * 消费者一律走 {@link resolvePayload} 拿物化后的完整清单。
858
+ */
859
+ async readStoredPayload(hash) {
860
+ const target = this.payloadPath(hash);
861
+ if (target === null)
862
+ return { error: 'record-incomplete: payload ref invalid' };
863
+ let raw;
864
+ try {
865
+ raw = await this.readText(target);
866
+ }
867
+ catch (error) {
868
+ return { error: 'record-incomplete: payload unreadable (' + errorDetail(error) + ')' };
869
+ }
870
+ // 内容寻址 ⇒ 字节一变就对不上:这是「这份载荷是不是我以为的那份」的唯一自证
871
+ if (sha256Hex(raw) !== hash)
872
+ return { error: 'record-incomplete: payload hash mismatch' };
873
+ let parsed;
874
+ try {
875
+ parsed = JSON.parse(raw);
876
+ }
877
+ catch (error) {
878
+ return { error: 'record-incomplete: payload unreadable (' + errorDetail(error) + ')' };
879
+ }
880
+ const payload = normalizeStoredPayload(parsed);
881
+ if (payload === null)
882
+ return { error: 'record-incomplete: payload shape invalid' };
883
+ return { payload };
884
+ }
885
+ /**
886
+ * 解析一份载荷:增量视图**物化**成完整 `files`(含 `object` 字段补回),全量视图原样返回。
887
+ *
888
+ * 任何一环不成立(基准缺失 / 哈希不符 / 形状不符 / 链成环)⇒ **如实失败**:绝不返回半个清单 ——
889
+ * 半份清单会让「留点后新建」的判据把绝大多数文件当成新建的,回退就会删掉它们。
890
+ */
891
+ async resolvePayload(hash) {
892
+ // **不缓存**:载荷被删/被改必须立刻看得见(缓存会把「存储已经坏了」蒙过去),
893
+ // 也守住「一次只驻留一份载荷」的内存护栏(issue #89 的 OOM 现场)。
894
+ return this.materializePayload(hash, new Set([hash]));
895
+ }
896
+ async materializePayload(hash, seen) {
897
+ const stored = await this.readStoredPayload(hash);
898
+ if (stored.payload === undefined)
899
+ return { error: stored.error ?? 'record-incomplete: payload unreadable' };
900
+ const payload = stored.payload;
901
+ const delta = payload.delta;
902
+ if (delta === undefined)
903
+ return { payload: withObjectRefs(payload) };
904
+ if (typeof delta.base !== 'string' || !OBJECT_KEY_RE.test(delta.base)) {
905
+ return { error: 'record-incomplete: delta base ref invalid' };
906
+ }
907
+ if (seen.has(delta.base))
908
+ return { error: 'record-incomplete: delta chain cycle' };
909
+ const next = new Set(seen);
910
+ next.add(delta.base);
911
+ const base = await this.materializePayload(delta.base, next);
912
+ if (base.payload === undefined) {
913
+ return { error: 'record-incomplete: delta base unresolved (' + String(base.error ?? '') + ')' };
914
+ }
915
+ const removed = new Set();
916
+ for (const p of Array.isArray(delta.removed) ? delta.removed : [])
917
+ if (typeof p === 'string')
918
+ removed.add(p);
919
+ const added = withObjectRefs({ ...payload, files: delta.added ?? [], delta: undefined }).files;
920
+ const addedPaths = new Set();
921
+ for (const file of added)
922
+ if (typeof file?.path === 'string')
923
+ addedPaths.add(file.path);
924
+ const files = [];
925
+ for (const file of base.payload.files) {
926
+ if (typeof file?.path !== 'string')
927
+ continue;
928
+ if (removed.has(file.path) || addedPaths.has(file.path))
929
+ continue;
930
+ files.push(file);
931
+ }
932
+ for (const file of added)
933
+ files.push(file);
934
+ // 与全量捕获同序(`payload.files.sort` 用的就是这个比较器)
935
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
936
+ return { payload: { ...payload, files, delta: undefined } };
937
+ }
938
+ /** 读一条**完整**记录(引用解析对调用方透明);任何一步不成立就如实失败 */
939
+ async readRecord(id) {
940
+ const target = this.recordPath(id);
941
+ if (target === null)
942
+ return { error: 'record-incomplete: record id invalid' };
943
+ let raw;
944
+ try {
945
+ raw = await this.readText(target);
946
+ }
947
+ catch (error) {
948
+ return { error: 'record-incomplete: record file unreadable (' + errorDetail(error) + ')' };
949
+ }
950
+ let parsed;
951
+ try {
952
+ parsed = JSON.parse(raw);
953
+ }
954
+ catch (error) {
955
+ return { error: 'record-incomplete: record unreadable (' + errorDetail(error) + ')' };
956
+ }
957
+ if (!isRecord(parsed) || parsed['id'] !== id)
958
+ return { error: 'record-incomplete: record shape invalid' };
959
+ const workspace = normalizeTrackHeader(parsed['workspace'], 'workspace');
960
+ const session = normalizeTrackHeader(parsed['session'], 'session');
961
+ if (workspace === null || session === null)
962
+ return { error: 'record-incomplete: record track invalid' };
963
+ const payloadRef = isRecord(parsed['workspace']) ? parsed['workspace']['payload'] : null;
964
+ let payload = null;
965
+ if (payloadRef !== null) {
966
+ if (typeof payloadRef !== 'string' || !OBJECT_KEY_RE.test(payloadRef)) {
967
+ return { error: 'record-incomplete: payload ref invalid' };
968
+ }
969
+ const resolved = await this.resolvePayload(payloadRef);
970
+ if (resolved.payload === undefined)
971
+ return { error: resolved.error ?? 'record-incomplete: payload unreadable' };
972
+ payload = resolved.payload;
973
+ }
974
+ const sessionPayloadRaw = isRecord(parsed['session']) ? parsed['session']['payload'] : null;
975
+ let sessionPayload = null;
976
+ if (sessionPayloadRaw !== null) {
977
+ sessionPayload = normalizeSessionPayload(sessionPayloadRaw);
978
+ if (sessionPayload === null)
979
+ return { error: 'record-incomplete: session payload shape invalid' };
980
+ }
981
+ const record = {
982
+ schemaVersion: typeof parsed['schemaVersion'] === 'number' ? parsed['schemaVersion'] : 0,
983
+ id,
984
+ kind: parsed['kind'] === 'guard' ? 'guard' : 'auto',
985
+ createdAt: typeof parsed['createdAt'] === 'number' ? parsed['createdAt'] : 0,
986
+ spreadMs: typeof parsed['spreadMs'] === 'number' ? parsed['spreadMs'] : 0,
987
+ protected: parsed['protected'] === true,
988
+ incomplete: parsed['incomplete'] === true,
989
+ note: asNullableString(parsed['note']),
990
+ sessionId: asNullableString(parsed['sessionId']),
991
+ unitId: asNullableString(parsed['unitId']),
992
+ turnSeq: asNullableNumber(parsed['turnSeq']),
993
+ boundarySeq: asNullableNumber(parsed['boundarySeq']),
994
+ workspace: { ...workspace, reasonCode: asReasonCode(workspace.reasonCode), payload },
995
+ session: { ...session, reasonCode: asReasonCode(session.reasonCode), payload: sessionPayload },
996
+ };
997
+ if (!this.isLedgerRecord(record))
998
+ return { error: 'record-incomplete: record shape invalid' };
999
+ return { record };
1000
+ }
1001
+ /** 删记录文件(返回实际释放字节;ENOENT 不计),**不**动载荷与对象 */
1002
+ async deleteRecords(ids) {
1003
+ let removed = 0;
1004
+ let bytes = 0;
1005
+ for (const id of ids) {
1006
+ const target = this.recordPath(id);
1007
+ if (target === null)
1008
+ continue;
1009
+ let size = 0;
1010
+ try {
1011
+ const info = await fs.stat(target);
1012
+ size = info.size;
1013
+ }
1014
+ catch {
1015
+ continue;
1016
+ }
1017
+ try {
1018
+ await fs.rm(target, { force: true });
1019
+ }
1020
+ catch (error) {
1021
+ this.warn('rollback ledger record delete failed: ' + id + ' ' + errorDetail(error));
1022
+ continue;
1023
+ }
1024
+ removed += 1;
1025
+ bytes += size;
1026
+ }
1027
+ return { removed, bytes };
1028
+ }
1029
+ /* ---------------------------------------------------------- 载荷回收 */
1030
+ /** 没人引用的载荷文件一律删(引用计数靠扫清单得到,**不读载荷内容**) */
1031
+ async prunePayloads(entries) {
1032
+ const referenced = new Set();
1033
+ for (const entry of entries) {
1034
+ // 增量载荷依赖基准 ⇒ **按链保活**(否则淘汰/删点会把基准删掉,那个点就永远读不回来了)
1035
+ const chain = entry.workspace.payloadChain;
1036
+ if (Array.isArray(chain) && chain.length > 0) {
1037
+ for (const hash of chain)
1038
+ referenced.add(hash);
1039
+ continue;
1040
+ }
1041
+ if (entry.workspace.payload !== null)
1042
+ referenced.add(entry.workspace.payload);
1043
+ }
1044
+ let names;
1045
+ try {
1046
+ names = await fs.readdir(this.payloadsDir);
1047
+ }
1048
+ catch {
1049
+ return { removed: 0, bytes: 0 };
1050
+ }
1051
+ let removed = 0;
1052
+ let bytes = 0;
1053
+ for (const name of names) {
1054
+ if (!name.endsWith('.json'))
1055
+ continue;
1056
+ const hash = name.slice(0, -5);
1057
+ if (!OBJECT_KEY_RE.test(hash) || referenced.has(hash))
1058
+ continue;
1059
+ const target = path.join(this.payloadsDir, name);
1060
+ try {
1061
+ const info = await fs.stat(target);
1062
+ if (!info.isFile())
1063
+ continue;
1064
+ await fs.rm(target, { force: true });
1065
+ removed += 1;
1066
+ bytes += info.size;
1067
+ }
1068
+ catch (error) {
1069
+ this.warn('rollback ledger payload delete failed: ' + name + ' ' + errorDetail(error));
1070
+ continue;
1071
+ }
1072
+ }
1073
+ return { removed, bytes };
1074
+ }
1075
+ /* ---------------------------------------------------------- 对象回收 */
1076
+ /**
1077
+ * 内容寻址对象仓回收(**重写版,内存 ∝ 单份载荷 + 对象键集合**)。
1078
+ *
1079
+ * 旧实现要先在内存里拿到「所有记录的全部 files 数组」,于是回收一次就等于把整份台账读进内存
1080
+ * (issue #89 的 OOM 现场之一)。现在:扫清单 -> **逐个读引用到的载荷文件(一次只驻留一份)**
1081
+ * -> 累计 referenced 对象键 -> 扫 objects/ 删无引用项。
1082
+ *
1083
+ * 任何一份被引用的载荷读不出来(缺失 / 哈希不符 / 形状不符)⇒ **整体放弃回收**(aborted 非空):
1084
+ * 读不到的载荷里有什么对象谁也不知道,此时删对象可能删掉活数据 —— fail-closed。
1085
+ */
1086
+ async collectObjects(entries) {
1087
+ const referenced = new Set();
1088
+ const referencedPaths = new Set();
1089
+ for (const entry of entries) {
1090
+ const hash = entry.workspace.payload;
1091
+ if (hash !== null) {
1092
+ const resolved = await this.resolvePayload(hash);
1093
+ if (resolved.payload === undefined) {
1094
+ return { removed: 0, bytes: 0, referencedPaths, aborted: resolved.error ?? 'payload unreadable' };
1095
+ }
1096
+ for (const file of resolved.payload.files) {
1097
+ if (typeof file?.object === 'string')
1098
+ referenced.add(file.object);
1099
+ if (typeof file?.path === 'string')
1100
+ referencedPaths.add(file.path);
1101
+ }
1102
+ const index = resolved.payload.pathIndex ?? null;
1103
+ if (index !== null && index !== undefined && typeof index.object === 'string')
1104
+ referenced.add(index.object);
1105
+ }
1106
+ if (entry.session.object !== null)
1107
+ referenced.add(entry.session.object);
1108
+ }
1109
+ let names;
1110
+ try {
1111
+ names = await fs.readdir(this.objectsDir);
1112
+ }
1113
+ catch {
1114
+ return { removed: 0, bytes: 0, referencedPaths, aborted: null };
1115
+ }
1116
+ let removed = 0;
1117
+ let bytes = 0;
1118
+ for (const name of names) {
1119
+ if (!OBJECT_KEY_RE.test(name) || referenced.has(name))
1120
+ continue;
1121
+ const target = path.join(this.objectsDir, name);
1122
+ try {
1123
+ const info = await fs.stat(target);
1124
+ if (!info.isFile())
1125
+ continue;
1126
+ await fs.rm(target, { force: true });
1127
+ removed += 1;
1128
+ bytes += info.size;
1129
+ }
1130
+ catch (error) {
1131
+ this.warn('rollback gc failed: ' + name + ' ' + errorDetail(error));
1132
+ }
1133
+ }
1134
+ return { removed, bytes, referencedPaths, aborted: null };
1135
+ }
1136
+ /* ------------------------------------------------------------ 统计 */
1137
+ /** records/ + payloads/ 的**实际**磁盘占用与文件数(诚实:含尚未回收的孤儿文件) */
1138
+ async stats() {
1139
+ let records = 0;
1140
+ let bytes = 0;
1141
+ const dirs = [
1142
+ { dir: this.recordsDir, count: true },
1143
+ { dir: this.payloadsDir, count: false },
1144
+ ];
1145
+ for (const item of dirs) {
1146
+ let names;
1147
+ try {
1148
+ names = await fs.readdir(item.dir);
1149
+ }
1150
+ catch {
1151
+ continue;
1152
+ }
1153
+ for (const name of names) {
1154
+ try {
1155
+ const info = await fs.stat(path.join(item.dir, name));
1156
+ if (!info.isFile())
1157
+ continue;
1158
+ bytes += info.size;
1159
+ if (item.count)
1160
+ records += 1;
1161
+ }
1162
+ catch { /* 竞态消失的文件:跳过 */ }
1163
+ }
1164
+ }
1165
+ return { records, bytes };
1166
+ }
1167
+ /* ------------------------------------------------------------ 迁移 */
1168
+ async sniffSchemaVersion() {
1169
+ let handle;
1170
+ try {
1171
+ handle = await fs.open(this.manifestPath, 'r');
1172
+ }
1173
+ catch {
1174
+ return { present: false, schemaVersion: null };
1175
+ }
1176
+ try {
1177
+ const buf = Buffer.alloc(SCHEMA_SNIFF_BYTES);
1178
+ const { bytesRead } = await handle.read(buf, 0, SCHEMA_SNIFF_BYTES, 0);
1179
+ const head = buf.subarray(0, bytesRead).toString('utf8');
1180
+ const m = /"schemaVersion"\s*:\s*(\d+)/.exec(head);
1181
+ return { present: true, schemaVersion: m === null ? null : Number(m[1]) };
1182
+ }
1183
+ catch {
1184
+ return { present: true, schemaVersion: null };
1185
+ }
1186
+ finally {
1187
+ await handle.close().catch(() => { });
1188
+ }
1189
+ }
1190
+ async acquireMigrationLock() {
1191
+ const lockPath = path.join(this.root, MIGRATION_LOCK_NAME);
1192
+ await fs.mkdir(this.root, { recursive: true }).catch(() => { });
1193
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1194
+ try {
1195
+ const handle = await fs.open(lockPath, 'wx');
1196
+ await handle.close();
1197
+ return lockPath;
1198
+ }
1199
+ catch (error) {
1200
+ if (error.code !== 'EEXIST')
1201
+ return null;
1202
+ let stale = true;
1203
+ try {
1204
+ const info = await fs.stat(lockPath);
1205
+ stale = this.nowFn() - info.mtimeMs > MIGRATION_LOCK_STALE_MS;
1206
+ }
1207
+ catch {
1208
+ stale = true;
1209
+ }
1210
+ if (!stale)
1211
+ return null;
1212
+ await fs.rm(lockPath, { force: true }).catch(() => { });
1213
+ }
1214
+ }
1215
+ return null;
1216
+ }
1217
+ /**
1218
+ * v2 单文件 -> v3:**流式**切记录、逐条落盘,最后才动原文件。
1219
+ *
1220
+ * 幂等与崩溃安全:
1221
+ * · 步骤 1(流式落 records/ + payloads/)可重复执行 —— id 稳定,重复写是覆盖,不产生重复条目;
1222
+ * · 步骤 2 才把 v2 文件改名为 index.v2.backup.json(**成功之前原文件一动不动**);
1223
+ * · 若在步骤 2 与 3 之间崩溃(没有 index.json、有 index.v2.backup.json),下次启动走
1224
+ * rebuildManifest() 从 records/ 重建清单 —— 清单自证:每条 id 必须在 records/ 找得到。
1225
+ */
1226
+ /**
1227
+ * 清单**读不出来**时从 `records/` 重建,并返回重建后重新读到的清单。
1228
+ *
1229
+ * 为什么公开:留点侧(`appendRecord`)在 `readManifest` 报错时**必须**走这里 —— 旧点的最终真相
1230
+ * 在记录文件里,而拿一个空 entries 直接覆盖清单会把旧点整批抹掉、紧接着 `prunePayloads` 还会删掉
1231
+ * 它们的载荷(永久不可恢复)。重建后仍读不出来才由调用方拒写(fail-closed)。
1232
+ */
1233
+ async recoverManifest() {
1234
+ const ids = await this.listRecordIds().catch(() => []);
1235
+ if (ids.length === 0) {
1236
+ // `records/` 里一条记录都没有 ⇒ 台账**本来就是空的**(或目录还没建):
1237
+ // 落一份空清单让留点继续 —— 这不是「覆盖」,因为没有东西可丢。
1238
+ // (rebuildManifest 在这种情形会刻意什么都不做,于是清单仍读不出来、留点被永久卡死。)
1239
+ try {
1240
+ await this.writeManifest([]);
1241
+ }
1242
+ catch (error) {
1243
+ return { entries: [], error: 'ledger manifest reset failed: ' + errorDetail(error) };
1244
+ }
1245
+ return { entries: [] };
1246
+ }
1247
+ try {
1248
+ await this.rebuildManifest();
1249
+ }
1250
+ catch (error) {
1251
+ return { entries: [], error: 'ledger unreadable: rebuild failed (' + errorDetail(error) + ')' };
1252
+ }
1253
+ return await this.readManifest();
1254
+ }
1255
+ async migrateIfNeeded() {
1256
+ const sniff = await this.sniffSchemaVersion();
1257
+ if (!sniff.present)
1258
+ return await this.rebuildManifest();
1259
+ if (sniff.schemaVersion !== 2)
1260
+ return { migrated: false, records: 0, skipped: 0 };
1261
+ const lock = await this.acquireMigrationLock();
1262
+ if (lock === null) {
1263
+ // 另一个实例正在迁:如实说「进行中」,绝不假装读到了空台账
1264
+ return { migrated: false, records: 0, skipped: 0, error: 'ledger unreadable: migration in progress' };
1265
+ }
1266
+ try {
1267
+ const again = await this.sniffSchemaVersion();
1268
+ if (!again.present)
1269
+ return await this.rebuildManifest();
1270
+ if (again.schemaVersion !== 2)
1271
+ return { migrated: false, records: 0, skipped: 0 };
1272
+ return await this.migrateV2();
1273
+ }
1274
+ finally {
1275
+ await fs.rm(lock, { force: true }).catch(() => { });
1276
+ }
1277
+ }
1278
+ async migrateV2() {
1279
+ let written = 0;
1280
+ let skipped = 0;
1281
+ try {
1282
+ const streamed = await streamLedgerRecords(this.manifestPath, async (text) => {
1283
+ let parsed;
1284
+ try {
1285
+ parsed = JSON.parse(text);
1286
+ }
1287
+ catch {
1288
+ skipped += 1;
1289
+ return;
1290
+ }
1291
+ if (!this.isLedgerRecord(parsed)) {
1292
+ skipped += 1;
1293
+ return;
1294
+ }
1295
+ await this.writePrepared(this.prepareRecord(parsed));
1296
+ written += 1;
1297
+ });
1298
+ skipped += streamed.skipped;
1299
+ }
1300
+ catch (error) {
1301
+ // 原文件仍在:下次启动重试(已写的记录文件会被同 id 覆盖,不产生重复清单条目)
1302
+ return { migrated: false, records: written, skipped, error: 'ledger migration failed: ' + errorDetail(error) };
1303
+ }
1304
+ try {
1305
+ await fs.rename(this.manifestPath, this.backupPath);
1306
+ }
1307
+ catch (error) {
1308
+ return { migrated: false, records: written, skipped, error: 'ledger migration rename failed: ' + errorDetail(error) };
1309
+ }
1310
+ try {
1311
+ const built = await this.readEntriesFromRecords();
1312
+ await this.writeManifest(built.entries);
1313
+ }
1314
+ catch (error) {
1315
+ return { migrated: false, records: written, skipped, error: 'ledger migration manifest failed: ' + errorDetail(error) };
1316
+ }
1317
+ return { migrated: true, records: written, skipped };
1318
+ }
1319
+ /** 从 records/ 重建清单(崩溃恢复;清单自证:找不到记录文件的 id 一律跳过并计数) */
1320
+ async rebuildManifest() {
1321
+ const ids = await this.listRecordIds();
1322
+ if (ids.length === 0)
1323
+ return { migrated: false, records: 0, skipped: 0 };
1324
+ const built = await this.readEntriesFromRecords();
1325
+ try {
1326
+ await this.writeManifest(built.entries);
1327
+ }
1328
+ catch (error) {
1329
+ return { migrated: false, records: built.entries.length, skipped: built.skipped, error: 'ledger manifest rebuild failed: ' + errorDetail(error) };
1330
+ }
1331
+ return { migrated: true, records: built.entries.length, skipped: built.skipped };
1332
+ }
1333
+ async listRecordIds() {
1334
+ let names;
1335
+ try {
1336
+ names = await fs.readdir(this.recordsDir);
1337
+ }
1338
+ catch {
1339
+ return [];
1340
+ }
1341
+ const out = [];
1342
+ for (const name of names) {
1343
+ if (!name.endsWith('.json'))
1344
+ continue;
1345
+ const id = name.slice(0, -5);
1346
+ if (LEDGER_RECORD_ID_RE.test(id))
1347
+ out.push(id);
1348
+ }
1349
+ return out.sort();
1350
+ }
1351
+ async readEntriesFromRecords() {
1352
+ const entries = [];
1353
+ let skipped = 0;
1354
+ for (const id of await this.listRecordIds()) {
1355
+ const resolved = await this.readRecord(id);
1356
+ if (resolved.record === undefined) {
1357
+ skipped += 1;
1358
+ continue;
1359
+ }
1360
+ entries.push(this.prepareRecord(resolved.record).entry);
1361
+ }
1362
+ entries.sort((a, b) => (a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0));
1363
+ return { entries, skipped };
1364
+ }
1365
+ }
1366
+ /* --------------------------------------------------------------- 工具 */
1367
+ function trackHeader(track) {
1368
+ return {
1369
+ kind: track.kind,
1370
+ capturedAt: track.capturedAt,
1371
+ ok: track.ok,
1372
+ degraded: track.degraded,
1373
+ fingerprint: track.fingerprint,
1374
+ reasonCode: track.reasonCode,
1375
+ reasonDetail: track.reasonDetail,
1376
+ };
1377
+ }
1378
+ /**
1379
+ * 补回**落盘时省掉的** `object` 字段:内容寻址的名字就是内容哈希 ⇒ `object ≡ sha256`。
1380
+ * 缺 `sha256` 的畸形条目保持原样(消费侧的防御判据照旧生效)。
1381
+ */
1382
+ function withObjectRefs(payload) {
1383
+ for (const file of payload.files) {
1384
+ if (file === null || typeof file !== 'object')
1385
+ continue;
1386
+ if (typeof file.object === 'string')
1387
+ continue;
1388
+ if (typeof file.sha256 === 'string')
1389
+ file.object = file.sha256;
1390
+ }
1391
+ return payload;
1392
+ }
1393
+ function errorDetail(error) {
1394
+ if (error instanceof Error) {
1395
+ const code = error.code;
1396
+ return typeof code === 'string' ? code + ': ' + error.message : error.message;
1397
+ }
1398
+ return String(error);
1399
+ }
1400
+ //# sourceMappingURL=rollback-ledger.js.map