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.
- package/README.md +40 -4
- package/README.zh-CN.md +40 -4
- package/lib/adapters/index.d.ts +1 -0
- package/lib/adapters/index.js +5 -0
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-data.d.ts +114 -0
- package/lib/adapters/plugin-data.js +309 -0
- package/lib/adapters/self.d.ts +1 -1
- package/lib/adapters/self.js +2 -1
- package/lib/adapters/test-helpers.d.ts +9 -0
- package/lib/adapters/test-helpers.js +14 -0
- package/lib/client.d.ts +95 -6
- package/lib/client.js +10630 -10133
- package/lib/core/analyzer.d.ts +11 -2
- package/lib/core/analyzer.js +31 -8
- package/lib/core/backup-plan.d.ts +10 -0
- package/lib/core/backup-plan.js +21 -3
- package/lib/core/backup.d.ts +20 -1
- package/lib/core/backup.js +48 -3
- package/lib/core/command-rollback.d.ts +6 -2
- package/lib/core/command-rollback.js +6 -2
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +2 -1
- package/lib/core/messages.js +4 -2
- package/lib/core/plugin-data-registry.d.ts +124 -0
- package/lib/core/plugin-data-registry.js +198 -0
- package/lib/core/rollback-gate.d.ts +33 -0
- package/lib/core/rollback-gate.js +57 -0
- package/lib/core/rollback-ledger.d.ts +248 -0
- package/lib/core/rollback-ledger.js +1282 -0
- package/lib/core/rollback-point.d.ts +187 -4
- package/lib/core/rollback-point.js +689 -179
- package/lib/core/rollback.js +6 -4
- package/lib/core/types.d.ts +19 -0
- package/lib/foreign/registry.d.ts +1 -1
- package/lib/foreign/registry.js +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +83 -13
- package/lib/routes/messages.d.ts +1 -0
- package/lib/routes/messages.js +2 -0
- package/lib/routes/prefs.js +26 -3
- package/lib/routes/recovery.d.ts +21 -0
- package/lib/routes/recovery.js +127 -7
- package/lib/routes/sync.js +11 -1
- package/lib/schema/section-registry.d.ts +1 -1
- package/lib/schema/section-registry.js +32 -3
- package/lib/schema/types.d.ts +1 -1
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +52 -11
- package/lib/sync/ui-prefs.d.ts +22 -0
- package/lib/sync/ui-prefs.js +28 -0
- package/lib/ui/diff-view.d.ts +10 -0
- package/lib/ui/diff-view.js +17 -0
- package/lib/ui/export-flow.js +2 -0
- package/lib/ui/i18n.d.ts +4 -2
- package/lib/ui/i18n.js +8 -4
- package/lib/ui/rollback-view.d.ts +59 -0
- package/lib/ui/rollback-view.js +14 -0
- package/lib/ui/test-helpers.js +2 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +5 -0
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-data.test.ts +225 -0
- package/src/adapters/plugin-data.ts +351 -0
- package/src/adapters/self.ts +2 -1
- package/src/adapters/test-helpers.ts +15 -0
- package/src/client/chat/ChatEntryView.tsx +21 -1
- package/src/client/chat/RollbackDiffTab.tsx +49 -2
- package/src/client/chat/RollbackReviewDialog.tsx +20 -4
- package/src/client/chat/chat-point-window.ts +11 -1
- package/src/client/chat/rollback-diff-tab.ts +13 -3
- package/src/client/chat/rollback-entry-view.ts +5 -0
- package/src/client/chat/sidebar-slots.ts +24 -2
- package/src/client/common/DiffTable.tsx +44 -8
- package/src/client/common/client-route-parity.test.ts +6 -0
- package/src/client/common/routes.ts +10 -0
- package/src/client/common/section-labels.test.ts +1 -0
- package/src/client/common/section-labels.ts +2 -0
- package/src/client/config-manager.module.css +113 -10
- package/src/client/index.ts +47 -1
- package/src/client/locales.ts +8 -4
- package/src/client/recovery/RollbackCard.tsx +201 -7
- package/src/client/recovery/RollbackDiffPane.tsx +13 -2
- package/src/client/recovery/recovery-api.ts +28 -0
- package/src/client/recovery/recovery-locales.ts +34 -2
- package/src/client/recovery/rollback-enabled.ts +41 -0
- package/src/client/recovery/rollback-file-changes.test.ts +41 -0
- package/src/client/sync/sync-view.test.ts +1 -1
- package/src/core/analyzer.ts +31 -9
- package/src/core/backup-plan.test.ts +20 -5
- package/src/core/backup-plan.ts +22 -3
- package/src/core/backup.ts +50 -4
- package/src/core/command-rollback.ts +6 -2
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +4 -2
- package/src/core/plugin-data-registry.test.ts +113 -0
- package/src/core/plugin-data-registry.ts +261 -0
- package/src/core/rollback-gate.test.ts +66 -0
- package/src/core/rollback-gate.ts +61 -0
- package/src/core/rollback-ledger.test.ts +655 -0
- package/src/core/rollback-ledger.ts +1314 -0
- package/src/core/rollback-point.test.ts +274 -24
- package/src/core/rollback-point.ts +745 -160
- package/src/core/rollback.ts +5 -3
- package/src/core/types.ts +19 -0
- package/src/foreign/registry.test.ts +1 -1
- package/src/foreign/registry.ts +1 -1
- package/src/index.ts +73 -10
- package/src/routes/messages.ts +2 -0
- package/src/routes/prefs.ts +31 -2
- package/src/routes/recovery.ts +122 -7
- package/src/routes/sync.ts +15 -1
- package/src/schema/manifest-schema.test.ts +4 -4
- package/src/schema/registry.test.ts +9 -2
- package/src/schema/section-registry.ts +32 -3
- package/src/schema/types.ts +2 -1
- package/src/sync/sync-credentials.test.ts +119 -0
- package/src/sync/sync-engine.ts +51 -11
- package/src/sync/transport.test.ts +1 -0
- package/src/sync/ui-prefs.test.ts +44 -1
- package/src/sync/ui-prefs.ts +39 -0
- package/src/ui/diff-view.test.ts +9 -1
- package/src/ui/diff-view.ts +22 -0
- package/src/ui/export-flow.ts +2 -0
- package/src/ui/i18n.ts +8 -4
- package/src/ui/rollback-view.test.ts +17 -0
- package/src/ui/rollback-view.ts +66 -0
- 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
|
|
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:
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
return {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
-
|
|
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
|
|
1223
|
-
if (
|
|
1224
|
-
kept.push(
|
|
1225
|
-
else if (
|
|
1226
|
-
guards.push(
|
|
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(
|
|
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
|
|
1238
|
-
const key =
|
|
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(
|
|
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
|
|
1251
|
-
const key =
|
|
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(
|
|
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 =
|
|
1494
|
+
const removed = entries.filter((r) => !keptIds.has(r.id)).map((r) => r.id);
|
|
1261
1495
|
return { kept, removed };
|
|
1262
1496
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1305
|
-
const
|
|
1306
|
-
|
|
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
|
|
1646
|
+
await this.ledger.writePrepared(prepared);
|
|
1311
1647
|
}
|
|
1312
1648
|
catch (error) {
|
|
1313
1649
|
return { ok: false, detail: errorDetail(error) };
|
|
1314
1650
|
}
|
|
1315
|
-
|
|
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.
|
|
1754
|
+
return this.ledgerSnapshotCache.entries;
|
|
1402
1755
|
}
|
|
1403
|
-
const ledger = await this.
|
|
1404
|
-
this.ledgerSnapshotCache = { at: now,
|
|
1405
|
-
return ledger.
|
|
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
|
|
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(
|
|
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
|
|
1459
|
-
const key = unitProjectKeyOf(
|
|
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(
|
|
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
|
|
1529
|
-
if (
|
|
1530
|
-
out.add(
|
|
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
|
|
1541
|
-
if (
|
|
1897
|
+
for (const entry of manifest) {
|
|
1898
|
+
if (entry.sessionId !== ancestor || entry.turnSeq === null)
|
|
1542
1899
|
continue;
|
|
1543
|
-
if (own.has(
|
|
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
|
-
|
|
1910
|
+
// 段摘要来自清单里**写入时算好**的那份(口径仍是 summarizeRollbackPoint)
|
|
1911
|
+
...entrySummaryOf(entry),
|
|
1551
1912
|
sessionId: child,
|
|
1552
|
-
unitId: childUnitIdOf(
|
|
1553
|
-
inheritedFrom:
|
|
1554
|
-
inheritedSessionId:
|
|
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.
|
|
1569
|
-
await this.refreshForkLinks(ledger.
|
|
1570
|
-
|
|
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(
|
|
1573
|
-
const inherited = this.inheritedViews(ledger.
|
|
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.
|
|
1593
|
-
const
|
|
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:
|
|
1958
|
+
records: [],
|
|
1596
1959
|
storage,
|
|
1597
1960
|
};
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1821
|
-
if (
|
|
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
|
-
|
|
1845
|
-
|
|
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.
|
|
1992
|
-
const
|
|
1993
|
-
if (
|
|
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.
|
|
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
|
|
2111
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
2593
|
-
|
|
2594
|
-
|
|
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 (
|
|
2597
|
-
return { ok: false, code: 'protected-point', detail:
|
|
3033
|
+
if (entry.protected || entry.kind === 'guard') {
|
|
3034
|
+
return { ok: false, code: 'protected-point', detail: entry.kind, id };
|
|
2598
3035
|
}
|
|
2599
|
-
const kept = ledger.
|
|
3036
|
+
const kept = ledger.entries.filter((r) => r.id !== id);
|
|
2600
3037
|
try {
|
|
2601
|
-
await
|
|
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);
|