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