dsh-config-manager 0.1.73 → 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 (49) hide show
  1. package/lib/client.d.ts +8 -4
  2. package/lib/client.js +814 -682
  3. package/lib/core/rollback-ledger.d.ts +29 -3
  4. package/lib/core/rollback-ledger.js +125 -7
  5. package/lib/core/rollback-point.d.ts +221 -3
  6. package/lib/core/rollback-point.js +790 -153
  7. package/lib/index.d.ts +1 -1
  8. package/lib/index.js +1 -1
  9. package/lib/sync/messages.d.ts +1 -0
  10. package/lib/sync/messages.js +3 -0
  11. package/lib/sync/sync-engine.js +56 -7
  12. package/lib/ui/i18n.d.ts +1 -1
  13. package/lib/ui/i18n.js +2 -2
  14. package/lib/utils/bundle-scan.d.ts +5 -3
  15. package/lib/utils/bundle-scan.js +5 -3
  16. package/lib/utils/line-diff.d.ts +7 -1
  17. package/lib/utils/line-diff.js +8 -2
  18. package/package.json +1 -1
  19. package/src/client/chat/ChatEntryView.tsx +38 -9
  20. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  21. package/src/client/chat/chat-entry-view.test.ts +164 -1
  22. package/src/client/chat/chat-entry-view.ts +127 -10
  23. package/src/client/chat/chat-node-registration.ts +40 -6
  24. package/src/client/common/DiffTable.tsx +12 -23
  25. package/src/client/common/host-primitives.ts +78 -0
  26. package/src/client/config-manager.module.css +33 -12
  27. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  28. package/src/client/index.ts +7 -1
  29. package/src/client/library/RestorePlanView.tsx +0 -1
  30. package/src/client/locales.ts +0 -2
  31. package/src/client/recovery/RollbackDiffPane.tsx +13 -6
  32. package/src/client/recovery/recovery-locales.ts +2 -4
  33. package/src/client/recovery/rollback-file-changes.test.ts +37 -1
  34. package/src/client/sync/PushPreviewCard.tsx +57 -0
  35. package/src/client/sync/SyncPanel.tsx +2 -37
  36. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  37. package/src/client/sync/sync-push-preview.test.ts +14 -0
  38. package/src/client/sync/sync-view.ts +9 -1
  39. package/src/core/rollback-ledger.ts +123 -7
  40. package/src/core/rollback-point.test.ts +592 -17
  41. package/src/core/rollback-point.ts +861 -155
  42. package/src/index.ts +1 -1
  43. package/src/sync/messages.ts +3 -0
  44. package/src/sync/sync-engine.ts +55 -7
  45. package/src/sync/sync-preview-push.test.ts +158 -0
  46. package/src/ui/i18n.ts +2 -2
  47. package/src/utils/bundle-scan.ts +5 -3
  48. package/src/utils/line-diff.test.ts +12 -0
  49. package/src/utils/line-diff.ts +9 -3
@@ -83,10 +83,18 @@ import {
83
83
  RollbackLedger,
84
84
  type RollbackLedgerManifestEntry,
85
85
  type RollbackLedgerMigration,
86
+ type RollbackLedgerPrepared,
86
87
  } from './rollback-ledger.ts'
87
88
 
88
89
  /* ------------------------------------------------------------------ 常量 */
89
90
 
91
+ /** 增量链的最大长度:到顶就落一个**全量锚点**(重建最多读 1 份全量 + 几份小增量) */
92
+ const ROLLBACK_DELTA_CHAIN_LIMIT = 8
93
+ /** path → 基准摘要键(sha256/bytes/chunk 拼成,只用于判「这条与基准逐条相同」) */
94
+ type RollbackDeltaDigest = { hash: string; chain: string[]; chunks: string[]; entries: Map<string, string> }
95
+ /** 增量视图只有在小于全量的这个比例时才采用(否则直接全量,简单可靠) */
96
+ const ROLLBACK_DELTA_MAX_RATIO = 0.6
97
+
90
98
  export const ROLLBACK_SCHEMA_VERSION = 2
91
99
  /** 回滚点库目录名(dataDir/rollback-points)——路由族 /recovery/rollback-points/* 同源 */
92
100
  export const ROLLBACK_POINTS_DIR_NAME = 'rollback-points'
@@ -194,6 +202,53 @@ export const ROLLBACK_LEFTOVER_LIST_LIMIT = 20
194
202
  * `truncated` 置真 ⇒ 删除集自动 fail-closed(证明名单不完整就一条都不删)。
195
203
  */
196
204
  export const ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS = 30_000
205
+ /**
206
+ * 自适应预算区间(见 RollbackEngineOptions.captureBudgetMs):上一次留点被截断 ⇒ 下次 ×2,
207
+ * 上一次只用掉不到 1/4 ⇒ 下次 ÷2。区间外不再调整(避免无限拉长 / 缩到不可用)。
208
+ */
209
+ export const ROLLBACK_MIN_CAPTURE_BUDGET_MS = 10_000
210
+ export const ROLLBACK_MAX_CAPTURE_BUDGET_MS = 120_000
211
+ /**
212
+ * 捕获期「读内容 + 写对象仓」的并发上限。
213
+ *
214
+ * 为什么必须并发:真机实测(34k 文件 / 512 MiB)一次全量捕获 175 s,其中 **148 s 花在
215
+ * 31,702 次对象写**(一次 4.7 ms:temp + fsync + rename),而走查本身只要 0.66 s。
216
+ * 对象写入是纯 I/O,串行做等于把预算全花在等待上 ⇒ 覆盖永远只有一小半。
217
+ * 并发上限与**在飞字节闸门**共同限流(在飞内容上界 = `captureInFlightBytes`)。
218
+ */
219
+ export const ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY = 16
220
+ /**
221
+ * 捕获期**读入内存、尚未落对象仓**的内容字节上限(真正的内存闸门)。
222
+ *
223
+ * 为什么必须独立于 `maxFileBytes`(0.1.74 真机闪退事故,2026-10-16):
224
+ * `maxFileBytes` 的缺省是**总量预算** `maxBytes`(512 MiB)。并发捕获把闸门接到它上面,
225
+ * 于是「同时在飞的内容」可以到 ~512 MiB —— 串行实现的上界是「一份文件」,并发实现的
226
+ * 上界是「闸门限额」,两者差着两三个数量级。真机表现:发出消息后 1–2 s(= `turn/start`
227
+ * 开始留点)DSH Desktop 宿主被系统终止 ⇒ 桌面闪退(无 JS 栈、插件零错误、
228
+ * 退出码是「被外部终止」形状)。这里取一个小常量当闸门;要放宽只能显式传
229
+ * `captureInFlightBytes`,且被 `maxBytes` 夹住。
230
+ */
231
+ export const ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES = 32 * 1024 * 1024
232
+ /** 对象写遇到这些码视为「瞬时占用」,有界退避重试(判据同 atomic-write 的 isRetryableLockCode) */
233
+ const OBJECT_WRITE_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY']
234
+ /** 捕获阶段①的 stat 批大小:判定仍严格按顺序,只是 stat 并发取(真机 34k 次 stat 从 5 s → <1 s) */
235
+ export const ROLLBACK_CAPTURE_STAT_BATCH = 128
236
+ /**
237
+ * 捕获期**候选名单**上限(不是入点上限):必须显著大于 maxFiles,否则「缺口优先」排序作用不到
238
+ * 那些排在遍历顺序尾部、上一回合没覆盖到的文件(它们连候选都进不了 ⇒ 覆盖率永远不收敛)。
239
+ * 走查本来就会把每条路径记进 walked(路径索引的来源),这里多留的只是候选数组本身。
240
+ */
241
+ export const ROLLBACK_CAPTURE_LIST_LIMIT = 200_000
242
+ /** 捕获阶段①的 stat 结果(一次 bigint stat 拿到大小 / mtime / 身份;失败时带原始 detail) */
243
+ interface RollbackFileStat {
244
+ isFile: boolean
245
+ size: number
246
+ mtimeMs: number
247
+ identity: string | null
248
+ error?: string
249
+ }
250
+ /** 走查中断时逐条记下的「没进去的子树」上限;超出即放弃精确范围,退回全局 fail-closed */
251
+ export const ROLLBACK_UNWALKED_DIRS_LIMIT = 200
197
252
 
198
253
  /**
199
254
  * 「删除回退点之后新建的路径」的**整套上限**(用户 2026-10-11 定案 A)。
@@ -227,6 +282,14 @@ export const ROLLBACK_PREVIEW_DIFF_TOTAL_WORK = 32_000_000
227
282
  export const ROLLBACK_FILE_DIFF_LIMITS = {
228
283
  maxFileBytes: ROLLBACK_PREVIEW_DIFF_MAX_FILE_BYTES,
229
284
  context: 3,
285
+ /**
286
+ * Myers 编辑距离上限(2026-10-15 用户:不该动辄出现「已按整块替换展示」)。
287
+ * 实测(本仓 rollback-point.ts 的 4983 行 × 2492 行版本对):D 在 3000~4000 就能拿到
288
+ * 最小编辑脚本 —— 耗时 ≤70 ms;代价是回溯的 V 快照 O(D²),RSS 峰值 +~100 MB。
289
+ * 缺省那档 400 会让几千行里的普通改动一律退化成「整块替换」,那正是用户看到的那条提示;
290
+ * 单文件是**按需点开**的一屏,这点内存换「真差异」划算。再大就换算法,不加预算。
291
+ */
292
+ maxD: 4000,
230
293
  } as const
231
294
 
232
295
  /** 恢复顺序(工作区在前;工作区失败即中止后面的会话段) */
@@ -359,7 +422,8 @@ export interface RollbackSkippedEntry {
359
422
  /**
360
423
  * link(符号链接/junction,不跟随)/ missing / not-a-file / self-store /
361
424
  * dep-dir(运行时依赖目录,整棵忽略)/ ignored(用户忽略规则命中)/ too-large(超单文件上限)/
362
- * budget-bytes(加它会超总量预算)/ budget-files(文件数预算用尽)
425
+ * budget-bytes(加它会超总量预算)/ budget-files(文件数预算用尽)/
426
+ * asar-container(含 `.asar` 段的路径:宿主会把它当容器解析、畸形夹具能带走宿主,因此绝不碰)
363
427
  */
364
428
  reason: string
365
429
  }
