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
|
@@ -75,14 +75,40 @@ import { projectKeyOf } from './session-select.ts'
|
|
|
75
75
|
import { ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules, type IgnoreRules } from './rollback-ignore-rules.ts'
|
|
76
76
|
import { isSessionLogName } from '../utils/session-log.ts'
|
|
77
77
|
import { decodeZstdFrame, scanZstdFrames, zstdAvailable, ZSTD_MAGIC } from '../utils/zstd-frame.ts'
|
|
78
|
+
import {
|
|
79
|
+
cleanupOrphanTempFiles,
|
|
80
|
+
entrySummaryOf,
|
|
81
|
+
ROLLBACK_DEFAULT_LEDGER_BYTES,
|
|
82
|
+
ROLLBACK_UNDO_DIR_NAME,
|
|
83
|
+
RollbackLedger,
|
|
84
|
+
type RollbackLedgerManifestEntry,
|
|
85
|
+
type RollbackLedgerMigration,
|
|
86
|
+
type RollbackLedgerPrepared,
|
|
87
|
+
} from './rollback-ledger.ts'
|
|
78
88
|
|
|
79
89
|
/* ------------------------------------------------------------------ 常量 */
|
|
80
90
|
|
|
91
|
+
/** 增量链的最大长度:到顶就落一个**全量锚点**(重建最多读 1 份全量 + 几份小增量) */
|
|
92
|
+
const ROLLBACK_DELTA_CHAIN_LIMIT = 8
|
|
93
|
+
/** path → 基准摘要键(sha256/bytes/chunk 拼成,只用于判「这条与基准逐条相同」) */
|
|
94
|
+
type RollbackDeltaDigest = { hash: string; chain: string[]; chunks: string[]; entries: Map<string, string> }
|
|
95
|
+
/** 增量视图只有在小于全量的这个比例时才采用(否则直接全量,简单可靠) */
|
|
96
|
+
const ROLLBACK_DELTA_MAX_RATIO = 0.6
|
|
97
|
+
|
|
81
98
|
export const ROLLBACK_SCHEMA_VERSION = 2
|
|
82
99
|
/** 回滚点库目录名(dataDir/rollback-points)——路由族 /recovery/rollback-points/* 同源 */
|
|
83
100
|
export const ROLLBACK_POINTS_DIR_NAME = 'rollback-points'
|
|
101
|
+
/**
|
|
102
|
+
* 台账**清单**文件名(v3,issue #89):它只是一份小清单(元数据 + 段摘要),
|
|
103
|
+
* 完整记录在 records/<id>.json、工作区载荷在 payloads/<sha256>.json —— 清单里绝不含大数组。
|
|
104
|
+
*/
|
|
84
105
|
export const ROLLBACK_LEDGER_NAME = 'index.json'
|
|
85
106
|
export const ROLLBACK_OBJECTS_DIR_NAME = 'objects'
|
|
107
|
+
/**
|
|
108
|
+
* 台账字节预算(records/ + payloads/)的缺省值,可由 RollbackEngineOptions.ledgerBytesBudget 覆盖。
|
|
109
|
+
* 为什么需要它:ROLLBACK_MAX_RECORDS(50)只限**条数**;本机实测 50 条 = 473 MB 直接把宿主撑爆。
|
|
110
|
+
*/
|
|
111
|
+
export { ROLLBACK_DEFAULT_LEDGER_BYTES }
|
|
86
112
|
/** 全局自动点上限(S6);protected / guard 点不占预算、只进不出 */
|
|
87
113
|
export const ROLLBACK_MAX_RECORDS = 50
|
|
88
114
|
/** 每会话自动点上限(S6) */
|
|
@@ -166,6 +192,64 @@ export const ROLLBACK_DEFAULT_SESSION_QUIESCENT_MS = 30_000
|
|
|
166
192
|
export const ROLLBACK_DEFAULT_POLL_INTERVAL_MS = 5_000
|
|
167
193
|
export const ROLLBACK_LEFTOVER_LIST_LIMIT = 20
|
|
168
194
|
|
|
195
|
+
/**
|
|
196
|
+
* 工作区捕获的**挂钟时间预算**(缺省 30 s)。
|
|
197
|
+
*
|
|
198
|
+
* 为什么需要(对标 dsh-turn-rewind 的 `deadline.ts`、dsh-checkpoint-rewind 至今未合并的 PR #25):
|
|
199
|
+
* 捕获路径此前只有「文件数 / 总字节 / 单文件字节」三个**规模**上限,没有**时间**上限 ——
|
|
200
|
+
* 一个「文件数很多但总量不大」的工作区能让 walk + stat + sha256 跑很久。
|
|
201
|
+
* 超预算**不是失败**:已收条目照常成点(ok=true),未走查的部分按 `budget-time` 逐条留痕、
|
|
202
|
+
* `truncated` 置真 ⇒ 删除集自动 fail-closed(证明名单不完整就一条都不删)。
|
|
203
|
+
*/
|
|
204
|
+
export const ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS = 30_000
|
|
205
|
+
/**
|
|
206
|
+
* 自适应预算区间(见 RollbackEngineOptions.captureBudgetMs):上一次留点被截断 ⇒ 下次 ×2,
|
|
207
|
+
* 上一次只用掉不到 1/4 ⇒ 下次 ÷2。区间外不再调整(避免无限拉长 / 缩到不可用)。
|
|
208
|
+
*/
|
|
209
|
+
export const ROLLBACK_MIN_CAPTURE_BUDGET_MS = 10_000
|
|
210
|
+
export const ROLLBACK_MAX_CAPTURE_BUDGET_MS = 120_000
|
|
211
|
+
/**
|
|
212
|
+
* 捕获期「读内容 + 写对象仓」的并发上限。
|
|
213
|
+
*
|
|
214
|
+
* 为什么必须并发:真机实测(34k 文件 / 512 MiB)一次全量捕获 175 s,其中 **148 s 花在
|
|
215
|
+
* 31,702 次对象写**(一次 4.7 ms:temp + fsync + rename),而走查本身只要 0.66 s。
|
|
216
|
+
* 对象写入是纯 I/O,串行做等于把预算全花在等待上 ⇒ 覆盖永远只有一小半。
|
|
217
|
+
* 并发上限与**在飞字节闸门**共同限流(在飞内容上界 = `captureInFlightBytes`)。
|
|
218
|
+
*/
|
|
219
|
+
export const ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY = 16
|
|
220
|
+
/**
|
|
221
|
+
* 捕获期**读入内存、尚未落对象仓**的内容字节上限(真正的内存闸门)。
|
|
222
|
+
*
|
|
223
|
+
* 为什么必须独立于 `maxFileBytes`(0.1.74 真机闪退事故,2026-10-16):
|
|
224
|
+
* `maxFileBytes` 的缺省是**总量预算** `maxBytes`(512 MiB)。并发捕获把闸门接到它上面,
|
|
225
|
+
* 于是「同时在飞的内容」可以到 ~512 MiB —— 串行实现的上界是「一份文件」,并发实现的
|
|
226
|
+
* 上界是「闸门限额」,两者差着两三个数量级。真机表现:发出消息后 1–2 s(= `turn/start`
|
|
227
|
+
* 开始留点)DSH Desktop 宿主被系统终止 ⇒ 桌面闪退(无 JS 栈、插件零错误、
|
|
228
|
+
* 退出码是「被外部终止」形状)。这里取一个小常量当闸门;要放宽只能显式传
|
|
229
|
+
* `captureInFlightBytes`,且被 `maxBytes` 夹住。
|
|
230
|
+
*/
|
|
231
|
+
export const ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES = 32 * 1024 * 1024
|
|
232
|
+
/** 对象写遇到这些码视为「瞬时占用」,有界退避重试(判据同 atomic-write 的 isRetryableLockCode) */
|
|
233
|
+
const OBJECT_WRITE_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY']
|
|
234
|
+
/** 捕获阶段①的 stat 批大小:判定仍严格按顺序,只是 stat 并发取(真机 34k 次 stat 从 5 s → <1 s) */
|
|
235
|
+
export const ROLLBACK_CAPTURE_STAT_BATCH = 128
|
|
236
|
+
/**
|
|
237
|
+
* 捕获期**候选名单**上限(不是入点上限):必须显著大于 maxFiles,否则「缺口优先」排序作用不到
|
|
238
|
+
* 那些排在遍历顺序尾部、上一回合没覆盖到的文件(它们连候选都进不了 ⇒ 覆盖率永远不收敛)。
|
|
239
|
+
* 走查本来就会把每条路径记进 walked(路径索引的来源),这里多留的只是候选数组本身。
|
|
240
|
+
*/
|
|
241
|
+
export const ROLLBACK_CAPTURE_LIST_LIMIT = 200_000
|
|
242
|
+
/** 捕获阶段①的 stat 结果(一次 bigint stat 拿到大小 / mtime / 身份;失败时带原始 detail) */
|
|
243
|
+
interface RollbackFileStat {
|
|
244
|
+
isFile: boolean
|
|
245
|
+
size: number
|
|
246
|
+
mtimeMs: number
|
|
247
|
+
identity: string | null
|
|
248
|
+
error?: string
|
|
249
|
+
}
|
|
250
|
+
/** 走查中断时逐条记下的「没进去的子树」上限;超出即放弃精确范围,退回全局 fail-closed */
|
|
251
|
+
export const ROLLBACK_UNWALKED_DIRS_LIMIT = 200
|
|
252
|
+
|
|
169
253
|
/**
|
|
170
254
|
* 「删除回退点之后新建的路径」的**整套上限**(用户 2026-10-11 定案 A)。
|
|
171
255
|
* 超过就整套拒绝(fail-closed),绝不删一半 —— 半套删除既没有可解释的状态,也无法整体回滚。
|
|
@@ -198,6 +282,14 @@ export const ROLLBACK_PREVIEW_DIFF_TOTAL_WORK = 32_000_000
|
|
|
198
282
|
export const ROLLBACK_FILE_DIFF_LIMITS = {
|
|
199
283
|
maxFileBytes: ROLLBACK_PREVIEW_DIFF_MAX_FILE_BYTES,
|
|
200
284
|
context: 3,
|
|
285
|
+
/**
|
|
286
|
+
* Myers 编辑距离上限(2026-10-15 用户:不该动辄出现「已按整块替换展示」)。
|
|
287
|
+
* 实测(本仓 rollback-point.ts 的 4983 行 × 2492 行版本对):D 在 3000~4000 就能拿到
|
|
288
|
+
* 最小编辑脚本 —— 耗时 ≤70 ms;代价是回溯的 V 快照 O(D²),RSS 峰值 +~100 MB。
|
|
289
|
+
* 缺省那档 400 会让几千行里的普通改动一律退化成「整块替换」,那正是用户看到的那条提示;
|
|
290
|
+
* 单文件是**按需点开**的一屏,这点内存换「真差异」划算。再大就换算法,不加预算。
|
|
291
|
+
*/
|
|
292
|
+
maxD: 4000,
|
|
201
293
|
} as const
|
|
202
294
|
|
|
203
295
|
/** 恢复顺序(工作区在前;工作区失败即中止后面的会话段) */
|
|
@@ -289,6 +381,13 @@ export type RollbackCode =
|
|
|
289
381
|
| 'session-out-of-root'
|
|
290
382
|
| 'session-too-large'
|
|
291
383
|
| 'session-active'
|
|
384
|
+
/**
|
|
385
|
+
* 子代理会话(`header.origin === 'subagent'`):**不自动留点**。
|
|
386
|
+
* 它的工作区与父会话相同、回合号是自己的,留点既与父会话重复又只挤占预算;
|
|
387
|
+
* 用户在侧栏看到的是嵌套在父对话下的子代理,不需要单独回退。
|
|
388
|
+
* 读不到 `origin`(老宿主)时**不加判定** —— 绝不因为读不到就少留点。
|
|
389
|
+
*/
|
|
390
|
+
| 'subagent-session'
|
|
292
391
|
| 'session-fork-unavailable'
|
|
293
392
|
| 'fork-failed'
|
|
294
393
|
| 'capture-failed'
|
|
@@ -323,7 +422,8 @@ export interface RollbackSkippedEntry {
|
|
|
323
422
|
/**
|
|
324
423
|
* link(符号链接/junction,不跟随)/ missing / not-a-file / self-store /
|
|
325
424
|
* dep-dir(运行时依赖目录,整棵忽略)/ ignored(用户忽略规则命中)/ too-large(超单文件上限)/
|
|
326
|
-
* budget-bytes(加它会超总量预算)/ budget-files
|
|
425
|
+
* budget-bytes(加它会超总量预算)/ budget-files(文件数预算用尽)/
|
|
426
|
+
* asar-container(含 `.asar` 段的路径:宿主会把它当容器解析、畸形夹具能带走宿主,因此绝不碰)
|
|
327
427
|
*/
|
|
328
428
|
reason: string
|
|
329
429
|
}
|
|
@@ -347,7 +447,28 @@ export interface RollbackPathIndex {
|
|
|
347
447
|
export interface RollbackWorkspacePayload {
|
|
348
448
|
/** 显式路径分块:恢复只在这些分块内覆盖 */
|
|
349
449
|
chunks: string[]
|
|
450
|
+
/**
|
|
451
|
+
* 记录在案的**文件条目数**。**必须自带**(不能再用 `files.length`):增量视图下 `files` 只是
|
|
452
|
+
* 「相对基准新增/改写的子集」,而摘要(界面上的条数 / 可恢复判定)要的是全量条数。
|
|
453
|
+
*/
|
|
454
|
+
fileCount?: number
|
|
350
455
|
files: RollbackWorkspaceFile[]
|
|
456
|
+
/**
|
|
457
|
+
* **存盘专用**:这份载荷是「相对某个基准载荷的增量」(见 rollback-ledger 的 resolvePayload)。
|
|
458
|
+
* 只在写盘那一刻存在 —— 任何读路径(readRecord / 预览 / 回退 / 对象回收)拿到的都是**物化后**
|
|
459
|
+
* 的完整 `files`(`delta` 已被抹掉)。**绝不把带 delta 的对象交给消费者**:它看起来像一份
|
|
460
|
+
* 「只有这几条记录」的完整清单,拿去规划删除会删掉其它全部文件。
|
|
461
|
+
*/
|
|
462
|
+
delta?: {
|
|
463
|
+
/** 基准载荷的内容寻址哈希(必须在 prune/回收时一并保活) */
|
|
464
|
+
base: string
|
|
465
|
+
/** 基准自己的依赖链(深→浅,含基准),保活用 */
|
|
466
|
+
baseChain: string[]
|
|
467
|
+
/** 相对基准**不再存在**的路径(逐字,与捕获产出的绝对路径同形) */
|
|
468
|
+
removed: string[]
|
|
469
|
+
/** 相对基准**新增 / 改写**的条目(`files` 在增量视图下为 null) */
|
|
470
|
+
added?: RollbackWorkspaceFile[]
|
|
471
|
+
}
|
|
351
472
|
skipped: RollbackSkippedEntry[]
|
|
352
473
|
/**
|
|
353
474
|
* 整棵跳过的「运行时依赖目录」(忽略规则 = 全仓唯一来源 EXCLUDED_DEP_DIR_NAMES,当前 node_modules)。
|
|
@@ -376,6 +497,22 @@ export interface RollbackWorkspacePayload {
|
|
|
376
497
|
* 文件级忽略**不**置它:被忽略的文件既进 skipped、也进 walked(路径索引来源)⇒ 名单照样完整。
|
|
377
498
|
*/
|
|
378
499
|
ignoreIncomplete?: boolean
|
|
500
|
+
/**
|
|
501
|
+
* true = 走查被**时间预算 / 取消信号**中断 ⇒ 剩余子树**一条都没进任何名单**
|
|
502
|
+
* (不在 files / skipped / walked 里)⇒ 证明名单**不完整** ⇒ 删除集整体拒绝(fail-closed)。
|
|
503
|
+
*
|
|
504
|
+
* 与 `truncated` 的**关键区别**:`truncated` 的三种成因(文件数 / 总字节 / 单文件超限)都是
|
|
505
|
+
* 「继续遍历 + 逐条留痕」,名单依然完整,所以**不**拒删;只有「整棵子树根本没被走查」才必须拒删
|
|
506
|
+
* —— 否则那些文件会被判成「留点后才新建」而被删掉(数据丢失)。
|
|
507
|
+
* 老台账(本字段诞生前写下的点)一律没有它:按「名单完整」处理(与旧行为一致)。
|
|
508
|
+
*/
|
|
509
|
+
walkIncomplete?: boolean
|
|
510
|
+
/**
|
|
511
|
+
* 走查被中断时**栈里还没进去的子树**(精确到目录)。有它 ⇒ 拒删只覆盖这些子树
|
|
512
|
+
* (分子树 fail-closed),而不是把整个工作区的删除全关掉;缺它(老台账 / 栈太深被截断)
|
|
513
|
+
* ⇒ 退回全局拒删。与 walkIncomplete 一起看:后者是「安全判据」,本字段是「精确范围」。
|
|
514
|
+
*/
|
|
515
|
+
unwalkedDirs?: string[]
|
|
379
516
|
/** 命中文件数 / 字节上限(内容未全部进来,已如实留痕) */
|
|
380
517
|
truncated: boolean
|
|
381
518
|
bytes: number
|
|
@@ -497,12 +634,34 @@ export interface RollbackPointSummary {
|
|
|
497
634
|
inheritedSessionId?: string | null
|
|
498
635
|
}
|
|
499
636
|
|
|
637
|
+
/**
|
|
638
|
+
* 台账(records/ + payloads/)的可观测快照。
|
|
639
|
+
* 路由层转发给界面时必须**照抄字段名与形状**(issue #89 的交付契约)。
|
|
640
|
+
*/
|
|
641
|
+
export interface RollbackLedgerStatus {
|
|
642
|
+
/** 清单里的记录条数 */
|
|
643
|
+
records: number
|
|
644
|
+
/** records/ + payloads/ 当前总字节 */
|
|
645
|
+
bytes: number
|
|
646
|
+
/** 生效的字节预算(缺省 ROLLBACK_DEFAULT_LEDGER_BYTES) */
|
|
647
|
+
budgetBytes: number
|
|
648
|
+
/** 本进程内被**预算**淘汰的自动点累计数(条数淘汰不计入) */
|
|
649
|
+
evicted: number
|
|
650
|
+
/**
|
|
651
|
+
* 当前磁盘字节**已超**预算、且已无处可淘汰(典型:单条点本身就大于预算)。
|
|
652
|
+
* 界面据此说「台账已超预算、无法再自动回收」,绝不静默成「一切正常」。
|
|
653
|
+
*/
|
|
654
|
+
overBudget: boolean
|
|
655
|
+
}
|
|
656
|
+
|
|
500
657
|
export interface RollbackStorageStatus {
|
|
501
658
|
available: boolean
|
|
502
659
|
root: string
|
|
503
660
|
detail?: string
|
|
504
661
|
guidance: RollbackGuidance[]
|
|
505
662
|
requirements: readonly string[]
|
|
663
|
+
/** 新增,可选(不破坏既有构造点);存储栈不可用时缺省 */
|
|
664
|
+
ledger?: RollbackLedgerStatus
|
|
506
665
|
}
|
|
507
666
|
|
|
508
667
|
/** 捕获一个回滚点的输入(宿主/测试构造;无 HTTP capture 端点) */
|
|
@@ -523,6 +682,11 @@ export interface RollbackPointInput {
|
|
|
523
682
|
chunks?: readonly string[]
|
|
524
683
|
note?: string | null
|
|
525
684
|
protect?: boolean
|
|
685
|
+
/**
|
|
686
|
+
* 调用方的取消信号(插件卸载 / 会话销毁):已中止时捕获在**下一个文件边界**停下,
|
|
687
|
+
* 已收条目照常成点(部分点 + truncated)。缺省不传 = 只受时间预算约束。
|
|
688
|
+
*/
|
|
689
|
+
signal?: AbortSignal
|
|
526
690
|
}
|
|
527
691
|
|
|
528
692
|
export interface RollbackCaptureResult {
|
|
@@ -861,6 +1025,33 @@ export interface RollbackEngineOptions {
|
|
|
861
1025
|
maxGuardsPerSession?: number
|
|
862
1026
|
/** **全局** guard 点上限(缺省 50,见 ROLLBACK_MAX_GUARDS;与自动点的 maxRecords 无关) */
|
|
863
1027
|
maxGuards?: number
|
|
1028
|
+
/**
|
|
1029
|
+
* 台账字节预算(records/ + payloads/ 的总字节,缺省 ROLLBACK_DEFAULT_LEDGER_BYTES)。
|
|
1030
|
+
* 超预算时**只淘汰最旧的 auto 点**(guard / protected 不淘汰,沿用 selectPrune 的类别口径)。
|
|
1031
|
+
*/
|
|
1032
|
+
ledgerBytesBudget?: number
|
|
1033
|
+
/**
|
|
1034
|
+
* 工作区捕获的挂钟时间预算(毫秒,缺省 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。
|
|
1035
|
+
* 超时留**部分点**,不拒绝留点;见该常量的说明。
|
|
1036
|
+
*
|
|
1037
|
+
* 运行期**自适应**:被截断 ⇒ 下次翻倍;用得很少 ⇒ 下次减半(区间见
|
|
1038
|
+
* ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS)。回退期走查的预算见 rewindBudgetMs。
|
|
1039
|
+
*/
|
|
1040
|
+
captureBudgetMs?: number
|
|
1041
|
+
/** 自适应区间下界(缺省 ROLLBACK_MIN_CAPTURE_BUDGET_MS;测试可注入更小值) */
|
|
1042
|
+
captureBudgetMinMs?: number
|
|
1043
|
+
/** 自适应区间上界(缺省 ROLLBACK_MAX_CAPTURE_BUDGET_MS;测试可注入更小值) */
|
|
1044
|
+
captureBudgetMaxMs?: number
|
|
1045
|
+
/** 回退期走查的时间预算(缺省 = captureBudgetMs 初值;**不参与自适应**) */
|
|
1046
|
+
rewindBudgetMs?: number
|
|
1047
|
+
/** 捕获期读 + 写并发上限(缺省 ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY,1..64) */
|
|
1048
|
+
captureConcurrency?: number
|
|
1049
|
+
/**
|
|
1050
|
+
* 捕获期**在飞内容字节**上限(缺省 ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES = 32 MiB,
|
|
1051
|
+
* 有效范围 1..maxBytes)。与 `maxFileBytes`(单文件「要不要读」的判据)是两件事:
|
|
1052
|
+
* **这个才是并发的内存闸门**。
|
|
1053
|
+
*/
|
|
1054
|
+
captureInFlightBytes?: number
|
|
864
1055
|
guardPolicy?: RollbackGuardPolicy
|
|
865
1056
|
/** 存储栈探针(缺省 = 真实磁盘探测);注入失败探针可测「缺失时仍可挂载」 */
|
|
866
1057
|
storage?: RollbackStorageProbe
|
|
@@ -873,6 +1064,12 @@ export interface RollbackEngineOptions {
|
|
|
873
1064
|
* 测试用它计数「真的读了几次」,从而钉住路径身份缓存命中时**零重读**。
|
|
874
1065
|
*/
|
|
875
1066
|
readFile?: (target: string) => Promise<Uint8Array>
|
|
1067
|
+
/**
|
|
1068
|
+
* 台账读盘观测 seam(缺省不观测):每次「读一条记录文件 / 一份载荷文件」前回调一次。
|
|
1069
|
+
* 与 readFile seam 同一用途 —— 测试用它**确定性地**计数「一次留点到底读了几份记录」,
|
|
1070
|
+
* 而不是靠无法复现的堆采样(issue #89 的内存护栏)。
|
|
1071
|
+
*/
|
|
1072
|
+
ledgerReadFile?: (file: string) => void
|
|
876
1073
|
/** E1:预览行级统计的规模上限(缺省见 ROLLBACK_PREVIEW_DIFF_*);注入小值即可确定性地覆盖 truncated 分支 */
|
|
877
1074
|
previewDiffMaxFileBytes?: number
|
|
878
1075
|
previewDiffMaxTotalBytes?: number
|
|
@@ -888,6 +1085,24 @@ function win32Fold(value: string): string {
|
|
|
888
1085
|
return process.platform === 'win32' ? value.toLowerCase() : value
|
|
889
1086
|
}
|
|
890
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* 路径里是否存在以 `.asar` 结尾的段(**捕获 / 走查期一律不碰这类路径**)。
|
|
1090
|
+
*
|
|
1091
|
+
* 为什么(真机事故,0.1.76):Electron 宿主对任何含 `.asar` 段的路径**启用了 asar 虚拟化** ——
|
|
1092
|
+
* 即使那只是个普通文件,`fs.stat`/`readdir`/`read` 也会被接管去当容器解析。工作区里只要躺着一枚
|
|
1093
|
+
* **畸形** `.asar`(测试夹具、被打断的打包残留),宿主进程就会被原生层带走:crashpad
|
|
1094
|
+
* `not connected`、退出码 4294930435 ⇒ DSH Desktop 闪退(无 JS 栈、插件零错误)。
|
|
1095
|
+
* 实测(本仓真机捕获、同一份输入):`.tmp` 下 19 枚 `.asar` 夹具在场 → Electron 进程死;
|
|
1096
|
+
* 全部改名后 → 同一趟捕获 9 s 跑完、退出码 0。
|
|
1097
|
+
* `.asar.unpacked` 是**真目录**(段名不以 `.asar` 结尾)⇒ 不拦。
|
|
1098
|
+
*/
|
|
1099
|
+
export function hasAsarSegment(target: string): boolean {
|
|
1100
|
+
for (const segment of target.split(/[\\/]+/)) {
|
|
1101
|
+
if (segment.length > '.asar'.length && win32Fold(segment).endsWith('.asar')) return true
|
|
1102
|
+
}
|
|
1103
|
+
return false
|
|
1104
|
+
}
|
|
1105
|
+
|
|
891
1106
|
/** child 是否等于 parent 或位于其下(win32 折叠大小写;段边界判定) */
|
|
892
1107
|
export function isInsidePath(child: string, parent: string): boolean {
|
|
893
1108
|
const c = win32Fold(path.resolve(child))
|
|
@@ -900,6 +1115,87 @@ function pathKey(value: string): string {
|
|
|
900
1115
|
return win32Fold(path.resolve(value))
|
|
901
1116
|
}
|
|
902
1117
|
|
|
1118
|
+
/** 两个分块列表是否逐个相同(增量基准必须作用于同一批分块) */
|
|
1119
|
+
function sameChunks(a: readonly string[], b: readonly string[]): boolean {
|
|
1120
|
+
if (a.length !== b.length) return false
|
|
1121
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
|
|
1122
|
+
return true
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* **存盘视图**:条目里的 `object` 恒等于 `sha256`(内容寻址的名字就是内容哈希)⇒ 落盘时不写这个字段,
|
|
1127
|
+
* 读取时按 `sha256` 补回(见 ledger 的 `withObjectRefs`)。每条约省 1/5 字节。
|
|
1128
|
+
*/
|
|
1129
|
+
function compactPayloadForStorage(payload: RollbackWorkspacePayload): RollbackWorkspacePayload {
|
|
1130
|
+
const strip = (list: RollbackWorkspaceFile[] | null | undefined): RollbackWorkspaceFile[] | null | undefined => {
|
|
1131
|
+
if (!Array.isArray(list)) return list
|
|
1132
|
+
return list.map((file) => {
|
|
1133
|
+
const copy: Record<string, unknown> = { ...file }
|
|
1134
|
+
delete copy['object']
|
|
1135
|
+
return copy as unknown as RollbackWorkspaceFile
|
|
1136
|
+
})
|
|
1137
|
+
}
|
|
1138
|
+
const delta = payload.delta
|
|
1139
|
+
return {
|
|
1140
|
+
...payload,
|
|
1141
|
+
files: strip(payload.files) as RollbackWorkspaceFile[],
|
|
1142
|
+
...(delta === undefined
|
|
1143
|
+
? {}
|
|
1144
|
+
: { delta: { ...delta, ...(delta.added === undefined ? {} : { added: strip(delta.added) as RollbackWorkspaceFile[] }) } }),
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* 构造「相对基准的增量视图」;不划算(≥ 全量 × {@link ROLLBACK_DELTA_MAX_RATIO})时返回 null。
|
|
1150
|
+
*
|
|
1151
|
+
* 条目**逐字比较**:路径、sha、字节数、mtimeMs、所属分块任一不同 ⇒ 进 `added`;基准里不再存在的路径进 `removed`。
|
|
1152
|
+
* 重建 = 基准 − removed,再用 added 覆盖/追加,最后按路径排序(与全量捕获同序)。
|
|
1153
|
+
*/
|
|
1154
|
+
/** 基准摘要键:内容 + 字节数 + 所属分块(`mtimeMs` 不参与 —— 只被触摸、内容没变的文件不必进增量) */
|
|
1155
|
+
function digestKeyOf(file: RollbackWorkspaceFile): string {
|
|
1156
|
+
return String(file.sha256) + ':' + String(file.bytes) + ':' + String(file.chunk)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** 相对**内存基准摘要**的条目差异(新增/改写 / 不再存在);路径逐字比较,不读任何文件 */
|
|
1160
|
+
function payloadDiffAgainstDigest(
|
|
1161
|
+
payload: RollbackWorkspacePayload,
|
|
1162
|
+
baseEntries: ReadonlyMap<string, string>,
|
|
1163
|
+
): { added: RollbackWorkspaceFile[]; removed: string[] } {
|
|
1164
|
+
const added: RollbackWorkspaceFile[] = []
|
|
1165
|
+
const nextPaths = new Set<string>()
|
|
1166
|
+
for (const file of payload.files) {
|
|
1167
|
+
nextPaths.add(file.path)
|
|
1168
|
+
const prev = baseEntries.get(file.path)
|
|
1169
|
+
if (prev === undefined || prev !== digestKeyOf(file)) added.push(file)
|
|
1170
|
+
}
|
|
1171
|
+
const removed: string[] = []
|
|
1172
|
+
for (const path of baseEntries.keys()) if (!nextPaths.has(path)) removed.push(path)
|
|
1173
|
+
return { added, removed }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* 构造「相对基准的增量视图」;不划算(≥ 全量 × {@link ROLLBACK_DELTA_MAX_RATIO})时返回 null。
|
|
1179
|
+
* 重建 = 基准 − removed,再用 added 覆盖/追加,最后按路径排序(与全量捕获同序)。
|
|
1180
|
+
*/
|
|
1181
|
+
function buildPayloadDelta(
|
|
1182
|
+
payload: RollbackWorkspacePayload,
|
|
1183
|
+
baseHash: string,
|
|
1184
|
+
baseChain: readonly string[],
|
|
1185
|
+
diff: { added: RollbackWorkspaceFile[]; removed: string[] },
|
|
1186
|
+
): RollbackWorkspacePayload | null {
|
|
1187
|
+
if (baseHash === '') return null
|
|
1188
|
+
const delta: RollbackWorkspacePayload = {
|
|
1189
|
+
...payload,
|
|
1190
|
+
// **故意不是数组**:不认识 `delta` 的旧读取器会在形状校验这一步**拒绝这个点**(fail-closed),
|
|
1191
|
+
// 而不是把「增量那几条」当成完整清单去规划删除 —— 那会把留点时存在的文件全判成新建、全部删掉。
|
|
1192
|
+
files: null as unknown as RollbackWorkspaceFile[],
|
|
1193
|
+
delta: { base: baseHash, baseChain: [...baseChain], removed: diff.removed, added: diff.added },
|
|
1194
|
+
}
|
|
1195
|
+
if (JSON.stringify(delta).length >= JSON.stringify(payload).length * ROLLBACK_DELTA_MAX_RATIO) return null
|
|
1196
|
+
return delta
|
|
1197
|
+
}
|
|
1198
|
+
|
|
903
1199
|
function errorDetail(error: unknown): string {
|
|
904
1200
|
if (error instanceof Error) {
|
|
905
1201
|
const code = (error as { code?: unknown }).code
|
|
@@ -1134,6 +1430,16 @@ export function resolveTurnBoundary(
|
|
|
1134
1430
|
return { ok: true, boundarySeq: boundary.boundarySeq, endSeq: boundary.endSeq }
|
|
1135
1431
|
}
|
|
1136
1432
|
|
|
1433
|
+
/**
|
|
1434
|
+
* 记录在案的文件条目数:优先用载荷自带的 `fileCount`(增量视图下 `files` 可能只是子集),
|
|
1435
|
+
* 老载荷没有该字段才回落到 `files.length`。
|
|
1436
|
+
*/
|
|
1437
|
+
export function workspaceFileCount(payload: RollbackWorkspacePayload | null): number {
|
|
1438
|
+
if (payload === null) return 0
|
|
1439
|
+
if (typeof payload.fileCount === 'number' && Number.isFinite(payload.fileCount)) return payload.fileCount
|
|
1440
|
+
return payload.files.length
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1137
1443
|
export function summarizeRollbackPoint(record: RollbackPointRecord): RollbackPointSummary {
|
|
1138
1444
|
const workspace = record.workspace.payload
|
|
1139
1445
|
const session = record.session.payload
|
|
@@ -1173,7 +1479,16 @@ export function summarizeRollbackPoint(record: RollbackPointRecord): RollbackPoi
|
|
|
1173
1479
|
note: record.note,
|
|
1174
1480
|
spreadMs: record.spreadMs,
|
|
1175
1481
|
segments: [
|
|
1176
|
-
segment(
|
|
1482
|
+
segment(
|
|
1483
|
+
'workspace',
|
|
1484
|
+
record.workspace,
|
|
1485
|
+
workspaceFileCount(workspace),
|
|
1486
|
+
workspace?.bytes ?? 0,
|
|
1487
|
+
null,
|
|
1488
|
+
workspaceFileCount(workspace) > 0,
|
|
1489
|
+
workspace?.skippedCount ?? 0,
|
|
1490
|
+
workspace?.truncated ?? false,
|
|
1491
|
+
),
|
|
1177
1492
|
segment(
|
|
1178
1493
|
'session',
|
|
1179
1494
|
record.session,
|
|
@@ -1305,6 +1620,116 @@ interface RewindDraft {
|
|
|
1305
1620
|
warnings?: RollbackGuidance[]
|
|
1306
1621
|
}
|
|
1307
1622
|
|
|
1623
|
+
/**
|
|
1624
|
+
* 回退前像暂存器:**字节落盘、索引随栈**。
|
|
1625
|
+
*
|
|
1626
|
+
* 为什么不是内存数组(旧实现):大工作区整体回退时前像峰值 = 待写总字节(可达数百 MB),
|
|
1627
|
+
* 与 issue #89 的 OOM 同族。落盘后内存只随**文件条数**增长。
|
|
1628
|
+
*
|
|
1629
|
+
* 生命周期:`stage()` 逐条写前像 → 成功则 `dispose()`;中途失败则 `rollback()` 写回再 `dispose()`。
|
|
1630
|
+
* 目录名带 pid,崩溃残留由 rollback-ledger 的 `cleanupOrphanTempFiles` 在下次启动回收。
|
|
1631
|
+
*/
|
|
1632
|
+
class UndoStager {
|
|
1633
|
+
private readonly entries: { path: string; file: string | null }[] = []
|
|
1634
|
+
private readonly dir: string
|
|
1635
|
+
private seq = 0
|
|
1636
|
+
|
|
1637
|
+
constructor(dir: string) {
|
|
1638
|
+
this.dir = dir
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/** 记一条前像:`bytes === null` = 该路径当时**不存在**(回滚时删掉它)。 */
|
|
1642
|
+
async stage(target: string, bytes: Uint8Array | null): Promise<void> {
|
|
1643
|
+
if (bytes === null) {
|
|
1644
|
+
this.entries.push({ path: target, file: null })
|
|
1645
|
+
return
|
|
1646
|
+
}
|
|
1647
|
+
const file = path.join(this.dir, String(this.seq) + '.bin')
|
|
1648
|
+
this.seq += 1
|
|
1649
|
+
await fs.writeFile(file, bytes)
|
|
1650
|
+
this.entries.push({ path: target, file })
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* 逐条写回(**逆序**:同一路径被 stage 两次时,最后一次 stage 记的是最早状态,必须最后写)。
|
|
1655
|
+
* 返回失败清单 —— 回滚失败绝不静默。
|
|
1656
|
+
*/
|
|
1657
|
+
async rollback(writer: (target: string, bytes: Uint8Array) => Promise<void>): Promise<string[]> {
|
|
1658
|
+
const failed: string[] = []
|
|
1659
|
+
for (const entry of [...this.entries].reverse()) {
|
|
1660
|
+
try {
|
|
1661
|
+
if (entry.file === null) await fs.rm(entry.path, { force: true })
|
|
1662
|
+
else await writer(entry.path, await fs.readFile(entry.file))
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
failed.push(entry.path + ' ' + errorDetail(error))
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return failed
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/** 前像目录用完即清(best-effort:清理失败不该把回退报成失败)。 */
|
|
1671
|
+
async dispose(): Promise<void> {
|
|
1672
|
+
try {
|
|
1673
|
+
await fs.rm(this.dir, { recursive: true, force: true })
|
|
1674
|
+
} catch {
|
|
1675
|
+
/* best-effort */
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* 单文件字节闸门:保证「同时在飞(已读入、尚未落仓)的内容字节」≤ limit。
|
|
1682
|
+
*
|
|
1683
|
+
* 与 captureConcurrency 一起构成捕获期的内存上界:并发只放大**同时**驻留的量,而它被
|
|
1684
|
+
* `captureInFlightBytes` 钉死(缺省 32 MiB)。**不能**用 maxFileBytes 当限额 —— 它缺省 =
|
|
1685
|
+
* 总量预算 512 MiB,「一份文件」与「整个点的预算」不是同一个量级(0.1.74 闪退根因)。
|
|
1686
|
+
*/
|
|
1687
|
+
/**
|
|
1688
|
+
* 路径身份键:dev:ino:size:mtimeNs:ctimeNs:mode(全 BigInt 字符串化)。必须 BigInt:ino / mtimeNs /
|
|
1689
|
+
* ctimeNs 在 64 位 inode 文件系统上超出 Number 安全整数范围,用 Number 会静默丢精度(把两次不同的
|
|
1690
|
+
* 写当成同一个身份)。单一来源:fileStatOf 与 pathIdentityOf 都用它。
|
|
1691
|
+
*/
|
|
1692
|
+
function fileIdentityOf(stat: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint; mode: bigint }): string {
|
|
1693
|
+
return [
|
|
1694
|
+
String(stat.dev),
|
|
1695
|
+
String(stat.ino),
|
|
1696
|
+
String(stat.size),
|
|
1697
|
+
String(stat.mtimeNs),
|
|
1698
|
+
String(stat.ctimeNs),
|
|
1699
|
+
String(stat.mode & 0o777n),
|
|
1700
|
+
].join(':')
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** 预览的逐条判定(阶段 A 并发产出;阶段 B 按记录顺序装配,语义与旧串行实现逐条一致) */
|
|
1704
|
+
type PreviewScanResult =
|
|
1705
|
+
| { kind: 'ok'; size: number; regular: boolean; unchanged: boolean }
|
|
1706
|
+
| { kind: 'missing' }
|
|
1707
|
+
| { kind: 'unavailable' }
|
|
1708
|
+
|
|
1709
|
+
class ByteGate {
|
|
1710
|
+
private used = 0
|
|
1711
|
+
private readonly waiters: (() => void)[] = []
|
|
1712
|
+
private readonly limit: number
|
|
1713
|
+
|
|
1714
|
+
constructor(limit: number) {
|
|
1715
|
+
this.limit = limit
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
async acquire(bytes: number): Promise<void> {
|
|
1719
|
+
const need = Math.max(0, Math.min(bytes, this.limit))
|
|
1720
|
+
while (this.used + need > this.limit && this.used > 0) {
|
|
1721
|
+
await new Promise<void>((resolve) => { this.waiters.push(resolve) })
|
|
1722
|
+
}
|
|
1723
|
+
this.used += need
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
release(bytes: number): void {
|
|
1727
|
+
this.used = Math.max(0, this.used - Math.max(0, Math.min(bytes, this.limit)))
|
|
1728
|
+
const next = this.waiters.shift()
|
|
1729
|
+
if (next !== undefined) next()
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1308
1733
|
export class RollbackPointEngine {
|
|
1309
1734
|
private readonly dataDir: string
|
|
1310
1735
|
private readonly homeDir: string
|
|
@@ -1316,6 +1741,7 @@ export class RollbackPointEngine {
|
|
|
1316
1741
|
private readonly maxFiles: number
|
|
1317
1742
|
private readonly maxBytes: number
|
|
1318
1743
|
private readonly maxFileBytes: number
|
|
1744
|
+
private readonly captureInFlightBytes: number
|
|
1319
1745
|
private readonly maxSkippedEntries: number
|
|
1320
1746
|
private readonly sessionMaxBytes: number
|
|
1321
1747
|
private readonly sessionQuiescentMs: number
|
|
@@ -1347,9 +1773,50 @@ export class RollbackPointEngine {
|
|
|
1347
1773
|
private forkLinksAt = 0
|
|
1348
1774
|
private forkLinksComplete = true
|
|
1349
1775
|
/** 会话日志头缓存(mtime + size 失效;只服务 fork 链扫描) */
|
|
1776
|
+
/**
|
|
1777
|
+
* **本进程**最近一次成功留点的工作区摘要(清单增量化的基准)。
|
|
1778
|
+
*
|
|
1779
|
+
* 为什么是内存而不是读盘:一次留点只准读清单(L-08 / issue #89)—— 读上一份 12 MB 载荷去算差异
|
|
1780
|
+
* 正是那条被消灭的路。内存量 ≈ 每文件一个 `sha:bytes:chunk` 串(3.4 万文件约几 MB),
|
|
1781
|
+
* 与已有的身份缓存(`pathCache`)同量级;进程重启后第一次留点落全量锚点,随后继续链。
|
|
1782
|
+
*/
|
|
1783
|
+
private deltaBase: RollbackDeltaDigest | null = null
|
|
1784
|
+
|
|
1350
1785
|
private readonly forkHeaderCache = new Map<string, { mtimeMs: number; size: number; header: SessionForkHeader | null }>()
|
|
1351
|
-
/** 继承判据用的台账快照(TTL
|
|
1352
|
-
private ledgerSnapshotCache: { at: number;
|
|
1786
|
+
/** 继承判据用的台账快照(TTL 缓存;只缓存**清单条目**,绝不缓存完整记录) */
|
|
1787
|
+
private ledgerSnapshotCache: { at: number; entries: RollbackLedgerManifestEntry[] } | null = null
|
|
1788
|
+
/** 台账存储层(v3:清单 + 单条记录 + 内容寻址载荷;issue #89) */
|
|
1789
|
+
private readonly ledger: RollbackLedger
|
|
1790
|
+
private readonly ledgerBytesBudget: number
|
|
1791
|
+
/**
|
|
1792
|
+
* 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
|
|
1793
|
+
* 每次留点后按「是否被截断 / 用掉多少」自适应(见 ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS)。
|
|
1794
|
+
*/
|
|
1795
|
+
private captureBudgetMs: number
|
|
1796
|
+
/** 自适应区间下界 / 上界(构造期定死,运行期只调 captureBudgetMs) */
|
|
1797
|
+
private readonly captureBudgetMinMs: number
|
|
1798
|
+
private readonly captureBudgetMaxMs: number
|
|
1799
|
+
/** 回退期走查的时间预算(与捕获预算分开:自适应不该顺手拉长回退期的走查) */
|
|
1800
|
+
private readonly rewindBudgetMs: number
|
|
1801
|
+
/** 捕获期读 + 写并发上限 */
|
|
1802
|
+
private readonly captureConcurrency: number
|
|
1803
|
+
/** 对象仓目录已确认存在(省掉每个对象一次 mkdir) */
|
|
1804
|
+
private objectsDirReady = false
|
|
1805
|
+
/**
|
|
1806
|
+
* 本回合对象名单快照(一次 readdir 顶掉「每个候选一次 fs.access」;真机 34k 次 access ≈ 3.7 s)。
|
|
1807
|
+
* null = 读不到名单(首次还没建目录/异常)⇒ 回落逐条 objectExists。
|
|
1808
|
+
* 只保证「写进去的立刻可见」:捕获期间被**并发删除**的对象可能仍被判为存在 —— 那一路由
|
|
1809
|
+
* 读取侧(readObject + 哈希校验)响亮失败,不会静默给出错误的 before 内容。
|
|
1810
|
+
*/
|
|
1811
|
+
private captureObjectNames: Set<string> | null = null
|
|
1812
|
+
/**
|
|
1813
|
+
* 启动期一次性初始化(流式迁移 v2 -> v3 + 孤儿 tmp 清理)的惰性单飞。
|
|
1814
|
+
* 为什么惰取而不是构造时同步跑:构造发生在每个请求路径上,同步扫盘/迁移会把请求卡死;
|
|
1815
|
+
* 而「每回合都扫一遍」正是要避免的浪费 —— 所以只跑一次并记住结果。
|
|
1816
|
+
*/
|
|
1817
|
+
private initPromise: Promise<void> | null = null
|
|
1818
|
+
/** 启动期初始化失败的如实记录(读路径必须报出来,绝不假装台账是空的) */
|
|
1819
|
+
private initError: string | undefined
|
|
1353
1820
|
|
|
1354
1821
|
constructor(options: RollbackEngineOptions) {
|
|
1355
1822
|
this.dataDir = path.resolve(options.dataDir)
|
|
@@ -1363,6 +1830,10 @@ export class RollbackPointEngine {
|
|
|
1363
1830
|
this.maxBytes = options.maxBytes ?? ROLLBACK_DEFAULT_MAX_BYTES
|
|
1364
1831
|
// 单文件上限缺省 = 总量预算(一个文件不可能比整个点的预算更大)
|
|
1365
1832
|
this.maxFileBytes = options.maxFileBytes ?? this.maxBytes
|
|
1833
|
+
// **内存闸门与单文件上限分开**:拿 maxFileBytes 当闸门 = 允许把整个点的预算同时驻留
|
|
1834
|
+
// (0.1.74 真机闪退的根因),见 ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES。
|
|
1835
|
+
const inFlight = options.captureInFlightBytes ?? ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES
|
|
1836
|
+
this.captureInFlightBytes = Math.max(1, Math.min(this.maxBytes, Math.floor(inFlight)))
|
|
1366
1837
|
this.maxSkippedEntries = options.maxSkippedEntries ?? ROLLBACK_SKIPPED_LIST_LIMIT
|
|
1367
1838
|
this.sessionMaxBytes = options.sessionMaxBytes ?? ROLLBACK_DEFAULT_SESSION_MAX_BYTES
|
|
1368
1839
|
this.sessionQuiescentMs = options.sessionQuiescentMs ?? ROLLBACK_DEFAULT_SESSION_QUIESCENT_MS
|
|
@@ -1380,6 +1851,22 @@ export class RollbackPointEngine {
|
|
|
1380
1851
|
this.previewDiffMaxWork = options.previewDiffMaxWork ?? ROLLBACK_PREVIEW_DIFF_MAX_WORK
|
|
1381
1852
|
this.previewDiffTotalWork = options.previewDiffTotalWork ?? ROLLBACK_PREVIEW_DIFF_TOTAL_WORK
|
|
1382
1853
|
this.log = options.log
|
|
1854
|
+
this.ledgerBytesBudget = options.ledgerBytesBudget ?? ROLLBACK_DEFAULT_LEDGER_BYTES
|
|
1855
|
+
this.captureBudgetMs = options.captureBudgetMs ?? ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS
|
|
1856
|
+
this.captureBudgetMinMs = options.captureBudgetMinMs ?? ROLLBACK_MIN_CAPTURE_BUDGET_MS
|
|
1857
|
+
this.captureBudgetMaxMs = options.captureBudgetMaxMs ?? ROLLBACK_MAX_CAPTURE_BUDGET_MS
|
|
1858
|
+
this.rewindBudgetMs = options.rewindBudgetMs ?? this.captureBudgetMs
|
|
1859
|
+
this.captureConcurrency = Math.max(1, Math.min(64, Math.floor(options.captureConcurrency ?? ROLLBACK_DEFAULT_CAPTURE_CONCURRENCY)))
|
|
1860
|
+
this.ledger = new RollbackLedger({
|
|
1861
|
+
root: this.root,
|
|
1862
|
+
bytesBudget: this.ledgerBytesBudget,
|
|
1863
|
+
// 摘要与记录校验的单一事实源都在本模块:注入而不是反向 import,避免运行期循环依赖
|
|
1864
|
+
summarize: summarizeRollbackPoint,
|
|
1865
|
+
isLedgerRecord: isRollbackPointRecord,
|
|
1866
|
+
now: () => this.stamp(),
|
|
1867
|
+
log: (line) => this.warn(line),
|
|
1868
|
+
onFileRead: options.ledgerReadFile,
|
|
1869
|
+
})
|
|
1383
1870
|
}
|
|
1384
1871
|
|
|
1385
1872
|
/** 回滚点库根目录(只读侦察用;写路径全部经本类的门) */
|
|
@@ -1426,6 +1913,22 @@ export class RollbackPointEngine {
|
|
|
1426
1913
|
else sink.skippedTruncated = true
|
|
1427
1914
|
}
|
|
1428
1915
|
|
|
1916
|
+
/**
|
|
1917
|
+
* 打开一次回退的前像暂存目录(`<root>/undo/<pid>-<ts36>-<8hex>/`)。
|
|
1918
|
+
* 返回 null = 建不出来(权限 / 磁盘满)⇒ 调用方必须**零写入失败**。
|
|
1919
|
+
*/
|
|
1920
|
+
private async openUndoStager(): Promise<UndoStager | null> {
|
|
1921
|
+
const name = String(process.pid) + '-' + this.stamp().toString(36) + '-' + crypto.randomBytes(4).toString('hex')
|
|
1922
|
+
const dir = path.join(this.root, ROLLBACK_UNDO_DIR_NAME, name)
|
|
1923
|
+
try {
|
|
1924
|
+
await fs.mkdir(dir, { recursive: true })
|
|
1925
|
+
} catch (error) {
|
|
1926
|
+
this.warn('rollback undo staging unavailable: ' + errorDetail(error))
|
|
1927
|
+
return null
|
|
1928
|
+
}
|
|
1929
|
+
return new UndoStager(dir)
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1429
1932
|
private async writeFile(target: string, bytes: Uint8Array): Promise<void> {
|
|
1430
1933
|
if (this.writeSeam !== undefined) {
|
|
1431
1934
|
await this.writeSeam(target, bytes)
|
|
@@ -1436,8 +1939,48 @@ export class RollbackPointEngine {
|
|
|
1436
1939
|
|
|
1437
1940
|
/* ---------------------------------------------------------- 存储栈探针 */
|
|
1438
1941
|
|
|
1942
|
+
/**
|
|
1943
|
+
* 启动期一次性初始化(惰性单飞):先**流式**迁移 v2 单文件 -> v3,再清理崩溃残留的原子写 tmp。
|
|
1944
|
+
*
|
|
1945
|
+
* 为什么惰取而不是在构造里同步跑:引擎在每个请求路径上都会被新建(路由层不缓存引擎),
|
|
1946
|
+
* 构造里同步扫盘/迁移会把请求卡死;而「每回合扫一遍」又是纯浪费 —— 所以只跑一次并记住结果。
|
|
1947
|
+
* 迁移失败**不抛**:如实记进 initError,由读路径报出来,并**拒写**(绝不覆盖还没迁完的 v2 文件)。
|
|
1948
|
+
*/
|
|
1949
|
+
private async ensureReady(): Promise<void> {
|
|
1950
|
+
if (this.initPromise === null) this.initPromise = this.runInit()
|
|
1951
|
+
await this.initPromise
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
private async runInit(): Promise<void> {
|
|
1955
|
+
let migration: RollbackLedgerMigration
|
|
1956
|
+
try {
|
|
1957
|
+
migration = await this.ledger.migrateIfNeeded()
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
this.initError = 'ledger unreadable: ' + errorDetail(error)
|
|
1960
|
+
return
|
|
1961
|
+
}
|
|
1962
|
+
if (migration.error !== undefined) {
|
|
1963
|
+
this.initError = migration.error
|
|
1964
|
+
this.warn('rollback ledger migration failed: ' + migration.error)
|
|
1965
|
+
return
|
|
1966
|
+
}
|
|
1967
|
+
if (migration.migrated) {
|
|
1968
|
+
this.warn('rollback ledger migrated: records=' + String(migration.records) + ' skipped=' + String(migration.skipped))
|
|
1969
|
+
}
|
|
1970
|
+
// 迁移**之后**再清残留:连本次迁移自己的 tmp 一并收掉(只放行可自证形状的崩溃残留)
|
|
1971
|
+
try {
|
|
1972
|
+
const cleaned = await cleanupOrphanTempFiles(this.root)
|
|
1973
|
+
if (cleaned.removed > 0) {
|
|
1974
|
+
this.warn('rollback temp cleanup: removed=' + String(cleaned.removed) + ' bytes=' + String(cleaned.bytes) + ' skipped=' + String(cleaned.skipped))
|
|
1975
|
+
}
|
|
1976
|
+
} catch (error) {
|
|
1977
|
+
this.warn('rollback temp cleanup failed: ' + errorDetail(error))
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1439
1981
|
async storageStatus(): Promise<RollbackStorageStatus> {
|
|
1440
1982
|
const base = { root: this.root, requirements: ROLLBACK_STORAGE_REQUIREMENTS }
|
|
1983
|
+
await this.ensureReady()
|
|
1441
1984
|
let result: { ok: true } | { ok: false; detail: string }
|
|
1442
1985
|
if (this.probe !== undefined) {
|
|
1443
1986
|
try {
|
|
@@ -1448,23 +1991,83 @@ export class RollbackPointEngine {
|
|
|
1448
1991
|
} else {
|
|
1449
1992
|
result = await defaultStorageProbe(this.root)
|
|
1450
1993
|
}
|
|
1451
|
-
if (result.ok)
|
|
1452
|
-
|
|
1994
|
+
if (!result.ok) {
|
|
1995
|
+
return { ...base, available: false, detail: result.detail, guidance: storageGuidance(this.root, result.detail) }
|
|
1996
|
+
}
|
|
1997
|
+
const out: RollbackStorageStatus = { ...base, available: true, guidance: [] }
|
|
1998
|
+
const ledger = await this.ledgerStatus()
|
|
1999
|
+
if (ledger !== undefined) out.ledger = ledger
|
|
2000
|
+
if (this.initError !== undefined) {
|
|
2001
|
+
// 磁盘本身没问题,但台账读不出来(迁移失败 / 迁到一半被别人占着):如实上屏,不谎报可用
|
|
2002
|
+
out.detail = this.initError
|
|
2003
|
+
out.guidance = storageGuidance(this.root, this.initError)
|
|
2004
|
+
}
|
|
2005
|
+
return out
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/** 台账可观测快照(清单条数 + 磁盘字节 + 预算 + 本进程淘汰计数) */
|
|
2009
|
+
private async ledgerStatus(): Promise<RollbackLedgerStatus | undefined> {
|
|
2010
|
+
try {
|
|
2011
|
+
const manifest = await this.ledger.readManifest()
|
|
2012
|
+
const stats = await this.ledger.stats()
|
|
2013
|
+
return {
|
|
2014
|
+
records: manifest.entries.length,
|
|
2015
|
+
bytes: stats.bytes,
|
|
2016
|
+
budgetBytes: this.ledgerBytesBudget,
|
|
2017
|
+
evicted: this.ledger.evictedCount,
|
|
2018
|
+
// 每次 append 都会尽力淘汰到预算内 ⇒「bytes > budget」本身就等于「淘汰到极限了」
|
|
2019
|
+
// (单条点大于预算时恒为 true)。这里不额外读盘,只用已有 stats。
|
|
2020
|
+
overBudget: stats.bytes > this.ledgerBytesBudget,
|
|
2021
|
+
}
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
this.warn('rollback ledger status failed: ' + errorDetail(error))
|
|
2024
|
+
return undefined
|
|
2025
|
+
}
|
|
1453
2026
|
}
|
|
1454
2027
|
|
|
1455
2028
|
/* -------------------------------------------------------------- 对象仓 */
|
|
1456
2029
|
|
|
2030
|
+
/** 对象仓目录只 mkdir 一次(每个对象各 mkdir 一次是纯白花的 syscall) */
|
|
2031
|
+
private async ensureObjectsDir(): Promise<void> {
|
|
2032
|
+
if (this.objectsDirReady) return
|
|
2033
|
+
await fs.mkdir(this.objectsDir, { recursive: true })
|
|
2034
|
+
this.objectsDirReady = true
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
/**
|
|
2038
|
+
* 写内容寻址对象(对象文件名 = 内容 SHA-256)。
|
|
2039
|
+
*
|
|
2040
|
+
* **为什么这里不用 atomicWriteFile**:那是给「可写配置文件」用的强原子写(同目录 tmp → fsync →
|
|
2041
|
+
* rename),真机实测一次约 4.7 ms —— 34k 文件的工作区一次全量捕获 148 s 全花在它上面(走查本身
|
|
2042
|
+
* 只要 0.66 s)。对象仓的正确性要求不同:文件名即内容哈希 ⇒
|
|
2043
|
+
* · 用 'wx' **独占创建最终名**本身已是原子的(不覆盖已有对象;并发同写只有一方成功);
|
|
2044
|
+
* · 半截 / 被篡改的内容在读取侧必然过不了哈希校验(readObject + sha256Hex)⇒ 按「读不出」
|
|
2045
|
+
* 处理并 fail-closed,绝不会冒充有效前像;
|
|
2046
|
+
* · 不做 fsync:回滚点是尽力而为的本地快照,不为掉电持久性付出每文件一次 fsync 的代价。
|
|
2047
|
+
*/
|
|
1457
2048
|
private async writeObjectWithHash(bytes: Uint8Array, sha: string): Promise<string> {
|
|
1458
2049
|
if (!OBJECT_KEY_RE.test(sha)) throw new Error('rollback object key invalid')
|
|
1459
2050
|
const target = path.join(this.objectsDir, sha)
|
|
1460
2051
|
if (!isInsidePath(target, this.objectsDir)) throw new Error('rollback object path escapes store')
|
|
1461
|
-
await
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2052
|
+
await this.ensureObjectsDir()
|
|
2053
|
+
let delay = 10
|
|
2054
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
2055
|
+
try {
|
|
2056
|
+
await fs.writeFile(target, bytes, { flag: 'wx' })
|
|
2057
|
+
this.captureObjectNames?.add(sha)
|
|
2058
|
+
return sha
|
|
2059
|
+
} catch (error) {
|
|
2060
|
+
const code = (error as { code?: unknown }).code
|
|
2061
|
+
if (code === 'EEXIST') return sha // 已有同内容对象(去重 / 并发竞写)
|
|
2062
|
+
// 杀软 / 句柄瞬时占用:有界退避重试(与 atomic-write 的判据同族)
|
|
2063
|
+
if (OBJECT_WRITE_RETRY_CODES.includes(code as string) && attempt < 3) {
|
|
2064
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delay))
|
|
2065
|
+
delay *= 2
|
|
2066
|
+
continue
|
|
2067
|
+
}
|
|
2068
|
+
throw error
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
1468
2071
|
}
|
|
1469
2072
|
|
|
1470
2073
|
/** 对象仓里是否真有这个 blob(缓存命中时校验;任何异常一律按「没有」处理) */
|
|
@@ -1583,6 +2186,10 @@ export class RollbackPointEngine {
|
|
|
1583
2186
|
/**
|
|
1584
2187
|
* 列出一个显式分块内的候选文件(不跟随符号链接 / junction;跳过与读不到都留痕)。
|
|
1585
2188
|
* 返回 null = 该分块本身不可用(不存在 / 是链接 / 非常规文件),细节已写进 sink。
|
|
2189
|
+
*
|
|
2190
|
+
* `truncated` 与 `walkAborted` 是**两件事**:前者只说「本次列表不完整」(文件数上限 / 提交上限),
|
|
2191
|
+
* 后者说「走查被预算或取消打断,栈里还有没进去的子树」—— 只有后者才意味着那些路径
|
|
2192
|
+
* **一条都没进任何名单**,调用方必须据此让删除集 fail-closed。
|
|
1586
2193
|
*/
|
|
1587
2194
|
private async listChunkFiles(
|
|
1588
2195
|
chunk: string,
|
|
@@ -1594,7 +2201,18 @@ export class RollbackPointEngine {
|
|
|
1594
2201
|
*/
|
|
1595
2202
|
collect?: string[],
|
|
1596
2203
|
ignore?: IgnoreRules,
|
|
1597
|
-
|
|
2204
|
+
/**
|
|
2205
|
+
* 停止判据(捕获期传时间/取消预算;回退期的走查不传)—— 返回 true 立即停止遍历,
|
|
2206
|
+
* 已走查的部分照常返回并置 truncated + walkAborted(栈里剩余的子树从未进门 ⇒ 由调用方据此 fail-closed;
|
|
2207
|
+
* 回退期不传它,故 walkAborted 恒 false)。
|
|
2208
|
+
*/
|
|
2209
|
+
shouldStop?: () => boolean,
|
|
2210
|
+
): Promise<{ files: string[]; truncated: boolean; walkAborted: boolean; unwalkedDirs: string[] } | null> {
|
|
2211
|
+
// 分块根本身就是 `.asar` 段 ⇒ 一次 fs 调用都不发(见 hasAsarSegment)
|
|
2212
|
+
if (hasAsarSegment(chunk)) {
|
|
2213
|
+
this.recordSkip(sink, 'skipped', { path: chunk, reason: 'asar-container' })
|
|
2214
|
+
return null
|
|
2215
|
+
}
|
|
1598
2216
|
let stat
|
|
1599
2217
|
try {
|
|
1600
2218
|
stat = await fs.lstat(chunk)
|
|
@@ -1606,15 +2224,25 @@ export class RollbackPointEngine {
|
|
|
1606
2224
|
this.recordSkip(sink, 'skipped', { path: chunk, reason: 'link' })
|
|
1607
2225
|
return null
|
|
1608
2226
|
}
|
|
1609
|
-
if (stat.isFile()) return { files: [chunk], truncated: false }
|
|
2227
|
+
if (stat.isFile()) return { files: [chunk], truncated: false, walkAborted: false, unwalkedDirs: [] }
|
|
1610
2228
|
if (!stat.isDirectory()) {
|
|
1611
2229
|
this.recordSkip(sink, 'skipped', { path: chunk, reason: 'not-a-file' })
|
|
1612
2230
|
return null
|
|
1613
2231
|
}
|
|
1614
2232
|
const out: string[] = []
|
|
1615
2233
|
let truncated = false
|
|
2234
|
+
// 走查**被预算 / 取消打断**:栈里还压着没进去的子树 —— 那些路径一条都没进任何名单
|
|
2235
|
+
let walkAborted = false
|
|
2236
|
+
// 中断时把**还没进去的子树**如实带出去 ⇒ 调用方可以只对这几棵子树 fail-closed(精确范围)
|
|
2237
|
+
let unwalkedDirs: string[] = []
|
|
1616
2238
|
const stack: { dir: string; depth: number }[] = [{ dir: chunk, depth: 0 }]
|
|
1617
2239
|
while (stack.length > 0) {
|
|
2240
|
+
if (shouldStop?.() === true) {
|
|
2241
|
+
truncated = true
|
|
2242
|
+
walkAborted = true
|
|
2243
|
+
unwalkedDirs = stack.length > ROLLBACK_UNWALKED_DIRS_LIMIT ? [] : stack.map((item) => item.dir)
|
|
2244
|
+
break
|
|
2245
|
+
}
|
|
1618
2246
|
const item = stack.pop()
|
|
1619
2247
|
if (item === undefined) break
|
|
1620
2248
|
if (item.depth >= MAX_WALK_DEPTH) {
|
|
@@ -1630,14 +2258,27 @@ export class RollbackPointEngine {
|
|
|
1630
2258
|
}
|
|
1631
2259
|
for (const entry of entries) {
|
|
1632
2260
|
const full = path.join(item.dir, entry.name)
|
|
2261
|
+
// **绝不把含 `.asar` 段的路径交给 fs**(见 hasAsarSegment:宿主里这一步是原生解析,
|
|
2262
|
+
// 畸形夹具会让整个宿主进程消失)。留痕进 skipped —— 它是**证明名单**的一员,
|
|
2263
|
+
// 因此该路径绝不会被当成「留点后新建」删掉。
|
|
2264
|
+
if (hasAsarSegment(full)) {
|
|
2265
|
+
this.recordSkip(sink, 'skipped', { path: full, reason: 'asar-container' })
|
|
2266
|
+
continue
|
|
2267
|
+
}
|
|
1633
2268
|
if (entry.isSymbolicLink()) {
|
|
1634
2269
|
this.recordSkip(sink, 'skipped', { path: full, reason: 'link' })
|
|
1635
2270
|
continue
|
|
1636
2271
|
}
|
|
1637
2272
|
if (entry.isDirectory()) {
|
|
1638
|
-
|
|
2273
|
+
// `.git` 与运行时依赖目录**同档**:整棵不进快照。两条理由 ——
|
|
2274
|
+
// ① 回退写回 .git(index/refs/logs)会让 git 状态错乱(提交看似消失、可能留 index.lock),
|
|
2275
|
+
// 而删除侧本来就把 .git 当拒删目录 ⇒ 排除它才让两侧口径一致;
|
|
2276
|
+
// ② .git 常常几百 MB,会**挤掉 maxBytes 预算里的源码文件**。
|
|
2277
|
+
// 大小写折半比较:Windows 上 .GIT/.Git 是同一个目录。
|
|
2278
|
+
const dirName = win32Fold(entry.name)
|
|
2279
|
+
if (EXCLUDED_DEP_DIR_NAMES.includes(entry.name) || dirName === '.git') {
|
|
1639
2280
|
// 内置忽略规则(S4「沿用现有 IGNORE 规则」):整棵不进快照,但留痕(不静默)
|
|
1640
|
-
this.recordSkip(sink, 'excluded', { path: full, reason: 'dep-dir' })
|
|
2281
|
+
this.recordSkip(sink, 'excluded', { path: full, reason: dirName === '.git' ? 'git-dir' : 'dep-dir' })
|
|
1641
2282
|
continue
|
|
1642
2283
|
}
|
|
1643
2284
|
if (ignore !== undefined && this.ignoredByRules(ignore, full, true)) {
|
|
@@ -1671,12 +2312,22 @@ export class RollbackPointEngine {
|
|
|
1671
2312
|
}
|
|
1672
2313
|
}
|
|
1673
2314
|
out.sort()
|
|
1674
|
-
return { files: out, truncated }
|
|
2315
|
+
return { files: out, truncated, walkAborted, unwalkedDirs }
|
|
1675
2316
|
}
|
|
1676
2317
|
|
|
1677
|
-
private async captureWorkspace(
|
|
2318
|
+
private async captureWorkspace(
|
|
2319
|
+
chunks: readonly string[],
|
|
2320
|
+
signal?: AbortSignal,
|
|
2321
|
+
): Promise<RollbackTrack<RollbackWorkspacePayload>> {
|
|
1678
2322
|
const capturedAt = this.stamp()
|
|
1679
2323
|
if (chunks.length === 0) return this.emptyTrack('workspace', capturedAt, 'no-chunks', 'chunks empty')
|
|
2324
|
+
// 时间预算(见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS):用 stamp()(单调)比较,
|
|
2325
|
+
// 系统时钟回拨也不会让预算永不触发。超时置 payload.truncated ⇒ 结尾的 reasonCode
|
|
2326
|
+
// 自动落成 capture-truncated,UI 走既有的 degraded 提示路径(零新增文案)。
|
|
2327
|
+
const deadline = capturedAt + this.captureBudgetMs
|
|
2328
|
+
const outOfBudget = (): boolean => signal?.aborted === true || this.stamp() >= deadline
|
|
2329
|
+
// 对象名单快照按回合重建(一次 readdir 换掉每个候选一次 fs.access)
|
|
2330
|
+
this.captureObjectNames = null
|
|
1680
2331
|
const payload: RollbackWorkspacePayload = {
|
|
1681
2332
|
chunks: [...chunks],
|
|
1682
2333
|
files: [],
|
|
@@ -1696,98 +2347,58 @@ export class RollbackPointEngine {
|
|
|
1696
2347
|
let sawChunk = false
|
|
1697
2348
|
// 走查过的**全部**文件路径(含被预算 / 单文件上限挡下的)—— 路径索引的来源(方向 B)
|
|
1698
2349
|
const walked: string[] = []
|
|
2350
|
+
// 走查中断时**没进去的子树**(精确到目录)—— 供删除集做分子树 fail-closed
|
|
2351
|
+
const unwalked: string[] = []
|
|
1699
2352
|
for (const chunk of chunks) {
|
|
2353
|
+
if (outOfBudget()) {
|
|
2354
|
+
// 分块之间就超预算:不再开新分块,并如实留痕(该分块的条目一条都没被走查)
|
|
2355
|
+
// walkIncomplete 是**安全判据**(删除集 fail-closed);这里能精确给出「没进去的是哪个分块」
|
|
2356
|
+
// ⇒ 删除只拒这棵子树,不再把整个工作区的新建文件一起放弃。
|
|
2357
|
+
payload.walkIncomplete = true
|
|
2358
|
+
payload.truncated = true
|
|
2359
|
+
unwalked.push(chunk)
|
|
2360
|
+
this.recordSkip(payload, 'skipped', { path: chunk, reason: 'budget-time' })
|
|
2361
|
+
break
|
|
2362
|
+
}
|
|
1700
2363
|
if (!path.isAbsolute(chunk)) return this.emptyTrack('workspace', capturedAt, 'chunk-not-absolute', chunk)
|
|
1701
2364
|
const resolved = path.resolve(chunk)
|
|
1702
2365
|
// 文件数预算还剩多少就收多少;+1 只用于让遍历器发现「还有溢出」——溢出条目逐条记 skipped
|
|
1703
|
-
|
|
2366
|
+
// 候选名单上限取 ROLLBACK_CAPTURE_LIST_LIMIT(而不是 maxFiles):入点上限由阶段①的 slots
|
|
2367
|
+
// 把关,这里若提前截断,缺口优先排序就够不到尾部文件 ⇒ 覆盖率不收敛
|
|
2368
|
+
const listed = await this.listChunkFiles(resolved, payload, ROLLBACK_CAPTURE_LIST_LIMIT, walked, await loadIgnoreRules(resolved), outOfBudget)
|
|
1704
2369
|
if (listed === null) continue
|
|
1705
2370
|
sawChunk = true
|
|
1706
2371
|
if (listed.truncated) payload.truncated = true
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
}
|
|
1714
|
-
if (isInsidePath(file, this.root)) {
|
|
1715
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'self-store' })
|
|
1716
|
-
continue
|
|
1717
|
-
}
|
|
1718
|
-
// **先 stat 判大小、再决定读不读**:单文件超限或加上它会超总量 → 跳过并如实留痕
|
|
1719
|
-
// (旧实现的 `read > 0` 短路让**首个**文件完全不设上限,会被整份读进内存 —— t5-F1)
|
|
1720
|
-
let stat
|
|
1721
|
-
try {
|
|
1722
|
-
stat = await fs.stat(file)
|
|
1723
|
-
} catch (error) {
|
|
1724
|
-
payload.unreadableFiles.push({ path: file, reason: errorDetail(error) })
|
|
1725
|
-
continue
|
|
1726
|
-
}
|
|
1727
|
-
if (!stat.isFile()) {
|
|
1728
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'not-a-file' })
|
|
1729
|
-
continue
|
|
1730
|
-
}
|
|
1731
|
-
if (stat.size > this.maxFileBytes) {
|
|
1732
|
-
payload.truncated = true
|
|
1733
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'too-large' })
|
|
1734
|
-
continue
|
|
1735
|
-
}
|
|
1736
|
-
if (payload.bytes + stat.size > this.maxBytes) {
|
|
1737
|
-
payload.truncated = true
|
|
1738
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' })
|
|
1739
|
-
continue
|
|
1740
|
-
}
|
|
1741
|
-
try {
|
|
1742
|
-
// 路径身份缓存:身份(dev:ino:size:mtimeNs:ctimeNs:mode)未变 ⇒ 复用已存 sha256,**不重读**
|
|
1743
|
-
const identity = await this.pathIdentityOf(file)
|
|
1744
|
-
const candidate = identity === null ? null : await this.lookupCachedFile(identity, file)
|
|
1745
|
-
// **F-2(t12 修复)**:缓存命中也要确认对象仓里真有那个 blob —— 对象被外部清掉后
|
|
1746
|
-
// 若直接复用 sha256,新点会「零对象却 canRestore=true」,预览骗人(执行才响亮失败)。
|
|
1747
|
-
// 对象不在 ⇒ 当作未命中:真读 + 重写对象(缓存因此自愈)。
|
|
1748
|
-
const cached = candidate === null || !(await this.objectExists(candidate.sha256)) ? null : candidate
|
|
1749
|
-
const data = cached === null ? await this.readFileBytes(file) : null
|
|
1750
|
-
const size = cached === null ? (data?.byteLength ?? 0) : cached.bytes
|
|
1751
|
-
if (cached === null && data !== null && data.byteLength !== size) {
|
|
1752
|
-
payload.truncated = true
|
|
1753
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' })
|
|
1754
|
-
continue
|
|
1755
|
-
}
|
|
1756
|
-
if (cached !== null) {
|
|
1757
|
-
payload.files.push({ path: file, chunk: resolved, bytes: cached.bytes, sha256: cached.sha256, mtimeMs: stat.mtimeMs, object: cached.sha256 })
|
|
1758
|
-
payload.bytes += cached.bytes
|
|
1759
|
-
read += 1
|
|
1760
|
-
continue
|
|
1761
|
-
}
|
|
1762
|
-
if (data === null) {
|
|
1763
|
-
payload.unreadableFiles.push({ path: file, reason: 'read-failed' })
|
|
1764
|
-
continue
|
|
1765
|
-
}
|
|
1766
|
-
// stat 与 read 之间文件可能变化:真读到的字节数仍必须服从总量预算(不猜、不静默)
|
|
1767
|
-
if (payload.bytes + data.byteLength > this.maxBytes) {
|
|
1768
|
-
payload.truncated = true
|
|
1769
|
-
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' })
|
|
1770
|
-
continue
|
|
1771
|
-
}
|
|
1772
|
-
const sha = sha256Hex(data)
|
|
1773
|
-
await this.writeObjectWithHash(data, sha)
|
|
1774
|
-
if (identity !== null) this.rememberCachedFile(identity, file, sha, data.byteLength)
|
|
1775
|
-
payload.files.push({ path: file, chunk: resolved, bytes: data.byteLength, sha256: sha, mtimeMs: stat.mtimeMs, object: sha })
|
|
1776
|
-
payload.bytes += data.byteLength
|
|
1777
|
-
read += 1
|
|
1778
|
-
} catch (error) {
|
|
1779
|
-
payload.unreadableFiles.push({ path: file, reason: errorDetail(error) })
|
|
1780
|
-
}
|
|
2372
|
+
// 走查本身被打断 ⇒ 栈里还有没进去的子树(那些路径从未进任何名单)⇒ fail-closed。
|
|
2373
|
+
// **区别于**下面「走查已结束、只是读到一半超时」:后者的路径全在 walked 里,
|
|
2374
|
+
// 由文件末尾的覆盖自检补进证明名单(补不齐才拒删),不该整体拒删。
|
|
2375
|
+
if (listed.walkAborted) {
|
|
2376
|
+
payload.walkIncomplete = true
|
|
2377
|
+
for (const dir of listed.unwalkedDirs) unwalked.push(dir)
|
|
1781
2378
|
}
|
|
2379
|
+
read += await this.captureChunkCandidates(resolved, listed.files, Math.max(0, this.maxFiles - read), payload, outOfBudget)
|
|
1782
2380
|
}
|
|
1783
|
-
|
|
2381
|
+
// 精确的「没进去的子树」:有 ⇒ 删除集只在这些子树里 fail-closed;没有(老台账 / 栈被截断)
|
|
2382
|
+
// ⇒ 退回全局拒删(见 planCreatedRemovals)
|
|
2383
|
+
if (unwalked.length > 0) payload.unwalkedDirs = [...new Set(unwalked)].sort()
|
|
2384
|
+
|
|
2385
|
+
// `truncated` 为真时**不能**判 chunk-not-found:那是「分块确实存在、只是没来得及走查完」
|
|
2386
|
+
// (时间预算 / 取消信号)—— 报成「找不到分块」会让用户以为工作区配错了。
|
|
2387
|
+
if (!sawChunk && payload.files.length === 0 && !payload.truncated) {
|
|
1784
2388
|
return this.emptyTrack('workspace', capturedAt, 'chunk-not-found', chunks.join(' '))
|
|
1785
2389
|
}
|
|
1786
|
-
// **方向 B
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
1789
|
-
//
|
|
1790
|
-
|
|
2390
|
+
// **方向 B**:把走查过的全部路径另存成路径索引对象 —— 「未记录」与「留点时就存在」只有在
|
|
2391
|
+
// 证明名单完整时才可区分。触发条件是**覆盖自检**(不是「跳过名单被截断」这一种特例):
|
|
2392
|
+
// · 逐条跳过名单被记录上限截断(skippedTruncated);或
|
|
2393
|
+
// · 读到一半被时间预算 / 取消打断 —— 本分块剩余文件进了 walked、却一条都没进任何名单。
|
|
2394
|
+
// 覆盖完整时不必写(未截断时 files ∪ skipped 本身就是完整名单,零额外成本)。
|
|
2395
|
+
// 写失败 ⇒ 证明名单补不齐 ⇒ 必须整体拒删(安全方向;R-05f)。
|
|
2396
|
+
const proven = new Set<string>()
|
|
2397
|
+
for (const file of payload.files) proven.add(pathKey(file.path))
|
|
2398
|
+
for (const entry of payload.skipped) proven.add(pathKey(entry.path))
|
|
2399
|
+
for (const entry of payload.unreadableFiles) proven.add(pathKey(entry.path))
|
|
2400
|
+
const uncovered = walked.some((item) => !proven.has(pathKey(item)))
|
|
2401
|
+
if (uncovered) {
|
|
1791
2402
|
try {
|
|
1792
2403
|
const paths = [...new Set(walked)].sort()
|
|
1793
2404
|
const gz = gzipSync(Buffer.from(paths.join('\n'), 'utf8'))
|
|
@@ -1796,6 +2407,8 @@ export class RollbackPointEngine {
|
|
|
1796
2407
|
payload.pathIndex = { object, count: paths.length, bytes: gz.byteLength }
|
|
1797
2408
|
} catch (error) {
|
|
1798
2409
|
this.warn('rollback path index failed: ' + errorDetail(error))
|
|
2410
|
+
// 走查过、却没进名单的路径无法被证明 ⇒ 删除集整体拒绝(绝不「删一半」)
|
|
2411
|
+
payload.walkIncomplete = true
|
|
1799
2412
|
}
|
|
1800
2413
|
}
|
|
1801
2414
|
// 用户忽略的整目录 ⇒ 证明名单不完整(删除集整体拒绝;见 planCreatedRemovals)。
|
|
@@ -1804,6 +2417,8 @@ export class RollbackPointEngine {
|
|
|
1804
2417
|
payload.ignoreIncomplete = payload.ignoredDirCount > 0
|
|
1805
2418
|
// 路径身份缓存落盘(best-effort:写不进去只意味着下次全量读,绝不影响留点成败)
|
|
1806
2419
|
await this.persistPathCache()
|
|
2420
|
+
// 落点列表排序:并发读让入点顺序不定,契约必须是「按路径有序」(预览 / 对账 / 指纹都靠它)
|
|
2421
|
+
payload.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
1807
2422
|
// 留痕列表也排序:跨分块 / 依赖 readdir 顺序时输出仍确定(测试与对账都能逐条比)
|
|
1808
2423
|
payload.skipped.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
1809
2424
|
payload.excludedDirs.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
@@ -1811,9 +2426,24 @@ export class RollbackPointEngine {
|
|
|
1811
2426
|
const canonical = [...payload.files]
|
|
1812
2427
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
1813
2428
|
.map((f) => [f.path, f.bytes, f.sha256])
|
|
2429
|
+
payload.fileCount = payload.files.length
|
|
1814
2430
|
const fingerprint = sha256Hex(JSON.stringify({ chunks: [...payload.chunks].sort(), files: canonical }))
|
|
2431
|
+
// 自适应预算(见 ROLLBACK_MIN/MAX_CAPTURE_BUDGET_MS):被截断 ⇒ 下次翻倍(上限);
|
|
2432
|
+
// 用得很少 ⇒ 下次减半(下限)。大仓库第一次撞上限后自动拿到更大预算,小仓库自动回落,
|
|
2433
|
+
// 不会每回合白等。回退期的走查预算(rewindBudgetMs)**不参与**这套调整。
|
|
2434
|
+
if (payload.truncated || payload.walkIncomplete === true) {
|
|
2435
|
+
this.captureBudgetMs = Math.min(this.captureBudgetMaxMs, this.captureBudgetMs * 2)
|
|
2436
|
+
} else if (this.stamp() - capturedAt < this.captureBudgetMs / 4) {
|
|
2437
|
+
this.captureBudgetMs = Math.max(this.captureBudgetMinMs, Math.floor(this.captureBudgetMs / 2))
|
|
2438
|
+
}
|
|
2439
|
+
// 「降级(留得不完整)」只由**真的缺内容**触发:
|
|
2440
|
+
// ① truncated —— 文件数 / 总字节 / 单文件上限 / 时间预算挡下的内容;
|
|
2441
|
+
// ② unreadableDirs / unreadableFiles —— 读不出来或捕获竞态(内容不可信)。
|
|
2442
|
+
// **不再用 skippedCount**:那里的条目大多是**设计内**的跳过(符号链接不跟随、文件级忽略、
|
|
2443
|
+
// 自身数据区、读之前就消失),既不缺内容也无法回退 —— 旧口径让「工作区里有任何一个符号链接」
|
|
2444
|
+
// 的仓库每个点都挂「回退可能不完整」,真出问题时反而被噪声淹没(真机 2026-10-15:10 个 link
|
|
2445
|
+
// 跳过 ⇒ 每个点 degraded=true)。条目与计数仍逐条留痕(skippedCount / skipped 列表不变)。
|
|
1815
2446
|
const degraded = payload.truncated
|
|
1816
|
-
|| payload.skippedCount > 0
|
|
1817
2447
|
|| payload.unreadableDirs.length > 0
|
|
1818
2448
|
|| payload.unreadableFiles.length > 0
|
|
1819
2449
|
return {
|
|
@@ -1831,6 +2461,202 @@ export class RollbackPointEngine {
|
|
|
1831
2461
|
/* ---------------------------------------------------------- 忽略规则 / 路径身份缓存 */
|
|
1832
2462
|
|
|
1833
2463
|
/** 用户忽略规则是否命中该路径(只认「相对分块根」的斜杠路径) */
|
|
2464
|
+
/**
|
|
2465
|
+
* 处理一个分块的候选文件(**两阶段**)。
|
|
2466
|
+
*
|
|
2467
|
+
* ① **顺序判定**(与旧的串行实现逐条一致,保证预算 / 计数 / 字节上限完全确定):
|
|
2468
|
+
* 文件数上限、时间预算、自身数据区、stat、单文件上限、总量上限、身份缓存 + 对象存在性
|
|
2469
|
+
* → 得出「当场入点(缓存命中)」还是「要真读」,该跳过的**当场**按原有 reason 逐条留痕。
|
|
2470
|
+
* stat 按**有序批**并发取(判定仍严格按顺序),并用「一次 readdir 的对象名单快照」代替
|
|
2471
|
+
* 每个文件一次 fs.access —— 这两处是稳态成本的大头(真机实测各占 ~5 s / ~3.7 s)。
|
|
2472
|
+
* ② **并发读 + 哈希 + 落对象仓**:这是捕获耗时的**唯一大头**(真机 148 s / 31,702 个对象,
|
|
2473
|
+
* 96% 的时间),用 captureConcurrency 个 worker 并发跑;并用**在飞字节闸门**保证
|
|
2474
|
+
* 「同时在飞的内容字节 ≤ captureInFlightBytes」。**不是** maxFileBytes —— 它缺省 =
|
|
2475
|
+
* 总量预算(512 MiB),拿它当闸门等于允许把整个点的预算同时驻留(0.1.74 闪退根因)。
|
|
2476
|
+
*
|
|
2477
|
+
* 返回本分块**真正入点**的文件数(调用方据此推进 maxFiles 预算)。
|
|
2478
|
+
* 没判到 / 没读完的条目一律逐条进 skipped(budget-time),绝不静默丢弃。
|
|
2479
|
+
*/
|
|
2480
|
+
private async captureChunkCandidates(
|
|
2481
|
+
chunk: string,
|
|
2482
|
+
candidates: readonly string[],
|
|
2483
|
+
slots: number,
|
|
2484
|
+
payload: RollbackWorkspacePayload,
|
|
2485
|
+
outOfBudget: () => boolean,
|
|
2486
|
+
): Promise<number> {
|
|
2487
|
+
// 缺口优先:身份缓存里还没有的路径排前面 ⇒ 每个点都在补上次没覆盖的部分(见 orderByCoverage)
|
|
2488
|
+
const ordered = await this.orderByCoverage(candidates)
|
|
2489
|
+
const decided = new Array<boolean>(ordered.length).fill(false)
|
|
2490
|
+
interface ReadPlan { path: string; size: number; mtimeMs: number; identity: string | null }
|
|
2491
|
+
const plans: ReadPlan[] = []
|
|
2492
|
+
let captured = 0
|
|
2493
|
+
let newReads = 0
|
|
2494
|
+
// 字节预算必须在**准入那一刻**按 stat.size 预留:真正入点发生在并发的第二阶段,
|
|
2495
|
+
// 若拿 payload.bytes 当判据,同一个分块里后面的文件会全部误判成「还有额度」
|
|
2496
|
+
let accounted = payload.bytes
|
|
2497
|
+
// 对象名单快照:一次 readdir 顶掉「每个候选一次 fs.access」(读不到则回落逐条判断)
|
|
2498
|
+
const names = await this.objectNamesSnapshot()
|
|
2499
|
+
|
|
2500
|
+
let cursor = 0
|
|
2501
|
+
while (cursor < ordered.length) {
|
|
2502
|
+
if (outOfBudget()) break // 剩余条目由收尾循环逐条记 budget-time(不预取 stat)
|
|
2503
|
+
const window = ordered.slice(cursor, cursor + ROLLBACK_CAPTURE_STAT_BATCH)
|
|
2504
|
+
const stats = await Promise.all(window.map((file) => this.fileStatOf(file)))
|
|
2505
|
+
let broke = false
|
|
2506
|
+
for (let offset = 0; offset < window.length; offset += 1) {
|
|
2507
|
+
const file = window[offset] ?? ''
|
|
2508
|
+
const info: RollbackFileStat = stats[offset] ?? { isFile: false, size: 0, mtimeMs: 0, identity: null, error: 'stat-missing' }
|
|
2509
|
+
const index = cursor + offset
|
|
2510
|
+
if (outOfBudget()) { broke = true; break }
|
|
2511
|
+
decided[index] = true
|
|
2512
|
+
if (isInsidePath(file, this.root)) {
|
|
2513
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'self-store' })
|
|
2514
|
+
continue
|
|
2515
|
+
}
|
|
2516
|
+
// **先 stat 判大小、再决定读不读**:单文件超限或加上它会超总量 → 跳过并如实留痕
|
|
2517
|
+
// (旧实现的 read > 0 短路让**首个**文件完全不设上限,会被整份读进内存 —— t5-F1)
|
|
2518
|
+
if (info.error !== undefined) {
|
|
2519
|
+
payload.unreadableFiles.push({ path: file, reason: info.error })
|
|
2520
|
+
continue
|
|
2521
|
+
}
|
|
2522
|
+
if (!info.isFile) {
|
|
2523
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'not-a-file' })
|
|
2524
|
+
continue
|
|
2525
|
+
}
|
|
2526
|
+
if (info.size > this.maxFileBytes) {
|
|
2527
|
+
payload.truncated = true
|
|
2528
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'too-large' })
|
|
2529
|
+
continue
|
|
2530
|
+
}
|
|
2531
|
+
// 路径身份缓存:身份(dev:ino:size:mtimeNs:ctimeNs:mode)未变 ⇒ 复用已存 sha256,**不重读**
|
|
2532
|
+
const candidate = info.identity === null ? null : await this.lookupCachedFile(info.identity, file)
|
|
2533
|
+
// **F-2(t12 修复)**:缓存命中也要确认对象仓里真有那个 blob —— 对象被外部清掉后
|
|
2534
|
+
// 若直接复用 sha256,新点会「零对象却 canRestore=true」,预览骗人(执行才响亮失败)。
|
|
2535
|
+
// 对象不在 ⇒ 当作未命中:真读 + 重写对象(缓存因此自愈)。
|
|
2536
|
+
const objectPresent = candidate === null
|
|
2537
|
+
? false
|
|
2538
|
+
: names !== null ? names.has(candidate.sha256) : await this.objectExists(candidate.sha256)
|
|
2539
|
+
if (candidate !== null && objectPresent) {
|
|
2540
|
+
// 身份未变 ⇒ 内容必然与缓存一致:零 I/O 直接入点(稳态成本与仓库大小无关的来源)。
|
|
2541
|
+
// **不占读预算**:maxFiles / maxBytes 约束的是「这回合新读进来的内容」,不是「记录多少条」
|
|
2542
|
+
// —— 否则大仓库每个点都只能记下前 N 条,滚回某点时会漏掉其余文件(覆盖率永远不收敛)。
|
|
2543
|
+
payload.files.push({
|
|
2544
|
+
path: file, chunk, bytes: candidate.bytes, sha256: candidate.sha256, mtimeMs: info.mtimeMs, object: candidate.sha256,
|
|
2545
|
+
})
|
|
2546
|
+
payload.bytes += candidate.bytes
|
|
2547
|
+
captured += 1
|
|
2548
|
+
continue
|
|
2549
|
+
}
|
|
2550
|
+
// 只有**要真读的新内容**才受每回合的规模预算约束(判定仍严格按顺序 ⇒ 结果确定)
|
|
2551
|
+
if (newReads >= slots) {
|
|
2552
|
+
payload.truncated = true
|
|
2553
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-files' })
|
|
2554
|
+
continue
|
|
2555
|
+
}
|
|
2556
|
+
if (accounted + info.size > this.maxBytes) {
|
|
2557
|
+
payload.truncated = true
|
|
2558
|
+
this.recordSkip(payload, 'skipped', { path: file, reason: 'budget-bytes' })
|
|
2559
|
+
continue
|
|
2560
|
+
}
|
|
2561
|
+
accounted += info.size
|
|
2562
|
+
newReads += 1
|
|
2563
|
+
plans.push({ path: file, size: info.size, mtimeMs: info.mtimeMs, identity: info.identity })
|
|
2564
|
+
}
|
|
2565
|
+
cursor += window.length
|
|
2566
|
+
if (broke) break
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
if (plans.length > 0) {
|
|
2570
|
+
// ② 并发读 + 落对象仓。outOfBudget 一命中就**不再开新条目**(在飞的照常收尾),
|
|
2571
|
+
// 未开工的条目在循环外逐条记 budget-time —— 与串行版「中断处逐条留痕」同一诚实口径。
|
|
2572
|
+
const gate = new ByteGate(this.captureInFlightBytes)
|
|
2573
|
+
const done: boolean[] = new Array<boolean>(plans.length).fill(false)
|
|
2574
|
+
let planCursor = 0
|
|
2575
|
+
let stopped = false
|
|
2576
|
+
const workers = Array.from({ length: Math.min(this.captureConcurrency, plans.length) }, async () => {
|
|
2577
|
+
for (;;) {
|
|
2578
|
+
if (stopped) return
|
|
2579
|
+
const index = planCursor
|
|
2580
|
+
planCursor += 1
|
|
2581
|
+
if (index >= plans.length) return
|
|
2582
|
+
const item = plans[index] as ReadPlan
|
|
2583
|
+
if (outOfBudget()) { stopped = true; return }
|
|
2584
|
+
await gate.acquire(item.size)
|
|
2585
|
+
try {
|
|
2586
|
+
if (outOfBudget()) { stopped = true; return }
|
|
2587
|
+
const data = await this.readFileBytes(item.path)
|
|
2588
|
+
if (data.byteLength !== item.size) {
|
|
2589
|
+
// stat 与 read 之间文件变了(旧口径:budget-bytes)—— 绝不按旧字节数记账
|
|
2590
|
+
payload.truncated = true
|
|
2591
|
+
this.recordSkip(payload, 'skipped', { path: item.path, reason: 'budget-bytes' })
|
|
2592
|
+
done[index] = true
|
|
2593
|
+
continue
|
|
2594
|
+
}
|
|
2595
|
+
// **捕获竞态**:读之前拿到的身份若在读完时已变,说明这次读到的内容未必是「留点时刻」
|
|
2596
|
+
// 的内容(回合与留点并发)⇒ 不当作前像,只如实进 unreadableFiles(它本身是**证明名单**
|
|
2597
|
+
// 的一员:证明「留点时存在」,因此绝不会被当成新建删掉)。
|
|
2598
|
+
const after = item.identity === null ? null : await this.pathIdentityOf(item.path)
|
|
2599
|
+
if (item.identity !== null && after !== item.identity) {
|
|
2600
|
+
payload.unreadableFiles.push({ path: item.path, reason: 'changed-during-read' })
|
|
2601
|
+
done[index] = true
|
|
2602
|
+
continue
|
|
2603
|
+
}
|
|
2604
|
+
const sha = sha256Hex(data)
|
|
2605
|
+
await this.writeObjectWithHash(data, sha)
|
|
2606
|
+
if (item.identity !== null) this.rememberCachedFile(item.identity, item.path, sha, data.byteLength)
|
|
2607
|
+
payload.files.push({
|
|
2608
|
+
path: item.path, chunk, bytes: data.byteLength, sha256: sha, mtimeMs: item.mtimeMs, object: sha,
|
|
2609
|
+
})
|
|
2610
|
+
payload.bytes += data.byteLength
|
|
2611
|
+
captured += 1
|
|
2612
|
+
done[index] = true
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
payload.unreadableFiles.push({ path: item.path, reason: errorDetail(error) })
|
|
2615
|
+
done[index] = true
|
|
2616
|
+
} finally {
|
|
2617
|
+
gate.release(item.size)
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
})
|
|
2621
|
+
await Promise.all(workers)
|
|
2622
|
+
for (let index = 0; index < plans.length; index += 1) {
|
|
2623
|
+
if (done[index] === true) continue
|
|
2624
|
+
payload.truncated = true
|
|
2625
|
+
this.recordSkip(payload, 'skipped', { path: (plans[index] as ReadPlan).path, reason: 'budget-time' })
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
// 阶段 ① 里没判定到的(时间预算打断)同样逐条留痕 —— 覆盖自检因此仍认为名单完整
|
|
2630
|
+
for (let index = 0; index < ordered.length; index += 1) {
|
|
2631
|
+
if (decided[index] === true) continue
|
|
2632
|
+
payload.truncated = true
|
|
2633
|
+
this.recordSkip(payload, 'skipped', { path: ordered[index] ?? '', reason: 'budget-time' })
|
|
2634
|
+
}
|
|
2635
|
+
// 返回值 = 本分块消耗掉的**读槽位**(缓存命中不占槽位;调用方据此推进 maxFiles)
|
|
2636
|
+
return newReads
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
/**
|
|
2640
|
+
* 缺口优先排序:身份缓存里**没有**的路径排前面。
|
|
2641
|
+
*
|
|
2642
|
+
* 为什么:预算固定时按目录顺序走查,同一个「已覆盖且便宜」的前缀每回合都会吃掉全部预算,
|
|
2643
|
+
* 尾部永远进不来(真机:34k 文件每次只覆盖 1/5,且五次都是同一批 outputs/ 前缀)。
|
|
2644
|
+
* 未覆盖的排前面 ⇒ 每个点都在补上一次的缺口,覆盖率随回合**单调收敛**到 100%。
|
|
2645
|
+
* 组内保持走查顺序(稳定),因此同一输入下的结果仍然确定。
|
|
2646
|
+
*/
|
|
2647
|
+
private async orderByCoverage(candidates: readonly string[]): Promise<string[]> {
|
|
2648
|
+
if (candidates.length <= 1) return [...candidates]
|
|
2649
|
+
const cache = await this.loadPathCache()
|
|
2650
|
+
if (cache.size === 0) return [...candidates]
|
|
2651
|
+
const covered = new Set<string>()
|
|
2652
|
+
for (const entry of cache.values()) covered.add(pathKey(entry.path))
|
|
2653
|
+
if (covered.size === 0) return [...candidates]
|
|
2654
|
+
const cold: string[] = []
|
|
2655
|
+
const hot: string[] = []
|
|
2656
|
+
for (const file of candidates) (covered.has(pathKey(file)) ? hot : cold).push(file)
|
|
2657
|
+
return cold.length === 0 ? hot : [...cold, ...hot]
|
|
2658
|
+
}
|
|
2659
|
+
|
|
1834
2660
|
private ignoredByRules(rules: IgnoreRules, target: string, isDir: boolean): boolean {
|
|
1835
2661
|
const rel = ignoreRelativePath(rules.root, target)
|
|
1836
2662
|
return rel === null ? false : ignoreRuleMatches(rules, rel, isDir)
|
|
@@ -1849,18 +2675,41 @@ export class RollbackPointEngine {
|
|
|
1849
2675
|
* 用毫秒换算同样不行:会把「同一毫秒内的两次写」当成同一个身份。
|
|
1850
2676
|
* 读不到身份(文件消失 / 权限 / 平台不支持 bigint stat)→ null ⇒ 回落全量真读。
|
|
1851
2677
|
*/
|
|
2678
|
+
/**
|
|
2679
|
+
* 一次 bigint stat 拿到阶段①需要的**全部**东西(是否文件 / 大小 / mtime / 身份)。
|
|
2680
|
+
* 旧实现「fs.stat + pathIdentityOf」每文件 stat 两次,真机 34k 文件白花约 5 s。
|
|
2681
|
+
*/
|
|
2682
|
+
private async fileStatOf(target: string): Promise<RollbackFileStat> {
|
|
2683
|
+
try {
|
|
2684
|
+
const stat = await fs.stat(target, { bigint: true })
|
|
2685
|
+
const isFile = stat.isFile()
|
|
2686
|
+
return {
|
|
2687
|
+
isFile,
|
|
2688
|
+
size: Number(stat.size),
|
|
2689
|
+
mtimeMs: Number(stat.mtimeMs),
|
|
2690
|
+
identity: isFile ? fileIdentityOf(stat) : null,
|
|
2691
|
+
}
|
|
2692
|
+
} catch (error) {
|
|
2693
|
+
return { isFile: false, size: 0, mtimeMs: 0, identity: null, error: errorDetail(error) }
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
/** 对象名单快照(每个捕获回合一次 readdir);读不到则返回 null ⇒ 调用方回落逐条判断 */
|
|
2698
|
+
private async objectNamesSnapshot(): Promise<Set<string> | null> {
|
|
2699
|
+
if (this.captureObjectNames !== null) return this.captureObjectNames
|
|
2700
|
+
try {
|
|
2701
|
+
this.captureObjectNames = new Set(await fs.readdir(this.objectsDir))
|
|
2702
|
+
} catch {
|
|
2703
|
+
this.captureObjectNames = null
|
|
2704
|
+
}
|
|
2705
|
+
return this.captureObjectNames
|
|
2706
|
+
}
|
|
2707
|
+
|
|
1852
2708
|
private async pathIdentityOf(target: string): Promise<string | null> {
|
|
1853
2709
|
try {
|
|
1854
2710
|
const stat = await fs.stat(target, { bigint: true })
|
|
1855
2711
|
if (!stat.isFile()) return null
|
|
1856
|
-
return
|
|
1857
|
-
String(stat.dev),
|
|
1858
|
-
String(stat.ino),
|
|
1859
|
-
String(stat.size),
|
|
1860
|
-
String(stat.mtimeNs),
|
|
1861
|
-
String(stat.ctimeNs),
|
|
1862
|
-
String(stat.mode & 0o777n),
|
|
1863
|
-
].join(':')
|
|
2712
|
+
return fileIdentityOf(stat)
|
|
1864
2713
|
} catch {
|
|
1865
2714
|
return null
|
|
1866
2715
|
}
|
|
@@ -1899,6 +2748,25 @@ export class RollbackPointEngine {
|
|
|
1899
2748
|
}
|
|
1900
2749
|
|
|
1901
2750
|
/** 缓存命中:身份键存在、且路径逐字相同(身份被复用也绝不认)。 */
|
|
2751
|
+
/**
|
|
2752
|
+
* 「这条记录的当前内容**仍然是它**吗」的身份快路径 —— **预览与执行共用同一条判据**
|
|
2753
|
+
* (否则「预览说会改的 = 实际会改的」这条不变式会被打破)。
|
|
2754
|
+
*
|
|
2755
|
+
* 判据:路径身份(dev:ino:size:mtimeNs:ctimeNs:mode)命中**身份缓存**,且缓存里的 sha/bytes 与记录一致。
|
|
2756
|
+
* 身份未变 ⇒ 内容未变:缓存里的 sha 就是**这份内容**在留点那一次真读出来的哈希(身份含 size/mtimeNs/
|
|
2757
|
+
* ctimeNs/ino/dev/mode,改内容必动 mtime/ctime,Windows 上 user 代码改不了 ctime)。
|
|
2758
|
+
* 任何一环拿不到(读不到身份 / 缓存没有该身份 / sha 或 bytes 不同)⇒ false,调用方回落「真读 + 哈希」。
|
|
2759
|
+
*
|
|
2760
|
+
* 为什么必须有它:大仓库一次预览/回退要判 3 万+ 条目,逐个真读等于把整个工作区读两遍
|
|
2761
|
+
* (真机 34k 文件 / 609 MiB:预览 14.2 s 里 13 s 全在这,回退 60 s+ 同理)。**记录的指纹被篡改**
|
|
2762
|
+
* 时本判据必然不成立(缓存的 sha 是真算出来的)⇒ 仍走真读 + 哈希的旧路径,护栏不退化。
|
|
2763
|
+
*/
|
|
2764
|
+
private async unchangedByIdentity(file: RollbackWorkspaceFile, identity: string | null): Promise<boolean> {
|
|
2765
|
+
if (identity === null) return false
|
|
2766
|
+
const cached = await this.lookupCachedFile(identity, file.path)
|
|
2767
|
+
return cached !== null && cached.sha256 === file.sha256 && cached.bytes === file.bytes
|
|
2768
|
+
}
|
|
2769
|
+
|
|
1902
2770
|
private async lookupCachedFile(identity: string, target: string): Promise<{ sha256: string; bytes: number } | null> {
|
|
1903
2771
|
const cache = await this.loadPathCache()
|
|
1904
2772
|
const hit = cache.get(identity)
|
|
@@ -1947,6 +2815,10 @@ export class RollbackPointEngine {
|
|
|
1947
2815
|
out.push({ code: 'rollback.warn.createdRefused', detail: 'ignore-dirs' })
|
|
1948
2816
|
return out
|
|
1949
2817
|
}
|
|
2818
|
+
if (payload.walkIncomplete === true) {
|
|
2819
|
+
out.push({ code: 'rollback.warn.createdRefused', detail: 'walk-incomplete' })
|
|
2820
|
+
return out
|
|
2821
|
+
}
|
|
1950
2822
|
if (payload.skippedTruncated && (payload.pathIndex === null || payload.pathIndex === undefined)) {
|
|
1951
2823
|
out.push({ code: 'rollback.warn.createdRefused', detail: 'path-index-missing' })
|
|
1952
2824
|
return out
|
|
@@ -1960,27 +2832,27 @@ export class RollbackPointEngine {
|
|
|
1960
2832
|
|
|
1961
2833
|
/* -------------------------------------------------------------- 台账 */
|
|
1962
2834
|
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
2835
|
+
/**
|
|
2836
|
+
* 读台账**清单**(只读元数据 + 段摘要;代价与工作区文件数**无关**)。
|
|
2837
|
+
*
|
|
2838
|
+
* 与 v2 的差别只在实现:v2 每回合把整份 index.json 读进内存并 parse(473 MB 那条路),
|
|
2839
|
+
* v3 读的是一份小清单。**外部语义一个字没变**:返回 entries + 同一套 error 文案。
|
|
2840
|
+
* 启动期初始化失败时如实回报(不读 v2 文件、不假装台账为空)。
|
|
2841
|
+
*/
|
|
2842
|
+
private async readManifest(): Promise<{ entries: RollbackLedgerManifestEntry[]; error?: string }> {
|
|
2843
|
+
await this.ensureReady()
|
|
2844
|
+
if (this.initError !== undefined) return { entries: [], error: this.initError }
|
|
2845
|
+
return await this.ledger.readManifest()
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/**
|
|
2849
|
+
* 读**完整**记录(引用解析对调用方透明)。失败一律如实回报,**绝不返回半个记录**:
|
|
2850
|
+
* 载荷缺失 / 哈希不符 / 形状不符 ⇒ error(调用方走 record-incomplete 路径)。
|
|
2851
|
+
*/
|
|
2852
|
+
private async readFullRecord(id: string): Promise<{ record?: RollbackPointRecord; error?: string }> {
|
|
2853
|
+
await this.ensureReady()
|
|
2854
|
+
if (this.initError !== undefined) return { error: this.initError }
|
|
2855
|
+
return await this.ledger.readRecord(id)
|
|
1984
2856
|
}
|
|
1985
2857
|
|
|
1986
2858
|
/**
|
|
@@ -1992,96 +2864,297 @@ export class RollbackPointEngine {
|
|
|
1992
2864
|
* | guard 救援点 | 每会话 `maxGuardsPerSession`(20)→ 全局 `maxGuards`(`ROLLBACK_MAX_GUARDS`,50) | **不占**自动点预算、**不受**自动点的 20/50 影响;用户删不掉 ⇒ 必须有自己的两道预算(单会话挡住不够,跨会话也要收敛) |
|
|
1993
2865
|
* | protected 点 | 无上限(只进不出) | 生产路径**造不出来**(capture 端点已下线、tracker 从不设 protect),只有测试能造 |
|
|
1994
2866
|
*/
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2867
|
+
/**
|
|
2868
|
+
* 条数淘汰(口径与 v2 逐字相同,只是从「记录」换成「清单条目」):三类点各按各的预算裁,**只删最旧的**。
|
|
2869
|
+
*
|
|
2870
|
+
* | 类别 | 预算 | 说明 |
|
|
2871
|
+
* |---|---|---|
|
|
2872
|
+
* | auto 自动点 | 每会话 maxRecordsPerSession(20)→ 全局 maxRecords(50) | 两次裁剪都只删最旧 |
|
|
2873
|
+
* | guard 救援点 | 每会话 maxGuardsPerSession(20)→ 全局 maxGuards(ROLLBACK_MAX_GUARDS,50) | **不占**自动点预算、**不受**自动点的 20/50 影响;用户删不掉 ⇒ 必须有自己的两道预算(单会话挡住不够,跨会话也要收敛) |
|
|
2874
|
+
* | protected 点 | 无上限(只进不出) | 生产路径**造不出来**(capture 端点已下线、tracker 从不设 protect),只有测试能造 |
|
|
2875
|
+
*/
|
|
2876
|
+
private selectPruneEntries(entries: readonly RollbackLedgerManifestEntry[]): { kept: RollbackLedgerManifestEntry[]; removed: string[] } {
|
|
2877
|
+
const kept: RollbackLedgerManifestEntry[] = []
|
|
2878
|
+
const guards: RollbackLedgerManifestEntry[] = []
|
|
2879
|
+
const prunable: RollbackLedgerManifestEntry[] = []
|
|
2880
|
+
for (const entry of entries) {
|
|
2881
|
+
if (entry.protected) kept.push(entry)
|
|
2882
|
+
else if (entry.kind === 'guard') guards.push(entry)
|
|
2883
|
+
else prunable.push(entry)
|
|
2003
2884
|
}
|
|
2004
2885
|
// 最新在前(下面三类裁剪共用同一排序口径)
|
|
2005
|
-
const newestFirst = (a:
|
|
2886
|
+
const newestFirst = (a: RollbackLedgerManifestEntry, b: RollbackLedgerManifestEntry): number =>
|
|
2006
2887
|
a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0
|
|
2007
2888
|
// guard:先每会话各自保留最近 maxGuardsPerSession 个,再全局保留最近 maxGuards 个
|
|
2008
2889
|
// (guards 已按 newestFirst 排序,withinGuardSession 保持同序 ⇒ 两轮裁掉的都是「最旧的」)
|
|
2009
2890
|
guards.sort(newestFirst)
|
|
2010
2891
|
const guardsPerSession = new Map<string, number>()
|
|
2011
|
-
const withinGuardSession:
|
|
2012
|
-
for (const
|
|
2013
|
-
const key =
|
|
2892
|
+
const withinGuardSession: RollbackLedgerManifestEntry[] = []
|
|
2893
|
+
for (const entry of guards) {
|
|
2894
|
+
const key = entry.sessionId ?? ''
|
|
2014
2895
|
const used = guardsPerSession.get(key) ?? 0
|
|
2015
2896
|
if (used >= this.maxGuardsPerSession) continue
|
|
2016
2897
|
guardsPerSession.set(key, used + 1)
|
|
2017
|
-
withinGuardSession.push(
|
|
2898
|
+
withinGuardSession.push(entry)
|
|
2018
2899
|
}
|
|
2019
2900
|
kept.push(...withinGuardSession.slice(0, Math.max(0, this.maxGuards)))
|
|
2020
2901
|
// 自动点:先每会话上限,再全局上限
|
|
2021
2902
|
prunable.sort(newestFirst)
|
|
2022
2903
|
const perSession = new Map<string, number>()
|
|
2023
|
-
const withinSession:
|
|
2024
|
-
for (const
|
|
2025
|
-
const key =
|
|
2904
|
+
const withinSession: RollbackLedgerManifestEntry[] = []
|
|
2905
|
+
for (const entry of prunable) {
|
|
2906
|
+
const key = entry.sessionId ?? ''
|
|
2026
2907
|
const used = perSession.get(key) ?? 0
|
|
2027
2908
|
if (used >= this.maxRecordsPerSession) continue
|
|
2028
2909
|
perSession.set(key, used + 1)
|
|
2029
|
-
withinSession.push(
|
|
2910
|
+
withinSession.push(entry)
|
|
2030
2911
|
}
|
|
2031
2912
|
kept.push(...withinSession.slice(0, Math.max(0, this.maxRecords)))
|
|
2032
2913
|
const keptIds = new Set(kept.map((r) => r.id))
|
|
2033
|
-
const removed =
|
|
2914
|
+
const removed = entries.filter((r) => !keptIds.has(r.id)).map((r) => r.id)
|
|
2034
2915
|
return { kept, removed }
|
|
2035
2916
|
}
|
|
2036
2917
|
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2918
|
+
/**
|
|
2919
|
+
* 字节预算淘汰(issue #89 的**根因修复**):条数没超、字节超了照样会 OOM。
|
|
2920
|
+
*
|
|
2921
|
+
* 判据与条数淘汰共用同一类别口径:**只淘汰最旧的 auto 点**(guard / protected 永不淘汰)。
|
|
2922
|
+
* 释放量按「记录文件字节 + 载荷**最后一个引用**消失时的载荷字节」估算(引用计数靠清单,不读载荷)。
|
|
2923
|
+
* 返回淘汰后的清单(调用方负责落盘与删文件)。
|
|
2924
|
+
*/
|
|
2925
|
+
private async applyByteBudget(
|
|
2926
|
+
all: readonly RollbackLedgerManifestEntry[],
|
|
2927
|
+
keptInput: readonly RollbackLedgerManifestEntry[],
|
|
2928
|
+
extraBytes: number,
|
|
2929
|
+
protectedNewId: string | null,
|
|
2930
|
+
): Promise<RollbackLedgerManifestEntry[]> {
|
|
2931
|
+
const kept = [...keptInput]
|
|
2932
|
+
// 基线口径(**精确**,不用估算):当前磁盘字节 - 本次已定要删的条目所占字节 + 本次新增字节。
|
|
2933
|
+
// 「已定要删」= 条数淘汰掉的(它们的文件马上会被删),所以不能拿 stats.bytes 当基线。
|
|
2934
|
+
const keptIds = new Set(kept.map((entry) => entry.id))
|
|
2935
|
+
const keptPayloads = new Set<string>()
|
|
2936
|
+
for (const entry of kept) {
|
|
2937
|
+
if (entry.workspace.payload !== null) keptPayloads.add(entry.workspace.payload)
|
|
2938
|
+
}
|
|
2939
|
+
let freed = 0
|
|
2940
|
+
const freedPayloads = new Set<string>()
|
|
2941
|
+
for (const entry of all) {
|
|
2942
|
+
if (keptIds.has(entry.id)) continue
|
|
2943
|
+
freed += entry.recordBytes
|
|
2944
|
+
const hash = entry.workspace.payload
|
|
2945
|
+
if (hash !== null && !keptPayloads.has(hash) && !freedPayloads.has(hash)) {
|
|
2946
|
+
freedPayloads.add(hash)
|
|
2947
|
+
freed += entry.workspace.payloadBytes
|
|
2044
2948
|
}
|
|
2045
|
-
const pathIndex = record.workspace.payload?.pathIndex
|
|
2046
|
-
if (pathIndex !== null && pathIndex !== undefined) referenced.add(pathIndex.object)
|
|
2047
|
-
const sessionObject = record.session.payload?.object
|
|
2048
|
-
if (sessionObject !== null && sessionObject !== undefined) referenced.add(sessionObject)
|
|
2049
2949
|
}
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2950
|
+
const stats = await this.ledger.stats()
|
|
2951
|
+
let bytes = stats.bytes - freed + extraBytes
|
|
2952
|
+
if (bytes <= this.ledgerBytesBudget) return kept
|
|
2953
|
+
const refs = new Map<string, number>()
|
|
2954
|
+
for (const entry of kept) {
|
|
2955
|
+
const hash = entry.workspace.payload
|
|
2956
|
+
if (hash !== null) refs.set(hash, (refs.get(hash) ?? 0) + 1)
|
|
2055
2957
|
}
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2958
|
+
const candidates = kept
|
|
2959
|
+
.filter((entry) => !entry.protected && entry.kind === 'auto' && entry.id !== protectedNewId)
|
|
2960
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
|
|
2961
|
+
const victims = new Set<string>()
|
|
2962
|
+
for (const entry of candidates) {
|
|
2963
|
+
if (bytes <= this.ledgerBytesBudget) break
|
|
2964
|
+
victims.add(entry.id)
|
|
2965
|
+
const index = kept.findIndex((item) => item.id === entry.id)
|
|
2966
|
+
if (index >= 0) kept.splice(index, 1)
|
|
2967
|
+
bytes -= entry.recordBytes
|
|
2968
|
+
const hash = entry.workspace.payload
|
|
2969
|
+
if (hash !== null) {
|
|
2970
|
+
const left = (refs.get(hash) ?? 1) - 1
|
|
2971
|
+
refs.set(hash, left)
|
|
2972
|
+
if (left <= 0) bytes -= entry.workspace.payloadBytes
|
|
2062
2973
|
}
|
|
2063
2974
|
}
|
|
2975
|
+
if (victims.size === 0) {
|
|
2976
|
+
// 一条都淘汰不了却仍然超预算(典型:单条点自身的体积就大于预算)—— **如实留痕**。
|
|
2977
|
+
// 不写这一条,「预算生效」就是假象(竞品 dsh-checkpoint-rewind #35 正是「软配额 bound
|
|
2978
|
+
// 不住单条」的口子);界面侧由 RollbackLedgerStatus.overBudget 如实上屏。
|
|
2979
|
+
if (bytes > this.ledgerBytesBudget) {
|
|
2980
|
+
this.warn('rollback ledger over budget: nothing prunable bytes=' + String(bytes) + ' budget=' + String(this.ledgerBytesBudget))
|
|
2981
|
+
}
|
|
2982
|
+
return kept
|
|
2983
|
+
}
|
|
2984
|
+
this.ledger.markEvicted(victims.size)
|
|
2985
|
+
this.warn('rollback ledger over budget: evicted=' + String(victims.size) + ' bytes=' + String(bytes) + ' budget=' + String(this.ledgerBytesBudget))
|
|
2986
|
+
return kept
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* 对象仓回收(**重写版**,issue #89):账本字节数不再进内存。
|
|
2991
|
+
*
|
|
2992
|
+
* 旧实现要从「内存里所有记录的全部 files 数组」累计引用 ⇒ 回收一次 = 把整份台账读进内存。
|
|
2993
|
+
* 现在:扫**清单** -> 逐个读引用到的**载荷文件**(一次只驻留一份)-> 累计 referenced 对象键 -> 扫 objects/。
|
|
2994
|
+
* 峰值内存 ∝ 单份载荷 + 对象键集合,**不 ∝ 台账总字节**。
|
|
2995
|
+
* 任何一份被引用的载荷读不出来 ⇒ 整体放弃回收(fail-closed:读不到的载荷里有什么对象谁也不知道)。
|
|
2996
|
+
*/
|
|
2997
|
+
private async gcObjects(entries: readonly RollbackLedgerManifestEntry[]): Promise<void> {
|
|
2998
|
+
const result = await this.ledger.collectObjects(entries)
|
|
2999
|
+
if (result.aborted !== null) {
|
|
3000
|
+
this.warn('rollback gc skipped: ' + result.aborted)
|
|
3001
|
+
return
|
|
3002
|
+
}
|
|
2064
3003
|
// 路径身份缓存随引用回收:没有任何回滚点引用的路径不留条目(不无界增长)
|
|
2065
|
-
await this.gcPathCache(referencedPaths)
|
|
3004
|
+
await this.gcPathCache(new Set([...result.referencedPaths].map(pathKey)))
|
|
2066
3005
|
}
|
|
2067
3006
|
|
|
2068
3007
|
/**
|
|
2069
3008
|
* 把记录写进台账(**留点侧唯一写入口**):整段「读全文 → 合并 → S6 淘汰 → 原子写 → 回收对象」
|
|
2070
3009
|
* 都在 {@link withLedgerWriteLock} 的进程内串行队列里 —— 绝不与 tracker / 路由的另一次写交错。
|
|
2071
3010
|
*/
|
|
3011
|
+
/**
|
|
3012
|
+
* 写一条记录(**最多三次尝试**)—— 跨进程乐观并发(①B)。
|
|
3013
|
+
*
|
|
3014
|
+
* dataDir 由 `$DSH_HOME` 决定、**不随 profile 分家**(与本插件自带的实例锁同一套多实例假设),
|
|
3015
|
+
* 所以「另一个 DSH 实例正在写同一份清单」是设计内的场景,而 `withLedgerWriteLock` 只串行本进程。
|
|
3016
|
+
* 策略:读清单前后各取一次**代际戳**,被改过就重读重试;三次都变 ⇒ **放弃本次留点并 warn**,
|
|
3017
|
+
* 绝不拿自己的快照覆盖别人的清单(宁可少一条点,也不丢别人的点)。
|
|
3018
|
+
* 诚实的边界:检测与写入之间仍有极小窗口,无跨进程锁时无法彻底消除 —— 这里保证的是
|
|
3019
|
+
* 「不静默覆盖 + 尽量合并」,不是原子性。
|
|
3020
|
+
*/
|
|
3021
|
+
/**
|
|
3022
|
+
* 把工作区载荷改写成**增量视图**(相对最近一个同分块的点)——清单增量化,省的是磁盘,不是语义。
|
|
3023
|
+
*
|
|
3024
|
+
* 判据与安全边界(逐条):
|
|
3025
|
+
* · 基准 = 清单里最近一个 `workspace.payload` 非空、**分块完全相同**的点(只试最近几个,代价有界);
|
|
3026
|
+
* · 增量 = 「相对基准新增 / 改写的条目」+「基准里不再存在的路径」,条目**逐字比较**(同形绝对路径);
|
|
3027
|
+
* · 只有增量明显更小(< 全量的 60%)才用;否则原样全量落盘;
|
|
3028
|
+
* · 读路径由 ledger 物化(`resolvePayload`)⇒ 预览 / 回退 / 回收看到的永远是完整清单;
|
|
3029
|
+
* · 基准读不出来 / 分块对不上 / 任何异常 ⇒ 原样全量(绝不因为省字节而让点不可用);
|
|
3030
|
+
* · 基准载荷的保活由 ledger 的 `payloadChain` 负责(淘汰时不会把基准删掉)。
|
|
3031
|
+
*/
|
|
3032
|
+
private async storagePlanFor(
|
|
3033
|
+
record: RollbackPointRecord,
|
|
3034
|
+
entries: readonly RollbackLedgerManifestEntry[],
|
|
3035
|
+
): Promise<{ record: RollbackPointRecord; reuse: { hash: string; bytes: number; chain: string[] } | null }> {
|
|
3036
|
+
const full = { record, reuse: null }
|
|
3037
|
+
const payload = record.workspace.payload
|
|
3038
|
+
if (payload === null || record.workspace.ok !== true || payload.files.length === 0) return full
|
|
3039
|
+
if (payload.delta !== undefined) return full
|
|
3040
|
+
// 基准 = **本进程上一次成功留点**的内存摘要(绝不读别人的载荷/记录文件:L-08 / issue #89)。
|
|
3041
|
+
// 没有摘要(进程刚起 / 分块变了)⇒ 这一次落**全量锚点**,随后由摘要继续链下去。
|
|
3042
|
+
const base = this.deltaBase
|
|
3043
|
+
if (base !== null && sameChunks(base.chunks, payload.chunks)) {
|
|
3044
|
+
const diff = payloadDiffAgainstDigest(payload, base.entries)
|
|
3045
|
+
if (diff.added.length === 0 && diff.removed.length === 0) {
|
|
3046
|
+
// 工作区与该点**逐条相同** ⇒ 直接复用它的载荷文件(零额外字节、链不增长)
|
|
3047
|
+
const entry = entries.find((candidate) => candidate.workspace.payload === base.hash)
|
|
3048
|
+
if (entry !== undefined && (await this.ledger.hasPayload(base.hash))) {
|
|
3049
|
+
return { record, reuse: { hash: base.hash, bytes: entry.workspace.payloadBytes, chain: [...base.chain] } }
|
|
3050
|
+
}
|
|
3051
|
+
} else if (base.chain.length < ROLLBACK_DELTA_CHAIN_LIMIT) {
|
|
3052
|
+
const delta = buildPayloadDelta(payload, base.hash, base.chain, diff)
|
|
3053
|
+
if (delta !== null) {
|
|
3054
|
+
return {
|
|
3055
|
+
record: { ...record, workspace: { ...record.workspace, payload: compactPayloadForStorage(delta) } },
|
|
3056
|
+
reuse: null,
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
// 没有可用基准 / 分块不同 / 增量不划算 ⇒ 全量落盘(仍省掉冗余的 object 字段)
|
|
3062
|
+
return {
|
|
3063
|
+
record: { ...record, workspace: { ...record.workspace, payload: compactPayloadForStorage(payload) } },
|
|
3064
|
+
reuse: null,
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
/** 记下这份点作为下一次留点的增量基准(内存摘要,见 {@link deltaBase}) */
|
|
3069
|
+
private rememberDeltaBase(record: RollbackPointRecord, prepared: RollbackLedgerPrepared): void {
|
|
3070
|
+
const payload = record.workspace.payload
|
|
3071
|
+
const hash = prepared.payloadHash
|
|
3072
|
+
if (payload === null || hash === null) {
|
|
3073
|
+
this.deltaBase = null
|
|
3074
|
+
return
|
|
3075
|
+
}
|
|
3076
|
+
const entries = new Map<string, string>()
|
|
3077
|
+
for (const file of payload.files) entries.set(file.path, digestKeyOf(file))
|
|
3078
|
+
this.deltaBase = {
|
|
3079
|
+
hash,
|
|
3080
|
+
chain: prepared.entry.workspace.payloadChain ?? [hash],
|
|
3081
|
+
chunks: [...payload.chunks],
|
|
3082
|
+
entries,
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
|
|
2072
3086
|
private async appendRecord(record: RollbackPointRecord): Promise<{ ok: boolean; detail?: string }> {
|
|
3087
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
3088
|
+
const result = await this.appendRecordOnce(record)
|
|
3089
|
+
if (result.conflict !== true) {
|
|
3090
|
+
return result.detail !== undefined ? { ok: result.ok, detail: result.detail } : { ok: result.ok }
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
this.warn('rollback ledger busy: another instance kept changing the manifest (auto point skipped)')
|
|
3094
|
+
return { ok: false, detail: 'ledger-busy: manifest changed by another instance' }
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
/** `appendRecord` 的单次尝试;`conflict: true` = 这期间清单被别的实例改过,调用方应当重试。 */
|
|
3098
|
+
private async appendRecordOnce(record: RollbackPointRecord): Promise<{ ok: boolean; detail?: string; conflict?: boolean }> {
|
|
2073
3099
|
return await withLedgerWriteLock(this.ledgerPath, async () => {
|
|
2074
|
-
|
|
2075
|
-
const
|
|
2076
|
-
|
|
3100
|
+
// 读之前先取一次代际戳(不参与锁:它天生是跨进程的探针)
|
|
3101
|
+
const stampBefore = await this.ledger.manifestStamp()
|
|
3102
|
+
let manifest = await this.readManifest()
|
|
3103
|
+
if (this.initError !== undefined) {
|
|
3104
|
+
// 台账还有 v2 没迁完(或迁移失败):**拒写**,绝不覆盖原文件(下次启动重试)
|
|
3105
|
+
return { ok: false, detail: this.initError }
|
|
3106
|
+
}
|
|
3107
|
+
if (manifest.error !== undefined) {
|
|
3108
|
+
// 清单**读不出来**:先**从 records/ 重建**(旧点的最终真相在记录文件里),
|
|
3109
|
+
// 而不是拿一个空 entries 覆盖 —— 后者会把旧点整批从清单抹掉,紧接着 prunePayloads
|
|
3110
|
+
// 还会删掉它们的载荷 ⇒ 永久不可恢复。重建后**仍**读不出来才拒写(fail-closed)。
|
|
3111
|
+
manifest = await this.ledger.recoverManifest()
|
|
3112
|
+
if (manifest.error !== undefined) {
|
|
3113
|
+
return { ok: false, detail: 'ledger unreadable: ' + manifest.error }
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
// 清单增量化:能省就把这份载荷改写成「相对最近一个同分块载荷的增量」(读路径由 ledger 物化回来)。
|
|
3117
|
+
// 失败 / 不划算 / 没有可用基准一律**原样返回** —— 增量只影响磁盘占用,绝不参与正确性判断。
|
|
3118
|
+
const storage = await this.storagePlanFor(record, manifest.entries)
|
|
3119
|
+
const prepared = this.ledger.prepareRecord(storage.record, storage.reuse ?? undefined)
|
|
3120
|
+
const merged = [prepared.entry, ...manifest.entries.filter((r) => r.id !== record.id)]
|
|
3121
|
+
const pruned = this.selectPruneEntries(merged)
|
|
3122
|
+
// 本次新增字节:记录文件 + **首次落盘**的载荷(同一工作区态已存在 ⇒ 零额外字节)
|
|
3123
|
+
const payloadIsNew = prepared.payloadHash !== null && !(await this.ledger.hasPayload(prepared.payloadHash))
|
|
3124
|
+
const extraBytes = prepared.entry.recordBytes + (payloadIsNew ? prepared.entry.workspace.payloadBytes : 0)
|
|
3125
|
+
// 刚写入的这一条不参与本轮字节淘汰(否则 capturePoint 会回一个台账里查不到的幽灵点)
|
|
3126
|
+
const kept = await this.applyByteBudget(merged, pruned.kept, extraBytes, prepared.entry.id)
|
|
2077
3127
|
kept.sort((a, b) => (a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0))
|
|
3128
|
+
// 写之前复核:这份清单在这期间有没有被别的实例改过?改过就**不写**,交给调用方重读重试。
|
|
3129
|
+
// 读不出代际戳(任一时刻为 null)⇒ 不参与判定、照常写 —— 绝不因为一个可选探针失败就拒绝留点。
|
|
3130
|
+
const stampAfter = await this.ledger.manifestStamp()
|
|
3131
|
+
if (stampBefore !== null && stampAfter !== null && stampBefore !== stampAfter) {
|
|
3132
|
+
return { ok: false, conflict: true, detail: 'manifest changed by another instance' }
|
|
3133
|
+
}
|
|
2078
3134
|
try {
|
|
2079
3135
|
await fs.mkdir(this.root, { recursive: true })
|
|
2080
|
-
await
|
|
3136
|
+
await this.ledger.writePrepared(prepared)
|
|
2081
3137
|
} catch (error) {
|
|
2082
3138
|
return { ok: false, detail: errorDetail(error) }
|
|
2083
3139
|
}
|
|
2084
|
-
|
|
3140
|
+
try {
|
|
3141
|
+
await this.ledger.writeManifest(kept)
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
// 清单没写成 ⇒ 这一条**不算落进台账**:撤掉刚落的记录文件与无人引用的载荷(不留半截)
|
|
3144
|
+
await this.ledger.deleteRecords([prepared.entry.id])
|
|
3145
|
+
await this.ledger.prunePayloads(manifest.entries)
|
|
3146
|
+
return { ok: false, detail: errorDetail(error) }
|
|
3147
|
+
}
|
|
3148
|
+
// 记下「这次留点」作为下一次的增量基准(只在真正写成功后;失败不更新 ⇒ 链只跨成功落盘的点)
|
|
3149
|
+
this.rememberDeltaBase(record, prepared)
|
|
3150
|
+
const keptIds = new Set(kept.map((entry) => entry.id))
|
|
3151
|
+
const dropped = merged.filter((entry) => !keptIds.has(entry.id)).map((entry) => entry.id)
|
|
3152
|
+
await this.ledger.deleteRecords(dropped)
|
|
3153
|
+
await this.ledger.prunePayloads(kept)
|
|
3154
|
+
// **只在真的淘汰了记录时**才扫对象仓:对象回收需要逐份读被引用的载荷(代价 ∝ 载荷总量),
|
|
3155
|
+
// 而没有淘汰时不可能产生无人引用的对象 —— 每回合都扫一遍等于把「读全账本」搬进了留点热路径
|
|
3156
|
+
// (issue #89 正是要消灭这条路)。删除点 / 预算淘汰 / 清理入口都会走到这里。
|
|
3157
|
+
if (dropped.length > 0) await this.gcObjects(kept)
|
|
2085
3158
|
return { ok: true }
|
|
2086
3159
|
})
|
|
2087
3160
|
}
|
|
@@ -2108,7 +3181,7 @@ export class RollbackPointEngine {
|
|
|
2108
3181
|
boundarySeq: typeof input.boundarySeq === 'number' && Number.isFinite(input.boundarySeq) ? input.boundarySeq : null,
|
|
2109
3182
|
boundaryEndSeq: typeof input.boundaryEndSeq === 'number' && Number.isFinite(input.boundaryEndSeq) ? input.boundaryEndSeq : null,
|
|
2110
3183
|
})
|
|
2111
|
-
const workspace = await this.captureWorkspace(chunks)
|
|
3184
|
+
const workspace = await this.captureWorkspace(chunks, input.signal)
|
|
2112
3185
|
const spreadMs = Math.abs(session.capturedAt - workspace.capturedAt)
|
|
2113
3186
|
const record: RollbackPointRecord = {
|
|
2114
3187
|
schemaVersion: ROLLBACK_SCHEMA_VERSION,
|
|
@@ -2167,14 +3240,14 @@ export class RollbackPointEngine {
|
|
|
2167
3240
|
}
|
|
2168
3241
|
|
|
2169
3242
|
/** 台账快照(TTL;继承判据用 —— 只读、失败按空台账处理,绝不因此拒绝留点)。 */
|
|
2170
|
-
private async ledgerSnapshot(): Promise<
|
|
3243
|
+
private async ledgerSnapshot(): Promise<RollbackLedgerManifestEntry[]> {
|
|
2171
3244
|
const now = this.stamp()
|
|
2172
3245
|
if (this.ledgerSnapshotCache !== null && now - this.ledgerSnapshotCache.at < ROLLBACK_INHERITED_LEDGER_TTL_MS) {
|
|
2173
|
-
return this.ledgerSnapshotCache.
|
|
3246
|
+
return this.ledgerSnapshotCache.entries
|
|
2174
3247
|
}
|
|
2175
|
-
const ledger = await this.
|
|
2176
|
-
this.ledgerSnapshotCache = { at: now,
|
|
2177
|
-
return ledger.
|
|
3248
|
+
const ledger = await this.readManifest()
|
|
3249
|
+
this.ledgerSnapshotCache = { at: now, entries: ledger.entries }
|
|
3250
|
+
return ledger.entries
|
|
2178
3251
|
}
|
|
2179
3252
|
|
|
2180
3253
|
/**
|
|
@@ -2190,7 +3263,7 @@ export class RollbackPointEngine {
|
|
|
2190
3263
|
const records = await this.ledgerSnapshot()
|
|
2191
3264
|
for (const ancestor of ancestors) {
|
|
2192
3265
|
const hit = records.find((r) => r.sessionId === ancestor && r.turnSeq === turn)
|
|
2193
|
-
if (hit !== undefined) return
|
|
3266
|
+
if (hit !== undefined) return entrySummaryOf(hit)
|
|
2194
3267
|
}
|
|
2195
3268
|
return null
|
|
2196
3269
|
}
|
|
@@ -2214,13 +3287,13 @@ export class RollbackPointEngine {
|
|
|
2214
3287
|
* 任何东西),而 fork 子会话与父会话在**同一个项目键**下(fork 不改 cwd)⇒ 覆盖这点就够了。
|
|
2215
3288
|
* 台账空 ⇒ 直接返回(零扫描,热路径零代价);结果按 {@link ROLLBACK_FORK_LINKS_TTL_MS} 缓存。
|
|
2216
3289
|
*/
|
|
2217
|
-
private async refreshForkLinks(
|
|
3290
|
+
private async refreshForkLinks(entries: readonly RollbackLedgerManifestEntry[]): Promise<void> {
|
|
2218
3291
|
const now = this.stamp()
|
|
2219
3292
|
if (this.forkLinksAt !== 0 && now - this.forkLinksAt < ROLLBACK_FORK_LINKS_TTL_MS) return
|
|
2220
3293
|
this.forkLinksAt = now
|
|
2221
3294
|
const projectKeys = new Set<string>()
|
|
2222
|
-
for (const
|
|
2223
|
-
const key = unitProjectKeyOf(
|
|
3295
|
+
for (const entry of entries) {
|
|
3296
|
+
const key = unitProjectKeyOf(entry.unitId)
|
|
2224
3297
|
if (key !== null) projectKeys.add(key)
|
|
2225
3298
|
}
|
|
2226
3299
|
if (projectKeys.size === 0) {
|
|
@@ -2270,13 +3343,13 @@ export class RollbackPointEngine {
|
|
|
2270
3343
|
* · `inheritedFrom` / `inheritedSessionId` 如实标出它的来处。
|
|
2271
3344
|
* 有界({@link ROLLBACK_INHERITED_VIEW_MAX}),超出如实标记。
|
|
2272
3345
|
*/
|
|
2273
|
-
private inheritedViews(
|
|
3346
|
+
private inheritedViews(manifest: readonly RollbackLedgerManifestEntry[]): { entries: RollbackPointSummary[]; truncated: boolean } {
|
|
2274
3347
|
const entries: RollbackPointSummary[] = []
|
|
2275
3348
|
let truncated = false
|
|
2276
3349
|
const ownTurnsOf = (sessionId: string): Set<number> => {
|
|
2277
3350
|
const out = new Set<number>()
|
|
2278
|
-
for (const
|
|
2279
|
-
if (
|
|
3351
|
+
for (const entry of manifest) {
|
|
3352
|
+
if (entry.sessionId === sessionId && entry.turnSeq !== null) out.add(entry.turnSeq)
|
|
2280
3353
|
}
|
|
2281
3354
|
return out
|
|
2282
3355
|
}
|
|
@@ -2284,17 +3357,24 @@ export class RollbackPointEngine {
|
|
|
2284
3357
|
const chain = this.ancestorIdsOf(child)
|
|
2285
3358
|
if (chain.length === 0) continue
|
|
2286
3359
|
const own = ownTurnsOf(child)
|
|
3360
|
+
// 多代链(A→B→D)里同一个回合可能同时存在于多个祖先:**只取最近的那个**。
|
|
3361
|
+
// 没有这份集合时,D 会拿到两条 (sessionId=D, turn=N) 却指向**不同状态**的记录,
|
|
3362
|
+
// 客户端取第一条时结果随祖先顺序漂移。chain 由近到远 ⇒ 先到先得。
|
|
3363
|
+
const pickedTurns = new Set<number>()
|
|
2287
3364
|
for (const ancestor of chain) {
|
|
2288
|
-
for (const
|
|
2289
|
-
if (
|
|
2290
|
-
if (own.has(
|
|
3365
|
+
for (const entry of manifest) {
|
|
3366
|
+
if (entry.sessionId !== ancestor || entry.turnSeq === null) continue
|
|
3367
|
+
if (own.has(entry.turnSeq)) continue // 本会话自己有这一回合的点 ⇒ 用自己那个
|
|
3368
|
+
if (pickedTurns.has(entry.turnSeq)) continue // 更近的祖先已经提供过这一回合
|
|
2291
3369
|
if (entries.length >= ROLLBACK_INHERITED_VIEW_MAX) { truncated = true; break }
|
|
3370
|
+
pickedTurns.add(entry.turnSeq)
|
|
2292
3371
|
entries.push({
|
|
2293
|
-
|
|
3372
|
+
// 段摘要来自清单里**写入时算好**的那份(口径仍是 summarizeRollbackPoint)
|
|
3373
|
+
...entrySummaryOf(entry),
|
|
2294
3374
|
sessionId: child,
|
|
2295
|
-
unitId: childUnitIdOf(
|
|
2296
|
-
inheritedFrom:
|
|
2297
|
-
inheritedSessionId:
|
|
3375
|
+
unitId: childUnitIdOf(entry.unitId, child),
|
|
3376
|
+
inheritedFrom: entry.id,
|
|
3377
|
+
inheritedSessionId: entry.sessionId,
|
|
2298
3378
|
})
|
|
2299
3379
|
}
|
|
2300
3380
|
if (truncated) break
|
|
@@ -2308,12 +3388,13 @@ export class RollbackPointEngine {
|
|
|
2308
3388
|
|
|
2309
3389
|
async list(): Promise<RollbackListResult> {
|
|
2310
3390
|
const storage = await this.storageStatus()
|
|
2311
|
-
const ledger = await this.
|
|
2312
|
-
await this.refreshForkLinks(ledger.
|
|
2313
|
-
|
|
3391
|
+
const ledger = await this.readManifest()
|
|
3392
|
+
await this.refreshForkLinks(ledger.entries)
|
|
3393
|
+
// v3:列表**只读清单**(元数据 + 写入时算好的段摘要)—— 一个记录文件都不读(issue #89 的内存护栏)
|
|
3394
|
+
const points = [...ledger.entries]
|
|
2314
3395
|
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0))
|
|
2315
|
-
.map(
|
|
2316
|
-
const inherited = this.inheritedViews(ledger.
|
|
3396
|
+
.map(entrySummaryOf)
|
|
3397
|
+
const inherited = this.inheritedViews(ledger.entries)
|
|
2317
3398
|
const out: RollbackListResult = {
|
|
2318
3399
|
ok: storage.available,
|
|
2319
3400
|
storage,
|
|
@@ -2331,19 +3412,49 @@ export class RollbackPointEngine {
|
|
|
2331
3412
|
|
|
2332
3413
|
async read(id: string): Promise<{ point?: RollbackPointRecord; records: RollbackPointRecord[]; error?: string; storage: RollbackStorageStatus }> {
|
|
2333
3414
|
const storage = await this.storageStatus()
|
|
2334
|
-
const ledger = await this.
|
|
2335
|
-
const
|
|
3415
|
+
const ledger = await this.readManifest()
|
|
3416
|
+
const entry = ledger.entries.find((r) => r.id === id)
|
|
3417
|
+
// v3:records 只回传命中的那一条 —— 调用方只用 point;逐条读全部记录文件是不可接受的代价
|
|
2336
3418
|
const out: { point?: RollbackPointRecord; records: RollbackPointRecord[]; error?: string; storage: RollbackStorageStatus } = {
|
|
2337
|
-
records:
|
|
3419
|
+
records: [],
|
|
2338
3420
|
storage,
|
|
2339
3421
|
}
|
|
2340
|
-
|
|
2341
|
-
if (
|
|
3422
|
+
let error = ledger.error
|
|
3423
|
+
if (entry !== undefined) {
|
|
3424
|
+
const resolved = await this.readFullRecord(entry.id)
|
|
3425
|
+
if (resolved.record !== undefined) {
|
|
3426
|
+
out.point = resolved.record
|
|
3427
|
+
out.records = [resolved.record]
|
|
3428
|
+
} else if (resolved.error !== undefined) {
|
|
3429
|
+
error = error === undefined ? resolved.error : error + '; ' + resolved.error
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
if (error !== undefined) out.error = error
|
|
2342
3433
|
return out
|
|
2343
3434
|
}
|
|
2344
3435
|
|
|
2345
3436
|
/* -------------------------------------------------------------- 预览 */
|
|
2346
3437
|
|
|
3438
|
+
/**
|
|
3439
|
+
* 预览的**单条只读判定**(阶段 A,可并发跑):一次 bigint lstat + 身份快路径。
|
|
3440
|
+
* `unchanged = true` 表示「已证明与记录一致」(身份命中身份缓存且 sha/bytes 相同)⇒ 调用方直接跳过。
|
|
3441
|
+
*/
|
|
3442
|
+
private async previewScan(file: RollbackWorkspaceFile): Promise<PreviewScanResult> {
|
|
3443
|
+
try {
|
|
3444
|
+
// **lstat,不是 stat**:写入侧用 `symlink:'reject'`(lstat 是链接即抛),预览若跟随链接就会
|
|
3445
|
+
// 说「会创建 / 会覆盖」,执行却整段失败 —— 两侧必须是同一个口径。链接一律按「不可写」报。
|
|
3446
|
+
const stat = await fs.lstat(file.path, { bigint: true })
|
|
3447
|
+
if (stat.isSymbolicLink()) return { kind: 'unavailable' }
|
|
3448
|
+
const size = Number(stat.size)
|
|
3449
|
+
const regular = stat.isFile()
|
|
3450
|
+
const unchanged = regular && size === file.bytes
|
|
3451
|
+
&& (await this.unchangedByIdentity(file, regular ? fileIdentityOf(stat) : null))
|
|
3452
|
+
return { kind: 'ok', size, regular, unchanged }
|
|
3453
|
+
} catch (error) {
|
|
3454
|
+
return classifyPreviewStatFailure(error) === 'missing' ? { kind: 'missing' } : { kind: 'unavailable' }
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
|
|
2347
3458
|
private trackWarnings(record: RollbackPointRecord): RollbackGuidance[] {
|
|
2348
3459
|
const out: RollbackGuidance[] = []
|
|
2349
3460
|
for (const track of [record.workspace, record.session]) {
|
|
@@ -2390,46 +3501,78 @@ export class RollbackPointEngine {
|
|
|
2390
3501
|
for (const entry of payload.excludedDirs) proof.add(pathKey(entry.path))
|
|
2391
3502
|
for (const entry of payload.ignoredDirs ?? []) proof.add(pathKey(entry.path))
|
|
2392
3503
|
let refused: CreatedRemovalPlan['refused'] = null
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
refused = 'capture-incomplete'
|
|
2403
|
-
}
|
|
3504
|
+
// 路径索引**存在即并入**证明名单:写入条件已从「跳过名单被截断」推广为「覆盖自检不完整」
|
|
3505
|
+
// (读到一半被预算打断也会写索引)—— 这里只看 skippedTruncated 会漏读索引 ⇒ 把老文件当新建删掉。
|
|
3506
|
+
const index = payload.pathIndex ?? null
|
|
3507
|
+
if (index !== null) {
|
|
3508
|
+
try {
|
|
3509
|
+
for (const item of await this.readPathIndex(index)) proof.add(pathKey(item))
|
|
3510
|
+
} catch (error) {
|
|
3511
|
+
this.warn('rollback path index unreadable: ' + errorDetail(error))
|
|
3512
|
+
refused = 'capture-incomplete'
|
|
2404
3513
|
}
|
|
3514
|
+
} else if (payload.skippedTruncated) {
|
|
3515
|
+
// 逐条跳过名单被记录上限截断、又没有索引 ⇒ 「未记录」≠「留点之后新建」,证明不了
|
|
3516
|
+
refused = 'capture-incomplete'
|
|
3517
|
+
}
|
|
3518
|
+
// —— 分子树拒删范围 ——
|
|
3519
|
+
// 忽略目录 / 读不出的目录 / 走查没进去的子树:这三类的共同点是「其下内容在留点时刻未知」,
|
|
3520
|
+
// 但它们**各自只影响自己那棵子树**。旧口径一律全局拒删,代价是「忽略一个 outputs/ 就让整个
|
|
3521
|
+
// 工作区的新建文件一条都不删」;现在只拒这几棵子树,范围之外由证明名单照常判。
|
|
3522
|
+
// 走查中断且拿不到精确子树列表(老台账 / 栈被截断)时才退回全局。
|
|
3523
|
+
// 范围**说得清**才敢分子树拒删:忽略目录的逐条列表可能被记录上限截断(ignoredDirCount 是恒增
|
|
3524
|
+
// 计数),走查中断也可能拿不到子树列表(老台账 / 栈太深)。任一「说不清」⇒ 退回全局拒删。
|
|
3525
|
+
const ignoredKnown = (payload.ignoredDirs ?? []).length >= (payload.ignoredDirCount ?? 0)
|
|
3526
|
+
const unwalkedKnown = (payload.unwalkedDirs ?? []).length > 0 || payload.walkIncomplete !== true
|
|
3527
|
+
const scopesKnown = ignoredKnown && unwalkedKnown
|
|
3528
|
+
const refusalScopes: string[] = scopesKnown
|
|
3529
|
+
? [
|
|
3530
|
+
...(payload.ignoredDirs ?? []).map((entry) => entry.path),
|
|
3531
|
+
...payload.unreadableDirs,
|
|
3532
|
+
...(payload.unwalkedDirs ?? []),
|
|
3533
|
+
]
|
|
3534
|
+
: []
|
|
3535
|
+
if (refused === null && !scopesKnown) {
|
|
3536
|
+
// 证明名单不完整、且**说不出**不完整的确切范围 ⇒ 整个工作区一条都不删(安全方向)
|
|
3537
|
+
refused = 'capture-incomplete'
|
|
2405
3538
|
}
|
|
2406
|
-
// 读不出的目录:目录内容在留点时刻**未知**(路径索引只含走查过的路径)⇒ 同样证明不了
|
|
2407
|
-
if (refused === null && payload.unreadableDirs.length > 0) refused = 'capture-incomplete'
|
|
2408
|
-
// 用户忽略的整目录 ⇒ 其下内容在留点时刻未知 ⇒ 证明名单不完整(fail-closed)
|
|
2409
|
-
if (refused === null && payload.ignoreIncomplete === true) refused = 'capture-incomplete'
|
|
2410
3539
|
const chunks = payload.chunks
|
|
2411
3540
|
// 走查上限 = 全树:候选的**准入**由证明名单负责,条数上限不再是安全阀
|
|
2412
3541
|
//(旧上限 maxFiles*4 会在巨型工作区里静默漏掉真正的新建文件 —— 那是「删一半」,不是 fail-closed)
|
|
2413
3542
|
const limit = Number.MAX_SAFE_INTEGER
|
|
3543
|
+
// **时间预算**:回退期的走查同样不能无限跑(分块 = 会话 cwd,家目录 / 大仓库会走很久,
|
|
3544
|
+
// 而捕获侧早就有 30 s 预算 —— 只修一侧等于把 OOM 形态从留点搬到了回退)。
|
|
3545
|
+
// 中断只会让候选**变少**(安全方向),但必须如实置 truncated(绝不静默漏删、也不谎报完整)。
|
|
3546
|
+
const deadline = this.stamp() + this.rewindBudgetMs
|
|
3547
|
+
const outOfBudget = (): boolean => this.stamp() >= deadline
|
|
2414
3548
|
const candidates: string[] = []
|
|
2415
3549
|
let truncated = false
|
|
2416
3550
|
for (const chunk of chunks) {
|
|
2417
3551
|
if (!path.isAbsolute(chunk)) continue
|
|
2418
|
-
|
|
3552
|
+
if (outOfBudget()) { truncated = true; break }
|
|
3553
|
+
const listed = await this.listChunkFiles(path.resolve(chunk), sink, limit, undefined, await loadIgnoreRules(path.resolve(chunk)), outOfBudget)
|
|
2419
3554
|
if (listed === null) continue
|
|
2420
3555
|
if (listed.truncated) truncated = true
|
|
2421
3556
|
for (const file of listed.files) {
|
|
3557
|
+
if (outOfBudget()) { truncated = true; break }
|
|
2422
3558
|
if (proof.has(pathKey(file))) continue
|
|
2423
3559
|
candidates.push(file)
|
|
2424
3560
|
}
|
|
2425
3561
|
}
|
|
2426
|
-
|
|
3562
|
+
// 警告只在**全局拒绝**时给:分子树拒绝由 createdSkipped 逐条说明(那句「本次不删除任何
|
|
3563
|
+
// 『留点后新建』的路径」在分子树拒绝下是假话 —— 文案诚实要求按真实范围分派)。
|
|
3564
|
+
const refusalWarnings = refused === null ? [] : this.createdRefusalWarnings(payload)
|
|
2427
3565
|
const all: string[] = []
|
|
2428
3566
|
const paths: string[] = []
|
|
2429
3567
|
const skipped: string[] = []
|
|
2430
3568
|
for (const file of candidates) {
|
|
2431
3569
|
all.push(file)
|
|
2432
3570
|
// 拒删的也留在「未记录」清单里(如实报告),但绝不进删除集合
|
|
3571
|
+
// 分子树拒删(忽略目录 / 读不出的目录 / 没走查到的子树):只拒这几棵子树里的候选
|
|
3572
|
+
if (refusalScopes.length > 0 && refusalScopes.some((dir) => isInsidePath(file, dir))) {
|
|
3573
|
+
skipped.push(file)
|
|
3574
|
+
continue
|
|
3575
|
+
}
|
|
2433
3576
|
if (this.createdRemovalRefusal(file, chunks) !== null) { skipped.push(file); continue }
|
|
2434
3577
|
let stat
|
|
2435
3578
|
try {
|
|
@@ -2528,7 +3671,7 @@ export class RollbackPointEngine {
|
|
|
2528
3671
|
/** 只读预览:零写入(不过确认门、不拍 guard、不写任何字节)。 */
|
|
2529
3672
|
async preview(id: string): Promise<RollbackPreviewResult> {
|
|
2530
3673
|
const storage = await this.storageStatus()
|
|
2531
|
-
const ledger = await this.
|
|
3674
|
+
const ledger = await this.readManifest()
|
|
2532
3675
|
const warnings: RollbackGuidance[] = []
|
|
2533
3676
|
if (ledger.error !== undefined) warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail: ledger.error })
|
|
2534
3677
|
const workspace: RollbackPreviewWorkspace = {
|
|
@@ -2551,8 +3694,17 @@ export class RollbackPointEngine {
|
|
|
2551
3694
|
if (!storage.available) {
|
|
2552
3695
|
return { ...base, code: 'storage-unavailable', detail: storage.detail, guidance: storage.guidance }
|
|
2553
3696
|
}
|
|
2554
|
-
const
|
|
2555
|
-
if (
|
|
3697
|
+
const entry = ledger.entries.find((r) => r.id === id)
|
|
3698
|
+
if (entry === undefined) return { ...base, code: 'record-not-found' }
|
|
3699
|
+
const resolved = await this.readFullRecord(entry.id)
|
|
3700
|
+
if (resolved.record === undefined) {
|
|
3701
|
+
// 引用解析失败(载荷文件缺失 / 哈希不符 / 形状不符):**如实失败** —— 绝不拿半个记录去预览
|
|
3702
|
+
// (半个记录会让「可回退」看起来成立,然后回退真的写错文件)
|
|
3703
|
+
const detail = resolved.error ?? 'record-incomplete'
|
|
3704
|
+
warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail })
|
|
3705
|
+
return { ...base, code: 'record-incomplete', detail }
|
|
3706
|
+
}
|
|
3707
|
+
const record = resolved.record
|
|
2556
3708
|
for (const guidance of this.trackWarnings(record)) warnings.push(guidance)
|
|
2557
3709
|
|
|
2558
3710
|
// 工作区态
|
|
@@ -2571,18 +3723,28 @@ export class RollbackPointEngine {
|
|
|
2571
3723
|
// 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
|
|
2572
3724
|
const diffBudget = { bytes: 0, work: 0 }
|
|
2573
3725
|
// T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
3726
|
+
const previewFiles = wsPayload.files
|
|
3727
|
+
// 阶段 A(**并发**):逐条一次 bigint lstat + 身份快路径。大仓库这里是预览的大头:
|
|
3728
|
+
// 真机 34k 条实测 —— 串行 lstat ≈ 6 s、串行真读 ≈ 13 s;并发后降到亚秒级 + 只读真正变了的那几条。
|
|
3729
|
+
const scans = new Array<PreviewScanResult>(previewFiles.length)
|
|
3730
|
+
let scanCursor = 0
|
|
3731
|
+
const scanWorkers = Array.from(
|
|
3732
|
+
{ length: Math.min(this.captureConcurrency, Math.max(1, previewFiles.length)) },
|
|
3733
|
+
async () => {
|
|
3734
|
+
for (;;) {
|
|
3735
|
+
const index = scanCursor
|
|
3736
|
+
scanCursor += 1
|
|
3737
|
+
if (index >= previewFiles.length) return
|
|
3738
|
+
scans[index] = await this.previewScan(previewFiles[index] as RollbackWorkspaceFile)
|
|
3739
|
+
}
|
|
3740
|
+
},
|
|
3741
|
+
)
|
|
3742
|
+
await Promise.all(scanWorkers)
|
|
3743
|
+
// 阶段 B(**顺序**):只对「变了 / 没了 / 不可写」的条目按记录顺序装配 —— 列表顺序与行级 diff 的
|
|
3744
|
+
// 共享预算因此仍**完全确定**(与并发前逐条一致,测试可逐字对账)。
|
|
3745
|
+
for (let index = 0; index < previewFiles.length; index += 1) {
|
|
3746
|
+
const file = previewFiles[index] as RollbackWorkspaceFile
|
|
3747
|
+
const outcome: PreviewScanResult = scans[index] ?? { kind: 'unavailable' }
|
|
2586
3748
|
if (outcome.kind === 'missing') {
|
|
2587
3749
|
// **只有 ENOENT** 才算「当前确实不存在」⇒ 回退会重新创建它
|
|
2588
3750
|
missing += 1
|
|
@@ -2600,6 +3762,8 @@ export class RollbackPointEngine {
|
|
|
2600
3762
|
changedFiles.push({ path: file.path, status: 'overwrite', ...previewCountsUnavailable() })
|
|
2601
3763
|
continue
|
|
2602
3764
|
}
|
|
3765
|
+
// 身份快路径已证明「与记录一致」⇒ 一个字段都不改(内容一致的文件不列进列表)
|
|
3766
|
+
if (outcome.unchanged) continue
|
|
2603
3767
|
const { size, regular } = outcome
|
|
2604
3768
|
const sizeMatches = regular && size === file.bytes
|
|
2605
3769
|
const withinCaps = file.bytes <= this.previewDiffMaxFileBytes && size <= this.previewDiffMaxFileBytes
|
|
@@ -2608,7 +3772,7 @@ export class RollbackPointEngine {
|
|
|
2608
3772
|
// ② 大小不一致时也得读一次才能给行级统计(超限的大文件不白读)。
|
|
2609
3773
|
if (regular && (sizeMatches || withinCaps)) {
|
|
2610
3774
|
try {
|
|
2611
|
-
current = await
|
|
3775
|
+
current = await this.readFileBytes(file.path)
|
|
2612
3776
|
} catch {
|
|
2613
3777
|
current = null
|
|
2614
3778
|
}
|
|
@@ -2719,9 +3883,15 @@ export class RollbackPointEngine {
|
|
|
2719
3883
|
const storage = await this.storageStatus()
|
|
2720
3884
|
if (!storage.available) return { ...base, code: 'storage-unavailable', detail: storage.detail }
|
|
2721
3885
|
if (filePath === '') return { ...base, detail: 'empty-path' }
|
|
2722
|
-
const ledger = await this.
|
|
2723
|
-
const
|
|
2724
|
-
if (
|
|
3886
|
+
const ledger = await this.readManifest()
|
|
3887
|
+
const entry = ledger.entries.find((r) => r.id === id)
|
|
3888
|
+
if (entry === undefined) return { ...base, code: 'record-not-found' }
|
|
3889
|
+
const resolved = await this.readFullRecord(entry.id)
|
|
3890
|
+
if (resolved.record === undefined) {
|
|
3891
|
+
// 引用解析失败:**如实失败**(RollbackFileDiffResult 没有 warnings 槽位,用 detail 说清楚)
|
|
3892
|
+
return { ...base, code: 'record-incomplete', detail: resolved.error ?? 'record-incomplete' }
|
|
3893
|
+
}
|
|
3894
|
+
const record = resolved.record
|
|
2725
3895
|
const payload = record.workspace.payload
|
|
2726
3896
|
if (!record.workspace.ok || payload === null) {
|
|
2727
3897
|
const detail = record.workspace.reasonDetail
|
|
@@ -2789,7 +3959,7 @@ export class RollbackPointEngine {
|
|
|
2789
3959
|
const result = diffLines(
|
|
2790
3960
|
current === null ? '' : DIFF_TEXT_DECODER.decode(current),
|
|
2791
3961
|
DIFF_TEXT_DECODER.decode(snapshot),
|
|
2792
|
-
{ context: ROLLBACK_FILE_DIFF_LIMITS.context },
|
|
3962
|
+
{ context: ROLLBACK_FILE_DIFF_LIMITS.context, maxD: ROLLBACK_FILE_DIFF_LIMITS.maxD },
|
|
2793
3963
|
)
|
|
2794
3964
|
return {
|
|
2795
3965
|
...view,
|
|
@@ -2828,12 +3998,20 @@ export class RollbackPointEngine {
|
|
|
2828
3998
|
if (!storage.available) {
|
|
2829
3999
|
return this.result({ outcome: 'failed', code: 'storage-unavailable', detail: storage.detail, id: input.id, storage, guidance: storage.guidance })
|
|
2830
4000
|
}
|
|
2831
|
-
const ledger = await this.
|
|
4001
|
+
const ledger = await this.readManifest()
|
|
2832
4002
|
if (ledger.error !== undefined) warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail: ledger.error })
|
|
2833
|
-
const
|
|
2834
|
-
if (
|
|
4003
|
+
const entry = ledger.entries.find((r) => r.id === input.id)
|
|
4004
|
+
if (entry === undefined) {
|
|
2835
4005
|
return this.result({ outcome: 'failed', code: 'record-not-found', id: input.id, storage, warnings })
|
|
2836
4006
|
}
|
|
4007
|
+
const resolved = await this.readFullRecord(entry.id)
|
|
4008
|
+
if (resolved.record === undefined) {
|
|
4009
|
+
// 记录读不完整(载荷缺失 / 哈希不符 / 形状不符):**零写入**失败,绝不拿半个记录去回退
|
|
4010
|
+
const detail = resolved.error ?? 'record-incomplete'
|
|
4011
|
+
warnings.push({ code: 'rollback.warn.ledgerUnreadable', detail })
|
|
4012
|
+
return this.result({ outcome: 'failed', code: 'record-incomplete', detail, id: input.id, storage, warnings })
|
|
4013
|
+
}
|
|
4014
|
+
const record = resolved.record
|
|
2837
4015
|
// 门 1(fail-closed 确认门):必须恰好 true
|
|
2838
4016
|
if (input.userConfirmed !== true) {
|
|
2839
4017
|
return this.result({ outcome: 'denied', code: 'confirmation-required', detail: 'userConfirmed!==true', id: input.id, storage, warnings })
|
|
@@ -2963,7 +4141,9 @@ export class RollbackPointEngine {
|
|
|
2963
4141
|
if (!path.isAbsolute(file)) return 'not-absolute'
|
|
2964
4142
|
if (!chunks.some((chunk) => path.isAbsolute(chunk) && isInsidePath(file, chunk))) return 'outside-chunk'
|
|
2965
4143
|
if (isInsidePath(file, this.root)) return 'inside-data-dir'
|
|
2966
|
-
|
|
4144
|
+
// Windows 文件系统**不分大小写**:`.GIT` / `.Git` / `NODE_MODULES` 是同一个目录,必须折大小写后
|
|
4145
|
+
// 再比 —— 不折会让它们的子树失去保护(本文件其它路径判据都走 win32Fold,这里此前漏了)。
|
|
4146
|
+
const segments = win32Fold(path.resolve(file)).split(/[\\/]+/)
|
|
2967
4147
|
if (segments.includes('.git')) return 'excluded-dir'
|
|
2968
4148
|
if (segments.includes('node_modules')) return 'excluded-dir'
|
|
2969
4149
|
return null
|
|
@@ -2984,6 +4164,8 @@ export class RollbackPointEngine {
|
|
|
2984
4164
|
const detail: string[] = []
|
|
2985
4165
|
const writable: { file: RollbackWorkspaceFile; bytes: Uint8Array }[] = []
|
|
2986
4166
|
let failed = 0
|
|
4167
|
+
// 「已与记录一致」的条目数(身份快路径证明)—— 后面「0 变更」的文案与统计要用
|
|
4168
|
+
let unchanged = 0
|
|
2987
4169
|
for (const file of payload.files) {
|
|
2988
4170
|
// 门:记录里的路径必须落在记录里的分块内(记录不可成为写原语)
|
|
2989
4171
|
const inChunk = payload.chunks.some((chunk) => path.isAbsolute(chunk) && isInsidePath(file.path, chunk))
|
|
@@ -2992,6 +4174,17 @@ export class RollbackPointEngine {
|
|
|
2992
4174
|
pushDetail(detail, 'invalid-path ' + file.path)
|
|
2993
4175
|
continue
|
|
2994
4176
|
}
|
|
4177
|
+
// **身份快路径**:身份(dev:ino:size:mtimeNs:ctimeNs:mode)命中身份缓存、且缓存的 sha/bytes 与记录
|
|
4178
|
+
// 一致 ⇒ 这个文件当前就是留点时刻的内容,**它根本不会被写** ⇒ 连对象都不必读。
|
|
4179
|
+
// 大仓库实测:旧口径下阶段 1 会把 34k 个对象(609 MiB)全读一遍 + 哈希一遍(回退 60 s+ 全在这)。
|
|
4180
|
+
// 语义变化(有意、且更准):阶段 1 的对象校验只对**真要写**的条目做 —— 一个用不到的对象坏掉,
|
|
4181
|
+
// 不该中止整次回退;真要用它时(该文件确实变了)照样会 `object-missing` / `object-hash-mismatch` 硬失败。
|
|
4182
|
+
const info = await this.fileStatOf(file.path)
|
|
4183
|
+
if (info.error === undefined && info.isFile && info.size === file.bytes
|
|
4184
|
+
&& (await this.unchangedByIdentity(file, info.identity))) {
|
|
4185
|
+
unchanged += 1
|
|
4186
|
+
continue
|
|
4187
|
+
}
|
|
2995
4188
|
try {
|
|
2996
4189
|
const bytes = await this.readObject(file.object)
|
|
2997
4190
|
if (sha256Hex(bytes) !== file.sha256) {
|
|
@@ -3042,7 +4235,6 @@ export class RollbackPointEngine {
|
|
|
3042
4235
|
// E1:内容已与快照一致的条目**不再写** —— 「0 变更」就要是真的 0 次写入
|
|
3043
4236
|
// (否则工作区已与该点一致时也会重写全部文件,白耗 I/O 并抖动 mtime)。
|
|
3044
4237
|
const needed: { file: RollbackWorkspaceFile; bytes: Uint8Array }[] = []
|
|
3045
|
-
let unchanged = 0
|
|
3046
4238
|
for (const item of writable) {
|
|
3047
4239
|
let identical = false
|
|
3048
4240
|
try {
|
|
@@ -3070,16 +4262,53 @@ export class RollbackPointEngine {
|
|
|
3070
4262
|
if (removal.refused !== null) noop.createdRefusedReason = removal.refused
|
|
3071
4263
|
return noop
|
|
3072
4264
|
}
|
|
3073
|
-
//
|
|
3074
|
-
|
|
4265
|
+
// 前像(**字节落盘、索引随栈**):失败时要能整体回到恢复前状态 —— 只覆盖**真会被写**的文件。
|
|
4266
|
+
// 字节之所以不留在内存:大工作区整体回退时前像峰值 = 待写总字节(可达数百 MB),
|
|
4267
|
+
// 与 issue #89 的 OOM 同族;落盘后内存只随**文件条数**增长。
|
|
4268
|
+
// 暂存目录建不出来 ⇒ **零写入失败**(fail-closed):回退没有安全网就不该开始。
|
|
4269
|
+
const stager = await this.openUndoStager()
|
|
4270
|
+
if (stager === null) {
|
|
4271
|
+
pushDetail(detail, 'undo-staging-unavailable root=' + this.root + ' zeroWrites=true')
|
|
4272
|
+
return {
|
|
4273
|
+
state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
|
|
4274
|
+
restored: 0, failed: needed.length + removal.paths.length,
|
|
4275
|
+
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
4276
|
+
}
|
|
4277
|
+
}
|
|
4278
|
+
const preCount = needed.length
|
|
3075
4279
|
for (const item of needed) {
|
|
3076
4280
|
let bytes: Uint8Array | null = null
|
|
4281
|
+
let unreadable: string | null = null
|
|
3077
4282
|
try {
|
|
3078
|
-
bytes = await
|
|
3079
|
-
} catch {
|
|
3080
|
-
|
|
4283
|
+
bytes = await this.readFileBytes(item.file.path)
|
|
4284
|
+
} catch (error) {
|
|
4285
|
+
// **只认 ENOENT = 当时确实不存在**。其它 errno(EACCES / EMFILE / EBUSY…)是
|
|
4286
|
+
// 「读不出来」而不是「没有」—— 折成 null 会让回滚阶段把这个**存在**的文件删掉
|
|
4287
|
+
// (UndoStager.stage(null) 的语义正是「回滚时删掉它」)。
|
|
4288
|
+
if ((error as { code?: unknown }).code !== 'ENOENT') unreadable = errorDetail(error)
|
|
4289
|
+
}
|
|
4290
|
+
if (unreadable !== null) {
|
|
4291
|
+
await stager.dispose()
|
|
4292
|
+
pushDetail(detail, 'preimage-unreadable ' + item.file.path + ' ' + unreadable + ' zeroWrites=true')
|
|
4293
|
+
return {
|
|
4294
|
+
state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
|
|
4295
|
+
restored: 0, failed: needed.length + removal.paths.length,
|
|
4296
|
+
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
try {
|
|
4300
|
+
await stager.stage(item.file.path, bytes)
|
|
4301
|
+
} catch (error) {
|
|
4302
|
+
// 暂存写不下去(ENOSPC / EACCES):**零写入失败** —— 绝不带着不完整的前像开工,
|
|
4303
|
+
// 也绝不让异常穿出引擎(那会被路由兜成没有机器码的裸 500,且暂存目录无人清理)。
|
|
4304
|
+
await stager.dispose()
|
|
4305
|
+
pushDetail(detail, 'preimage-stage-failed ' + item.file.path + ' ' + errorDetail(error) + ' zeroWrites=true')
|
|
4306
|
+
return {
|
|
4307
|
+
state: 'workspace', status: 'failed', reasonCode: 'restore-failed', detail,
|
|
4308
|
+
restored: 0, failed: needed.length + removal.paths.length,
|
|
4309
|
+
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
4310
|
+
}
|
|
3081
4311
|
}
|
|
3082
|
-
pre.push({ path: item.file.path, existed: bytes !== null, bytes })
|
|
3083
4312
|
}
|
|
3084
4313
|
let restored = 0
|
|
3085
4314
|
let writtenBytes = 0
|
|
@@ -3098,7 +4327,7 @@ export class RollbackPointEngine {
|
|
|
3098
4327
|
if (failed === 0) {
|
|
3099
4328
|
for (const item of needed) {
|
|
3100
4329
|
try {
|
|
3101
|
-
const current = await
|
|
4330
|
+
const current = await this.readFileBytes(item.file.path)
|
|
3102
4331
|
if (sha256Hex(current) !== item.file.sha256) mismatched.push(item.file.path)
|
|
3103
4332
|
} catch {
|
|
3104
4333
|
mismatched.push(item.file.path)
|
|
@@ -3106,18 +4335,11 @@ export class RollbackPointEngine {
|
|
|
3106
4335
|
}
|
|
3107
4336
|
}
|
|
3108
4337
|
if (failed > 0 || mismatched.length > 0) {
|
|
3109
|
-
const rollbackFailed
|
|
3110
|
-
|
|
3111
|
-
try {
|
|
3112
|
-
if (item.existed && item.bytes !== null) await this.writeFile(item.path, item.bytes)
|
|
3113
|
-
else await fs.rm(item.path, { force: true })
|
|
3114
|
-
} catch (error) {
|
|
3115
|
-
rollbackFailed.push(item.path + ' ' + errorDetail(error))
|
|
3116
|
-
}
|
|
3117
|
-
}
|
|
4338
|
+
const rollbackFailed = await stager.rollback((target, bytes) => this.writeFile(target, bytes))
|
|
4339
|
+
await stager.dispose()
|
|
3118
4340
|
for (const target of mismatched) pushDetail(detail, 'verify-mismatch ' + target)
|
|
3119
4341
|
for (const line of rollbackFailed) pushDetail(detail, 'rolled-back-failed ' + line)
|
|
3120
|
-
detail.push('restored=0 failed=' + String(failed + mismatched.length) + ' rolledBack=' + String(
|
|
4342
|
+
detail.push('restored=0 failed=' + String(failed + mismatched.length) + ' rolledBack=' + String(preCount - rollbackFailed.length) + ' unchanged=' + String(unchanged))
|
|
3121
4343
|
return {
|
|
3122
4344
|
state: 'workspace', status: 'failed',
|
|
3123
4345
|
reasonCode: mismatched.length > 0 ? 'verify-failed' : 'restore-failed',
|
|
@@ -3132,7 +4354,8 @@ export class RollbackPointEngine {
|
|
|
3132
4354
|
// 阶段 4(用户 2026-10-11 定案 A):删除「回退点之后新建」的路径。
|
|
3133
4355
|
// 顺序刻死:记录在案的路径**先写并复核通过**,再删新建的 —— 先删后写一旦写失败就回不去了。
|
|
3134
4356
|
// 删除同样可回滚:先读前像(内存)→ 删;任一失败就把已写的还原、把已删的写回。
|
|
3135
|
-
|
|
4357
|
+
// 待删文件的前像同样落盘(stage(path, bytes) 的语义就是「回滚时把它写回」)
|
|
4358
|
+
let destroyedCount = 0
|
|
3136
4359
|
const removalFailures: string[] = []
|
|
3137
4360
|
// 逐条记录**真的删掉**的路径(全量,不再切 20 条):它与预览的 workspace.removals 同源同序,
|
|
3138
4361
|
// 界面把两份清单当作同一件事(「预览说会删的 = 实际删的」)。
|
|
@@ -3146,7 +4369,8 @@ export class RollbackPointEngine {
|
|
|
3146
4369
|
removal.skipped.push(target)
|
|
3147
4370
|
continue
|
|
3148
4371
|
}
|
|
3149
|
-
|
|
4372
|
+
await stager.stage(target, bytes)
|
|
4373
|
+
destroyedCount += 1
|
|
3150
4374
|
await fs.rm(target, { force: true })
|
|
3151
4375
|
removed += 1
|
|
3152
4376
|
removedPaths.push(target)
|
|
@@ -3155,27 +4379,13 @@ export class RollbackPointEngine {
|
|
|
3155
4379
|
}
|
|
3156
4380
|
}
|
|
3157
4381
|
if (removalFailures.length > 0) {
|
|
3158
|
-
const rollbackFailed
|
|
3159
|
-
|
|
3160
|
-
try {
|
|
3161
|
-
if (item.existed && item.bytes !== null) await this.writeFile(item.path, item.bytes)
|
|
3162
|
-
else await fs.rm(item.path, { force: true })
|
|
3163
|
-
} catch (error) {
|
|
3164
|
-
rollbackFailed.push(item.path + ' ' + errorDetail(error))
|
|
3165
|
-
}
|
|
3166
|
-
}
|
|
3167
|
-
for (const item of destroyed) {
|
|
3168
|
-
try {
|
|
3169
|
-
await this.writeFile(item.path, item.bytes)
|
|
3170
|
-
} catch (error) {
|
|
3171
|
-
rollbackFailed.push(item.path + ' ' + errorDetail(error))
|
|
3172
|
-
}
|
|
3173
|
-
}
|
|
4382
|
+
const rollbackFailed = await stager.rollback((target, bytes) => this.writeFile(target, bytes))
|
|
4383
|
+
await stager.dispose()
|
|
3174
4384
|
for (const line of removalFailures) pushDetail(detail, 'created-remove-failed ' + line)
|
|
3175
4385
|
for (const line of rollbackFailed) pushDetail(detail, 'rolled-back-failed ' + line)
|
|
3176
4386
|
detail.push(
|
|
3177
4387
|
'restored=0 removed=0 failed=' + String(removalFailures.length) +
|
|
3178
|
-
' rolledBack=' + String(
|
|
4388
|
+
' rolledBack=' + String(preCount + destroyedCount - rollbackFailed.length) +
|
|
3179
4389
|
' unchanged=' + String(unchanged),
|
|
3180
4390
|
)
|
|
3181
4391
|
return {
|
|
@@ -3203,6 +4413,8 @@ export class RollbackPointEngine {
|
|
|
3203
4413
|
}
|
|
3204
4414
|
// 捕获不完整 ⇒ 一条都没删:如实回报原因(界面据此说「因为捕获不完整,一条都没动」)
|
|
3205
4415
|
if (removal.refused !== null) segment.createdRefusedReason = removal.refused
|
|
4416
|
+
// 前像字节用完即清(成功路径):留在磁盘上只会变成无人认领的残留
|
|
4417
|
+
await stager.dispose()
|
|
3206
4418
|
return segment
|
|
3207
4419
|
}
|
|
3208
4420
|
|
|
@@ -3285,22 +4497,87 @@ export class RollbackPointEngine {
|
|
|
3285
4497
|
const storage = await this.storageStatus()
|
|
3286
4498
|
if (!storage.available) return { ok: false, code: 'storage-unavailable', detail: storage.detail, id }
|
|
3287
4499
|
return await withLedgerWriteLock<RollbackRemoveResult>(this.ledgerPath, async () => {
|
|
3288
|
-
const ledger = await this.
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
if (record.protected || record.kind === 'guard') {
|
|
3292
|
-
return { ok: false, code: 'protected-point', detail: record.kind, id }
|
|
4500
|
+
const ledger = await this.readManifest()
|
|
4501
|
+
if (this.initError !== undefined) {
|
|
4502
|
+
return { ok: false, code: 'ledger-write-failed', detail: this.initError, id }
|
|
3293
4503
|
}
|
|
3294
|
-
|
|
4504
|
+
// 删除只看**清单元数据**(protected / kind 都在清单里)—— 一个记录文件都不读、一个载荷都不读
|
|
4505
|
+
const entry = ledger.entries.find((r) => r.id === id)
|
|
4506
|
+
if (entry === undefined) return { ok: false, code: 'record-not-found', id }
|
|
4507
|
+
if (entry.protected || entry.kind === 'guard') {
|
|
4508
|
+
return { ok: false, code: 'protected-point', detail: entry.kind, id }
|
|
4509
|
+
}
|
|
4510
|
+
const kept = ledger.entries.filter((r) => r.id !== id)
|
|
3295
4511
|
try {
|
|
3296
|
-
await
|
|
4512
|
+
await this.ledger.writeManifest(kept)
|
|
3297
4513
|
} catch (error) {
|
|
3298
4514
|
return { ok: false, code: 'ledger-write-failed', detail: errorDetail(error), id }
|
|
3299
4515
|
}
|
|
4516
|
+
await this.ledger.deleteRecords([id])
|
|
4517
|
+
await this.ledger.prunePayloads(kept)
|
|
3300
4518
|
await this.gcObjects(kept)
|
|
3301
4519
|
return { ok: true, code: 'deleted', id }
|
|
3302
4520
|
})
|
|
3303
4521
|
}
|
|
4522
|
+
|
|
4523
|
+
/**
|
|
4524
|
+
* 磁盘清理入口(引擎侧):删**全部**自动点(includeGuards=true 时连 guard 也删),
|
|
4525
|
+
* protected 点**永不删**(生产路径造不出来,只有测试能造)。随后回收无人引用的载荷与对象。
|
|
4526
|
+
*
|
|
4527
|
+
* 零写入拒绝路径照旧:存储不可用 / 清单读不出来(形状不符、迁移失败、迁移中)⇒ **抛带 code 的错误**,
|
|
4528
|
+
* 一个字节都不动、也绝不静默返回全 0(「什么都没删」与「读不出来所以没删」是两件事)。
|
|
4529
|
+
* 返回的 bytes = 本次真正删掉的字节(记录文件 + 载荷文件 + 对象文件)。
|
|
4530
|
+
*/
|
|
4531
|
+
async purgeLedger(options: { includeGuards?: boolean } = {}): Promise<{
|
|
4532
|
+
removedRecords: number
|
|
4533
|
+
removedPayloads: number
|
|
4534
|
+
removedObjects: number
|
|
4535
|
+
bytes: number
|
|
4536
|
+
}> {
|
|
4537
|
+
const storage = await this.storageStatus()
|
|
4538
|
+
if (!storage.available) {
|
|
4539
|
+
throw new RollbackLedgerRefusedError('storage-unavailable', storage.detail ?? 'rollback storage unavailable')
|
|
4540
|
+
}
|
|
4541
|
+
return await withLedgerWriteLock(this.ledgerPath, async () => {
|
|
4542
|
+
const ledger = await this.readManifest()
|
|
4543
|
+
if (this.initError !== undefined) throw new RollbackLedgerRefusedError('ledger-unreadable', this.initError)
|
|
4544
|
+
if (ledger.error !== undefined) throw new RollbackLedgerRefusedError('ledger-unreadable', ledger.error)
|
|
4545
|
+
const includeGuards = options.includeGuards === true
|
|
4546
|
+
const victims = ledger.entries.filter((entry) => !entry.protected && (includeGuards || entry.kind === 'auto'))
|
|
4547
|
+
if (victims.length === 0) return { removedRecords: 0, removedPayloads: 0, removedObjects: 0, bytes: 0 }
|
|
4548
|
+
const victimIds = new Set(victims.map((entry) => entry.id))
|
|
4549
|
+
const kept = ledger.entries.filter((entry) => !victimIds.has(entry.id))
|
|
4550
|
+
// 先落清单、再删文件:崩在中途也只会留下「清单已删、文件还在」的垃圾,不会留下指向空文件的清单
|
|
4551
|
+
await this.ledger.writeManifest(kept)
|
|
4552
|
+
const records = await this.ledger.deleteRecords([...victimIds])
|
|
4553
|
+
const payloads = await this.ledger.prunePayloads(kept)
|
|
4554
|
+
const objects = await this.ledger.collectObjects(kept)
|
|
4555
|
+
if (objects.aborted === null) {
|
|
4556
|
+
await this.gcPathCache(new Set([...objects.referencedPaths].map(pathKey)))
|
|
4557
|
+
} else {
|
|
4558
|
+
this.warn('rollback purge: object gc skipped: ' + objects.aborted)
|
|
4559
|
+
}
|
|
4560
|
+
return {
|
|
4561
|
+
removedRecords: records.removed,
|
|
4562
|
+
removedPayloads: payloads.removed,
|
|
4563
|
+
removedObjects: objects.removed,
|
|
4564
|
+
bytes: records.bytes + payloads.bytes + objects.bytes,
|
|
4565
|
+
}
|
|
4566
|
+
})
|
|
4567
|
+
}
|
|
4568
|
+
}
|
|
4569
|
+
|
|
4570
|
+
/**
|
|
4571
|
+
* 台账清理的**显式拒绝**(形状不符 / 存储不可用):带机器可读 code,绝不静默返回全 0。
|
|
4572
|
+
* 路由层按 code 映射 HTTP(与既有 RollbackCode 冻结集同源)。
|
|
4573
|
+
*/
|
|
4574
|
+
export class RollbackLedgerRefusedError extends Error {
|
|
4575
|
+
readonly code: RollbackCode
|
|
4576
|
+
constructor(code: RollbackCode, detail: string) {
|
|
4577
|
+
super(detail)
|
|
4578
|
+
this.name = 'RollbackLedgerRefusedError'
|
|
4579
|
+
this.code = code
|
|
4580
|
+
}
|
|
3304
4581
|
}
|
|
3305
4582
|
|
|
3306
4583
|
function uniqueStates(states: readonly RollbackStateKind[]): RollbackStateKind[] {
|
|
@@ -3435,6 +4712,12 @@ export function rollbackSessionBindingOf(session: unknown): RollbackBindingNarro
|
|
|
3435
4712
|
}
|
|
3436
4713
|
const header = (session as { header?: unknown }).header
|
|
3437
4714
|
const headerRecord = header !== null && typeof header === 'object' ? header as Record<string, unknown> : null
|
|
4715
|
+
// 子代理会话**不自动留点**(见 RollbackCode 的 'subagent-session'):它的工作区与父会话相同、
|
|
4716
|
+
// 回合号是自己的,留点只挤占用户自己会话的预算。**只在字段逐字等于 'subagent' 时判定** ——
|
|
4717
|
+
// 读不到 origin(老宿主 / 形状不符)就不判,绝不因为读不到而少留点。
|
|
4718
|
+
if (headerRecord?.['origin'] === 'subagent') {
|
|
4719
|
+
return { ok: false, code: 'subagent-session', detail: 'origin=subagent session=' + id }
|
|
4720
|
+
}
|
|
3438
4721
|
const cwd = headerRecord?.['cwd']
|
|
3439
4722
|
// t9:父链与「是否播种而来」只从**活会话头**读;读不到就不带字段(=不是继承子会话),绝不猜
|
|
3440
4723
|
const fork: { parentSessionId?: string; forked?: boolean } = {}
|
|
@@ -3554,6 +4837,8 @@ export class RollbackAutoTracker {
|
|
|
3554
4837
|
private readonly lastStat = new Map<string, { size: number; mtimeMs: number }>()
|
|
3555
4838
|
private timer: ReturnType<typeof setInterval> | null = null
|
|
3556
4839
|
private mechanism: 'event' | 'poll' = 'event'
|
|
4840
|
+
/** 在途捕获的取消信号(stop() 时 abort;重新 start() 时换一个新的) */
|
|
4841
|
+
private captureAbort = new AbortController()
|
|
3557
4842
|
|
|
3558
4843
|
constructor(options: RollbackTrackerOptions) {
|
|
3559
4844
|
this.engine = options.engine
|
|
@@ -3603,6 +4888,8 @@ export class RollbackAutoTracker {
|
|
|
3603
4888
|
boundaryEndSeq: info.endSeq,
|
|
3604
4889
|
chunks: binding.chunks ?? [],
|
|
3605
4890
|
note: this.mechanism === 'poll' ? 'auto:poll' : 'auto:turn',
|
|
4891
|
+
// 卸载 / 停止 ⇒ 立刻中断在途捕获(下一个文件边界停下;已收条目照常成点)
|
|
4892
|
+
signal: this.captureAbort.signal,
|
|
3606
4893
|
})
|
|
3607
4894
|
// T10:写台账失败(ok=false)绝不算留点成功 —— 否则 tracker 会记住一个台账里不存在的 pointId,
|
|
3608
4895
|
// 后续 rewind/详情都查不到它。(capturePoint 失败时也不再回传 point,这条判据是第二道保险。)
|
|
@@ -3734,6 +5021,8 @@ export class RollbackAutoTracker {
|
|
|
3734
5021
|
/** 起轮询定时器(≤5 s)。已起则幂等返回。 */
|
|
3735
5022
|
start(): void {
|
|
3736
5023
|
if (this.timer !== null) return
|
|
5024
|
+
// 上一轮 stop() 已经把信号 abort 掉:重新 start 必须换一个新的,否则捕获一动就停。
|
|
5025
|
+
if (this.captureAbort.signal.aborted) this.captureAbort = new AbortController()
|
|
3737
5026
|
this.mechanism = 'poll'
|
|
3738
5027
|
this.timer = setInterval(() => {
|
|
3739
5028
|
void this.pollOnce().catch((error) => { this.warn('rollback tracker poll failed: ' + errorDetail(error)) })
|
|
@@ -3743,6 +5032,8 @@ export class RollbackAutoTracker {
|
|
|
3743
5032
|
}
|
|
3744
5033
|
|
|
3745
5034
|
stop(): void {
|
|
5035
|
+
// 中断在途捕获:卸载之后不该再有任何一次「读工作区 + 写对象仓」跑完。
|
|
5036
|
+
this.captureAbort.abort()
|
|
3746
5037
|
if (this.timer === null) return
|
|
3747
5038
|
clearInterval(this.timer)
|
|
3748
5039
|
this.timer = null
|