dsh-config-manager 0.1.72 → 0.1.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +103 -10
- package/lib/client.js +10748 -10119
- 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 +274 -0
- package/lib/core/rollback-ledger.js +1400 -0
- package/lib/core/rollback-point.d.ts +406 -5
- package/lib/core/rollback-point.js +1453 -306
- 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/messages.d.ts +1 -0
- package/lib/sync/messages.js +3 -0
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +108 -18
- 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 +5 -3
- package/lib/ui/i18n.js +10 -6
- 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/lib/utils/bundle-scan.d.ts +5 -3
- package/lib/utils/bundle-scan.js +5 -3
- package/lib/utils/line-diff.d.ts +7 -1
- package/lib/utils/line-diff.js +8 -2
- 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 +59 -10
- package/src/client/chat/RollbackDiffTab.tsx +49 -2
- package/src/client/chat/RollbackFileIcon.tsx +7 -45
- package/src/client/chat/RollbackReviewDialog.tsx +20 -4
- package/src/client/chat/chat-entry-view.test.ts +164 -1
- package/src/client/chat/chat-entry-view.ts +127 -10
- package/src/client/chat/chat-node-registration.ts +40 -6
- 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 +54 -29
- package/src/client/common/client-route-parity.test.ts +6 -0
- package/src/client/common/host-primitives.ts +78 -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 +138 -14
- package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
- package/src/client/index.ts +54 -2
- package/src/client/library/RestorePlanView.tsx +0 -1
- package/src/client/locales.ts +8 -6
- package/src/client/recovery/RollbackCard.tsx +201 -7
- package/src/client/recovery/RollbackDiffPane.tsx +26 -8
- package/src/client/recovery/recovery-api.ts +28 -0
- package/src/client/recovery/recovery-locales.ts +36 -6
- package/src/client/recovery/rollback-enabled.ts +41 -0
- package/src/client/recovery/rollback-file-changes.test.ts +77 -0
- package/src/client/sync/PushPreviewCard.tsx +57 -0
- package/src/client/sync/SyncPanel.tsx +2 -37
- package/src/client/sync/SyncSettingsView.test.ts +3 -0
- package/src/client/sync/sync-push-preview.test.ts +14 -0
- package/src/client/sync/sync-view.test.ts +1 -1
- package/src/client/sync/sync-view.ts +9 -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 +1430 -0
- package/src/core/rollback-point.test.ts +866 -41
- package/src/core/rollback-point.ts +1582 -291
- 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/messages.ts +3 -0
- package/src/sync/sync-credentials.test.ts +119 -0
- package/src/sync/sync-engine.ts +106 -18
- package/src/sync/sync-preview-push.test.ts +158 -0
- 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 +10 -6
- 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
- package/src/utils/bundle-scan.ts +5 -3
- package/src/utils/line-diff.test.ts +12 -0
- package/src/utils/line-diff.ts +9 -3
|
@@ -74,12 +74,26 @@ 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
|
/* ------------------------------------------------------------------ 常量 */
|
|
79
|
+
/** 增量链的最大长度:到顶就落一个**全量锚点**(重建最多读 1 份全量 + 几份小增量) */
|
|
80
|
+
const ROLLBACK_DELTA_CHAIN_LIMIT = 8;
|
|
81
|
+
/** 增量视图只有在小于全量的这个比例时才采用(否则直接全量,简单可靠) */
|
|
82
|
+
const ROLLBACK_DELTA_MAX_RATIO = 0.6;
|
|
78
83
|
export const ROLLBACK_SCHEMA_VERSION = 2;
|
|
79
84
|
/** 回滚点库目录名(dataDir/rollback-points)——路由族 /recovery/rollback-points/* 同源 */
|
|
80
85
|
export const ROLLBACK_POINTS_DIR_NAME = 'rollback-points';
|
|
86
|
+
/**
|
|
87
|
+
* 台账**清单**文件名(v3,issue #89):它只是一份小清单(元数据 + 段摘要),
|
|
88
|
+
* 完整记录在 records/<id>.json、工作区载荷在 payloads/<sha256>.json —— 清单里绝不含大数组。
|
|
89
|
+
*/
|
|
81
90
|
export const ROLLBACK_LEDGER_NAME = 'index.json';
|
|
82
91
|
export const ROLLBACK_OBJECTS_DIR_NAME = 'objects';
|
|
92
|
+
/**
|
|
93
|
+
* 台账字节预算(records/ + payloads/)的缺省值,可由 RollbackEngineOptions.ledgerBytesBudget 覆盖。
|
|
94
|
+
* 为什么需要它:ROLLBACK_MAX_RECORDS(50)只限**条数**;本机实测 50 条 = 473 MB 直接把宿主撑爆。
|
|
95
|
+
*/
|
|
96
|
+
export { ROLLBACK_DEFAULT_LEDGER_BYTES };
|
|
83
97
|
/** 全局自动点上限(S6);protected / guard 点不占预算、只进不出 */
|
|
84
98
|
export const ROLLBACK_MAX_RECORDS = 50;
|
|
85
99
|
/** 每会话自动点上限(S6) */
|
|
@@ -157,6 +171,55 @@ export const ROLLBACK_DEFAULT_SESSION_QUIESCENT_MS = 30_000;
|
|
|
157
171
|
/** 轮询回退的间隔上界(S1:会话日志 mtime ≤5 s 轮询) */
|
|
158
172
|
export const ROLLBACK_DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
159
173
|
export const ROLLBACK_LEFTOVER_LIST_LIMIT = 20;
|
|
174
|
+
/**
|
|
175
|
+
* 工作区捕获的**挂钟时间预算**(缺省 30 s)。
|
|
176
|
+
*
|
|
177
|
+
* 为什么需要(对标 dsh-turn-rewind 的 `deadline.ts`、dsh-checkpoint-rewind 至今未合并的 PR #25):
|
|
178
|
+
* 捕获路径此前只有「文件数 / 总字节 / 单文件字节」三个**规模**上限,没有**时间**上限 ——
|
|
179
|
+
* 一个「文件数很多但总量不大」的工作区能让 walk + stat + sha256 跑很久。
|
|
180
|
+
* 超预算**不是失败**:已收条目照常成点(ok=true),未走查的部分按 `budget-time` 逐条留痕、
|
|
181
|
+
* `truncated` 置真 ⇒ 删除集自动 fail-closed(证明名单不完整就一条都不删)。
|
|
182
|
+
*/
|
|
183
|
+
export const ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS = 30_000;
|
|
184
|
+
/**
|
|
185
|
+
* 自适应预算区间(见 RollbackEngineOptions.captureBudgetMs):上一次留点被截断 ⇒ 下次 ×2,
|
|
186
|
+
* 上一次只用掉不到 1/4 ⇒ 下次 ÷2。区间外不再调整(避免无限拉长 / 缩到不可用)。
|
|
187
|
+
*/
|
|
188
|
+
export const ROLLBACK_MIN_CAPTURE_BUDGET_MS = 10_000;
|
|
189
|
+
export const ROLLBACK_MAX_CAPTURE_BUDGET_MS = 120_000;
|
|
190
|
+
/**
|
|
191
|
+
* 捕获期「读内容 + 写对象仓」的并发上限。
|
|
192
|
+
*
|
|
193
|
+
* 为什么必须并发:真机实测(34k 文件 / 512 MiB)一次全量捕获 175 s,其中 **148 s 花在
|
|
194
|
+
* 31,702 次对象写**(一次 4.7 ms:temp + fsync + rename),而走查本身只要 0.66 s。
|
|
195
|
+
* 对象写入是纯 I/O,串行做等于把预算全花在等待上 ⇒ 覆盖永远只有一小半。
|
|
196
|
+
* 并发上限与**在飞字节闸门**共同限流(在飞内容上界 = `captureInFlightBytes`)。
|
|
197
|
+
*/
|
|
198
|
+
export const ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY = 16;
|
|
199
|
+
/**
|
|
200
|
+
* 捕获期**读入内存、尚未落对象仓**的内容字节上限(真正的内存闸门)。
|
|
201
|
+
*
|
|
202
|
+
* 为什么必须独立于 `maxFileBytes`(0.1.74 真机闪退事故,2026-10-16):
|
|
203
|
+
* `maxFileBytes` 的缺省是**总量预算** `maxBytes`(512 MiB)。并发捕获把闸门接到它上面,
|
|
204
|
+
* 于是「同时在飞的内容」可以到 ~512 MiB —— 串行实现的上界是「一份文件」,并发实现的
|
|
205
|
+
* 上界是「闸门限额」,两者差着两三个数量级。真机表现:发出消息后 1–2 s(= `turn/start`
|
|
206
|
+
* 开始留点)DSH Desktop 宿主被系统终止 ⇒ 桌面闪退(无 JS 栈、插件零错误、
|
|
207
|
+
* 退出码是「被外部终止」形状)。这里取一个小常量当闸门;要放宽只能显式传
|
|
208
|
+
* `captureInFlightBytes`,且被 `maxBytes` 夹住。
|
|
209
|
+
*/
|
|
210
|
+
export const ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES = 32 * 1024 * 1024;
|
|
211
|
+
/** 对象写遇到这些码视为「瞬时占用」,有界退避重试(判据同 atomic-write 的 isRetryableLockCode) */
|
|
212
|
+
const OBJECT_WRITE_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'];
|
|
213
|
+
/** 捕获阶段①的 stat 批大小:判定仍严格按顺序,只是 stat 并发取(真机 34k 次 stat 从 5 s → <1 s) */
|
|
214
|
+
export const ROLLBACK_CAPTURE_STAT_BATCH = 128;
|
|
215
|
+
/**
|
|
216
|
+
* 捕获期**候选名单**上限(不是入点上限):必须显著大于 maxFiles,否则「缺口优先」排序作用不到
|
|
217
|
+
* 那些排在遍历顺序尾部、上一回合没覆盖到的文件(它们连候选都进不了 ⇒ 覆盖率永远不收敛)。
|
|
218
|
+
* 走查本来就会把每条路径记进 walked(路径索引的来源),这里多留的只是候选数组本身。
|
|
219
|
+
*/
|
|
220
|
+
export const ROLLBACK_CAPTURE_LIST_LIMIT = 200_000;
|
|
221
|
+
/** 走查中断时逐条记下的「没进去的子树」上限;超出即放弃精确范围,退回全局 fail-closed */
|
|
222
|
+
export const ROLLBACK_UNWALKED_DIRS_LIMIT = 200;
|
|
160
223
|
/**
|
|
161
224
|
* 「删除回退点之后新建的路径」的**整套上限**(用户 2026-10-11 定案 A)。
|
|
162
225
|
* 超过就整套拒绝(fail-closed),绝不删一半 —— 半套删除既没有可解释的状态,也无法整体回滚。
|
|
@@ -188,6 +251,14 @@ export const ROLLBACK_PREVIEW_DIFF_TOTAL_WORK = 32_000_000;
|
|
|
188
251
|
export const ROLLBACK_FILE_DIFF_LIMITS = {
|
|
189
252
|
maxFileBytes: ROLLBACK_PREVIEW_DIFF_MAX_FILE_BYTES,
|
|
190
253
|
context: 3,
|
|
254
|
+
/**
|
|
255
|
+
* Myers 编辑距离上限(2026-10-15 用户:不该动辄出现「已按整块替换展示」)。
|
|
256
|
+
* 实测(本仓 rollback-point.ts 的 4983 行 × 2492 行版本对):D 在 3000~4000 就能拿到
|
|
257
|
+
* 最小编辑脚本 —— 耗时 ≤70 ms;代价是回溯的 V 快照 O(D²),RSS 峰值 +~100 MB。
|
|
258
|
+
* 缺省那档 400 会让几千行里的普通改动一律退化成「整块替换」,那正是用户看到的那条提示;
|
|
259
|
+
* 单文件是**按需点开**的一屏,这点内存换「真差异」划算。再大就换算法,不加预算。
|
|
260
|
+
*/
|
|
261
|
+
maxD: 4000,
|
|
191
262
|
};
|
|
192
263
|
/** 恢复顺序(工作区在前;工作区失败即中止后面的会话段) */
|
|
193
264
|
export const ROLLBACK_REWIND_ORDER = ['workspace', 'session'];
|
|
@@ -211,6 +282,24 @@ export const ROLLBACK_PATH_CACHE_NAME = 'path-cache.json';
|
|
|
211
282
|
function win32Fold(value) {
|
|
212
283
|
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
213
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* 路径里是否存在以 `.asar` 结尾的段(**捕获 / 走查期一律不碰这类路径**)。
|
|
287
|
+
*
|
|
288
|
+
* 为什么(真机事故,0.1.76):Electron 宿主对任何含 `.asar` 段的路径**启用了 asar 虚拟化** ——
|
|
289
|
+
* 即使那只是个普通文件,`fs.stat`/`readdir`/`read` 也会被接管去当容器解析。工作区里只要躺着一枚
|
|
290
|
+
* **畸形** `.asar`(测试夹具、被打断的打包残留),宿主进程就会被原生层带走:crashpad
|
|
291
|
+
* `not connected`、退出码 4294930435 ⇒ DSH Desktop 闪退(无 JS 栈、插件零错误)。
|
|
292
|
+
* 实测(本仓真机捕获、同一份输入):`.tmp` 下 19 枚 `.asar` 夹具在场 → Electron 进程死;
|
|
293
|
+
* 全部改名后 → 同一趟捕获 9 s 跑完、退出码 0。
|
|
294
|
+
* `.asar.unpacked` 是**真目录**(段名不以 `.asar` 结尾)⇒ 不拦。
|
|
295
|
+
*/
|
|
296
|
+
export function hasAsarSegment(target) {
|
|
297
|
+
for (const segment of target.split(/[\\/]+/)) {
|
|
298
|
+
if (segment.length > '.asar'.length && win32Fold(segment).endsWith('.asar'))
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
214
303
|
/** child 是否等于 parent 或位于其下(win32 折叠大小写;段边界判定) */
|
|
215
304
|
export function isInsidePath(child, parent) {
|
|
216
305
|
const c = win32Fold(path.resolve(child));
|
|
@@ -221,6 +310,82 @@ export function isInsidePath(child, parent) {
|
|
|
221
310
|
function pathKey(value) {
|
|
222
311
|
return win32Fold(path.resolve(value));
|
|
223
312
|
}
|
|
313
|
+
/** 两个分块列表是否逐个相同(增量基准必须作用于同一批分块) */
|
|
314
|
+
function sameChunks(a, b) {
|
|
315
|
+
if (a.length !== b.length)
|
|
316
|
+
return false;
|
|
317
|
+
for (let i = 0; i < a.length; i += 1)
|
|
318
|
+
if (a[i] !== b[i])
|
|
319
|
+
return false;
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* **存盘视图**:条目里的 `object` 恒等于 `sha256`(内容寻址的名字就是内容哈希)⇒ 落盘时不写这个字段,
|
|
324
|
+
* 读取时按 `sha256` 补回(见 ledger 的 `withObjectRefs`)。每条约省 1/5 字节。
|
|
325
|
+
*/
|
|
326
|
+
function compactPayloadForStorage(payload) {
|
|
327
|
+
const strip = (list) => {
|
|
328
|
+
if (!Array.isArray(list))
|
|
329
|
+
return list;
|
|
330
|
+
return list.map((file) => {
|
|
331
|
+
const copy = { ...file };
|
|
332
|
+
delete copy['object'];
|
|
333
|
+
return copy;
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
const delta = payload.delta;
|
|
337
|
+
return {
|
|
338
|
+
...payload,
|
|
339
|
+
files: strip(payload.files),
|
|
340
|
+
...(delta === undefined
|
|
341
|
+
? {}
|
|
342
|
+
: { delta: { ...delta, ...(delta.added === undefined ? {} : { added: strip(delta.added) }) } }),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* 构造「相对基准的增量视图」;不划算(≥ 全量 × {@link ROLLBACK_DELTA_MAX_RATIO})时返回 null。
|
|
347
|
+
*
|
|
348
|
+
* 条目**逐字比较**:路径、sha、字节数、mtimeMs、所属分块任一不同 ⇒ 进 `added`;基准里不再存在的路径进 `removed`。
|
|
349
|
+
* 重建 = 基准 − removed,再用 added 覆盖/追加,最后按路径排序(与全量捕获同序)。
|
|
350
|
+
*/
|
|
351
|
+
/** 基准摘要键:内容 + 字节数 + 所属分块(`mtimeMs` 不参与 —— 只被触摸、内容没变的文件不必进增量) */
|
|
352
|
+
function digestKeyOf(file) {
|
|
353
|
+
return String(file.sha256) + ':' + String(file.bytes) + ':' + String(file.chunk);
|
|
354
|
+
}
|
|
355
|
+
/** 相对**内存基准摘要**的条目差异(新增/改写 / 不再存在);路径逐字比较,不读任何文件 */
|
|
356
|
+
function payloadDiffAgainstDigest(payload, baseEntries) {
|
|
357
|
+
const added = [];
|
|
358
|
+
const nextPaths = new Set();
|
|
359
|
+
for (const file of payload.files) {
|
|
360
|
+
nextPaths.add(file.path);
|
|
361
|
+
const prev = baseEntries.get(file.path);
|
|
362
|
+
if (prev === undefined || prev !== digestKeyOf(file))
|
|
363
|
+
added.push(file);
|
|
364
|
+
}
|
|
365
|
+
const removed = [];
|
|
366
|
+
for (const path of baseEntries.keys())
|
|
367
|
+
if (!nextPaths.has(path))
|
|
368
|
+
removed.push(path);
|
|
369
|
+
return { added, removed };
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* 构造「相对基准的增量视图」;不划算(≥ 全量 × {@link ROLLBACK_DELTA_MAX_RATIO})时返回 null。
|
|
373
|
+
* 重建 = 基准 − removed,再用 added 覆盖/追加,最后按路径排序(与全量捕获同序)。
|
|
374
|
+
*/
|
|
375
|
+
function buildPayloadDelta(payload, baseHash, baseChain, diff) {
|
|
376
|
+
if (baseHash === '')
|
|
377
|
+
return null;
|
|
378
|
+
const delta = {
|
|
379
|
+
...payload,
|
|
380
|
+
// **故意不是数组**:不认识 `delta` 的旧读取器会在形状校验这一步**拒绝这个点**(fail-closed),
|
|
381
|
+
// 而不是把「增量那几条」当成完整清单去规划删除 —— 那会把留点时存在的文件全判成新建、全部删掉。
|
|
382
|
+
files: null,
|
|
383
|
+
delta: { base: baseHash, baseChain: [...baseChain], removed: diff.removed, added: diff.added },
|
|
384
|
+
};
|
|
385
|
+
if (JSON.stringify(delta).length >= JSON.stringify(payload).length * ROLLBACK_DELTA_MAX_RATIO)
|
|
386
|
+
return null;
|
|
387
|
+
return delta;
|
|
388
|
+
}
|
|
224
389
|
function errorDetail(error) {
|
|
225
390
|
if (error instanceof Error) {
|
|
226
391
|
const code = error.code;
|
|
@@ -419,6 +584,17 @@ export function resolveTurnBoundary(events, turnSeq) {
|
|
|
419
584
|
}
|
|
420
585
|
return { ok: true, boundarySeq: boundary.boundarySeq, endSeq: boundary.endSeq };
|
|
421
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* 记录在案的文件条目数:优先用载荷自带的 `fileCount`(增量视图下 `files` 可能只是子集),
|
|
589
|
+
* 老载荷没有该字段才回落到 `files.length`。
|
|
590
|
+
*/
|
|
591
|
+
export function workspaceFileCount(payload) {
|
|
592
|
+
if (payload === null)
|
|
593
|
+
return 0;
|
|
594
|
+
if (typeof payload.fileCount === 'number' && Number.isFinite(payload.fileCount))
|
|
595
|
+
return payload.fileCount;
|
|
596
|
+
return payload.files.length;
|
|
597
|
+
}
|
|
422
598
|
export function summarizeRollbackPoint(record) {
|
|
423
599
|
const workspace = record.workspace.payload;
|
|
424
600
|
const session = record.session.payload;
|
|
@@ -449,7 +625,7 @@ export function summarizeRollbackPoint(record) {
|
|
|
449
625
|
note: record.note,
|
|
450
626
|
spreadMs: record.spreadMs,
|
|
451
627
|
segments: [
|
|
452
|
-
segment('workspace', record.workspace, workspace
|
|
628
|
+
segment('workspace', record.workspace, workspaceFileCount(workspace), workspace?.bytes ?? 0, null, workspaceFileCount(workspace) > 0, workspace?.skippedCount ?? 0, workspace?.truncated ?? false),
|
|
453
629
|
segment('session', record.session, session === null ? 0 : 1, session?.bytes ?? 0, session?.cursorBytes ?? null, session !== null && session.sessionId !== null && session.boundarySeq !== null, 0, false),
|
|
454
630
|
],
|
|
455
631
|
};
|
|
@@ -524,6 +700,105 @@ function withLedgerWriteLock(ledgerPath, task) {
|
|
|
524
700
|
});
|
|
525
701
|
return run;
|
|
526
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* 回退前像暂存器:**字节落盘、索引随栈**。
|
|
705
|
+
*
|
|
706
|
+
* 为什么不是内存数组(旧实现):大工作区整体回退时前像峰值 = 待写总字节(可达数百 MB),
|
|
707
|
+
* 与 issue #89 的 OOM 同族。落盘后内存只随**文件条数**增长。
|
|
708
|
+
*
|
|
709
|
+
* 生命周期:`stage()` 逐条写前像 → 成功则 `dispose()`;中途失败则 `rollback()` 写回再 `dispose()`。
|
|
710
|
+
* 目录名带 pid,崩溃残留由 rollback-ledger 的 `cleanupOrphanTempFiles` 在下次启动回收。
|
|
711
|
+
*/
|
|
712
|
+
class UndoStager {
|
|
713
|
+
entries = [];
|
|
714
|
+
dir;
|
|
715
|
+
seq = 0;
|
|
716
|
+
constructor(dir) {
|
|
717
|
+
this.dir = dir;
|
|
718
|
+
}
|
|
719
|
+
/** 记一条前像:`bytes === null` = 该路径当时**不存在**(回滚时删掉它)。 */
|
|
720
|
+
async stage(target, bytes) {
|
|
721
|
+
if (bytes === null) {
|
|
722
|
+
this.entries.push({ path: target, file: null });
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const file = path.join(this.dir, String(this.seq) + '.bin');
|
|
726
|
+
this.seq += 1;
|
|
727
|
+
await fs.writeFile(file, bytes);
|
|
728
|
+
this.entries.push({ path: target, file });
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* 逐条写回(**逆序**:同一路径被 stage 两次时,最后一次 stage 记的是最早状态,必须最后写)。
|
|
732
|
+
* 返回失败清单 —— 回滚失败绝不静默。
|
|
733
|
+
*/
|
|
734
|
+
async rollback(writer) {
|
|
735
|
+
const failed = [];
|
|
736
|
+
for (const entry of [...this.entries].reverse()) {
|
|
737
|
+
try {
|
|
738
|
+
if (entry.file === null)
|
|
739
|
+
await fs.rm(entry.path, { force: true });
|
|
740
|
+
else
|
|
741
|
+
await writer(entry.path, await fs.readFile(entry.file));
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
failed.push(entry.path + ' ' + errorDetail(error));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return failed;
|
|
748
|
+
}
|
|
749
|
+
/** 前像目录用完即清(best-effort:清理失败不该把回退报成失败)。 */
|
|
750
|
+
async dispose() {
|
|
751
|
+
try {
|
|
752
|
+
await fs.rm(this.dir, { recursive: true, force: true });
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
/* best-effort */
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* 单文件字节闸门:保证「同时在飞(已读入、尚未落仓)的内容字节」≤ limit。
|
|
761
|
+
*
|
|
762
|
+
* 与 captureConcurrency 一起构成捕获期的内存上界:并发只放大**同时**驻留的量,而它被
|
|
763
|
+
* `captureInFlightBytes` 钉死(缺省 32 MiB)。**不能**用 maxFileBytes 当限额 —— 它缺省 =
|
|
764
|
+
* 总量预算 512 MiB,「一份文件」与「整个点的预算」不是同一个量级(0.1.74 闪退根因)。
|
|
765
|
+
*/
|
|
766
|
+
/**
|
|
767
|
+
* 路径身份键:dev:ino:size:mtimeNs:ctimeNs:mode(全 BigInt 字符串化)。必须 BigInt:ino / mtimeNs /
|
|
768
|
+
* ctimeNs 在 64 位 inode 文件系统上超出 Number 安全整数范围,用 Number 会静默丢精度(把两次不同的
|
|
769
|
+
* 写当成同一个身份)。单一来源:fileStatOf 与 pathIdentityOf 都用它。
|
|
770
|
+
*/
|
|
771
|
+
function fileIdentityOf(stat) {
|
|
772
|
+
return [
|
|
773
|
+
String(stat.dev),
|
|
774
|
+
String(stat.ino),
|
|
775
|
+
String(stat.size),
|
|
776
|
+
String(stat.mtimeNs),
|
|
777
|
+
String(stat.ctimeNs),
|
|
778
|
+
String(stat.mode & 511n),
|
|
779
|
+
].join(':');
|
|
780
|
+
}
|
|
781
|
+
class ByteGate {
|
|
782
|
+
used = 0;
|
|
783
|
+
waiters = [];
|
|
784
|
+
limit;
|
|
785
|
+
constructor(limit) {
|
|
786
|
+
this.limit = limit;
|
|
787
|
+
}
|
|
788
|
+
async acquire(bytes) {
|
|
789
|
+
const need = Math.max(0, Math.min(bytes, this.limit));
|
|
790
|
+
while (this.used + need > this.limit && this.used > 0) {
|
|
791
|
+
await new Promise((resolve) => { this.waiters.push(resolve); });
|
|
792
|
+
}
|
|
793
|
+
this.used += need;
|
|
794
|
+
}
|
|
795
|
+
release(bytes) {
|
|
796
|
+
this.used = Math.max(0, this.used - Math.max(0, Math.min(bytes, this.limit)));
|
|
797
|
+
const next = this.waiters.shift();
|
|
798
|
+
if (next !== undefined)
|
|
799
|
+
next();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
527
802
|
export class RollbackPointEngine {
|
|
528
803
|
dataDir;
|
|
529
804
|
homeDir;
|
|
@@ -535,6 +810,7 @@ export class RollbackPointEngine {
|
|
|
535
810
|
maxFiles;
|
|
536
811
|
maxBytes;
|
|
537
812
|
maxFileBytes;
|
|
813
|
+
captureInFlightBytes;
|
|
538
814
|
maxSkippedEntries;
|
|
539
815
|
sessionMaxBytes;
|
|
540
816
|
sessionQuiescentMs;
|
|
@@ -564,9 +840,49 @@ export class RollbackPointEngine {
|
|
|
564
840
|
forkLinksAt = 0;
|
|
565
841
|
forkLinksComplete = true;
|
|
566
842
|
/** 会话日志头缓存(mtime + size 失效;只服务 fork 链扫描) */
|
|
843
|
+
/**
|
|
844
|
+
* **本进程**最近一次成功留点的工作区摘要(清单增量化的基准)。
|
|
845
|
+
*
|
|
846
|
+
* 为什么是内存而不是读盘:一次留点只准读清单(L-08 / issue #89)—— 读上一份 12 MB 载荷去算差异
|
|
847
|
+
* 正是那条被消灭的路。内存量 ≈ 每文件一个 `sha:bytes:chunk` 串(3.4 万文件约几 MB),
|
|
848
|
+
* 与已有的身份缓存(`pathCache`)同量级;进程重启后第一次留点落全量锚点,随后继续链。
|
|
849
|
+
*/
|
|
850
|
+
deltaBase = null;
|
|
567
851
|
forkHeaderCache = new Map();
|
|
568
|
-
/** 继承判据用的台账快照(TTL
|
|
852
|
+
/** 继承判据用的台账快照(TTL 缓存;只缓存**清单条目**,绝不缓存完整记录) */
|
|
569
853
|
ledgerSnapshotCache = null;
|
|
854
|
+
/** 台账存储层(v3:清单 + 单条记录 + 内容寻址载荷;issue #89) */
|
|
855
|
+
ledger;
|
|
856
|
+
ledgerBytesBudget;
|
|
857
|
+
/**
|
|
858
|
+
* 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
|
|
859
|
+
* 每次留点后按「是否被截断 / 用掉多少」自适应(见 ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS)。
|
|
860
|
+
*/
|
|
861
|
+
captureBudgetMs;
|
|
862
|
+
/** 自适应区间下界 / 上界(构造期定死,运行期只调 captureBudgetMs) */
|
|
863
|
+
captureBudgetMinMs;
|
|
864
|
+
captureBudgetMaxMs;
|
|
865
|
+
/** 回退期走查的时间预算(与捕获预算分开:自适应不该顺手拉长回退期的走查) */
|
|
866
|
+
rewindBudgetMs;
|
|
867
|
+
/** 捕获期读 + 写并发上限 */
|
|
868
|
+
captureConcurrency;
|
|
869
|
+
/** 对象仓目录已确认存在(省掉每个对象一次 mkdir) */
|
|
870
|
+
objectsDirReady = false;
|
|
871
|
+
/**
|
|
872
|
+
* 本回合对象名单快照(一次 readdir 顶掉「每个候选一次 fs.access」;真机 34k 次 access ≈ 3.7 s)。
|
|
873
|
+
* null = 读不到名单(首次还没建目录/异常)⇒ 回落逐条 objectExists。
|
|
874
|
+
* 只保证「写进去的立刻可见」:捕获期间被**并发删除**的对象可能仍被判为存在 —— 那一路由
|
|
875
|
+
* 读取侧(readObject + 哈希校验)响亮失败,不会静默给出错误的 before 内容。
|
|
876
|
+
*/
|
|
877
|
+
captureObjectNames = null;
|
|
878
|
+
/**
|
|
879
|
+
* 启动期一次性初始化(流式迁移 v2 -> v3 + 孤儿 tmp 清理)的惰性单飞。
|
|
880
|
+
* 为什么惰取而不是构造时同步跑:构造发生在每个请求路径上,同步扫盘/迁移会把请求卡死;
|
|
881
|
+
* 而「每回合都扫一遍」正是要避免的浪费 —— 所以只跑一次并记住结果。
|
|
882
|
+
*/
|
|
883
|
+
initPromise = null;
|
|
884
|
+
/** 启动期初始化失败的如实记录(读路径必须报出来,绝不假装台账是空的) */
|
|
885
|
+
initError;
|
|
570
886
|
constructor(options) {
|
|
571
887
|
this.dataDir = path.resolve(options.dataDir);
|
|
572
888
|
this.homeDir = path.resolve(options.homeDir);
|
|
@@ -579,6 +895,10 @@ export class RollbackPointEngine {
|
|
|
579
895
|
this.maxBytes = options.maxBytes ?? ROLLBACK_DEFAULT_MAX_BYTES;
|
|
580
896
|
// 单文件上限缺省 = 总量预算(一个文件不可能比整个点的预算更大)
|
|
581
897
|
this.maxFileBytes = options.maxFileBytes ?? this.maxBytes;
|
|
898
|
+
// **内存闸门与单文件上限分开**:拿 maxFileBytes 当闸门 = 允许把整个点的预算同时驻留
|
|
899
|
+
// (0.1.74 真机闪退的根因),见 ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES。
|
|
900
|
+
const inFlight = options.captureInFlightBytes ?? ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES;
|
|
901
|
+
this.captureInFlightBytes = Math.max(1, Math.min(this.maxBytes, Math.floor(inFlight)));
|
|
582
902
|
this.maxSkippedEntries = options.maxSkippedEntries ?? ROLLBACK_SKIPPED_LIST_LIMIT;
|
|
583
903
|
this.sessionMaxBytes = options.sessionMaxBytes ?? ROLLBACK_DEFAULT_SESSION_MAX_BYTES;
|
|
584
904
|
this.sessionQuiescentMs = options.sessionQuiescentMs ?? ROLLBACK_DEFAULT_SESSION_QUIESCENT_MS;
|
|
@@ -596,6 +916,22 @@ export class RollbackPointEngine {
|
|
|
596
916
|
this.previewDiffMaxWork = options.previewDiffMaxWork ?? ROLLBACK_PREVIEW_DIFF_MAX_WORK;
|
|
597
917
|
this.previewDiffTotalWork = options.previewDiffTotalWork ?? ROLLBACK_PREVIEW_DIFF_TOTAL_WORK;
|
|
598
918
|
this.log = options.log;
|
|
919
|
+
this.ledgerBytesBudget = options.ledgerBytesBudget ?? ROLLBACK_DEFAULT_LEDGER_BYTES;
|
|
920
|
+
this.captureBudgetMs = options.captureBudgetMs ?? ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS;
|
|
921
|
+
this.captureBudgetMinMs = options.captureBudgetMinMs ?? ROLLBACK_MIN_CAPTURE_BUDGET_MS;
|
|
922
|
+
this.captureBudgetMaxMs = options.captureBudgetMaxMs ?? ROLLBACK_MAX_CAPTURE_BUDGET_MS;
|
|
923
|
+
this.rewindBudgetMs = options.rewindBudgetMs ?? this.captureBudgetMs;
|
|
924
|
+
this.captureConcurrency = Math.max(1, Math.min(64, Math.floor(options.captureConcurrency ?? ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY)));
|
|
925
|
+
this.ledger = new RollbackLedger({
|
|
926
|
+
root: this.root,
|
|
927
|
+
bytesBudget: this.ledgerBytesBudget,
|
|
928
|
+
// 摘要与记录校验的单一事实源都在本模块:注入而不是反向 import,避免运行期循环依赖
|
|
929
|
+
summarize: summarizeRollbackPoint,
|
|
930
|
+
isLedgerRecord: isRollbackPointRecord,
|
|
931
|
+
now: () => this.stamp(),
|
|
932
|
+
log: (line) => this.warn(line),
|
|
933
|
+
onFileRead: options.ledgerReadFile,
|
|
934
|
+
});
|
|
599
935
|
}
|
|
600
936
|
/** 回滚点库根目录(只读侦察用;写路径全部经本类的门) */
|
|
601
937
|
get storeDir() {
|
|
@@ -643,6 +979,22 @@ export class RollbackPointEngine {
|
|
|
643
979
|
else
|
|
644
980
|
sink.skippedTruncated = true;
|
|
645
981
|
}
|
|
982
|
+
/**
|
|
983
|
+
* 打开一次回退的前像暂存目录(`<root>/undo/<pid>-<ts36>-<8hex>/`)。
|
|
984
|
+
* 返回 null = 建不出来(权限 / 磁盘满)⇒ 调用方必须**零写入失败**。
|
|
985
|
+
*/
|
|
986
|
+
async openUndoStager() {
|
|
987
|
+
const name = String(process.pid) + '-' + this.stamp().toString(36) + '-' + crypto.randomBytes(4).toString('hex');
|
|
988
|
+
const dir = path.join(this.root, ROLLBACK_UNDO_DIR_NAME, name);
|
|
989
|
+
try {
|
|
990
|
+
await fs.mkdir(dir, { recursive: true });
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
this.warn('rollback undo staging unavailable: ' + errorDetail(error));
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
return new UndoStager(dir);
|
|
997
|
+
}
|
|
646
998
|
async writeFile(target, bytes) {
|
|
647
999
|
if (this.writeSeam !== undefined) {
|
|
648
1000
|
await this.writeSeam(target, bytes);
|
|
@@ -651,8 +1003,49 @@ export class RollbackPointEngine {
|
|
|
651
1003
|
await atomicWriteFile(target, bytes, { symlink: 'reject' });
|
|
652
1004
|
}
|
|
653
1005
|
/* ---------------------------------------------------------- 存储栈探针 */
|
|
1006
|
+
/**
|
|
1007
|
+
* 启动期一次性初始化(惰性单飞):先**流式**迁移 v2 单文件 -> v3,再清理崩溃残留的原子写 tmp。
|
|
1008
|
+
*
|
|
1009
|
+
* 为什么惰取而不是在构造里同步跑:引擎在每个请求路径上都会被新建(路由层不缓存引擎),
|
|
1010
|
+
* 构造里同步扫盘/迁移会把请求卡死;而「每回合扫一遍」又是纯浪费 —— 所以只跑一次并记住结果。
|
|
1011
|
+
* 迁移失败**不抛**:如实记进 initError,由读路径报出来,并**拒写**(绝不覆盖还没迁完的 v2 文件)。
|
|
1012
|
+
*/
|
|
1013
|
+
async ensureReady() {
|
|
1014
|
+
if (this.initPromise === null)
|
|
1015
|
+
this.initPromise = this.runInit();
|
|
1016
|
+
await this.initPromise;
|
|
1017
|
+
}
|
|
1018
|
+
async runInit() {
|
|
1019
|
+
let migration;
|
|
1020
|
+
try {
|
|
1021
|
+
migration = await this.ledger.migrateIfNeeded();
|
|
1022
|
+
}
|
|
1023
|
+
catch (error) {
|
|
1024
|
+
this.initError = 'ledger unreadable: ' + errorDetail(error);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (migration.error !== undefined) {
|
|
1028
|
+
this.initError = migration.error;
|
|
1029
|
+
this.warn('rollback ledger migration failed: ' + migration.error);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (migration.migrated) {
|
|
1033
|
+
this.warn('rollback ledger migrated: records=' + String(migration.records) + ' skipped=' + String(migration.skipped));
|
|
1034
|
+
}
|
|
1035
|
+
// 迁移**之后**再清残留:连本次迁移自己的 tmp 一并收掉(只放行可自证形状的崩溃残留)
|
|
1036
|
+
try {
|
|
1037
|
+
const cleaned = await cleanupOrphanTempFiles(this.root);
|
|
1038
|
+
if (cleaned.removed > 0) {
|
|
1039
|
+
this.warn('rollback temp cleanup: removed=' + String(cleaned.removed) + ' bytes=' + String(cleaned.bytes) + ' skipped=' + String(cleaned.skipped));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
this.warn('rollback temp cleanup failed: ' + errorDetail(error));
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
654
1046
|
async storageStatus() {
|
|
655
1047
|
const base = { root: this.root, requirements: ROLLBACK_STORAGE_REQUIREMENTS };
|
|
1048
|
+
await this.ensureReady();
|
|
656
1049
|
let result;
|
|
657
1050
|
if (this.probe !== undefined) {
|
|
658
1051
|
try {
|
|
@@ -665,25 +1058,86 @@ export class RollbackPointEngine {
|
|
|
665
1058
|
else {
|
|
666
1059
|
result = await defaultStorageProbe(this.root);
|
|
667
1060
|
}
|
|
668
|
-
if (result.ok)
|
|
669
|
-
return { ...base, available:
|
|
670
|
-
|
|
1061
|
+
if (!result.ok) {
|
|
1062
|
+
return { ...base, available: false, detail: result.detail, guidance: storageGuidance(this.root, result.detail) };
|
|
1063
|
+
}
|
|
1064
|
+
const out = { ...base, available: true, guidance: [] };
|
|
1065
|
+
const ledger = await this.ledgerStatus();
|
|
1066
|
+
if (ledger !== undefined)
|
|
1067
|
+
out.ledger = ledger;
|
|
1068
|
+
if (this.initError !== undefined) {
|
|
1069
|
+
// 磁盘本身没问题,但台账读不出来(迁移失败 / 迁到一半被别人占着):如实上屏,不谎报可用
|
|
1070
|
+
out.detail = this.initError;
|
|
1071
|
+
out.guidance = storageGuidance(this.root, this.initError);
|
|
1072
|
+
}
|
|
1073
|
+
return out;
|
|
1074
|
+
}
|
|
1075
|
+
/** 台账可观测快照(清单条数 + 磁盘字节 + 预算 + 本进程淘汰计数) */
|
|
1076
|
+
async ledgerStatus() {
|
|
1077
|
+
try {
|
|
1078
|
+
const manifest = await this.ledger.readManifest();
|
|
1079
|
+
const stats = await this.ledger.stats();
|
|
1080
|
+
return {
|
|
1081
|
+
records: manifest.entries.length,
|
|
1082
|
+
bytes: stats.bytes,
|
|
1083
|
+
budgetBytes: this.ledgerBytesBudget,
|
|
1084
|
+
evicted: this.ledger.evictedCount,
|
|
1085
|
+
// 每次 append 都会尽力淘汰到预算内 ⇒「bytes > budget」本身就等于「淘汰到极限了」
|
|
1086
|
+
// (单条点大于预算时恒为 true)。这里不额外读盘,只用已有 stats。
|
|
1087
|
+
overBudget: stats.bytes > this.ledgerBytesBudget,
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
this.warn('rollback ledger status failed: ' + errorDetail(error));
|
|
1092
|
+
return undefined;
|
|
1093
|
+
}
|
|
671
1094
|
}
|
|
672
1095
|
/* -------------------------------------------------------------- 对象仓 */
|
|
1096
|
+
/** 对象仓目录只 mkdir 一次(每个对象各 mkdir 一次是纯白花的 syscall) */
|
|
1097
|
+
async ensureObjectsDir() {
|
|
1098
|
+
if (this.objectsDirReady)
|
|
1099
|
+
return;
|
|
1100
|
+
await fs.mkdir(this.objectsDir, { recursive: true });
|
|
1101
|
+
this.objectsDirReady = true;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* 写内容寻址对象(对象文件名 = 内容 SHA-256)。
|
|
1105
|
+
*
|
|
1106
|
+
* **为什么这里不用 atomicWriteFile**:那是给「可写配置文件」用的强原子写(同目录 tmp → fsync →
|
|
1107
|
+
* rename),真机实测一次约 4.7 ms —— 34k 文件的工作区一次全量捕获 148 s 全花在它上面(走查本身
|
|
1108
|
+
* 只要 0.66 s)。对象仓的正确性要求不同:文件名即内容哈希 ⇒
|
|
1109
|
+
* · 用 'wx' **独占创建最终名**本身已是原子的(不覆盖已有对象;并发同写只有一方成功);
|
|
1110
|
+
* · 半截 / 被篡改的内容在读取侧必然过不了哈希校验(readObject + sha256Hex)⇒ 按「读不出」
|
|
1111
|
+
* 处理并 fail-closed,绝不会冒充有效前像;
|
|
1112
|
+
* · 不做 fsync:回滚点是尽力而为的本地快照,不为掉电持久性付出每文件一次 fsync 的代价。
|
|
1113
|
+
*/
|
|
673
1114
|
async writeObjectWithHash(bytes, sha) {
|
|
674
1115
|
if (!OBJECT_KEY_RE.test(sha))
|
|
675
1116
|
throw new Error('rollback object key invalid');
|
|
676
1117
|
const target = path.join(this.objectsDir, sha);
|
|
677
1118
|
if (!isInsidePath(target, this.objectsDir))
|
|
678
1119
|
throw new Error('rollback object path escapes store');
|
|
679
|
-
await
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
1120
|
+
await this.ensureObjectsDir();
|
|
1121
|
+
let delay = 10;
|
|
1122
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1123
|
+
try {
|
|
1124
|
+
await fs.writeFile(target, bytes, { flag: 'wx' });
|
|
1125
|
+
this.captureObjectNames?.add(sha);
|
|
1126
|
+
return sha;
|
|
1127
|
+
}
|
|
1128
|
+
catch (error) {
|
|
1129
|
+
const code = error.code;
|
|
1130
|
+
if (code === 'EEXIST')
|
|
1131
|
+
return sha; // 已有同内容对象(去重 / 并发竞写)
|
|
1132
|
+
// 杀软 / 句柄瞬时占用:有界退避重试(与 atomic-write 的判据同族)
|
|
1133
|
+
if (OBJECT_WRITE_RETRY_CODES.includes(code) && attempt < 3) {
|
|
1134
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1135
|
+
delay *= 2;
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
throw error;
|
|
1139
|
+
}
|
|
683
1140
|
}
|
|
684
|
-
catch { /* 不存在才写(内容寻址天然去重) */ }
|
|
685
|
-
await atomicWriteFile(target, bytes);
|
|
686
|
-
return sha;
|
|
687
1141
|
}
|
|
688
1142
|
/** 对象仓里是否真有这个 blob(缓存命中时校验;任何异常一律按「没有」处理) */
|
|
689
1143
|
async objectExists(sha) {
|
|
@@ -792,13 +1246,28 @@ export class RollbackPointEngine {
|
|
|
792
1246
|
/**
|
|
793
1247
|
* 列出一个显式分块内的候选文件(不跟随符号链接 / junction;跳过与读不到都留痕)。
|
|
794
1248
|
* 返回 null = 该分块本身不可用(不存在 / 是链接 / 非常规文件),细节已写进 sink。
|
|
1249
|
+
*
|
|
1250
|
+
* `truncated` 与 `walkAborted` 是**两件事**:前者只说「本次列表不完整」(文件数上限 / 提交上限),
|
|
1251
|
+
* 后者说「走查被预算或取消打断,栈里还有没进去的子树」—— 只有后者才意味着那些路径
|
|
1252
|
+
* **一条都没进任何名单**,调用方必须据此让删除集 fail-closed。
|
|
795
1253
|
*/
|
|
796
1254
|
async listChunkFiles(chunk, sink, limit,
|
|
797
1255
|
/**
|
|
798
1256
|
* 捕获期专用:把**走查过**的每条文件路径都记进来(含超出 limit、因而没进 `out` 的那些)——
|
|
799
1257
|
* 路径索引({@link RollbackPathIndex})的来源。回退期的走查不传它(那份名单只由记录决定)。
|
|
800
1258
|
*/
|
|
801
|
-
collect, ignore
|
|
1259
|
+
collect, ignore,
|
|
1260
|
+
/**
|
|
1261
|
+
* 停止判据(捕获期传时间/取消预算;回退期的走查不传)—— 返回 true 立即停止遍历,
|
|
1262
|
+
* 已走查的部分照常返回并置 truncated + walkAborted(栈里剩余的子树从未进门 ⇒ 由调用方据此 fail-closed;
|
|
1263
|
+
* 回退期不传它,故 walkAborted 恒 false)。
|
|
1264
|
+
*/
|
|
1265
|
+
shouldStop) {
|
|
1266
|
+
// 分块根本身就是 `.asar` 段 ⇒ 一次 fs 调用都不发(见 hasAsarSegment)
|
|
1267
|
+
if (hasAsarSegment(chunk)) {
|
|
1268
|
+
this.recordSkip(sink, 'skipped', { path: chunk, reason: 'asar-container' });
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
802
1271
|
let stat;
|
|
803
1272
|
try {
|
|
804
1273
|
stat = await fs.lstat(chunk);
|
|
@@ -812,15 +1281,25 @@ export class RollbackPointEngine {
|
|
|
812
1281
|
return null;
|
|
813
1282
|
}
|
|
814
1283
|
if (stat.isFile())
|
|
815
|
-
return { files: [chunk], truncated: false };
|
|
1284
|
+
return { files: [chunk], truncated: false, walkAborted: false, unwalkedDirs: [] };
|
|
816
1285
|
if (!stat.isDirectory()) {
|
|
817
1286
|
this.recordSkip(sink, 'skipped', { path: chunk, reason: 'not-a-file' });
|
|
818
1287
|
return null;
|
|
819
1288
|
}
|
|
820
1289
|
const out = [];
|
|
821
1290
|
let truncated = false;
|
|
1291
|
+
// 走查**被预算 / 取消打断**:栈里还压着没进去的子树 —— 那些路径一条都没进任何名单
|
|
1292
|
+
let walkAborted = false;
|
|
1293
|
+
// 中断时把**还没进去的子树**如实带出去 ⇒ 调用方可以只对这几棵子树 fail-closed(精确范围)
|
|
1294
|
+
let unwalkedDirs = [];
|
|
822
1295
|
const stack = [{ dir: chunk, depth: 0 }];
|
|
823
1296
|
while (stack.length > 0) {
|
|
1297
|
+
if (shouldStop?.() === true) {
|
|
1298
|
+
truncated = true;
|
|
1299
|
+
walkAborted = true;
|
|
1300
|
+
unwalkedDirs = stack.length > ROLLBACK_UNWALKED_DIRS_LIMIT ? [] : stack.map((item) => item.dir);
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
824
1303
|
const item = stack.pop();
|
|
825
1304
|
if (item === undefined)
|
|
826
1305
|
break;
|
|
@@ -838,14 +1317,27 @@ export class RollbackPointEngine {
|
|
|
838
1317
|
}
|
|
839
1318
|
for (const entry of entries) {
|
|
840
1319
|
const full = path.join(item.dir, entry.name);
|
|
1320
|
+
// **绝不把含 `.asar` 段的路径交给 fs**(见 hasAsarSegment:宿主里这一步是原生解析,
|
|
1321
|
+
// 畸形夹具会让整个宿主进程消失)。留痕进 skipped —— 它是**证明名单**的一员,
|
|
1322
|
+
// 因此该路径绝不会被当成「留点后新建」删掉。
|
|
1323
|
+
if (hasAsarSegment(full)) {
|
|
1324
|
+
this.recordSkip(sink, 'skipped', { path: full, reason: 'asar-container' });
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
841
1327
|
if (entry.isSymbolicLink()) {
|
|
842
1328
|
this.recordSkip(sink, 'skipped', { path: full, reason: 'link' });
|
|
843
1329
|
continue;
|
|
844
1330
|
}
|
|
845
1331
|
if (entry.isDirectory()) {
|
|
846
|
-
|
|
1332
|
+
// `.git` 与运行时依赖目录**同档**:整棵不进快照。两条理由 ——
|
|
1333
|
+
// ① 回退写回 .git(index/refs/logs)会让 git 状态错乱(提交看似消失、可能留 index.lock),
|
|
1334
|
+
// 而删除侧本来就把 .git 当拒删目录 ⇒ 排除它才让两侧口径一致;
|
|
1335
|
+
// ② .git 常常几百 MB,会**挤掉 maxBytes 预算里的源码文件**。
|
|
1336
|
+
// 大小写折半比较:Windows 上 .GIT/.Git 是同一个目录。
|
|
1337
|
+
const dirName = win32Fold(entry.name);
|
|
1338
|
+
if (EXCLUDED_DEP_DIR_NAMES.includes(entry.name) || dirName === '.git') {
|
|
847
1339
|
// 内置忽略规则(S4「沿用现有 IGNORE 规则」):整棵不进快照,但留痕(不静默)
|
|
848
|
-
this.recordSkip(sink, 'excluded', { path: full, reason: 'dep-dir' });
|
|
1340
|
+
this.recordSkip(sink, 'excluded', { path: full, reason: dirName === '.git' ? 'git-dir' : 'dep-dir' });
|
|
849
1341
|
continue;
|
|
850
1342
|
}
|
|
851
1343
|
if (ignore !== undefined && this.ignoredByRules(ignore, full, true)) {
|
|
@@ -879,12 +1371,19 @@ export class RollbackPointEngine {
|
|
|
879
1371
|
}
|
|
880
1372
|
}
|
|
881
1373
|
out.sort();
|
|
882
|
-
return { files: out, truncated };
|
|
1374
|
+
return { files: out, truncated, walkAborted, unwalkedDirs };
|
|
883
1375
|
}
|
|
884
|
-
async captureWorkspace(chunks) {
|
|
1376
|
+
async captureWorkspace(chunks, signal) {
|
|
885
1377
|
const capturedAt = this.stamp();
|
|
886
1378
|
if (chunks.length === 0)
|
|
887
1379
|
return this.emptyTrack('workspace', capturedAt, 'no-chunks', 'chunks empty');
|
|
1380
|
+
// 时间预算(见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS):用 stamp()(单调)比较,
|
|
1381
|
+
// 系统时钟回拨也不会让预算永不触发。超时置 payload.truncated ⇒ 结尾的 reasonCode
|
|
1382
|
+
// 自动落成 capture-truncated,UI 走既有的 degraded 提示路径(零新增文案)。
|
|
1383
|
+
const deadline = capturedAt + this.captureBudgetMs;
|
|
1384
|
+
const outOfBudget = () => signal?.aborted === true || this.stamp() >= deadline;
|
|
1385
|
+
// 对象名单快照按回合重建(一次 readdir 换掉每个候选一次 fs.access)
|
|
1386
|
+
this.captureObjectNames = null;
|
|
888
1387
|
const payload = {
|
|
889
1388
|
chunks: [...chunks],
|
|
890
1389
|
files: [],
|
|
@@ -904,104 +1403,65 @@ export class RollbackPointEngine {
|
|
|
904
1403
|
let sawChunk = false;
|
|
905
1404
|
// 走查过的**全部**文件路径(含被预算 / 单文件上限挡下的)—— 路径索引的来源(方向 B)
|
|
906
1405
|
const walked = [];
|
|
1406
|
+
// 走查中断时**没进去的子树**(精确到目录)—— 供删除集做分子树 fail-closed
|
|
1407
|
+
const unwalked = [];
|
|
907
1408
|
for (const chunk of chunks) {
|
|
1409
|
+
if (outOfBudget()) {
|
|
1410
|
+
// 分块之间就超预算:不再开新分块,并如实留痕(该分块的条目一条都没被走查)
|
|
1411
|
+
// walkIncomplete 是**安全判据**(删除集 fail-closed);这里能精确给出「没进去的是哪个分块」
|
|
1412
|
+
// ⇒ 删除只拒这棵子树,不再把整个工作区的新建文件一起放弃。
|
|
1413
|
+
payload.walkIncomplete = true;
|
|
1414
|
+
payload.truncated = true;
|
|
1415
|
+
unwalked.push(chunk);
|
|
1416
|
+
this.recordSkip(payload, 'skipped', { path: chunk, reason: 'budget-time' });
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
908
1419
|
if (!path.isAbsolute(chunk))
|
|
909
1420
|
return this.emptyTrack('workspace', capturedAt, 'chunk-not-absolute', chunk);
|
|
910
1421
|
const resolved = path.resolve(chunk);
|
|
911
1422
|
// 文件数预算还剩多少就收多少;+1 只用于让遍历器发现「还有溢出」——溢出条目逐条记 skipped
|
|
912
|
-
|
|
1423
|
+
// 候选名单上限取 ROLLBACK_CAPTURE_LIST_LIMIT(而不是 maxFiles):入点上限由阶段①的 slots
|
|
1424
|
+
// 把关,这里若提前截断,缺口优先排序就够不到尾部文件 ⇒ 覆盖率不收敛
|
|
1425
|
+
const listed = await this.listChunkFiles(resolved, payload, ROLLBACK_CAPTURE_LIST_LIMIT, walked, await loadIgnoreRules(resolved), outOfBudget);
|
|
913
1426
|
if (listed === null)
|
|
914
1427
|
continue;
|
|
915
1428
|
sawChunk = true;
|
|
916
1429
|
if (listed.truncated)
|
|
917
1430
|
payload.truncated = true;
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
if (isInsidePath(file, this.root)) {
|
|
926
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'self-store' });
|
|
927
|
-
continue;
|
|
928
|
-
}
|
|
929
|
-
// **先 stat 判大小、再决定读不读**:单文件超限或加上它会超总量 → 跳过并如实留痕
|
|
930
|
-
// (旧实现的 `read > 0` 短路让**首个**文件完全不设上限,会被整份读进内存 —— t5-F1)
|
|
931
|
-
let stat;
|
|
932
|
-
try {
|
|
933
|
-
stat = await fs.stat(file);
|
|
934
|
-
}
|
|
935
|
-
catch (error) {
|
|
936
|
-
payload.unreadableFiles.push({ path: file, reason: errorDetail(error) });
|
|
937
|
-
continue;
|
|
938
|
-
}
|
|
939
|
-
if (!stat.isFile()) {
|
|
940
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'not-a-file' });
|
|
941
|
-
continue;
|
|
942
|
-
}
|
|
943
|
-
if (stat.size > this.maxFileBytes) {
|
|
944
|
-
payload.truncated = true;
|
|
945
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'too-large' });
|
|
946
|
-
continue;
|
|
947
|
-
}
|
|
948
|
-
if (payload.bytes + stat.size > this.maxBytes) {
|
|
949
|
-
payload.truncated = true;
|
|
950
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' });
|
|
951
|
-
continue;
|
|
952
|
-
}
|
|
953
|
-
try {
|
|
954
|
-
// 路径身份缓存:身份(dev:ino:size:mtimeNs:ctimeNs:mode)未变 ⇒ 复用已存 sha256,**不重读**
|
|
955
|
-
const identity = await this.pathIdentityOf(file);
|
|
956
|
-
const candidate = identity === null ? null : await this.lookupCachedFile(identity, file);
|
|
957
|
-
// **F-2(t12 修复)**:缓存命中也要确认对象仓里真有那个 blob —— 对象被外部清掉后
|
|
958
|
-
// 若直接复用 sha256,新点会「零对象却 canRestore=true」,预览骗人(执行才响亮失败)。
|
|
959
|
-
// 对象不在 ⇒ 当作未命中:真读 + 重写对象(缓存因此自愈)。
|
|
960
|
-
const cached = candidate === null || !(await this.objectExists(candidate.sha256)) ? null : candidate;
|
|
961
|
-
const data = cached === null ? await this.readFileBytes(file) : null;
|
|
962
|
-
const size = cached === null ? (data?.byteLength ?? 0) : cached.bytes;
|
|
963
|
-
if (cached === null && data !== null && data.byteLength !== size) {
|
|
964
|
-
payload.truncated = true;
|
|
965
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' });
|
|
966
|
-
continue;
|
|
967
|
-
}
|
|
968
|
-
if (cached !== null) {
|
|
969
|
-
payload.files.push({ path: file, chunk: resolved, bytes: cached.bytes, sha256: cached.sha256, mtimeMs: stat.mtimeMs, object: cached.sha256 });
|
|
970
|
-
payload.bytes += cached.bytes;
|
|
971
|
-
read += 1;
|
|
972
|
-
continue;
|
|
973
|
-
}
|
|
974
|
-
if (data === null) {
|
|
975
|
-
payload.unreadableFiles.push({ path: file, reason: 'read-failed' });
|
|
976
|
-
continue;
|
|
977
|
-
}
|
|
978
|
-
// stat 与 read 之间文件可能变化:真读到的字节数仍必须服从总量预算(不猜、不静默)
|
|
979
|
-
if (payload.bytes + data.byteLength > this.maxBytes) {
|
|
980
|
-
payload.truncated = true;
|
|
981
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' });
|
|
982
|
-
continue;
|
|
983
|
-
}
|
|
984
|
-
const sha = sha256Hex(data);
|
|
985
|
-
await this.writeObjectWithHash(data, sha);
|
|
986
|
-
if (identity !== null)
|
|
987
|
-
this.rememberCachedFile(identity, file, sha, data.byteLength);
|
|
988
|
-
payload.files.push({ path: file, chunk: resolved, bytes: data.byteLength, sha256: sha, mtimeMs: stat.mtimeMs, object: sha });
|
|
989
|
-
payload.bytes += data.byteLength;
|
|
990
|
-
read += 1;
|
|
991
|
-
}
|
|
992
|
-
catch (error) {
|
|
993
|
-
payload.unreadableFiles.push({ path: file, reason: errorDetail(error) });
|
|
994
|
-
}
|
|
1431
|
+
// 走查本身被打断 ⇒ 栈里还有没进去的子树(那些路径从未进任何名单)⇒ fail-closed。
|
|
1432
|
+
// **区别于**下面「走查已结束、只是读到一半超时」:后者的路径全在 walked 里,
|
|
1433
|
+
// 由文件末尾的覆盖自检补进证明名单(补不齐才拒删),不该整体拒删。
|
|
1434
|
+
if (listed.walkAborted) {
|
|
1435
|
+
payload.walkIncomplete = true;
|
|
1436
|
+
for (const dir of listed.unwalkedDirs)
|
|
1437
|
+
unwalked.push(dir);
|
|
995
1438
|
}
|
|
1439
|
+
read += await this.captureChunkCandidates(resolved, listed.files, Math.max(0, this.maxFiles - read), payload, outOfBudget);
|
|
996
1440
|
}
|
|
997
|
-
|
|
1441
|
+
// 精确的「没进去的子树」:有 ⇒ 删除集只在这些子树里 fail-closed;没有(老台账 / 栈被截断)
|
|
1442
|
+
// ⇒ 退回全局拒删(见 planCreatedRemovals)
|
|
1443
|
+
if (unwalked.length > 0)
|
|
1444
|
+
payload.unwalkedDirs = [...new Set(unwalked)].sort();
|
|
1445
|
+
// `truncated` 为真时**不能**判 chunk-not-found:那是「分块确实存在、只是没来得及走查完」
|
|
1446
|
+
// (时间预算 / 取消信号)—— 报成「找不到分块」会让用户以为工作区配错了。
|
|
1447
|
+
if (!sawChunk && payload.files.length === 0 && !payload.truncated) {
|
|
998
1448
|
return this.emptyTrack('workspace', capturedAt, 'chunk-not-found', chunks.join(' '));
|
|
999
1449
|
}
|
|
1000
|
-
// **方向 B
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1450
|
+
// **方向 B**:把走查过的全部路径另存成路径索引对象 —— 「未记录」与「留点时就存在」只有在
|
|
1451
|
+
// 证明名单完整时才可区分。触发条件是**覆盖自检**(不是「跳过名单被截断」这一种特例):
|
|
1452
|
+
// · 逐条跳过名单被记录上限截断(skippedTruncated);或
|
|
1453
|
+
// · 读到一半被时间预算 / 取消打断 —— 本分块剩余文件进了 walked、却一条都没进任何名单。
|
|
1454
|
+
// 覆盖完整时不必写(未截断时 files ∪ skipped 本身就是完整名单,零额外成本)。
|
|
1455
|
+
// 写失败 ⇒ 证明名单补不齐 ⇒ 必须整体拒删(安全方向;R-05f)。
|
|
1456
|
+
const proven = new Set();
|
|
1457
|
+
for (const file of payload.files)
|
|
1458
|
+
proven.add(pathKey(file.path));
|
|
1459
|
+
for (const entry of payload.skipped)
|
|
1460
|
+
proven.add(pathKey(entry.path));
|
|
1461
|
+
for (const entry of payload.unreadableFiles)
|
|
1462
|
+
proven.add(pathKey(entry.path));
|
|
1463
|
+
const uncovered = walked.some((item) => !proven.has(pathKey(item)));
|
|
1464
|
+
if (uncovered) {
|
|
1005
1465
|
try {
|
|
1006
1466
|
const paths = [...new Set(walked)].sort();
|
|
1007
1467
|
const gz = gzipSync(Buffer.from(paths.join('\n'), 'utf8'));
|
|
@@ -1011,6 +1471,8 @@ export class RollbackPointEngine {
|
|
|
1011
1471
|
}
|
|
1012
1472
|
catch (error) {
|
|
1013
1473
|
this.warn('rollback path index failed: ' + errorDetail(error));
|
|
1474
|
+
// 走查过、却没进名单的路径无法被证明 ⇒ 删除集整体拒绝(绝不「删一半」)
|
|
1475
|
+
payload.walkIncomplete = true;
|
|
1014
1476
|
}
|
|
1015
1477
|
}
|
|
1016
1478
|
// 用户忽略的整目录 ⇒ 证明名单不完整(删除集整体拒绝;见 planCreatedRemovals)。
|
|
@@ -1019,6 +1481,8 @@ export class RollbackPointEngine {
|
|
|
1019
1481
|
payload.ignoreIncomplete = payload.ignoredDirCount > 0;
|
|
1020
1482
|
// 路径身份缓存落盘(best-effort:写不进去只意味着下次全量读,绝不影响留点成败)
|
|
1021
1483
|
await this.persistPathCache();
|
|
1484
|
+
// 落点列表排序:并发读让入点顺序不定,契约必须是「按路径有序」(预览 / 对账 / 指纹都靠它)
|
|
1485
|
+
payload.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
1022
1486
|
// 留痕列表也排序:跨分块 / 依赖 readdir 顺序时输出仍确定(测试与对账都能逐条比)
|
|
1023
1487
|
payload.skipped.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
1024
1488
|
payload.excludedDirs.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
@@ -1026,9 +1490,25 @@ export class RollbackPointEngine {
|
|
|
1026
1490
|
const canonical = [...payload.files]
|
|
1027
1491
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
1028
1492
|
.map((f) => [f.path, f.bytes, f.sha256]);
|
|
1493
|
+
payload.fileCount = payload.files.length;
|
|
1029
1494
|
const fingerprint = sha256Hex(JSON.stringify({ chunks: [...payload.chunks].sort(), files: canonical }));
|
|
1495
|
+
// 自适应预算(见 ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS):被截断 ⇒ 下次翻倍(上限);
|
|
1496
|
+
// 用得很少 ⇒ 下次减半(下限)。大仓库第一次撞上限后自动拿到更大预算,小仓库自动回落,
|
|
1497
|
+
// 不会每回合白等。回退期的走查预算(rewindBudgetMs)**不参与**这套调整。
|
|
1498
|
+
if (payload.truncated || payload.walkIncomplete === true) {
|
|
1499
|
+
this.captureBudgetMs = Math.min(this.captureBudgetMaxMs, this.captureBudgetMs * 2);
|
|
1500
|
+
}
|
|
1501
|
+
else if (this.stamp() - capturedAt < this.captureBudgetMs / 4) {
|
|
1502
|
+
this.captureBudgetMs = Math.max(this.captureBudgetMinMs, Math.floor(this.captureBudgetMs / 2));
|
|
1503
|
+
}
|
|
1504
|
+
// 「降级(留得不完整)」只由**真的缺内容**触发:
|
|
1505
|
+
// ① truncated —— 文件数 / 总字节 / 单文件上限 / 时间预算挡下的内容;
|
|
1506
|
+
// ② unreadableDirs / unreadableFiles —— 读不出来或捕获竞态(内容不可信)。
|
|
1507
|
+
// **不再用 skippedCount**:那里的条目大多是**设计内**的跳过(符号链接不跟随、文件级忽略、
|
|
1508
|
+
// 自身数据区、读之前就消失),既不缺内容也无法回退 —— 旧口径让「工作区里有任何一个符号链接」
|
|
1509
|
+
// 的仓库每个点都挂「回退可能不完整」,真出问题时反而被噪声淹没(真机 2026-10-15:10 个 link
|
|
1510
|
+
// 跳过 ⇒ 每个点 degraded=true)。条目与计数仍逐条留痕(skippedCount / skipped 列表不变)。
|
|
1030
1511
|
const degraded = payload.truncated
|
|
1031
|
-
|| payload.skippedCount > 0
|
|
1032
1512
|
|| payload.unreadableDirs.length > 0
|
|
1033
1513
|
|| payload.unreadableFiles.length > 0;
|
|
1034
1514
|
return {
|
|
@@ -1044,6 +1524,213 @@ export class RollbackPointEngine {
|
|
|
1044
1524
|
}
|
|
1045
1525
|
/* ---------------------------------------------------------- 忽略规则 / 路径身份缓存 */
|
|
1046
1526
|
/** 用户忽略规则是否命中该路径(只认「相对分块根」的斜杠路径) */
|
|
1527
|
+
/**
|
|
1528
|
+
* 处理一个分块的候选文件(**两阶段**)。
|
|
1529
|
+
*
|
|
1530
|
+
* ① **顺序判定**(与旧的串行实现逐条一致,保证预算 / 计数 / 字节上限完全确定):
|
|
1531
|
+
* 文件数上限、时间预算、自身数据区、stat、单文件上限、总量上限、身份缓存 + 对象存在性
|
|
1532
|
+
* → 得出「当场入点(缓存命中)」还是「要真读」,该跳过的**当场**按原有 reason 逐条留痕。
|
|
1533
|
+
* stat 按**有序批**并发取(判定仍严格按顺序),并用「一次 readdir 的对象名单快照」代替
|
|
1534
|
+
* 每个文件一次 fs.access —— 这两处是稳态成本的大头(真机实测各占 ~5 s / ~3.7 s)。
|
|
1535
|
+
* ② **并发读 + 哈希 + 落对象仓**:这是捕获耗时的**唯一大头**(真机 148 s / 31,702 个对象,
|
|
1536
|
+
* 96% 的时间),用 captureConcurrency 个 worker 并发跑;并用**在飞字节闸门**保证
|
|
1537
|
+
* 「同时在飞的内容字节 ≤ captureInFlightBytes」。**不是** maxFileBytes —— 它缺省 =
|
|
1538
|
+
* 总量预算(512 MiB),拿它当闸门等于允许把整个点的预算同时驻留(0.1.74 闪退根因)。
|
|
1539
|
+
*
|
|
1540
|
+
* 返回本分块**真正入点**的文件数(调用方据此推进 maxFiles 预算)。
|
|
1541
|
+
* 没判到 / 没读完的条目一律逐条进 skipped(budget-time),绝不静默丢弃。
|
|
1542
|
+
*/
|
|
1543
|
+
async captureChunkCandidates(chunk, candidates, slots, payload, outOfBudget) {
|
|
1544
|
+
// 缺口优先:身份缓存里还没有的路径排前面 ⇒ 每个点都在补上次没覆盖的部分(见 orderByCoverage)
|
|
1545
|
+
const ordered = await this.orderByCoverage(candidates);
|
|
1546
|
+
const decided = new Array(ordered.length).fill(false);
|
|
1547
|
+
const plans = [];
|
|
1548
|
+
let captured = 0;
|
|
1549
|
+
let newReads = 0;
|
|
1550
|
+
// 字节预算必须在**准入那一刻**按 stat.size 预留:真正入点发生在并发的第二阶段,
|
|
1551
|
+
// 若拿 payload.bytes 当判据,同一个分块里后面的文件会全部误判成「还有额度」
|
|
1552
|
+
let accounted = payload.bytes;
|
|
1553
|
+
// 对象名单快照:一次 readdir 顶掉「每个候选一次 fs.access」(读不到则回落逐条判断)
|
|
1554
|
+
const names = await this.objectNamesSnapshot();
|
|
1555
|
+
let cursor = 0;
|
|
1556
|
+
while (cursor < ordered.length) {
|
|
1557
|
+
if (outOfBudget())
|
|
1558
|
+
break; // 剩余条目由收尾循环逐条记 budget-time(不预取 stat)
|
|
1559
|
+
const window = ordered.slice(cursor, cursor + ROLLBACK_CAPTURE_STAT_BATCH);
|
|
1560
|
+
const stats = await Promise.all(window.map((file) => this.fileStatOf(file)));
|
|
1561
|
+
let broke = false;
|
|
1562
|
+
for (let offset = 0; offset < window.length; offset += 1) {
|
|
1563
|
+
const file = window[offset] ?? '';
|
|
1564
|
+
const info = stats[offset] ?? { isFile: false, size: 0, mtimeMs: 0, identity: null, error: 'stat-missing' };
|
|
1565
|
+
const index = cursor + offset;
|
|
1566
|
+
if (outOfBudget()) {
|
|
1567
|
+
broke = true;
|
|
1568
|
+
break;
|
|
1569
|
+
}
|
|
1570
|
+
decided[index] = true;
|
|
1571
|
+
if (isInsidePath(file, this.root)) {
|
|
1572
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'self-store' });
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
// **先 stat 判大小、再决定读不读**:单文件超限或加上它会超总量 → 跳过并如实留痕
|
|
1576
|
+
// (旧实现的 read > 0 短路让**首个**文件完全不设上限,会被整份读进内存 —— t5-F1)
|
|
1577
|
+
if (info.error !== undefined) {
|
|
1578
|
+
payload.unreadableFiles.push({ path: file, reason: info.error });
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
if (!info.isFile) {
|
|
1582
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'not-a-file' });
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
if (info.size > this.maxFileBytes) {
|
|
1586
|
+
payload.truncated = true;
|
|
1587
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'too-large' });
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
// 路径身份缓存:身份(dev:ino:size:mtimeNs:ctimeNs:mode)未变 ⇒ 复用已存 sha256,**不重读**
|
|
1591
|
+
const candidate = info.identity === null ? null : await this.lookupCachedFile(info.identity, file);
|
|
1592
|
+
// **F-2(t12 修复)**:缓存命中也要确认对象仓里真有那个 blob —— 对象被外部清掉后
|
|
1593
|
+
// 若直接复用 sha256,新点会「零对象却 canRestore=true」,预览骗人(执行才响亮失败)。
|
|
1594
|
+
// 对象不在 ⇒ 当作未命中:真读 + 重写对象(缓存因此自愈)。
|
|
1595
|
+
const objectPresent = candidate === null
|
|
1596
|
+
? false
|
|
1597
|
+
: names !== null ? names.has(candidate.sha256) : await this.objectExists(candidate.sha256);
|
|
1598
|
+
if (candidate !== null && objectPresent) {
|
|
1599
|
+
// 身份未变 ⇒ 内容必然与缓存一致:零 I/O 直接入点(稳态成本与仓库大小无关的来源)。
|
|
1600
|
+
// **不占读预算**:maxFiles / maxBytes 约束的是「这回合新读进来的内容」,不是「记录多少条」
|
|
1601
|
+
// —— 否则大仓库每个点都只能记下前 N 条,滚回某点时会漏掉其余文件(覆盖率永远不收敛)。
|
|
1602
|
+
payload.files.push({
|
|
1603
|
+
path: file, chunk, bytes: candidate.bytes, sha256: candidate.sha256, mtimeMs: info.mtimeMs, object: candidate.sha256,
|
|
1604
|
+
});
|
|
1605
|
+
payload.bytes += candidate.bytes;
|
|
1606
|
+
captured += 1;
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
// 只有**要真读的新内容**才受每回合的规模预算约束(判定仍严格按顺序 ⇒ 结果确定)
|
|
1610
|
+
if (newReads >= slots) {
|
|
1611
|
+
payload.truncated = true;
|
|
1612
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-files' });
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1615
|
+
if (accounted + info.size > this.maxBytes) {
|
|
1616
|
+
payload.truncated = true;
|
|
1617
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' });
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
accounted += info.size;
|
|
1621
|
+
newReads += 1;
|
|
1622
|
+
plans.push({ path: file, size: info.size, mtimeMs: info.mtimeMs, identity: info.identity });
|
|
1623
|
+
}
|
|
1624
|
+
cursor += window.length;
|
|
1625
|
+
if (broke)
|
|
1626
|
+
break;
|
|
1627
|
+
}
|
|
1628
|
+
if (plans.length > 0) {
|
|
1629
|
+
// ② 并发读 + 落对象仓。outOfBudget 一命中就**不再开新条目**(在飞的照常收尾),
|
|
1630
|
+
// 未开工的条目在循环外逐条记 budget-time —— 与串行版「中断处逐条留痕」同一诚实口径。
|
|
1631
|
+
const gate = new ByteGate(this.captureInFlightBytes);
|
|
1632
|
+
const done = new Array(plans.length).fill(false);
|
|
1633
|
+
let planCursor = 0;
|
|
1634
|
+
let stopped = false;
|
|
1635
|
+
const workers = Array.from({ length: Math.min(this.captureConcurrency, plans.length) }, async () => {
|
|
1636
|
+
for (;;) {
|
|
1637
|
+
if (stopped)
|
|
1638
|
+
return;
|
|
1639
|
+
const index = planCursor;
|
|
1640
|
+
planCursor += 1;
|
|
1641
|
+
if (index >= plans.length)
|
|
1642
|
+
return;
|
|
1643
|
+
const item = plans[index];
|
|
1644
|
+
if (outOfBudget()) {
|
|
1645
|
+
stopped = true;
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
await gate.acquire(item.size);
|
|
1649
|
+
try {
|
|
1650
|
+
if (outOfBudget()) {
|
|
1651
|
+
stopped = true;
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
const data = await this.readFileBytes(item.path);
|
|
1655
|
+
if (data.byteLength !== item.size) {
|
|
1656
|
+
// stat 与 read 之间文件变了(旧口径:budget-bytes)—— 绝不按旧字节数记账
|
|
1657
|
+
payload.truncated = true;
|
|
1658
|
+
this.recordSkip(payload, 'skipped', { path: item.path, reason: 'budget-bytes' });
|
|
1659
|
+
done[index] = true;
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
// **捕获竞态**:读之前拿到的身份若在读完时已变,说明这次读到的内容未必是「留点时刻」
|
|
1663
|
+
// 的内容(回合与留点并发)⇒ 不当作前像,只如实进 unreadableFiles(它本身是**证明名单**
|
|
1664
|
+
// 的一员:证明「留点时存在」,因此绝不会被当成新建删掉)。
|
|
1665
|
+
const after = item.identity === null ? null : await this.pathIdentityOf(item.path);
|
|
1666
|
+
if (item.identity !== null && after !== item.identity) {
|
|
1667
|
+
payload.unreadableFiles.push({ path: item.path, reason: 'changed-during-read' });
|
|
1668
|
+
done[index] = true;
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
const sha = sha256Hex(data);
|
|
1672
|
+
await this.writeObjectWithHash(data, sha);
|
|
1673
|
+
if (item.identity !== null)
|
|
1674
|
+
this.rememberCachedFile(item.identity, item.path, sha, data.byteLength);
|
|
1675
|
+
payload.files.push({
|
|
1676
|
+
path: item.path, chunk, bytes: data.byteLength, sha256: sha, mtimeMs: item.mtimeMs, object: sha,
|
|
1677
|
+
});
|
|
1678
|
+
payload.bytes += data.byteLength;
|
|
1679
|
+
captured += 1;
|
|
1680
|
+
done[index] = true;
|
|
1681
|
+
}
|
|
1682
|
+
catch (error) {
|
|
1683
|
+
payload.unreadableFiles.push({ path: item.path, reason: errorDetail(error) });
|
|
1684
|
+
done[index] = true;
|
|
1685
|
+
}
|
|
1686
|
+
finally {
|
|
1687
|
+
gate.release(item.size);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
});
|
|
1691
|
+
await Promise.all(workers);
|
|
1692
|
+
for (let index = 0; index < plans.length; index += 1) {
|
|
1693
|
+
if (done[index] === true)
|
|
1694
|
+
continue;
|
|
1695
|
+
payload.truncated = true;
|
|
1696
|
+
this.recordSkip(payload, 'skipped', { path: plans[index].path, reason: 'budget-time' });
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
// 阶段 ① 里没判定到的(时间预算打断)同样逐条留痕 —— 覆盖自检因此仍认为名单完整
|
|
1700
|
+
for (let index = 0; index < ordered.length; index += 1) {
|
|
1701
|
+
if (decided[index] === true)
|
|
1702
|
+
continue;
|
|
1703
|
+
payload.truncated = true;
|
|
1704
|
+
this.recordSkip(payload, 'skipped', { path: ordered[index] ?? '', reason: 'budget-time' });
|
|
1705
|
+
}
|
|
1706
|
+
// 返回值 = 本分块消耗掉的**读槽位**(缓存命中不占槽位;调用方据此推进 maxFiles)
|
|
1707
|
+
return newReads;
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* 缺口优先排序:身份缓存里**没有**的路径排前面。
|
|
1711
|
+
*
|
|
1712
|
+
* 为什么:预算固定时按目录顺序走查,同一个「已覆盖且便宜」的前缀每回合都会吃掉全部预算,
|
|
1713
|
+
* 尾部永远进不来(真机:34k 文件每次只覆盖 1/5,且五次都是同一批 outputs/ 前缀)。
|
|
1714
|
+
* 未覆盖的排前面 ⇒ 每个点都在补上一次的缺口,覆盖率随回合**单调收敛**到 100%。
|
|
1715
|
+
* 组内保持走查顺序(稳定),因此同一输入下的结果仍然确定。
|
|
1716
|
+
*/
|
|
1717
|
+
async orderByCoverage(candidates) {
|
|
1718
|
+
if (candidates.length <= 1)
|
|
1719
|
+
return [...candidates];
|
|
1720
|
+
const cache = await this.loadPathCache();
|
|
1721
|
+
if (cache.size === 0)
|
|
1722
|
+
return [...candidates];
|
|
1723
|
+
const covered = new Set();
|
|
1724
|
+
for (const entry of cache.values())
|
|
1725
|
+
covered.add(pathKey(entry.path));
|
|
1726
|
+
if (covered.size === 0)
|
|
1727
|
+
return [...candidates];
|
|
1728
|
+
const cold = [];
|
|
1729
|
+
const hot = [];
|
|
1730
|
+
for (const file of candidates)
|
|
1731
|
+
(covered.has(pathKey(file)) ? hot : cold).push(file);
|
|
1732
|
+
return cold.length === 0 ? hot : [...cold, ...hot];
|
|
1733
|
+
}
|
|
1047
1734
|
ignoredByRules(rules, target, isDir) {
|
|
1048
1735
|
const rel = ignoreRelativePath(rules.root, target);
|
|
1049
1736
|
return rel === null ? false : ignoreRuleMatches(rules, rel, isDir);
|
|
@@ -1060,19 +1747,43 @@ export class RollbackPointEngine {
|
|
|
1060
1747
|
* 用毫秒换算同样不行:会把「同一毫秒内的两次写」当成同一个身份。
|
|
1061
1748
|
* 读不到身份(文件消失 / 权限 / 平台不支持 bigint stat)→ null ⇒ 回落全量真读。
|
|
1062
1749
|
*/
|
|
1750
|
+
/**
|
|
1751
|
+
* 一次 bigint stat 拿到阶段①需要的**全部**东西(是否文件 / 大小 / mtime / 身份)。
|
|
1752
|
+
* 旧实现「fs.stat + pathIdentityOf」每文件 stat 两次,真机 34k 文件白花约 5 s。
|
|
1753
|
+
*/
|
|
1754
|
+
async fileStatOf(target) {
|
|
1755
|
+
try {
|
|
1756
|
+
const stat = await fs.stat(target, { bigint: true });
|
|
1757
|
+
const isFile = stat.isFile();
|
|
1758
|
+
return {
|
|
1759
|
+
isFile,
|
|
1760
|
+
size: Number(stat.size),
|
|
1761
|
+
mtimeMs: Number(stat.mtimeMs),
|
|
1762
|
+
identity: isFile ? fileIdentityOf(stat) : null,
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
catch (error) {
|
|
1766
|
+
return { isFile: false, size: 0, mtimeMs: 0, identity: null, error: errorDetail(error) };
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
/** 对象名单快照(每个捕获回合一次 readdir);读不到则返回 null ⇒ 调用方回落逐条判断 */
|
|
1770
|
+
async objectNamesSnapshot() {
|
|
1771
|
+
if (this.captureObjectNames !== null)
|
|
1772
|
+
return this.captureObjectNames;
|
|
1773
|
+
try {
|
|
1774
|
+
this.captureObjectNames = new Set(await fs.readdir(this.objectsDir));
|
|
1775
|
+
}
|
|
1776
|
+
catch {
|
|
1777
|
+
this.captureObjectNames = null;
|
|
1778
|
+
}
|
|
1779
|
+
return this.captureObjectNames;
|
|
1780
|
+
}
|
|
1063
1781
|
async pathIdentityOf(target) {
|
|
1064
1782
|
try {
|
|
1065
1783
|
const stat = await fs.stat(target, { bigint: true });
|
|
1066
1784
|
if (!stat.isFile())
|
|
1067
1785
|
return null;
|
|
1068
|
-
return
|
|
1069
|
-
String(stat.dev),
|
|
1070
|
-
String(stat.ino),
|
|
1071
|
-
String(stat.size),
|
|
1072
|
-
String(stat.mtimeNs),
|
|
1073
|
-
String(stat.ctimeNs),
|
|
1074
|
-
String(stat.mode & 511n),
|
|
1075
|
-
].join(':');
|
|
1786
|
+
return fileIdentityOf(stat);
|
|
1076
1787
|
}
|
|
1077
1788
|
catch {
|
|
1078
1789
|
return null;
|
|
@@ -1117,6 +1828,25 @@ export class RollbackPointEngine {
|
|
|
1117
1828
|
return map;
|
|
1118
1829
|
}
|
|
1119
1830
|
/** 缓存命中:身份键存在、且路径逐字相同(身份被复用也绝不认)。 */
|
|
1831
|
+
/**
|
|
1832
|
+
* 「这条记录的当前内容**仍然是它**吗」的身份快路径 —— **预览与执行共用同一条判据**
|
|
1833
|
+
* (否则「预览说会改的 = 实际会改的」这条不变式会被打破)。
|
|
1834
|
+
*
|
|
1835
|
+
* 判据:路径身份(dev:ino:size:mtimeNs:ctimeNs:mode)命中**身份缓存**,且缓存里的 sha/bytes 与记录一致。
|
|
1836
|
+
* 身份未变 ⇒ 内容未变:缓存里的 sha 就是**这份内容**在留点那一次真读出来的哈希(身份含 size/mtimeNs/
|
|
1837
|
+
* ctimeNs/ino/dev/mode,改内容必动 mtime/ctime,Windows 上 user 代码改不了 ctime)。
|
|
1838
|
+
* 任何一环拿不到(读不到身份 / 缓存没有该身份 / sha 或 bytes 不同)⇒ false,调用方回落「真读 + 哈希」。
|
|
1839
|
+
*
|
|
1840
|
+
* 为什么必须有它:大仓库一次预览/回退要判 3 万+ 条目,逐个真读等于把整个工作区读两遍
|
|
1841
|
+
* (真机 34k 文件 / 609 MiB:预览 14.2 s 里 13 s 全在这,回退 60 s+ 同理)。**记录的指纹被篡改**
|
|
1842
|
+
* 时本判据必然不成立(缓存的 sha 是真算出来的)⇒ 仍走真读 + 哈希的旧路径,护栏不退化。
|
|
1843
|
+
*/
|
|
1844
|
+
async unchangedByIdentity(file, identity) {
|
|
1845
|
+
if (identity === null)
|
|
1846
|
+
return false;
|
|
1847
|
+
const cached = await this.lookupCachedFile(identity, file.path);
|
|
1848
|
+
return cached !== null && cached.sha256 === file.sha256 && cached.bytes === file.bytes;
|
|
1849
|
+
}
|
|
1120
1850
|
async lookupCachedFile(identity, target) {
|
|
1121
1851
|
const cache = await this.loadPathCache();
|
|
1122
1852
|
const hit = cache.get(identity);
|
|
@@ -1168,6 +1898,10 @@ export class RollbackPointEngine {
|
|
|
1168
1898
|
out.push({ code: 'rollback.warn.createdRefused', detail: 'ignore-dirs' });
|
|
1169
1899
|
return out;
|
|
1170
1900
|
}
|
|
1901
|
+
if (payload.walkIncomplete === true) {
|
|
1902
|
+
out.push({ code: 'rollback.warn.createdRefused', detail: 'walk-incomplete' });
|
|
1903
|
+
return out;
|
|
1904
|
+
}
|
|
1171
1905
|
if (payload.skippedTruncated && (payload.pathIndex === null || payload.pathIndex === undefined)) {
|
|
1172
1906
|
out.push({ code: 'rollback.warn.createdRefused', detail: 'path-index-missing' });
|
|
1173
1907
|
return out;
|
|
@@ -1179,32 +1913,28 @@ export class RollbackPointEngine {
|
|
|
1179
1913
|
return out;
|
|
1180
1914
|
}
|
|
1181
1915
|
/* -------------------------------------------------------------- 台账 */
|
|
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
|
-
}
|
|
1916
|
+
/**
|
|
1917
|
+
* 读台账**清单**(只读元数据 + 段摘要;代价与工作区文件数**无关**)。
|
|
1918
|
+
*
|
|
1919
|
+
* 与 v2 的差别只在实现:v2 每回合把整份 index.json 读进内存并 parse(473 MB 那条路),
|
|
1920
|
+
* v3 读的是一份小清单。**外部语义一个字没变**:返回 entries + 同一套 error 文案。
|
|
1921
|
+
* 启动期初始化失败时如实回报(不读 v2 文件、不假装台账为空)。
|
|
1922
|
+
*/
|
|
1923
|
+
async readManifest() {
|
|
1924
|
+
await this.ensureReady();
|
|
1925
|
+
if (this.initError !== undefined)
|
|
1926
|
+
return { entries: [], error: this.initError };
|
|
1927
|
+
return await this.ledger.readManifest();
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* 读**完整**记录(引用解析对调用方透明)。失败一律如实回报,**绝不返回半个记录**:
|
|
1931
|
+
* 载荷缺失 / 哈希不符 / 形状不符 ⇒ error(调用方走 record-incomplete 路径)。
|
|
1932
|
+
*/
|
|
1933
|
+
async readFullRecord(id) {
|
|
1934
|
+
await this.ensureReady();
|
|
1935
|
+
if (this.initError !== undefined)
|
|
1936
|
+
return { error: this.initError };
|
|
1937
|
+
return await this.ledger.readRecord(id);
|
|
1208
1938
|
}
|
|
1209
1939
|
/**
|
|
1210
1940
|
* S6 淘汰(t14 冻结 + 2026-10-12 guard 全局上限):三类点各按各的预算裁,**只删最旧的**。
|
|
@@ -1215,17 +1945,26 @@ export class RollbackPointEngine {
|
|
|
1215
1945
|
* | guard 救援点 | 每会话 `maxGuardsPerSession`(20)→ 全局 `maxGuards`(`ROLLBACK_MAX_GUARDS`,50) | **不占**自动点预算、**不受**自动点的 20/50 影响;用户删不掉 ⇒ 必须有自己的两道预算(单会话挡住不够,跨会话也要收敛) |
|
|
1216
1946
|
* | protected 点 | 无上限(只进不出) | 生产路径**造不出来**(capture 端点已下线、tracker 从不设 protect),只有测试能造 |
|
|
1217
1947
|
*/
|
|
1218
|
-
|
|
1948
|
+
/**
|
|
1949
|
+
* 条数淘汰(口径与 v2 逐字相同,只是从「记录」换成「清单条目」):三类点各按各的预算裁,**只删最旧的**。
|
|
1950
|
+
*
|
|
1951
|
+
* | 类别 | 预算 | 说明 |
|
|
1952
|
+
* |---|---|---|
|
|
1953
|
+
* | auto 自动点 | 每会话 maxRecordsPerSession(20)→ 全局 maxRecords(50) | 两次裁剪都只删最旧 |
|
|
1954
|
+
* | guard 救援点 | 每会话 maxGuardsPerSession(20)→ 全局 maxGuards(ROLLBACK_MAX_GUARDS,50) | **不占**自动点预算、**不受**自动点的 20/50 影响;用户删不掉 ⇒ 必须有自己的两道预算(单会话挡住不够,跨会话也要收敛) |
|
|
1955
|
+
* | protected 点 | 无上限(只进不出) | 生产路径**造不出来**(capture 端点已下线、tracker 从不设 protect),只有测试能造 |
|
|
1956
|
+
*/
|
|
1957
|
+
selectPruneEntries(entries) {
|
|
1219
1958
|
const kept = [];
|
|
1220
1959
|
const guards = [];
|
|
1221
1960
|
const prunable = [];
|
|
1222
|
-
for (const
|
|
1223
|
-
if (
|
|
1224
|
-
kept.push(
|
|
1225
|
-
else if (
|
|
1226
|
-
guards.push(
|
|
1961
|
+
for (const entry of entries) {
|
|
1962
|
+
if (entry.protected)
|
|
1963
|
+
kept.push(entry);
|
|
1964
|
+
else if (entry.kind === 'guard')
|
|
1965
|
+
guards.push(entry);
|
|
1227
1966
|
else
|
|
1228
|
-
prunable.push(
|
|
1967
|
+
prunable.push(entry);
|
|
1229
1968
|
}
|
|
1230
1969
|
// 最新在前(下面三类裁剪共用同一排序口径)
|
|
1231
1970
|
const newestFirst = (a, b) => a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0;
|
|
@@ -1234,85 +1973,273 @@ export class RollbackPointEngine {
|
|
|
1234
1973
|
guards.sort(newestFirst);
|
|
1235
1974
|
const guardsPerSession = new Map();
|
|
1236
1975
|
const withinGuardSession = [];
|
|
1237
|
-
for (const
|
|
1238
|
-
const key =
|
|
1976
|
+
for (const entry of guards) {
|
|
1977
|
+
const key = entry.sessionId ?? '';
|
|
1239
1978
|
const used = guardsPerSession.get(key) ?? 0;
|
|
1240
1979
|
if (used >= this.maxGuardsPerSession)
|
|
1241
1980
|
continue;
|
|
1242
1981
|
guardsPerSession.set(key, used + 1);
|
|
1243
|
-
withinGuardSession.push(
|
|
1982
|
+
withinGuardSession.push(entry);
|
|
1244
1983
|
}
|
|
1245
1984
|
kept.push(...withinGuardSession.slice(0, Math.max(0, this.maxGuards)));
|
|
1246
1985
|
// 自动点:先每会话上限,再全局上限
|
|
1247
1986
|
prunable.sort(newestFirst);
|
|
1248
1987
|
const perSession = new Map();
|
|
1249
1988
|
const withinSession = [];
|
|
1250
|
-
for (const
|
|
1251
|
-
const key =
|
|
1989
|
+
for (const entry of prunable) {
|
|
1990
|
+
const key = entry.sessionId ?? '';
|
|
1252
1991
|
const used = perSession.get(key) ?? 0;
|
|
1253
1992
|
if (used >= this.maxRecordsPerSession)
|
|
1254
1993
|
continue;
|
|
1255
1994
|
perSession.set(key, used + 1);
|
|
1256
|
-
withinSession.push(
|
|
1995
|
+
withinSession.push(entry);
|
|
1257
1996
|
}
|
|
1258
1997
|
kept.push(...withinSession.slice(0, Math.max(0, this.maxRecords)));
|
|
1259
1998
|
const keptIds = new Set(kept.map((r) => r.id));
|
|
1260
|
-
const removed =
|
|
1999
|
+
const removed = entries.filter((r) => !keptIds.has(r.id)).map((r) => r.id);
|
|
1261
2000
|
return { kept, removed };
|
|
1262
2001
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
try {
|
|
1280
|
-
names = await fs.readdir(this.objectsDir);
|
|
1281
|
-
}
|
|
1282
|
-
catch {
|
|
1283
|
-
return;
|
|
2002
|
+
/**
|
|
2003
|
+
* 字节预算淘汰(issue #89 的**根因修复**):条数没超、字节超了照样会 OOM。
|
|
2004
|
+
*
|
|
2005
|
+
* 判据与条数淘汰共用同一类别口径:**只淘汰最旧的 auto 点**(guard / protected 永不淘汰)。
|
|
2006
|
+
* 释放量按「记录文件字节 + 载荷**最后一个引用**消失时的载荷字节」估算(引用计数靠清单,不读载荷)。
|
|
2007
|
+
* 返回淘汰后的清单(调用方负责落盘与删文件)。
|
|
2008
|
+
*/
|
|
2009
|
+
async applyByteBudget(all, keptInput, extraBytes, protectedNewId) {
|
|
2010
|
+
const kept = [...keptInput];
|
|
2011
|
+
// 基线口径(**精确**,不用估算):当前磁盘字节 - 本次已定要删的条目所占字节 + 本次新增字节。
|
|
2012
|
+
// 「已定要删」= 条数淘汰掉的(它们的文件马上会被删),所以不能拿 stats.bytes 当基线。
|
|
2013
|
+
const keptIds = new Set(kept.map((entry) => entry.id));
|
|
2014
|
+
const keptPayloads = new Set();
|
|
2015
|
+
for (const entry of kept) {
|
|
2016
|
+
if (entry.workspace.payload !== null)
|
|
2017
|
+
keptPayloads.add(entry.workspace.payload);
|
|
1284
2018
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
2019
|
+
let freed = 0;
|
|
2020
|
+
const freedPayloads = new Set();
|
|
2021
|
+
for (const entry of all) {
|
|
2022
|
+
if (keptIds.has(entry.id))
|
|
1287
2023
|
continue;
|
|
1288
|
-
|
|
1289
|
-
|
|
2024
|
+
freed += entry.recordBytes;
|
|
2025
|
+
const hash = entry.workspace.payload;
|
|
2026
|
+
if (hash !== null && !keptPayloads.has(hash) && !freedPayloads.has(hash)) {
|
|
2027
|
+
freedPayloads.add(hash);
|
|
2028
|
+
freed += entry.workspace.payloadBytes;
|
|
1290
2029
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
2030
|
+
}
|
|
2031
|
+
const stats = await this.ledger.stats();
|
|
2032
|
+
let bytes = stats.bytes - freed + extraBytes;
|
|
2033
|
+
if (bytes <= this.ledgerBytesBudget)
|
|
2034
|
+
return kept;
|
|
2035
|
+
const refs = new Map();
|
|
2036
|
+
for (const entry of kept) {
|
|
2037
|
+
const hash = entry.workspace.payload;
|
|
2038
|
+
if (hash !== null)
|
|
2039
|
+
refs.set(hash, (refs.get(hash) ?? 0) + 1);
|
|
2040
|
+
}
|
|
2041
|
+
const candidates = kept
|
|
2042
|
+
.filter((entry) => !entry.protected && entry.kind === 'auto' && entry.id !== protectedNewId)
|
|
2043
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
|
|
2044
|
+
const victims = new Set();
|
|
2045
|
+
for (const entry of candidates) {
|
|
2046
|
+
if (bytes <= this.ledgerBytesBudget)
|
|
2047
|
+
break;
|
|
2048
|
+
victims.add(entry.id);
|
|
2049
|
+
const index = kept.findIndex((item) => item.id === entry.id);
|
|
2050
|
+
if (index >= 0)
|
|
2051
|
+
kept.splice(index, 1);
|
|
2052
|
+
bytes -= entry.recordBytes;
|
|
2053
|
+
const hash = entry.workspace.payload;
|
|
2054
|
+
if (hash !== null) {
|
|
2055
|
+
const left = (refs.get(hash) ?? 1) - 1;
|
|
2056
|
+
refs.set(hash, left);
|
|
2057
|
+
if (left <= 0)
|
|
2058
|
+
bytes -= entry.workspace.payloadBytes;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
if (victims.size === 0) {
|
|
2062
|
+
// 一条都淘汰不了却仍然超预算(典型:单条点自身的体积就大于预算)—— **如实留痕**。
|
|
2063
|
+
// 不写这一条,「预算生效」就是假象(竞品 dsh-checkpoint-rewind #35 正是「软配额 bound
|
|
2064
|
+
// 不住单条」的口子);界面侧由 RollbackLedgerStatus.overBudget 如实上屏。
|
|
2065
|
+
if (bytes > this.ledgerBytesBudget) {
|
|
2066
|
+
this.warn('rollback ledger over budget: nothing prunable bytes=' + String(bytes) + ' budget=' + String(this.ledgerBytesBudget));
|
|
1293
2067
|
}
|
|
2068
|
+
return kept;
|
|
2069
|
+
}
|
|
2070
|
+
this.ledger.markEvicted(victims.size);
|
|
2071
|
+
this.warn('rollback ledger over budget: evicted=' + String(victims.size) + ' bytes=' + String(bytes) + ' budget=' + String(this.ledgerBytesBudget));
|
|
2072
|
+
return kept;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* 对象仓回收(**重写版**,issue #89):账本字节数不再进内存。
|
|
2076
|
+
*
|
|
2077
|
+
* 旧实现要从「内存里所有记录的全部 files 数组」累计引用 ⇒ 回收一次 = 把整份台账读进内存。
|
|
2078
|
+
* 现在:扫**清单** -> 逐个读引用到的**载荷文件**(一次只驻留一份)-> 累计 referenced 对象键 -> 扫 objects/。
|
|
2079
|
+
* 峰值内存 ∝ 单份载荷 + 对象键集合,**不 ∝ 台账总字节**。
|
|
2080
|
+
* 任何一份被引用的载荷读不出来 ⇒ 整体放弃回收(fail-closed:读不到的载荷里有什么对象谁也不知道)。
|
|
2081
|
+
*/
|
|
2082
|
+
async gcObjects(entries) {
|
|
2083
|
+
const result = await this.ledger.collectObjects(entries);
|
|
2084
|
+
if (result.aborted !== null) {
|
|
2085
|
+
this.warn('rollback gc skipped: ' + result.aborted);
|
|
2086
|
+
return;
|
|
1294
2087
|
}
|
|
1295
2088
|
// 路径身份缓存随引用回收:没有任何回滚点引用的路径不留条目(不无界增长)
|
|
1296
|
-
await this.gcPathCache(referencedPaths);
|
|
2089
|
+
await this.gcPathCache(new Set([...result.referencedPaths].map(pathKey)));
|
|
1297
2090
|
}
|
|
1298
2091
|
/**
|
|
1299
2092
|
* 把记录写进台账(**留点侧唯一写入口**):整段「读全文 → 合并 → S6 淘汰 → 原子写 → 回收对象」
|
|
1300
2093
|
* 都在 {@link withLedgerWriteLock} 的进程内串行队列里 —— 绝不与 tracker / 路由的另一次写交错。
|
|
1301
2094
|
*/
|
|
2095
|
+
/**
|
|
2096
|
+
* 写一条记录(**最多三次尝试**)—— 跨进程乐观并发(①B)。
|
|
2097
|
+
*
|
|
2098
|
+
* dataDir 由 `$DSH_HOME` 决定、**不随 profile 分家**(与本插件自带的实例锁同一套多实例假设),
|
|
2099
|
+
* 所以「另一个 DSH 实例正在写同一份清单」是设计内的场景,而 `withLedgerWriteLock` 只串行本进程。
|
|
2100
|
+
* 策略:读清单前后各取一次**代际戳**,被改过就重读重试;三次都变 ⇒ **放弃本次留点并 warn**,
|
|
2101
|
+
* 绝不拿自己的快照覆盖别人的清单(宁可少一条点,也不丢别人的点)。
|
|
2102
|
+
* 诚实的边界:检测与写入之间仍有极小窗口,无跨进程锁时无法彻底消除 —— 这里保证的是
|
|
2103
|
+
* 「不静默覆盖 + 尽量合并」,不是原子性。
|
|
2104
|
+
*/
|
|
2105
|
+
/**
|
|
2106
|
+
* 把工作区载荷改写成**增量视图**(相对最近一个同分块的点)——清单增量化,省的是磁盘,不是语义。
|
|
2107
|
+
*
|
|
2108
|
+
* 判据与安全边界(逐条):
|
|
2109
|
+
* · 基准 = 清单里最近一个 `workspace.payload` 非空、**分块完全相同**的点(只试最近几个,代价有界);
|
|
2110
|
+
* · 增量 = 「相对基准新增 / 改写的条目」+「基准里不再存在的路径」,条目**逐字比较**(同形绝对路径);
|
|
2111
|
+
* · 只有增量明显更小(< 全量的 60%)才用;否则原样全量落盘;
|
|
2112
|
+
* · 读路径由 ledger 物化(`resolvePayload`)⇒ 预览 / 回退 / 回收看到的永远是完整清单;
|
|
2113
|
+
* · 基准读不出来 / 分块对不上 / 任何异常 ⇒ 原样全量(绝不因为省字节而让点不可用);
|
|
2114
|
+
* · 基准载荷的保活由 ledger 的 `payloadChain` 负责(淘汰时不会把基准删掉)。
|
|
2115
|
+
*/
|
|
2116
|
+
async storagePlanFor(record, entries) {
|
|
2117
|
+
const full = { record, reuse: null };
|
|
2118
|
+
const payload = record.workspace.payload;
|
|
2119
|
+
if (payload === null || record.workspace.ok !== true || payload.files.length === 0)
|
|
2120
|
+
return full;
|
|
2121
|
+
if (payload.delta !== undefined)
|
|
2122
|
+
return full;
|
|
2123
|
+
// 基准 = **本进程上一次成功留点**的内存摘要(绝不读别人的载荷/记录文件:L-08 / issue #89)。
|
|
2124
|
+
// 没有摘要(进程刚起 / 分块变了)⇒ 这一次落**全量锚点**,随后由摘要继续链下去。
|
|
2125
|
+
const base = this.deltaBase;
|
|
2126
|
+
if (base !== null && sameChunks(base.chunks, payload.chunks)) {
|
|
2127
|
+
const diff = payloadDiffAgainstDigest(payload, base.entries);
|
|
2128
|
+
if (diff.added.length === 0 && diff.removed.length === 0) {
|
|
2129
|
+
// 工作区与该点**逐条相同** ⇒ 直接复用它的载荷文件(零额外字节、链不增长)
|
|
2130
|
+
const entry = entries.find((candidate) => candidate.workspace.payload === base.hash);
|
|
2131
|
+
if (entry !== undefined && (await this.ledger.hasPayload(base.hash))) {
|
|
2132
|
+
return { record, reuse: { hash: base.hash, bytes: entry.workspace.payloadBytes, chain: [...base.chain] } };
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
else if (base.chain.length < ROLLBACK_DELTA_CHAIN_LIMIT) {
|
|
2136
|
+
const delta = buildPayloadDelta(payload, base.hash, base.chain, diff);
|
|
2137
|
+
if (delta !== null) {
|
|
2138
|
+
return {
|
|
2139
|
+
record: { ...record, workspace: { ...record.workspace, payload: compactPayloadForStorage(delta) } },
|
|
2140
|
+
reuse: null,
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
// 没有可用基准 / 分块不同 / 增量不划算 ⇒ 全量落盘(仍省掉冗余的 object 字段)
|
|
2146
|
+
return {
|
|
2147
|
+
record: { ...record, workspace: { ...record.workspace, payload: compactPayloadForStorage(payload) } },
|
|
2148
|
+
reuse: null,
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
/** 记下这份点作为下一次留点的增量基准(内存摘要,见 {@link deltaBase}) */
|
|
2152
|
+
rememberDeltaBase(record, prepared) {
|
|
2153
|
+
const payload = record.workspace.payload;
|
|
2154
|
+
const hash = prepared.payloadHash;
|
|
2155
|
+
if (payload === null || hash === null) {
|
|
2156
|
+
this.deltaBase = null;
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
const entries = new Map();
|
|
2160
|
+
for (const file of payload.files)
|
|
2161
|
+
entries.set(file.path, digestKeyOf(file));
|
|
2162
|
+
this.deltaBase = {
|
|
2163
|
+
hash,
|
|
2164
|
+
chain: prepared.entry.workspace.payloadChain ?? [hash],
|
|
2165
|
+
chunks: [...payload.chunks],
|
|
2166
|
+
entries,
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
1302
2169
|
async appendRecord(record) {
|
|
2170
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2171
|
+
const result = await this.appendRecordOnce(record);
|
|
2172
|
+
if (result.conflict !== true) {
|
|
2173
|
+
return result.detail !== undefined ? { ok: result.ok, detail: result.detail } : { ok: result.ok };
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
this.warn('rollback ledger busy: another instance kept changing the manifest (auto point skipped)');
|
|
2177
|
+
return { ok: false, detail: 'ledger-busy: manifest changed by another instance' };
|
|
2178
|
+
}
|
|
2179
|
+
/** `appendRecord` 的单次尝试;`conflict: true` = 这期间清单被别的实例改过,调用方应当重试。 */
|
|
2180
|
+
async appendRecordOnce(record) {
|
|
1303
2181
|
return await withLedgerWriteLock(this.ledgerPath, async () => {
|
|
1304
|
-
|
|
1305
|
-
const
|
|
1306
|
-
|
|
2182
|
+
// 读之前先取一次代际戳(不参与锁:它天生是跨进程的探针)
|
|
2183
|
+
const stampBefore = await this.ledger.manifestStamp();
|
|
2184
|
+
let manifest = await this.readManifest();
|
|
2185
|
+
if (this.initError !== undefined) {
|
|
2186
|
+
// 台账还有 v2 没迁完(或迁移失败):**拒写**,绝不覆盖原文件(下次启动重试)
|
|
2187
|
+
return { ok: false, detail: this.initError };
|
|
2188
|
+
}
|
|
2189
|
+
if (manifest.error !== undefined) {
|
|
2190
|
+
// 清单**读不出来**:先**从 records/ 重建**(旧点的最终真相在记录文件里),
|
|
2191
|
+
// 而不是拿一个空 entries 覆盖 —— 后者会把旧点整批从清单抹掉,紧接着 prunePayloads
|
|
2192
|
+
// 还会删掉它们的载荷 ⇒ 永久不可恢复。重建后**仍**读不出来才拒写(fail-closed)。
|
|
2193
|
+
manifest = await this.ledger.recoverManifest();
|
|
2194
|
+
if (manifest.error !== undefined) {
|
|
2195
|
+
return { ok: false, detail: 'ledger unreadable: ' + manifest.error };
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
// 清单增量化:能省就把这份载荷改写成「相对最近一个同分块载荷的增量」(读路径由 ledger 物化回来)。
|
|
2199
|
+
// 失败 / 不划算 / 没有可用基准一律**原样返回** —— 增量只影响磁盘占用,绝不参与正确性判断。
|
|
2200
|
+
const storage = await this.storagePlanFor(record, manifest.entries);
|
|
2201
|
+
const prepared = this.ledger.prepareRecord(storage.record, storage.reuse ?? undefined);
|
|
2202
|
+
const merged = [prepared.entry, ...manifest.entries.filter((r) => r.id !== record.id)];
|
|
2203
|
+
const pruned = this.selectPruneEntries(merged);
|
|
2204
|
+
// 本次新增字节:记录文件 + **首次落盘**的载荷(同一工作区态已存在 ⇒ 零额外字节)
|
|
2205
|
+
const payloadIsNew = prepared.payloadHash !== null && !(await this.ledger.hasPayload(prepared.payloadHash));
|
|
2206
|
+
const extraBytes = prepared.entry.recordBytes + (payloadIsNew ? prepared.entry.workspace.payloadBytes : 0);
|
|
2207
|
+
// 刚写入的这一条不参与本轮字节淘汰(否则 capturePoint 会回一个台账里查不到的幽灵点)
|
|
2208
|
+
const kept = await this.applyByteBudget(merged, pruned.kept, extraBytes, prepared.entry.id);
|
|
1307
2209
|
kept.sort((a, b) => (a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0));
|
|
2210
|
+
// 写之前复核:这份清单在这期间有没有被别的实例改过?改过就**不写**,交给调用方重读重试。
|
|
2211
|
+
// 读不出代际戳(任一时刻为 null)⇒ 不参与判定、照常写 —— 绝不因为一个可选探针失败就拒绝留点。
|
|
2212
|
+
const stampAfter = await this.ledger.manifestStamp();
|
|
2213
|
+
if (stampBefore !== null && stampAfter !== null && stampBefore !== stampAfter) {
|
|
2214
|
+
return { ok: false, conflict: true, detail: 'manifest changed by another instance' };
|
|
2215
|
+
}
|
|
1308
2216
|
try {
|
|
1309
2217
|
await fs.mkdir(this.root, { recursive: true });
|
|
1310
|
-
await
|
|
2218
|
+
await this.ledger.writePrepared(prepared);
|
|
1311
2219
|
}
|
|
1312
2220
|
catch (error) {
|
|
1313
2221
|
return { ok: false, detail: errorDetail(error) };
|
|
1314
2222
|
}
|
|
1315
|
-
|
|
2223
|
+
try {
|
|
2224
|
+
await this.ledger.writeManifest(kept);
|
|
2225
|
+
}
|
|
2226
|
+
catch (error) {
|
|
2227
|
+
// 清单没写成 ⇒ 这一条**不算落进台账**:撤掉刚落的记录文件与无人引用的载荷(不留半截)
|
|
2228
|
+
await this.ledger.deleteRecords([prepared.entry.id]);
|
|
2229
|
+
await this.ledger.prunePayloads(manifest.entries);
|
|
2230
|
+
return { ok: false, detail: errorDetail(error) };
|
|
2231
|
+
}
|
|
2232
|
+
// 记下「这次留点」作为下一次的增量基准(只在真正写成功后;失败不更新 ⇒ 链只跨成功落盘的点)
|
|
2233
|
+
this.rememberDeltaBase(record, prepared);
|
|
2234
|
+
const keptIds = new Set(kept.map((entry) => entry.id));
|
|
2235
|
+
const dropped = merged.filter((entry) => !keptIds.has(entry.id)).map((entry) => entry.id);
|
|
2236
|
+
await this.ledger.deleteRecords(dropped);
|
|
2237
|
+
await this.ledger.prunePayloads(kept);
|
|
2238
|
+
// **只在真的淘汰了记录时**才扫对象仓:对象回收需要逐份读被引用的载荷(代价 ∝ 载荷总量),
|
|
2239
|
+
// 而没有淘汰时不可能产生无人引用的对象 —— 每回合都扫一遍等于把「读全账本」搬进了留点热路径
|
|
2240
|
+
// (issue #89 正是要消灭这条路)。删除点 / 预算淘汰 / 清理入口都会走到这里。
|
|
2241
|
+
if (dropped.length > 0)
|
|
2242
|
+
await this.gcObjects(kept);
|
|
1316
2243
|
return { ok: true };
|
|
1317
2244
|
});
|
|
1318
2245
|
}
|
|
@@ -1337,7 +2264,7 @@ export class RollbackPointEngine {
|
|
|
1337
2264
|
boundarySeq: typeof input.boundarySeq === 'number' && Number.isFinite(input.boundarySeq) ? input.boundarySeq : null,
|
|
1338
2265
|
boundaryEndSeq: typeof input.boundaryEndSeq === 'number' && Number.isFinite(input.boundaryEndSeq) ? input.boundaryEndSeq : null,
|
|
1339
2266
|
});
|
|
1340
|
-
const workspace = await this.captureWorkspace(chunks);
|
|
2267
|
+
const workspace = await this.captureWorkspace(chunks, input.signal);
|
|
1341
2268
|
const spreadMs = Math.abs(session.capturedAt - workspace.capturedAt);
|
|
1342
2269
|
const record = {
|
|
1343
2270
|
schemaVersion: ROLLBACK_SCHEMA_VERSION,
|
|
@@ -1398,11 +2325,11 @@ export class RollbackPointEngine {
|
|
|
1398
2325
|
async ledgerSnapshot() {
|
|
1399
2326
|
const now = this.stamp();
|
|
1400
2327
|
if (this.ledgerSnapshotCache !== null && now - this.ledgerSnapshotCache.at < ROLLBACK_INHERITED_LEDGER_TTL_MS) {
|
|
1401
|
-
return this.ledgerSnapshotCache.
|
|
2328
|
+
return this.ledgerSnapshotCache.entries;
|
|
1402
2329
|
}
|
|
1403
|
-
const ledger = await this.
|
|
1404
|
-
this.ledgerSnapshotCache = { at: now,
|
|
1405
|
-
return ledger.
|
|
2330
|
+
const ledger = await this.readManifest();
|
|
2331
|
+
this.ledgerSnapshotCache = { at: now, entries: ledger.entries };
|
|
2332
|
+
return ledger.entries;
|
|
1406
2333
|
}
|
|
1407
2334
|
/**
|
|
1408
2335
|
* 本会话/本回合**没有自己的点**时,沿继承链找祖先的同回合点(t9 的判据层)。
|
|
@@ -1420,7 +2347,7 @@ export class RollbackPointEngine {
|
|
|
1420
2347
|
for (const ancestor of ancestors) {
|
|
1421
2348
|
const hit = records.find((r) => r.sessionId === ancestor && r.turnSeq === turn);
|
|
1422
2349
|
if (hit !== undefined)
|
|
1423
|
-
return
|
|
2350
|
+
return entrySummaryOf(hit);
|
|
1424
2351
|
}
|
|
1425
2352
|
return null;
|
|
1426
2353
|
}
|
|
@@ -1449,14 +2376,14 @@ export class RollbackPointEngine {
|
|
|
1449
2376
|
* 任何东西),而 fork 子会话与父会话在**同一个项目键**下(fork 不改 cwd)⇒ 覆盖这点就够了。
|
|
1450
2377
|
* 台账空 ⇒ 直接返回(零扫描,热路径零代价);结果按 {@link ROLLBACK_FORK_LINKS_TTL_MS} 缓存。
|
|
1451
2378
|
*/
|
|
1452
|
-
async refreshForkLinks(
|
|
2379
|
+
async refreshForkLinks(entries) {
|
|
1453
2380
|
const now = this.stamp();
|
|
1454
2381
|
if (this.forkLinksAt !== 0 && now - this.forkLinksAt < ROLLBACK_FORK_LINKS_TTL_MS)
|
|
1455
2382
|
return;
|
|
1456
2383
|
this.forkLinksAt = now;
|
|
1457
2384
|
const projectKeys = new Set();
|
|
1458
|
-
for (const
|
|
1459
|
-
const key = unitProjectKeyOf(
|
|
2385
|
+
for (const entry of entries) {
|
|
2386
|
+
const key = unitProjectKeyOf(entry.unitId);
|
|
1460
2387
|
if (key !== null)
|
|
1461
2388
|
projectKeys.add(key);
|
|
1462
2389
|
}
|
|
@@ -1520,14 +2447,14 @@ export class RollbackPointEngine {
|
|
|
1520
2447
|
* · `inheritedFrom` / `inheritedSessionId` 如实标出它的来处。
|
|
1521
2448
|
* 有界({@link ROLLBACK_INHERITED_VIEW_MAX}),超出如实标记。
|
|
1522
2449
|
*/
|
|
1523
|
-
inheritedViews(
|
|
2450
|
+
inheritedViews(manifest) {
|
|
1524
2451
|
const entries = [];
|
|
1525
2452
|
let truncated = false;
|
|
1526
2453
|
const ownTurnsOf = (sessionId) => {
|
|
1527
2454
|
const out = new Set();
|
|
1528
|
-
for (const
|
|
1529
|
-
if (
|
|
1530
|
-
out.add(
|
|
2455
|
+
for (const entry of manifest) {
|
|
2456
|
+
if (entry.sessionId === sessionId && entry.turnSeq !== null)
|
|
2457
|
+
out.add(entry.turnSeq);
|
|
1531
2458
|
}
|
|
1532
2459
|
return out;
|
|
1533
2460
|
};
|
|
@@ -1536,22 +2463,30 @@ export class RollbackPointEngine {
|
|
|
1536
2463
|
if (chain.length === 0)
|
|
1537
2464
|
continue;
|
|
1538
2465
|
const own = ownTurnsOf(child);
|
|
2466
|
+
// 多代链(A→B→D)里同一个回合可能同时存在于多个祖先:**只取最近的那个**。
|
|
2467
|
+
// 没有这份集合时,D 会拿到两条 (sessionId=D, turn=N) 却指向**不同状态**的记录,
|
|
2468
|
+
// 客户端取第一条时结果随祖先顺序漂移。chain 由近到远 ⇒ 先到先得。
|
|
2469
|
+
const pickedTurns = new Set();
|
|
1539
2470
|
for (const ancestor of chain) {
|
|
1540
|
-
for (const
|
|
1541
|
-
if (
|
|
2471
|
+
for (const entry of manifest) {
|
|
2472
|
+
if (entry.sessionId !== ancestor || entry.turnSeq === null)
|
|
1542
2473
|
continue;
|
|
1543
|
-
if (own.has(
|
|
2474
|
+
if (own.has(entry.turnSeq))
|
|
1544
2475
|
continue; // 本会话自己有这一回合的点 ⇒ 用自己那个
|
|
2476
|
+
if (pickedTurns.has(entry.turnSeq))
|
|
2477
|
+
continue; // 更近的祖先已经提供过这一回合
|
|
1545
2478
|
if (entries.length >= ROLLBACK_INHERITED_VIEW_MAX) {
|
|
1546
2479
|
truncated = true;
|
|
1547
2480
|
break;
|
|
1548
2481
|
}
|
|
2482
|
+
pickedTurns.add(entry.turnSeq);
|
|
1549
2483
|
entries.push({
|
|
1550
|
-
|
|
2484
|
+
// 段摘要来自清单里**写入时算好**的那份(口径仍是 summarizeRollbackPoint)
|
|
2485
|
+
...entrySummaryOf(entry),
|
|
1551
2486
|
sessionId: child,
|
|
1552
|
-
unitId: childUnitIdOf(
|
|
1553
|
-
inheritedFrom:
|
|
1554
|
-
inheritedSessionId:
|
|
2487
|
+
unitId: childUnitIdOf(entry.unitId, child),
|
|
2488
|
+
inheritedFrom: entry.id,
|
|
2489
|
+
inheritedSessionId: entry.sessionId,
|
|
1555
2490
|
});
|
|
1556
2491
|
}
|
|
1557
2492
|
if (truncated)
|
|
@@ -1565,12 +2500,13 @@ export class RollbackPointEngine {
|
|
|
1565
2500
|
/* -------------------------------------------------------------- 列 / 读 */
|
|
1566
2501
|
async list() {
|
|
1567
2502
|
const storage = await this.storageStatus();
|
|
1568
|
-
const ledger = await this.
|
|
1569
|
-
await this.refreshForkLinks(ledger.
|
|
1570
|
-
|
|
2503
|
+
const ledger = await this.readManifest();
|
|
2504
|
+
await this.refreshForkLinks(ledger.entries);
|
|
2505
|
+
// v3:列表**只读清单**(元数据 + 写入时算好的段摘要)—— 一个记录文件都不读(issue #89 的内存护栏)
|
|
2506
|
+
const points = [...ledger.entries]
|
|
1571
2507
|
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0))
|
|
1572
|
-
.map(
|
|
1573
|
-
const inherited = this.inheritedViews(ledger.
|
|
2508
|
+
.map(entrySummaryOf);
|
|
2509
|
+
const inherited = this.inheritedViews(ledger.entries);
|
|
1574
2510
|
const out = {
|
|
1575
2511
|
ok: storage.available,
|
|
1576
2512
|
storage,
|
|
@@ -1589,19 +2525,50 @@ export class RollbackPointEngine {
|
|
|
1589
2525
|
}
|
|
1590
2526
|
async read(id) {
|
|
1591
2527
|
const storage = await this.storageStatus();
|
|
1592
|
-
const ledger = await this.
|
|
1593
|
-
const
|
|
2528
|
+
const ledger = await this.readManifest();
|
|
2529
|
+
const entry = ledger.entries.find((r) => r.id === id);
|
|
2530
|
+
// v3:records 只回传命中的那一条 —— 调用方只用 point;逐条读全部记录文件是不可接受的代价
|
|
1594
2531
|
const out = {
|
|
1595
|
-
records:
|
|
2532
|
+
records: [],
|
|
1596
2533
|
storage,
|
|
1597
2534
|
};
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
2535
|
+
let error = ledger.error;
|
|
2536
|
+
if (entry !== undefined) {
|
|
2537
|
+
const resolved = await this.readFullRecord(entry.id);
|
|
2538
|
+
if (resolved.record !== undefined) {
|
|
2539
|
+
out.point = resolved.record;
|
|
2540
|
+
out.records = [resolved.record];
|
|
2541
|
+
}
|
|
2542
|
+
else if (resolved.error !== undefined) {
|
|
2543
|
+
error = error === undefined ? resolved.error : error + '; ' + resolved.error;
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
if (error !== undefined)
|
|
2547
|
+
out.error = error;
|
|
1602
2548
|
return out;
|
|
1603
2549
|
}
|
|
1604
2550
|
/* -------------------------------------------------------------- 预览 */
|
|
2551
|
+
/**
|
|
2552
|
+
* 预览的**单条只读判定**(阶段 A,可并发跑):一次 bigint lstat + 身份快路径。
|
|
2553
|
+
* `unchanged = true` 表示「已证明与记录一致」(身份命中身份缓存且 sha/bytes 相同)⇒ 调用方直接跳过。
|
|
2554
|
+
*/
|
|
2555
|
+
async previewScan(file) {
|
|
2556
|
+
try {
|
|
2557
|
+
// **lstat,不是 stat**:写入侧用 `symlink:'reject'`(lstat 是链接即抛),预览若跟随链接就会
|
|
2558
|
+
// 说「会创建 / 会覆盖」,执行却整段失败 —— 两侧必须是同一个口径。链接一律按「不可写」报。
|
|
2559
|
+
const stat = await fs.lstat(file.path, { bigint: true });
|
|
2560
|
+
if (stat.isSymbolicLink())
|
|
2561
|
+
return { kind: 'unavailable' };
|
|
2562
|
+
const size = Number(stat.size);
|
|
2563
|
+
const regular = stat.isFile();
|
|
2564
|
+
const unchanged = regular && size === file.bytes
|
|
2565
|
+
&& (await this.unchangedByIdentity(file, regular ? fileIdentityOf(stat) : null));
|
|
2566
|
+
return { kind: 'ok', size, regular, unchanged };
|
|
2567
|
+
}
|
|
2568
|
+
catch (error) {
|
|
2569
|
+
return classifyPreviewStatFailure(error) === 'missing' ? { kind: 'missing' } : { kind: 'unavailable' };
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
1605
2572
|
trackWarnings(record) {
|
|
1606
2573
|
const out = [];
|
|
1607
2574
|
for (const track of [record.workspace, record.session]) {
|
|
@@ -1650,55 +2617,91 @@ export class RollbackPointEngine {
|
|
|
1650
2617
|
for (const entry of payload.ignoredDirs ?? [])
|
|
1651
2618
|
proof.add(pathKey(entry.path));
|
|
1652
2619
|
let refused = null;
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
2620
|
+
// 路径索引**存在即并入**证明名单:写入条件已从「跳过名单被截断」推广为「覆盖自检不完整」
|
|
2621
|
+
// (读到一半被预算打断也会写索引)—— 这里只看 skippedTruncated 会漏读索引 ⇒ 把老文件当新建删掉。
|
|
2622
|
+
const index = payload.pathIndex ?? null;
|
|
2623
|
+
if (index !== null) {
|
|
2624
|
+
try {
|
|
2625
|
+
for (const item of await this.readPathIndex(index))
|
|
2626
|
+
proof.add(pathKey(item));
|
|
2627
|
+
}
|
|
2628
|
+
catch (error) {
|
|
2629
|
+
this.warn('rollback path index unreadable: ' + errorDetail(error));
|
|
1657
2630
|
refused = 'capture-incomplete';
|
|
1658
|
-
else {
|
|
1659
|
-
try {
|
|
1660
|
-
for (const item of await this.readPathIndex(index))
|
|
1661
|
-
proof.add(pathKey(item));
|
|
1662
|
-
}
|
|
1663
|
-
catch (error) {
|
|
1664
|
-
this.warn('rollback path index unreadable: ' + errorDetail(error));
|
|
1665
|
-
refused = 'capture-incomplete';
|
|
1666
|
-
}
|
|
1667
2631
|
}
|
|
1668
2632
|
}
|
|
1669
|
-
|
|
1670
|
-
|
|
2633
|
+
else if (payload.skippedTruncated) {
|
|
2634
|
+
// 逐条跳过名单被记录上限截断、又没有索引 ⇒ 「未记录」≠「留点之后新建」,证明不了
|
|
1671
2635
|
refused = 'capture-incomplete';
|
|
1672
|
-
|
|
1673
|
-
|
|
2636
|
+
}
|
|
2637
|
+
// —— 分子树拒删范围 ——
|
|
2638
|
+
// 忽略目录 / 读不出的目录 / 走查没进去的子树:这三类的共同点是「其下内容在留点时刻未知」,
|
|
2639
|
+
// 但它们**各自只影响自己那棵子树**。旧口径一律全局拒删,代价是「忽略一个 outputs/ 就让整个
|
|
2640
|
+
// 工作区的新建文件一条都不删」;现在只拒这几棵子树,范围之外由证明名单照常判。
|
|
2641
|
+
// 走查中断且拿不到精确子树列表(老台账 / 栈被截断)时才退回全局。
|
|
2642
|
+
// 范围**说得清**才敢分子树拒删:忽略目录的逐条列表可能被记录上限截断(ignoredDirCount 是恒增
|
|
2643
|
+
// 计数),走查中断也可能拿不到子树列表(老台账 / 栈太深)。任一「说不清」⇒ 退回全局拒删。
|
|
2644
|
+
const ignoredKnown = (payload.ignoredDirs ?? []).length >= (payload.ignoredDirCount ?? 0);
|
|
2645
|
+
const unwalkedKnown = (payload.unwalkedDirs ?? []).length > 0 || payload.walkIncomplete !== true;
|
|
2646
|
+
const scopesKnown = ignoredKnown && unwalkedKnown;
|
|
2647
|
+
const refusalScopes = scopesKnown
|
|
2648
|
+
? [
|
|
2649
|
+
...(payload.ignoredDirs ?? []).map((entry) => entry.path),
|
|
2650
|
+
...payload.unreadableDirs,
|
|
2651
|
+
...(payload.unwalkedDirs ?? []),
|
|
2652
|
+
]
|
|
2653
|
+
: [];
|
|
2654
|
+
if (refused === null && !scopesKnown) {
|
|
2655
|
+
// 证明名单不完整、且**说不出**不完整的确切范围 ⇒ 整个工作区一条都不删(安全方向)
|
|
1674
2656
|
refused = 'capture-incomplete';
|
|
2657
|
+
}
|
|
1675
2658
|
const chunks = payload.chunks;
|
|
1676
2659
|
// 走查上限 = 全树:候选的**准入**由证明名单负责,条数上限不再是安全阀
|
|
1677
2660
|
//(旧上限 maxFiles*4 会在巨型工作区里静默漏掉真正的新建文件 —— 那是「删一半」,不是 fail-closed)
|
|
1678
2661
|
const limit = Number.MAX_SAFE_INTEGER;
|
|
2662
|
+
// **时间预算**:回退期的走查同样不能无限跑(分块 = 会话 cwd,家目录 / 大仓库会走很久,
|
|
2663
|
+
// 而捕获侧早就有 30 s 预算 —— 只修一侧等于把 OOM 形态从留点搬到了回退)。
|
|
2664
|
+
// 中断只会让候选**变少**(安全方向),但必须如实置 truncated(绝不静默漏删、也不谎报完整)。
|
|
2665
|
+
const deadline = this.stamp() + this.rewindBudgetMs;
|
|
2666
|
+
const outOfBudget = () => this.stamp() >= deadline;
|
|
1679
2667
|
const candidates = [];
|
|
1680
2668
|
let truncated = false;
|
|
1681
2669
|
for (const chunk of chunks) {
|
|
1682
2670
|
if (!path.isAbsolute(chunk))
|
|
1683
2671
|
continue;
|
|
1684
|
-
|
|
2672
|
+
if (outOfBudget()) {
|
|
2673
|
+
truncated = true;
|
|
2674
|
+
break;
|
|
2675
|
+
}
|
|
2676
|
+
const listed = await this.listChunkFiles(path.resolve(chunk), sink, limit, undefined, await loadIgnoreRules(path.resolve(chunk)), outOfBudget);
|
|
1685
2677
|
if (listed === null)
|
|
1686
2678
|
continue;
|
|
1687
2679
|
if (listed.truncated)
|
|
1688
2680
|
truncated = true;
|
|
1689
2681
|
for (const file of listed.files) {
|
|
2682
|
+
if (outOfBudget()) {
|
|
2683
|
+
truncated = true;
|
|
2684
|
+
break;
|
|
2685
|
+
}
|
|
1690
2686
|
if (proof.has(pathKey(file)))
|
|
1691
2687
|
continue;
|
|
1692
2688
|
candidates.push(file);
|
|
1693
2689
|
}
|
|
1694
2690
|
}
|
|
1695
|
-
|
|
2691
|
+
// 警告只在**全局拒绝**时给:分子树拒绝由 createdSkipped 逐条说明(那句「本次不删除任何
|
|
2692
|
+
// 『留点后新建』的路径」在分子树拒绝下是假话 —— 文案诚实要求按真实范围分派)。
|
|
2693
|
+
const refusalWarnings = refused === null ? [] : this.createdRefusalWarnings(payload);
|
|
1696
2694
|
const all = [];
|
|
1697
2695
|
const paths = [];
|
|
1698
2696
|
const skipped = [];
|
|
1699
2697
|
for (const file of candidates) {
|
|
1700
2698
|
all.push(file);
|
|
1701
2699
|
// 拒删的也留在「未记录」清单里(如实报告),但绝不进删除集合
|
|
2700
|
+
// 分子树拒删(忽略目录 / 读不出的目录 / 没走查到的子树):只拒这几棵子树里的候选
|
|
2701
|
+
if (refusalScopes.length > 0 && refusalScopes.some((dir) => isInsidePath(file, dir))) {
|
|
2702
|
+
skipped.push(file);
|
|
2703
|
+
continue;
|
|
2704
|
+
}
|
|
1702
2705
|
if (this.createdRemovalRefusal(file, chunks) !== null) {
|
|
1703
2706
|
skipped.push(file);
|
|
1704
2707
|
continue;
|
|
@@ -1793,7 +2796,7 @@ export class RollbackPointEngine {
|
|
|
1793
2796
|
/** 只读预览:零写入(不过确认门、不拍 guard、不写任何字节)。 */
|
|
1794
2797
|
async preview(id) {
|
|
1795
2798
|
const storage = await this.storageStatus();
|
|
1796
|
-
const ledger = await this.
|
|
2799
|
+
const ledger = await this.readManifest();
|
|
1797
2800
|
const warnings = [];
|
|
1798
2801
|
if (ledger.error !== undefined)
|
|
1799
2802
|
warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail: ledger.error });
|
|
@@ -1817,9 +2820,18 @@ export class RollbackPointEngine {
|
|
|
1817
2820
|
if (!storage.available) {
|
|
1818
2821
|
return { ...base, code: 'storage-unavailable', detail: storage.detail, guidance: storage.guidance };
|
|
1819
2822
|
}
|
|
1820
|
-
const
|
|
1821
|
-
if (
|
|
2823
|
+
const entry = ledger.entries.find((r) => r.id === id);
|
|
2824
|
+
if (entry === undefined)
|
|
1822
2825
|
return { ...base, code: 'record-not-found' };
|
|
2826
|
+
const resolved = await this.readFullRecord(entry.id);
|
|
2827
|
+
if (resolved.record === undefined) {
|
|
2828
|
+
// 引用解析失败(载荷文件缺失 / 哈希不符 / 形状不符):**如实失败** —— 绝不拿半个记录去预览
|
|
2829
|
+
// (半个记录会让「可回退」看起来成立,然后回退真的写错文件)
|
|
2830
|
+
const detail = resolved.error ?? 'record-incomplete';
|
|
2831
|
+
warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail });
|
|
2832
|
+
return { ...base, code: 'record-incomplete', detail };
|
|
2833
|
+
}
|
|
2834
|
+
const record = resolved.record;
|
|
1823
2835
|
for (const guidance of this.trackWarnings(record))
|
|
1824
2836
|
warnings.push(guidance);
|
|
1825
2837
|
// 工作区态
|
|
@@ -1838,15 +2850,27 @@ export class RollbackPointEngine {
|
|
|
1838
2850
|
const changedFiles = [];
|
|
1839
2851
|
// 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
|
|
1840
2852
|
const diffBudget = { bytes: 0, work: 0 };
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
2853
|
+
// T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
|
|
2854
|
+
const previewFiles = wsPayload.files;
|
|
2855
|
+
// 阶段 A(**并发**):逐条一次 bigint lstat + 身份快路径。大仓库这里是预览的大头:
|
|
2856
|
+
// 真机 34k 条实测 —— 串行 lstat ≈ 6 s、串行真读 ≈ 13 s;并发后降到亚秒级 + 只读真正变了的那几条。
|
|
2857
|
+
const scans = new Array(previewFiles.length);
|
|
2858
|
+
let scanCursor = 0;
|
|
2859
|
+
const scanWorkers = Array.from({ length: Math.min(this.captureConcurrency, Math.max(1, previewFiles.length)) }, async () => {
|
|
2860
|
+
for (;;) {
|
|
2861
|
+
const index = scanCursor;
|
|
2862
|
+
scanCursor += 1;
|
|
2863
|
+
if (index >= previewFiles.length)
|
|
2864
|
+
return;
|
|
2865
|
+
scans[index] = await this.previewScan(previewFiles[index]);
|
|
1849
2866
|
}
|
|
2867
|
+
});
|
|
2868
|
+
await Promise.all(scanWorkers);
|
|
2869
|
+
// 阶段 B(**顺序**):只对「变了 / 没了 / 不可写」的条目按记录顺序装配 —— 列表顺序与行级 diff 的
|
|
2870
|
+
// 共享预算因此仍**完全确定**(与并发前逐条一致,测试可逐字对账)。
|
|
2871
|
+
for (let index = 0; index < previewFiles.length; index += 1) {
|
|
2872
|
+
const file = previewFiles[index];
|
|
2873
|
+
const outcome = scans[index] ?? { kind: 'unavailable' };
|
|
1850
2874
|
if (outcome.kind === 'missing') {
|
|
1851
2875
|
// **只有 ENOENT** 才算「当前确实不存在」⇒ 回退会重新创建它
|
|
1852
2876
|
missing += 1;
|
|
@@ -1864,6 +2888,9 @@ export class RollbackPointEngine {
|
|
|
1864
2888
|
changedFiles.push({ path: file.path, status: 'overwrite', ...previewCountsUnavailable() });
|
|
1865
2889
|
continue;
|
|
1866
2890
|
}
|
|
2891
|
+
// 身份快路径已证明「与记录一致」⇒ 一个字段都不改(内容一致的文件不列进列表)
|
|
2892
|
+
if (outcome.unchanged)
|
|
2893
|
+
continue;
|
|
1867
2894
|
const { size, regular } = outcome;
|
|
1868
2895
|
const sizeMatches = regular && size === file.bytes;
|
|
1869
2896
|
const withinCaps = file.bytes <= this.previewDiffMaxFileBytes && size <= this.previewDiffMaxFileBytes;
|
|
@@ -1872,7 +2899,7 @@ export class RollbackPointEngine {
|
|
|
1872
2899
|
// ② 大小不一致时也得读一次才能给行级统计(超限的大文件不白读)。
|
|
1873
2900
|
if (regular && (sizeMatches || withinCaps)) {
|
|
1874
2901
|
try {
|
|
1875
|
-
current = await
|
|
2902
|
+
current = await this.readFileBytes(file.path);
|
|
1876
2903
|
}
|
|
1877
2904
|
catch {
|
|
1878
2905
|
current = null;
|
|
@@ -1988,10 +3015,16 @@ export class RollbackPointEngine {
|
|
|
1988
3015
|
return { ...base, code: 'storage-unavailable', detail: storage.detail };
|
|
1989
3016
|
if (filePath === '')
|
|
1990
3017
|
return { ...base, detail: 'empty-path' };
|
|
1991
|
-
const ledger = await this.
|
|
1992
|
-
const
|
|
1993
|
-
if (
|
|
3018
|
+
const ledger = await this.readManifest();
|
|
3019
|
+
const entry = ledger.entries.find((r) => r.id === id);
|
|
3020
|
+
if (entry === undefined)
|
|
1994
3021
|
return { ...base, code: 'record-not-found' };
|
|
3022
|
+
const resolved = await this.readFullRecord(entry.id);
|
|
3023
|
+
if (resolved.record === undefined) {
|
|
3024
|
+
// 引用解析失败:**如实失败**(RollbackFileDiffResult 没有 warnings 槽位,用 detail 说清楚)
|
|
3025
|
+
return { ...base, code: 'record-incomplete', detail: resolved.error ?? 'record-incomplete' };
|
|
3026
|
+
}
|
|
3027
|
+
const record = resolved.record;
|
|
1995
3028
|
const payload = record.workspace.payload;
|
|
1996
3029
|
if (!record.workspace.ok || payload === null) {
|
|
1997
3030
|
const detail = record.workspace.reasonDetail;
|
|
@@ -2067,7 +3100,7 @@ export class RollbackPointEngine {
|
|
|
2067
3100
|
return { ...view, reason: 'too-large' };
|
|
2068
3101
|
if (before.binary || after.binary)
|
|
2069
3102
|
return { ...view, reason: 'binary' };
|
|
2070
|
-
const result = diffLines(current === null ? '' : DIFF_TEXT_DECODER.decode(current), DIFF_TEXT_DECODER.decode(snapshot), { context: ROLLBACK_FILE_DIFF_LIMITS.context });
|
|
3103
|
+
const result = diffLines(current === null ? '' : DIFF_TEXT_DECODER.decode(current), DIFF_TEXT_DECODER.decode(snapshot), { context: ROLLBACK_FILE_DIFF_LIMITS.context, maxD: ROLLBACK_FILE_DIFF_LIMITS.maxD });
|
|
2071
3104
|
return {
|
|
2072
3105
|
...view,
|
|
2073
3106
|
hunks: result.hunks, added: result.added, removed: result.removed,
|
|
@@ -2104,13 +3137,21 @@ export class RollbackPointEngine {
|
|
|
2104
3137
|
if (!storage.available) {
|
|
2105
3138
|
return this.result({ outcome: 'failed', code: 'storage-unavailable', detail: storage.detail, id: input.id, storage, guidance: storage.guidance });
|
|
2106
3139
|
}
|
|
2107
|
-
const ledger = await this.
|
|
3140
|
+
const ledger = await this.readManifest();
|
|
2108
3141
|
if (ledger.error !== undefined)
|
|
2109
3142
|
warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail: ledger.error });
|
|
2110
|
-
const
|
|
2111
|
-
if (
|
|
3143
|
+
const entry = ledger.entries.find((r) => r.id === input.id);
|
|
3144
|
+
if (entry === undefined) {
|
|
2112
3145
|
return this.result({ outcome: 'failed', code: 'record-not-found', id: input.id, storage, warnings });
|
|
2113
3146
|
}
|
|
3147
|
+
const resolved = await this.readFullRecord(entry.id);
|
|
3148
|
+
if (resolved.record === undefined) {
|
|
3149
|
+
// 记录读不完整(载荷缺失 / 哈希不符 / 形状不符):**零写入**失败,绝不拿半个记录去回退
|
|
3150
|
+
const detail = resolved.error ?? 'record-incomplete';
|
|
3151
|
+
warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail });
|
|
3152
|
+
return this.result({ outcome: 'failed', code: 'record-incomplete', detail, id: input.id, storage, warnings });
|
|
3153
|
+
}
|
|
3154
|
+
const record = resolved.record;
|
|
2114
3155
|
// 门 1(fail-closed 确认门):必须恰好 true
|
|
2115
3156
|
if (input.userConfirmed !== true) {
|
|
2116
3157
|
return this.result({ outcome: 'denied', code: 'confirmation-required', detail: 'userConfirmed!==true', id: input.id, storage, warnings });
|
|
@@ -2253,7 +3294,9 @@ export class RollbackPointEngine {
|
|
|
2253
3294
|
return 'outside-chunk';
|
|
2254
3295
|
if (isInsidePath(file, this.root))
|
|
2255
3296
|
return 'inside-data-dir';
|
|
2256
|
-
|
|
3297
|
+
// Windows 文件系统**不分大小写**:`.GIT` / `.Git` / `NODE_MODULES` 是同一个目录,必须折大小写后
|
|
3298
|
+
// 再比 —— 不折会让它们的子树失去保护(本文件其它路径判据都走 win32Fold,这里此前漏了)。
|
|
3299
|
+
const segments = win32Fold(path.resolve(file)).split(/[\\/]+/);
|
|
2257
3300
|
if (segments.includes('.git'))
|
|
2258
3301
|
return 'excluded-dir';
|
|
2259
3302
|
if (segments.includes('node_modules'))
|
|
@@ -2273,6 +3316,8 @@ export class RollbackPointEngine {
|
|
|
2273
3316
|
const detail = [];
|
|
2274
3317
|
const writable = [];
|
|
2275
3318
|
let failed = 0;
|
|
3319
|
+
// 「已与记录一致」的条目数(身份快路径证明)—— 后面「0 变更」的文案与统计要用
|
|
3320
|
+
let unchanged = 0;
|
|
2276
3321
|
for (const file of payload.files) {
|
|
2277
3322
|
// 门:记录里的路径必须落在记录里的分块内(记录不可成为写原语)
|
|
2278
3323
|
const inChunk = payload.chunks.some((chunk) => path.isAbsolute(chunk) && isInsidePath(file.path, chunk));
|
|
@@ -2281,6 +3326,17 @@ export class RollbackPointEngine {
|
|
|
2281
3326
|
pushDetail(detail, 'invalid-path ' + file.path);
|
|
2282
3327
|
continue;
|
|
2283
3328
|
}
|
|
3329
|
+
// **身份快路径**:身份(dev:ino:size:mtimeNs:ctimeNs:mode)命中身份缓存、且缓存的 sha/bytes 与记录
|
|
3330
|
+
// 一致 ⇒ 这个文件当前就是留点时刻的内容,**它根本不会被写** ⇒ 连对象都不必读。
|
|
3331
|
+
// 大仓库实测:旧口径下阶段 1 会把 34k 个对象(609 MiB)全读一遍 + 哈希一遍(回退 60 s+ 全在这)。
|
|
3332
|
+
// 语义变化(有意、且更准):阶段 1 的对象校验只对**真要写**的条目做 —— 一个用不到的对象坏掉,
|
|
3333
|
+
// 不该中止整次回退;真要用它时(该文件确实变了)照样会 `object-missing` / `object-hash-mismatch` 硬失败。
|
|
3334
|
+
const info = await this.fileStatOf(file.path);
|
|
3335
|
+
if (info.error === undefined && info.isFile && info.size === file.bytes
|
|
3336
|
+
&& (await this.unchangedByIdentity(file, info.identity))) {
|
|
3337
|
+
unchanged += 1;
|
|
3338
|
+
continue;
|
|
3339
|
+
}
|
|
2284
3340
|
try {
|
|
2285
3341
|
const bytes = await this.readObject(file.object);
|
|
2286
3342
|
if (sha256Hex(bytes) !== file.sha256) {
|
|
@@ -2332,7 +3388,6 @@ export class RollbackPointEngine {
|
|
|
2332
3388
|
// E1:内容已与快照一致的条目**不再写** —— 「0 变更」就要是真的 0 次写入
|
|
2333
3389
|
// (否则工作区已与该点一致时也会重写全部文件,白耗 I/O 并抖动 mtime)。
|
|
2334
3390
|
const needed = [];
|
|
2335
|
-
let unchanged = 0;
|
|
2336
3391
|
for (const item of writable) {
|
|
2337
3392
|
let identical = false;
|
|
2338
3393
|
try {
|
|
@@ -2362,17 +3417,56 @@ export class RollbackPointEngine {
|
|
|
2362
3417
|
noop.createdRefusedReason = removal.refused;
|
|
2363
3418
|
return noop;
|
|
2364
3419
|
}
|
|
2365
|
-
//
|
|
2366
|
-
|
|
3420
|
+
// 前像(**字节落盘、索引随栈**):失败时要能整体回到恢复前状态 —— 只覆盖**真会被写**的文件。
|
|
3421
|
+
// 字节之所以不留在内存:大工作区整体回退时前像峰值 = 待写总字节(可达数百 MB),
|
|
3422
|
+
// 与 issue #89 的 OOM 同族;落盘后内存只随**文件条数**增长。
|
|
3423
|
+
// 暂存目录建不出来 ⇒ **零写入失败**(fail-closed):回退没有安全网就不该开始。
|
|
3424
|
+
const stager = await this.openUndoStager();
|
|
3425
|
+
if (stager === null) {
|
|
3426
|
+
pushDetail(detail, 'undo-staging-unavailable root=' + this.root + ' zeroWrites=true');
|
|
3427
|
+
return {
|
|
3428
|
+
state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
|
|
3429
|
+
restored: 0, failed: needed.length + removal.paths.length,
|
|
3430
|
+
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
const preCount = needed.length;
|
|
2367
3434
|
for (const item of needed) {
|
|
2368
3435
|
let bytes = null;
|
|
3436
|
+
let unreadable = null;
|
|
2369
3437
|
try {
|
|
2370
|
-
bytes = await
|
|
3438
|
+
bytes = await this.readFileBytes(item.file.path);
|
|
2371
3439
|
}
|
|
2372
|
-
catch {
|
|
2373
|
-
|
|
3440
|
+
catch (error) {
|
|
3441
|
+
// **只认 ENOENT = 当时确实不存在**。其它 errno(EACCES / EMFILE / EBUSY…)是
|
|
3442
|
+
// 「读不出来」而不是「没有」—— 折成 null 会让回滚阶段把这个**存在**的文件删掉
|
|
3443
|
+
// (UndoStager.stage(null) 的语义正是「回滚时删掉它」)。
|
|
3444
|
+
if (error.code !== 'ENOENT')
|
|
3445
|
+
unreadable = errorDetail(error);
|
|
3446
|
+
}
|
|
3447
|
+
if (unreadable !== null) {
|
|
3448
|
+
await stager.dispose();
|
|
3449
|
+
pushDetail(detail, 'preimage-unreadable ' + item.file.path + ' ' + unreadable + ' zeroWrites=true');
|
|
3450
|
+
return {
|
|
3451
|
+
state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
|
|
3452
|
+
restored: 0, failed: needed.length + removal.paths.length,
|
|
3453
|
+
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
3454
|
+
};
|
|
3455
|
+
}
|
|
3456
|
+
try {
|
|
3457
|
+
await stager.stage(item.file.path, bytes);
|
|
3458
|
+
}
|
|
3459
|
+
catch (error) {
|
|
3460
|
+
// 暂存写不下去(ENOSPC / EACCES):**零写入失败** —— 绝不带着不完整的前像开工,
|
|
3461
|
+
// 也绝不让异常穿出引擎(那会被路由兜成没有机器码的裸 500,且暂存目录无人清理)。
|
|
3462
|
+
await stager.dispose();
|
|
3463
|
+
pushDetail(detail, 'preimage-stage-failed ' + item.file.path + ' ' + errorDetail(error) + ' zeroWrites=true');
|
|
3464
|
+
return {
|
|
3465
|
+
state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
|
|
3466
|
+
restored: 0, failed: needed.length + removal.paths.length,
|
|
3467
|
+
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
3468
|
+
};
|
|
2374
3469
|
}
|
|
2375
|
-
pre.push({ path: item.file.path, existed: bytes !== null, bytes });
|
|
2376
3470
|
}
|
|
2377
3471
|
let restored = 0;
|
|
2378
3472
|
let writtenBytes = 0;
|
|
@@ -2392,7 +3486,7 @@ export class RollbackPointEngine {
|
|
|
2392
3486
|
if (failed === 0) {
|
|
2393
3487
|
for (const item of needed) {
|
|
2394
3488
|
try {
|
|
2395
|
-
const current = await
|
|
3489
|
+
const current = await this.readFileBytes(item.file.path);
|
|
2396
3490
|
if (sha256Hex(current) !== item.file.sha256)
|
|
2397
3491
|
mismatched.push(item.file.path);
|
|
2398
3492
|
}
|
|
@@ -2402,23 +3496,13 @@ export class RollbackPointEngine {
|
|
|
2402
3496
|
}
|
|
2403
3497
|
}
|
|
2404
3498
|
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
|
-
}
|
|
3499
|
+
const rollbackFailed = await stager.rollback((target, bytes) => this.writeFile(target, bytes));
|
|
3500
|
+
await stager.dispose();
|
|
2417
3501
|
for (const target of mismatched)
|
|
2418
3502
|
pushDetail(detail, 'verify-mismatch ' + target);
|
|
2419
3503
|
for (const line of rollbackFailed)
|
|
2420
3504
|
pushDetail(detail, 'rolled-back-failed ' + line);
|
|
2421
|
-
detail.push('restored=0 failed=' + String(failed + mismatched.length) + ' rolledBack=' + String(
|
|
3505
|
+
detail.push('restored=0 failed=' + String(failed + mismatched.length) + ' rolledBack=' + String(preCount - rollbackFailed.length) + ' unchanged=' + String(unchanged));
|
|
2422
3506
|
return {
|
|
2423
3507
|
state: 'workspace', status: 'failed',
|
|
2424
3508
|
reasonCode: mismatched.length > 0 ? 'verify-failed' : 'restore-failed',
|
|
@@ -2433,7 +3517,8 @@ export class RollbackPointEngine {
|
|
|
2433
3517
|
// 阶段 4(用户 2026-10-11 定案 A):删除「回退点之后新建」的路径。
|
|
2434
3518
|
// 顺序刻死:记录在案的路径**先写并复核通过**,再删新建的 —— 先删后写一旦写失败就回不去了。
|
|
2435
3519
|
// 删除同样可回滚:先读前像(内存)→ 删;任一失败就把已写的还原、把已删的写回。
|
|
2436
|
-
|
|
3520
|
+
// 待删文件的前像同样落盘(stage(path, bytes) 的语义就是「回滚时把它写回」)
|
|
3521
|
+
let destroyedCount = 0;
|
|
2437
3522
|
const removalFailures = [];
|
|
2438
3523
|
// 逐条记录**真的删掉**的路径(全量,不再切 20 条):它与预览的 workspace.removals 同源同序,
|
|
2439
3524
|
// 界面把两份清单当作同一件事(「预览说会删的 = 实际删的」)。
|
|
@@ -2447,7 +3532,8 @@ export class RollbackPointEngine {
|
|
|
2447
3532
|
removal.skipped.push(target);
|
|
2448
3533
|
continue;
|
|
2449
3534
|
}
|
|
2450
|
-
|
|
3535
|
+
await stager.stage(target, bytes);
|
|
3536
|
+
destroyedCount += 1;
|
|
2451
3537
|
await fs.rm(target, { force: true });
|
|
2452
3538
|
removed += 1;
|
|
2453
3539
|
removedPaths.push(target);
|
|
@@ -2457,32 +3543,14 @@ export class RollbackPointEngine {
|
|
|
2457
3543
|
}
|
|
2458
3544
|
}
|
|
2459
3545
|
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
|
-
}
|
|
3546
|
+
const rollbackFailed = await stager.rollback((target, bytes) => this.writeFile(target, bytes));
|
|
3547
|
+
await stager.dispose();
|
|
2480
3548
|
for (const line of removalFailures)
|
|
2481
3549
|
pushDetail(detail, 'created-remove-failed ' + line);
|
|
2482
3550
|
for (const line of rollbackFailed)
|
|
2483
3551
|
pushDetail(detail, 'rolled-back-failed ' + line);
|
|
2484
3552
|
detail.push('restored=0 removed=0 failed=' + String(removalFailures.length) +
|
|
2485
|
-
' rolledBack=' + String(
|
|
3553
|
+
' rolledBack=' + String(preCount + destroyedCount - rollbackFailed.length) +
|
|
2486
3554
|
' unchanged=' + String(unchanged));
|
|
2487
3555
|
return {
|
|
2488
3556
|
state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
|
|
@@ -2508,6 +3576,8 @@ export class RollbackPointEngine {
|
|
|
2508
3576
|
// 捕获不完整 ⇒ 一条都没删:如实回报原因(界面据此说「因为捕获不完整,一条都没动」)
|
|
2509
3577
|
if (removal.refused !== null)
|
|
2510
3578
|
segment.createdRefusedReason = removal.refused;
|
|
3579
|
+
// 前像字节用完即清(成功路径):留在磁盘上只会变成无人认领的残留
|
|
3580
|
+
await stager.dispose();
|
|
2511
3581
|
return segment;
|
|
2512
3582
|
}
|
|
2513
3583
|
/**
|
|
@@ -2589,24 +3659,86 @@ export class RollbackPointEngine {
|
|
|
2589
3659
|
if (!storage.available)
|
|
2590
3660
|
return { ok: false, code: 'storage-unavailable', detail: storage.detail, id };
|
|
2591
3661
|
return await withLedgerWriteLock(this.ledgerPath, async () => {
|
|
2592
|
-
const ledger = await this.
|
|
2593
|
-
|
|
2594
|
-
|
|
3662
|
+
const ledger = await this.readManifest();
|
|
3663
|
+
if (this.initError !== undefined) {
|
|
3664
|
+
return { ok: false, code: 'ledger-write-failed', detail: this.initError, id };
|
|
3665
|
+
}
|
|
3666
|
+
// 删除只看**清单元数据**(protected / kind 都在清单里)—— 一个记录文件都不读、一个载荷都不读
|
|
3667
|
+
const entry = ledger.entries.find((r) => r.id === id);
|
|
3668
|
+
if (entry === undefined)
|
|
2595
3669
|
return { ok: false, code: 'record-not-found', id };
|
|
2596
|
-
if (
|
|
2597
|
-
return { ok: false, code: 'protected-point', detail:
|
|
3670
|
+
if (entry.protected || entry.kind === 'guard') {
|
|
3671
|
+
return { ok: false, code: 'protected-point', detail: entry.kind, id };
|
|
2598
3672
|
}
|
|
2599
|
-
const kept = ledger.
|
|
3673
|
+
const kept = ledger.entries.filter((r) => r.id !== id);
|
|
2600
3674
|
try {
|
|
2601
|
-
await
|
|
3675
|
+
await this.ledger.writeManifest(kept);
|
|
2602
3676
|
}
|
|
2603
3677
|
catch (error) {
|
|
2604
3678
|
return { ok: false, code: 'ledger-write-failed', detail: errorDetail(error), id };
|
|
2605
3679
|
}
|
|
3680
|
+
await this.ledger.deleteRecords([id]);
|
|
3681
|
+
await this.ledger.prunePayloads(kept);
|
|
2606
3682
|
await this.gcObjects(kept);
|
|
2607
3683
|
return { ok: true, code: 'deleted', id };
|
|
2608
3684
|
});
|
|
2609
3685
|
}
|
|
3686
|
+
/**
|
|
3687
|
+
* 磁盘清理入口(引擎侧):删**全部**自动点(includeGuards=true 时连 guard 也删),
|
|
3688
|
+
* protected 点**永不删**(生产路径造不出来,只有测试能造)。随后回收无人引用的载荷与对象。
|
|
3689
|
+
*
|
|
3690
|
+
* 零写入拒绝路径照旧:存储不可用 / 清单读不出来(形状不符、迁移失败、迁移中)⇒ **抛带 code 的错误**,
|
|
3691
|
+
* 一个字节都不动、也绝不静默返回全 0(「什么都没删」与「读不出来所以没删」是两件事)。
|
|
3692
|
+
* 返回的 bytes = 本次真正删掉的字节(记录文件 + 载荷文件 + 对象文件)。
|
|
3693
|
+
*/
|
|
3694
|
+
async purgeLedger(options = {}) {
|
|
3695
|
+
const storage = await this.storageStatus();
|
|
3696
|
+
if (!storage.available) {
|
|
3697
|
+
throw new RollbackLedgerRefusedError('storage-unavailable', storage.detail ?? 'rollback storage unavailable');
|
|
3698
|
+
}
|
|
3699
|
+
return await withLedgerWriteLock(this.ledgerPath, async () => {
|
|
3700
|
+
const ledger = await this.readManifest();
|
|
3701
|
+
if (this.initError !== undefined)
|
|
3702
|
+
throw new RollbackLedgerRefusedError('ledger-unreadable', this.initError);
|
|
3703
|
+
if (ledger.error !== undefined)
|
|
3704
|
+
throw new RollbackLedgerRefusedError('ledger-unreadable', ledger.error);
|
|
3705
|
+
const includeGuards = options.includeGuards === true;
|
|
3706
|
+
const victims = ledger.entries.filter((entry) => !entry.protected && (includeGuards || entry.kind === 'auto'));
|
|
3707
|
+
if (victims.length === 0)
|
|
3708
|
+
return { removedRecords: 0, removedPayloads: 0, removedObjects: 0, bytes: 0 };
|
|
3709
|
+
const victimIds = new Set(victims.map((entry) => entry.id));
|
|
3710
|
+
const kept = ledger.entries.filter((entry) => !victimIds.has(entry.id));
|
|
3711
|
+
// 先落清单、再删文件:崩在中途也只会留下「清单已删、文件还在」的垃圾,不会留下指向空文件的清单
|
|
3712
|
+
await this.ledger.writeManifest(kept);
|
|
3713
|
+
const records = await this.ledger.deleteRecords([...victimIds]);
|
|
3714
|
+
const payloads = await this.ledger.prunePayloads(kept);
|
|
3715
|
+
const objects = await this.ledger.collectObjects(kept);
|
|
3716
|
+
if (objects.aborted === null) {
|
|
3717
|
+
await this.gcPathCache(new Set([...objects.referencedPaths].map(pathKey)));
|
|
3718
|
+
}
|
|
3719
|
+
else {
|
|
3720
|
+
this.warn('rollback purge: object gc skipped: ' + objects.aborted);
|
|
3721
|
+
}
|
|
3722
|
+
return {
|
|
3723
|
+
removedRecords: records.removed,
|
|
3724
|
+
removedPayloads: payloads.removed,
|
|
3725
|
+
removedObjects: objects.removed,
|
|
3726
|
+
bytes: records.bytes + payloads.bytes + objects.bytes,
|
|
3727
|
+
};
|
|
3728
|
+
});
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
/**
|
|
3732
|
+
* 台账清理的**显式拒绝**(形状不符 / 存储不可用):带机器可读 code,绝不静默返回全 0。
|
|
3733
|
+
* 路由层按 code 映射 HTTP(与既有 RollbackCode 冻结集同源)。
|
|
3734
|
+
*/
|
|
3735
|
+
export class RollbackLedgerRefusedError extends Error {
|
|
3736
|
+
code;
|
|
3737
|
+
constructor(code, detail) {
|
|
3738
|
+
super(detail);
|
|
3739
|
+
this.name = 'RollbackLedgerRefusedError';
|
|
3740
|
+
this.code = code;
|
|
3741
|
+
}
|
|
2610
3742
|
}
|
|
2611
3743
|
function uniqueStates(states) {
|
|
2612
3744
|
const out = [];
|
|
@@ -2665,6 +3797,12 @@ export function rollbackSessionBindingOf(session) {
|
|
|
2665
3797
|
}
|
|
2666
3798
|
const header = session.header;
|
|
2667
3799
|
const headerRecord = header !== null && typeof header === 'object' ? header : null;
|
|
3800
|
+
// 子代理会话**不自动留点**(见 RollbackCode 的 'subagent-session'):它的工作区与父会话相同、
|
|
3801
|
+
// 回合号是自己的,留点只挤占用户自己会话的预算。**只在字段逐字等于 'subagent' 时判定** ——
|
|
3802
|
+
// 读不到 origin(老宿主 / 形状不符)就不判,绝不因为读不到而少留点。
|
|
3803
|
+
if (headerRecord?.['origin'] === 'subagent') {
|
|
3804
|
+
return { ok: false, code: 'subagent-session', detail: 'origin=subagent session=' + id };
|
|
3805
|
+
}
|
|
2668
3806
|
const cwd = headerRecord?.['cwd'];
|
|
2669
3807
|
// t9:父链与「是否播种而来」只从**活会话头**读;读不到就不带字段(=不是继承子会话),绝不猜
|
|
2670
3808
|
const fork = {};
|
|
@@ -2792,6 +3930,8 @@ export class RollbackAutoTracker {
|
|
|
2792
3930
|
lastStat = new Map();
|
|
2793
3931
|
timer = null;
|
|
2794
3932
|
mechanism = 'event';
|
|
3933
|
+
/** 在途捕获的取消信号(stop() 时 abort;重新 start() 时换一个新的) */
|
|
3934
|
+
captureAbort = new AbortController();
|
|
2795
3935
|
constructor(options) {
|
|
2796
3936
|
this.engine = options.engine;
|
|
2797
3937
|
this.nowFn = options.now ?? (() => Date.now());
|
|
@@ -2831,6 +3971,8 @@ export class RollbackAutoTracker {
|
|
|
2831
3971
|
boundaryEndSeq: info.endSeq,
|
|
2832
3972
|
chunks: binding.chunks ?? [],
|
|
2833
3973
|
note: this.mechanism === 'poll' ? 'auto:poll' : 'auto:turn',
|
|
3974
|
+
// 卸载 / 停止 ⇒ 立刻中断在途捕获(下一个文件边界停下;已收条目照常成点)
|
|
3975
|
+
signal: this.captureAbort.signal,
|
|
2834
3976
|
});
|
|
2835
3977
|
// T10:写台账失败(ok=false)绝不算留点成功 —— 否则 tracker 会记住一个台账里不存在的 pointId,
|
|
2836
3978
|
// 后续 rewind/详情都查不到它。(capturePoint 失败时也不再回传 point,这条判据是第二道保险。)
|
|
@@ -2971,6 +4113,9 @@ export class RollbackAutoTracker {
|
|
|
2971
4113
|
start() {
|
|
2972
4114
|
if (this.timer !== null)
|
|
2973
4115
|
return;
|
|
4116
|
+
// 上一轮 stop() 已经把信号 abort 掉:重新 start 必须换一个新的,否则捕获一动就停。
|
|
4117
|
+
if (this.captureAbort.signal.aborted)
|
|
4118
|
+
this.captureAbort = new AbortController();
|
|
2974
4119
|
this.mechanism = 'poll';
|
|
2975
4120
|
this.timer = setInterval(() => {
|
|
2976
4121
|
void this.pollOnce().catch((error) => { this.warn('rollback tracker poll failed: ' + errorDetail(error)); });
|
|
@@ -2980,6 +4125,8 @@ export class RollbackAutoTracker {
|
|
|
2980
4125
|
handle.unref();
|
|
2981
4126
|
}
|
|
2982
4127
|
stop() {
|
|
4128
|
+
// 中断在途捕获:卸载之后不该再有任何一次「读工作区 + 写对象仓」跑完。
|
|
4129
|
+
this.captureAbort.abort();
|
|
2983
4130
|
if (this.timer === null)
|
|
2984
4131
|
return;
|
|
2985
4132
|
clearInterval(this.timer);
|