@@ -383,7 +447,28 @@ export interface RollbackPathIndex {
383
447
  export interface RollbackWorkspacePayload {
384
448
  /** 显式路径分块:恢复只在这些分块内覆盖 */
385
449
  chunks: string[]
450
+ /**
451
+ * 记录在案的**文件条目数**。**必须自带**(不能再用 `files.length`):增量视图下 `files` 只是
452
+ * 「相对基准新增/改写的子集」,而摘要(界面上的条数 / 可恢复判定)要的是全量条数。
453
+ */
454
+ fileCount?: number
386
455
  files: RollbackWorkspaceFile[]
456
+ /**
457
+ * **存盘专用**:这份载荷是「相对某个基准载荷的增量」(见 rollback-ledger 的 resolvePayload)。
458
+ * 只在写盘那一刻存在 —— 任何读路径(readRecord / 预览 / 回退 / 对象回收)拿到的都是**物化后**
459
+ * 的完整 `files`(`delta` 已被抹掉)。**绝不把带 delta 的对象交给消费者**:它看起来像一份
460
+ * 「只有这几条记录」的完整清单,拿去规划删除会删掉其它全部文件。
461
+ */
462
+ delta?: {
463
+ /** 基准载荷的内容寻址哈希(必须在 prune/回收时一并保活) */
464
+ base: string
465
+ /** 基准自己的依赖链(深→浅,含基准),保活用 */
466
+ baseChain: string[]
467
+ /** 相对基准**不再存在**的路径(逐字,与捕获产出的绝对路径同形) */
468
+ removed: string[]
469
+ /** 相对基准**新增 / 改写**的条目(`files` 在增量视图下为 null) */
470
+ added?: RollbackWorkspaceFile[]
471
+ }
387
472
  skipped: RollbackSkippedEntry[]
388
473
  /**
389
474
  * 整棵跳过的「运行时依赖目录」(忽略规则 = 全仓唯一来源 EXCLUDED_DEP_DIR_NAMES,当前 node_modules)。
@@ -422,6 +507,12 @@ export interface RollbackWorkspacePayload {
422
507
  * 老台账(本字段诞生前写下的点)一律没有它:按「名单完整」处理(与旧行为一致)。
423
508
  */
424
509
  walkIncomplete?: boolean
510
+ /**
511
+ * 走查被中断时**栈里还没进去的子树**(精确到目录)。有它 ⇒ 拒删只覆盖这些子树
512
+ * (分子树 fail-closed),而不是把整个工作区的删除全关掉;缺它(老台账 / 栈太深被截断)
513
+ * ⇒ 退回全局拒删。与 walkIncomplete 一起看:后者是「安全判据」,本字段是「精确范围」。
514
+ */
515
+ unwalkedDirs?: string[]
425
516
  /** 命中文件数 / 字节上限(内容未全部进来,已如实留痕) */
426
517
  truncated: boolean
427
518
  bytes: number
@@ -942,8 +1033,25 @@ export interface RollbackEngineOptions {
942
1033
  /**
943
1034
  * 工作区捕获的挂钟时间预算(毫秒,缺省 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。
944
1035
  * 超时留**部分点**,不拒绝留点;见该常量的说明。
1036
+ *
1037
+ * 运行期**自适应**:被截断 ⇒ 下次翻倍;用得很少 ⇒ 下次减半(区间见
1038
+ * ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS)。回退期走查的预算见 rewindBudgetMs。
945
1039
  */
946
1040
  captureBudgetMs?: number
1041
+ /** 自适应区间下界(缺省 ROLLBACK_MIN_CAPTURE_BUDGET_MS;测试可注入更小值) */
1042
+ captureBudgetMinMs?: number
1043
+ /** 自适应区间上界(缺省 ROLLBACK_MAX_CAPTURE_BUDGET_MS;测试可注入更小值) */
1044
+ captureBudgetMaxMs?: number
1045
+ /** 回退期走查的时间预算(缺省 = captureBudgetMs 初值;**不参与自适应**) */
1046
+ rewindBudgetMs?: number
1047
+ /** 捕获期读 + 写并发上限(缺省 ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY,1..64) */
1048
+ captureConcurrency?: number
1049
+ /**
1050
+ * 捕获期**在飞内容字节**上限(缺省 ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES = 32 MiB,
1051
+ * 有效范围 1..maxBytes)。与 `maxFileBytes`(单文件「要不要读」的判据)是两件事:
1052
+ * **这个才是并发的内存闸门**。
1053
+ */
1054
+ captureInFlightBytes?: number
947
1055
  guardPolicy?: RollbackGuardPolicy
948
1056
  /** 存储栈探针(缺省 = 真实磁盘探测);注入失败探针可测「缺失时仍可挂载」 */
949
1057
  storage?: RollbackStorageProbe
@@ -977,6 +1085,24 @@ function win32Fold(value: string): string {
977
1085
  return process.platform === 'win32' ? value.toLowerCase() : value
978
1086
  }
979
1087
 
1088
+ /**
1089
+ * 路径里是否存在以 `.asar` 结尾的段(**捕获 / 走查期一律不碰这类路径**)。
1090
+ *
1091
+ * 为什么(真机事故,0.1.76):Electron 宿主对任何含 `.asar` 段的路径**启用了 asar 虚拟化** ——
1092
+ * 即使那只是个普通文件,`fs.stat`/`readdir`/`read` 也会被接管去当容器解析。工作区里只要躺着一枚
1093
+ * **畸形** `.asar`(测试夹具、被打断的打包残留),宿主进程就会被原生层带走:crashpad
1094
+ * `not connected`、退出码 4294930435 ⇒ DSH Desktop 闪退(无 JS 栈、插件零错误)。
1095
+ * 实测(本仓真机捕获、同一份输入):`.tmp` 下 19 枚 `.asar` 夹具在场 → Electron 进程死;
1096
+ * 全部改名后 → 同一趟捕获 9 s 跑完、退出码 0。
1097
+ * `.asar.unpacked` 是**真目录**(段名不以 `.asar` 结尾)⇒ 不拦。
1098
+ */
1099
+ export function hasAsarSegment(target: string): boolean {
1100
+ for (const segment of target.split(/[\\/]+/)) {
1101
+ if (segment.length > '.asar'.length && win32Fold(segment).endsWith('.asar')) return true
1102
+ }
1103
+ return false
1104
+ }
1105
+
980
1106
  /** child 是否等于 parent 或位于其下(win32 折叠大小写;段边界判定) */
981
1107
  export function isInsidePath(child: string, parent: string): boolean {
982
1108
  const c = win32Fold(path.resolve(child))
@@ -989,6 +1115,87 @@ function pathKey(value: string): string {
989
1115
  return win32Fold(path.resolve(value))
990
1116
  }
991
1117
 
1118
+ /** 两个分块列表是否逐个相同(增量基准必须作用于同一批分块) */
1119
+ function sameChunks(a: readonly string[], b: readonly string[]): boolean {
1120
+ if (a.length !== b.length) return false
1121
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
1122
+ return true
1123
+ }
1124
+
1125
+ /**
1126
+ * **存盘视图**:条目里的 `object` 恒等于 `sha256`(内容寻址的名字就是内容哈希)⇒ 落盘时不写这个字段,
1127
+ * 读取时按 `sha256` 补回(见 ledger 的 `withObjectRefs`)。每条约省 1/5 字节。
1128
+ */
1129
+ function compactPayloadForStorage(payload: RollbackWorkspacePayload): RollbackWorkspacePayload {
1130
+ const strip = (list: RollbackWorkspaceFile[] | null | undefined): RollbackWorkspaceFile[] | null | undefined => {
1131
+ if (!Array.isArray(list)) return list
1132
+ return list.map((file) => {
1133
+ const copy: Record<string, unknown> = { ...file }
1134
+ delete copy['object']
1135
+ return copy as unknown as RollbackWorkspaceFile
1136
+ })
1137
+ }
1138
+ const delta = payload.delta
1139
+ return {
1140
+ ...payload,
1141
+ files: strip(payload.files) as RollbackWorkspaceFile[],
1142
+ ...(delta === undefined
1143
+ ? {}
1144
+ : { delta: { ...delta, ...(delta.added === undefined ? {} : { added: strip(delta.added) as RollbackWorkspaceFile[] }) } }),
1145
+ }
1146
+ }
1147
+
1148
+ /**
1149
+ * 构造「相对基准的增量视图」;不划算(≥ 全量 × {@link ROLLBACK_DELTA_MAX_RATIO})时返回 null。
1150
+ *
1151
+ * 条目**逐字比较**:路径、sha、字节数、mtimeMs、所属分块任一不同 ⇒ 进 `added`;基准里不再存在的路径进 `removed`。
1152
+ * 重建 = 基准 − removed,再用 added 覆盖/追加,最后按路径排序(与全量捕获同序)。
1153
+ */
1154
+ /** 基准摘要键:内容 + 字节数 + 所属分块(`mtimeMs` 不参与 —— 只被触摸、内容没变的文件不必进增量) */
1155
+ function digestKeyOf(file: RollbackWorkspaceFile): string {
1156
+ return String(file.sha256) + ':' + String(file.bytes) + ':' + String(file.chunk)
1157
+ }
1158
+
1159
+ /** 相对**内存基准摘要**的条目差异(新增/改写 / 不再存在);路径逐字比较,不读任何文件 */
1160
+ function payloadDiffAgainstDigest(
1161
+ payload: RollbackWorkspacePayload,
1162
+ baseEntries: ReadonlyMap<string, string>,
1163
+ ): { added: RollbackWorkspaceFile[]; removed: string[] } {
1164
+ const added: RollbackWorkspaceFile[] = []
1165
+ const nextPaths = new Set<string>()
1166
+ for (const file of payload.files) {
1167
+ nextPaths.add(file.path)
1168
+ const prev = baseEntries.get(file.path)
1169
+ if (prev === undefined || prev !== digestKeyOf(file)) added.push(file)
1170
+ }
1171
+ const removed: string[] = []
1172
+ for (const path of baseEntries.keys()) if (!nextPaths.has(path)) removed.push(path)
1173
+ return { added, removed }
1174
+ }
1175
+
1176
+
1177
+ /**
1178
+ * 构造「相对基准的增量视图」;不划算(≥ 全量 × {@link ROLLBACK_DELTA_MAX_RATIO})时返回 null。
1179
+ * 重建 = 基准 − removed,再用 added 覆盖/追加,最后按路径排序(与全量捕获同序)。
1180
+ */
1181
+ function buildPayloadDelta(
1182
+ payload: RollbackWorkspacePayload,
1183
+ baseHash: string,
1184
+ baseChain: readonly string[],
1185
+ diff: { added: RollbackWorkspaceFile[]; removed: string[] },
1186
+ ): RollbackWorkspacePayload | null {
1187
+ if (baseHash === '') return null
1188
+ const delta: RollbackWorkspacePayload = {
1189
+ ...payload,
1190
+ // **故意不是数组**:不认识 `delta` 的旧读取器会在形状校验这一步**拒绝这个点**(fail-closed),
1191
+ // 而不是把「增量那几条」当成完整清单去规划删除 —— 那会把留点时存在的文件全判成新建、全部删掉。
1192
+ files: null as unknown as RollbackWorkspaceFile[],
1193
+ delta: { base: baseHash, baseChain: [...baseChain], removed: diff.removed, added: diff.added },
1194
+ }
1195
+ if (JSON.stringify(delta).length >= JSON.stringify(payload).length * ROLLBACK_DELTA_MAX_RATIO) return null
1196
+ return delta
1197
+ }
1198
+
992
1199
  function errorDetail(error: unknown): string {
993
1200
  if (error instanceof Error) {
994
1201
  const code = (error as { code?: unknown }).code
@@ -1223,6 +1430,16 @@ export function resolveTurnBoundary(
1223
1430
  return { ok: true, boundarySeq: boundary.boundarySeq, endSeq: boundary.endSeq }
1224
1431
  }
1225
1432
 
1433
+ /**
1434
+ * 记录在案的文件条目数:优先用载荷自带的 `fileCount`(增量视图下 `files` 可能只是子集),
1435
+ * 老载荷没有该字段才回落到 `files.length`。
1436
+ */
1437
+ export function workspaceFileCount(payload: RollbackWorkspacePayload | null): number {
1438
+ if (payload === null) return 0
1439
+ if (typeof payload.fileCount === 'number' && Number.isFinite(payload.fileCount)) return payload.fileCount
1440
+ return payload.files.length
1441
+ }
1442
+
1226
1443
  export function summarizeRollbackPoint(record: RollbackPointRecord): RollbackPointSummary {
1227
1444
  const workspace = record.workspace.payload
1228
1445
  const session = record.session.payload
@@ -1262,7 +1479,16 @@ export function summarizeRollbackPoint(record: RollbackPointRecord): RollbackPoi
1262
1479
  note: record.note,
1263
1480
  spreadMs: record.spreadMs,
1264
1481
  segments: [
1265
- segment('workspace', record.workspace, workspace?.files.length ?? 0, workspace?.bytes ?? 0, null, (workspace?.files.length ?? 0) > 0, workspace?.skippedCount ?? 0, workspace?.truncated ?? false),
1482
+ segment(
1483
+ 'workspace',
1484
+ record.workspace,
1485
+ workspaceFileCount(workspace),
1486
+ workspace?.bytes ?? 0,
1487
+ null,
1488
+ workspaceFileCount(workspace) > 0,
1489
+ workspace?.skippedCount ?? 0,
1490
+ workspace?.truncated ?? false,
1491
+ ),
1266
1492
  segment(
1267
1493
  'session',
1268
1494
  record.session,
@@ -1451,6 +1677,59 @@ class UndoStager {
1451
1677
  }
1452
1678
  }
1453
1679
 
1680
+ /**
1681
+ * 单文件字节闸门:保证「同时在飞(已读入、尚未落仓)的内容字节」≤ limit。
1682
+ *
1683
+ * 与 captureConcurrency 一起构成捕获期的内存上界:并发只放大**同时**驻留的量,而它被
1684
+ * `captureInFlightBytes` 钉死(缺省 32 MiB)。**不能**用 maxFileBytes 当限额 —— 它缺省 =
1685
+ * 总量预算 512 MiB,「一份文件」与「整个点的预算」不是同一个量级(0.1.74 闪退根因)。
1686
+ */
1687
+ /**
1688
+ * 路径身份键:dev:ino:size:mtimeNs:ctimeNs:mode(全 BigInt 字符串化)。必须 BigInt:ino / mtimeNs /
1689
+ * ctimeNs 在 64 位 inode 文件系统上超出 Number 安全整数范围,用 Number 会静默丢精度(把两次不同的
1690
+ * 写当成同一个身份)。单一来源:fileStatOf 与 pathIdentityOf 都用它。
1691
+ */
1692
+ function fileIdentityOf(stat: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint; mode: bigint }): string {
1693
+ return [
1694
+ String(stat.dev),
1695
+ String(stat.ino),
1696
+ String(stat.size),
1697
+ String(stat.mtimeNs),
1698
+ String(stat.ctimeNs),
1699
+ String(stat.mode & 0o777n),
1700
+ ].join(':')
1701
+ }
1702
+
1703
+ /** 预览的逐条判定(阶段 A 并发产出;阶段 B 按记录顺序装配,语义与旧串行实现逐条一致) */
1704
+ type PreviewScanResult =
1705
+ | { kind: 'ok'; size: number; regular: boolean; unchanged: boolean }
1706
+ | { kind: 'missing' }
1707
+ | { kind: 'unavailable' }
1708
+
1709
+ class ByteGate {
1710
+ private used = 0
1711
+ private readonly waiters: (() => void)[] = []
1712
+ private readonly limit: number
1713
+
1714
+ constructor(limit: number) {
1715
+ this.limit = limit
1716
+ }
1717
+
1718
+ async acquire(bytes: number): Promise<void> {
1719
+ const need = Math.max(0, Math.min(bytes, this.limit))
1720
+ while (this.used + need > this.limit && this.used > 0) {
1721
+ await new Promise<void>((resolve) => { this.waiters.push(resolve) })
1722
+ }
1723
+ this.used += need
1724
+ }
1725
+
1726
+ release(bytes: number): void {
1727
+ this.used = Math.max(0, this.used - Math.max(0, Math.min(bytes, this.limit)))
1728
+ const next = this.waiters.shift()
1729
+ if (next !== undefined) next()
1730
+ }
1731
+ }
1732
+
1454
1733
  export class RollbackPointEngine {
1455
1734
  private readonly dataDir: string
1456
1735
  private readonly homeDir: string
@@ -1462,6 +1741,7 @@ export class RollbackPointEngine {
1462
1741
  private readonly maxFiles: number
1463
1742
  private readonly maxBytes: number
1464
1743
  private readonly maxFileBytes: number
1744
+ private readonly captureInFlightBytes: number
1465
1745
  private readonly maxSkippedEntries: number
1466
1746
  private readonly sessionMaxBytes: number
1467
1747
  private readonly sessionQuiescentMs: number
@@ -1493,14 +1773,42 @@ export class RollbackPointEngine {
1493
1773
  private forkLinksAt = 0
1494
1774
  private forkLinksComplete = true
1495
1775
  /** 会话日志头缓存(mtime + size 失效;只服务 fork 链扫描) */
1776
+ /**
1777
+ * **本进程**最近一次成功留点的工作区摘要(清单增量化的基准)。
1778
+ *
1779
+ * 为什么是内存而不是读盘:一次留点只准读清单(L-08 / issue #89)—— 读上一份 12 MB 载荷去算差异
1780
+ * 正是那条被消灭的路。内存量 ≈ 每文件一个 `sha:bytes:chunk` 串(3.4 万文件约几 MB),
1781
+ * 与已有的身份缓存(`pathCache`)同量级;进程重启后第一次留点落全量锚点,随后继续链。
1782
+ */
1783
+ private deltaBase: RollbackDeltaDigest | null = null
1784
+
1496
1785
  private readonly forkHeaderCache = new Map<string, { mtimeMs: number; size: number; header: SessionForkHeader | null }>()
1497
1786
  /** 继承判据用的台账快照(TTL 缓存;只缓存**清单条目**,绝不缓存完整记录) */
1498
1787
  private ledgerSnapshotCache: { at: number; entries: RollbackLedgerManifestEntry[] } | null = null
1499
1788
  /** 台账存储层(v3:清单 + 单条记录 + 内容寻址载荷;issue #89) */
1500
1789
  private readonly ledger: RollbackLedger
1501
1790
  private readonly ledgerBytesBudget: number
1502
- /** 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS) */
1503
- private readonly captureBudgetMs: number
1791
+ /**
1792
+ * 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
1793
+ * 每次留点后按「是否被截断 / 用掉多少」自适应(见 ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS)。
1794
+ */
1795
+ private captureBudgetMs: number
1796
+ /** 自适应区间下界 / 上界(构造期定死,运行期只调 captureBudgetMs) */
1797
+ private readonly captureBudgetMinMs: number
1798
+ private readonly captureBudgetMaxMs: number
1799
+ /** 回退期走查的时间预算(与捕获预算分开:自适应不该顺手拉长回退期的走查) */
1800
+ private readonly rewindBudgetMs: number
1801
+ /** 捕获期读 + 写并发上限 */
1802
+ private readonly captureConcurrency: number
1803
+ /** 对象仓目录已确认存在(省掉每个对象一次 mkdir) */
1804
+ private objectsDirReady = false
1805
+ /**
1806
+ * 本回合对象名单快照(一次 readdir 顶掉「每个候选一次 fs.access」;真机 34k 次 access ≈ 3.7 s)。
1807
+ * null = 读不到名单(首次还没建目录/异常)⇒ 回落逐条 objectExists。
1808
+ * 只保证「写进去的立刻可见」:捕获期间被**并发删除**的对象可能仍被判为存在 —— 那一路由
1809
+ * 读取侧(readObject + 哈希校验)响亮失败,不会静默给出错误的 before 内容。
1810
+ */
1811
+ private captureObjectNames: Set<string> | null = null
1504
1812
  /**
1505
1813
  * 启动期一次性初始化(流式迁移 v2 -> v3 + 孤儿 tmp 清理)的惰性单飞。
1506
1814
  * 为什么惰取而不是构造时同步跑:构造发生在每个请求路径上,同步扫盘/迁移会把请求卡死;
@@ -1522,6 +1830,10 @@ export class RollbackPointEngine {
1522
1830
  this.maxBytes = options.maxBytes ?? ROLLBACK_DEFAULT_MAX_BYTES
1523
1831
  // 单文件上限缺省 = 总量预算(一个文件不可能比整个点的预算更大)
1524
1832
  this.maxFileBytes = options.maxFileBytes ?? this.maxBytes
1833
+ // **内存闸门与单文件上限分开**:拿 maxFileBytes 当闸门 = 允许把整个点的预算同时驻留
1834
+ // (0.1.74 真机闪退的根因),见 ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES。
1835
+ const inFlight = options.captureInFlightBytes ?? ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES
1836
+ this.captureInFlightBytes = Math.max(1, Math.min(this.maxBytes, Math.floor(inFlight)))
1525
1837
  this.maxSkippedEntries = options.maxSkippedEntries ?? ROLLBACK_SKIPPED_LIST_LIMIT
1526
1838
  this.sessionMaxBytes = options.sessionMaxBytes ?? ROLLBACK_DEFAULT_SESSION_MAX_BYTES
1527
1839
  this.sessionQuiescentMs = options.sessionQuiescentMs ?? ROLLBACK_DEFAULT_SESSION_QUIESCENT_MS
@@ -1541,6 +1853,10 @@ export class RollbackPointEngine {
1541
1853
  this.log = options.log
1542
1854
  this.ledgerBytesBudget = options.ledgerBytesBudget ?? ROLLBACK_DEFAULT_LEDGER_BYTES
1543
1855
  this.captureBudgetMs = options.captureBudgetMs ?? ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS
1856
+ this.captureBudgetMinMs = options.captureBudgetMinMs ?? ROLLBACK_MIN_CAPTURE_BUDGET_MS
1857
+ this.captureBudgetMaxMs = options.captureBudgetMaxMs ?? ROLLBACK_MAX_CAPTURE_BUDGET_MS
1858
+ this.rewindBudgetMs = options.rewindBudgetMs ?? this.captureBudgetMs
1859
+ this.captureConcurrency = Math.max(1, Math.min(64, Math.floor(options.captureConcurrency ?? ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY)))
1544
1860
  this.ledger = new RollbackLedger({
1545
1861
  root: this.root,
1546
1862
  bytesBudget: this.ledgerBytesBudget,
@@ -1711,17 +2027,47 @@ export class RollbackPointEngine {
1711
2027
 
1712
2028
  /* -------------------------------------------------------------- 对象仓 */
1713
2029
 
2030
+ /** 对象仓目录只 mkdir 一次(每个对象各 mkdir 一次是纯白花的 syscall) */
2031
+ private async ensureObjectsDir(): Promise<void> {
2032
+ if (this.objectsDirReady) return
2033
+ await fs.mkdir(this.objectsDir, { recursive: true })
2034
+ this.objectsDirReady = true
2035
+ }
2036
+
2037
+ /**
2038
+ * 写内容寻址对象(对象文件名 = 内容 SHA-256)。
2039
+ *
2040
+ * **为什么这里不用 atomicWriteFile**:那是给「可写配置文件」用的强原子写(同目录 tmp → fsync →
2041
+ * rename),真机实测一次约 4.7 ms —— 34k 文件的工作区一次全量捕获 148 s 全花在它上面(走查本身
2042
+ * 只要 0.66 s)。对象仓的正确性要求不同:文件名即内容哈希 ⇒
2043
+ * · 用 'wx' **独占创建最终名**本身已是原子的(不覆盖已有对象;并发同写只有一方成功);
2044
+ * · 半截 / 被篡改的内容在读取侧必然过不了哈希校验(readObject + sha256Hex)⇒ 按「读不出」
2045
+ * 处理并 fail-closed,绝不会冒充有效前像;
2046
+ * · 不做 fsync:回滚点是尽力而为的本地快照,不为掉电持久性付出每文件一次 fsync 的代价。
2047
+ */
1714
2048
  private async writeObjectWithHash(bytes: Uint8Array, sha: string): Promise<string> {
1715
2049
  if (!OBJECT_KEY_RE.test(sha)) throw new Error('rollback object key invalid')
1716
2050
  const target = path.join(this.objectsDir, sha)
1717
2051
  if (!isInsidePath(target, this.objectsDir)) throw new Error('rollback object path escapes store')
1718
- await fs.mkdir(this.objectsDir, { recursive: true })
1719
- try {
1720
- await fs.access(target)
1721
- return sha
1722
- } catch { /* 不存在才写(内容寻址天然去重) */ }
1723
- await atomicWriteFile(target, bytes)
1724
- return sha
2052
+ await this.ensureObjectsDir()
2053
+ let delay = 10
2054
+ for (let attempt = 0; ; attempt += 1) {
2055
+ try {
2056
+ await fs.writeFile(target, bytes, { flag: 'wx' })
2057
+ this.captureObjectNames?.add(sha)
2058
+ return sha
2059
+ } catch (error) {
2060
+ const code = (error as { code?: unknown }).code
2061
+ if (code === 'EEXIST') return sha // 已有同内容对象(去重 / 并发竞写)
2062
+ // 杀软 / 句柄瞬时占用:有界退避重试(与 atomic-write 的判据同族)
2063
+ if (OBJECT_WRITE_RETRY_CODES.includes(code as string) && attempt < 3) {
2064
+ await new Promise<void>((resolve) => setTimeout(resolve, delay))
2065
+ delay *= 2
2066
+ continue
2067
+ }
2068
+ throw error
2069
+ }
2070
+ }
1725
2071
  }
1726
2072
 
1727
2073
  /** 对象仓里是否真有这个 blob(缓存命中时校验;任何异常一律按「没有」处理) */
@@ -1840,6 +2186,10 @@ export class RollbackPointEngine {
1840
2186
  /**
1841
2187
  * 列出一个显式分块内的候选文件(不跟随符号链接 / junction;跳过与读不到都留痕)。
1842
2188
  * 返回 null = 该分块本身不可用(不存在 / 是链接 / 非常规文件),细节已写进 sink。
2189
+ *
2190
+ * `truncated` 与 `walkAborted` 是**两件事**:前者只说「本次列表不完整」(文件数上限 / 提交上限),
2191
+ * 后者说「走查被预算或取消打断,栈里还有没进去的子树」—— 只有后者才意味着那些路径
2192
+ * **一条都没进任何名单**,调用方必须据此让删除集 fail-closed。
1843
2193
  */
1844
2194
  private async listChunkFiles(
1845
2195
  chunk: string,
@@ -1853,10 +2203,16 @@ export class RollbackPointEngine {
1853
2203
  ignore?: IgnoreRules,
1854
2204
  /**
1855
2205
  * 停止判据(捕获期传时间/取消预算;回退期的走查不传)—— 返回 true 立即停止遍历,
1856
- * 已走查的部分照常返回并置 truncated(证明名单因此不完整 ⇒ 删除集 fail-closed)。
2206
+ * 已走查的部分照常返回并置 truncated + walkAborted(栈里剩余的子树从未进门 ⇒ 由调用方据此 fail-closed;
2207
+ * 回退期不传它,故 walkAborted 恒 false)。
1857
2208
  */
1858
2209
  shouldStop?: () => boolean,
1859
- ): Promise<{ files: string[]; truncated: boolean } | null> {
2210
+ ): Promise<{ files: string[]; truncated: boolean; walkAborted: boolean; unwalkedDirs: string[] } | null> {
2211
+ // 分块根本身就是 `.asar` 段 ⇒ 一次 fs 调用都不发(见 hasAsarSegment)
2212
+ if (hasAsarSegment(chunk)) {
2213
+ this.recordSkip(sink, 'skipped', { path: chunk, reason: 'asar-container' })
2214
+ return null
2215
+ }
1860
2216
  let stat
1861
2217
  try {
1862
2218
  stat = await fs.lstat(chunk)
@@ -1868,16 +2224,25 @@ export class RollbackPointEngine {
1868
2224
  this.recordSkip(sink, 'skipped', { path: chunk, reason: 'link' })
1869
2225
  return null
1870
2226
  }
1871
- if (stat.isFile()) return { files: [chunk], truncated: false }
2227
+ if (stat.isFile()) return { files: [chunk], truncated: false, walkAborted: false, unwalkedDirs: [] }
1872
2228
  if (!stat.isDirectory()) {
1873
2229
  this.recordSkip(sink, 'skipped', { path: chunk, reason: 'not-a-file' })
1874
2230
  return null
1875
2231
  }
1876
2232
  const out: string[] = []
1877
2233
  let truncated = false
2234
+ // 走查**被预算 / 取消打断**:栈里还压着没进去的子树 —— 那些路径一条都没进任何名单
2235
+ let walkAborted = false
2236
+ // 中断时把**还没进去的子树**如实带出去 ⇒ 调用方可以只对这几棵子树 fail-closed(精确范围)
2237
+ let unwalkedDirs: string[] = []
1878
2238
  const stack: { dir: string; depth: number }[] = [{ dir: chunk, depth: 0 }]
1879
2239
  while (stack.length > 0) {
1880
- if (shouldStop?.() === true) { truncated = true; break }
2240
+ if (shouldStop?.() === true) {
2241
+ truncated = true
2242
+ walkAborted = true
2243
+ unwalkedDirs = stack.length > ROLLBACK_UNWALKED_DIRS_LIMIT ? [] : stack.map((item) => item.dir)
2244
+ break
2245
+ }
1881
2246
  const item = stack.pop()
1882
2247
  if (item === undefined) break
1883
2248
  if (item.depth >= MAX_WALK_DEPTH) {
@@ -1893,6 +2258,13 @@ export class RollbackPointEngine {
1893
2258
  }
1894
2259
  for (const entry of entries) {
1895
2260
  const full = path.join(item.dir, entry.name)
2261
+ // **绝不把含 `.asar` 段的路径交给 fs**(见 hasAsarSegment:宿主里这一步是原生解析,
2262
+ // 畸形夹具会让整个宿主进程消失)。留痕进 skipped —— 它是**证明名单**的一员,
2263
+ // 因此该路径绝不会被当成「留点后新建」删掉。
2264
+ if (hasAsarSegment(full)) {
2265
+ this.recordSkip(sink, 'skipped', { path: full, reason: 'asar-container' })
2266
+ continue
2267
+ }
1896
2268
  if (entry.isSymbolicLink()) {
1897
2269
  this.recordSkip(sink, 'skipped', { path: full, reason: 'link' })
1898
2270
  continue
@@ -1940,7 +2312,7 @@ export class RollbackPointEngine {
1940
2312
  }
1941
2313
  }
1942
2314
  out.sort()
1943
- return { files: out, truncated }
2315
+ return { files: out, truncated, walkAborted, unwalkedDirs }
1944
2316
  }
1945
2317
 
1946
2318
  private async captureWorkspace(
@@ -1954,6 +2326,8 @@ export class RollbackPointEngine {
1954
2326
  // 自动落成 capture-truncated,UI 走既有的 degraded 提示路径(零新增文案)。
1955
2327
  const deadline = capturedAt + this.captureBudgetMs
1956
2328
  const outOfBudget = (): boolean => signal?.aborted === true || this.stamp() >= deadline
2329
+ // 对象名单快照按回合重建(一次 readdir 换掉每个候选一次 fs.access)
2330
+ this.captureObjectNames = null
1957
2331
  const payload: RollbackWorkspacePayload = {
1958
2332
  chunks: [...chunks],
1959
2333
  files: [],
@@ -1973,115 +2347,58 @@ export class RollbackPointEngine {
1973
2347
  let sawChunk = false
1974
2348
  // 走查过的**全部**文件路径(含被预算 / 单文件上限挡下的)—— 路径索引的来源(方向 B)
1975
2349
  const walked: string[] = []
2350
+ // 走查中断时**没进去的子树**(精确到目录)—— 供删除集做分子树 fail-closed
2351
+ const unwalked: string[] = []
1976
2352
  for (const chunk of chunks) {
1977
2353
  if (outOfBudget()) {
1978
2354
  // 分块之间就超预算:不再开新分块,并如实留痕(该分块的条目一条都没被走查)
1979
- // walkIncomplete 是**安全判据**(删除集 fail-closed),漏了它会删掉未走查子树里的文件
2355
+ // walkIncomplete 是**安全判据**(删除集 fail-closed);这里能精确给出「没进去的是哪个分块」
2356
+ // ⇒ 删除只拒这棵子树,不再把整个工作区的新建文件一起放弃。
1980
2357
  payload.walkIncomplete = true
1981
2358
  payload.truncated = true
2359
+ unwalked.push(chunk)
1982
2360
  this.recordSkip(payload, 'skipped', { path: chunk, reason: 'budget-time' })
1983
2361
  break
1984
2362
  }
1985
2363
  if (!path.isAbsolute(chunk)) return this.emptyTrack('workspace', capturedAt, 'chunk-not-absolute', chunk)
1986
2364
  const resolved = path.resolve(chunk)
1987
2365
  // 文件数预算还剩多少就收多少;+1 只用于让遍历器发现「还有溢出」——溢出条目逐条记 skipped
1988
- const listed = await this.listChunkFiles(resolved, payload, Math.max(0, this.maxFiles - read) + 1, walked, await loadIgnoreRules(resolved), outOfBudget)
2366
+ // 候选名单上限取 ROLLBACK_CAPTURE_LIST_LIMIT(而不是 maxFiles):入点上限由阶段①的 slots
2367
+ // 把关,这里若提前截断,缺口优先排序就够不到尾部文件 ⇒ 覆盖率不收敛
2368
+ const listed = await this.listChunkFiles(resolved, payload, ROLLBACK_CAPTURE_LIST_LIMIT, walked, await loadIgnoreRules(resolved), outOfBudget)
1989
2369
  if (listed === null) continue
1990
2370
  sawChunk = true
1991
2371
  if (listed.truncated) payload.truncated = true
1992
- for (const file of listed.files) {
1993
- if (outOfBudget()) {
1994
- // 同上:中断 = 剩余路径从未进任何名单 ⇒ 必须让删除集 fail-closed
1995
- payload.walkIncomplete = true
1996
- payload.truncated = true
1997
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-time' })
1998
- break
1999
- }
2000
- if (read >= this.maxFiles) {
2001
- // 文件数预算耗尽:其后每一条都如实留痕(不再 break 静默丢弃)
2002
- payload.truncated = true
2003
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-files' })
2004
- continue
2005
- }
2006
- if (isInsidePath(file, this.root)) {
2007
- this.recordSkip(payload, 'skipped', { path: file, reason: 'self-store' })
2008
- continue
2009
- }
2010
- // **先 stat 判大小、再决定读不读**:单文件超限或加上它会超总量 → 跳过并如实留痕
2011
- // (旧实现的 `read > 0` 短路让**首个**文件完全不设上限,会被整份读进内存 —— t5-F1)
2012
- let stat
2013
- try {
2014
- stat = await fs.stat(file)
2015
- } catch (error) {
2016
- payload.unreadableFiles.push({ path: file, reason: errorDetail(error) })
2017
- continue
2018
- }
2019
- if (!stat.isFile()) {
2020
- this.recordSkip(payload, 'skipped', { path: file, reason: 'not-a-file' })
2021
- continue
2022
- }
2023
- if (stat.size > this.maxFileBytes) {
2024
- payload.truncated = true
2025
- this.recordSkip(payload, 'skipped', { path: file, reason: 'too-large' })
2026
- continue
2027
- }
2028
- if (payload.bytes + stat.size > this.maxBytes) {
2029
- payload.truncated = true
2030
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' })
2031
- continue
2032
- }
2033
- try {
2034
- // 路径身份缓存:身份(dev:ino:size:mtimeNs:ctimeNs:mode)未变 ⇒ 复用已存 sha256,**不重读**
2035
- const identity = await this.pathIdentityOf(file)
2036
- const candidate = identity === null ? null : await this.lookupCachedFile(identity, file)
2037
- // **F-2(t12 修复)**:缓存命中也要确认对象仓里真有那个 blob —— 对象被外部清掉后
2038
- // 若直接复用 sha256,新点会「零对象却 canRestore=true」,预览骗人(执行才响亮失败)。
2039
- // 对象不在 ⇒ 当作未命中:真读 + 重写对象(缓存因此自愈)。
2040
- const cached = candidate === null || !(await this.objectExists(candidate.sha256)) ? null : candidate
2041
- const data = cached === null ? await this.readFileBytes(file) : null
2042
- const size = cached === null ? (data?.byteLength ?? 0) : cached.bytes
2043
- if (cached === null && data !== null && data.byteLength !== size) {
2044
- payload.truncated = true
2045
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' })
2046
- continue
2047
- }
2048
- if (cached !== null) {
2049
- payload.files.push({ path: file, chunk: resolved, bytes: cached.bytes, sha256: cached.sha256, mtimeMs: stat.mtimeMs, object: cached.sha256 })
2050
- payload.bytes += cached.bytes
2051
- read += 1
2052
- continue
2053
- }
2054
- if (data === null) {
2055
- payload.unreadableFiles.push({ path: file, reason: 'read-failed' })
2056
- continue
2057
- }
2058
- // stat 与 read 之间文件可能变化:真读到的字节数仍必须服从总量预算(不猜、不静默)
2059
- if (payload.bytes + data.byteLength > this.maxBytes) {
2060
- payload.truncated = true
2061
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' })
2062
- continue
2063
- }
2064
- const sha = sha256Hex(data)
2065
- await this.writeObjectWithHash(data, sha)
2066
- if (identity !== null) this.rememberCachedFile(identity, file, sha, data.byteLength)
2067
- payload.files.push({ path: file, chunk: resolved, bytes: data.byteLength, sha256: sha, mtimeMs: stat.mtimeMs, object: sha })
2068
- payload.bytes += data.byteLength
2069
- read += 1
2070
- } catch (error) {
2071
- payload.unreadableFiles.push({ path: file, reason: errorDetail(error) })
2072
- }
2372
+ // 走查本身被打断 ⇒ 栈里还有没进去的子树(那些路径从未进任何名单)⇒ fail-closed。
2373
+ // **区别于**下面「走查已结束、只是读到一半超时」:后者的路径全在 walked 里,
2374
+ // 由文件末尾的覆盖自检补进证明名单(补不齐才拒删),不该整体拒删。
2375
+ if (listed.walkAborted) {
2376
+ payload.walkIncomplete = true
2377
+ for (const dir of listed.unwalkedDirs) unwalked.push(dir)
2073
2378
  }
2379
+ read += await this.captureChunkCandidates(resolved, listed.files, Math.max(0, this.maxFiles - read), payload, outOfBudget)
2074
2380
  }
2381
+ // 精确的「没进去的子树」:有 ⇒ 删除集只在这些子树里 fail-closed;没有(老台账 / 栈被截断)
2382
+ // ⇒ 退回全局拒删(见 planCreatedRemovals)
2383
+ if (unwalked.length > 0) payload.unwalkedDirs = [...new Set(unwalked)].sort()
2384
+
2075
2385
  // `truncated` 为真时**不能**判 chunk-not-found:那是「分块确实存在、只是没来得及走查完」
2076
2386
  // (时间预算 / 取消信号)—— 报成「找不到分块」会让用户以为工作区配错了。
2077
2387
  if (!sawChunk && payload.files.length === 0 && !payload.truncated) {
2078
2388
  return this.emptyTrack('workspace', capturedAt, 'chunk-not-found', chunks.join(' '))
2079
2389
  }
2080
- // **方向 B**:逐条跳过名单被记录上限截断(skippedTruncated)时,把走查过的全部路径另存成
2081
- // 路径索引对象 —— 「未记录」与「留点时就存在」只有在名单完整时才可区分。
2082
- // 只有这一种情形需要它:未截断时 files ∪ skipped 本身就是完整名单(零额外成本)。
2083
- // 写失败绝不改判留点成败:skippedTruncated 仍为 true ⇒ 回退侧 fail-closed 一条都不删(R-05f)。
2084
- if (payload.skippedTruncated) {
2390
+ // **方向 B**:把走查过的全部路径另存成路径索引对象 —— 「未记录」与「留点时就存在」只有在
2391
+ // 证明名单完整时才可区分。触发条件是**覆盖自检**(不是「跳过名单被截断」这一种特例):
2392
+ // · 逐条跳过名单被记录上限截断(skippedTruncated);或
2393
+ // · 读到一半被时间预算 / 取消打断 —— 本分块剩余文件进了 walked、却一条都没进任何名单。
2394
+ // 覆盖完整时不必写(未截断时 files ∪ skipped 本身就是完整名单,零额外成本)。
2395
+ // 写失败 ⇒ 证明名单补不齐 ⇒ 必须整体拒删(安全方向;R-05f)。
2396
+ const proven = new Set<string>()
2397
+ for (const file of payload.files) proven.add(pathKey(file.path))
2398
+ for (const entry of payload.skipped) proven.add(pathKey(entry.path))
2399
+ for (const entry of payload.unreadableFiles) proven.add(pathKey(entry.path))
2400
+ const uncovered = walked.some((item) => !proven.has(pathKey(item)))
2401
+ if (uncovered) {
2085
2402
  try {
2086
2403
  const paths = [...new Set(walked)].sort()
2087
2404
  const gz = gzipSync(Buffer.from(paths.join('\n'), 'utf8'))
@@ -2090,6 +2407,8 @@ export class RollbackPointEngine {
2090
2407
  payload.pathIndex = { object, count: paths.length, bytes: gz.byteLength }
2091
2408
  } catch (error) {
2092
2409
  this.warn('rollback path index failed: ' + errorDetail(error))
2410
+ // 走查过、却没进名单的路径无法被证明 ⇒ 删除集整体拒绝(绝不「删一半」)
2411
+ payload.walkIncomplete = true
2093
2412
  }
2094
2413
  }
2095
2414
  // 用户忽略的整目录 ⇒ 证明名单不完整(删除集整体拒绝;见 planCreatedRemovals)。
@@ -2098,6 +2417,8 @@ export class RollbackPointEngine {
2098
2417
  payload.ignoreIncomplete = payload.ignoredDirCount > 0
2099
2418
  // 路径身份缓存落盘(best-effort:写不进去只意味着下次全量读,绝不影响留点成败)
2100
2419
  await this.persistPathCache()
2420
+ // 落点列表排序:并发读让入点顺序不定,契约必须是「按路径有序」(预览 / 对账 / 指纹都靠它)
2421
+ payload.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
2101
2422
  // 留痕列表也排序:跨分块 / 依赖 readdir 顺序时输出仍确定(测试与对账都能逐条比)
2102
2423
  payload.skipped.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
2103
2424
  payload.excludedDirs.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
@@ -2105,9 +2426,24 @@ export class RollbackPointEngine {
2105
2426
  const canonical = [...payload.files]
2106
2427
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
2107
2428
  .map((f) => [f.path, f.bytes, f.sha256])
2429
+ payload.fileCount = payload.files.length
2108
2430
  const fingerprint = sha256Hex(JSON.stringify({ chunks: [...payload.chunks].sort(), files: canonical }))
2431
+ // 自适应预算(见 ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS):被截断 ⇒ 下次翻倍(上限);
2432
+ // 用得很少 ⇒ 下次减半(下限)。大仓库第一次撞上限后自动拿到更大预算,小仓库自动回落,
2433
+ // 不会每回合白等。回退期的走查预算(rewindBudgetMs)**不参与**这套调整。
2434
+ if (payload.truncated || payload.walkIncomplete === true) {
2435
+ this.captureBudgetMs = Math.min(this.captureBudgetMaxMs, this.captureBudgetMs * 2)
2436
+ } else if (this.stamp() - capturedAt < this.captureBudgetMs / 4) {
2437
+ this.captureBudgetMs = Math.max(this.captureBudgetMinMs, Math.floor(this.captureBudgetMs / 2))
2438
+ }
2439
+ // 「降级(留得不完整)」只由**真的缺内容**触发:
2440
+ // ① truncated —— 文件数 / 总字节 / 单文件上限 / 时间预算挡下的内容;
2441
+ // ② unreadableDirs / unreadableFiles —— 读不出来或捕获竞态(内容不可信)。
2442
+ // **不再用 skippedCount**:那里的条目大多是**设计内**的跳过(符号链接不跟随、文件级忽略、
2443
+ // 自身数据区、读之前就消失),既不缺内容也无法回退 —— 旧口径让「工作区里有任何一个符号链接」
2444
+ // 的仓库每个点都挂「回退可能不完整」,真出问题时反而被噪声淹没(真机 2026-10-15:10 个 link
2445
+ // 跳过 ⇒ 每个点 degraded=true)。条目与计数仍逐条留痕(skippedCount / skipped 列表不变)。
2109
2446
  const degraded = payload.truncated
2110
- || payload.skippedCount > 0
2111
2447
  || payload.unreadableDirs.length > 0
2112
2448
  || payload.unreadableFiles.length > 0
2113
2449
  return {
@@ -2125,6 +2461,202 @@ export class RollbackPointEngine {
2125
2461
  /* ---------------------------------------------------------- 忽略规则 / 路径身份缓存 */
2126
2462
 
2127
2463
  /** 用户忽略规则是否命中该路径(只认「相对分块根」的斜杠路径) */
2464
+ /**
2465
+ * 处理一个分块的候选文件(**两阶段**)。
2466
+ *
2467
+ * ① **顺序判定**(与旧的串行实现逐条一致,保证预算 / 计数 / 字节上限完全确定):
2468
+ * 文件数上限、时间预算、自身数据区、stat、单文件上限、总量上限、身份缓存 + 对象存在性
2469
+ * → 得出「当场入点(缓存命中)」还是「要真读」,该跳过的**当场**按原有 reason 逐条留痕。
2470
+ * stat 按**有序批**并发取(判定仍严格按顺序),并用「一次 readdir 的对象名单快照」代替
2471
+ * 每个文件一次 fs.access —— 这两处是稳态成本的大头(真机实测各占 ~5 s / ~3.7 s)。
2472
+ * ② **并发读 + 哈希 + 落对象仓**:这是捕获耗时的**唯一大头**(真机 148 s / 31,702 个对象,
2473
+ * 96% 的时间),用 captureConcurrency 个 worker 并发跑;并用**在飞字节闸门**保证
2474
+ * 「同时在飞的内容字节 ≤ captureInFlightBytes」。**不是** maxFileBytes —— 它缺省 =
2475
+ * 总量预算(512 MiB),拿它当闸门等于允许把整个点的预算同时驻留(0.1.74 闪退根因)。
2476
+ *
2477
+ * 返回本分块**真正入点**的文件数(调用方据此推进 maxFiles 预算)。
2478
+ * 没判到 / 没读完的条目一律逐条进 skipped(budget-time),绝不静默丢弃。
2479
+ */
2480
+ private async captureChunkCandidates(
2481
+ chunk: string,
2482
+ candidates: readonly string[],
2483
+ slots: number,
2484
+ payload: RollbackWorkspacePayload,
2485
+ outOfBudget: () => boolean,
2486
+ ): Promise<number> {
2487
+ // 缺口优先:身份缓存里还没有的路径排前面 ⇒ 每个点都在补上次没覆盖的部分(见 orderByCoverage)
2488
+ const ordered = await this.orderByCoverage(candidates)
2489
+ const decided = new Array<boolean>(ordered.length).fill(false)
2490
+ interface ReadPlan { path: string; size: number; mtimeMs: number; identity: string | null }
2491
+ const plans: ReadPlan[] = []
2492
+ let captured = 0
2493
+ let newReads = 0
2494
+ // 字节预算必须在**准入那一刻**按 stat.size 预留:真正入点发生在并发的第二阶段,
2495
+ // 若拿 payload.bytes 当判据,同一个分块里后面的文件会全部误判成「还有额度」
2496
+ let accounted = payload.bytes
2497
+ // 对象名单快照:一次 readdir 顶掉「每个候选一次 fs.access」(读不到则回落逐条判断)
2498
+ const names = await this.objectNamesSnapshot()
2499
+
2500
+ let cursor = 0
2501
+ while (cursor < ordered.length) {
2502
+ if (outOfBudget()) break // 剩余条目由收尾循环逐条记 budget-time(不预取 stat)
2503
+ const window = ordered.slice(cursor, cursor + ROLLBACK_CAPTURE_STAT_BATCH)
2504
+ const stats = await Promise.all(window.map((file) => this.fileStatOf(file)))
2505
+ let broke = false
2506
+ for (let offset = 0; offset < window.length; offset += 1) {
2507
+ const file = window[offset] ?? ''
2508
+ const info: RollbackFileStat = stats[offset] ?? { isFile: false, size: 0, mtimeMs: 0, identity: null, error: 'stat-missing' }
2509
+ const index = cursor + offset
2510
+ if (outOfBudget()) { broke = true; break }
2511
+ decided[index] = true
2512
+ if (isInsidePath(file, this.root)) {
2513
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'self-store' })
2514
+ continue
2515
+ }
2516
+ // **先 stat 判大小、再决定读不读**:单文件超限或加上它会超总量 → 跳过并如实留痕
2517
+ // (旧实现的 read > 0 短路让**首个**文件完全不设上限,会被整份读进内存 —— t5-F1)
2518
+ if (info.error !== undefined) {
2519
+ payload.unreadableFiles.push({ path: file, reason: info.error })
2520
+ continue
2521
+ }
2522
+ if (!info.isFile) {
2523
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'not-a-file' })
2524
+ continue
2525
+ }
2526
+ if (info.size > this.maxFileBytes) {
2527
+ payload.truncated = true
2528
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'too-large' })
2529
+ continue
2530
+ }
2531
+ // 路径身份缓存:身份(dev:ino:size:mtimeNs:ctimeNs:mode)未变 ⇒ 复用已存 sha256,**不重读**
2532
+ const candidate = info.identity === null ? null : await this.lookupCachedFile(info.identity, file)
2533
+ // **F-2(t12 修复)**:缓存命中也要确认对象仓里真有那个 blob —— 对象被外部清掉后
2534
+ // 若直接复用 sha256,新点会「零对象却 canRestore=true」,预览骗人(执行才响亮失败)。
2535
+ // 对象不在 ⇒ 当作未命中:真读 + 重写对象(缓存因此自愈)。
2536
+ const objectPresent = candidate === null
2537
+ ? false
2538
+ : names !== null ? names.has(candidate.sha256) : await this.objectExists(candidate.sha256)
2539
+ if (candidate !== null && objectPresent) {
2540
+ // 身份未变 ⇒ 内容必然与缓存一致:零 I/O 直接入点(稳态成本与仓库大小无关的来源)。
2541
+ // **不占读预算**:maxFiles / maxBytes 约束的是「这回合新读进来的内容」,不是「记录多少条」
2542
+ // —— 否则大仓库每个点都只能记下前 N 条,滚回某点时会漏掉其余文件(覆盖率永远不收敛)。
2543
+ payload.files.push({
2544
+ path: file, chunk, bytes: candidate.bytes, sha256: candidate.sha256, mtimeMs: info.mtimeMs, object: candidate.sha256,
2545
+ })
2546
+ payload.bytes += candidate.bytes
2547
+ captured += 1
2548
+ continue
2549
+ }
2550
+ // 只有**要真读的新内容**才受每回合的规模预算约束(判定仍严格按顺序 ⇒ 结果确定)
2551
+ if (newReads >= slots) {
2552
+ payload.truncated = true
2553
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-files' })
2554
+ continue
2555
+ }
2556
+ if (accounted + info.size > this.maxBytes) {
2557
+ payload.truncated = true
2558
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' })
2559
+ continue
2560
+ }
2561
+ accounted += info.size
2562
+ newReads += 1
2563
+ plans.push({ path: file, size: info.size, mtimeMs: info.mtimeMs, identity: info.identity })
2564
+ }
2565
+ cursor += window.length
2566
+ if (broke) break
2567
+ }
2568
+
2569
+ if (plans.length > 0) {
2570
+ // ② 并发读 + 落对象仓。outOfBudget 一命中就**不再开新条目**(在飞的照常收尾),
2571
+ // 未开工的条目在循环外逐条记 budget-time —— 与串行版「中断处逐条留痕」同一诚实口径。
2572
+ const gate = new ByteGate(this.captureInFlightBytes)
2573
+ const done: boolean[] = new Array<boolean>(plans.length).fill(false)
2574
+ let planCursor = 0
2575
+ let stopped = false
2576
+ const workers = Array.from({ length: Math.min(this.captureConcurrency, plans.length) }, async () => {
2577
+ for (;;) {
2578
+ if (stopped) return
2579
+ const index = planCursor
2580
+ planCursor += 1
2581
+ if (index >= plans.length) return
2582
+ const item = plans[index] as ReadPlan
2583
+ if (outOfBudget()) { stopped = true; return }
2584
+ await gate.acquire(item.size)
2585
+ try {
2586
+ if (outOfBudget()) { stopped = true; return }
2587
+ const data = await this.readFileBytes(item.path)
2588
+ if (data.byteLength !== item.size) {
2589
+ // stat 与 read 之间文件变了(旧口径:budget-bytes)—— 绝不按旧字节数记账
2590
+ payload.truncated = true
2591
+ this.recordSkip(payload, 'skipped', { path: item.path, reason: 'budget-bytes' })
2592
+ done[index] = true
2593
+ continue
2594
+ }
2595
+ // **捕获竞态**:读之前拿到的身份若在读完时已变,说明这次读到的内容未必是「留点时刻」
2596
+ // 的内容(回合与留点并发)⇒ 不当作前像,只如实进 unreadableFiles(它本身是**证明名单**
2597
+ // 的一员:证明「留点时存在」,因此绝不会被当成新建删掉)。
2598
+ const after = item.identity === null ? null : await this.pathIdentityOf(item.path)
2599
+ if (item.identity !== null && after !== item.identity) {
2600
+ payload.unreadableFiles.push({ path: item.path, reason: 'changed-during-read' })
2601
+ done[index] = true
2602
+ continue
2603
+ }
2604
+ const sha = sha256Hex(data)
2605
+ await this.writeObjectWithHash(data, sha)
2606
+ if (item.identity !== null) this.rememberCachedFile(item.identity, item.path, sha, data.byteLength)
2607
+ payload.files.push({
2608
+ path: item.path, chunk, bytes: data.byteLength, sha256: sha, mtimeMs: item.mtimeMs, object: sha,
2609
+ })
2610
+ payload.bytes += data.byteLength
2611
+ captured += 1
2612
+ done[index] = true
2613
+ } catch (error) {
2614
+ payload.unreadableFiles.push({ path: item.path, reason: errorDetail(error) })
2615
+ done[index] = true
2616
+ } finally {
2617
+ gate.release(item.size)
2618
+ }
2619
+ }
2620
+ })
2621
+ await Promise.all(workers)
2622
+ for (let index = 0; index < plans.length; index += 1) {
2623
+ if (done[index] === true) continue
2624
+ payload.truncated = true
2625
+ this.recordSkip(payload, 'skipped', { path: (plans[index] as ReadPlan).path, reason: 'budget-time' })
2626
+ }
2627
+ }
2628
+
2629
+ // 阶段 ① 里没判定到的(时间预算打断)同样逐条留痕 —— 覆盖自检因此仍认为名单完整
2630
+ for (let index = 0; index < ordered.length; index += 1) {
2631
+ if (decided[index] === true) continue
2632
+ payload.truncated = true
2633
+ this.recordSkip(payload, 'skipped', { path: ordered[index] ?? '', reason: 'budget-time' })
2634
+ }
2635
+ // 返回值 = 本分块消耗掉的**读槽位**(缓存命中不占槽位;调用方据此推进 maxFiles)
2636
+ return newReads
2637
+ }
2638
+
2639
+ /**
2640
+ * 缺口优先排序:身份缓存里**没有**的路径排前面。
2641
+ *
2642
+ * 为什么:预算固定时按目录顺序走查,同一个「已覆盖且便宜」的前缀每回合都会吃掉全部预算,
2643
+ * 尾部永远进不来(真机:34k 文件每次只覆盖 1/5,且五次都是同一批 outputs/ 前缀)。
2644
+ * 未覆盖的排前面 ⇒ 每个点都在补上一次的缺口,覆盖率随回合**单调收敛**到 100%。
2645
+ * 组内保持走查顺序(稳定),因此同一输入下的结果仍然确定。
2646
+ */
2647
+ private async orderByCoverage(candidates: readonly string[]): Promise<string[]> {
2648
+ if (candidates.length <= 1) return [...candidates]
2649
+ const cache = await this.loadPathCache()
2650
+ if (cache.size === 0) return [...candidates]
2651
+ const covered = new Set<string>()
2652
+ for (const entry of cache.values()) covered.add(pathKey(entry.path))
2653
+ if (covered.size === 0) return [...candidates]
2654
+ const cold: string[] = []
2655
+ const hot: string[] = []
2656
+ for (const file of candidates) (covered.has(pathKey(file)) ? hot : cold).push(file)
2657
+ return cold.length === 0 ? hot : [...cold, ...hot]
2658
+ }
2659
+
2128
2660
  private ignoredByRules(rules: IgnoreRules, target: string, isDir: boolean): boolean {
2129
2661
  const rel = ignoreRelativePath(rules.root, target)
2130
2662
  return rel === null ? false : ignoreRuleMatches(rules, rel, isDir)
@@ -2143,18 +2675,41 @@ export class RollbackPointEngine {
2143
2675
  * 用毫秒换算同样不行:会把「同一毫秒内的两次写」当成同一个身份。
2144
2676
  * 读不到身份(文件消失 / 权限 / 平台不支持 bigint stat)→ null ⇒ 回落全量真读。
2145
2677
  */
2678
+ /**
2679
+ * 一次 bigint stat 拿到阶段①需要的**全部**东西(是否文件 / 大小 / mtime / 身份)。
2680
+ * 旧实现「fs.stat + pathIdentityOf」每文件 stat 两次,真机 34k 文件白花约 5 s。
2681
+ */
2682
+ private async fileStatOf(target: string): Promise<RollbackFileStat> {
2683
+ try {
2684
+ const stat = await fs.stat(target, { bigint: true })
2685
+ const isFile = stat.isFile()
2686
+ return {
2687
+ isFile,
2688
+ size: Number(stat.size),
2689
+ mtimeMs: Number(stat.mtimeMs),
2690
+ identity: isFile ? fileIdentityOf(stat) : null,
2691
+ }
2692
+ } catch (error) {
2693
+ return { isFile: false, size: 0, mtimeMs: 0, identity: null, error: errorDetail(error) }
2694
+ }
2695
+ }
2696
+
2697
+ /** 对象名单快照(每个捕获回合一次 readdir);读不到则返回 null ⇒ 调用方回落逐条判断 */
2698
+ private async objectNamesSnapshot(): Promise<Set<string> | null> {
2699
+ if (this.captureObjectNames !== null) return this.captureObjectNames
2700
+ try {
2701
+ this.captureObjectNames = new Set(await fs.readdir(this.objectsDir))
2702
+ } catch {
2703
+ this.captureObjectNames = null
2704
+ }
2705
+ return this.captureObjectNames
2706
+ }
2707
+
2146
2708
  private async pathIdentityOf(target: string): Promise<string | null> {
2147
2709
  try {
2148
2710
  const stat = await fs.stat(target, { bigint: true })
2149
2711
  if (!stat.isFile()) return null
2150
- return [
2151
- String(stat.dev),
2152
- String(stat.ino),
2153
- String(stat.size),
2154
- String(stat.mtimeNs),
2155
- String(stat.ctimeNs),
2156
- String(stat.mode & 0o777n),
2157
- ].join(':')
2712
+ return fileIdentityOf(stat)
2158
2713
  } catch {
2159
2714
  return null
2160
2715
  }
@@ -2193,6 +2748,25 @@ export class RollbackPointEngine {
2193
2748
  }
2194
2749
 
2195
2750
  /** 缓存命中:身份键存在、且路径逐字相同(身份被复用也绝不认)。 */
2751
+ /**
2752
+ * 「这条记录的当前内容**仍然是它**吗」的身份快路径 —— **预览与执行共用同一条判据**
2753
+ * (否则「预览说会改的 = 实际会改的」这条不变式会被打破)。
2754
+ *
2755
+ * 判据:路径身份(dev:ino:size:mtimeNs:ctimeNs:mode)命中**身份缓存**,且缓存里的 sha/bytes 与记录一致。
2756
+ * 身份未变 ⇒ 内容未变:缓存里的 sha 就是**这份内容**在留点那一次真读出来的哈希(身份含 size/mtimeNs/
2757
+ * ctimeNs/ino/dev/mode,改内容必动 mtime/ctime,Windows 上 user 代码改不了 ctime)。
2758
+ * 任何一环拿不到(读不到身份 / 缓存没有该身份 / sha 或 bytes 不同)⇒ false,调用方回落「真读 + 哈希」。
2759
+ *
2760
+ * 为什么必须有它:大仓库一次预览/回退要判 3 万+ 条目,逐个真读等于把整个工作区读两遍
2761
+ * (真机 34k 文件 / 609 MiB:预览 14.2 s 里 13 s 全在这,回退 60 s+ 同理)。**记录的指纹被篡改**
2762
+ * 时本判据必然不成立(缓存的 sha 是真算出来的)⇒ 仍走真读 + 哈希的旧路径,护栏不退化。
2763
+ */
2764
+ private async unchangedByIdentity(file: RollbackWorkspaceFile, identity: string | null): Promise<boolean> {
2765
+ if (identity === null) return false
2766
+ const cached = await this.lookupCachedFile(identity, file.path)
2767
+ return cached !== null && cached.sha256 === file.sha256 && cached.bytes === file.bytes
2768
+ }
2769
+
2196
2770
  private async lookupCachedFile(identity: string, target: string): Promise<{ sha256: string; bytes: number } | null> {
2197
2771
  const cache = await this.loadPathCache()
2198
2772
  const hit = cache.get(identity)
@@ -2444,6 +3018,71 @@ export class RollbackPointEngine {
2444
3018
  * 诚实的边界:检测与写入之间仍有极小窗口,无跨进程锁时无法彻底消除 —— 这里保证的是
2445
3019
  * 「不静默覆盖 + 尽量合并」,不是原子性。
2446
3020
  */
3021
+ /**
3022
+ * 把工作区载荷改写成**增量视图**(相对最近一个同分块的点)——清单增量化,省的是磁盘,不是语义。
3023
+ *
3024
+ * 判据与安全边界(逐条):
3025
+ * · 基准 = 清单里最近一个 `workspace.payload` 非空、**分块完全相同**的点(只试最近几个,代价有界);
3026
+ * · 增量 = 「相对基准新增 / 改写的条目」+「基准里不再存在的路径」,条目**逐字比较**(同形绝对路径);
3027
+ * · 只有增量明显更小(< 全量的 60%)才用;否则原样全量落盘;
3028
+ * · 读路径由 ledger 物化(`resolvePayload`)⇒ 预览 / 回退 / 回收看到的永远是完整清单;
3029
+ * · 基准读不出来 / 分块对不上 / 任何异常 ⇒ 原样全量(绝不因为省字节而让点不可用);
3030
+ * · 基准载荷的保活由 ledger 的 `payloadChain` 负责(淘汰时不会把基准删掉)。
3031
+ */
3032
+ private async storagePlanFor(
3033
+ record: RollbackPointRecord,
3034
+ entries: readonly RollbackLedgerManifestEntry[],
3035
+ ): Promise<{ record: RollbackPointRecord; reuse: { hash: string; bytes: number; chain: string[] } | null }> {
3036
+ const full = { record, reuse: null }
3037
+ const payload = record.workspace.payload
3038
+ if (payload === null || record.workspace.ok !== true || payload.files.length === 0) return full
3039
+ if (payload.delta !== undefined) return full
3040
+ // 基准 = **本进程上一次成功留点**的内存摘要(绝不读别人的载荷/记录文件:L-08 / issue #89)。
3041
+ // 没有摘要(进程刚起 / 分块变了)⇒ 这一次落**全量锚点**,随后由摘要继续链下去。
3042
+ const base = this.deltaBase
3043
+ if (base !== null && sameChunks(base.chunks, payload.chunks)) {
3044
+ const diff = payloadDiffAgainstDigest(payload, base.entries)
3045
+ if (diff.added.length === 0 && diff.removed.length === 0) {
3046
+ // 工作区与该点**逐条相同** ⇒ 直接复用它的载荷文件(零额外字节、链不增长)
3047
+ const entry = entries.find((candidate) => candidate.workspace.payload === base.hash)
3048
+ if (entry !== undefined && (await this.ledger.hasPayload(base.hash))) {
3049
+ return { record, reuse: { hash: base.hash, bytes: entry.workspace.payloadBytes, chain: [...base.chain] } }
3050
+ }
3051
+ } else if (base.chain.length < ROLLBACK_DELTA_CHAIN_LIMIT) {
3052
+ const delta = buildPayloadDelta(payload, base.hash, base.chain, diff)
3053
+ if (delta !== null) {
3054
+ return {
3055
+ record: { ...record, workspace: { ...record.workspace, payload: compactPayloadForStorage(delta) } },
3056
+ reuse: null,
3057
+ }
3058
+ }
3059
+ }
3060
+ }
3061
+ // 没有可用基准 / 分块不同 / 增量不划算 ⇒ 全量落盘(仍省掉冗余的 object 字段)
3062
+ return {
3063
+ record: { ...record, workspace: { ...record.workspace, payload: compactPayloadForStorage(payload) } },
3064
+ reuse: null,
3065
+ }
3066
+ }
3067
+
3068
+ /** 记下这份点作为下一次留点的增量基准(内存摘要,见 {@link deltaBase}) */
3069
+ private rememberDeltaBase(record: RollbackPointRecord, prepared: RollbackLedgerPrepared): void {
3070
+ const payload = record.workspace.payload
3071
+ const hash = prepared.payloadHash
3072
+ if (payload === null || hash === null) {
3073
+ this.deltaBase = null
3074
+ return
3075
+ }
3076
+ const entries = new Map<string, string>()
3077
+ for (const file of payload.files) entries.set(file.path, digestKeyOf(file))
3078
+ this.deltaBase = {
3079
+ hash,
3080
+ chain: prepared.entry.workspace.payloadChain ?? [hash],
3081
+ chunks: [...payload.chunks],
3082
+ entries,
3083
+ }
3084
+ }
3085
+
2447
3086
  private async appendRecord(record: RollbackPointRecord): Promise<{ ok: boolean; detail?: string }> {
2448
3087
  for (let attempt = 0; attempt < 3; attempt += 1) {
2449
3088
  const result = await this.appendRecordOnce(record)
@@ -2474,7 +3113,10 @@ export class RollbackPointEngine {
2474
3113
  return { ok: false, detail: 'ledger unreadable: ' + manifest.error }
2475
3114
  }
2476
3115
  }
2477
- const prepared = this.ledger.prepareRecord(record)
3116
+ // 清单增量化:能省就把这份载荷改写成「相对最近一个同分块载荷的增量」(读路径由 ledger 物化回来)。
3117
+ // 失败 / 不划算 / 没有可用基准一律**原样返回** —— 增量只影响磁盘占用,绝不参与正确性判断。
3118
+ const storage = await this.storagePlanFor(record, manifest.entries)
3119
+ const prepared = this.ledger.prepareRecord(storage.record, storage.reuse ?? undefined)
2478
3120
  const merged = [prepared.entry, ...manifest.entries.filter((r) => r.id !== record.id)]
2479
3121
  const pruned = this.selectPruneEntries(merged)
2480
3122
  // 本次新增字节:记录文件 + **首次落盘**的载荷(同一工作区态已存在 ⇒ 零额外字节)
@@ -2503,6 +3145,8 @@ export class RollbackPointEngine {
2503
3145
  await this.ledger.prunePayloads(manifest.entries)
2504
3146
  return { ok: false, detail: errorDetail(error) }
2505
3147
  }
3148
+ // 记下「这次留点」作为下一次的增量基准(只在真正写成功后;失败不更新 ⇒ 链只跨成功落盘的点)
3149
+ this.rememberDeltaBase(record, prepared)
2506
3150
  const keptIds = new Set(kept.map((entry) => entry.id))
2507
3151
  const dropped = merged.filter((entry) => !keptIds.has(entry.id)).map((entry) => entry.id)
2508
3152
  await this.ledger.deleteRecords(dropped)
@@ -2791,6 +3435,26 @@ export class RollbackPointEngine {
2791
3435
 
2792
3436
  /* -------------------------------------------------------------- 预览 */
2793
3437
 
3438
+ /**
3439
+ * 预览的**单条只读判定**(阶段 A,可并发跑):一次 bigint lstat + 身份快路径。
3440
+ * `unchanged = true` 表示「已证明与记录一致」(身份命中身份缓存且 sha/bytes 相同)⇒ 调用方直接跳过。
3441
+ */
3442
+ private async previewScan(file: RollbackWorkspaceFile): Promise<PreviewScanResult> {
3443
+ try {
3444
+ // **lstat,不是 stat**:写入侧用 `symlink:'reject'`(lstat 是链接即抛),预览若跟随链接就会
3445
+ // 说「会创建 / 会覆盖」,执行却整段失败 —— 两侧必须是同一个口径。链接一律按「不可写」报。
3446
+ const stat = await fs.lstat(file.path, { bigint: true })
3447
+ if (stat.isSymbolicLink()) return { kind: 'unavailable' }
3448
+ const size = Number(stat.size)
3449
+ const regular = stat.isFile()
3450
+ const unchanged = regular && size === file.bytes
3451
+ && (await this.unchangedByIdentity(file, regular ? fileIdentityOf(stat) : null))
3452
+ return { kind: 'ok', size, regular, unchanged }
3453
+ } catch (error) {
3454
+ return classifyPreviewStatFailure(error) === 'missing' ? { kind: 'missing' } : { kind: 'unavailable' }
3455
+ }
3456
+ }
3457
+
2794
3458
  private trackWarnings(record: RollbackPointRecord): RollbackGuidance[] {
2795
3459
  const out: RollbackGuidance[] = []
2796
3460
  for (const track of [record.workspace, record.session]) {
@@ -2837,26 +3501,41 @@ export class RollbackPointEngine {
2837
3501
  for (const entry of payload.excludedDirs) proof.add(pathKey(entry.path))
2838
3502
  for (const entry of payload.ignoredDirs ?? []) proof.add(pathKey(entry.path))
2839
3503
  let refused: CreatedRemovalPlan['refused'] = null
2840
- if (payload.skippedTruncated) {
2841
- // 逐条跳过名单被记录上限截断 ⇒ 必须有路径索引,否则「未记录」≠「留点之后新建」
2842
- const index = payload.pathIndex ?? null
2843
- if (index === null) refused = 'capture-incomplete'
2844
- else {
2845
- try {
2846
- for (const item of await this.readPathIndex(index)) proof.add(pathKey(item))
2847
- } catch (error) {
2848
- this.warn('rollback path index unreadable: ' + errorDetail(error))
2849
- refused = 'capture-incomplete'
2850
- }
3504
+ // 路径索引**存在即并入**证明名单:写入条件已从「跳过名单被截断」推广为「覆盖自检不完整」
3505
+ // (读到一半被预算打断也会写索引)—— 这里只看 skippedTruncated 会漏读索引 ⇒ 把老文件当新建删掉。
3506
+ const index = payload.pathIndex ?? null
3507
+ if (index !== null) {
3508
+ try {
3509
+ for (const item of await this.readPathIndex(index)) proof.add(pathKey(item))
3510
+ } catch (error) {
3511
+ this.warn('rollback path index unreadable: ' + errorDetail(error))
3512
+ refused = 'capture-incomplete'
2851
3513
  }
3514
+ } else if (payload.skippedTruncated) {
3515
+ // 逐条跳过名单被记录上限截断、又没有索引 ⇒ 「未记录」≠「留点之后新建」,证明不了
3516
+ refused = 'capture-incomplete'
3517
+ }
3518
+ // —— 分子树拒删范围 ——
3519
+ // 忽略目录 / 读不出的目录 / 走查没进去的子树:这三类的共同点是「其下内容在留点时刻未知」,
3520
+ // 但它们**各自只影响自己那棵子树**。旧口径一律全局拒删,代价是「忽略一个 outputs/ 就让整个
3521
+ // 工作区的新建文件一条都不删」;现在只拒这几棵子树,范围之外由证明名单照常判。
3522
+ // 走查中断且拿不到精确子树列表(老台账 / 栈被截断)时才退回全局。
3523
+ // 范围**说得清**才敢分子树拒删:忽略目录的逐条列表可能被记录上限截断(ignoredDirCount 是恒增
3524
+ // 计数),走查中断也可能拿不到子树列表(老台账 / 栈太深)。任一「说不清」⇒ 退回全局拒删。
3525
+ const ignoredKnown = (payload.ignoredDirs ?? []).length >= (payload.ignoredDirCount ?? 0)
3526
+ const unwalkedKnown = (payload.unwalkedDirs ?? []).length > 0 || payload.walkIncomplete !== true
3527
+ const scopesKnown = ignoredKnown && unwalkedKnown
3528
+ const refusalScopes: string[] = scopesKnown
3529
+ ? [
3530
+ ...(payload.ignoredDirs ?? []).map((entry) => entry.path),
3531
+ ...payload.unreadableDirs,
3532
+ ...(payload.unwalkedDirs ?? []),
3533
+ ]
3534
+ : []
3535
+ if (refused === null && !scopesKnown) {
3536
+ // 证明名单不完整、且**说不出**不完整的确切范围 ⇒ 整个工作区一条都不删(安全方向)
3537
+ refused = 'capture-incomplete'
2852
3538
  }
2853
- // 读不出的目录:目录内容在留点时刻**未知**(路径索引只含走查过的路径)⇒ 同样证明不了
2854
- if (refused === null && payload.unreadableDirs.length > 0) refused = 'capture-incomplete'
2855
- // 用户忽略的整目录 ⇒ 其下内容在留点时刻未知 ⇒ 证明名单不完整(fail-closed)
2856
- if (refused === null && payload.ignoreIncomplete === true) refused = 'capture-incomplete'
2857
- // 走查被时间预算 / 取消信号中断 ⇒ 剩余子树**一条都没进任何名单** ⇒ 同样证明不了
2858
- // (这是 `truncated` 之外的第四种「名单不完整」:其余三种截断都会逐条留痕)
2859
- if (refused === null && payload.walkIncomplete === true) refused = 'capture-incomplete'
2860
3539
  const chunks = payload.chunks
2861
3540
  // 走查上限 = 全树:候选的**准入**由证明名单负责,条数上限不再是安全阀
2862
3541
  //(旧上限 maxFiles*4 会在巨型工作区里静默漏掉真正的新建文件 —— 那是「删一半」,不是 fail-closed)
@@ -2864,7 +3543,7 @@ export class RollbackPointEngine {
2864
3543
  // **时间预算**:回退期的走查同样不能无限跑(分块 = 会话 cwd,家目录 / 大仓库会走很久,
2865
3544
  // 而捕获侧早就有 30 s 预算 —— 只修一侧等于把 OOM 形态从留点搬到了回退)。
2866
3545
  // 中断只会让候选**变少**(安全方向),但必须如实置 truncated(绝不静默漏删、也不谎报完整)。
2867
- const deadline = this.stamp() + this.captureBudgetMs
3546
+ const deadline = this.stamp() + this.rewindBudgetMs
2868
3547
  const outOfBudget = (): boolean => this.stamp() >= deadline
2869
3548
  const candidates: string[] = []
2870
3549
  let truncated = false
@@ -2880,13 +3559,20 @@ export class RollbackPointEngine {
2880
3559
  candidates.push(file)
2881
3560
  }
2882
3561
  }
2883
- const refusalWarnings = this.createdRefusalWarnings(payload)
3562
+ // 警告只在**全局拒绝**时给:分子树拒绝由 createdSkipped 逐条说明(那句「本次不删除任何
3563
+ // 『留点后新建』的路径」在分子树拒绝下是假话 —— 文案诚实要求按真实范围分派)。
3564
+ const refusalWarnings = refused === null ? [] : this.createdRefusalWarnings(payload)
2884
3565
  const all: string[] = []
2885
3566
  const paths: string[] = []
2886
3567
  const skipped: string[] = []
2887
3568
  for (const file of candidates) {
2888
3569
  all.push(file)
2889
3570
  // 拒删的也留在「未记录」清单里(如实报告),但绝不进删除集合
3571
+ // 分子树拒删(忽略目录 / 读不出的目录 / 没走查到的子树):只拒这几棵子树里的候选
3572
+ if (refusalScopes.length > 0 && refusalScopes.some((dir) => isInsidePath(file, dir))) {
3573
+ skipped.push(file)
3574
+ continue
3575
+ }
2890
3576
  if (this.createdRemovalRefusal(file, chunks) !== null) { skipped.push(file); continue }
2891
3577
  let stat
2892
3578
  try {
@@ -3037,22 +3723,28 @@ export class RollbackPointEngine {
3037
3723
  // 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
3038
3724
  const diffBudget = { bytes: 0, work: 0 }
3039
3725
  // T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
3040
- type PreviewStatOutcome =
3041
- | { kind: 'ok'; size: number; regular: boolean }
3042
- | { kind: 'missing' }
3043
- | { kind: 'unavailable' }
3044
- for (const file of wsPayload.files) {
3045
- let outcome: PreviewStatOutcome
3046
- try {
3047
- // **lstat,不是 stat**:写入侧用 `symlink:'reject'`(lstat 是链接即抛),预览若跟随链接就会
3048
- // 说「会创建 / 会覆盖」,执行却整段失败 —— 两侧必须是同一个口径。链接一律按「不可写」报。
3049
- const stat = await fs.lstat(file.path)
3050
- outcome = stat.isSymbolicLink()
3051
- ? { kind: 'unavailable' }
3052
- : { kind: 'ok', size: stat.size, regular: stat.isFile() }
3053
- } catch (error) {
3054
- outcome = classifyPreviewStatFailure(error) === 'missing' ? { kind: 'missing' } : { kind: 'unavailable' }
3055
- }
3726
+ const previewFiles = wsPayload.files
3727
+ // 阶段 A(**并发**):逐条一次 bigint lstat + 身份快路径。大仓库这里是预览的大头:
3728
+ // 真机 34k 条实测 —— 串行 lstat ≈ 6 s、串行真读 ≈ 13 s;并发后降到亚秒级 + 只读真正变了的那几条。
3729
+ const scans = new Array<PreviewScanResult>(previewFiles.length)
3730
+ let scanCursor = 0
3731
+ const scanWorkers = Array.from(
3732
+ { length: Math.min(this.captureConcurrency, Math.max(1, previewFiles.length)) },
3733
+ async () => {
3734
+ for (;;) {
3735
+ const index = scanCursor
3736
+ scanCursor += 1
3737
+ if (index >= previewFiles.length) return
3738
+ scans[index] = await this.previewScan(previewFiles[index] as RollbackWorkspaceFile)
3739
+ }
3740
+ },
3741
+ )
3742
+ await Promise.all(scanWorkers)
3743
+ // 阶段 B(**顺序**):只对「变了 / 没了 / 不可写」的条目按记录顺序装配 —— 列表顺序与行级 diff 的
3744
+ // 共享预算因此仍**完全确定**(与并发前逐条一致,测试可逐字对账)。
3745
+ for (let index = 0; index < previewFiles.length; index += 1) {
3746
+ const file = previewFiles[index] as RollbackWorkspaceFile
3747
+ const outcome: PreviewScanResult = scans[index] ?? { kind: 'unavailable' }
3056
3748
  if (outcome.kind === 'missing') {
3057
3749
  // **只有 ENOENT** 才算「当前确实不存在」⇒ 回退会重新创建它
3058
3750
  missing += 1
@@ -3070,6 +3762,8 @@ export class RollbackPointEngine {
3070
3762
  changedFiles.push({ path: file.path, status: 'overwrite', ...previewCountsUnavailable() })
3071
3763
  continue
3072
3764
  }
3765
+ // 身份快路径已证明「与记录一致」⇒ 一个字段都不改(内容一致的文件不列进列表)
3766
+ if (outcome.unchanged) continue
3073
3767
  const { size, regular } = outcome
3074
3768
  const sizeMatches = regular && size === file.bytes
3075
3769
  const withinCaps = file.bytes <= this.previewDiffMaxFileBytes && size <= this.previewDiffMaxFileBytes
@@ -3078,7 +3772,7 @@ export class RollbackPointEngine {
3078
3772
  // ② 大小不一致时也得读一次才能给行级统计(超限的大文件不白读)。
3079
3773
  if (regular && (sizeMatches || withinCaps)) {
3080
3774
  try {
3081
- current = await fs.readFile(file.path)
3775
+ current = await this.readFileBytes(file.path)
3082
3776
  } catch {
3083
3777
  current = null
3084
3778
  }
@@ -3265,7 +3959,7 @@ export class RollbackPointEngine {
3265
3959
  const result = diffLines(
3266
3960
  current === null ? '' : DIFF_TEXT_DECODER.decode(current),
3267
3961
  DIFF_TEXT_DECODER.decode(snapshot),
3268
- { context: ROLLBACK_FILE_DIFF_LIMITS.context },
3962
+ { context: ROLLBACK_FILE_DIFF_LIMITS.context, maxD: ROLLBACK_FILE_DIFF_LIMITS.maxD },
3269
3963
  )
3270
3964
  return {
3271
3965
  ...view,
@@ -3470,6 +4164,8 @@ export class RollbackPointEngine {
3470
4164
  const detail: string[] = []
3471
4165
  const writable: { file: RollbackWorkspaceFile; bytes: Uint8Array }[] = []
3472
4166
  let failed = 0
4167
+ // 「已与记录一致」的条目数(身份快路径证明)—— 后面「0 变更」的文案与统计要用
4168
+ let unchanged = 0
3473
4169
  for (const file of payload.files) {
3474
4170
  // 门:记录里的路径必须落在记录里的分块内(记录不可成为写原语)
3475
4171
  const inChunk = payload.chunks.some((chunk) => path.isAbsolute(chunk) && isInsidePath(file.path, chunk))
@@ -3478,6 +4174,17 @@ export class RollbackPointEngine {
3478
4174
  pushDetail(detail, 'invalid-path ' + file.path)
3479
4175
  continue
3480
4176
  }
4177
+ // **身份快路径**:身份(dev:ino:size:mtimeNs:ctimeNs:mode)命中身份缓存、且缓存的 sha/bytes 与记录
4178
+ // 一致 ⇒ 这个文件当前就是留点时刻的内容,**它根本不会被写** ⇒ 连对象都不必读。
4179
+ // 大仓库实测:旧口径下阶段 1 会把 34k 个对象(609 MiB)全读一遍 + 哈希一遍(回退 60 s+ 全在这)。
4180
+ // 语义变化(有意、且更准):阶段 1 的对象校验只对**真要写**的条目做 —— 一个用不到的对象坏掉,
4181
+ // 不该中止整次回退;真要用它时(该文件确实变了)照样会 `object-missing` / `object-hash-mismatch` 硬失败。
4182
+ const info = await this.fileStatOf(file.path)
4183
+ if (info.error === undefined && info.isFile && info.size === file.bytes
4184
+ && (await this.unchangedByIdentity(file, info.identity))) {
4185
+ unchanged += 1
4186
+ continue
4187
+ }
3481
4188
  try {
3482
4189
  const bytes = await this.readObject(file.object)
3483
4190
  if (sha256Hex(bytes) !== file.sha256) {
@@ -3528,7 +4235,6 @@ export class RollbackPointEngine {
3528
4235
  // E1:内容已与快照一致的条目**不再写** —— 「0 变更」就要是真的 0 次写入
3529
4236
  // (否则工作区已与该点一致时也会重写全部文件,白耗 I/O 并抖动 mtime)。
3530
4237
  const needed: { file: RollbackWorkspaceFile; bytes: Uint8Array }[] = []
3531
- let unchanged = 0
3532
4238
  for (const item of writable) {
3533
4239
  let identical = false
3534
4240
  try {
@@ -3574,7 +4280,7 @@ export class RollbackPointEngine {
3574
4280
  let bytes: Uint8Array | null = null
3575
4281
  let unreadable: string | null = null
3576
4282
  try {
3577
- bytes = await fs.readFile(item.file.path)
4283
+ bytes = await this.readFileBytes(item.file.path)
3578
4284
  } catch (error) {
3579
4285
  // **只认 ENOENT = 当时确实不存在**。其它 errno(EACCES / EMFILE / EBUSY…)是
3580
4286
  // 「读不出来」而不是「没有」—— 折成 null 会让回滚阶段把这个**存在**的文件删掉
@@ -3621,7 +4327,7 @@ export class RollbackPointEngine {
3621
4327
  if (failed === 0) {
3622
4328
  for (const item of needed) {
3623
4329
  try {
3624
- const current = await fs.readFile(item.file.path)
4330
+ const current = await this.readFileBytes(item.file.path)
3625
4331
  if (sha256Hex(current) !== item.file.sha256) mismatched.push(item.file.path)
3626
4332
  } catch {
3627
4333
  mismatched.push(item.file.path)