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
@@ -76,6 +76,10 @@ import { isSessionLogName } from '../utils/session-log.js';
76
76
  import { decodeZstdFrame, scanZstdFrames, zstdAvailable, ZSTD_MAGIC } from '../utils/zstd-frame.js';
77
77
  import { cleanupOrphanTempFiles, entrySummaryOf, ROLLBACK_DEFAULT_LEDGER_BYTES, ROLLBACK_UNDO_DIR_NAME, RollbackLedger, } from './rollback-ledger.js';
78
78
  /* ------------------------------------------------------------------ 常量 */
79
+ /** 增量链的最大长度:到顶就落一个**全量锚点**(重建最多读 1 份全量 + 几份小增量) */
80
+ const ROLLBACK_DELTA_CHAIN_LIMIT = 8;
81
+ /** 增量视图只有在小于全量的这个比例时才采用(否则直接全量,简单可靠) */
82
+ const ROLLBACK_DELTA_MAX_RATIO = 0.6;
79
83
  export const ROLLBACK_SCHEMA_VERSION = 2;
80
84
  /** 回滚点库目录名(dataDir/rollback-points)——路由族 /recovery/rollback-points/* 同源 */
81
85
  export const ROLLBACK_POINTS_DIR_NAME = 'rollback-points';
@@ -177,6 +181,45 @@ export const ROLLBACK_LEFTOVER_LIST_LIMIT = 20;
177
181
  * `truncated` 置真 ⇒ 删除集自动 fail-closed(证明名单不完整就一条都不删)。
178
182
  */
179
183
  export const ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS = 30_000;
184
+ /**
185
+ * 自适应预算区间(见 RollbackEngineOptions.captureBudgetMs):上一次留点被截断 ⇒ 下次 ×2,
186
+ * 上一次只用掉不到 1/4 ⇒ 下次 ÷2。区间外不再调整(避免无限拉长 / 缩到不可用)。
187
+ */
188
+ export const ROLLBACK_MIN_CAPTURE_BUDGET_MS = 10_000;
189
+ export const ROLLBACK_MAX_CAPTURE_BUDGET_MS = 120_000;
190
+ /**
191
+ * 捕获期「读内容 + 写对象仓」的并发上限。
192
+ *
193
+ * 为什么必须并发:真机实测(34k 文件 / 512 MiB)一次全量捕获 175 s,其中 **148 s 花在
194
+ * 31,702 次对象写**(一次 4.7 ms:temp + fsync + rename),而走查本身只要 0.66 s。
195
+ * 对象写入是纯 I/O,串行做等于把预算全花在等待上 ⇒ 覆盖永远只有一小半。
196
+ * 并发上限与**在飞字节闸门**共同限流(在飞内容上界 = `captureInFlightBytes`)。
197
+ */
198
+ export const ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY = 16;
199
+ /**
200
+ * 捕获期**读入内存、尚未落对象仓**的内容字节上限(真正的内存闸门)。
201
+ *
202
+ * 为什么必须独立于 `maxFileBytes`(0.1.74 真机闪退事故,2026-10-16):
203
+ * `maxFileBytes` 的缺省是**总量预算** `maxBytes`(512 MiB)。并发捕获把闸门接到它上面,
204
+ * 于是「同时在飞的内容」可以到 ~512 MiB —— 串行实现的上界是「一份文件」,并发实现的
205
+ * 上界是「闸门限额」,两者差着两三个数量级。真机表现:发出消息后 1–2 s(= `turn/start`
206
+ * 开始留点)DSH Desktop 宿主被系统终止 ⇒ 桌面闪退(无 JS 栈、插件零错误、
207
+ * 退出码是「被外部终止」形状)。这里取一个小常量当闸门;要放宽只能显式传
208
+ * `captureInFlightBytes`,且被 `maxBytes` 夹住。
209
+ */
210
+ export const ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES = 32 * 1024 * 1024;
211
+ /** 对象写遇到这些码视为「瞬时占用」,有界退避重试(判据同 atomic-write 的 isRetryableLockCode) */
212
+ const OBJECT_WRITE_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'];
213
+ /** 捕获阶段①的 stat 批大小:判定仍严格按顺序,只是 stat 并发取(真机 34k 次 stat 从 5 s → <1 s) */
214
+ export const ROLLBACK_CAPTURE_STAT_BATCH = 128;
215
+ /**
216
+ * 捕获期**候选名单**上限(不是入点上限):必须显著大于 maxFiles,否则「缺口优先」排序作用不到
217
+ * 那些排在遍历顺序尾部、上一回合没覆盖到的文件(它们连候选都进不了 ⇒ 覆盖率永远不收敛)。
218
+ * 走查本来就会把每条路径记进 walked(路径索引的来源),这里多留的只是候选数组本身。
219
+ */
220
+ export const ROLLBACK_CAPTURE_LIST_LIMIT = 200_000;
221
+ /** 走查中断时逐条记下的「没进去的子树」上限;超出即放弃精确范围,退回全局 fail-closed */
222
+ export const ROLLBACK_UNWALKED_DIRS_LIMIT = 200;
180
223
  /**
181
224
  * 「删除回退点之后新建的路径」的**整套上限**(用户 2026-10-11 定案 A)。
182
225
  * 超过就整套拒绝(fail-closed),绝不删一半 —— 半套删除既没有可解释的状态,也无法整体回滚。
@@ -208,6 +251,14 @@ export const ROLLBACK_PREVIEW_DIFF_TOTAL_WORK = 32_000_000;
208
251
  export const ROLLBACK_FILE_DIFF_LIMITS = {
209
252
  maxFileBytes: ROLLBACK_PREVIEW_DIFF_MAX_FILE_BYTES,
210
253
  context: 3,
254
+ /**
255
+ * Myers 编辑距离上限(2026-10-15 用户:不该动辄出现「已按整块替换展示」)。
256
+ * 实测(本仓 rollback-point.ts 的 4983 行 × 2492 行版本对):D 在 3000~4000 就能拿到
257
+ * 最小编辑脚本 —— 耗时 ≤70 ms;代价是回溯的 V 快照 O(D²),RSS 峰值 +~100 MB。
258
+ * 缺省那档 400 会让几千行里的普通改动一律退化成「整块替换」,那正是用户看到的那条提示;
259
+ * 单文件是**按需点开**的一屏,这点内存换「真差异」划算。再大就换算法,不加预算。
260
+ */
261
+ maxD: 4000,
211
262
  };
212
263
  /** 恢复顺序(工作区在前;工作区失败即中止后面的会话段) */
213
264
  export const ROLLBACK_REWIND_ORDER = ['workspace', 'session'];
@@ -231,6 +282,24 @@ export const ROLLBACK_PATH_CACHE_NAME = 'path-cache.json';
231
282
  function win32Fold(value) {
232
283
  return process.platform === 'win32' ? value.toLowerCase() : value;
233
284
  }
285
+ /**
286
+ * 路径里是否存在以 `.asar` 结尾的段(**捕获 / 走查期一律不碰这类路径**)。
287
+ *
288
+ * 为什么(真机事故,0.1.76):Electron 宿主对任何含 `.asar` 段的路径**启用了 asar 虚拟化** ——
289
+ * 即使那只是个普通文件,`fs.stat`/`readdir`/`read` 也会被接管去当容器解析。工作区里只要躺着一枚
290
+ * **畸形** `.asar`(测试夹具、被打断的打包残留),宿主进程就会被原生层带走:crashpad
291
+ * `not connected`、退出码 4294930435 ⇒ DSH Desktop 闪退(无 JS 栈、插件零错误)。
292
+ * 实测(本仓真机捕获、同一份输入):`.tmp` 下 19 枚 `.asar` 夹具在场 → Electron 进程死;
293
+ * 全部改名后 → 同一趟捕获 9 s 跑完、退出码 0。
294
+ * `.asar.unpacked` 是**真目录**(段名不以 `.asar` 结尾)⇒ 不拦。
295
+ */
296
+ export function hasAsarSegment(target) {
297
+ for (const segment of target.split(/[\\/]+/)) {
298
+ if (segment.length > '.asar'.length && win32Fold(segment).endsWith('.asar'))
299
+ return true;
300
+ }
301
+ return false;
302
+ }
234
303
  /** child 是否等于 parent 或位于其下(win32 折叠大小写;段边界判定) */
