dsh-config-manager 0.1.72 → 0.1.73

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 (130) hide show
  1. package/README.md +40 -4
  2. package/README.zh-CN.md +40 -4
  3. package/lib/adapters/index.d.ts +1 -0
  4. package/lib/adapters/index.js +5 -0
  5. package/lib/adapters/messages.d.ts +3 -0
  6. package/lib/adapters/messages.js +7 -0
  7. package/lib/adapters/plugin-data.d.ts +114 -0
  8. package/lib/adapters/plugin-data.js +309 -0
  9. package/lib/adapters/self.d.ts +1 -1
  10. package/lib/adapters/self.js +2 -1
  11. package/lib/adapters/test-helpers.d.ts +9 -0
  12. package/lib/adapters/test-helpers.js +14 -0
  13. package/lib/client.d.ts +95 -6
  14. package/lib/client.js +10630 -10133
  15. package/lib/core/analyzer.d.ts +11 -2
  16. package/lib/core/analyzer.js +31 -8
  17. package/lib/core/backup-plan.d.ts +10 -0
  18. package/lib/core/backup-plan.js +21 -3
  19. package/lib/core/backup.d.ts +20 -1
  20. package/lib/core/backup.js +48 -3
  21. package/lib/core/command-rollback.d.ts +6 -2
  22. package/lib/core/command-rollback.js +6 -2
  23. package/lib/core/messages-core.d.ts +2 -0
  24. package/lib/core/messages-core.js +4 -0
  25. package/lib/core/messages.d.ts +2 -1
  26. package/lib/core/messages.js +4 -2
  27. package/lib/core/plugin-data-registry.d.ts +124 -0
  28. package/lib/core/plugin-data-registry.js +198 -0
  29. package/lib/core/rollback-gate.d.ts +33 -0
  30. package/lib/core/rollback-gate.js +57 -0
  31. package/lib/core/rollback-ledger.d.ts +248 -0
  32. package/lib/core/rollback-ledger.js +1282 -0
  33. package/lib/core/rollback-point.d.ts +187 -4
  34. package/lib/core/rollback-point.js +689 -179
  35. package/lib/core/rollback.js +6 -4
  36. package/lib/core/types.d.ts +19 -0
  37. package/lib/foreign/registry.d.ts +1 -1
  38. package/lib/foreign/registry.js +1 -1
  39. package/lib/index.d.ts +5 -1
  40. package/lib/index.js +83 -13
  41. package/lib/routes/messages.d.ts +1 -0
  42. package/lib/routes/messages.js +2 -0
  43. package/lib/routes/prefs.js +26 -3
  44. package/lib/routes/recovery.d.ts +21 -0
  45. package/lib/routes/recovery.js +127 -7
  46. package/lib/routes/sync.js +11 -1
  47. package/lib/schema/section-registry.d.ts +1 -1
  48. package/lib/schema/section-registry.js +32 -3
  49. package/lib/schema/types.d.ts +1 -1
  50. package/lib/sync/sync-engine.d.ts +21 -7
  51. package/lib/sync/sync-engine.js +52 -11
  52. package/lib/sync/ui-prefs.d.ts +22 -0
  53. package/lib/sync/ui-prefs.js +28 -0
  54. package/lib/ui/diff-view.d.ts +10 -0
  55. package/lib/ui/diff-view.js +17 -0
  56. package/lib/ui/export-flow.js +2 -0
  57. package/lib/ui/i18n.d.ts +4 -2
  58. package/lib/ui/i18n.js +8 -4
  59. package/lib/ui/rollback-view.d.ts +59 -0
  60. package/lib/ui/rollback-view.js +14 -0
  61. package/lib/ui/test-helpers.js +2 -1
  62. package/package.json +1 -1
  63. package/src/adapters/index.ts +5 -0
  64. package/src/adapters/messages.ts +7 -0
  65. package/src/adapters/plugin-data.test.ts +225 -0
  66. package/src/adapters/plugin-data.ts +351 -0
  67. package/src/adapters/self.ts +2 -1
  68. package/src/adapters/test-helpers.ts +15 -0
  69. package/src/client/chat/ChatEntryView.tsx +21 -1
  70. package/src/client/chat/RollbackDiffTab.tsx +49 -2
  71. package/src/client/chat/RollbackReviewDialog.tsx +20 -4
  72. package/src/client/chat/chat-point-window.ts +11 -1
  73. package/src/client/chat/rollback-diff-tab.ts +13 -3
  74. package/src/client/chat/rollback-entry-view.ts +5 -0
  75. package/src/client/chat/sidebar-slots.ts +24 -2
  76. package/src/client/common/DiffTable.tsx +44 -8
  77. package/src/client/common/client-route-parity.test.ts +6 -0
  78. package/src/client/common/routes.ts +10 -0
  79. package/src/client/common/section-labels.test.ts +1 -0
  80. package/src/client/common/section-labels.ts +2 -0
  81. package/src/client/config-manager.module.css +113 -10
  82. package/src/client/index.ts +47 -1
  83. package/src/client/locales.ts +8 -4
  84. package/src/client/recovery/RollbackCard.tsx +201 -7
  85. package/src/client/recovery/RollbackDiffPane.tsx +13 -2
  86. package/src/client/recovery/recovery-api.ts +28 -0
  87. package/src/client/recovery/recovery-locales.ts +34 -2
  88. package/src/client/recovery/rollback-enabled.ts +41 -0
  89. package/src/client/recovery/rollback-file-changes.test.ts +41 -0
  90. package/src/client/sync/sync-view.test.ts +1 -1
  91. package/src/core/analyzer.ts +31 -9
  92. package/src/core/backup-plan.test.ts +20 -5
  93. package/src/core/backup-plan.ts +22 -3
  94. package/src/core/backup.ts +50 -4
  95. package/src/core/command-rollback.ts +6 -2
  96. package/src/core/messages-core.ts +4 -0
  97. package/src/core/messages.ts +4 -2
  98. package/src/core/plugin-data-registry.test.ts +113 -0
  99. package/src/core/plugin-data-registry.ts +261 -0
  100. package/src/core/rollback-gate.test.ts +66 -0
  101. package/src/core/rollback-gate.ts +61 -0
  102. package/src/core/rollback-ledger.test.ts +655 -0
  103. package/src/core/rollback-ledger.ts +1314 -0
  104. package/src/core/rollback-point.test.ts +274 -24
  105. package/src/core/rollback-point.ts +745 -160
  106. package/src/core/rollback.ts +5 -3
  107. package/src/core/types.ts +19 -0
  108. package/src/foreign/registry.test.ts +1 -1
  109. package/src/foreign/registry.ts +1 -1
  110. package/src/index.ts +73 -10
  111. package/src/routes/messages.ts +2 -0
  112. package/src/routes/prefs.ts +31 -2
  113. package/src/routes/recovery.ts +122 -7
  114. package/src/routes/sync.ts +15 -1
  115. package/src/schema/manifest-schema.test.ts +4 -4
  116. package/src/schema/registry.test.ts +9 -2
  117. package/src/schema/section-registry.ts +32 -3
  118. package/src/schema/types.ts +2 -1
  119. package/src/sync/sync-credentials.test.ts +119 -0
  120. package/src/sync/sync-engine.ts +51 -11
  121. package/src/sync/transport.test.ts +1 -0
  122. package/src/sync/ui-prefs.test.ts +44 -1
  123. package/src/sync/ui-prefs.ts +39 -0
  124. package/src/ui/diff-view.test.ts +9 -1
  125. package/src/ui/diff-view.ts +22 -0
  126. package/src/ui/export-flow.ts +2 -0
  127. package/src/ui/i18n.ts +8 -4
  128. package/src/ui/rollback-view.test.ts +17 -0
  129. package/src/ui/rollback-view.ts +66 -0
  130. package/src/ui/test-helpers.ts +2 -1
@@ -74,12 +74,22 @@ import { projectKeyOf } from './session-select.js';
74
74
  import { ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules } from './rollback-ignore-rules.js';
75
75
  import { isSessionLogName } from '../utils/session-log.js';
76
76
  import { decodeZstdFrame, scanZstdFrames, zstdAvailable, ZSTD_MAGIC } from '../utils/zstd-frame.js';
77
+ import { cleanupOrphanTempFiles, entrySummaryOf, ROLLBACK_DEFAULT_LEDGER_BYTES, ROLLBACK_UNDO_DIR_NAME, RollbackLedger, } from './rollback-ledger.js';
77
78
  /* ------------------------------------------------------------------ 常量 */
78
79
  export const ROLLBACK_SCHEMA_VERSION = 2;
79
80
  /** 回滚点库目录名(dataDir/rollback-points)——路由族 /recovery/rollback-points/* 同源 */
80
81
  export const ROLLBACK_POINTS_DIR_NAME = 'rollback-points';
82
+ /**
83
+ * 台账**清单**文件名(v3,issue #89):它只是一份小清单(元数据 + 段摘要),
84
+ * 完整记录在 records/<id>.json、工作区载荷在 payloads/<sha256>.json —— 清单里绝不含大数组。
85
+ */
81
86
  export const ROLLBACK_LEDGER_NAME = 'index.json';
82
87
  export const ROLLBACK_OBJECTS_DIR_NAME = 'objects';
88
+ /**
89
+ * 台账字节预算(records/ + payloads/)的缺省值,可由 RollbackEngineOptions.ledgerBytesBudget 覆盖。
90
+ * 为什么需要它:ROLLBACK_MAX_RECORDS(50)只限**条数**;本机实测 50 条 = 473 MB 直接把宿主撑爆。
91
+ */
92
+ export { ROLLBACK_DEFAULT_LEDGER_BYTES };
83
93
  /** 全局自动点上限(S6);protected / guard 点不占预算、只进不出 */
84
94
  export const ROLLBACK_MAX_RECORDS = 50;
85
95
  /** 每会话自动点上限(S6) */
@@ -157,6 +167,16 @@ export const ROLLBACK_DEFAULT_SESSION_QUIESCENT_MS = 30_000;
157
167
  /** 轮询回退的间隔上界(S1:会话日志 mtime ≤5 s 轮询) */
158
168
  export const ROLLBACK_DEFAULT_POLL_INTERVAL_MS = 5_000;
159
169
  export const ROLLBACK_LEFTOVER_LIST_LIMIT = 20;
