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
|
@@ -30,6 +30,7 @@ import path from 'node:path'
|
|
|
30
30
|
|
|
31
31
|
import {
|
|
32
32
|
ROLLBACK_ANCESTOR_MAX_DEPTH,
|
|
33
|
+
ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES,
|
|
33
34
|
ROLLBACK_DEFAULT_MAX_BYTES,
|
|
34
35
|
ROLLBACK_DEFAULT_MAX_FILES,
|
|
35
36
|
ROLLBACK_LEFTOVER_LIST_LIMIT,
|
|
@@ -50,6 +51,8 @@ import {
|
|
|
50
51
|
type RollbackEngineOptions,
|
|
51
52
|
type RollbackForkInput,
|
|
52
53
|
type RollbackPointRecord,
|
|
54
|
+
type RollbackTrack,
|
|
55
|
+
type RollbackWorkspacePayload,
|
|
53
56
|
} from './rollback-point.ts'
|
|
54
57
|
import { projectKeyOf } from './session-select.ts'
|
|
55
58
|
import { atomicWriteFile } from '../utils/atomic-write.ts'
|
|
@@ -70,7 +73,10 @@ interface Fixture {
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
async function makeFixture(): Promise<Fixture> {
|
|
73
|
-
|
|
76
|
+
// issue #69 口径(与 tests/route/recovery-rollback.test.ts 同款):os.tmpdir() 在 Windows / macOS 上
|
|
77
|
+
// 可能给**未规范化**的拼写(Windows 的 8.3 短名 RUNNER~1 / macOS 的 /var → /private/var 软链),
|
|
78
|
+
// 而引擎内部一律 realpath ⇒ 断言会变成比「同一个目录的两种写法」(CI 的 windows/macos 必红,本机不复现)。
|
|
79
|
+
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-rollback-point-')))
|
|
74
80
|
const homeDir = path.join(root, 'home')
|
|
75
81
|
const dataDir = path.join(homeDir, 'dsh-config-manager')
|
|
76
82
|
const sessionsDir = path.join(homeDir, 'sessions')
|
|
@@ -129,6 +135,43 @@ async function ledgerOf(engine: RollbackPointEngine): Promise<{ records: Rollbac
|
|
|
129
135
|
return JSON.parse(await readText(path.join(engine.storeDir, 'index.json'))) as { records: RollbackPointRecord[] }
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
/** v3 记录文件(records/<id>.json)里工作区载荷只剩一个内容寻址哈希引用 */
|
|
139
|
+
interface StoredRecordFile {
|
|
140
|
+
workspace: Omit<RollbackTrack<RollbackWorkspacePayload>, 'payload'> & { payload: string | null }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* v3 台账的**完整记录**读写助手(只给「记录被篡改」这类白盒用例用)。
|
|
145
|
+
*
|
|
146
|
+
* 为什么必须成对改写:v3 起完整记录在 records/<id>.json,工作区大数组在**内容寻址**的
|
|
147
|
+
* payloads/<sha256>.json(清单 index.json 里只剩元数据)。篡改必须同时改载荷与记录里的哈希引用,
|
|
148
|
+
* 否则读路径会先按「哈希不符」如实失败,就测不到下游那条判据了。
|
|
149
|
+
*/
|
|
150
|
+
async function readFullRecord(engine: RollbackPointEngine, id: string): Promise<RollbackPointRecord> {
|
|
151
|
+
const file = path.join(engine.storeDir, 'records', id + '.json')
|
|
152
|
+
const stored = JSON.parse(await readText(file)) as StoredRecordFile & Omit<RollbackPointRecord, 'workspace'>
|
|
153
|
+
const hash = stored.workspace.payload
|
|
154
|
+
const payload = hash === null
|
|
155
|
+
? null
|
|
156
|
+
: JSON.parse(await readText(path.join(engine.storeDir, 'payloads', hash + '.json'))) as RollbackWorkspacePayload
|
|
157
|
+
return { ...stored, workspace: { ...stored.workspace, payload } }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function writeFullRecord(engine: RollbackPointEngine, record: RollbackPointRecord): Promise<void> {
|
|
161
|
+
const file = path.join(engine.storeDir, 'records', record.id + '.json')
|
|
162
|
+
const stored = JSON.parse(await readText(file)) as Record<string, unknown>
|
|
163
|
+
const payload = record.workspace.payload
|
|
164
|
+
let hash: string | null = null
|
|
165
|
+
if (payload !== null) {
|
|
166
|
+
const text = JSON.stringify(payload)
|
|
167
|
+
hash = sha256Hex(text)
|
|
168
|
+
await fs.writeFile(path.join(engine.storeDir, 'payloads', hash + '.json'), text)
|
|
169
|
+
}
|
|
170
|
+
const workspaceRaw = stored['workspace'] as Record<string, unknown>
|
|
171
|
+
workspaceRaw['payload'] = hash
|
|
172
|
+
await fs.writeFile(file, JSON.stringify(stored))
|
|
173
|
+
}
|
|
174
|
+
|
|
132
175
|
/* =================================================== R-01 两态捕获 / 无 config */
|
|
133
176
|
|
|
134
177
|
test('R-01 两态捕获:workspace + session 都有载荷与指纹,且**没有 config 段**', async (t) => {
|
|
@@ -656,12 +699,13 @@ test('R-05d 非相邻回退:预览的「会被删除路径集合」必须与
|
|
|
656
699
|
}
|
|
657
700
|
assert.equal(ws?.removed, 25, 'removed 必须等于真实删除数(detail=' + JSON.stringify(ws?.detail) + ')')
|
|
658
701
|
const refused = ws?.createdSkipped ?? []
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
702
|
+
// 2026-10-14 起 `.git` 在**捕获与回退两侧**都不进走查 ⇒ 它连候选都不是,不再是「候选后被拒删」。
|
|
703
|
+
// 断言的是更强的事实:.git 子树**一个字节都没被动过**(拒删判据仍在,只是更早的一道门就挡住了)。
|
|
704
|
+
assert.equal(refused.length, 0, '.git 子树不进候选(比「候选后拒删」更早一道门)')
|
|
705
|
+
for (const turn of [3, 4, 5]) {
|
|
706
|
+
assert.equal(await readText(path.join(fx.workspaceDir, '.git', 'mid' + String(turn))), 'GIT', '.git 内的文件绝不能被删')
|
|
663
707
|
}
|
|
664
|
-
assert.equal(ws?.createdSkippedCount,
|
|
708
|
+
assert.equal(ws?.createdSkippedCount, 0)
|
|
665
709
|
assert.equal(ws?.createdRefusedReason, undefined, '捕获完整 ⇒ 不是「捕获不完整」拒删')
|
|
666
710
|
|
|
667
711
|
// ② 目标点记录在案的路径写回并通过 SHA-256 复核
|
|
@@ -673,7 +717,7 @@ test('R-05d 非相邻回退:预览的「会被删除路径集合」必须与
|
|
|
673
717
|
assert.deepEqual(previewWs.removals, ws?.removedPaths, '预览说会删的 = 实际删的(逐条、同序)')
|
|
674
718
|
assert.equal(previewWs.removalsCount, ws?.removed, '预览的删除总数 = 实际删除数')
|
|
675
719
|
assert.equal(previewWs.removals.some((file) => file.split(path.sep).includes('.git')), false, '预览的删除清单里绝不能混进永不删除的 .git 路径')
|
|
676
|
-
assert.equal(previewWs.createdSkippedCount,
|
|
720
|
+
assert.equal(previewWs.createdSkippedCount, 0, '预览与执行同口径:.git 不进候选(所以没有拒删项)')
|
|
677
721
|
|
|
678
722
|
// ④ 清单**全量**(不再切 20 条):界面直接把这份清单渲染成「删除」行,不需要任何说明句
|
|
679
723
|
assert.equal(previewWs.removals.length, 25, '25 条会删的必须**全部**列出来(旧实现只列前 20 条,用户看到的正是「预览只列几个」)')
|
|
@@ -989,19 +1033,17 @@ test('R-11 记录被篡改不能变成写原语:路径越界 / 指纹不符一
|
|
|
989
1033
|
const captured = await capturePoint(engine, fx)
|
|
990
1034
|
const record = captured.point
|
|
991
1035
|
assert.ok(record !== undefined)
|
|
992
|
-
const ledgerPath = path.join(engine.storeDir, 'index.json')
|
|
993
1036
|
|
|
994
1037
|
const outside = path.join(fx.root, 'outside.txt')
|
|
995
1038
|
await fs.writeFile(outside, 'SAFE')
|
|
996
1039
|
await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
|
|
997
1040
|
|
|
998
|
-
// 篡改 1
|
|
999
|
-
const tampered = await
|
|
1000
|
-
const
|
|
1001
|
-
const hit = wsFiles.find((f) => f.path.endsWith('a.txt'))
|
|
1041
|
+
// 篡改 1:把记录里的路径改成分块之外的文件(同时改载荷与其哈希引用,见 writeFullRecord)
|
|
1042
|
+
const tampered = await readFullRecord(engine, record.id)
|
|
1043
|
+
const hit = (tampered.workspace.payload?.files ?? []).find((f) => f.path.endsWith('a.txt'))
|
|
1002
1044
|
assert.ok(hit !== undefined)
|
|
1003
1045
|
hit.path = outside
|
|
1004
|
-
await
|
|
1046
|
+
await writeFullRecord(engine, tampered)
|
|
1005
1047
|
const rewound1 = await engine.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off' })
|
|
1006
1048
|
assert.equal(rewound1.outcome, 'failed')
|
|
1007
1049
|
assert.ok((rewound1.segments[0]?.detail ?? []).some((line) => line.startsWith('invalid-path')))
|
|
@@ -1009,15 +1051,35 @@ test('R-11 记录被篡改不能变成写原语:路径越界 / 指纹不符一
|
|
|
1009
1051
|
assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2', '一条不合格 → 零写入')
|
|
1010
1052
|
|
|
1011
1053
|
// 篡改 2:路径合法但声明的内容指纹不符
|
|
1012
|
-
|
|
1013
|
-
|
|
1054
|
+
// 注:载荷**不再存** `object`(object ≡ sha256,落盘时省掉、读取时按 sha256 补回)⇒ 改 sha256
|
|
1055
|
+
// 等于同时改掉对象名 ⇒ 那个对象根本不存在 ⇒ 如实报 `object-missing`(仍是 fail-closed、零写入)。
|
|
1056
|
+
const tampered2 = await readFullRecord(engine, record.id)
|
|
1057
|
+
const hit2 = (tampered2.workspace.payload?.files ?? []).find((f) => f.sha256 === sha256Hex('A1'))
|
|
1014
1058
|
assert.ok(hit2 !== undefined)
|
|
1015
1059
|
hit2.path = path.join(fx.workspaceDir, 'a.txt') // 路径合法(分块内),只有指纹不符
|
|
1016
1060
|
hit2.sha256 = sha256Hex('NOT-THE-CAPTURED-CONTENT')
|
|
1017
|
-
await
|
|
1061
|
+
await writeFullRecord(engine, tampered2)
|
|
1018
1062
|
const rewound2 = await engine.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off' })
|
|
1019
1063
|
assert.equal(rewound2.outcome, 'failed')
|
|
1020
|
-
assert.ok((rewound2.segments[0]?.detail ?? []).some((line) => line.includes('object-
|
|
1064
|
+
assert.ok((rewound2.segments[0]?.detail ?? []).some((line) => line.includes('object-missing')),
|
|
1065
|
+
'改 sha256 ⇒ 对象名随之改 ⇒ 读不到:' + JSON.stringify(rewound2.segments[0]?.detail))
|
|
1066
|
+
assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2')
|
|
1067
|
+
|
|
1068
|
+
// 篡改 2b:指纹合法、但把**对象键**指向另一份真实存在的内容 ⇒ 该走 object-hash-mismatch
|
|
1069
|
+
const tampered3 = await readFullRecord(engine, record.id)
|
|
1070
|
+
const files3 = tampered3.workspace.payload?.files ?? []
|
|
1071
|
+
const aTxt = path.join(fx.workspaceDir, 'a.txt')
|
|
1072
|
+
const hit3 = files3.find((f) => f.path === aTxt)
|
|
1073
|
+
assert.ok(hit3 !== undefined)
|
|
1074
|
+
hit3.sha256 = sha256Hex('A1') // 指纹还原成**真的**(上一轮篡改改过它)
|
|
1075
|
+
const otherObject = files3.find((f) => f.path !== aTxt && f.sha256 !== hit3.sha256)?.sha256
|
|
1076
|
+
assert.ok(otherObject !== undefined)
|
|
1077
|
+
hit3.object = otherObject // 对象存在、但它的内容哈希 ≠ 记录里的 sha256
|
|
1078
|
+
await writeFullRecord(engine, tampered3)
|
|
1079
|
+
const rewound3 = await engine.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off' })
|
|
1080
|
+
assert.equal(rewound3.outcome, 'failed')
|
|
1081
|
+
assert.ok((rewound3.segments[0]?.detail ?? []).some((line) => line.includes('object-hash-mismatch')),
|
|
1082
|
+
'对象键被换 ⇒ 内容哈希对不上:' + JSON.stringify(rewound3.segments[0]?.detail))
|
|
1021
1083
|
assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2')
|
|
1022
1084
|
})
|
|
1023
1085
|
|
|
@@ -1337,7 +1399,10 @@ test('R-19 分块不可用 / 非绝对路径 / 会话越界:该态结构化失
|
|
|
1337
1399
|
const linked = await capturePoint(engine, fx)
|
|
1338
1400
|
const track = linked.point?.workspace
|
|
1339
1401
|
assert.equal(track?.ok, true)
|
|
1340
|
-
|
|
1402
|
+
// 链接是**设计内**跳过(不跟随、也不该回退它)⇒ 逐条留痕但不算「留得不完整」(R-50 口径):
|
|
1403
|
+
// 否则任何含符号链接的仓库每个点都挂「回退可能不完整」,真出问题时被噪声淹没
|
|
1404
|
+
assert.equal(track?.degraded, false)
|
|
1405
|
+
assert.equal(track?.reasonCode, null)
|
|
1341
1406
|
assert.ok((track?.payload?.skipped ?? []).some((s) => s.reason === 'link' && s.path === junction))
|
|
1342
1407
|
assert.equal((track?.payload?.files ?? []).some((f) => isInsidePath(f.path, junction)), false)
|
|
1343
1408
|
})
|
|
@@ -2150,15 +2215,13 @@ test('R-33 T10:preview 的 stat 失败语义 —— 只有 ENOENT 才算「当
|
|
|
2150
2215
|
const engine = makeEngine(fx)
|
|
2151
2216
|
const captured = await capturePoint(engine, fx)
|
|
2152
2217
|
const id = captured.point?.id ?? ''
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
const
|
|
2157
|
-
assert.ok(record !== undefined && record.workspace.payload !== null)
|
|
2158
|
-
const victim = record.workspace.payload?.files.find((f) => f.path.endsWith('a.txt'))
|
|
2218
|
+
// v3:完整记录在 records/<id>.json + 内容寻址载荷;篡改必须成对改写(见 writeFullRecord)
|
|
2219
|
+
const tampered = await readFullRecord(engine, id)
|
|
2220
|
+
assert.ok(tampered.workspace.payload !== null)
|
|
2221
|
+
const victim = tampered.workspace.payload.files.find((f) => f.path.endsWith('a.txt'))
|
|
2159
2222
|
assert.ok(victim !== undefined)
|
|
2160
2223
|
victim.path = path.join(fx.workspaceDir, 'a' + String.fromCharCode(0) + '.txt')
|
|
2161
|
-
await
|
|
2224
|
+
await writeFullRecord(engine, tampered)
|
|
2162
2225
|
|
|
2163
2226
|
const preview = await engine.preview(id)
|
|
2164
2227
|
assert.equal(preview.workspace.missing, 0, '非 ENOENT 的 stat 失败**不是**「当前不存在」')
|
|
@@ -2346,7 +2409,8 @@ test('T2-B 缓存命中与未命中两条路径下,删除候选判定(预览
|
|
|
2346
2409
|
const hit = await engine.preview(id)
|
|
2347
2410
|
assert.equal(hit.ok, true)
|
|
2348
2411
|
assert.deepEqual(hit.workspace.removals, created, '两条新建路径都必须被列为会删')
|
|
2349
|
-
|
|
2412
|
+
// `.git` 从两侧走查排除 ⇒ 它不进候选(口径与执行侧、与 R-05d 一致)
|
|
2413
|
+
assert.equal(hit.workspace.createdSkippedCount, 0, '.git 不进候选 ⇒ 无拒删项')
|
|
2350
2414
|
|
|
2351
2415
|
// ② 未命中路径:删掉缓存文件 + 用新实例(只能全量真读)
|
|
2352
2416
|
await fs.rm(path.join(engine.storeDir, 'path-cache.json'), { force: true })
|
|
@@ -2364,7 +2428,7 @@ test('T2-B 缓存命中与未命中两条路径下,删除候选判定(预览
|
|
|
2364
2428
|
for (const file of created) await assert.rejects(fs.readFile(file), { code: 'ENOENT' })
|
|
2365
2429
|
})
|
|
2366
2430
|
|
|
2367
|
-
test('T2-C 忽略规则:整个目录被忽略 ⇒
|
|
2431
|
+
test('T2-C 忽略规则:整个目录被忽略 ⇒ **只拒那棵子树**的删除(分子树 fail-closed),范围外照常删', async (t) => {
|
|
2368
2432
|
const fx = await makeFixture()
|
|
2369
2433
|
t.after(fx.cleanup)
|
|
2370
2434
|
const dir = await makeSizeDir(fx, 'cap-ignore-dir', { 'keep.txt': 3, 'note.md': 3 })
|
|
@@ -2390,23 +2454,29 @@ test('T2-C 忽略规则:整个目录被忽略 ⇒ 证明名单不完整 ⇒
|
|
|
2390
2454
|
await fs.writeFile(createdRoot, 'NEW')
|
|
2391
2455
|
await fs.writeFile(createdInBuild, 'NEW-IN-BUILD')
|
|
2392
2456
|
|
|
2393
|
-
//
|
|
2457
|
+
// 分子树 fail-closed:拒删只说 build/ 那棵子树,不再把整个工作区的删除一起关掉
|
|
2394
2458
|
const preview = await engine.preview(captured.point?.id ?? '')
|
|
2395
|
-
assert.equal(preview.workspace.createdRefusedReason, '
|
|
2396
|
-
assert.deepEqual(preview.workspace.removals, [], '
|
|
2397
|
-
|
|
2398
|
-
|
|
2459
|
+
assert.equal(preview.workspace.createdRefusedReason, null, '范围说得清 ⇒ 不是全局拒删')
|
|
2460
|
+
assert.deepEqual(preview.workspace.removals, [createdRoot], '范围外的候选照常删')
|
|
2461
|
+
// 被忽略的子树在**回退期也走查不到**(同一条忽略规则)⇒ 它里面的路径根本不进候选,
|
|
2462
|
+
// 既不在删除集、也不必进拒删清单;它的安全由「两端一致地看不见」保证。
|
|
2463
|
+
assert.deepEqual(preview.workspace.createdSkipped, [], '被忽略子树整棵不进候选')
|
|
2464
|
+
assert.equal(
|
|
2465
|
+
preview.warnings.some((w) => w.code === 'rollback.warn.createdRefused'),
|
|
2466
|
+
false,
|
|
2467
|
+
'不再「一条都不删」⇒ 不得给那句全局警告(文案诚实性)',
|
|
2468
|
+
)
|
|
2399
2469
|
|
|
2400
|
-
//
|
|
2470
|
+
// 执行:范围内删 1 条、子树里的一条都不删
|
|
2401
2471
|
const rewound = await engine.rewind({
|
|
2402
2472
|
id: captured.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true,
|
|
2403
2473
|
})
|
|
2404
2474
|
const ws = rewound.segments.find((s) => s.state === 'workspace')
|
|
2405
|
-
assert.equal(ws?.createdRefusedReason, '
|
|
2406
|
-
assert.equal(ws?.removed
|
|
2407
|
-
assert.
|
|
2408
|
-
assert.equal(await readText(createdInBuild), 'NEW-IN-BUILD')
|
|
2409
|
-
assert.
|
|
2475
|
+
assert.equal(ws?.createdRefusedReason, undefined, '分子树拒绝不是全局拒绝')
|
|
2476
|
+
assert.equal(ws?.removed, 1, '范围外的候选照常删')
|
|
2477
|
+
await assert.rejects(fs.readFile(createdRoot), { code: 'ENOENT' })
|
|
2478
|
+
assert.equal(await readText(createdInBuild), 'NEW-IN-BUILD', '被忽略子树里的新建路径绝不删')
|
|
2479
|
+
assert.equal(ws?.createdSkippedCount ?? 0, 0, '被忽略子树不进候选 ⇒ 拒删清单为空(不是「拒删了几条」)')
|
|
2410
2480
|
})
|
|
2411
2481
|
|
|
2412
2482
|
test('T2-D 忽略规则:文件级忽略仍保持证明名单完整 ⇒ 删除照常;内置 node_modules 规则不可反向取消', async (t) => {
|
|
@@ -2458,7 +2528,7 @@ test('T2-E 内存硬约束:同时驻留的文件内容 ≤ maxFileBytes(绝
|
|
|
2458
2528
|
sizes['huge.txt'] = 4096
|
|
2459
2529
|
const dir = await makeSizeDir(fx, 'cap-mem', sizes)
|
|
2460
2530
|
const reader = countingReader()
|
|
2461
|
-
const engine = makeEngine(fx, { maxFileBytes: 64, maxBytes: 1024 * 1024, readFile: reader.seam })
|
|
2531
|
+
const engine = makeEngine(fx, { maxFileBytes: 64, captureInFlightBytes: 64, maxBytes: 1024 * 1024, readFile: reader.seam })
|
|
2462
2532
|
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-mem' })
|
|
2463
2533
|
const payload = captured.point?.workspace.payload
|
|
2464
2534
|
assert.ok(payload !== null && payload !== undefined)
|
|
@@ -2466,11 +2536,12 @@ test('T2-E 内存硬约束:同时驻留的文件内容 ≤ maxFileBytes(绝
|
|
|
2466
2536
|
assert.equal(reader.reads.includes(path.join(dir, 'huge.txt')), false, '超限文件绝不整份读进内存')
|
|
2467
2537
|
assert.ok(payload.skipped.some((e) => e.path.endsWith('huge.txt') && e.reason === 'too-large'))
|
|
2468
2538
|
assert.equal(payload.files.length, 10)
|
|
2469
|
-
//
|
|
2539
|
+
// 两个上限是两件事:maxFileBytes = 单文件「要不要读」的判据;captureInFlightBytes = 并发内存闸门。
|
|
2540
|
+
// 2026-10-16 之前闸门错接到 maxFileBytes(缺省 = 总量预算 512 MiB)⇒ 桌面闪退。
|
|
2470
2541
|
assert.ok(reader.peakSingle <= 64, '单份驻留 = ' + String(reader.peakSingle) + ' > maxFileBytes=64')
|
|
2471
2542
|
assert.ok(
|
|
2472
2543
|
reader.peakLive <= 64,
|
|
2473
|
-
'同时驻留内容 = ' + String(reader.peakLive) + ' 字节,必须 ≤
|
|
2544
|
+
'同时驻留内容 = ' + String(reader.peakLive) + ' 字节,必须 ≤ captureInFlightBytes(64)',
|
|
2474
2545
|
)
|
|
2475
2546
|
})
|
|
2476
2547
|
|
|
@@ -2601,3 +2672,757 @@ test('T2-G 路径身份缓存随 gc 回收:删除回滚点后,缓存里不
|
|
|
2601
2672
|
assert.equal((await engine.remove(second.point?.id ?? '')).ok, true)
|
|
2602
2673
|
assert.equal(Object.keys((await cacheFileOf(engine)).entries).length, 0, '没有引用者 ⇒ 缓存条目必须被回收')
|
|
2603
2674
|
})
|
|
2675
|
+
|
|
2676
|
+
/* ========================== R-30..R-35(2026-10-14 三款竞品对照后的修复) */
|
|
2677
|
+
|
|
2678
|
+
test('R-30 子代理会话不参与自动留点:origin=subagent 给显式机器码;读不到 origin 照常', () => {
|
|
2679
|
+
const sub = rollbackSessionBindingOf({ id: 'session-9', header: { cwd: '/ws', origin: 'subagent' } })
|
|
2680
|
+
assert.equal(sub.ok, false)
|
|
2681
|
+
assert.equal(sub.ok === false ? sub.code : 'ok', 'subagent-session')
|
|
2682
|
+
// 老宿主 / 形状不符:**不加判定** —— 绝不因为读不到 origin 就少留点
|
|
2683
|
+
assert.equal(rollbackSessionBindingOf({ id: 'session-9', header: { cwd: '/ws' } }).ok, true)
|
|
2684
|
+
// origin 存在但不是 subagent(父会话 / 用户会话):照常
|
|
2685
|
+
assert.equal(rollbackSessionBindingOf({ id: 'session-9', header: { cwd: '/ws', origin: 'user' } }).ok, true)
|
|
2686
|
+
})
|
|
2687
|
+
|
|
2688
|
+
test('R-31 采集时间预算:超预算留**部分点**并如实标 truncated(不是失败、不静默)', async (t) => {
|
|
2689
|
+
const fx = await makeFixture()
|
|
2690
|
+
t.after(fx.cleanup)
|
|
2691
|
+
// 时钟每次读 +1ms、预算 1ms ⇒ 第一个判定点就超时(确定性的,不依赖真机速度)
|
|
2692
|
+
const engine = makeEngine(fx, { captureBudgetMs: 1, now: steppingClock(1_000, 1) })
|
|
2693
|
+
const captured = await capturePoint(engine, fx)
|
|
2694
|
+
assert.equal(captured.ok, true, '超预算不是失败:已收条目照常成点')
|
|
2695
|
+
const track = captured.point?.workspace
|
|
2696
|
+
assert.equal(track?.degraded, true)
|
|
2697
|
+
assert.equal(track?.reasonCode, 'capture-truncated')
|
|
2698
|
+
assert.equal(track?.payload?.truncated, true)
|
|
2699
|
+
assert.ok(
|
|
2700
|
+
(track?.payload?.skipped ?? []).some((s) => s.reason === 'budget-time'),
|
|
2701
|
+
'超时必须逐条留痕 budget-time(不是静默截断)',
|
|
2702
|
+
)
|
|
2703
|
+
assert.equal(track?.payload?.walkIncomplete, true, '走查中断必须标记 ⇒ 删除集随之 fail-closed')
|
|
2704
|
+
})
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* 测试专用的时序注入:把「预算**何时**越界」钉成确定性的(不靠真机速度)。
|
|
2708
|
+
*
|
|
2709
|
+
* `now` 恒定返回同一个基线时刻,直到 state 被置位才推过 deadline:
|
|
2710
|
+
* · `walkDone` 由 {@link timeOutAfterWalk} 在 listChunkFiles 返回后置位 ⇒ 越界发生在**读文件阶段**;
|
|
2711
|
+
* · `armed` 由 {@link timeOutAtStop} 在第 N 次停止判据内置位 ⇒ 越界发生在**走查之中**。
|
|
2712
|
+
*/
|
|
2713
|
+
function makeArmedClock(budgetMs: number): { now: () => number; state: { armed: boolean } } {
|
|
2714
|
+
const base = 1_000_000
|
|
2715
|
+
const state = { armed: false }
|
|
2716
|
+
// 未 armed ⇒ 恒定基线时刻(永不越界);armed ⇒ 直接推过 deadline
|
|
2717
|
+
const now = (): number => (state.armed ? base + budgetMs + 60_000 : base)
|
|
2718
|
+
return { now, state }
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
/** 包一层私有的 listChunkFiles:把第 `at` 次停止判据变成「越界」(栈里还剩没进去的子树)。 */
|
|
2722
|
+
function timeOutAtStop(engine: RollbackPointEngine, state: { armed: boolean }, at: number): void {
|
|
2723
|
+
const self = engine as unknown as { listChunkFiles: (...args: unknown[]) => Promise<unknown> }
|
|
2724
|
+
const original = self.listChunkFiles.bind(engine)
|
|
2725
|
+
// 计数跨分块累计(每个分块各调一次 listChunkFiles,停止判据是按次判定的)
|
|
2726
|
+
let calls = 0
|
|
2727
|
+
self.listChunkFiles = async (...args: unknown[]) => {
|
|
2728
|
+
const stop = args[5]
|
|
2729
|
+
if (typeof stop === 'function') {
|
|
2730
|
+
const stopFn = stop as () => boolean
|
|
2731
|
+
args[5] = () => {
|
|
2732
|
+
calls += 1
|
|
2733
|
+
if (calls >= at) state.armed = true
|
|
2734
|
+
return stopFn()
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
return await original(...args)
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
test('R-43 预算落在**走查之后**:不误报 walk-incomplete,被中断的条目逐条留痕(删除照常)', async (t) => {
|
|
2742
|
+
const fx = await makeFixture()
|
|
2743
|
+
t.after(fx.cleanup)
|
|
2744
|
+
const dir = await makeSizeDir(fx, 'cap-after-walk', { 'k1.txt': 3, 'k2.txt': 3, 'k3.txt': 3 })
|
|
2745
|
+
const clock = makeArmedClock(50)
|
|
2746
|
+
const engine = makeEngine(fx, {
|
|
2747
|
+
captureBudgetMs: 50,
|
|
2748
|
+
now: clock.now,
|
|
2749
|
+
captureConcurrency: 1,
|
|
2750
|
+
// 确定性地把「超时」钉在**第一个文件读完之后**(不靠真机速度):并发为 1 ⇒ 只此一份入点
|
|
2751
|
+
readFile: async (file: string) => {
|
|
2752
|
+
const bytes = await fs.readFile(file)
|
|
2753
|
+
clock.state.armed = true
|
|
2754
|
+
return bytes
|
|
2755
|
+
},
|
|
2756
|
+
})
|
|
2757
|
+
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-after-walk' })
|
|
2758
|
+
const payload = captured.point?.workspace.payload
|
|
2759
|
+
assert.ok(payload !== null && payload !== undefined)
|
|
2760
|
+
// 读文件阶段被打断 ⇒ 截断仍如实标注(诚实不降级)
|
|
2761
|
+
assert.equal(payload.truncated, true, '读到一半停下必须如实标截断')
|
|
2762
|
+
assert.equal(captured.point?.workspace.reasonCode, 'capture-truncated')
|
|
2763
|
+
assert.deepEqual(payload.files.map((f) => path.basename(f.path)), ['k1.txt'], JSON.stringify(payload.files))
|
|
2764
|
+
// 被中断的两条**逐条留痕**(不静默)—— 名单因此保持完整,不需要路径索引兜底
|
|
2765
|
+
assert.deepEqual(
|
|
2766
|
+
payload.skipped.filter((s) => s.reason === 'budget-time').map((s) => path.basename(s.path)),
|
|
2767
|
+
['k2.txt', 'k3.txt'],
|
|
2768
|
+
JSON.stringify(payload.skipped),
|
|
2769
|
+
)
|
|
2770
|
+
// 核心:走查已走完 ⇒ **不得**标记「走查不完整」(旧实现在这里误报,删除集整片失效)
|
|
2771
|
+
assert.equal(payload.walkIncomplete ?? false, false, '走查已完成 ⇒ 不许标记 walkIncomplete(误报回归)')
|
|
2772
|
+
assert.equal(payload.pathIndex ?? null, null, '每条都逐条留痕 ⇒ 覆盖自检认为名单完整,无需索引')
|
|
2773
|
+
|
|
2774
|
+
// 点之后新建一条:证明名单完整 ⇒ 预览与执行都照常删
|
|
2775
|
+
const created = path.join(dir, 'created-after.txt')
|
|
2776
|
+
await fs.writeFile(created, 'NEW')
|
|
2777
|
+
const preview = await engine.preview(captured.point?.id ?? '')
|
|
2778
|
+
assert.equal(preview.workspace.createdRefusedReason, null, '证明名单完整 ⇒ 不拒删')
|
|
2779
|
+
assert.deepEqual(preview.workspace.removals, [created])
|
|
2780
|
+
assert.equal(preview.warnings.some((w) => w.code === 'rollback.warn.createdRefused'), false, JSON.stringify(preview.warnings))
|
|
2781
|
+
|
|
2782
|
+
const rewound = await engine.rewind({
|
|
2783
|
+
id: captured.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true,
|
|
2784
|
+
})
|
|
2785
|
+
const ws = rewound.segments.find((s) => s.state === 'workspace')
|
|
2786
|
+
assert.equal(ws?.removed, 1, '证明名单完整 ⇒ 新建文件照常删')
|
|
2787
|
+
await assert.rejects(fs.readFile(created), { code: 'ENOENT' })
|
|
2788
|
+
assert.equal(await readText(path.join(dir, 'k3.txt')), 'xxx', '没读到的老文件绝不能被当成「新建」删掉')
|
|
2789
|
+
})
|
|
2790
|
+
|
|
2791
|
+
test('R-44 走查被打断:只拒「没走查到的那棵子树」(分子树 fail-closed),范围外照常删', async (t) => {
|
|
2792
|
+
const fx = await makeFixture()
|
|
2793
|
+
t.after(fx.cleanup)
|
|
2794
|
+
// 分块 1 整棵走完(其中的文件照常入点);分块 2 的走查在弹出 sub/ **之前**被预算打断 ⇒ sub/ 从未进门
|
|
2795
|
+
const dir1 = await makeSizeDir(fx, 'cap-walk-ok', { 'n1.txt': 3 })
|
|
2796
|
+
const dir2 = await makeSizeDir(fx, 'cap-walk-abort', { 'm1.txt': 3 })
|
|
2797
|
+
const sub = path.join(dir2, 'sub')
|
|
2798
|
+
await fs.mkdir(sub, { recursive: true })
|
|
2799
|
+
await fs.writeFile(path.join(sub, 'm2.txt'), 'OLD-IN-SUB')
|
|
2800
|
+
const clock = makeArmedClock(50)
|
|
2801
|
+
const engine = makeEngine(fx, { captureBudgetMs: 50, now: clock.now, captureConcurrency: 1 })
|
|
2802
|
+
// 停止判据调用序:dir1 排空(#1) → dir2 弹出(#2,仍在预算内) → dir2/sub 弹出(#3,越界 ⇒ sub/ 从未进门)
|
|
2803
|
+
timeOutAtStop(engine, clock.state, 3)
|
|
2804
|
+
|
|
2805
|
+
const captured = await engine.capturePoint({ chunks: [dir1, dir2], sessionId: 's-walk-abort' })
|
|
2806
|
+
const payload = captured.point?.workspace.payload
|
|
2807
|
+
assert.ok(payload !== null && payload !== undefined)
|
|
2808
|
+
assert.equal(payload.truncated, true)
|
|
2809
|
+
assert.equal(payload.walkIncomplete, true, '子树从未进门 ⇒ 安全判据必须生效')
|
|
2810
|
+
assert.deepEqual(payload.unwalkedDirs, [sub], '精确范围必须带出来(分子树 fail-closed 的依据)')
|
|
2811
|
+
assert.deepEqual(payload.files.map((f) => path.basename(f.path)), ['n1.txt'], '打断前读完的分块照常入点')
|
|
2812
|
+
assert.equal(payload.files.some((f) => f.path.startsWith(sub)), false, 'sub/ 从未被走查')
|
|
2813
|
+
assert.ok(
|
|
2814
|
+
payload.skipped.some((s) => s.path.endsWith('m1.txt') && s.reason === 'budget-time'),
|
|
2815
|
+
JSON.stringify(payload.skipped),
|
|
2816
|
+
)
|
|
2817
|
+
|
|
2818
|
+
const createdElsewhere = path.join(dir1, 'created-in-walked-chunk.txt')
|
|
2819
|
+
const createdRoot = path.join(dir2, 'created-root.txt')
|
|
2820
|
+
const createdInSub = path.join(sub, 'created-in-sub.txt')
|
|
2821
|
+
await fs.writeFile(createdElsewhere, 'NEW')
|
|
2822
|
+
await fs.writeFile(createdRoot, 'NEW')
|
|
2823
|
+
await fs.writeFile(createdInSub, 'NEW-IN-SUB')
|
|
2824
|
+
const preview = await engine.preview(captured.point?.id ?? '')
|
|
2825
|
+
assert.equal(preview.workspace.createdRefusedReason, null, '范围说得清 ⇒ 不是全局拒删')
|
|
2826
|
+
assert.deepEqual(
|
|
2827
|
+
[...preview.workspace.removals].sort(),
|
|
2828
|
+
[createdElsewhere, createdRoot].sort(),
|
|
2829
|
+
'没走查到的子树之外,候选照常删',
|
|
2830
|
+
)
|
|
2831
|
+
// 没走查到的子树里,**所有**当前存在且无记录的路径都进拒删清单(新建的 + 本来就在的 m2.txt)
|
|
2832
|
+
assert.deepEqual(
|
|
2833
|
+
[...preview.workspace.createdSkipped].sort(),
|
|
2834
|
+
[createdInSub, path.join(sub, 'm2.txt')].sort(),
|
|
2835
|
+
'子树里的候选如实进拒删清单',
|
|
2836
|
+
)
|
|
2837
|
+
assert.equal(
|
|
2838
|
+
preview.warnings.some((w) => w.code === 'rollback.warn.createdRefused'),
|
|
2839
|
+
false,
|
|
2840
|
+
'分子树拒绝不得套用那句「一条都不删」的全局警告',
|
|
2841
|
+
)
|
|
2842
|
+
|
|
2843
|
+
const rewound = await engine.rewind({
|
|
2844
|
+
id: captured.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true,
|
|
2845
|
+
})
|
|
2846
|
+
const ws = rewound.segments.find((s) => s.state === 'workspace')
|
|
2847
|
+
assert.equal(ws?.createdRefusedReason, undefined, '分子树拒绝不是全局拒绝')
|
|
2848
|
+
assert.equal(ws?.removed, 2, '没走查到的子树之外照常删')
|
|
2849
|
+
await assert.rejects(fs.readFile(createdElsewhere), { code: 'ENOENT' })
|
|
2850
|
+
await assert.rejects(fs.readFile(createdRoot), { code: 'ENOENT' })
|
|
2851
|
+
assert.equal(await readText(createdInSub), 'NEW-IN-SUB')
|
|
2852
|
+
assert.equal(await readText(path.join(sub, 'm2.txt')), 'OLD-IN-SUB', '从未走查的文件绝不能被删')
|
|
2853
|
+
assert.equal(await readText(path.join(dir1, 'n1.txt')), 'xxx')
|
|
2854
|
+
assert.ok((ws?.createdSkippedCount ?? 0) > 0, '候选必须如实进拒删清单')
|
|
2855
|
+
})
|
|
2856
|
+
|
|
2857
|
+
test('R-45 捕获并发:读 + 写并发跑(真机大头),但在飞内容字节 ≤ captureInFlightBytes', async (t) => {
|
|
2858
|
+
const fx = await makeFixture()
|
|
2859
|
+
t.after(fx.cleanup)
|
|
2860
|
+
const sizes: Record<string, number> = {}
|
|
2861
|
+
for (let i = 0; i < 24; i += 1) sizes['p-' + String(i).padStart(2, '0') + '.txt'] = 64
|
|
2862
|
+
const dir = await makeSizeDir(fx, 'cap-parallel', sizes)
|
|
2863
|
+
let live = 0
|
|
2864
|
+
let peak = 0
|
|
2865
|
+
let inFlight = 0
|
|
2866
|
+
let maxInFlight = 0
|
|
2867
|
+
const engine = makeEngine(fx, {
|
|
2868
|
+
// 闸门 = captureInFlightBytes(并发内存上界),与 maxFileBytes(单文件判据)分开:
|
|
2869
|
+
// 同时最多 4 份 64 B 的内容在飞。
|
|
2870
|
+
captureInFlightBytes: 256,
|
|
2871
|
+
readFile: async (file: string) => {
|
|
2872
|
+
inFlight += 1
|
|
2873
|
+
maxInFlight = Math.max(maxInFlight, inFlight)
|
|
2874
|
+
const bytes = await fs.readFile(file)
|
|
2875
|
+
live += bytes.byteLength
|
|
2876
|
+
peak = Math.max(peak, live)
|
|
2877
|
+
await new Promise((resolve) => setTimeout(resolve, 2)) // 让并发真的重叠
|
|
2878
|
+
live -= bytes.byteLength
|
|
2879
|
+
inFlight -= 1
|
|
2880
|
+
return bytes
|
|
2881
|
+
},
|
|
2882
|
+
})
|
|
2883
|
+
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-par' })
|
|
2884
|
+
const payload = captured.point?.workspace.payload
|
|
2885
|
+
assert.equal(payload?.files.length, 24, JSON.stringify(payload?.skipped))
|
|
2886
|
+
assert.ok(maxInFlight > 1, '必须真的并发(否则 34k 文件永远扫不完):实测 ' + String(maxInFlight))
|
|
2887
|
+
assert.ok(peak <= 256, '在飞内容字节必须 ≤ captureInFlightBytes,实测 ' + String(peak))
|
|
2888
|
+
})
|
|
2889
|
+
|
|
2890
|
+
test('R-45b 缺省闸门:maxFileBytes 缺省 = 总量预算,绝不能当成在飞内容上限(0.1.74 闪退根因)', async (t) => {
|
|
2891
|
+
const fx = await makeFixture()
|
|
2892
|
+
t.after(fx.cleanup)
|
|
2893
|
+
const sizes: Record<string, number> = {}
|
|
2894
|
+
for (let i = 0; i < 24; i += 1) sizes['g-' + String(i).padStart(2, '0') + '.txt'] = 64
|
|
2895
|
+
const dir = await makeSizeDir(fx, 'cap-inflight-default', sizes)
|
|
2896
|
+
let live = 0
|
|
2897
|
+
let peak = 0
|
|
2898
|
+
// maxFileBytes 给一个**远大于闸门**的值:旧实现(闸门 = maxFileBytes)会放行 24×64 = 1536 B 在飞。
|
|
2899
|
+
const engine = makeEngine(fx, {
|
|
2900
|
+
maxFileBytes: 4096,
|
|
2901
|
+
captureInFlightBytes: 128,
|
|
2902
|
+
readFile: async (file: string) => {
|
|
2903
|
+
const bytes = await fs.readFile(file)
|
|
2904
|
+
live += bytes.byteLength
|
|
2905
|
+
peak = Math.max(peak, live)
|
|
2906
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
2907
|
+
live -= bytes.byteLength
|
|
2908
|
+
return bytes
|
|
2909
|
+
},
|
|
2910
|
+
})
|
|
2911
|
+
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-inflight' })
|
|
2912
|
+
assert.equal(captured.point?.workspace.payload?.files.length, 24)
|
|
2913
|
+
assert.ok(peak <= 128, '在飞内容必须被 captureInFlightBytes 钉死(不是 maxFileBytes),实测 ' + String(peak))
|
|
2914
|
+
// 缺省闸门本身必须远小于总量预算 —— 它才是并发的内存上界
|
|
2915
|
+
assert.equal(ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES, 32 * 1024 * 1024)
|
|
2916
|
+
assert.ok(
|
|
2917
|
+
ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES < ROLLBACK_DEFAULT_MAX_BYTES / 8,
|
|
2918
|
+
'缺省在飞闸门必须比总量预算小一个数量级以上',
|
|
2919
|
+
)
|
|
2920
|
+
})
|
|
2921
|
+
|
|
2922
|
+
test('T2-H .asar 路径绝不交给 fs:宿主里那是原生容器解析,畸形夹具能带走整个宿主', async (t) => {
|
|
2923
|
+
const fx = await makeFixture()
|
|
2924
|
+
t.after(fx.cleanup)
|
|
2925
|
+
const dir = await makeSizeDir(fx, 'cap-asar', {})
|
|
2926
|
+
await fs.writeFile(path.join(dir, 'normal.txt'), 'N')
|
|
2927
|
+
await fs.writeFile(path.join(dir, 'broken.asar'), Buffer.alloc(64, 0xff))
|
|
2928
|
+
await fs.mkdir(path.join(dir, 'bundle.asar'), { recursive: true })
|
|
2929
|
+
await fs.writeFile(path.join(dir, 'bundle.asar', 'inner.txt'), 'I')
|
|
2930
|
+
const reads: string[] = []
|
|
2931
|
+
const engine = makeEngine(fx, {
|
|
2932
|
+
readFile: async (file: string) => {
|
|
2933
|
+
reads.push(file)
|
|
2934
|
+
return await fs.readFile(file)
|
|
2935
|
+
},
|
|
2936
|
+
})
|
|
2937
|
+
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-asar' })
|
|
2938
|
+
const payload = captured.point?.workspace.payload
|
|
2939
|
+
assert.ok(payload !== null && payload !== undefined)
|
|
2940
|
+
assert.equal(reads.some((f) => f.endsWith('.asar')), false, '任何 .asar 路径都不得被读')
|
|
2941
|
+
assert.equal(reads.some((f) => f.includes('.asar' + path.sep)), false, '含 .asar 段的路径一律不碰')
|
|
2942
|
+
assert.ok(
|
|
2943
|
+
payload.skipped.some((e) => e.path.endsWith('broken.asar') && e.reason === 'asar-container'),
|
|
2944
|
+
'畸形 .asar 必须留痕为 asar-container: ' + JSON.stringify(payload.skipped.map((e) => [e.path, e.reason])),
|
|
2945
|
+
)
|
|
2946
|
+
assert.ok(
|
|
2947
|
+
payload.skipped.some((e) => e.path.endsWith('bundle.asar') && e.reason === 'asar-container'),
|
|
2948
|
+
'名字含 .asar 段的目录同样不碰',
|
|
2949
|
+
)
|
|
2950
|
+
assert.ok(payload.files.some((f) => f.path.endsWith('normal.txt')), '普通文件照常入点')
|
|
2951
|
+
})
|
|
2952
|
+
|
|
2953
|
+
test('R-46 缺口优先:预算被限死时覆盖率随回合**单调收敛**到 100%', async (t) => {
|
|
2954
|
+
const fx = await makeFixture()
|
|
2955
|
+
t.after(fx.cleanup)
|
|
2956
|
+
const dir = await makeSizeDir(fx, 'cap-converge', {})
|
|
2957
|
+
const names = Array.from({ length: 60 }, (_, i) => 'c-' + String(i).padStart(3, '0') + '.txt')
|
|
2958
|
+
for (const name of names) await fs.writeFile(path.join(dir, name), 'x')
|
|
2959
|
+
// 每回合只放行 3 次读:等价于「预算只够扫一小部分」的确定性版本
|
|
2960
|
+
let allowance = 3
|
|
2961
|
+
const engine = makeEngine(fx, {
|
|
2962
|
+
captureConcurrency: 1,
|
|
2963
|
+
now: () => (allowance <= 0 ? 2_000_000 + 60_000 : 2_000_000),
|
|
2964
|
+
readFile: async (file: string) => {
|
|
2965
|
+
const bytes = await fs.readFile(file)
|
|
2966
|
+
allowance -= 1
|
|
2967
|
+
return bytes
|
|
2968
|
+
},
|
|
2969
|
+
})
|
|
2970
|
+
const seen = new Set<string>()
|
|
2971
|
+
const covered: number[] = []
|
|
2972
|
+
for (let round = 0; round < 25; round += 1) {
|
|
2973
|
+
allowance = 3
|
|
2974
|
+
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-converge' })
|
|
2975
|
+
for (const file of captured.point?.workspace.payload?.files ?? []) seen.add(file.path)
|
|
2976
|
+
covered.push(seen.size)
|
|
2977
|
+
}
|
|
2978
|
+
for (let i = 1; i < covered.length; i += 1) {
|
|
2979
|
+
assert.ok(covered[i]! >= covered[i - 1]!, '覆盖率不得回退:' + covered.join(','))
|
|
2980
|
+
}
|
|
2981
|
+
assert.equal(covered[covered.length - 1], 60, '足够多回合后必须覆盖 100%:' + covered.join(','))
|
|
2982
|
+
})
|
|
2983
|
+
|
|
2984
|
+
test('R-47 自适应预算:被截断 ⇒ 下次翻倍;提前收工 ⇒ 下次减半(区间内)', async (t) => {
|
|
2985
|
+
const fx = await makeFixture()
|
|
2986
|
+
t.after(fx.cleanup)
|
|
2987
|
+
const dir = await makeSizeDir(fx, 'cap-adapt', { 'a.txt': 3, 'b.txt': 3 })
|
|
2988
|
+
let stepped = true
|
|
2989
|
+
let tick = 0
|
|
2990
|
+
const engine = makeEngine(fx, {
|
|
2991
|
+
captureBudgetMs: 1_000,
|
|
2992
|
+
captureBudgetMinMs: 500,
|
|
2993
|
+
captureBudgetMaxMs: 4_000,
|
|
2994
|
+
now: () => (stepped ? (tick += 1_000) : Date.now()),
|
|
2995
|
+
})
|
|
2996
|
+
const budgetOf = (): number => (engine as unknown as { captureBudgetMs: number }).captureBudgetMs
|
|
2997
|
+
assert.equal(budgetOf(), 1_000, '初值 = 选项值')
|
|
2998
|
+
// ① 步进时钟 ⇒ 一次次判定就撞满预算 ⇒ 截断 ⇒ 下次翻倍
|
|
2999
|
+
const truncated = await engine.capturePoint({ chunks: [dir], sessionId: 's-adapt' })
|
|
3000
|
+
assert.equal(truncated.point?.workspace.payload?.truncated, true)
|
|
3001
|
+
assert.equal(budgetOf(), 2_000, '被截断 ⇒ 翻倍')
|
|
3002
|
+
// ② 真实时钟 ⇒ 两个小文件毫秒级跑完(用不到 1/4)⇒ 下次减半;继续跑到下界为止
|
|
3003
|
+
stepped = false
|
|
3004
|
+
await engine.capturePoint({ chunks: [dir], sessionId: 's-adapt' })
|
|
3005
|
+
assert.equal(budgetOf(), 1_000, '提前收工 ⇒ 减半')
|
|
3006
|
+
await engine.capturePoint({ chunks: [dir], sessionId: 's-adapt' })
|
|
3007
|
+
await engine.capturePoint({ chunks: [dir], sessionId: 's-adapt' })
|
|
3008
|
+
assert.equal(budgetOf(), 500, '减到下界即停(回到 captureBudgetMinMs 下界)')
|
|
3009
|
+
})
|
|
3010
|
+
|
|
3011
|
+
test('R-48 捕获竞态:读到一半被改写的文件不当前像,但进证明名单(绝不被当成新建删掉)', async (t) => {
|
|
3012
|
+
const fx = await makeFixture()
|
|
3013
|
+
t.after(fx.cleanup)
|
|
3014
|
+
const dir = await makeSizeDir(fx, 'cap-race', { 'r1.txt': 3, 'r2.txt': 3 })
|
|
3015
|
+
const target = path.join(dir, 'r1.txt')
|
|
3016
|
+
let mutated = false
|
|
3017
|
+
const engine = makeEngine(fx, {
|
|
3018
|
+
captureConcurrency: 1,
|
|
3019
|
+
readFile: async (file: string) => {
|
|
3020
|
+
const bytes = await fs.readFile(file)
|
|
3021
|
+
if (file === target && !mutated) {
|
|
3022
|
+
mutated = true
|
|
3023
|
+
await fs.writeFile(file, 'XYZ') // 同长度原地改写:身份(mtimeNs/ctimeNs)必变
|
|
3024
|
+
}
|
|
3025
|
+
return bytes
|
|
3026
|
+
},
|
|
3027
|
+
})
|
|
3028
|
+
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-race' })
|
|
3029
|
+
const payload = captured.point?.workspace.payload
|
|
3030
|
+
assert.ok(payload !== null && payload !== undefined)
|
|
3031
|
+
assert.equal(mutated, true)
|
|
3032
|
+
assert.equal(payload.files.some((f) => f.path === target), false, '竞态中的文件不得当作前像')
|
|
3033
|
+
assert.ok(
|
|
3034
|
+
payload.unreadableFiles.some((f) => f.path === target && f.reason === 'changed-during-read'),
|
|
3035
|
+
JSON.stringify(payload.unreadableFiles),
|
|
3036
|
+
)
|
|
3037
|
+
const created = path.join(dir, 'created.txt')
|
|
3038
|
+
await fs.writeFile(created, 'NEW')
|
|
3039
|
+
const preview = await engine.preview(captured.point?.id ?? '')
|
|
3040
|
+
assert.equal(preview.workspace.createdRefusedReason, null, '证明名单仍完整(unreadableFiles 也是证明)')
|
|
3041
|
+
assert.deepEqual(preview.workspace.removals, [created])
|
|
3042
|
+
const rewound = await engine.rewind({
|
|
3043
|
+
id: captured.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true,
|
|
3044
|
+
})
|
|
3045
|
+
assert.equal(rewound.ok, true)
|
|
3046
|
+
assert.equal(await readText(target), 'XYZ', '竞态文件绝不能被当成新建删掉')
|
|
3047
|
+
assert.equal(await readText(path.join(dir, 'r2.txt')), 'xxx')
|
|
3048
|
+
})
|
|
3049
|
+
|
|
3050
|
+
test('R-49 记录与读预算解耦:已知内容不占读槽位 ⇒ 大仓库从第二个点起记录全量', async (t) => {
|
|
3051
|
+
const fx = await makeFixture()
|
|
3052
|
+
t.after(fx.cleanup)
|
|
3053
|
+
const dir = await makeSizeDir(fx, 'cap-entries', {})
|
|
3054
|
+
const names = Array.from({ length: 12 }, (_, i) => 'e-' + String(i).padStart(2, '0') + '.txt')
|
|
3055
|
+
for (const name of names) await fs.writeFile(path.join(dir, name), 'x')
|
|
3056
|
+
// 每回合只允许读 5 条**新内容**(maxFiles 约束的是读,不是「记录多少条」)
|
|
3057
|
+
const engine = makeEngine(fx, { maxFiles: 5 })
|
|
3058
|
+
const first = await engine.capturePoint({ chunks: [dir], sessionId: 's-entries' })
|
|
3059
|
+
const p1 = first.point?.workspace.payload
|
|
3060
|
+
assert.equal(p1?.files.length, 5, '第一点:只有 5 条新内容被读进来')
|
|
3061
|
+
assert.equal(p1?.truncated, true, '还有新内容没读 ⇒ 如实标截断')
|
|
3062
|
+
const second = await engine.capturePoint({ chunks: [dir], sessionId: 's-entries' })
|
|
3063
|
+
const p2 = second.point?.workspace.payload
|
|
3064
|
+
assert.equal(p2?.files.length, 10, '已知的 5 条不占槽位、一律进记录:' + JSON.stringify(p2?.files.map((f) => path.basename(f.path))))
|
|
3065
|
+
const third = await engine.capturePoint({ chunks: [dir], sessionId: 's-entries' })
|
|
3066
|
+
const p3 = third.point?.workspace.payload
|
|
3067
|
+
assert.equal(p3?.files.length, 12, '全部已知 ⇒ 记录必须是全量(这是「任何大小都收敛」的判据)')
|
|
3068
|
+
assert.equal(p3?.truncated, false, '没有新内容要读 ⇒ 不该再标截断')
|
|
3069
|
+
assert.deepEqual(p3?.files.map((f) => path.basename(f.path)), names, '记录顺序仍按路径有序')
|
|
3070
|
+
})
|
|
3071
|
+
|
|
3072
|
+
test('R-50 降级口径:设计内跳过(符号链接 / 文件级忽略)不构成「留得不完整」;缺内容才算', async (t) => {
|
|
3073
|
+
const fx = await makeFixture()
|
|
3074
|
+
t.after(fx.cleanup)
|
|
3075
|
+
// 分块根:一个正常文件 + 一个被文件级忽略规则跳过的文件(等价于「符号链接被跳过」那类设计内跳过)
|
|
3076
|
+
const dir = await makeSizeDir(fx, 'cap-degraded', { 'keep.txt': 3 })
|
|
3077
|
+
await fs.writeFile(path.join(dir, 'old.log'), 'OLD')
|
|
3078
|
+
await fs.writeFile(path.join(dir, '.dsh-rollback-ignore'), '*.log' + '\n')
|
|
3079
|
+
const engine = makeEngine(fx)
|
|
3080
|
+
const first = await engine.capturePoint({ chunks: [dir], sessionId: 's-degraded' })
|
|
3081
|
+
const p1 = first.point?.workspace.payload
|
|
3082
|
+
assert.ok(p1 !== null && p1 !== undefined)
|
|
3083
|
+
assert.ok(p1.skippedCount > 0, '文件级忽略必须逐条留痕:' + JSON.stringify(p1.skipped))
|
|
3084
|
+
assert.equal(p1.skipped.some((e) => e.reason === 'ignored'), true)
|
|
3085
|
+
assert.equal(p1.truncated, false)
|
|
3086
|
+
assert.equal(first.point?.workspace.degraded, false, '设计内跳过 ⇒ 不得报「留得不完整」')
|
|
3087
|
+
assert.equal(first.point?.workspace.reasonCode, null)
|
|
3088
|
+
const seg = (await engine.list()).points[0]?.segments.find((s) => s.state === 'workspace')
|
|
3089
|
+
assert.equal(seg?.degraded, false, '列表摘要同样不得降级')
|
|
3090
|
+
assert.equal(seg?.reasonCode, null)
|
|
3091
|
+
|
|
3092
|
+
// 对照:真的缺内容(超单文件上限被跳过)⇒ 必须降级且如实给码
|
|
3093
|
+
const dir2 = await makeSizeDir(fx, 'cap-degraded-real', { 'big.bin': 200 })
|
|
3094
|
+
const engine2 = makeEngine(fx, { maxFileBytes: 64 })
|
|
3095
|
+
const second = await engine2.capturePoint({ chunks: [dir2], sessionId: 's-degraded2' })
|
|
3096
|
+
assert.equal(second.point?.workspace.degraded, true)
|
|
3097
|
+
assert.equal(second.point?.workspace.reasonCode, 'capture-truncated')
|
|
3098
|
+
assert.ok(second.point?.workspace.payload?.skipped.some((e) => e.reason === 'too-large'))
|
|
3099
|
+
})
|
|
3100
|
+
|
|
3101
|
+
test('R-51 预览快路径:身份命中时不再真读工作区文件(大仓库预览的 13 s 就在这一步)', async (t) => {
|
|
3102
|
+
const fx = await makeFixture()
|
|
3103
|
+
t.after(fx.cleanup)
|
|
3104
|
+
const dir = await makeSizeDir(fx, 'cap-preview', { 'a.txt': 5, 'b.txt': 5, 'c.txt': 5 })
|
|
3105
|
+
const reader = countingReader()
|
|
3106
|
+
const engine = makeEngine(fx, { readFile: reader.seam })
|
|
3107
|
+
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-preview' })
|
|
3108
|
+
const id = captured.point?.id ?? ''
|
|
3109
|
+
reader.reset()
|
|
3110
|
+
const first = await engine.preview(id)
|
|
3111
|
+
assert.equal(first.workspace.different, 0, '三条全都一致')
|
|
3112
|
+
assert.equal(reader.reads.length, 0, '身份命中 ⇒ 预览一次都不读内容:' + JSON.stringify(reader.reads))
|
|
3113
|
+
// 改一条:**只有它**需要被真读(其余仍走快路径)
|
|
3114
|
+
await fs.writeFile(path.join(dir, 'b.txt'), 'ZZZZZ')
|
|
3115
|
+
reader.reset()
|
|
3116
|
+
const second = await engine.preview(id)
|
|
3117
|
+
assert.equal(second.workspace.different, 1)
|
|
3118
|
+
assert.deepEqual(reader.reads, [path.join(dir, 'b.txt')], '只真读被改的那一条:' + JSON.stringify(reader.reads))
|
|
3119
|
+
})
|
|
3120
|
+
|
|
3121
|
+
test('R-52 回退快路径:已一致的条目不读对象、不写盘;用不到的对象坏掉不再中止整次回退', async (t) => {
|
|
3122
|
+
const fx = await makeFixture()
|
|
3123
|
+
t.after(fx.cleanup)
|
|
3124
|
+
const dir = await makeSizeDir(fx, 'cap-rewind-fast', { 'k1.txt': 5, 'k2.txt': 5 })
|
|
3125
|
+
const reader = countingReader()
|
|
3126
|
+
const engine = makeEngine(fx, {
|
|
3127
|
+
readFile: reader.seam,
|
|
3128
|
+
writeFile: async () => { throw new Error('不该写任何文件') },
|
|
3129
|
+
})
|
|
3130
|
+
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-rewind-fast' })
|
|
3131
|
+
const id = captured.point?.id ?? ''
|
|
3132
|
+
reader.reset()
|
|
3133
|
+
const rewound = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true })
|
|
3134
|
+
const ws = rewound.segments.find((s) => s.state === 'workspace')
|
|
3135
|
+
assert.equal(rewound.outcome, 'restored', JSON.stringify(ws))
|
|
3136
|
+
assert.equal(ws?.reasonCode, 'nothing-to-restore', '全部已一致 ⇒ 0 变更')
|
|
3137
|
+
assert.equal(reader.reads.length, 0, '身份命中 ⇒ 阶段 2/3 一次都不读:' + JSON.stringify(reader.reads))
|
|
3138
|
+
|
|
3139
|
+
// 新语义:**用不到的对象**坏掉不该中止整次回退(真要用它时仍会 object-missing 硬失败)
|
|
3140
|
+
const payload = (await engine.read(id)).point?.workspace.payload
|
|
3141
|
+
assert.ok(payload !== null && payload !== undefined)
|
|
3142
|
+
await fs.rm(path.join(engine.storeDir, 'objects', payload.files[0]?.object ?? ''))
|
|
3143
|
+
const second = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true })
|
|
3144
|
+
const ws2 = second.segments.find((s) => s.state === 'workspace')
|
|
3145
|
+
assert.equal(second.outcome, 'restored', '用不到的对象坏掉仍应成功:' + JSON.stringify(ws2?.detail))
|
|
3146
|
+
assert.equal(ws2?.reasonCode, 'nothing-to-restore')
|
|
3147
|
+
})
|
|
3148
|
+
|
|
3149
|
+
test('R-53 清单增量化:第二点只落「相对第一点的增量」,读回来是同一份完整清单', async (t) => {
|
|
3150
|
+
const fx = await makeFixture()
|
|
3151
|
+
t.after(fx.cleanup)
|
|
3152
|
+
const dir = path.join(fx.workspaceDir, 'cap-delta')
|
|
3153
|
+
await fs.mkdir(dir, { recursive: true })
|
|
3154
|
+
for (let i = 0; i < 40; i += 1) {
|
|
3155
|
+
await fs.writeFile(path.join(dir, 'f' + String(i).padStart(2, '0') + '.txt'), 'V' + String(i))
|
|
3156
|
+
}
|
|
3157
|
+
const engine = makeEngine(fx)
|
|
3158
|
+
const first = await engine.capturePoint({ chunks: [dir], sessionId: 's-delta' })
|
|
3159
|
+
const firstFull = first.point?.workspace.payload
|
|
3160
|
+
assert.ok(firstFull !== null && firstFull !== undefined)
|
|
3161
|
+
assert.equal(firstFull.fileCount, 40)
|
|
3162
|
+
const manifest = JSON.parse(await fs.readFile(path.join(engine.storeDir, 'index.json'), 'utf8')) as {
|
|
3163
|
+
records: { id: string; workspace: { payload: string | null; payloadChain?: string[] } }[]
|
|
3164
|
+
}
|
|
3165
|
+
void manifest
|
|
3166
|
+
// 每次现读清单(第二点会再写一次清单)
|
|
3167
|
+
const hashOf = async (id: string): Promise<string> => {
|
|
3168
|
+
const fresh = JSON.parse(await fs.readFile(path.join(engine.storeDir, 'index.json'), 'utf8')) as {
|
|
3169
|
+
records: { id: string; workspace: { payload: string | null; payloadChain?: string[] } }[]
|
|
3170
|
+
}
|
|
3171
|
+
return fresh.records.find((r) => r.id === id)?.workspace.payload ?? ''
|
|
3172
|
+
}
|
|
3173
|
+
const payloadPath = (hash: string): string => path.join(engine.storeDir, 'payloads', hash + '.json')
|
|
3174
|
+
const firstHash = await hashOf(first.point?.id ?? '')
|
|
3175
|
+
const firstBytes = (await fs.stat(payloadPath(firstHash))).size
|
|
3176
|
+
t.diagnostic('首点载荷 ' + String(firstBytes) + ' 字节')
|
|
3177
|
+
// 第二点:改 1 个文件 + 新增 1 个 ⇒ 增量应远小于全量
|
|
3178
|
+
await fs.writeFile(path.join(dir, 'f00.txt'), 'CHANGED')
|
|
3179
|
+
await fs.writeFile(path.join(dir, 'new.txt'), 'NEW')
|
|
3180
|
+
const second = await engine.capturePoint({ chunks: [dir], sessionId: 's-delta' })
|
|
3181
|
+
const secondFull = second.point?.workspace.payload
|
|
3182
|
+
assert.ok(secondFull !== null && secondFull !== undefined)
|
|
3183
|
+
assert.equal(secondFull.fileCount, 41, '摘要里的条数必须是全量条数,不是增量条数')
|
|
3184
|
+
const payloadsDir = path.join(engine.storeDir, 'payloads')
|
|
3185
|
+
const names = await fs.readdir(payloadsDir)
|
|
3186
|
+
assert.equal(names.length, 2, '首点全量 + 第二点增量(内容不同 ⇒ 两份载荷)')
|
|
3187
|
+
const secondHash = await hashOf(second.point?.id ?? '')
|
|
3188
|
+
assert.notEqual(secondHash, firstHash)
|
|
3189
|
+
const secondBytes = (await fs.stat(payloadPath(secondHash))).size
|
|
3190
|
+
const secondStored = JSON.parse(await fs.readFile(payloadPath(secondHash), 'utf8')) as { delta?: { base?: string } }
|
|
3191
|
+
assert.equal(secondStored.delta?.base, firstHash, '第二点必须是相对首点的增量视图(而不是又一份全量)')
|
|
3192
|
+
assert.ok(secondBytes < firstBytes / 2, '增量载荷必须明显更小:' + String(secondBytes) + ' vs ' + String(firstBytes))
|
|
3193
|
+
// 读回来必须与「全量捕获」逐条一致(路径序 / sha / bytes / 对象键)
|
|
3194
|
+
const back = await engine.read(second.point?.id ?? '')
|
|
3195
|
+
const rebuilt = back.point?.workspace.payload
|
|
3196
|
+
assert.ok(rebuilt !== null && rebuilt !== undefined)
|
|
3197
|
+
assert.equal(rebuilt.delta, undefined, '读回来必须是物化后的完整清单')
|
|
3198
|
+
const sorted = [...(secondFull.files ?? [])].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
3199
|
+
assert.deepEqual(
|
|
3200
|
+
rebuilt.files.map((f) => [f.path, f.sha256, f.bytes, f.object]),
|
|
3201
|
+
sorted.map((f) => [f.path, f.sha256, f.bytes, f.object]),
|
|
3202
|
+
'增量重建的清单必须与全量逐条一致(含补回的 object)',
|
|
3203
|
+
)
|
|
3204
|
+
assert.equal(rebuilt.pathIndex?.object ?? null, secondFull.pathIndex?.object ?? null)
|
|
3205
|
+
// 预览与全量点同一口径:内容一致 ⇒ 零改动
|
|
3206
|
+
const preview = await engine.preview(second.point?.id ?? '')
|
|
3207
|
+
assert.equal(preview.workspace.different, 0)
|
|
3208
|
+
assert.equal(preview.workspace.overwrite, 41, '全量条数(不是增量条数)')
|
|
3209
|
+
})
|
|
3210
|
+
|
|
3211
|
+
test('R-54 增量链:基准载荷缺失 ⇒ 该点如实失败(绝不返回半份清单);prune 不删被链引用的载荷', async (t) => {
|
|
3212
|
+
const fx = await makeFixture()
|
|
3213
|
+
t.after(fx.cleanup)
|
|
3214
|
+
const dir = path.join(fx.workspaceDir, 'cap-delta-chain')
|
|
3215
|
+
await fs.mkdir(dir, { recursive: true })
|
|
3216
|
+
for (let i = 0; i < 20; i += 1) await fs.writeFile(path.join(dir, 'g' + String(i) + '.txt'), 'C' + String(i))
|
|
3217
|
+
const engine = makeEngine(fx)
|
|
3218
|
+
const a = await engine.capturePoint({ chunks: [dir], sessionId: 's-chain' })
|
|
3219
|
+
await fs.writeFile(path.join(dir, 'g0.txt'), 'CHANGED')
|
|
3220
|
+
const b = await engine.capturePoint({ chunks: [dir], sessionId: 's-chain' })
|
|
3221
|
+
assert.ok(a.point !== undefined && b.point !== undefined)
|
|
3222
|
+
// 第二点必须是增量(载荷哈希 ≠ 第一点;且它的载荷里有 delta 标记)
|
|
3223
|
+
const payloadsDir = path.join(engine.storeDir, 'payloads')
|
|
3224
|
+
const names = await fs.readdir(payloadsDir)
|
|
3225
|
+
assert.equal(names.length, 2)
|
|
3226
|
+
const manifest = JSON.parse(await fs.readFile(path.join(engine.storeDir, 'index.json'), 'utf8')) as {
|
|
3227
|
+
records: { id: string; workspace: { payload: string | null; payloadChain?: string[] } }[]
|
|
3228
|
+
}
|
|
3229
|
+
const entryOf = (id: string) => manifest.records.find((r) => r.id === id)
|
|
3230
|
+
const payloadPath = (hash: string): string => path.join(payloadsDir, hash + '.json')
|
|
3231
|
+
const bHash = entryOf(b.point.id)?.workspace.payload ?? ''
|
|
3232
|
+
assert.ok(Array.isArray(entryOf(b.point.id)?.workspace.payloadChain), '清单条目必须带上解析所需的链')
|
|
3233
|
+
const bFile = JSON.parse(await fs.readFile(payloadPath(bHash), 'utf8')) as {
|
|
3234
|
+
files?: unknown
|
|
3235
|
+
delta?: { base?: string; added?: unknown; removed?: unknown }
|
|
3236
|
+
}
|
|
3237
|
+
assert.ok(bFile.delta !== undefined && bFile.delta.base !== undefined, '第二点必须落成增量视图')
|
|
3238
|
+
const delta0 = bFile.delta as { base: string; added?: unknown }
|
|
3239
|
+
// 增量视图**故意**让 files 不是数组:不认识 delta 的旧读取器会因形状不符拒绝这个点(fail-closed),
|
|
3240
|
+
// 而不是把「增量那几条」当完整清单去规划删除
|
|
3241
|
+
assert.equal(bFile.files, null, '增量视图的 files 必须为 null')
|
|
3242
|
+
assert.ok(Array.isArray(delta0.added))
|
|
3243
|
+
// 删掉基准载荷:**读第二点必须如实失败**(半份清单会被拿去规划删除,等于删掉其它全部文件)
|
|
3244
|
+
await fs.rm(payloadPath(delta0.base))
|
|
3245
|
+
const broken = await engine.read(b.point.id)
|
|
3246
|
+
assert.equal(broken.point, undefined)
|
|
3247
|
+
assert.ok((broken.error ?? '').includes('record-incomplete'), '如实失败:' + String(broken.error))
|
|
3248
|
+
})
|
|
3249
|
+
|
|
3250
|
+
test('R-32 取消信号:已中止 ⇒ 在文件边界停下(部分点 + budget-time 留痕,绝不整份跑完)', async (t) => {
|
|
3251
|
+
const fx = await makeFixture()
|
|
3252
|
+
t.after(fx.cleanup)
|
|
3253
|
+
const engine = makeEngine(fx)
|
|
3254
|
+
const controller = new AbortController()
|
|
3255
|
+
controller.abort()
|
|
3256
|
+
const captured = await capturePoint(engine, fx, { signal: controller.signal })
|
|
3257
|
+
assert.equal(captured.ok, true, '取消 = 部分点,不是失败')
|
|
3258
|
+
const track = captured.point?.workspace
|
|
3259
|
+
assert.equal(track?.payload?.truncated, true)
|
|
3260
|
+
assert.ok((track?.payload?.skipped ?? []).some((s) => s.reason === 'budget-time'))
|
|
3261
|
+
})
|
|
3262
|
+
|
|
3263
|
+
test('R-33 预算兜不住单条点:如实标 overBudget(记录仍保留,绝不悄悄丢用户刚留的点)', async (t) => {
|
|
3264
|
+
const fx = await makeFixture()
|
|
3265
|
+
t.after(fx.cleanup)
|
|
3266
|
+
// 预算 1 字节:任何一条记录都超,且候选里没有可淘汰的旧点
|
|
3267
|
+
const engine = makeEngine(fx, { ledgerBytesBudget: 1 })
|
|
3268
|
+
assert.equal((await capturePoint(engine, fx)).ok, true)
|
|
3269
|
+
const status = await engine.storageStatus()
|
|
3270
|
+
assert.equal(status.ledger?.records, 1, '超预算不删刚留的点')
|
|
3271
|
+
assert.equal(status.ledger?.overBudget, true, '预算兜不住时必须如实上报,不能显示成「一切正常」')
|
|
3272
|
+
})
|
|
3273
|
+
|
|
3274
|
+
test('R-34 回退前像落盘:成功后 undo 目录零残留', async (t) => {
|
|
3275
|
+
const fx = await makeFixture()
|
|
3276
|
+
t.after(fx.cleanup)
|
|
3277
|
+
const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
|
|
3278
|
+
const captured = await capturePoint(engine, fx)
|
|
3279
|
+
const id = captured.point?.id ?? ''
|
|
3280
|
+
await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
|
|
3281
|
+
const rewind = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off' })
|
|
3282
|
+
assert.equal(rewind.ok, true)
|
|
3283
|
+
assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A1', '前像落盘不影响「写回原内容」')
|
|
3284
|
+
const undoRoot = path.join(engine.storeDir, 'undo')
|
|
3285
|
+
assert.deepEqual(await fs.readdir(undoRoot).catch(() => []), [], '前像目录用完即清(不留无人认领的字节)')
|
|
3286
|
+
})
|
|
3287
|
+
|
|
3288
|
+
test('R-34b 前像暂存建不出来 ⇒ 零写入失败(回退没有安全网就不该开始)', async (t) => {
|
|
3289
|
+
const fx = await makeFixture()
|
|
3290
|
+
t.after(fx.cleanup)
|
|
3291
|
+
const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
|
|
3292
|
+
const captured = await capturePoint(engine, fx)
|
|
3293
|
+
const id = captured.point?.id ?? ''
|
|
3294
|
+
await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
|
|
3295
|
+
// 用一个**文件**占住 undo 路径 ⇒ mkdir 必然失败(ENOTDIR/EEXIST)
|
|
3296
|
+
await fs.writeFile(path.join(engine.storeDir, 'undo'), 'block')
|
|
3297
|
+
const rewind = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off' })
|
|
3298
|
+
assert.equal(rewind.ok, false)
|
|
3299
|
+
assert.equal(rewind.outcome, 'failed')
|
|
3300
|
+
assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2', '零写入:文件保持回退前的内容')
|
|
3301
|
+
})
|
|
3302
|
+
|
|
3303
|
+
test('R-35 崩溃残留的回退前像目录:死 pid 整目录清掉、活 pid 一律不碰', async (t) => {
|
|
3304
|
+
const fx = await makeFixture()
|
|
3305
|
+
t.after(fx.cleanup)
|
|
3306
|
+
const engine = makeEngine(fx)
|
|
3307
|
+
await engine.storageStatus()
|
|
3308
|
+
const undoRoot = path.join(engine.storeDir, 'undo')
|
|
3309
|
+
const dead = path.join(undoRoot, '999999-abc-01234567')
|
|
3310
|
+
const live = path.join(undoRoot, String(process.pid) + '-abc-01234567')
|
|
3311
|
+
await fs.mkdir(dead, { recursive: true })
|
|
3312
|
+
await fs.writeFile(path.join(dead, '0.bin'), 'x'.repeat(64))
|
|
3313
|
+
await fs.mkdir(live, { recursive: true })
|
|
3314
|
+
const { cleanupOrphanTempFiles } = await import('./rollback-ledger.ts')
|
|
3315
|
+
const result = await cleanupOrphanTempFiles(engine.storeDir)
|
|
3316
|
+
assert.equal(result.removed, 1)
|
|
3317
|
+
assert.ok(result.bytes >= 64)
|
|
3318
|
+
await assert.rejects(fs.access(dead), '死 pid 的残留目录必须被清掉')
|
|
3319
|
+
await fs.access(live)
|
|
3320
|
+
})
|
|
3321
|
+
|
|
3322
|
+
test('R-36 走查被中断的点:删除集整体拒绝(未走查的子树不得被当成「留点后新建」删掉)', async (t) => {
|
|
3323
|
+
const fx = await makeFixture()
|
|
3324
|
+
t.after(fx.cleanup)
|
|
3325
|
+
const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
|
|
3326
|
+
const captured = await capturePoint(engine, fx)
|
|
3327
|
+
const id = captured.point?.id ?? ''
|
|
3328
|
+
// 白盒:造出 R-31 现场的那个状态(时间预算中断 ⇒ 剩余子树一条都没进任何名单)
|
|
3329
|
+
const record = await readFullRecord(engine, id)
|
|
3330
|
+
assert.notEqual(record.workspace.payload, null)
|
|
3331
|
+
if (record.workspace.payload !== null) record.workspace.payload.walkIncomplete = true
|
|
3332
|
+
await writeFullRecord(engine, record)
|
|
3333
|
+
|
|
3334
|
+
// 留点之后新建的文件:它在留点时刻**是否存在**已无从证明
|
|
3335
|
+
const created = path.join(fx.workspaceDir, 'created-after.txt')
|
|
3336
|
+
await fs.writeFile(created, 'NEW')
|
|
3337
|
+
|
|
3338
|
+
const preview = await engine.preview(id)
|
|
3339
|
+
assert.equal(preview.workspace.createdRefusedReason, 'capture-incomplete', '预览必须拒绝删除并说明原因')
|
|
3340
|
+
assert.deepEqual(preview.workspace.removals, [], '预览不得列出任何待删路径')
|
|
3341
|
+
|
|
3342
|
+
const rewind = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', removeCreated: true })
|
|
3343
|
+
assert.equal(rewind.ok, true)
|
|
3344
|
+
assert.equal(await readText(created), 'NEW', '证明名单不完整 ⇒ 一条都不删(绝不误删未走查子树里的文件)')
|
|
3345
|
+
})
|
|
3346
|
+
|
|
3347
|
+
|
|
3348
|
+
test('R-42 .git 不进捕获面:整棵进 excludedDirs 留痕,且不进 files', async (t) => {
|
|
3349
|
+
const fx = await makeFixture()
|
|
3350
|
+
t.after(fx.cleanup)
|
|
3351
|
+
await fs.mkdir(path.join(fx.workspaceDir, '.git'), { recursive: true })
|
|
3352
|
+
await fs.writeFile(path.join(fx.workspaceDir, '.git', 'index'), 'GIT')
|
|
3353
|
+
const engine = makeEngine(fx)
|
|
3354
|
+
const captured = await capturePoint(engine, fx)
|
|
3355
|
+
const payload = captured.point?.workspace.payload
|
|
3356
|
+
assert.ok(payload)
|
|
3357
|
+
assert.equal(
|
|
3358
|
+
(payload?.files ?? []).some((f) => f.path.split(path.sep).includes('.git')),
|
|
3359
|
+
false,
|
|
3360
|
+
'.git 下的文件不得进快照(回退写回 .git 会让 git 状态错乱,且 .git 会挤掉 maxBytes 预算)',
|
|
3361
|
+
)
|
|
3362
|
+
assert.ok(
|
|
3363
|
+
(payload?.excludedDirs ?? []).some((e) => e.path.endsWith('.git') && e.reason === 'git-dir'),
|
|
3364
|
+
'.git 必须如实进 excludedDirs(留痕,不静默)',
|
|
3365
|
+
)
|
|
3366
|
+
assert.equal(payload?.files.length, 2, '工作区里的两个正常文件照常进快照')
|
|
3367
|
+
})
|
|
3368
|
+
|
|
3369
|
+
test('R-37 清单损坏时**从 records/ 重建**:旧点不丢(而不是拿空清单覆盖掉它们)', async (t) => {
|
|
3370
|
+
const fx = await makeFixture()
|
|
3371
|
+
t.after(fx.cleanup)
|
|
3372
|
+
const engine = makeEngine(fx)
|
|
3373
|
+
const first = await capturePoint(engine, fx)
|
|
3374
|
+
assert.equal(first.ok, true)
|
|
3375
|
+
const firstId = first.point?.id ?? ''
|
|
3376
|
+
const manifestPath = path.join(engine.storeDir, 'index.json')
|
|
3377
|
+
// 把清单弄成**读不出来**:readManifest 返回 { entries: [], error }
|
|
3378
|
+
await fs.writeFile(manifestPath, '{"schemaVersion": 3, "records": "not-an-array"}')
|
|
3379
|
+
const second = await capturePoint(engine, fx, { sessionId: 's-other' })
|
|
3380
|
+
assert.equal(second.ok, true, '清单损坏不该阻塞留点(R-16 的既有口径)')
|
|
3381
|
+
const after = await engine.list()
|
|
3382
|
+
assert.equal(after.error, undefined)
|
|
3383
|
+
assert.equal(after.points.length, 2, '旧点必须从 records/ 重建回来,绝不能被空清单覆盖丢掉')
|
|
3384
|
+
assert.ok(after.points.some((pt) => pt.id === firstId), '而且就是原来那一条(同 id)')
|
|
3385
|
+
})
|
|
3386
|
+
|
|
3387
|
+
// 折大小写只在 Windows 生效(win32Fold):Linux 上 `.GIT` 与 `.git` 是两个不同目录,
|
|
3388
|
+
// 本用例断言的是「不区分大小写的文件系统」下的保护行为,故非 Windows 一律跳过。
|
|
3389
|
+
test(
|
|
3390
|
+
'R-40 拒删判据折大小写:.GIT 子树同样受保护(Windows 文件系统不区分大小写)',
|
|
3391
|
+
{ skip: process.platform === 'win32' ? false : '折大小写只在 Windows 生效(win32Fold);Linux 上 .GIT 与 .git 是不同目录' },
|
|
3392
|
+
async (t) => {
|
|
3393
|
+
const fx = await makeFixture()
|
|
3394
|
+
t.after(fx.cleanup)
|
|
3395
|
+
const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
|
|
3396
|
+
const captured = await capturePoint(engine, fx)
|
|
3397
|
+
const id = captured.point?.id ?? ''
|
|
3398
|
+
const dir = path.join(fx.workspaceDir, '.GIT')
|
|
3399
|
+
await fs.mkdir(dir, { recursive: true })
|
|
3400
|
+
const inner = path.join(dir, 'x.txt')
|
|
3401
|
+
await fs.writeFile(inner, 'PROTECTED')
|
|
3402
|
+
const rewind = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', removeCreated: true })
|
|
3403
|
+
assert.equal(rewind.ok, true)
|
|
3404
|
+
assert.equal(await readText(inner), 'PROTECTED', '.GIT 子树不得被当成「留点后新建」删除')
|
|
3405
|
+
})
|
|
3406
|
+
|
|
3407
|
+
test('R-41 回退期走查也有时间预算:中断只让候选变少(安全方向)并如实标 truncated', async (t) => {
|
|
3408
|
+
const fx = await makeFixture()
|
|
3409
|
+
t.after(fx.cleanup)
|
|
3410
|
+
const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
|
|
3411
|
+
const captured = await capturePoint(engine, fx)
|
|
3412
|
+
const id = captured.point?.id ?? ''
|
|
3413
|
+
const created = path.join(fx.workspaceDir, 'created-after.txt')
|
|
3414
|
+
await fs.writeFile(created, 'NEW')
|
|
3415
|
+
// 同一个台账、另一套预算:回退阶段第一个判定点就超时(步进时钟 + 1ms 预算,确定性)
|
|
3416
|
+
const fast = makeEngine(fx, {
|
|
3417
|
+
captureBudgetMs: 1,
|
|
3418
|
+
now: steppingClock(5_000_000, 1),
|
|
3419
|
+
fork: async () => ({ ok: true, childSessionId: 'child-1' }),
|
|
3420
|
+
})
|
|
3421
|
+
const rewind = await fast.rewind({ id, userConfirmed: true, guardPolicy: 'off', removeCreated: true })
|
|
3422
|
+
assert.equal(rewind.ok, true, '走查中断不是失败')
|
|
3423
|
+
assert.equal(await readText(created), 'NEW', '中断时宁可少删(绝不因为没走完就当成「可以删」)')
|
|
3424
|
+
const ws = rewind.segments.find((s) => s.state === 'workspace')
|
|
3425
|
+
assert.equal(ws?.createdSkippedTruncated, true, '中断必须如实标 truncated,不谎报「已完整走查」')
|
|
3426
|
+
})
|
|
3427
|
+
|
|
3428
|
+
|