235
304
  export function isInsidePath(child, parent) {
236
305
  const c = win32Fold(path.resolve(child));
@@ -241,6 +310,82 @@ export function isInsidePath(child, parent) {
241
310
  function pathKey(value) {
242
311
  return win32Fold(path.resolve(value));
243
312
  }
313
+ /** 两个分块列表是否逐个相同(增量基准必须作用于同一批分块) */
314
+ function sameChunks(a, b) {
315
+ if (a.length !== b.length)
316
+ return false;
317
+ for (let i = 0; i < a.length; i += 1)
318
+ if (a[i] !== b[i])
319
+ return false;
320
+ return true;
321
+ }
322
+ /**
323
+ * **存盘视图**:条目里的 `object` 恒等于 `sha256`(内容寻址的名字就是内容哈希)⇒ 落盘时不写这个字段,
324
+ * 读取时按 `sha256` 补回(见 ledger 的 `withObjectRefs`)。每条约省 1/5 字节。
325
+ */
326
+ function compactPayloadForStorage(payload) {
327
+ const strip = (list) => {
328
+ if (!Array.isArray(list))
329
+ return list;
330
+ return list.map((file) => {
331
+ const copy = { ...file };
332
+ delete copy['object'];
333
+ return copy;
334
+ });
335
+ };
336
+ const delta = payload.delta;
337
+ return {
338
+ ...payload,
339
+ files: strip(payload.files),
340
+ ...(delta === undefined
341
+ ? {}
342
+ : { delta: { ...delta, ...(delta.added === undefined ? {} : { added: strip(delta.added) }) } }),
343
+ };
344
+ }
345
+ /**
346
+ * 构造「相对基准的增量视图」;不划算(≥ 全量 × {@link ROLLBACK_DELTA_MAX_RATIO})时返回 null。
347
+ *
348
+ * 条目**逐字比较**:路径、sha、字节数、mtimeMs、所属分块任一不同 ⇒ 进 `added`;基准里不再存在的路径进 `removed`。
349
+ * 重建 = 基准 − removed,再用 added 覆盖/追加,最后按路径排序(与全量捕获同序)。
350
+ */
351
+ /** 基准摘要键:内容 + 字节数 + 所属分块(`mtimeMs` 不参与 —— 只被触摸、内容没变的文件不必进增量) */
352
+ function digestKeyOf(file) {
353
+ return String(file.sha256) + ':' + String(file.bytes) + ':' + String(file.chunk);
354
+ }
355
+ /** 相对**内存基准摘要**的条目差异(新增/改写 / 不再存在);路径逐字比较,不读任何文件 */
356
+ function payloadDiffAgainstDigest(payload, baseEntries) {
357
+ const added = [];
358
+ const nextPaths = new Set();
359
+ for (const file of payload.files) {
360
+ nextPaths.add(file.path);
361
+ const prev = baseEntries.get(file.path);
362
+ if (prev === undefined || prev !== digestKeyOf(file))
363
+ added.push(file);
364
+ }
365
+ const removed = [];
366
+ for (const path of baseEntries.keys())
367
+ if (!nextPaths.has(path))
368
+ removed.push(path);
369
+ return { added, removed };
370
+ }
371
+ /**
372
+ * 构造「相对基准的增量视图」;不划算(≥ 全量 × {@link ROLLBACK_DELTA_MAX_RATIO})时返回 null。
373
+ * 重建 = 基准 − removed,再用 added 覆盖/追加,最后按路径排序(与全量捕获同序)。
374
+ */
375
+ function buildPayloadDelta(payload, baseHash, baseChain, diff) {
376
+ if (baseHash === '')
377
+ return null;
378
+ const delta = {
379
+ ...payload,
380
+ // **故意不是数组**:不认识 `delta` 的旧读取器会在形状校验这一步**拒绝这个点**(fail-closed),
381
+ // 而不是把「增量那几条」当成完整清单去规划删除 —— 那会把留点时存在的文件全判成新建、全部删掉。
382
+ files: null,
383
+ delta: { base: baseHash, baseChain: [...baseChain], removed: diff.removed, added: diff.added },
384
+ };
385
+ if (JSON.stringify(delta).length >= JSON.stringify(payload).length * ROLLBACK_DELTA_MAX_RATIO)
386
+ return null;
387
+ return delta;
388
+ }
244
389
  function errorDetail(error) {
245
390
  if (error instanceof Error) {
246
391
  const code = error.code;
@@ -439,6 +584,17 @@ export function resolveTurnBoundary(events, turnSeq) {
439
584
  }
440
585
  return { ok: true, boundarySeq: boundary.boundarySeq, endSeq: boundary.endSeq };
441
586
  }
587
+ /**
588
+ * 记录在案的文件条目数:优先用载荷自带的 `fileCount`(增量视图下 `files` 可能只是子集),
589
+ * 老载荷没有该字段才回落到 `files.length`。
590
+ */
591
+ export function workspaceFileCount(payload) {
592
+ if (payload === null)
593
+ return 0;
594
+ if (typeof payload.fileCount === 'number' && Number.isFinite(payload.fileCount))
595
+ return payload.fileCount;
596
+ return payload.files.length;
597
+ }
442
598
  export function summarizeRollbackPoint(record) {
443
599
  const workspace = record.workspace.payload;
444
600
  const session = record.session.payload;
@@ -469,7 +625,7 @@ export function summarizeRollbackPoint(record) {
469
625
  note: record.note,
470
626
  spreadMs: record.spreadMs,
471
627
  segments: [
472
- segment('workspace', record.workspace, workspace?.files.length ?? 0, workspace?.bytes ?? 0, null, (workspace?.files.length ?? 0) > 0, workspace?.skippedCount ?? 0, workspace?.truncated ?? false),
628
+ segment('workspace', record.workspace, workspaceFileCount(workspace), workspace?.bytes ?? 0, null, workspaceFileCount(workspace) > 0, workspace?.skippedCount ?? 0, workspace?.truncated ?? false),
473
629
  segment('session', record.session, session === null ? 0 : 1, session?.bytes ?? 0, session?.cursorBytes ?? null, session !== null && session.sessionId !== null && session.boundarySeq !== null, 0, false),
474
630
  ],
475
631
  };
@@ -600,6 +756,49 @@ class UndoStager {
600
756
  }
601
757
  }
602
758
  }
759
+ /**
760
+ * 单文件字节闸门:保证「同时在飞(已读入、尚未落仓)的内容字节」≤ limit。
761
+ *
762
+ * 与 captureConcurrency 一起构成捕获期的内存上界:并发只放大**同时**驻留的量,而它被
763
+ * `captureInFlightBytes` 钉死(缺省 32 MiB)。**不能**用 maxFileBytes 当限额 —— 它缺省 =
764
+ * 总量预算 512 MiB,「一份文件」与「整个点的预算」不是同一个量级(0.1.74 闪退根因)。
765
+ */
766
+ /**
767
+ * 路径身份键:dev:ino:size:mtimeNs:ctimeNs:mode(全 BigInt 字符串化)。必须 BigInt:ino / mtimeNs /
768
+ * ctimeNs 在 64 位 inode 文件系统上超出 Number 安全整数范围,用 Number 会静默丢精度(把两次不同的
769
+ * 写当成同一个身份)。单一来源:fileStatOf 与 pathIdentityOf 都用它。
770
+ */
771
+ function fileIdentityOf(stat) {
772
+ return [
773
+ String(stat.dev),
774
+ String(stat.ino),
775
+ String(stat.size),
776
+ String(stat.mtimeNs),
777
+ String(stat.ctimeNs),
778
+ String(stat.mode & 511n),
779
+ ].join(':');
780
+ }
781
+ class ByteGate {
782
+ used = 0;
783
+ waiters = [];
784
+ limit;
785
+ constructor(limit) {
786
+ this.limit = limit;
787
+ }
788
+ async acquire(bytes) {
789
+ const need = Math.max(0, Math.min(bytes, this.limit));
790
+ while (this.used + need > this.limit && this.used > 0) {
791
+ await new Promise((resolve) => { this.waiters.push(resolve); });
792
+ }
793
+ this.used += need;
794
+ }
795
+ release(bytes) {
796
+ this.used = Math.max(0, this.used - Math.max(0, Math.min(bytes, this.limit)));
797
+ const next = this.waiters.shift();
798
+ if (next !== undefined)
799
+ next();
800
+ }
801
+ }
603
802
  export class RollbackPointEngine {
604
803
  dataDir;
605
804
  homeDir;
@@ -611,6 +810,7 @@ export class RollbackPointEngine {
611
810
  maxFiles;
612
811
  maxBytes;
613
812
  maxFileBytes;
813
+ captureInFlightBytes;
614
814
  maxSkippedEntries;
615
815
  sessionMaxBytes;
616
816
  sessionQuiescentMs;
@@ -640,14 +840,41 @@ export class RollbackPointEngine {
640
840
  forkLinksAt = 0;
641
841
  forkLinksComplete = true;
642
842
  /** 会话日志头缓存(mtime + size 失效;只服务 fork 链扫描) */
843
+ /**
844
+ * **本进程**最近一次成功留点的工作区摘要(清单增量化的基准)。
845
+ *
846
+ * 为什么是内存而不是读盘:一次留点只准读清单(L-08 / issue #89)—— 读上一份 12 MB 载荷去算差异
847
+ * 正是那条被消灭的路。内存量 ≈ 每文件一个 `sha:bytes:chunk` 串(3.4 万文件约几 MB),
848
+ * 与已有的身份缓存(`pathCache`)同量级;进程重启后第一次留点落全量锚点,随后继续链。
849
+ */
850
+ deltaBase = null;
643
851
  forkHeaderCache = new Map();
644
852
  /** 继承判据用的台账快照(TTL 缓存;只缓存**清单条目**,绝不缓存完整记录) */
645
853
  ledgerSnapshotCache = null;
646
854
  /** 台账存储层(v3:清单 + 单条记录 + 内容寻址载荷;issue #89) */
647
855
  ledger;
648
856
  ledgerBytesBudget;
649
- /** 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS) */
857
+ /**
858
+ * 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
859
+ * 每次留点后按「是否被截断 / 用掉多少」自适应(见 ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS)。
860
+ */
650
861
  captureBudgetMs;
862
+ /** 自适应区间下界 / 上界(构造期定死,运行期只调 captureBudgetMs) */
863
+ captureBudgetMinMs;
864
+ captureBudgetMaxMs;
865
+ /** 回退期走查的时间预算(与捕获预算分开:自适应不该顺手拉长回退期的走查) */
866
+ rewindBudgetMs;
867
+ /** 捕获期读 + 写并发上限 */
868
+ captureConcurrency;
869
+ /** 对象仓目录已确认存在(省掉每个对象一次 mkdir) */
870
+ objectsDirReady = false;
871
+ /**
872
+ * 本回合对象名单快照(一次 readdir 顶掉「每个候选一次 fs.access」;真机 34k 次 access ≈ 3.7 s)。
873
+ * null = 读不到名单(首次还没建目录/异常)⇒ 回落逐条 objectExists。
874
+ * 只保证「写进去的立刻可见」:捕获期间被**并发删除**的对象可能仍被判为存在 —— 那一路由
875
+ * 读取侧(readObject + 哈希校验)响亮失败,不会静默给出错误的 before 内容。
876
+ */
877
+ captureObjectNames = null;
651
878
  /**
652
879
  * 启动期一次性初始化(流式迁移 v2 -> v3 + 孤儿 tmp 清理)的惰性单飞。
653
880
  * 为什么惰取而不是构造时同步跑:构造发生在每个请求路径上,同步扫盘/迁移会把请求卡死;
@@ -668,6 +895,10 @@ export class RollbackPointEngine {
668
895
  this.maxBytes = options.maxBytes ?? ROLLBACK_DEFAULT_MAX_BYTES;
669
896
  // 单文件上限缺省 = 总量预算(一个文件不可能比整个点的预算更大)
670
897
  this.maxFileBytes = options.maxFileBytes ?? this.maxBytes;
898
+ // **内存闸门与单文件上限分开**:拿 maxFileBytes 当闸门 = 允许把整个点的预算同时驻留
899
+ // (0.1.74 真机闪退的根因),见 ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES。
900
+ const inFlight = options.captureInFlightBytes ?? ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES;
901
+ this.captureInFlightBytes = Math.max(1, Math.min(this.maxBytes, Math.floor(inFlight)));
671
902
  this.maxSkippedEntries = options.maxSkippedEntries ?? ROLLBACK_SKIPPED_LIST_LIMIT;
672
903
  this.sessionMaxBytes = options.sessionMaxBytes ?? ROLLBACK_DEFAULT_SESSION_MAX_BYTES;
673
904
  this.sessionQuiescentMs = options.sessionQuiescentMs ?? ROLLBACK_DEFAULT_SESSION_QUIESCENT_MS;
@@ -687,6 +918,10 @@ export class RollbackPointEngine {
687
918
  this.log = options.log;
688
919
  this.ledgerBytesBudget = options.ledgerBytesBudget ?? ROLLBACK_DEFAULT_LEDGER_BYTES;
689
920
  this.captureBudgetMs = options.captureBudgetMs ?? ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS;
921
+ this.captureBudgetMinMs = options.captureBudgetMinMs ?? ROLLBACK_MIN_CAPTURE_BUDGET_MS;
922
+ this.captureBudgetMaxMs = options.captureBudgetMaxMs ?? ROLLBACK_MAX_CAPTURE_BUDGET_MS;
923
+ this.rewindBudgetMs = options.rewindBudgetMs ?? this.captureBudgetMs;
924
+ this.captureConcurrency = Math.max(1, Math.min(64, Math.floor(options.captureConcurrency ?? ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY)));
690
925
  this.ledger = new RollbackLedger({
691
926
  root: this.root,
692
927
  bytesBudget: this.ledgerBytesBudget,
@@ -858,20 +1093,51 @@ export class RollbackPointEngine {
858
1093
  }
859
1094
  }
860
1095
  /* -------------------------------------------------------------- 对象仓 */
1096
+ /** 对象仓目录只 mkdir 一次(每个对象各 mkdir 一次是纯白花的 syscall) */
1097
+ async ensureObjectsDir() {
1098
+ if (this.objectsDirReady)
1099
+ return;
1100
+ await fs.mkdir(this.objectsDir, { recursive: true });
1101
+ this.objectsDirReady = true;
1102
+ }
1103
+ /**
1104
+ * 写内容寻址对象(对象文件名 = 内容 SHA-256)。
1105
+ *
1106
+ * **为什么这里不用 atomicWriteFile**:那是给「可写配置文件」用的强原子写(同目录 tmp → fsync →
1107
+ * rename),真机实测一次约 4.7 ms —— 34k 文件的工作区一次全量捕获 148 s 全花在它上面(走查本身
1108
+ * 只要 0.66 s)。对象仓的正确性要求不同:文件名即内容哈希 ⇒
1109
+ * · 用 'wx' **独占创建最终名**本身已是原子的(不覆盖已有对象;并发同写只有一方成功);
1110
+ * · 半截 / 被篡改的内容在读取侧必然过不了哈希校验(readObject + sha256Hex)⇒ 按「读不出」
1111
+ * 处理并 fail-closed,绝不会冒充有效前像;
1112
+ * · 不做 fsync:回滚点是尽力而为的本地快照,不为掉电持久性付出每文件一次 fsync 的代价。
1113
+ */
861
1114
  async writeObjectWithHash(bytes, sha) {
862
1115
  if (!OBJECT_KEY_RE.test(sha))
863
1116
  throw new Error('rollback object key invalid');
864
1117
  const target = path.join(this.objectsDir, sha);
865
1118
  if (!isInsidePath(target, this.objectsDir))
866
1119
  throw new Error('rollback object path escapes store');
867
- await fs.mkdir(this.objectsDir, { recursive: true });
868
- try {
869
- await fs.access(target);
870
- return sha;
1120
+ await this.ensureObjectsDir();
1121
+ let delay = 10;
1122
+ for (let attempt = 0;; attempt += 1) {
1123
+ try {
1124
+ await fs.writeFile(target, bytes, { flag: 'wx' });
1125
+ this.captureObjectNames?.add(sha);
1126
+ return sha;
1127
+ }
1128
+ catch (error) {
1129
+ const code = error.code;
1130
+ if (code === 'EEXIST')
1131
+ return sha; // 已有同内容对象(去重 / 并发竞写)
1132
+ // 杀软 / 句柄瞬时占用:有界退避重试(与 atomic-write 的判据同族)
1133
+ if (OBJECT_WRITE_RETRY_CODES.includes(code) && attempt < 3) {
1134
+ await new Promise((resolve) => setTimeout(resolve, delay));
1135
+ delay *= 2;
1136
+ continue;
1137
+ }
1138
+ throw error;
1139
+ }
871
1140
  }
872
- catch { /* 不存在才写(内容寻址天然去重) */ }
873
- await atomicWriteFile(target, bytes);
874
- return sha;
875
1141
  }
876
1142
  /** 对象仓里是否真有这个 blob(缓存命中时校验;任何异常一律按「没有」处理) */
877
1143
  async objectExists(sha) {
@@ -980,6 +1246,10 @@ export class RollbackPointEngine {
980
1246
  /**
981
1247
  * 列出一个显式分块内的候选文件(不跟随符号链接 / junction;跳过与读不到都留痕)。
982
1248
  * 返回 null = 该分块本身不可用(不存在 / 是链接 / 非常规文件),细节已写进 sink。
1249
+ *
1250
+ * `truncated` 与 `walkAborted` 是**两件事**:前者只说「本次列表不完整」(文件数上限 / 提交上限),
1251
+ * 后者说「走查被预算或取消打断,栈里还有没进去的子树」—— 只有后者才意味着那些路径
1252
+ * **一条都没进任何名单**,调用方必须据此让删除集 fail-closed。
983
1253
  */
984
1254
  async listChunkFiles(chunk, sink, limit,
985
1255
  /**
@@ -989,9 +1259,15 @@ export class RollbackPointEngine {
989
1259
  collect, ignore,
990
1260
  /**
991
1261
  * 停止判据(捕获期传时间/取消预算;回退期的走查不传)—— 返回 true 立即停止遍历,
992
- * 已走查的部分照常返回并置 truncated(证明名单因此不完整 ⇒ 删除集 fail-closed)。
1262
+ * 已走查的部分照常返回并置 truncated + walkAborted(栈里剩余的子树从未进门 ⇒ 由调用方据此 fail-closed;
1263
+ * 回退期不传它,故 walkAborted 恒 false)。
993
1264
  */
994
1265
  shouldStop) {
1266
+ // 分块根本身就是 `.asar` 段 ⇒ 一次 fs 调用都不发(见 hasAsarSegment)
1267
+ if (hasAsarSegment(chunk)) {
1268
+ this.recordSkip(sink, 'skipped', { path: chunk, reason: 'asar-container' });
1269
+ return null;
1270
+ }
995
1271
  let stat;
996
1272
  try {
997
1273
  stat = await fs.lstat(chunk);
@@ -1005,17 +1281,23 @@ export class RollbackPointEngine {
1005
1281
  return null;
1006
1282
  }
1007
1283
  if (stat.isFile())
1008
- return { files: [chunk], truncated: false };
1284
+ return { files: [chunk], truncated: false, walkAborted: false, unwalkedDirs: [] };
1009
1285
  if (!stat.isDirectory()) {
1010
1286
  this.recordSkip(sink, 'skipped', { path: chunk, reason: 'not-a-file' });
1011
1287
  return null;
1012
1288
  }
1013
1289
  const out = [];
1014
1290
  let truncated = false;
1291
+ // 走查**被预算 / 取消打断**:栈里还压着没进去的子树 —— 那些路径一条都没进任何名单
1292
+ let walkAborted = false;
1293
+ // 中断时把**还没进去的子树**如实带出去 ⇒ 调用方可以只对这几棵子树 fail-closed(精确范围)
1294
+ let unwalkedDirs = [];
1015
1295
  const stack = [{ dir: chunk, depth: 0 }];
1016
1296
  while (stack.length > 0) {
1017
1297
  if (shouldStop?.() === true) {
1018
1298
  truncated = true;
1299
+ walkAborted = true;
1300
+ unwalkedDirs = stack.length > ROLLBACK_UNWALKED_DIRS_LIMIT ? [] : stack.map((item) => item.dir);
1019
1301
  break;
1020
1302
  }
1021
1303
  const item = stack.pop();
@@ -1035,6 +1317,13 @@ export class RollbackPointEngine {
1035
1317
  }
1036
1318
  for (const entry of entries) {
1037
1319
  const full = path.join(item.dir, entry.name);
1320
+ // **绝不把含 `.asar` 段的路径交给 fs**(见 hasAsarSegment:宿主里这一步是原生解析,
1321
+ // 畸形夹具会让整个宿主进程消失)。留痕进 skipped —— 它是**证明名单**的一员,
1322
+ // 因此该路径绝不会被当成「留点后新建」删掉。
1323
+ if (hasAsarSegment(full)) {
1324
+ this.recordSkip(sink, 'skipped', { path: full, reason: 'asar-container' });
1325
+ continue;
1326
+ }
1038
1327
  if (entry.isSymbolicLink()) {
1039
1328
  this.recordSkip(sink, 'skipped', { path: full, reason: 'link' });
1040
1329
  continue;
@@ -1082,7 +1371,7 @@ export class RollbackPointEngine {
1082
1371
  }
1083
1372
  }
1084
1373
  out.sort();
1085
- return { files: out, truncated };
1374
+ return { files: out, truncated, walkAborted, unwalkedDirs };
1086
1375
  }
1087
1376
  async captureWorkspace(chunks, signal) {
1088
1377
  const capturedAt = this.stamp();
@@ -1093,6 +1382,8 @@ export class RollbackPointEngine {
1093
1382
  // 自动落成 capture-truncated,UI 走既有的 degraded 提示路径(零新增文案)。
1094
1383
  const deadline = capturedAt + this.captureBudgetMs;
1095
1384
  const outOfBudget = () => signal?.aborted === true || this.stamp() >= deadline;
1385
+ // 对象名单快照按回合重建(一次 readdir 换掉每个候选一次 fs.access)
1386
+ this.captureObjectNames = null;
1096
1387
  const payload = {
1097
1388
  chunks: [...chunks],
1098
1389
  files: [],
@@ -1112,12 +1403,16 @@ export class RollbackPointEngine {
1112
1403
  let sawChunk = false;
1113
1404
  // 走查过的**全部**文件路径(含被预算 / 单文件上限挡下的)—— 路径索引的来源(方向 B)
1114
1405
  const walked = [];
1406
+ // 走查中断时**没进去的子树**(精确到目录)—— 供删除集做分子树 fail-closed
1407
+ const unwalked = [];
1115
1408
  for (const chunk of chunks) {
1116
1409
  if (outOfBudget()) {
1117
1410
  // 分块之间就超预算:不再开新分块,并如实留痕(该分块的条目一条都没被走查)
1118
- // walkIncomplete 是**安全判据**(删除集 fail-closed),漏了它会删掉未走查子树里的文件
1411
+ // walkIncomplete 是**安全判据**(删除集 fail-closed);这里能精确给出「没进去的是哪个分块」
1412
+ // ⇒ 删除只拒这棵子树,不再把整个工作区的新建文件一起放弃。
1119
1413
  payload.walkIncomplete = true;
1120
1414
  payload.truncated = true;
1415
+ unwalked.push(chunk);
1121
1416
  this.recordSkip(payload, 'skipped', { path: chunk, reason: 'budget-time' });
1122
1417
  break;
1123
1418
  }
@@ -1125,108 +1420,48 @@ export class RollbackPointEngine {
1125
1420
  return this.emptyTrack('workspace', capturedAt, 'chunk-not-absolute', chunk);
1126
1421
  const resolved = path.resolve(chunk);
1127
1422
  // 文件数预算还剩多少就收多少;+1 只用于让遍历器发现「还有溢出」——溢出条目逐条记 skipped
1128
- const listed = await this.listChunkFiles(resolved, payload, Math.max(0, this.maxFiles - read) + 1, walked, await loadIgnoreRules(resolved), outOfBudget);
1423
+ // 候选名单上限取 ROLLBACK_CAPTURE_LIST_LIMIT(而不是 maxFiles):入点上限由阶段①的 slots
1424
+ // 把关,这里若提前截断,缺口优先排序就够不到尾部文件 ⇒ 覆盖率不收敛
1425
+ const listed = await this.listChunkFiles(resolved, payload, ROLLBACK_CAPTURE_LIST_LIMIT, walked, await loadIgnoreRules(resolved), outOfBudget);
1129
1426
  if (listed === null)
1130
1427
  continue;
1131
1428
  sawChunk = true;
1132
1429
  if (listed.truncated)
1133
1430
  payload.truncated = true;
1134
- for (const file of listed.files) {
1135
- if (outOfBudget()) {
1136
- // 同上:中断 = 剩余路径从未进任何名单 ⇒ 必须让删除集 fail-closed
1137
- payload.walkIncomplete = true;
1138
- payload.truncated = true;
1139
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-time' });
1140
- break;
1141
- }
1142
- if (read >= this.maxFiles) {
1143
- // 文件数预算耗尽:其后每一条都如实留痕(不再 break 静默丢弃)
1144
- payload.truncated = true;
1145
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-files' });
1146
- continue;
1147
- }
1148
- if (isInsidePath(file, this.root)) {
1149
- this.recordSkip(payload, 'skipped', { path: file, reason: 'self-store' });
1150
- continue;
1151
- }
1152
- // **先 stat 判大小、再决定读不读**:单文件超限或加上它会超总量 → 跳过并如实留痕
1153
- // (旧实现的 `read > 0` 短路让**首个**文件完全不设上限,会被整份读进内存 —— t5-F1)
1154
- let stat;
1155
- try {
1156
- stat = await fs.stat(file);
1157
- }
1158
- catch (error) {
1159
- payload.unreadableFiles.push({ path: file, reason: errorDetail(error) });
1160
- continue;
1161
- }
1162
- if (!stat.isFile()) {
1163
- this.recordSkip(payload, 'skipped', { path: file, reason: 'not-a-file' });
1164
- continue;
1165
- }
1166
- if (stat.size > this.maxFileBytes) {
1167
- payload.truncated = true;
1168
- this.recordSkip(payload, 'skipped', { path: file, reason: 'too-large' });
1169
- continue;
1170
- }
1171
- if (payload.bytes + stat.size > this.maxBytes) {
1172
- payload.truncated = true;
1173
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' });
1174
- continue;
1175
- }
1176
- try {
1177
- // 路径身份缓存:身份(dev:ino:size:mtimeNs:ctimeNs:mode)未变 ⇒ 复用已存 sha256,**不重读**
1178
- const identity = await this.pathIdentityOf(file);
1179
- const candidate = identity === null ? null : await this.lookupCachedFile(identity, file);
1180
- // **F-2(t12 修复)**:缓存命中也要确认对象仓里真有那个 blob —— 对象被外部清掉后
1181
- // 若直接复用 sha256,新点会「零对象却 canRestore=true」,预览骗人(执行才响亮失败)。
1182
- // 对象不在 ⇒ 当作未命中:真读 + 重写对象(缓存因此自愈)。
1183
- const cached = candidate === null || !(await this.objectExists(candidate.sha256)) ? null : candidate;
1184
- const data = cached === null ? await this.readFileBytes(file) : null;
1185
- const size = cached === null ? (data?.byteLength ?? 0) : cached.bytes;
1186
- if (cached === null && data !== null && data.byteLength !== size) {
1187
- payload.truncated = true;
1188
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' });
1189
- continue;
1190
- }
1191
- if (cached !== null) {
1192
- payload.files.push({ path: file, chunk: resolved, bytes: cached.bytes, sha256: cached.sha256, mtimeMs: stat.mtimeMs, object: cached.sha256 });
1193
- payload.bytes += cached.bytes;
1194
- read += 1;
1195
- continue;
1196
- }
1197
- if (data === null) {
1198
- payload.unreadableFiles.push({ path: file, reason: 'read-failed' });
1199
- continue;
1200
- }
1201
- // stat 与 read 之间文件可能变化:真读到的字节数仍必须服从总量预算(不猜、不静默)
1202
- if (payload.bytes + data.byteLength > this.maxBytes) {
1203
- payload.truncated = true;
1204
- this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' });
1205
- continue;
1206
- }
1207
- const sha = sha256Hex(data);
1208
- await this.writeObjectWithHash(data, sha);
1209
- if (identity !== null)
1210
- this.rememberCachedFile(identity, file, sha, data.byteLength);
1211
- payload.files.push({ path: file, chunk: resolved, bytes: data.byteLength, sha256: sha, mtimeMs: stat.mtimeMs, object: sha });
1212
- payload.bytes += data.byteLength;
1213
- read += 1;
1214
- }
1215
- catch (error) {
1216
- payload.unreadableFiles.push({ path: file, reason: errorDetail(error) });
1217
- }
1431
+ // 走查本身被打断 ⇒ 栈里还有没进去的子树(那些路径从未进任何名单)⇒ fail-closed。
1432
+ // **区别于**下面「走查已结束、只是读到一半超时」:后者的路径全在 walked 里,
1433
+ // 由文件末尾的覆盖自检补进证明名单(补不齐才拒删),不该整体拒删。
1434
+ if (listed.walkAborted) {
1435
+ payload.walkIncomplete = true;
1436
+ for (const dir of listed.unwalkedDirs)
1437
+ unwalked.push(dir);
1218
1438
  }
1439
+ read += await this.captureChunkCandidates(resolved, listed.files, Math.max(0, this.maxFiles - read), payload, outOfBudget);
1219
1440
  }
1441
+ // 精确的「没进去的子树」:有 ⇒ 删除集只在这些子树里 fail-closed;没有(老台账 / 栈被截断)
1442
+ // ⇒ 退回全局拒删(见 planCreatedRemovals)
1443
+ if (unwalked.length > 0)
1444
+ payload.unwalkedDirs = [...new Set(unwalked)].sort();
1220
1445
  // `truncated` 为真时**不能**判 chunk-not-found:那是「分块确实存在、只是没来得及走查完」
1221
1446
  // (时间预算 / 取消信号)—— 报成「找不到分块」会让用户以为工作区配错了。
1222
1447
  if (!sawChunk && payload.files.length === 0 && !payload.truncated) {
1223
1448
  return this.emptyTrack('workspace', capturedAt, 'chunk-not-found', chunks.join(' '));
1224
1449
  }
1225
- // **方向 B**:逐条跳过名单被记录上限截断(skippedTruncated)时,把走查过的全部路径另存成
1226
- // 路径索引对象 —— 「未记录」与「留点时就存在」只有在名单完整时才可区分。
1227
- // 只有这一种情形需要它:未截断时 files ∪ skipped 本身就是完整名单(零额外成本)。
1228
- // 写失败绝不改判留点成败:skippedTruncated 仍为 true ⇒ 回退侧 fail-closed 一条都不删(R-05f)。
1229
- if (payload.skippedTruncated) {
1450
+ // **方向 B**:把走查过的全部路径另存成路径索引对象 —— 「未记录」与「留点时就存在」只有在
1451
+ // 证明名单完整时才可区分。触发条件是**覆盖自检**(不是「跳过名单被截断」这一种特例):
1452
+ // · 逐条跳过名单被记录上限截断(skippedTruncated);或
1453
+ // · 读到一半被时间预算 / 取消打断 —— 本分块剩余文件进了 walked、却一条都没进任何名单。
1454
+ // 覆盖完整时不必写(未截断时 files ∪ skipped 本身就是完整名单,零额外成本)。
1455
+ // 写失败 ⇒ 证明名单补不齐 ⇒ 必须整体拒删(安全方向;R-05f)。
1456
+ const proven = new Set();
1457
+ for (const file of payload.files)
1458
+ proven.add(pathKey(file.path));
1459
+ for (const entry of payload.skipped)
1460
+ proven.add(pathKey(entry.path));
1461
+ for (const entry of payload.unreadableFiles)
1462
+ proven.add(pathKey(entry.path));
1463
+ const uncovered = walked.some((item) => !proven.has(pathKey(item)));
1464
+ if (uncovered) {
1230
1465
  try {
1231
1466
  const paths = [...new Set(walked)].sort();
1232
1467
  const gz = gzipSync(Buffer.from(paths.join('\n'), 'utf8'));
@@ -1236,6 +1471,8 @@ export class RollbackPointEngine {
1236
1471
  }
1237
1472
  catch (error) {
1238
1473
  this.warn('rollback path index failed: ' + errorDetail(error));
1474
+ // 走查过、却没进名单的路径无法被证明 ⇒ 删除集整体拒绝(绝不「删一半」)
1475
+ payload.walkIncomplete = true;
1239
1476
  }
1240
1477
  }
1241
1478
  // 用户忽略的整目录 ⇒ 证明名单不完整(删除集整体拒绝;见 planCreatedRemovals)。
@@ -1244,6 +1481,8 @@ export class RollbackPointEngine {
1244
1481
  payload.ignoreIncomplete = payload.ignoredDirCount > 0;
1245
1482
  // 路径身份缓存落盘(best-effort:写不进去只意味着下次全量读,绝不影响留点成败)
1246
1483
  await this.persistPathCache();
1484
+ // 落点列表排序:并发读让入点顺序不定,契约必须是「按路径有序」(预览 / 对账 / 指纹都靠它)
1485
+ payload.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
1247
1486
  // 留痕列表也排序:跨分块 / 依赖 readdir 顺序时输出仍确定(测试与对账都能逐条比)
1248
1487
  payload.skipped.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
1249
1488
  payload.excludedDirs.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
@@ -1251,9 +1490,25 @@ export class RollbackPointEngine {
1251
1490
  const canonical = [...payload.files]
1252
1491
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
1253
1492
  .map((f) => [f.path, f.bytes, f.sha256]);
1493
+ payload.fileCount = payload.files.length;
1254
1494
  const fingerprint = sha256Hex(JSON.stringify({ chunks: [...payload.chunks].sort(), files: canonical }));
1495
+ // 自适应预算(见 ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS):被截断 ⇒ 下次翻倍(上限);
1496
+ // 用得很少 ⇒ 下次减半(下限)。大仓库第一次撞上限后自动拿到更大预算,小仓库自动回落,
1497
+ // 不会每回合白等。回退期的走查预算(rewindBudgetMs)**不参与**这套调整。
1498
+ if (payload.truncated || payload.walkIncomplete === true) {
1499
+ this.captureBudgetMs = Math.min(this.captureBudgetMaxMs, this.captureBudgetMs * 2);
1500
+ }
1501
+ else if (this.stamp() - capturedAt < this.captureBudgetMs / 4) {
1502
+ this.captureBudgetMs = Math.max(this.captureBudgetMinMs, Math.floor(this.captureBudgetMs / 2));
1503
+ }
1504
+ // 「降级(留得不完整)」只由**真的缺内容**触发:
1505
+ // ① truncated —— 文件数 / 总字节 / 单文件上限 / 时间预算挡下的内容;
1506
+ // ② unreadableDirs / unreadableFiles —— 读不出来或捕获竞态(内容不可信)。
1507
+ // **不再用 skippedCount**:那里的条目大多是**设计内**的跳过(符号链接不跟随、文件级忽略、
1508
+ // 自身数据区、读之前就消失),既不缺内容也无法回退 —— 旧口径让「工作区里有任何一个符号链接」
1509
+ // 的仓库每个点都挂「回退可能不完整」,真出问题时反而被噪声淹没(真机 2026-10-15:10 个 link
1510
+ // 跳过 ⇒ 每个点 degraded=true)。条目与计数仍逐条留痕(skippedCount / skipped 列表不变)。
1255
1511
  const degraded = payload.truncated
1256
- || payload.skippedCount > 0
1257
1512
  || payload.unreadableDirs.length > 0
1258
1513
  || payload.unreadableFiles.length > 0;
1259
1514
  return {
@@ -1269,6 +1524,213 @@ export class RollbackPointEngine {
1269
1524
  }
1270
1525
  /* ---------------------------------------------------------- 忽略规则 / 路径身份缓存 */
1271
1526
  /** 用户忽略规则是否命中该路径(只认「相对分块根」的斜杠路径) */
1527
+ /**
1528
+ * 处理一个分块的候选文件(**两阶段**)。
1529
+ *
1530
+ * ① **顺序判定**(与旧的串行实现逐条一致,保证预算 / 计数 / 字节上限完全确定):
1531
+ * 文件数上限、时间预算、自身数据区、stat、单文件上限、总量上限、身份缓存 + 对象存在性
1532
+ * → 得出「当场入点(缓存命中)」还是「要真读」,该跳过的**当场**按原有 reason 逐条留痕。
1533
+ * stat 按**有序批**并发取(判定仍严格按顺序),并用「一次 readdir 的对象名单快照」代替
1534
+ * 每个文件一次 fs.access —— 这两处是稳态成本的大头(真机实测各占 ~5 s / ~3.7 s)。
1535
+ * ② **并发读 + 哈希 + 落对象仓**:这是捕获耗时的**唯一大头**(真机 148 s / 31,702 个对象,
1536
+ * 96% 的时间),用 captureConcurrency 个 worker 并发跑;并用**在飞字节闸门**保证
1537
+ * 「同时在飞的内容字节 ≤ captureInFlightBytes」。**不是** maxFileBytes —— 它缺省 =
1538
+ * 总量预算(512 MiB),拿它当闸门等于允许把整个点的预算同时驻留(0.1.74 闪退根因)。
1539
+ *
1540
+ * 返回本分块**真正入点**的文件数(调用方据此推进 maxFiles 预算)。
1541
+ * 没判到 / 没读完的条目一律逐条进 skipped(budget-time),绝不静默丢弃。
1542
+ */
1543
+ async captureChunkCandidates(chunk, candidates, slots, payload, outOfBudget) {
1544
+ // 缺口优先:身份缓存里还没有的路径排前面 ⇒ 每个点都在补上次没覆盖的部分(见 orderByCoverage)
1545
+ const ordered = await this.orderByCoverage(candidates);
1546
+ const decided = new Array(ordered.length).fill(false);
1547
+ const plans = [];
1548
+ let captured = 0;
1549
+ let newReads = 0;
1550
+ // 字节预算必须在**准入那一刻**按 stat.size 预留:真正入点发生在并发的第二阶段,
1551
+ // 若拿 payload.bytes 当判据,同一个分块里后面的文件会全部误判成「还有额度」
1552
+ let accounted = payload.bytes;
1553
+ // 对象名单快照:一次 readdir 顶掉「每个候选一次 fs.access」(读不到则回落逐条判断)
1554
+ const names = await this.objectNamesSnapshot();
1555
+ let cursor = 0;
1556
+ while (cursor < ordered.length) {
1557
+ if (outOfBudget())
1558
+ break; // 剩余条目由收尾循环逐条记 budget-time(不预取 stat)
1559
+ const window = ordered.slice(cursor, cursor + ROLLBACK_CAPTURE_STAT_BATCH);
1560
+ const stats = await Promise.all(window.map((file) => this.fileStatOf(file)));
1561
+ let broke = false;
1562
+ for (let offset = 0; offset < window.length; offset += 1) {
1563
+ const file = window[offset] ?? '';
1564
+ const info = stats[offset] ?? { isFile: false, size: 0, mtimeMs: 0, identity: null, error: 'stat-missing' };
1565
+ const index = cursor + offset;
1566
+ if (outOfBudget()) {
1567
+ broke = true;
1568
+ break;
1569
+ }
1570
+ decided[index] = true;
1571
+ if (isInsidePath(file, this.root)) {
1572
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'self-store' });
1573
+ continue;
1574
+ }
1575
+ // **先 stat 判大小、再决定读不读**:单文件超限或加上它会超总量 → 跳过并如实留痕
1576
+ // (旧实现的 read > 0 短路让**首个**文件完全不设上限,会被整份读进内存 —— t5-F1)
1577
+ if (info.error !== undefined) {
1578
+ payload.unreadableFiles.push({ path: file, reason: info.error });
1579
+ continue;
1580
+ }
1581
+ if (!info.isFile) {
1582
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'not-a-file' });
1583
+ continue;
1584
+ }
1585
+ if (info.size > this.maxFileBytes) {
1586
+ payload.truncated = true;
1587
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'too-large' });
1588
+ continue;
1589
+ }
1590
+ // 路径身份缓存:身份(dev:ino:size:mtimeNs:ctimeNs:mode)未变 ⇒ 复用已存 sha256,**不重读**
1591
+ const candidate = info.identity === null ? null : await this.lookupCachedFile(info.identity, file);
1592
+ // **F-2(t12 修复)**:缓存命中也要确认对象仓里真有那个 blob —— 对象被外部清掉后
1593
+ // 若直接复用 sha256,新点会「零对象却 canRestore=true」,预览骗人(执行才响亮失败)。
1594
+ // 对象不在 ⇒ 当作未命中:真读 + 重写对象(缓存因此自愈)。
1595
+ const objectPresent = candidate === null
1596
+ ? false
1597
+ : names !== null ? names.has(candidate.sha256) : await this.objectExists(candidate.sha256);
1598
+ if (candidate !== null && objectPresent) {
1599
+ // 身份未变 ⇒ 内容必然与缓存一致:零 I/O 直接入点(稳态成本与仓库大小无关的来源)。
1600
+ // **不占读预算**:maxFiles / maxBytes 约束的是「这回合新读进来的内容」,不是「记录多少条」
1601
+ // —— 否则大仓库每个点都只能记下前 N 条,滚回某点时会漏掉其余文件(覆盖率永远不收敛)。
1602
+ payload.files.push({
1603
+ path: file, chunk, bytes: candidate.bytes, sha256: candidate.sha256, mtimeMs: info.mtimeMs, object: candidate.sha256,
1604
+ });
1605
+ payload.bytes += candidate.bytes;
1606
+ captured += 1;
1607
+ continue;
1608
+ }
1609
+ // 只有**要真读的新内容**才受每回合的规模预算约束(判定仍严格按顺序 ⇒ 结果确定)
1610
+ if (newReads >= slots) {
1611
+ payload.truncated = true;
1612
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-files' });
1613
+ continue;
1614
+ }
1615
+ if (accounted + info.size > this.maxBytes) {
1616
+ payload.truncated = true;
1617
+ this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' });
1618
+ continue;
1619
+ }
1620
+ accounted += info.size;
1621
+ newReads += 1;
1622
+ plans.push({ path: file, size: info.size, mtimeMs: info.mtimeMs, identity: info.identity });
1623
+ }
1624
+ cursor += window.length;
1625
+ if (broke)
1626
+ break;
1627
+ }
1628
+ if (plans.length > 0) {
1629
+ // ② 并发读 + 落对象仓。outOfBudget 一命中就**不再开新条目**(在飞的照常收尾),
1630
+ // 未开工的条目在循环外逐条记 budget-time —— 与串行版「中断处逐条留痕」同一诚实口径。
1631
+ const gate = new ByteGate(this.captureInFlightBytes);
1632
+ const done = new Array(plans.length).fill(false);
1633
+ let planCursor = 0;
1634
+ let stopped = false;
1635
+ const workers = Array.from({ length: Math.min(this.captureConcurrency, plans.length) }, async () => {
1636
+ for (;;) {
1637
+ if (stopped)
1638
+ return;
1639
+ const index = planCursor;
1640
+ planCursor += 1;
1641
+ if (index >= plans.length)
1642
+ return;
1643
+ const item = plans[index];
1644
+ if (outOfBudget()) {
1645
+ stopped = true;
1646
+ return;
1647
+ }
1648
+ await gate.acquire(item.size);
1649
+ try {
1650
+ if (outOfBudget()) {
1651
+ stopped = true;
1652
+ return;
1653
+ }
1654
+ const data = await this.readFileBytes(item.path);
1655
+ if (data.byteLength !== item.size) {
1656
+ // stat 与 read 之间文件变了(旧口径:budget-bytes)—— 绝不按旧字节数记账
1657
+ payload.truncated = true;
1658
+ this.recordSkip(payload, 'skipped', { path: item.path, reason: 'budget-bytes' });
1659
+ done[index] = true;
1660
+ continue;
1661
+ }
1662
+ // **捕获竞态**:读之前拿到的身份若在读完时已变,说明这次读到的内容未必是「留点时刻」
1663
+ // 的内容(回合与留点并发)⇒ 不当作前像,只如实进 unreadableFiles(它本身是**证明名单**
1664
+ // 的一员:证明「留点时存在」,因此绝不会被当成新建删掉)。
1665
+ const after = item.identity === null ? null : await this.pathIdentityOf(item.path);
1666
+ if (item.identity !== null && after !== item.identity) {
1667
+ payload.unreadableFiles.push({ path: item.path, reason: 'changed-during-read' });
1668
+ done[index] = true;
1669
+ continue;
1670
+ }
1671
+ const sha = sha256Hex(data);
1672
+ await this.writeObjectWithHash(data, sha);
1673
+ if (item.identity !== null)
1674
+ this.rememberCachedFile(item.identity, item.path, sha, data.byteLength);
1675
+ payload.files.push({
1676
+ path: item.path, chunk, bytes: data.byteLength, sha256: sha, mtimeMs: item.mtimeMs, object: sha,
1677
+ });
1678
+ payload.bytes += data.byteLength;
1679
+ captured += 1;
1680
+ done[index] = true;
1681
+ }
1682
+ catch (error) {
1683
+ payload.unreadableFiles.push({ path: item.path, reason: errorDetail(error) });
1684
+ done[index] = true;
1685
+ }
1686
+ finally {
1687
+ gate.release(item.size);
1688
+ }
1689
+ }
1690
+ });
1691
+ await Promise.all(workers);
1692
+ for (let index = 0; index < plans.length; index += 1) {
1693
+ if (done[index] === true)
1694
+ continue;
1695
+ payload.truncated = true;
1696
+ this.recordSkip(payload, 'skipped', { path: plans[index].path, reason: 'budget-time' });
1697
+ }
1698
+ }
1699
+ // 阶段 ① 里没判定到的(时间预算打断)同样逐条留痕 —— 覆盖自检因此仍认为名单完整
1700
+ for (let index = 0; index < ordered.length; index += 1) {
1701
+ if (decided[index] === true)
1702
+ continue;
1703
+ payload.truncated = true;
1704
+ this.recordSkip(payload, 'skipped', { path: ordered[index] ?? '', reason: 'budget-time' });
1705
+ }
1706
+ // 返回值 = 本分块消耗掉的**读槽位**(缓存命中不占槽位;调用方据此推进 maxFiles)
1707
+ return newReads;
1708
+ }
1709
+ /**
1710
+ * 缺口优先排序:身份缓存里**没有**的路径排前面。
1711
+ *
1712
+ * 为什么:预算固定时按目录顺序走查,同一个「已覆盖且便宜」的前缀每回合都会吃掉全部预算,
1713
+ * 尾部永远进不来(真机:34k 文件每次只覆盖 1/5,且五次都是同一批 outputs/ 前缀)。
1714
+ * 未覆盖的排前面 ⇒ 每个点都在补上一次的缺口,覆盖率随回合**单调收敛**到 100%。
1715
+ * 组内保持走查顺序(稳定),因此同一输入下的结果仍然确定。
1716
+ */
1717
+ async orderByCoverage(candidates) {
1718
+ if (candidates.length <= 1)
1719
+ return [...candidates];
1720
+ const cache = await this.loadPathCache();
1721
+ if (cache.size === 0)
1722
+ return [...candidates];
1723
+ const covered = new Set();
1724
+ for (const entry of cache.values())
1725
+ covered.add(pathKey(entry.path));
1726
+ if (covered.size === 0)
1727
+ return [...candidates];
1728
+ const cold = [];
1729
+ const hot = [];
1730
+ for (const file of candidates)
1731
+ (covered.has(pathKey(file)) ? hot : cold).push(file);
1732
+ return cold.length === 0 ? hot : [...cold, ...hot];
1733
+ }
1272
1734
  ignoredByRules(rules, target, isDir) {
1273
1735
  const rel = ignoreRelativePath(rules.root, target);
1274
1736
  return rel === null ? false : ignoreRuleMatches(rules, rel, isDir);
@@ -1285,19 +1747,43 @@ export class RollbackPointEngine {
1285
1747
  * 用毫秒换算同样不行:会把「同一毫秒内的两次写」当成同一个身份。
1286
1748
  * 读不到身份(文件消失 / 权限 / 平台不支持 bigint stat)→ null ⇒ 回落全量真读。
1287
1749
  */
1750
+ /**
1751
+ * 一次 bigint stat 拿到阶段①需要的**全部**东西(是否文件 / 大小 / mtime / 身份)。
1752
+ * 旧实现「fs.stat + pathIdentityOf」每文件 stat 两次,真机 34k 文件白花约 5 s。
1753
+ */
1754
+ async fileStatOf(target) {
1755
+ try {
1756
+ const stat = await fs.stat(target, { bigint: true });
1757
+ const isFile = stat.isFile();
1758
+ return {
1759
+ isFile,
1760
+ size: Number(stat.size),
1761
+ mtimeMs: Number(stat.mtimeMs),
1762
+ identity: isFile ? fileIdentityOf(stat) : null,
1763
+ };
1764
+ }
1765
+ catch (error) {
1766
+ return { isFile: false, size: 0, mtimeMs: 0, identity: null, error: errorDetail(error) };
1767
+ }
1768
+ }
1769
+ /** 对象名单快照(每个捕获回合一次 readdir);读不到则返回 null ⇒ 调用方回落逐条判断 */
1770
+ async objectNamesSnapshot() {
1771
+ if (this.captureObjectNames !== null)
1772
+ return this.captureObjectNames;
1773
+ try {
1774
+ this.captureObjectNames = new Set(await fs.readdir(this.objectsDir));
1775
+ }
1776
+ catch {
1777
+ this.captureObjectNames = null;
1778
+ }
1779
+ return this.captureObjectNames;
1780
+ }
1288
1781
  async pathIdentityOf(target) {
1289
1782
  try {
1290
1783
  const stat = await fs.stat(target, { bigint: true });
1291
1784
  if (!stat.isFile())
1292
1785
  return null;
1293
- return [
1294
- String(stat.dev),
1295
- String(stat.ino),
1296
- String(stat.size),
1297
- String(stat.mtimeNs),
1298
- String(stat.ctimeNs),
1299
- String(stat.mode & 511n),
1300
- ].join(':');
1786
+ return fileIdentityOf(stat);
1301
1787
  }
1302
1788
  catch {
1303
1789
  return null;
@@ -1342,6 +1828,25 @@ export class RollbackPointEngine {
1342
1828
  return map;
1343
1829
  }
1344
1830
  /** 缓存命中:身份键存在、且路径逐字相同(身份被复用也绝不认)。 */
1831
+ /**
1832
+ * 「这条记录的当前内容**仍然是它**吗」的身份快路径 —— **预览与执行共用同一条判据**
1833
+ * (否则「预览说会改的 = 实际会改的」这条不变式会被打破)。
1834
+ *
1835
+ * 判据:路径身份(dev:ino:size:mtimeNs:ctimeNs:mode)命中**身份缓存**,且缓存里的 sha/bytes 与记录一致。
1836
+ * 身份未变 ⇒ 内容未变:缓存里的 sha 就是**这份内容**在留点那一次真读出来的哈希(身份含 size/mtimeNs/
1837
+ * ctimeNs/ino/dev/mode,改内容必动 mtime/ctime,Windows 上 user 代码改不了 ctime)。
1838
+ * 任何一环拿不到(读不到身份 / 缓存没有该身份 / sha 或 bytes 不同)⇒ false,调用方回落「真读 + 哈希」。
1839
+ *
1840
+ * 为什么必须有它:大仓库一次预览/回退要判 3 万+ 条目,逐个真读等于把整个工作区读两遍
1841
+ * (真机 34k 文件 / 609 MiB:预览 14.2 s 里 13 s 全在这,回退 60 s+ 同理)。**记录的指纹被篡改**
1842
+ * 时本判据必然不成立(缓存的 sha 是真算出来的)⇒ 仍走真读 + 哈希的旧路径,护栏不退化。
1843
+ */
1844
+ async unchangedByIdentity(file, identity) {
1845
+ if (identity === null)
1846
+ return false;
1847
+ const cached = await this.lookupCachedFile(identity, file.path);
1848
+ return cached !== null && cached.sha256 === file.sha256 && cached.bytes === file.bytes;
1849
+ }
1345
1850
  async lookupCachedFile(identity, target) {
1346
1851
  const cache = await this.loadPathCache();
1347
1852
  const hit = cache.get(identity);
@@ -1597,6 +2102,70 @@ export class RollbackPointEngine {
1597
2102
  * 诚实的边界:检测与写入之间仍有极小窗口,无跨进程锁时无法彻底消除 —— 这里保证的是
1598
2103
  * 「不静默覆盖 + 尽量合并」,不是原子性。
1599
2104
  */
2105
+ /**
2106
+ * 把工作区载荷改写成**增量视图**(相对最近一个同分块的点)——清单增量化,省的是磁盘,不是语义。
2107
+ *
2108
+ * 判据与安全边界(逐条):
2109
+ * · 基准 = 清单里最近一个 `workspace.payload` 非空、**分块完全相同**的点(只试最近几个,代价有界);
2110
+ * · 增量 = 「相对基准新增 / 改写的条目」+「基准里不再存在的路径」,条目**逐字比较**(同形绝对路径);
2111
+ * · 只有增量明显更小(< 全量的 60%)才用;否则原样全量落盘;
2112
+ * · 读路径由 ledger 物化(`resolvePayload`)⇒ 预览 / 回退 / 回收看到的永远是完整清单;
2113
+ * · 基准读不出来 / 分块对不上 / 任何异常 ⇒ 原样全量(绝不因为省字节而让点不可用);
2114
+ * · 基准载荷的保活由 ledger 的 `payloadChain` 负责(淘汰时不会把基准删掉)。
2115
+ */
2116
+ async storagePlanFor(record, entries) {
2117
+ const full = { record, reuse: null };
2118
+ const payload = record.workspace.payload;
2119
+ if (payload === null || record.workspace.ok !== true || payload.files.length === 0)
2120
+ return full;
2121
+ if (payload.delta !== undefined)
2122
+ return full;
2123
+ // 基准 = **本进程上一次成功留点**的内存摘要(绝不读别人的载荷/记录文件:L-08 / issue #89)。
2124
+ // 没有摘要(进程刚起 / 分块变了)⇒ 这一次落**全量锚点**,随后由摘要继续链下去。
2125
+ const base = this.deltaBase;
2126
+ if (base !== null && sameChunks(base.chunks, payload.chunks)) {
2127
+ const diff = payloadDiffAgainstDigest(payload, base.entries);
2128
+ if (diff.added.length === 0 && diff.removed.length === 0) {
2129
+ // 工作区与该点**逐条相同** ⇒ 直接复用它的载荷文件(零额外字节、链不增长)
2130
+ const entry = entries.find((candidate) => candidate.workspace.payload === base.hash);
2131
+ if (entry !== undefined && (await this.ledger.hasPayload(base.hash))) {
2132
+ return { record, reuse: { hash: base.hash, bytes: entry.workspace.payloadBytes, chain: [...base.chain] } };
2133
+ }
2134
+ }
2135
+ else if (base.chain.length < ROLLBACK_DELTA_CHAIN_LIMIT) {
2136
+ const delta = buildPayloadDelta(payload, base.hash, base.chain, diff);
2137
+ if (delta !== null) {
2138
+ return {
2139
+ record: { ...record, workspace: { ...record.workspace, payload: compactPayloadForStorage(delta) } },
2140
+ reuse: null,
2141
+ };
2142
+ }
2143
+ }
2144
+ }
2145
+ // 没有可用基准 / 分块不同 / 增量不划算 ⇒ 全量落盘(仍省掉冗余的 object 字段)
2146
+ return {
2147
+ record: { ...record, workspace: { ...record.workspace, payload: compactPayloadForStorage(payload) } },
2148
+ reuse: null,
2149
+ };
2150
+ }
2151
+ /** 记下这份点作为下一次留点的增量基准(内存摘要,见 {@link deltaBase}) */
2152
+ rememberDeltaBase(record, prepared) {
2153
+ const payload = record.workspace.payload;
2154
+ const hash = prepared.payloadHash;
2155
+ if (payload === null || hash === null) {
2156
+ this.deltaBase = null;
2157
+ return;
2158
+ }
2159
+ const entries = new Map();
2160
+ for (const file of payload.files)
2161
+ entries.set(file.path, digestKeyOf(file));
2162
+ this.deltaBase = {
2163
+ hash,
2164
+ chain: prepared.entry.workspace.payloadChain ?? [hash],
2165
+ chunks: [...payload.chunks],
2166
+ entries,
2167
+ };
2168
+ }
1600
2169
  async appendRecord(record) {
1601
2170
  for (let attempt = 0; attempt < 3; attempt += 1) {
1602
2171
  const result = await this.appendRecordOnce(record);
@@ -1626,7 +2195,10 @@ export class RollbackPointEngine {
1626
2195
  return { ok: false, detail: 'ledger unreadable: ' + manifest.error };
1627
2196
  }
1628
2197
  }
1629
- const prepared = this.ledger.prepareRecord(record);
2198
+ // 清单增量化:能省就把这份载荷改写成「相对最近一个同分块载荷的增量」(读路径由 ledger 物化回来)。
2199
+ // 失败 / 不划算 / 没有可用基准一律**原样返回** —— 增量只影响磁盘占用,绝不参与正确性判断。
2200
+ const storage = await this.storagePlanFor(record, manifest.entries);
2201
+ const prepared = this.ledger.prepareRecord(storage.record, storage.reuse ?? undefined);
1630
2202
  const merged = [prepared.entry, ...manifest.entries.filter((r) => r.id !== record.id)];
1631
2203
  const pruned = this.selectPruneEntries(merged);
1632
2204
  // 本次新增字节:记录文件 + **首次落盘**的载荷(同一工作区态已存在 ⇒ 零额外字节)
@@ -1657,6 +2229,8 @@ export class RollbackPointEngine {
1657
2229
  await this.ledger.prunePayloads(manifest.entries);
1658
2230
  return { ok: false, detail: errorDetail(error) };
1659
2231
  }
2232
+ // 记下「这次留点」作为下一次的增量基准(只在真正写成功后;失败不更新 ⇒ 链只跨成功落盘的点)
2233
+ this.rememberDeltaBase(record, prepared);
1660
2234
  const keptIds = new Set(kept.map((entry) => entry.id));
1661
2235
  const dropped = merged.filter((entry) => !keptIds.has(entry.id)).map((entry) => entry.id);
1662
2236
  await this.ledger.deleteRecords(dropped);
@@ -1974,6 +2548,27 @@ export class RollbackPointEngine {
1974
2548
  return out;
1975
2549
  }
1976
2550
  /* -------------------------------------------------------------- 预览 */
2551
+ /**
2552
+ * 预览的**单条只读判定**(阶段 A,可并发跑):一次 bigint lstat + 身份快路径。
2553
+ * `unchanged = true` 表示「已证明与记录一致」(身份命中身份缓存且 sha/bytes 相同)⇒ 调用方直接跳过。
2554
+ */
2555
+ async previewScan(file) {
2556
+ try {
2557
+ // **lstat,不是 stat**:写入侧用 `symlink:'reject'`(lstat 是链接即抛),预览若跟随链接就会
2558
+ // 说「会创建 / 会覆盖」,执行却整段失败 —— 两侧必须是同一个口径。链接一律按「不可写」报。
2559
+ const stat = await fs.lstat(file.path, { bigint: true });
2560
+ if (stat.isSymbolicLink())
2561
+ return { kind: 'unavailable' };
2562
+ const size = Number(stat.size);
2563
+ const regular = stat.isFile();
2564
+ const unchanged = regular && size === file.bytes
2565
+ && (await this.unchangedByIdentity(file, regular ? fileIdentityOf(stat) : null));
2566
+ return { kind: 'ok', size, regular, unchanged };
2567
+ }
2568
+ catch (error) {
2569
+ return classifyPreviewStatFailure(error) === 'missing' ? { kind: 'missing' } : { kind: 'unavailable' };
2570
+ }
2571
+ }
1977
2572
  trackWarnings(record) {
1978
2573
  const out = [];
1979
2574
  for (const track of [record.workspace, record.session]) {
@@ -2022,32 +2617,44 @@ export class RollbackPointEngine {
2022
2617
  for (const entry of payload.ignoredDirs ?? [])
2023
2618
  proof.add(pathKey(entry.path));
2024
2619
  let refused = null;
2025
- if (payload.skippedTruncated) {
2026
- // 逐条跳过名单被记录上限截断 ⇒ 必须有路径索引,否则「未记录」≠「留点之后新建」
2027
- const index = payload.pathIndex ?? null;
2028
- if (index === null)
2620
+ // 路径索引**存在即并入**证明名单:写入条件已从「跳过名单被截断」推广为「覆盖自检不完整」
2621
+ // (读到一半被预算打断也会写索引)—— 这里只看 skippedTruncated 会漏读索引 ⇒ 把老文件当新建删掉。
2622
+ const index = payload.pathIndex ?? null;
2623
+ if (index !== null) {
2624
+ try {
2625
+ for (const item of await this.readPathIndex(index))
2626
+ proof.add(pathKey(item));
2627
+ }
2628
+ catch (error) {
2629
+ this.warn('rollback path index unreadable: ' + errorDetail(error));
2029
2630
  refused = 'capture-incomplete';
2030
- else {
2031
- try {
2032
- for (const item of await this.readPathIndex(index))
2033
- proof.add(pathKey(item));
2034
- }
2035
- catch (error) {
2036
- this.warn('rollback path index unreadable: ' + errorDetail(error));
2037
- refused = 'capture-incomplete';
2038
- }
2039
2631
  }
2040
2632
  }
2041
- // 读不出的目录:目录内容在留点时刻**未知**(路径索引只含走查过的路径)⇒ 同样证明不了
2042
- if (refused === null && payload.unreadableDirs.length > 0)
2633
+ else if (payload.skippedTruncated) {
2634
+ // 逐条跳过名单被记录上限截断、又没有索引 ⇒ 「未记录」≠「留点之后新建」,证明不了
2043
2635
  refused = 'capture-incomplete';
2044
- // 用户忽略的整目录 ⇒ 其下内容在留点时刻未知 ⇒ 证明名单不完整(fail-closed)
2045
- if (refused === null && payload.ignoreIncomplete === true)
2046
- refused = 'capture-incomplete';
2047
- // 走查被时间预算 / 取消信号中断 ⇒ 剩余子树**一条都没进任何名单** ⇒ 同样证明不了
2048
- // (这是 `truncated` 之外的第四种「名单不完整」:其余三种截断都会逐条留痕)
2049
- if (refused === null && payload.walkIncomplete === true)
2636
+ }
2637
+ // —— 分子树拒删范围 ——
2638
+ // 忽略目录 / 读不出的目录 / 走查没进去的子树:这三类的共同点是「其下内容在留点时刻未知」,
2639
+ // 但它们**各自只影响自己那棵子树**。旧口径一律全局拒删,代价是「忽略一个 outputs/ 就让整个
2640
+ // 工作区的新建文件一条都不删」;现在只拒这几棵子树,范围之外由证明名单照常判。
2641
+ // 走查中断且拿不到精确子树列表(老台账 / 栈被截断)时才退回全局。
2642
+ // 范围**说得清**才敢分子树拒删:忽略目录的逐条列表可能被记录上限截断(ignoredDirCount 是恒增
2643
+ // 计数),走查中断也可能拿不到子树列表(老台账 / 栈太深)。任一「说不清」⇒ 退回全局拒删。
2644
+ const ignoredKnown = (payload.ignoredDirs ?? []).length >= (payload.ignoredDirCount ?? 0);
2645
+ const unwalkedKnown = (payload.unwalkedDirs ?? []).length > 0 || payload.walkIncomplete !== true;
2646
+ const scopesKnown = ignoredKnown && unwalkedKnown;
2647
+ const refusalScopes = scopesKnown
2648
+ ? [
2649
+ ...(payload.ignoredDirs ?? []).map((entry) => entry.path),
2650
+ ...payload.unreadableDirs,
2651
+ ...(payload.unwalkedDirs ?? []),
2652
+ ]
2653
+ : [];
2654
+ if (refused === null && !scopesKnown) {
2655
+ // 证明名单不完整、且**说不出**不完整的确切范围 ⇒ 整个工作区一条都不删(安全方向)
2050
2656
  refused = 'capture-incomplete';
2657
+ }
2051
2658
  const chunks = payload.chunks;
2052
2659
  // 走查上限 = 全树:候选的**准入**由证明名单负责,条数上限不再是安全阀
2053
2660
  //(旧上限 maxFiles*4 会在巨型工作区里静默漏掉真正的新建文件 —— 那是「删一半」,不是 fail-closed)
@@ -2055,7 +2662,7 @@ export class RollbackPointEngine {
2055
2662
  // **时间预算**:回退期的走查同样不能无限跑(分块 = 会话 cwd,家目录 / 大仓库会走很久,
2056
2663
  // 而捕获侧早就有 30 s 预算 —— 只修一侧等于把 OOM 形态从留点搬到了回退)。
2057
2664
  // 中断只会让候选**变少**(安全方向),但必须如实置 truncated(绝不静默漏删、也不谎报完整)。
2058
- const deadline = this.stamp() + this.captureBudgetMs;
2665
+ const deadline = this.stamp() + this.rewindBudgetMs;
2059
2666
  const outOfBudget = () => this.stamp() >= deadline;
2060
2667
  const candidates = [];
2061
2668
  let truncated = false;
@@ -2081,13 +2688,20 @@ export class RollbackPointEngine {
2081
2688
  candidates.push(file);
2082
2689
  }
2083
2690
  }
2084
- const refusalWarnings = this.createdRefusalWarnings(payload);
2691
+ // 警告只在**全局拒绝**时给:分子树拒绝由 createdSkipped 逐条说明(那句「本次不删除任何
2692
+ // 『留点后新建』的路径」在分子树拒绝下是假话 —— 文案诚实要求按真实范围分派)。
2693
+ const refusalWarnings = refused === null ? [] : this.createdRefusalWarnings(payload);
2085
2694
  const all = [];
2086
2695
  const paths = [];
2087
2696
  const skipped = [];
2088
2697
  for (const file of candidates) {
2089
2698
  all.push(file);
2090
2699
  // 拒删的也留在「未记录」清单里(如实报告),但绝不进删除集合
2700
+ // 分子树拒删(忽略目录 / 读不出的目录 / 没走查到的子树):只拒这几棵子树里的候选
2701
+ if (refusalScopes.length > 0 && refusalScopes.some((dir) => isInsidePath(file, dir))) {
2702
+ skipped.push(file);
2703
+ continue;
2704
+ }
2091
2705
  if (this.createdRemovalRefusal(file, chunks) !== null) {
2092
2706
  skipped.push(file);
2093
2707
  continue;
@@ -2236,19 +2850,27 @@ export class RollbackPointEngine {
2236
2850
  const changedFiles = [];
2237
2851
  // 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
2238
2852
  const diffBudget = { bytes: 0, work: 0 };
2239
- for (const file of wsPayload.files) {
2240
- let outcome;
2241
- try {
2242
- // **lstat,不是 stat**:写入侧用 `symlink:'reject'`(lstat 是链接即抛),预览若跟随链接就会
2243
- // 说「会创建 / 会覆盖」,执行却整段失败 —— 两侧必须是同一个口径。链接一律按「不可写」报。
2244
- const stat = await fs.lstat(file.path);
2245
- outcome = stat.isSymbolicLink()
2246
- ? { kind: 'unavailable' }
2247
- : { kind: 'ok', size: stat.size, regular: stat.isFile() };
2248
- }
2249
- catch (error) {
2250
- outcome = classifyPreviewStatFailure(error) === 'missing' ? { kind: 'missing' } : { kind: 'unavailable' };
2853
+ // T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
2854
+ const previewFiles = wsPayload.files;
2855
+ // 阶段 A(**并发**):逐条一次 bigint lstat + 身份快路径。大仓库这里是预览的大头:
2856
+ // 真机 34k 条实测 —— 串行 lstat ≈ 6 s、串行真读 ≈ 13 s;并发后降到亚秒级 + 只读真正变了的那几条。
2857
+ const scans = new Array(previewFiles.length);
2858
+ let scanCursor = 0;
2859
+ const scanWorkers = Array.from({ length: Math.min(this.captureConcurrency, Math.max(1, previewFiles.length)) }, async () => {
2860
+ for (;;) {
2861
+ const index = scanCursor;
2862
+ scanCursor += 1;
2863
+ if (index >= previewFiles.length)
2864
+ return;
2865
+ scans[index] = await this.previewScan(previewFiles[index]);
2251
2866
  }
2867
+ });
2868
+ await Promise.all(scanWorkers);
2869
+ // 阶段 B(**顺序**):只对「变了 / 没了 / 不可写」的条目按记录顺序装配 —— 列表顺序与行级 diff 的
2870
+ // 共享预算因此仍**完全确定**(与并发前逐条一致,测试可逐字对账)。
2871
+ for (let index = 0; index < previewFiles.length; index += 1) {
2872
+ const file = previewFiles[index];
2873
+ const outcome = scans[index] ?? { kind: 'unavailable' };
2252
2874
  if (outcome.kind === 'missing') {
2253
2875
  // **只有 ENOENT** 才算「当前确实不存在」⇒ 回退会重新创建它
2254
2876
  missing += 1;
@@ -2266,6 +2888,9 @@ export class RollbackPointEngine {
2266
2888
  changedFiles.push({ path: file.path, status: 'overwrite', ...previewCountsUnavailable() });
2267
2889
  continue;
2268
2890
  }
2891
+ // 身份快路径已证明「与记录一致」⇒ 一个字段都不改(内容一致的文件不列进列表)
2892
+ if (outcome.unchanged)
2893
+ continue;
2269
2894
  const { size, regular } = outcome;
2270
2895
  const sizeMatches = regular && size === file.bytes;
2271
2896
  const withinCaps = file.bytes <= this.previewDiffMaxFileBytes && size <= this.previewDiffMaxFileBytes;
@@ -2274,7 +2899,7 @@ export class RollbackPointEngine {
2274
2899
  // ② 大小不一致时也得读一次才能给行级统计(超限的大文件不白读)。
2275
2900
  if (regular && (sizeMatches || withinCaps)) {
2276
2901
  try {
2277
- current = await fs.readFile(file.path);
2902
+ current = await this.readFileBytes(file.path);
2278
2903
  }
2279
2904
  catch {
2280
2905
  current = null;
@@ -2475,7 +3100,7 @@ export class RollbackPointEngine {
2475
3100
  return { ...view, reason: 'too-large' };
2476
3101
  if (before.binary || after.binary)
2477
3102
  return { ...view, reason: 'binary' };
2478
- const result = diffLines(current === null ? '' : DIFF_TEXT_DECODER.decode(current), DIFF_TEXT_DECODER.decode(snapshot), { context: ROLLBACK_FILE_DIFF_LIMITS.context });
3103
+ const result = diffLines(current === null ? '' : DIFF_TEXT_DECODER.decode(current), DIFF_TEXT_DECODER.decode(snapshot), { context: ROLLBACK_FILE_DIFF_LIMITS.context, maxD: ROLLBACK_FILE_DIFF_LIMITS.maxD });
2479
3104
  return {
2480
3105
  ...view,
2481
3106
  hunks: result.hunks, added: result.added, removed: result.removed,
@@ -2691,6 +3316,8 @@ export class RollbackPointEngine {
2691
3316
  const detail = [];
2692
3317
  const writable = [];
2693
3318
  let failed = 0;
3319
+ // 「已与记录一致」的条目数(身份快路径证明)—— 后面「0 变更」的文案与统计要用
3320
+ let unchanged = 0;
2694
3321
  for (const file of payload.files) {
2695
3322
  // 门:记录里的路径必须落在记录里的分块内(记录不可成为写原语)
2696
3323
  const inChunk = payload.chunks.some((chunk) => path.isAbsolute(chunk) && isInsidePath(file.path, chunk));
@@ -2699,6 +3326,17 @@ export class RollbackPointEngine {
2699
3326
  pushDetail(detail, 'invalid-path ' + file.path);
2700
3327
  continue;
2701
3328
  }
3329
+ // **身份快路径**:身份(dev:ino:size:mtimeNs:ctimeNs:mode)命中身份缓存、且缓存的 sha/bytes 与记录
3330
+ // 一致 ⇒ 这个文件当前就是留点时刻的内容,**它根本不会被写** ⇒ 连对象都不必读。
3331
+ // 大仓库实测:旧口径下阶段 1 会把 34k 个对象(609 MiB)全读一遍 + 哈希一遍(回退 60 s+ 全在这)。
3332
+ // 语义变化(有意、且更准):阶段 1 的对象校验只对**真要写**的条目做 —— 一个用不到的对象坏掉,
3333
+ // 不该中止整次回退;真要用它时(该文件确实变了)照样会 `object-missing` / `object-hash-mismatch` 硬失败。
3334
+ const info = await this.fileStatOf(file.path);
3335
+ if (info.error === undefined && info.isFile && info.size === file.bytes
3336
+ && (await this.unchangedByIdentity(file, info.identity))) {
3337
+ unchanged += 1;
3338
+ continue;
3339
+ }
2702
3340
  try {
2703
3341
  const bytes = await this.readObject(file.object);
2704
3342
  if (sha256Hex(bytes) !== file.sha256) {
@@ -2750,7 +3388,6 @@ export class RollbackPointEngine {
2750
3388
  // E1:内容已与快照一致的条目**不再写** —— 「0 变更」就要是真的 0 次写入
2751
3389
  // (否则工作区已与该点一致时也会重写全部文件,白耗 I/O 并抖动 mtime)。
2752
3390
  const needed = [];
2753
- let unchanged = 0;
2754
3391
  for (const item of writable) {
2755
3392
  let identical = false;
2756
3393
  try {
@@ -2798,7 +3435,7 @@ export class RollbackPointEngine {
2798
3435
  let bytes = null;
2799
3436
  let unreadable = null;
2800
3437
  try {
2801
- bytes = await fs.readFile(item.file.path);
3438
+ bytes = await this.readFileBytes(item.file.path);
2802
3439
  }
2803
3440
  catch (error) {
2804
3441
  // **只认 ENOENT = 当时确实不存在**。其它 errno(EACCES / EMFILE / EBUSY…)是
@@ -2849,7 +3486,7 @@ export class RollbackPointEngine {
2849
3486
  if (failed === 0) {
2850
3487
  for (const item of needed) {
2851
3488
  try {
2852
- const current = await fs.readFile(item.file.path);
3489
+ const current = await this.readFileBytes(item.file.path);
2853
3490
  if (sha256Hex(current) !== item.file.sha256)
2854
3491
  mismatched.push(item.file.path);
2855
3492
  }