170
+ /**
171
+ * 工作区捕获的**挂钟时间预算**(缺省 30 s)。
172
+ *
173
+ * 为什么需要(对标 dsh-turn-rewind 的 `deadline.ts`、dsh-checkpoint-rewind 至今未合并的 PR #25):
174
+ * 捕获路径此前只有「文件数 / 总字节 / 单文件字节」三个**规模**上限,没有**时间**上限 ——
175
+ * 一个「文件数很多但总量不大」的工作区能让 walk + stat + sha256 跑很久。
176
+ * 超预算**不是失败**:已收条目照常成点(ok=true),未走查的部分按 `budget-time` 逐条留痕、
177
+ * `truncated` 置真 ⇒ 删除集自动 fail-closed(证明名单不完整就一条都不删)。
178
+ */
179
+ export const ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS = 30_000;
160
180
  /**
161
181
  * 「删除回退点之后新建的路径」的**整套上限**(用户 2026-10-11 定案 A)。
162
182
  * 超过就整套拒绝(fail-closed),绝不删一半 —— 半套删除既没有可解释的状态,也无法整体回滚。
@@ -524,6 +544,62 @@ function withLedgerWriteLock(ledgerPath, task) {
524
544
  });
525
545
  return run;
526
546
  }
547
+ /**
548
+ * 回退前像暂存器:**字节落盘、索引随栈**。
549
+ *
550
+ * 为什么不是内存数组(旧实现):大工作区整体回退时前像峰值 = 待写总字节(可达数百 MB),
551
+ * 与 issue #89 的 OOM 同族。落盘后内存只随**文件条数**增长。
552
+ *
553
+ * 生命周期:`stage()` 逐条写前像 → 成功则 `dispose()`;中途失败则 `rollback()` 写回再 `dispose()`。
554
+ * 目录名带 pid,崩溃残留由 rollback-ledger 的 `cleanupOrphanTempFiles` 在下次启动回收。
555
+ */
556
+ class UndoStager {
557
+ entries = [];
558
+ dir;
559
+ seq = 0;
560
+ constructor(dir) {
561
+ this.dir = dir;
562
+ }
563
+ /** 记一条前像:`bytes === null` = 该路径当时**不存在**(回滚时删掉它)。 */
564
+ async stage(target, bytes) {
565
+ if (bytes === null) {
566
+ this.entries.push({ path: target, file: null });
567
+ return;
568
+ }
569
+ const file = path.join(this.dir, String(this.seq) + '.bin');
570
+ this.seq += 1;
571
+ await fs.writeFile(file, bytes);
572
+ this.entries.push({ path: target, file });
573
+ }
574
+ /**
575
+ * 逐条写回(**逆序**:同一路径被 stage 两次时,最后一次 stage 记的是最早状态,必须最后写)。
576
+ * 返回失败清单 —— 回滚失败绝不静默。
577
+ */
578
+ async rollback(writer) {
579
+ const failed = [];
580
+ for (const entry of [...this.entries].reverse()) {
581
+ try {
582
+ if (entry.file === null)
583
+ await fs.rm(entry.path, { force: true });
584
+ else
585
+ await writer(entry.path, await fs.readFile(entry.file));
586
+ }
587
+ catch (error) {
588
+ failed.push(entry.path + ' ' + errorDetail(error));
589
+ }
590
+ }
591
+ return failed;
592
+ }
593
+ /** 前像目录用完即清(best-effort:清理失败不该把回退报成失败)。 */
594
+ async dispose() {
595
+ try {
596
+ await fs.rm(this.dir, { recursive: true, force: true });
597
+ }
598
+ catch {
599
+ /* best-effort */
600
+ }
601
+ }
602
+ }
527
603
  export class RollbackPointEngine {
528
604
  dataDir;
529
605
  homeDir;
@@ -565,8 +641,21 @@ export class RollbackPointEngine {
565
641
  forkLinksComplete = true;
566
642
  /** 会话日志头缓存(mtime + size 失效;只服务 fork 链扫描) */
567
643
  forkHeaderCache = new Map();
568
- /** 继承判据用的台账快照(TTL 缓存;避免每个 turn/start 都读整份台账) */
644
+ /** 继承判据用的台账快照(TTL 缓存;只缓存**清单条目**,绝不缓存完整记录) */
569
645
  ledgerSnapshotCache = null;
646
+ /** 台账存储层(v3:清单 + 单条记录 + 内容寻址载荷;issue #89) */
647
+ ledger;
648
+ ledgerBytesBudget;
649
+ /** 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS) */
650
+ captureBudgetMs;
651
+ /**
652
+ * 启动期一次性初始化(流式迁移 v2 -> v3 + 孤儿 tmp 清理)的惰性单飞。
653
+ * 为什么惰取而不是构造时同步跑:构造发生在每个请求路径上,同步扫盘/迁移会把请求卡死;
654
+ * 而「每回合都扫一遍」正是要避免的浪费 —— 所以只跑一次并记住结果。
655
+ */
656
+ initPromise = null;
657
+ /** 启动期初始化失败的如实记录(读路径必须报出来,绝不假装台账是空的) */
658
+ initError;
570
659
  constructor(options) {
571
660
  this.dataDir = path.resolve(options.dataDir);
572
661
  this.homeDir = path.resolve(options.homeDir);
@@ -596,6 +685,18 @@ export class RollbackPointEngine {
596
685
  this.previewDiffMaxWork = options.previewDiffMaxWork ?? ROLLBACK_PREVIEW_DIFF_MAX_WORK;
597
686
  this.previewDiffTotalWork = options.previewDiffTotalWork ?? ROLLBACK_PREVIEW_DIFF_TOTAL_WORK;
598
687
  this.log = options.log;
688
+ this.ledgerBytesBudget = options.ledgerBytesBudget ?? ROLLBACK_DEFAULT_LEDGER_BYTES;
689
+ this.captureBudgetMs = options.captureBudgetMs ?? ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS;
690
+ this.ledger = new RollbackLedger({
691
+ root: this.root,
692
+ bytesBudget: this.ledgerBytesBudget,
693
+ // 摘要与记录校验的单一事实源都在本模块:注入而不是反向 import,避免运行期循环依赖
694
+ summarize: summarizeRollbackPoint,
695
+ isLedgerRecord: isRollbackPointRecord,
696
+ now: () => this.stamp(),
697
+ log: (line) => this.warn(line),
698
+ onFileRead: options.ledgerReadFile,
699
+ });
599
700
  }
600
701
  /** 回滚点库根目录(只读侦察用;写路径全部经本类的门) */
601
702
  get storeDir() {
@@ -643,6 +744,22 @@ export class RollbackPointEngine {
643
744
  else
644
745
  sink.skippedTruncated = true;
645
746
  }
747
+ /**
748
+ * 打开一次回退的前像暂存目录(`<root>/undo/<pid>-<ts36>-<8hex>/`)。
749
+ * 返回 null = 建不出来(权限 / 磁盘满)⇒ 调用方必须**零写入失败**。
750
+ */
751
+ async openUndoStager() {
752
+ const name = String(process.pid) + '-' + this.stamp().toString(36) + '-' + crypto.randomBytes(4).toString('hex');
753
+ const dir = path.join(this.root, ROLLBACK_UNDO_DIR_NAME, name);
754
+ try {
755
+ await fs.mkdir(dir, { recursive: true });
756
+ }
757
+ catch (error) {
758
+ this.warn('rollback undo staging unavailable: ' + errorDetail(error));
759
+ return null;
760
+ }
761
+ return new UndoStager(dir);
762
+ }
646
763
  async writeFile(target, bytes) {
647
764
  if (this.writeSeam !== undefined) {
648
765
  await this.writeSeam(target, bytes);
@@ -651,8 +768,49 @@ export class RollbackPointEngine {
651
768
  await atomicWriteFile(target, bytes, { symlink: 'reject' });
652
769
  }
653
770
  /* ---------------------------------------------------------- 存储栈探针 */
771
+ /**
772
+ * 启动期一次性初始化(惰性单飞):先**流式**迁移 v2 单文件 -> v3,再清理崩溃残留的原子写 tmp。
773
+ *
774
+ * 为什么惰取而不是在构造里同步跑:引擎在每个请求路径上都会被新建(路由层不缓存引擎),
775
+ * 构造里同步扫盘/迁移会把请求卡死;而「每回合扫一遍」又是纯浪费 —— 所以只跑一次并记住结果。
776
+ * 迁移失败**不抛**:如实记进 initError,由读路径报出来,并**拒写**(绝不覆盖还没迁完的 v2 文件)。
777
+ */
778
+ async ensureReady() {
779
+ if (this.initPromise === null)
780
+ this.initPromise = this.runInit();
781
+ await this.initPromise;
782
+ }
783
+ async runInit() {
784
+ let migration;
785
+ try {
786
+ migration = await this.ledger.migrateIfNeeded();
787
+ }
788
+ catch (error) {
789
+ this.initError = 'ledger unreadable: ' + errorDetail(error);
790
+ return;
791
+ }
792
+ if (migration.error !== undefined) {
793
+ this.initError = migration.error;
794
+ this.warn('rollback ledger migration failed: ' + migration.error);
795
+ return;
796
+ }
797
+ if (migration.migrated) {
798
+ this.warn('rollback ledger migrated: records=' + String(migration.records) + ' skipped=' + String(migration.skipped));
799
+ }
800
+ // 迁移**之后**再清残留:连本次迁移自己的 tmp 一并收掉(只放行可自证形状的崩溃残留)
801
+ try {
802
+ const cleaned = await cleanupOrphanTempFiles(this.root);
803
+ if (cleaned.removed > 0) {
804
+ this.warn('rollback temp cleanup: removed=' + String(cleaned.removed) + ' bytes=' + String(cleaned.bytes) + ' skipped=' + String(cleaned.skipped));
805
+ }
806
+ }
807
+ catch (error) {
808
+ this.warn('rollback temp cleanup failed: ' + errorDetail(error));
809
+ }
810
+ }
654
811
  async storageStatus() {
655
812
  const base = { root: this.root, requirements: ROLLBACK_STORAGE_REQUIREMENTS };
813
+ await this.ensureReady();
656
814
  let result;
657
815
  if (this.probe !== undefined) {
658
816
  try {
@@ -665,9 +823,39 @@ export class RollbackPointEngine {
665
823
  else {
666
824
  result = await defaultStorageProbe(this.root);
667
825
  }
668
- if (result.ok)
669
- return { ...base, available: true, guidance: [] };
670
- return { ...base, available: false, detail: result.detail, guidance: storageGuidance(this.root, result.detail) };
826
+ if (!result.ok) {
827
+ return { ...base, available: false, detail: result.detail, guidance: storageGuidance(this.root, result.detail) };
828
+ }
829
+ const out = { ...base, available: true, guidance: [] };
830
+ const ledger = await this.ledgerStatus();
831
+ if (ledger !== undefined)
832
+ out.ledger = ledger;
833
+ if (this.initError !== undefined) {
834
+ // 磁盘本身没问题,但台账读不出来(迁移失败 / 迁到一半被别人占着):如实上屏,不谎报可用
835
+ out.detail = this.initError;
836
+ out.guidance = storageGuidance(this.root, this.initError);
837
+ }
838
+ return out;
839
+ }
840
+ /** 台账可观测快照(清单条数 + 磁盘字节 + 预算 + 本进程淘汰计数) */
841
+ async ledgerStatus() {
842
+ try {
843
+ const manifest = await this.ledger.readManifest();
844
+ const stats = await this.ledger.stats();
845
+ return {
846
+ records: manifest.entries.length,
847
+ bytes: stats.bytes,
848
+ budgetBytes: this.ledgerBytesBudget,
849
+ evicted: this.ledger.evictedCount,
850
+ // 每次 append 都会尽力淘汰到预算内 ⇒「bytes > budget」本身就等于「淘汰到极限了」
851
+ // (单条点大于预算时恒为 true)。这里不额外读盘,只用已有 stats。
852
+ overBudget: stats.bytes > this.ledgerBytesBudget,
853
+ };
854
+ }
855
+ catch (error) {
856
+ this.warn('rollback ledger status failed: ' + errorDetail(error));
857
+ return undefined;
858
+ }
671
859
  }
672
860
  /* -------------------------------------------------------------- 对象仓 */
673
861
  async writeObjectWithHash(bytes, sha) {
@@ -798,7 +986,12 @@ export class RollbackPointEngine {
798
986
  * 捕获期专用:把**走查过**的每条文件路径都记进来(含超出 limit、因而没进 `out` 的那些)——
799
987
  * 路径索引({@link RollbackPathIndex})的来源。回退期的走查不传它(那份名单只由记录决定)。
800
988
  */
801
- collect, ignore) {
989
+ collect, ignore,
990
+ /**
991
+ * 停止判据(捕获期传时间/取消预算;回退期的走查不传)—— 返回 true 立即停止遍历,
992
+ * 已走查的部分照常返回并置 truncated(证明名单因此不完整 ⇒ 删除集 fail-closed)。
993
+ */
994
+ shouldStop) {
802
995
  let stat;
803
996
  try {
804
997
  stat = await fs.lstat(chunk);
@@ -821,6 +1014,10 @@ export class RollbackPointEngine {
821
1014
  let truncated = false;
822
1015
  const stack = [{ dir: chunk, depth: 0 }];
823
1016
  while (stack.length > 0) {
1017
+ if (shouldStop?.() === true) {
1018
+ truncated = true;
1019
+ break;
1020
+ }
824
1021
  const item = stack.pop();
825
1022
  if (item === undefined)
826
1023
  break;
@@ -843,9 +1040,15 @@ export class RollbackPointEngine {
843
1040
  continue;
844
1041
  }
845
1042
  if (entry.isDirectory()) {
846
- if (EXCLUDED_DEP_DIR_NAMES.includes(entry.name)) {
1043
+ // `.git` 与运行时依赖目录**同档**:整棵不进快照。两条理由 ——
1044
+ // ① 回退写回 .git(index/refs/logs)会让 git 状态错乱(提交看似消失、可能留 index.lock),
1045
+ // 而删除侧本来就把 .git 当拒删目录 ⇒ 排除它才让两侧口径一致;
1046
+ // ② .git 常常几百 MB,会**挤掉 maxBytes 预算里的源码文件**。
1047
+ // 大小写折半比较:Windows 上 .GIT/.Git 是同一个目录。
1048
+ const dirName = win32Fold(entry.name);
1049
+ if (EXCLUDED_DEP_DIR_NAMES.includes(entry.name) || dirName === '.git') {
847
1050
  // 内置忽略规则(S4「沿用现有 IGNORE 规则」):整棵不进快照,但留痕(不静默)
848
- this.recordSkip(sink, 'excluded', { path: full, reason: 'dep-dir' });
1051
+ this.recordSkip(sink, 'excluded', { path: full, reason: dirName === '.git' ? 'git-dir' : 'dep-dir' });
849
1052
  continue;
850
1053
  }
851
1054
  if (ignore !== undefined && this.ignoredByRules(ignore, full, true)) {
@@ -881,10 +1084,15 @@ export class RollbackPointEngine {
881
1084
  out.sort();
882
1085
  return { files: out, truncated };
883
1086
  }
884
- async captureWorkspace(chunks) {
1087
+ async captureWorkspace(chunks, signal) {
885
1088
  const capturedAt = this.stamp();
886
1089
  if (chunks.length === 0)
887
1090
  return this.emptyTrack('workspace', capturedAt, 'no-chunks', 'chunks empty');
1091
+ // 时间预算(见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS):用 stamp()(单调)比较,
1092
+ // 系统时钟回拨也不会让预算永不触发。超时置 payload.truncated ⇒ 结尾的 reasonCode
1093
+ // 自动落成 capture-truncated,UI 走既有的 degraded 提示路径(零新增文案)。
1094
+ const deadline = capturedAt + this.captureBudgetMs;
1095
+ const outOfBudget = () => signal?.aborted === true || this.stamp() >= deadline;
888
1096
  const payload = {
889
1097
  chunks: [...chunks],
890
1098
  files: [],
@@ -905,17 +1113,32 @@ export class RollbackPointEngine {
905
1113
  // 走查过的**全部**文件路径(含被预算 / 单文件上限挡下的)—— 路径索引的来源(方向 B)
906
1114
  const walked = [];
907
1115
  for (const chunk of chunks) {
1116
+ if (outOfBudget()) {
1117
+ // 分块之间就超预算:不再开新分块,并如实留痕(该分块的条目一条都没被走查)
1118
+ // walkIncomplete 是**安全判据**(删除集 fail-closed),漏了它会删掉未走查子树里的文件
1119
+ payload.walkIncomplete = true;
1120
+ payload.truncated = true;
1121
+ this.recordSkip(payload, 'skipped', { path: chunk, reason: 'budget-time' });
1122
+ break;
1123
+ }
908
1124
  if (!path.isAbsolute(chunk))
909
1125
  return this.emptyTrack('workspace', capturedAt, 'chunk-not-absolute', chunk);
910
1126
  const resolved = path.resolve(chunk);
911
1127
  // 文件数预算还剩多少就收多少;+1 只用于让遍历器发现「还有溢出」——溢出条目逐条记 skipped
912
- const listed = await this.listChunkFiles(resolved, payload, Math.max(0, this.maxFiles - read) + 1, walked, await loadIgnoreRules(resolved));
1128
+ const listed = await this.listChunkFiles(resolved, payload, Math.max(0, this.maxFiles - read) + 1, walked, await loadIgnoreRules(resolved), outOfBudget);
913
1129
  if (listed === null)
914
1130
  continue;
915
1131
  sawChunk = true;
916
1132
  if (listed.truncated)
917
1133
  payload.truncated = true;
918
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
+ }
919
1142
  if (read >= this.maxFiles) {
920
1143
  // 文件数预算耗尽:其后每一条都如实留痕(不再 break 静默丢弃)
921
1144
  payload.truncated = true;
@@ -994,7 +1217,9 @@ export class RollbackPointEngine {
994
1217
  }
995
1218
  }
996
1219
  }
997
- if (!sawChunk && payload.files.length === 0) {
1220
+ // `truncated` 为真时**不能**判 chunk-not-found:那是「分块确实存在、只是没来得及走查完」
1221
+ // (时间预算 / 取消信号)—— 报成「找不到分块」会让用户以为工作区配错了。
1222
+ if (!sawChunk && payload.files.length === 0 && !payload.truncated) {
998
1223
  return this.emptyTrack('workspace', capturedAt, 'chunk-not-found', chunks.join(' '));
999
1224
  }
1000
1225
  // **方向 B**:逐条跳过名单被记录上限截断(skippedTruncated)时,把走查过的全部路径另存成
@@ -1168,6 +1393,10 @@ export class RollbackPointEngine {
1168
1393
  out.push({ code: 'rollback.warn.createdRefused', detail: 'ignore-dirs' });
1169
1394
  return out;
1170
1395
  }
1396
+ if (payload.walkIncomplete === true) {
1397
+ out.push({ code: 'rollback.warn.createdRefused', detail: 'walk-incomplete' });
1398
+ return out;
1399
+ }
1171
1400
  if (payload.skippedTruncated && (payload.pathIndex === null || payload.pathIndex === undefined)) {
1172
1401
  out.push({ code: 'rollback.warn.createdRefused', detail: 'path-index-missing' });
1173
1402
  return out;
@@ -1179,32 +1408,28 @@ export class RollbackPointEngine {
1179
1408
  return out;
1180
1409
  }
1181
1410
  /* -------------------------------------------------------------- 台账 */
1182
- async readLedger() {
1183
- let raw;
1184
- try {
1185
- raw = await fs.readFile(this.ledgerPath, 'utf8');
1186
- }
1187
- catch (error) {
1188
- // 不存在 = 首次使用(不是错误);其它读失败如实回报
1189
- const code = error.code;
1190
- if (code === 'ENOENT')
1191
- return { records: [] };
1192
- return { records: [], error: errorDetail(error) };
1193
- }
1194
- try {
1195
- const parsed = JSON.parse(raw);
1196
- if (!isRecord(parsed))
1197
- return { records: [], error: 'ledger shape invalid' };
1198
- const list = parsed['records'];
1199
- if (!Array.isArray(list))
1200
- return { records: [], error: 'ledger.records invalid' };
1201
- const records = list.filter(isRollbackPointRecord);
1202
- const skipped = list.length - records.length;
1203
- return skipped > 0 ? { records, error: 'ledger dropped ' + String(skipped) + ' invalid record(s)' } : { records };
1204
- }
1205
- catch (error) {
1206
- return { records: [], error: 'ledger unreadable: ' + errorDetail(error) };
1207
- }
1411
+ /**
1412
+ * 读台账**清单**(只读元数据 + 段摘要;代价与工作区文件数**无关**)。
1413
+ *
1414
+ * 与 v2 的差别只在实现:v2 每回合把整份 index.json 读进内存并 parse(473 MB 那条路),
1415
+ * v3 读的是一份小清单。**外部语义一个字没变**:返回 entries + 同一套 error 文案。
1416
+ * 启动期初始化失败时如实回报(不读 v2 文件、不假装台账为空)。
1417
+ */
1418
+ async readManifest() {
1419
+ await this.ensureReady();
1420
+ if (this.initError !== undefined)
1421
+ return { entries: [], error: this.initError };
1422
+ return await this.ledger.readManifest();
1423
+ }
1424
+ /**
1425
+ * 读**完整**记录(引用解析对调用方透明)。失败一律如实回报,**绝不返回半个记录**:
1426
+ * 载荷缺失 / 哈希不符 / 形状不符 ⇒ error(调用方走 record-incomplete 路径)。
1427
+ */
1428
+ async readFullRecord(id) {
1429
+ await this.ensureReady();
1430
+ if (this.initError !== undefined)
1431
+ return { error: this.initError };
1432
+ return await this.ledger.readRecord(id);
1208
1433
  }
1209
1434
  /**
1210
1435
  * S6 淘汰(t14 冻结 + 2026-10-12 guard 全局上限):三类点各按各的预算裁,**只删最旧的**。
@@ -1215,17 +1440,26 @@ export class RollbackPointEngine {
1215
1440
  * | guard 救援点 | 每会话 `maxGuardsPerSession`(20)→ 全局 `maxGuards`(`ROLLBACK_MAX_GUARDS`,50) | **不占**自动点预算、**不受**自动点的 20/50 影响;用户删不掉 ⇒ 必须有自己的两道预算(单会话挡住不够,跨会话也要收敛) |
1216
1441
  * | protected 点 | 无上限(只进不出) | 生产路径**造不出来**(capture 端点已下线、tracker 从不设 protect),只有测试能造 |
1217
1442
  */
1218
- selectPrune(records) {
1443
+ /**
1444
+ * 条数淘汰(口径与 v2 逐字相同,只是从「记录」换成「清单条目」):三类点各按各的预算裁,**只删最旧的**。
1445
+ *
1446
+ * | 类别 | 预算 | 说明 |
1447
+ * |---|---|---|
1448
+ * | auto 自动点 | 每会话 maxRecordsPerSession(20)→ 全局 maxRecords(50) | 两次裁剪都只删最旧 |
1449
+ * | guard 救援点 | 每会话 maxGuardsPerSession(20)→ 全局 maxGuards(ROLLBACK_MAX_GUARDS,50) | **不占**自动点预算、**不受**自动点的 20/50 影响;用户删不掉 ⇒ 必须有自己的两道预算(单会话挡住不够,跨会话也要收敛) |
1450
+ * | protected 点 | 无上限(只进不出) | 生产路径**造不出来**(capture 端点已下线、tracker 从不设 protect),只有测试能造 |
1451
+ */
1452
+ selectPruneEntries(entries) {
1219
1453
  const kept = [];
1220
1454
  const guards = [];
1221
1455
  const prunable = [];
1222
- for (const record of records) {
1223
- if (record.protected)
1224
- kept.push(record);
1225
- else if (record.kind === 'guard')
1226
- guards.push(record);
1456
+ for (const entry of entries) {
1457
+ if (entry.protected)
1458
+ kept.push(entry);
1459
+ else if (entry.kind === 'guard')
1460
+ guards.push(entry);
1227
1461
  else
1228
- prunable.push(record);
1462
+ prunable.push(entry);
1229
1463
  }
1230
1464
  // 最新在前(下面三类裁剪共用同一排序口径)
1231
1465
  const newestFirst = (a, b) => a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0;
@@ -1234,85 +1468,204 @@ export class RollbackPointEngine {
1234
1468
  guards.sort(newestFirst);
1235
1469
  const guardsPerSession = new Map();
1236
1470
  const withinGuardSession = [];
1237
- for (const record of guards) {
1238
- const key = record.sessionId ?? '';
1471
+ for (const entry of guards) {
1472
+ const key = entry.sessionId ?? '';
1239
1473
  const used = guardsPerSession.get(key) ?? 0;
1240
1474
  if (used >= this.maxGuardsPerSession)
1241
1475
  continue;
1242
1476
  guardsPerSession.set(key, used + 1);
1243
- withinGuardSession.push(record);
1477
+ withinGuardSession.push(entry);
1244
1478
  }
1245
1479
  kept.push(...withinGuardSession.slice(0, Math.max(0, this.maxGuards)));
1246
1480
  // 自动点:先每会话上限,再全局上限
1247
1481
  prunable.sort(newestFirst);
1248
1482
  const perSession = new Map();
1249
1483
  const withinSession = [];
1250
- for (const record of prunable) {
1251
- const key = record.sessionId ?? '';
1484
+ for (const entry of prunable) {
1485
+ const key = entry.sessionId ?? '';
1252
1486
  const used = perSession.get(key) ?? 0;
1253
1487
  if (used >= this.maxRecordsPerSession)
1254
1488
  continue;
1255
1489
  perSession.set(key, used + 1);
1256
- withinSession.push(record);
1490
+ withinSession.push(entry);
1257
1491
  }
1258
1492
  kept.push(...withinSession.slice(0, Math.max(0, this.maxRecords)));
1259
1493
  const keptIds = new Set(kept.map((r) => r.id));
1260
- const removed = records.filter((r) => !keptIds.has(r.id)).map((r) => r.id);
1494
+ const removed = entries.filter((r) => !keptIds.has(r.id)).map((r) => r.id);
1261
1495
  return { kept, removed };
1262
1496
  }
1263
- async gcObjects(records) {
1264
- const referenced = new Set();
1265
- const referencedPaths = new Set();
1266
- for (const record of records) {
1267
- for (const file of record.workspace.payload?.files ?? []) {
1268
- referenced.add(file.object);
1269
- referencedPaths.add(pathKey(file.path));
1270
- }
1271
- const pathIndex = record.workspace.payload?.pathIndex;
1272
- if (pathIndex !== null && pathIndex !== undefined)
1273
- referenced.add(pathIndex.object);
1274
- const sessionObject = record.session.payload?.object;
1275
- if (sessionObject !== null && sessionObject !== undefined)
1276
- referenced.add(sessionObject);
1277
- }
1278
- let names;
1279
- try {
1280
- names = await fs.readdir(this.objectsDir);
1281
- }
1282
- catch {
1283
- return;
1284
- }
1285
- for (const name of names) {
1286
- if (!OBJECT_KEY_RE.test(name) || referenced.has(name))
1497
+ /**
1498
+ * 字节预算淘汰(issue #89 的**根因修复**):条数没超、字节超了照样会 OOM。
1499
+ *
1500
+ * 判据与条数淘汰共用同一类别口径:**只淘汰最旧的 auto 点**(guard / protected 永不淘汰)。
1501
+ * 释放量按「记录文件字节 + 载荷**最后一个引用**消失时的载荷字节」估算(引用计数靠清单,不读载荷)。
1502
+ * 返回淘汰后的清单(调用方负责落盘与删文件)。
1503
+ */
1504
+ async applyByteBudget(all, keptInput, extraBytes, protectedNewId) {
1505
+ const kept = [...keptInput];
1506
+ // 基线口径(**精确**,不用估算):当前磁盘字节 - 本次已定要删的条目所占字节 + 本次新增字节。
1507
+ // 「已定要删」= 条数淘汰掉的(它们的文件马上会被删),所以不能拿 stats.bytes 当基线。
1508
+ const keptIds = new Set(kept.map((entry) => entry.id));
1509
+ const keptPayloads = new Set();
1510
+ for (const entry of kept) {
1511
+ if (entry.workspace.payload !== null)
1512
+ keptPayloads.add(entry.workspace.payload);
1513
+ }
1514
+ let freed = 0;
1515
+ const freedPayloads = new Set();
1516
+ for (const entry of all) {
1517
+ if (keptIds.has(entry.id))
1287
1518
  continue;
1288
- try {
1289
- await fs.rm(path.join(this.objectsDir, name), { force: true });
1290
- }
1291
- catch (error) {
1292
- this.warn('rollback gc failed: ' + name + ' ' + errorDetail(error));
1293
- }
1519
+ freed += entry.recordBytes;
1520
+ const hash = entry.workspace.payload;
1521
+ if (hash !== null && !keptPayloads.has(hash) && !freedPayloads.has(hash)) {
1522
+ freedPayloads.add(hash);
1523
+ freed += entry.workspace.payloadBytes;
1524
+ }
1525
+ }
1526
+ const stats = await this.ledger.stats();
1527
+ let bytes = stats.bytes - freed + extraBytes;
1528
+ if (bytes <= this.ledgerBytesBudget)
1529
+ return kept;
1530
+ const refs = new Map();
1531
+ for (const entry of kept) {
1532
+ const hash = entry.workspace.payload;
1533
+ if (hash !== null)
1534
+ refs.set(hash, (refs.get(hash) ?? 0) + 1);
1535
+ }
1536
+ const candidates = kept
1537
+ .filter((entry) => !entry.protected && entry.kind === 'auto' && entry.id !== protectedNewId)
1538
+ .sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
1539
+ const victims = new Set();
1540
+ for (const entry of candidates) {
1541
+ if (bytes <= this.ledgerBytesBudget)
1542
+ break;
1543
+ victims.add(entry.id);
1544
+ const index = kept.findIndex((item) => item.id === entry.id);
1545
+ if (index >= 0)
1546
+ kept.splice(index, 1);
1547
+ bytes -= entry.recordBytes;
1548
+ const hash = entry.workspace.payload;
1549
+ if (hash !== null) {
1550
+ const left = (refs.get(hash) ?? 1) - 1;
1551
+ refs.set(hash, left);
1552
+ if (left <= 0)
1553
+ bytes -= entry.workspace.payloadBytes;
1554
+ }
1555
+ }
1556
+ if (victims.size === 0) {
1557
+ // 一条都淘汰不了却仍然超预算(典型:单条点自身的体积就大于预算)—— **如实留痕**。
1558
+ // 不写这一条,「预算生效」就是假象(竞品 dsh-checkpoint-rewind #35 正是「软配额 bound
1559
+ // 不住单条」的口子);界面侧由 RollbackLedgerStatus.overBudget 如实上屏。
1560
+ if (bytes > this.ledgerBytesBudget) {
1561
+ this.warn('rollback ledger over budget: nothing prunable bytes=' + String(bytes) + ' budget=' + String(this.ledgerBytesBudget));
1562
+ }
1563
+ return kept;
1564
+ }
1565
+ this.ledger.markEvicted(victims.size);
1566
+ this.warn('rollback ledger over budget: evicted=' + String(victims.size) + ' bytes=' + String(bytes) + ' budget=' + String(this.ledgerBytesBudget));
1567
+ return kept;
1568
+ }
1569
+ /**
1570
+ * 对象仓回收(**重写版**,issue #89):账本字节数不再进内存。
1571
+ *
1572
+ * 旧实现要从「内存里所有记录的全部 files 数组」累计引用 ⇒ 回收一次 = 把整份台账读进内存。
1573
+ * 现在:扫**清单** -> 逐个读引用到的**载荷文件**(一次只驻留一份)-> 累计 referenced 对象键 -> 扫 objects/。
1574
+ * 峰值内存 ∝ 单份载荷 + 对象键集合,**不 ∝ 台账总字节**。
1575
+ * 任何一份被引用的载荷读不出来 ⇒ 整体放弃回收(fail-closed:读不到的载荷里有什么对象谁也不知道)。
1576
+ */
1577
+ async gcObjects(entries) {
1578
+ const result = await this.ledger.collectObjects(entries);
1579
+ if (result.aborted !== null) {
1580
+ this.warn('rollback gc skipped: ' + result.aborted);
1581
+ return;
1294
1582
  }
1295
1583
  // 路径身份缓存随引用回收:没有任何回滚点引用的路径不留条目(不无界增长)
1296
- await this.gcPathCache(referencedPaths);
1584
+ await this.gcPathCache(new Set([...result.referencedPaths].map(pathKey)));
1297
1585
  }
1298
1586
  /**
1299
1587
  * 把记录写进台账(**留点侧唯一写入口**):整段「读全文 → 合并 → S6 淘汰 → 原子写 → 回收对象」
1300
1588
  * 都在 {@link withLedgerWriteLock} 的进程内串行队列里 —— 绝不与 tracker / 路由的另一次写交错。
1301
1589
  */
1590
+ /**
1591
+ * 写一条记录(**最多三次尝试**)—— 跨进程乐观并发(①B)。
1592
+ *
1593
+ * dataDir 由 `$DSH_HOME` 决定、**不随 profile 分家**(与本插件自带的实例锁同一套多实例假设),
1594
+ * 所以「另一个 DSH 实例正在写同一份清单」是设计内的场景,而 `withLedgerWriteLock` 只串行本进程。
1595
+ * 策略:读清单前后各取一次**代际戳**,被改过就重读重试;三次都变 ⇒ **放弃本次留点并 warn**,
1596
+ * 绝不拿自己的快照覆盖别人的清单(宁可少一条点,也不丢别人的点)。
1597
+ * 诚实的边界:检测与写入之间仍有极小窗口,无跨进程锁时无法彻底消除 —— 这里保证的是
1598
+ * 「不静默覆盖 + 尽量合并」,不是原子性。
1599
+ */
1302
1600
  async appendRecord(record) {
1601
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1602
+ const result = await this.appendRecordOnce(record);
1603
+ if (result.conflict !== true) {
1604
+ return result.detail !== undefined ? { ok: result.ok, detail: result.detail } : { ok: result.ok };
1605
+ }
1606
+ }
1607
+ this.warn('rollback ledger busy: another instance kept changing the manifest (auto point skipped)');
1608
+ return { ok: false, detail: 'ledger-busy: manifest changed by another instance' };
1609
+ }
1610
+ /** `appendRecord` 的单次尝试;`conflict: true` = 这期间清单被别的实例改过,调用方应当重试。 */
1611
+ async appendRecordOnce(record) {
1303
1612
  return await withLedgerWriteLock(this.ledgerPath, async () => {
1304
- const ledger = await this.readLedger();
1305
- const merged = [record, ...ledger.records.filter((r) => r.id !== record.id)];
1306
- const { kept } = this.selectPrune(merged);
1613
+ // 读之前先取一次代际戳(不参与锁:它天生是跨进程的探针)
1614
+ const stampBefore = await this.ledger.manifestStamp();
1615
+ let manifest = await this.readManifest();
1616
+ if (this.initError !== undefined) {
1617
+ // 台账还有 v2 没迁完(或迁移失败):**拒写**,绝不覆盖原文件(下次启动重试)
1618
+ return { ok: false, detail: this.initError };
1619
+ }
1620
+ if (manifest.error !== undefined) {
1621
+ // 清单**读不出来**:先**从 records/ 重建**(旧点的最终真相在记录文件里),
1622
+ // 而不是拿一个空 entries 覆盖 —— 后者会把旧点整批从清单抹掉,紧接着 prunePayloads
1623
+ // 还会删掉它们的载荷 ⇒ 永久不可恢复。重建后**仍**读不出来才拒写(fail-closed)。
1624
+ manifest = await this.ledger.recoverManifest();
1625
+ if (manifest.error !== undefined) {
1626
+ return { ok: false, detail: 'ledger unreadable: ' + manifest.error };
1627
+ }
1628
+ }
1629
+ const prepared = this.ledger.prepareRecord(record);
1630
+ const merged = [prepared.entry, ...manifest.entries.filter((r) => r.id !== record.id)];
1631
+ const pruned = this.selectPruneEntries(merged);
1632
+ // 本次新增字节:记录文件 + **首次落盘**的载荷(同一工作区态已存在 ⇒ 零额外字节)
1633
+ const payloadIsNew = prepared.payloadHash !== null && !(await this.ledger.hasPayload(prepared.payloadHash));
1634
+ const extraBytes = prepared.entry.recordBytes + (payloadIsNew ? prepared.entry.workspace.payloadBytes : 0);
1635
+ // 刚写入的这一条不参与本轮字节淘汰(否则 capturePoint 会回一个台账里查不到的幽灵点)
1636
+ const kept = await this.applyByteBudget(merged, pruned.kept, extraBytes, prepared.entry.id);
1307
1637
  kept.sort((a, b) => (a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0));
1638
+ // 写之前复核:这份清单在这期间有没有被别的实例改过?改过就**不写**,交给调用方重读重试。
1639
+ // 读不出代际戳(任一时刻为 null)⇒ 不参与判定、照常写 —— 绝不因为一个可选探针失败就拒绝留点。
1640
+ const stampAfter = await this.ledger.manifestStamp();
1641
+ if (stampBefore !== null && stampAfter !== null && stampBefore !== stampAfter) {
1642
+ return { ok: false, conflict: true, detail: 'manifest changed by another instance' };
1643
+ }
1308
1644
  try {
1309
1645
  await fs.mkdir(this.root, { recursive: true });
1310
- await atomicWriteFile(this.ledgerPath, JSON.stringify({ schemaVersion: ROLLBACK_SCHEMA_VERSION, records: kept }, null, 2));
1646
+ await this.ledger.writePrepared(prepared);
1311
1647
  }
1312
1648
  catch (error) {
1313
1649
  return { ok: false, detail: errorDetail(error) };
1314
1650
  }
1315
- await this.gcObjects(kept);
1651
+ try {
1652
+ await this.ledger.writeManifest(kept);
1653
+ }
1654
+ catch (error) {
1655
+ // 清单没写成 ⇒ 这一条**不算落进台账**:撤掉刚落的记录文件与无人引用的载荷(不留半截)
1656
+ await this.ledger.deleteRecords([prepared.entry.id]);
1657
+ await this.ledger.prunePayloads(manifest.entries);
1658
+ return { ok: false, detail: errorDetail(error) };
1659
+ }
1660
+ const keptIds = new Set(kept.map((entry) => entry.id));
1661
+ const dropped = merged.filter((entry) => !keptIds.has(entry.id)).map((entry) => entry.id);
1662
+ await this.ledger.deleteRecords(dropped);
1663
+ await this.ledger.prunePayloads(kept);
1664
+ // **只在真的淘汰了记录时**才扫对象仓:对象回收需要逐份读被引用的载荷(代价 ∝ 载荷总量),
1665
+ // 而没有淘汰时不可能产生无人引用的对象 —— 每回合都扫一遍等于把「读全账本」搬进了留点热路径
1666
+ // (issue #89 正是要消灭这条路)。删除点 / 预算淘汰 / 清理入口都会走到这里。
1667
+ if (dropped.length > 0)
1668
+ await this.gcObjects(kept);
1316
1669
  return { ok: true };
1317
1670
  });
1318
1671
  }
@@ -1337,7 +1690,7 @@ export class RollbackPointEngine {
1337
1690
  boundarySeq: typeof input.boundarySeq === 'number' && Number.isFinite(input.boundarySeq) ? input.boundarySeq : null,
1338
1691
  boundaryEndSeq: typeof input.boundaryEndSeq === 'number' && Number.isFinite(input.boundaryEndSeq) ? input.boundaryEndSeq : null,
1339
1692
  });
1340
- const workspace = await this.captureWorkspace(chunks);
1693
+ const workspace = await this.captureWorkspace(chunks, input.signal);
1341
1694
  const spreadMs = Math.abs(session.capturedAt - workspace.capturedAt);
1342
1695
  const record = {
1343
1696
  schemaVersion: ROLLBACK_SCHEMA_VERSION,
@@ -1398,11 +1751,11 @@ export class RollbackPointEngine {
1398
1751
  async ledgerSnapshot() {
1399
1752
  const now = this.stamp();
1400
1753
  if (this.ledgerSnapshotCache !== null && now - this.ledgerSnapshotCache.at < ROLLBACK_INHERITED_LEDGER_TTL_MS) {
1401
- return this.ledgerSnapshotCache.records;
1754
+ return this.ledgerSnapshotCache.entries;
1402
1755
  }
1403
- const ledger = await this.readLedger();
1404
- this.ledgerSnapshotCache = { at: now, records: ledger.records };
1405
- return ledger.records;
1756
+ const ledger = await this.readManifest();
1757
+ this.ledgerSnapshotCache = { at: now, entries: ledger.entries };
1758
+ return ledger.entries;
1406
1759
  }
1407
1760
  /**
1408
1761
  * 本会话/本回合**没有自己的点**时,沿继承链找祖先的同回合点(t9 的判据层)。
@@ -1420,7 +1773,7 @@ export class RollbackPointEngine {
1420
1773
  for (const ancestor of ancestors) {
1421
1774
  const hit = records.find((r) => r.sessionId === ancestor && r.turnSeq === turn);
1422
1775
  if (hit !== undefined)
1423
- return summarizeRollbackPoint(hit);
1776
+ return entrySummaryOf(hit);
1424
1777
  }
1425
1778
  return null;
1426
1779
  }
@@ -1449,14 +1802,14 @@ export class RollbackPointEngine {
1449
1802
  * 任何东西),而 fork 子会话与父会话在**同一个项目键**下(fork 不改 cwd)⇒ 覆盖这点就够了。
1450
1803
  * 台账空 ⇒ 直接返回(零扫描,热路径零代价);结果按 {@link ROLLBACK_FORK_LINKS_TTL_MS} 缓存。
1451
1804
  */
1452
- async refreshForkLinks(records) {
1805
+ async refreshForkLinks(entries) {
1453
1806
  const now = this.stamp();
1454
1807
  if (this.forkLinksAt !== 0 && now - this.forkLinksAt < ROLLBACK_FORK_LINKS_TTL_MS)
1455
1808
  return;
1456
1809
  this.forkLinksAt = now;
1457
1810
  const projectKeys = new Set();
1458
- for (const record of records) {
1459
- const key = unitProjectKeyOf(record.unitId);
1811
+ for (const entry of entries) {
1812
+ const key = unitProjectKeyOf(entry.unitId);
1460
1813
  if (key !== null)
1461
1814
  projectKeys.add(key);
1462
1815
  }
@@ -1520,14 +1873,14 @@ export class RollbackPointEngine {
1520
1873
  * · `inheritedFrom` / `inheritedSessionId` 如实标出它的来处。
1521
1874
  * 有界({@link ROLLBACK_INHERITED_VIEW_MAX}),超出如实标记。
1522
1875
  */
1523
- inheritedViews(records) {
1876
+ inheritedViews(manifest) {
1524
1877
  const entries = [];
1525
1878
  let truncated = false;
1526
1879
  const ownTurnsOf = (sessionId) => {
1527
1880
  const out = new Set();
1528
- for (const record of records) {
1529
- if (record.sessionId === sessionId && record.turnSeq !== null)
1530
- out.add(record.turnSeq);
1881
+ for (const entry of manifest) {
1882
+ if (entry.sessionId === sessionId && entry.turnSeq !== null)
1883
+ out.add(entry.turnSeq);
1531
1884
  }
1532
1885
  return out;
1533
1886
  };
@@ -1536,22 +1889,30 @@ export class RollbackPointEngine {
1536
1889
  if (chain.length === 0)
1537
1890
  continue;
1538
1891
  const own = ownTurnsOf(child);
1892
+ // 多代链(A→B→D)里同一个回合可能同时存在于多个祖先:**只取最近的那个**。
1893
+ // 没有这份集合时,D 会拿到两条 (sessionId=D, turn=N) 却指向**不同状态**的记录,
1894
+ // 客户端取第一条时结果随祖先顺序漂移。chain 由近到远 ⇒ 先到先得。
1895
+ const pickedTurns = new Set();
1539
1896
  for (const ancestor of chain) {
1540
- for (const record of records) {
1541
- if (record.sessionId !== ancestor || record.turnSeq === null)
1897
+ for (const entry of manifest) {
1898
+ if (entry.sessionId !== ancestor || entry.turnSeq === null)
1542
1899
  continue;
1543
- if (own.has(record.turnSeq))
1900
+ if (own.has(entry.turnSeq))
1544
1901
  continue; // 本会话自己有这一回合的点 ⇒ 用自己那个
1902
+ if (pickedTurns.has(entry.turnSeq))
1903
+ continue; // 更近的祖先已经提供过这一回合
1545
1904
  if (entries.length >= ROLLBACK_INHERITED_VIEW_MAX) {
1546
1905
  truncated = true;
1547
1906
  break;
1548
1907
  }
1908
+ pickedTurns.add(entry.turnSeq);
1549
1909
  entries.push({
1550
- ...summarizeRollbackPoint(record),
1910
+ // 段摘要来自清单里**写入时算好**的那份(口径仍是 summarizeRollbackPoint)
1911
+ ...entrySummaryOf(entry),
1551
1912
  sessionId: child,
1552
- unitId: childUnitIdOf(record.unitId, child),
1553
- inheritedFrom: record.id,
1554
- inheritedSessionId: record.sessionId,
1913
+ unitId: childUnitIdOf(entry.unitId, child),
1914
+ inheritedFrom: entry.id,
1915
+ inheritedSessionId: entry.sessionId,
1555
1916
  });
1556
1917
  }
1557
1918
  if (truncated)
@@ -1565,12 +1926,13 @@ export class RollbackPointEngine {
1565
1926
  /* -------------------------------------------------------------- 列 / 读 */
1566
1927
  async list() {
1567
1928
  const storage = await this.storageStatus();
1568
- const ledger = await this.readLedger();
1569
- await this.refreshForkLinks(ledger.records);
1570
- const points = [...ledger.records]
1929
+ const ledger = await this.readManifest();
1930
+ await this.refreshForkLinks(ledger.entries);
1931
+ // v3:列表**只读清单**(元数据 + 写入时算好的段摘要)—— 一个记录文件都不读(issue #89 的内存护栏)
1932
+ const points = [...ledger.entries]
1571
1933
  .sort((a, b) => (a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0))
1572
- .map(summarizeRollbackPoint);
1573
- const inherited = this.inheritedViews(ledger.records);
1934
+ .map(entrySummaryOf);
1935
+ const inherited = this.inheritedViews(ledger.entries);
1574
1936
  const out = {
1575
1937
  ok: storage.available,
1576
1938
  storage,
@@ -1589,16 +1951,26 @@ export class RollbackPointEngine {
1589
1951
  }
1590
1952
  async read(id) {
1591
1953
  const storage = await this.storageStatus();
1592
- const ledger = await this.readLedger();
1593
- const point = ledger.records.find((r) => r.id === id);
1954
+ const ledger = await this.readManifest();
1955
+ const entry = ledger.entries.find((r) => r.id === id);
1956
+ // v3:records 只回传命中的那一条 —— 调用方只用 point;逐条读全部记录文件是不可接受的代价
1594
1957
  const out = {
1595
- records: ledger.records,
1958
+ records: [],
1596
1959
  storage,
1597
1960
  };
1598
- if (point !== undefined)
1599
- out.point = point;
1600
- if (ledger.error !== undefined)
1601
- out.error = ledger.error;
1961
+ let error = ledger.error;
1962
+ if (entry !== undefined) {
1963
+ const resolved = await this.readFullRecord(entry.id);
1964
+ if (resolved.record !== undefined) {
1965
+ out.point = resolved.record;
1966
+ out.records = [resolved.record];
1967
+ }
1968
+ else if (resolved.error !== undefined) {
1969
+ error = error === undefined ? resolved.error : error + '; ' + resolved.error;
1970
+ }
1971
+ }
1972
+ if (error !== undefined)
1973
+ out.error = error;
1602
1974
  return out;
1603
1975
  }
1604
1976
  /* -------------------------------------------------------------- 预览 */
@@ -1672,21 +2044,38 @@ export class RollbackPointEngine {
1672
2044
  // 用户忽略的整目录 ⇒ 其下内容在留点时刻未知 ⇒ 证明名单不完整(fail-closed)
1673
2045
  if (refused === null && payload.ignoreIncomplete === true)
1674
2046
  refused = 'capture-incomplete';
2047
+ // 走查被时间预算 / 取消信号中断 ⇒ 剩余子树**一条都没进任何名单** ⇒ 同样证明不了
2048
+ // (这是 `truncated` 之外的第四种「名单不完整」:其余三种截断都会逐条留痕)
2049
+ if (refused === null && payload.walkIncomplete === true)
2050
+ refused = 'capture-incomplete';
1675
2051
  const chunks = payload.chunks;
1676
2052
  // 走查上限 = 全树:候选的**准入**由证明名单负责,条数上限不再是安全阀
1677
2053
  //(旧上限 maxFiles*4 会在巨型工作区里静默漏掉真正的新建文件 —— 那是「删一半」,不是 fail-closed)
1678
2054
  const limit = Number.MAX_SAFE_INTEGER;
2055
+ // **时间预算**:回退期的走查同样不能无限跑(分块 = 会话 cwd,家目录 / 大仓库会走很久,
2056
+ // 而捕获侧早就有 30 s 预算 —— 只修一侧等于把 OOM 形态从留点搬到了回退)。
2057
+ // 中断只会让候选**变少**(安全方向),但必须如实置 truncated(绝不静默漏删、也不谎报完整)。
2058
+ const deadline = this.stamp() + this.captureBudgetMs;
2059
+ const outOfBudget = () => this.stamp() >= deadline;
1679
2060
  const candidates = [];
1680
2061
  let truncated = false;
1681
2062
  for (const chunk of chunks) {
1682
2063
  if (!path.isAbsolute(chunk))
1683
2064
  continue;
1684
- const listed = await this.listChunkFiles(path.resolve(chunk), sink, limit, undefined, await loadIgnoreRules(path.resolve(chunk)));
2065
+ if (outOfBudget()) {
2066
+ truncated = true;
2067
+ break;
2068
+ }
2069
+ const listed = await this.listChunkFiles(path.resolve(chunk), sink, limit, undefined, await loadIgnoreRules(path.resolve(chunk)), outOfBudget);
1685
2070
  if (listed === null)
1686
2071
  continue;
1687
2072
  if (listed.truncated)
1688
2073
  truncated = true;
1689
2074
  for (const file of listed.files) {
2075
+ if (outOfBudget()) {
2076
+ truncated = true;
2077
+ break;
2078
+ }
1690
2079
  if (proof.has(pathKey(file)))
1691
2080
  continue;
1692
2081
  candidates.push(file);
@@ -1793,7 +2182,7 @@ export class RollbackPointEngine {
1793
2182
  /** 只读预览:零写入(不过确认门、不拍 guard、不写任何字节)。 */
1794
2183
  async preview(id) {
1795
2184
  const storage = await this.storageStatus();
1796
- const ledger = await this.readLedger();
2185
+ const ledger = await this.readManifest();
1797
2186
  const warnings = [];
1798
2187
  if (ledger.error !== undefined)
1799
2188
  warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail: ledger.error });
@@ -1817,9 +2206,18 @@ export class RollbackPointEngine {
1817
2206
  if (!storage.available) {
1818
2207
  return { ...base, code: 'storage-unavailable', detail: storage.detail, guidance: storage.guidance };
1819
2208
  }
1820
- const record = ledger.records.find((r) => r.id === id);
1821
- if (record === undefined)
2209
+ const entry = ledger.entries.find((r) => r.id === id);
2210
+ if (entry === undefined)
1822
2211
  return { ...base, code: 'record-not-found' };
2212
+ const resolved = await this.readFullRecord(entry.id);
2213
+ if (resolved.record === undefined) {
2214
+ // 引用解析失败(载荷文件缺失 / 哈希不符 / 形状不符):**如实失败** —— 绝不拿半个记录去预览
2215
+ // (半个记录会让「可回退」看起来成立,然后回退真的写错文件)
2216
+ const detail = resolved.error ?? 'record-incomplete';
2217
+ warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail });
2218
+ return { ...base, code: 'record-incomplete', detail };
2219
+ }
2220
+ const record = resolved.record;
1823
2221
  for (const guidance of this.trackWarnings(record))
1824
2222
  warnings.push(guidance);
1825
2223
  // 工作区态
@@ -1841,8 +2239,12 @@ export class RollbackPointEngine {
1841
2239
  for (const file of wsPayload.files) {
1842
2240
  let outcome;
1843
2241
  try {
1844
- const stat = await fs.stat(file.path);
1845
- outcome = { kind: 'ok', size: stat.size, regular: stat.isFile() };
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() };
1846
2248
  }
1847
2249
  catch (error) {
1848
2250
  outcome = classifyPreviewStatFailure(error) === 'missing' ? { kind: 'missing' } : { kind: 'unavailable' };
@@ -1988,10 +2390,16 @@ export class RollbackPointEngine {
1988
2390
  return { ...base, code: 'storage-unavailable', detail: storage.detail };
1989
2391
  if (filePath === '')
1990
2392
  return { ...base, detail: 'empty-path' };
1991
- const ledger = await this.readLedger();
1992
- const record = ledger.records.find((r) => r.id === id);
1993
- if (record === undefined)
2393
+ const ledger = await this.readManifest();
2394
+ const entry = ledger.entries.find((r) => r.id === id);
2395
+ if (entry === undefined)
1994
2396
  return { ...base, code: 'record-not-found' };
2397
+ const resolved = await this.readFullRecord(entry.id);
2398
+ if (resolved.record === undefined) {
2399
+ // 引用解析失败:**如实失败**(RollbackFileDiffResult 没有 warnings 槽位,用 detail 说清楚)
2400
+ return { ...base, code: 'record-incomplete', detail: resolved.error ?? 'record-incomplete' };
2401
+ }
2402
+ const record = resolved.record;
1995
2403
  const payload = record.workspace.payload;
1996
2404
  if (!record.workspace.ok || payload === null) {
1997
2405
  const detail = record.workspace.reasonDetail;
@@ -2104,13 +2512,21 @@ export class RollbackPointEngine {
2104
2512
  if (!storage.available) {
2105
2513
  return this.result({ outcome: 'failed', code: 'storage-unavailable', detail: storage.detail, id: input.id, storage, guidance: storage.guidance });
2106
2514
  }
2107
- const ledger = await this.readLedger();
2515
+ const ledger = await this.readManifest();
2108
2516
  if (ledger.error !== undefined)
2109
2517
  warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail: ledger.error });
2110
- const record = ledger.records.find((r) => r.id === input.id);
2111
- if (record === undefined) {
2518
+ const entry = ledger.entries.find((r) => r.id === input.id);
2519
+ if (entry === undefined) {
2112
2520
  return this.result({ outcome: 'failed', code: 'record-not-found', id: input.id, storage, warnings });
2113
2521
  }
2522
+ const resolved = await this.readFullRecord(entry.id);
2523
+ if (resolved.record === undefined) {
2524
+ // 记录读不完整(载荷缺失 / 哈希不符 / 形状不符):**零写入**失败,绝不拿半个记录去回退
2525
+ const detail = resolved.error ?? 'record-incomplete';
2526
+ warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail });
2527
+ return this.result({ outcome: 'failed', code: 'record-incomplete', detail, id: input.id, storage, warnings });
2528
+ }
2529
+ const record = resolved.record;
2114
2530
  // 门 1(fail-closed 确认门):必须恰好 true
2115
2531
  if (input.userConfirmed !== true) {
2116
2532
  return this.result({ outcome: 'denied', code: 'confirmation-required', detail: 'userConfirmed!==true', id: input.id, storage, warnings });
@@ -2253,7 +2669,9 @@ export class RollbackPointEngine {
2253
2669
  return 'outside-chunk';
2254
2670
  if (isInsidePath(file, this.root))
2255
2671
  return 'inside-data-dir';
2256
- const segments = path.resolve(file).split(path.sep);
2672
+ // Windows 文件系统**不分大小写**:`.GIT` / `.Git` / `NODE_MODULES` 是同一个目录,必须折大小写后
2673
+ // 再比 —— 不折会让它们的子树失去保护(本文件其它路径判据都走 win32Fold,这里此前漏了)。
2674
+ const segments = win32Fold(path.resolve(file)).split(/[\\/]+/);
2257
2675
  if (segments.includes('.git'))
2258
2676
  return 'excluded-dir';
2259
2677
  if (segments.includes('node_modules'))
@@ -2362,17 +2780,56 @@ export class RollbackPointEngine {
2362
2780
  noop.createdRefusedReason = removal.refused;
2363
2781
  return noop;
2364
2782
  }
2365
- // 前像(内存):失败时要能整体回到恢复前状态 —— 只覆盖**真会被写**的文件
2366
- const pre = [];
2783
+ // 前像(**字节落盘、索引随栈**):失败时要能整体回到恢复前状态 —— 只覆盖**真会被写**的文件。
2784
+ // 字节之所以不留在内存:大工作区整体回退时前像峰值 = 待写总字节(可达数百 MB),
2785
+ // 与 issue #89 的 OOM 同族;落盘后内存只随**文件条数**增长。
2786
+ // 暂存目录建不出来 ⇒ **零写入失败**(fail-closed):回退没有安全网就不该开始。
2787
+ const stager = await this.openUndoStager();
2788
+ if (stager === null) {
2789
+ pushDetail(detail, 'undo-staging-unavailable root=' + this.root + ' zeroWrites=true');
2790
+ return {
2791
+ state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
2792
+ restored: 0, failed: needed.length + removal.paths.length,
2793
+ leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
2794
+ };
2795
+ }
2796
+ const preCount = needed.length;
2367
2797
  for (const item of needed) {
2368
2798
  let bytes = null;
2799
+ let unreadable = null;
2369
2800
  try {
2370
2801
  bytes = await fs.readFile(item.file.path);
2371
2802
  }
2372
- catch {
2373
- bytes = null;
2803
+ catch (error) {
2804
+ // **只认 ENOENT = 当时确实不存在**。其它 errno(EACCES / EMFILE / EBUSY…)是
2805
+ // 「读不出来」而不是「没有」—— 折成 null 会让回滚阶段把这个**存在**的文件删掉
2806
+ // (UndoStager.stage(null) 的语义正是「回滚时删掉它」)。
2807
+ if (error.code !== 'ENOENT')
2808
+ unreadable = errorDetail(error);
2809
+ }
2810
+ if (unreadable !== null) {
2811
+ await stager.dispose();
2812
+ pushDetail(detail, 'preimage-unreadable ' + item.file.path + ' ' + unreadable + ' zeroWrites=true');
2813
+ return {
2814
+ state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
2815
+ restored: 0, failed: needed.length + removal.paths.length,
2816
+ leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
2817
+ };
2818
+ }
2819
+ try {
2820
+ await stager.stage(item.file.path, bytes);
2821
+ }
2822
+ catch (error) {
2823
+ // 暂存写不下去(ENOSPC / EACCES):**零写入失败** —— 绝不带着不完整的前像开工,
2824
+ // 也绝不让异常穿出引擎(那会被路由兜成没有机器码的裸 500,且暂存目录无人清理)。
2825
+ await stager.dispose();
2826
+ pushDetail(detail, 'preimage-stage-failed ' + item.file.path + ' ' + errorDetail(error) + ' zeroWrites=true');
2827
+ return {
2828
+ state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
2829
+ restored: 0, failed: needed.length + removal.paths.length,
2830
+ leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
2831
+ };
2374
2832
  }
2375
- pre.push({ path: item.file.path, existed: bytes !== null, bytes });
2376
2833
  }
2377
2834
  let restored = 0;
2378
2835
  let writtenBytes = 0;
@@ -2402,23 +2859,13 @@ export class RollbackPointEngine {
2402
2859
  }
2403
2860
  }
2404
2861
  if (failed > 0 || mismatched.length > 0) {
2405
- const rollbackFailed = [];
2406
- for (const item of pre) {
2407
- try {
2408
- if (item.existed && item.bytes !== null)
2409
- await this.writeFile(item.path, item.bytes);
2410
- else
2411
- await fs.rm(item.path, { force: true });
2412
- }
2413
- catch (error) {
2414
- rollbackFailed.push(item.path + ' ' + errorDetail(error));
2415
- }
2416
- }
2862
+ const rollbackFailed = await stager.rollback((target, bytes) => this.writeFile(target, bytes));
2863
+ await stager.dispose();
2417
2864
  for (const target of mismatched)
2418
2865
  pushDetail(detail, 'verify-mismatch ' + target);
2419
2866
  for (const line of rollbackFailed)
2420
2867
  pushDetail(detail, 'rolled-back-failed ' + line);
2421
- detail.push('restored=0 failed=' + String(failed + mismatched.length) + ' rolledBack=' + String(pre.length - rollbackFailed.length) + ' unchanged=' + String(unchanged));
2868
+ detail.push('restored=0 failed=' + String(failed + mismatched.length) + ' rolledBack=' + String(preCount - rollbackFailed.length) + ' unchanged=' + String(unchanged));
2422
2869
  return {
2423
2870
  state: 'workspace', status: 'failed',
2424
2871
  reasonCode: mismatched.length > 0 ? 'verify-failed' : 'restore-failed',
@@ -2433,7 +2880,8 @@ export class RollbackPointEngine {
2433
2880
  // 阶段 4(用户 2026-10-11 定案 A):删除「回退点之后新建」的路径。
2434
2881
  // 顺序刻死:记录在案的路径**先写并复核通过**,再删新建的 —— 先删后写一旦写失败就回不去了。
2435
2882
  // 删除同样可回滚:先读前像(内存)→ 删;任一失败就把已写的还原、把已删的写回。
2436
- const destroyed = [];
2883
+ // 待删文件的前像同样落盘(stage(path, bytes) 的语义就是「回滚时把它写回」)
2884
+ let destroyedCount = 0;
2437
2885
  const removalFailures = [];
2438
2886
  // 逐条记录**真的删掉**的路径(全量,不再切 20 条):它与预览的 workspace.removals 同源同序,
2439
2887
  // 界面把两份清单当作同一件事(「预览说会删的 = 实际删的」)。
@@ -2447,7 +2895,8 @@ export class RollbackPointEngine {
2447
2895
  removal.skipped.push(target);
2448
2896
  continue;
2449
2897
  }
2450
- destroyed.push({ path: target, bytes });
2898
+ await stager.stage(target, bytes);
2899
+ destroyedCount += 1;
2451
2900
  await fs.rm(target, { force: true });
2452
2901
  removed += 1;
2453
2902
  removedPaths.push(target);
@@ -2457,32 +2906,14 @@ export class RollbackPointEngine {
2457
2906
  }
2458
2907
  }
2459
2908
  if (removalFailures.length > 0) {
2460
- const rollbackFailed = [];
2461
- for (const item of pre) {
2462
- try {
2463
- if (item.existed && item.bytes !== null)
2464
- await this.writeFile(item.path, item.bytes);
2465
- else
2466
- await fs.rm(item.path, { force: true });
2467
- }
2468
- catch (error) {
2469
- rollbackFailed.push(item.path + ' ' + errorDetail(error));
2470
- }
2471
- }
2472
- for (const item of destroyed) {
2473
- try {
2474
- await this.writeFile(item.path, item.bytes);
2475
- }
2476
- catch (error) {
2477
- rollbackFailed.push(item.path + ' ' + errorDetail(error));
2478
- }
2479
- }
2909
+ const rollbackFailed = await stager.rollback((target, bytes) => this.writeFile(target, bytes));
2910
+ await stager.dispose();
2480
2911
  for (const line of removalFailures)
2481
2912
  pushDetail(detail, 'created-remove-failed ' + line);
2482
2913
  for (const line of rollbackFailed)
2483
2914
  pushDetail(detail, 'rolled-back-failed ' + line);
2484
2915
  detail.push('restored=0 removed=0 failed=' + String(removalFailures.length) +
2485
- ' rolledBack=' + String(pre.length + destroyed.length - rollbackFailed.length) +
2916
+ ' rolledBack=' + String(preCount + destroyedCount - rollbackFailed.length) +
2486
2917
  ' unchanged=' + String(unchanged));
2487
2918
  return {
2488
2919
  state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
@@ -2508,6 +2939,8 @@ export class RollbackPointEngine {
2508
2939
  // 捕获不完整 ⇒ 一条都没删:如实回报原因(界面据此说「因为捕获不完整,一条都没动」)
2509
2940
  if (removal.refused !== null)
2510
2941
  segment.createdRefusedReason = removal.refused;
2942
+ // 前像字节用完即清(成功路径):留在磁盘上只会变成无人认领的残留
2943
+ await stager.dispose();
2511
2944
  return segment;
2512
2945
  }
2513
2946
  /**
@@ -2589,24 +3022,86 @@ export class RollbackPointEngine {
2589
3022
  if (!storage.available)
2590
3023
  return { ok: false, code: 'storage-unavailable', detail: storage.detail, id };
2591
3024
  return await withLedgerWriteLock(this.ledgerPath, async () => {
2592
- const ledger = await this.readLedger();
2593
- const record = ledger.records.find((r) => r.id === id);
2594
- if (record === undefined)
3025
+ const ledger = await this.readManifest();
3026
+ if (this.initError !== undefined) {
3027
+ return { ok: false, code: 'ledger-write-failed', detail: this.initError, id };
3028
+ }
3029
+ // 删除只看**清单元数据**(protected / kind 都在清单里)—— 一个记录文件都不读、一个载荷都不读
3030
+ const entry = ledger.entries.find((r) => r.id === id);
3031
+ if (entry === undefined)
2595
3032
  return { ok: false, code: 'record-not-found', id };
2596
- if (record.protected || record.kind === 'guard') {
2597
- return { ok: false, code: 'protected-point', detail: record.kind, id };
3033
+ if (entry.protected || entry.kind === 'guard') {
3034
+ return { ok: false, code: 'protected-point', detail: entry.kind, id };
2598
3035
  }
2599
- const kept = ledger.records.filter((r) => r.id !== id);
3036
+ const kept = ledger.entries.filter((r) => r.id !== id);
2600
3037
  try {
2601
- await atomicWriteFile(this.ledgerPath, JSON.stringify({ schemaVersion: ROLLBACK_SCHEMA_VERSION, records: kept }, null, 2));
3038
+ await this.ledger.writeManifest(kept);
2602
3039
  }
2603
3040
  catch (error) {
2604
3041
  return { ok: false, code: 'ledger-write-failed', detail: errorDetail(error), id };
2605
3042
  }
3043
+ await this.ledger.deleteRecords([id]);
3044
+ await this.ledger.prunePayloads(kept);
2606
3045
  await this.gcObjects(kept);
2607
3046
  return { ok: true, code: 'deleted', id };
2608
3047
  });
2609
3048
  }
3049
+ /**
3050
+ * 磁盘清理入口(引擎侧):删**全部**自动点(includeGuards=true 时连 guard 也删),
3051
+ * protected 点**永不删**(生产路径造不出来,只有测试能造)。随后回收无人引用的载荷与对象。
3052
+ *
3053
+ * 零写入拒绝路径照旧:存储不可用 / 清单读不出来(形状不符、迁移失败、迁移中)⇒ **抛带 code 的错误**,
3054
+ * 一个字节都不动、也绝不静默返回全 0(「什么都没删」与「读不出来所以没删」是两件事)。
3055
+ * 返回的 bytes = 本次真正删掉的字节(记录文件 + 载荷文件 + 对象文件)。
3056
+ */
3057
+ async purgeLedger(options = {}) {
3058
+ const storage = await this.storageStatus();
3059
+ if (!storage.available) {
3060
+ throw new RollbackLedgerRefusedError('storage-unavailable', storage.detail ?? 'rollback storage unavailable');
3061
+ }
3062
+ return await withLedgerWriteLock(this.ledgerPath, async () => {
3063
+ const ledger = await this.readManifest();
3064
+ if (this.initError !== undefined)
3065
+ throw new RollbackLedgerRefusedError('ledger-unreadable', this.initError);
3066
+ if (ledger.error !== undefined)
3067
+ throw new RollbackLedgerRefusedError('ledger-unreadable', ledger.error);
3068
+ const includeGuards = options.includeGuards === true;
3069
+ const victims = ledger.entries.filter((entry) => !entry.protected && (includeGuards || entry.kind === 'auto'));
3070
+ if (victims.length === 0)
3071
+ return { removedRecords: 0, removedPayloads: 0, removedObjects: 0, bytes: 0 };
3072
+ const victimIds = new Set(victims.map((entry) => entry.id));
3073
+ const kept = ledger.entries.filter((entry) => !victimIds.has(entry.id));
3074
+ // 先落清单、再删文件:崩在中途也只会留下「清单已删、文件还在」的垃圾,不会留下指向空文件的清单
3075
+ await this.ledger.writeManifest(kept);
3076
+ const records = await this.ledger.deleteRecords([...victimIds]);
3077
+ const payloads = await this.ledger.prunePayloads(kept);
3078
+ const objects = await this.ledger.collectObjects(kept);
3079
+ if (objects.aborted === null) {
3080
+ await this.gcPathCache(new Set([...objects.referencedPaths].map(pathKey)));
3081
+ }
3082
+ else {
3083
+ this.warn('rollback purge: object gc skipped: ' + objects.aborted);
3084
+ }
3085
+ return {
3086
+ removedRecords: records.removed,
3087
+ removedPayloads: payloads.removed,
3088
+ removedObjects: objects.removed,
3089
+ bytes: records.bytes + payloads.bytes + objects.bytes,
3090
+ };
3091
+ });
3092
+ }
3093
+ }
3094
+ /**
3095
+ * 台账清理的**显式拒绝**(形状不符 / 存储不可用):带机器可读 code,绝不静默返回全 0。
3096
+ * 路由层按 code 映射 HTTP(与既有 RollbackCode 冻结集同源)。
3097
+ */
3098
+ export class RollbackLedgerRefusedError extends Error {
3099
+ code;
3100
+ constructor(code, detail) {
3101
+ super(detail);
3102
+ this.name = 'RollbackLedgerRefusedError';
3103
+ this.code = code;
3104
+ }
2610
3105
  }
2611
3106
  function uniqueStates(states) {
2612
3107
  const out = [];
@@ -2665,6 +3160,12 @@ export function rollbackSessionBindingOf(session) {
2665
3160
  }
2666
3161
  const header = session.header;
2667
3162
  const headerRecord = header !== null && typeof header === 'object' ? header : null;
3163
+ // 子代理会话**不自动留点**(见 RollbackCode 的 'subagent-session'):它的工作区与父会话相同、
3164
+ // 回合号是自己的,留点只挤占用户自己会话的预算。**只在字段逐字等于 'subagent' 时判定** ——
3165
+ // 读不到 origin(老宿主 / 形状不符)就不判,绝不因为读不到而少留点。
3166
+ if (headerRecord?.['origin'] === 'subagent') {
3167
+ return { ok: false, code: 'subagent-session', detail: 'origin=subagent session=' + id };
3168
+ }
2668
3169
  const cwd = headerRecord?.['cwd'];
2669
3170
  // t9:父链与「是否播种而来」只从**活会话头**读;读不到就不带字段(=不是继承子会话),绝不猜
2670
3171
  const fork = {};
@@ -2792,6 +3293,8 @@ export class RollbackAutoTracker {
2792
3293
  lastStat = new Map();
2793
3294
  timer = null;
2794
3295
  mechanism = 'event';
3296
+ /** 在途捕获的取消信号(stop() 时 abort;重新 start() 时换一个新的) */
3297
+ captureAbort = new AbortController();
2795
3298
  constructor(options) {
2796
3299
  this.engine = options.engine;
2797
3300
  this.nowFn = options.now ?? (() => Date.now());
@@ -2831,6 +3334,8 @@ export class RollbackAutoTracker {
2831
3334
  boundaryEndSeq: info.endSeq,
2832
3335
  chunks: binding.chunks ?? [],
2833
3336
  note: this.mechanism === 'poll' ? 'auto:poll' : 'auto:turn',
3337
+ // 卸载 / 停止 ⇒ 立刻中断在途捕获(下一个文件边界停下;已收条目照常成点)
3338
+ signal: this.captureAbort.signal,
2834
3339
  });
2835
3340
  // T10:写台账失败(ok=false)绝不算留点成功 —— 否则 tracker 会记住一个台账里不存在的 pointId,
2836
3341
  // 后续 rewind/详情都查不到它。(capturePoint 失败时也不再回传 point,这条判据是第二道保险。)
@@ -2971,6 +3476,9 @@ export class RollbackAutoTracker {
2971
3476
  start() {
2972
3477
  if (this.timer !== null)
2973
3478
  return;
3479
+ // 上一轮 stop() 已经把信号 abort 掉:重新 start 必须换一个新的,否则捕获一动就停。
3480
+ if (this.captureAbort.signal.aborted)
3481
+ this.captureAbort = new AbortController();
2974
3482
  this.mechanism = 'poll';
2975
3483
  this.timer = setInterval(() => {
2976
3484
  void this.pollOnce().catch((error) => { this.warn('rollback tracker poll failed: ' + errorDetail(error)); });
@@ -2980,6 +3488,8 @@ export class RollbackAutoTracker {
2980
3488
  handle.unref();
2981
3489
  }
2982
3490
  stop() {
3491
+ // 中断在途捕获:卸载之后不该再有任何一次「读工作区 + 写对象仓」跑完。
3492
+ this.captureAbort.abort();
2983
3493
  if (this.timer === null)
2984
3494
  return;
2985
3495
  clearInterval(this.timer);