dsh-config-manager 0.1.73 → 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/lib/client.d.ts +8 -4
- package/lib/client.js +814 -682
- package/lib/core/rollback-ledger.d.ts +29 -3
- package/lib/core/rollback-ledger.js +125 -7
- package/lib/core/rollback-point.d.ts +221 -3
- package/lib/core/rollback-point.js +790 -153
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/sync/messages.d.ts +1 -0
- package/lib/sync/messages.js +3 -0
- package/lib/sync/sync-engine.js +56 -7
- package/lib/ui/i18n.d.ts +1 -1
- package/lib/ui/i18n.js +2 -2
- 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/client/chat/ChatEntryView.tsx +38 -9
- package/src/client/chat/RollbackFileIcon.tsx +7 -45
- 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/common/DiffTable.tsx +12 -23
- package/src/client/common/host-primitives.ts +78 -0
- package/src/client/config-manager.module.css +33 -12
- package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
- package/src/client/index.ts +7 -1
- package/src/client/library/RestorePlanView.tsx +0 -1
- package/src/client/locales.ts +0 -2
- package/src/client/recovery/RollbackDiffPane.tsx +13 -6
- package/src/client/recovery/recovery-locales.ts +2 -4
- package/src/client/recovery/rollback-file-changes.test.ts +37 -1
- 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.ts +9 -1
- package/src/core/rollback-ledger.ts +123 -7
- package/src/core/rollback-point.test.ts +592 -17
- package/src/core/rollback-point.ts +861 -155
- package/src/index.ts +1 -1
- package/src/sync/messages.ts +3 -0
- package/src/sync/sync-engine.ts +55 -7
- package/src/sync/sync-preview-push.test.ts +158 -0
- package/src/ui/i18n.ts +2 -2
- 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,
|
|
@@ -1050,6 +1051,8 @@ test('R-11 记录被篡改不能变成写原语:路径越界 / 指纹不符一
|
|
|
1050
1051
|
assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2', '一条不合格 → 零写入')
|
|
1051
1052
|
|
|
1052
1053
|
// 篡改 2:路径合法但声明的内容指纹不符
|
|
1054
|
+
// 注:载荷**不再存** `object`(object ≡ sha256,落盘时省掉、读取时按 sha256 补回)⇒ 改 sha256
|
|
1055
|
+
// 等于同时改掉对象名 ⇒ 那个对象根本不存在 ⇒ 如实报 `object-missing`(仍是 fail-closed、零写入)。
|
|
1053
1056
|
const tampered2 = await readFullRecord(engine, record.id)
|
|
1054
1057
|
const hit2 = (tampered2.workspace.payload?.files ?? []).find((f) => f.sha256 === sha256Hex('A1'))
|
|
1055
1058
|
assert.ok(hit2 !== undefined)
|
|
@@ -1058,7 +1061,25 @@ test('R-11 记录被篡改不能变成写原语:路径越界 / 指纹不符一
|
|
|
1058
1061
|
await writeFullRecord(engine, tampered2)
|
|
1059
1062
|
const rewound2 = await engine.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off' })
|
|
1060
1063
|
assert.equal(rewound2.outcome, 'failed')
|
|
1061
|
-
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))
|
|
1062
1083
|
assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2')
|
|
1063
1084
|
})
|
|
1064
1085
|
|
|
@@ -1378,7 +1399,10 @@ test('R-19 分块不可用 / 非绝对路径 / 会话越界:该态结构化失
|
|
|
1378
1399
|
const linked = await capturePoint(engine, fx)
|
|
1379
1400
|
const track = linked.point?.workspace
|
|
1380
1401
|
assert.equal(track?.ok, true)
|
|
1381
|
-
|
|
1402
|
+
// 链接是**设计内**跳过(不跟随、也不该回退它)⇒ 逐条留痕但不算「留得不完整」(R-50 口径):
|
|
1403
|
+
// 否则任何含符号链接的仓库每个点都挂「回退可能不完整」,真出问题时被噪声淹没
|
|
1404
|
+
assert.equal(track?.degraded, false)
|
|
1405
|
+
assert.equal(track?.reasonCode, null)
|
|
1382
1406
|
assert.ok((track?.payload?.skipped ?? []).some((s) => s.reason === 'link' && s.path === junction))
|
|
1383
1407
|
assert.equal((track?.payload?.files ?? []).some((f) => isInsidePath(f.path, junction)), false)
|
|
1384
1408
|
})
|
|
@@ -2404,7 +2428,7 @@ test('T2-B 缓存命中与未命中两条路径下,删除候选判定(预览
|
|
|
2404
2428
|
for (const file of created) await assert.rejects(fs.readFile(file), { code: 'ENOENT' })
|
|
2405
2429
|
})
|
|
2406
2430
|
|
|
2407
|
-
test('T2-C 忽略规则:整个目录被忽略 ⇒
|
|
2431
|
+
test('T2-C 忽略规则:整个目录被忽略 ⇒ **只拒那棵子树**的删除(分子树 fail-closed),范围外照常删', async (t) => {
|
|
2408
2432
|
const fx = await makeFixture()
|
|
2409
2433
|
t.after(fx.cleanup)
|
|
2410
2434
|
const dir = await makeSizeDir(fx, 'cap-ignore-dir', { 'keep.txt': 3, 'note.md': 3 })
|
|
@@ -2430,23 +2454,29 @@ test('T2-C 忽略规则:整个目录被忽略 ⇒ 证明名单不完整 ⇒
|
|
|
2430
2454
|
await fs.writeFile(createdRoot, 'NEW')
|
|
2431
2455
|
await fs.writeFile(createdInBuild, 'NEW-IN-BUILD')
|
|
2432
2456
|
|
|
2433
|
-
//
|
|
2457
|
+
// 分子树 fail-closed:拒删只说 build/ 那棵子树,不再把整个工作区的删除一起关掉
|
|
2434
2458
|
const preview = await engine.preview(captured.point?.id ?? '')
|
|
2435
|
-
assert.equal(preview.workspace.createdRefusedReason, '
|
|
2436
|
-
assert.deepEqual(preview.workspace.removals, [], '
|
|
2437
|
-
|
|
2438
|
-
|
|
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
|
+
)
|
|
2439
2469
|
|
|
2440
|
-
//
|
|
2470
|
+
// 执行:范围内删 1 条、子树里的一条都不删
|
|
2441
2471
|
const rewound = await engine.rewind({
|
|
2442
2472
|
id: captured.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true,
|
|
2443
2473
|
})
|
|
2444
2474
|
const ws = rewound.segments.find((s) => s.state === 'workspace')
|
|
2445
|
-
assert.equal(ws?.createdRefusedReason, '
|
|
2446
|
-
assert.equal(ws?.removed
|
|
2447
|
-
assert.
|
|
2448
|
-
assert.equal(await readText(createdInBuild), 'NEW-IN-BUILD')
|
|
2449
|
-
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, '被忽略子树不进候选 ⇒ 拒删清单为空(不是「拒删了几条」)')
|
|
2450
2480
|
})
|
|
2451
2481
|
|
|
2452
2482
|
test('T2-D 忽略规则:文件级忽略仍保持证明名单完整 ⇒ 删除照常;内置 node_modules 规则不可反向取消', async (t) => {
|
|
@@ -2498,7 +2528,7 @@ test('T2-E 内存硬约束:同时驻留的文件内容 ≤ maxFileBytes(绝
|
|
|
2498
2528
|
sizes['huge.txt'] = 4096
|
|
2499
2529
|
const dir = await makeSizeDir(fx, 'cap-mem', sizes)
|
|
2500
2530
|
const reader = countingReader()
|
|
2501
|
-
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 })
|
|
2502
2532
|
const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-mem' })
|
|
2503
2533
|
const payload = captured.point?.workspace.payload
|
|
2504
2534
|
assert.ok(payload !== null && payload !== undefined)
|
|
@@ -2506,11 +2536,12 @@ test('T2-E 内存硬约束:同时驻留的文件内容 ≤ maxFileBytes(绝
|
|
|
2506
2536
|
assert.equal(reader.reads.includes(path.join(dir, 'huge.txt')), false, '超限文件绝不整份读进内存')
|
|
2507
2537
|
assert.ok(payload.skipped.some((e) => e.path.endsWith('huge.txt') && e.reason === 'too-large'))
|
|
2508
2538
|
assert.equal(payload.files.length, 10)
|
|
2509
|
-
//
|
|
2539
|
+
// 两个上限是两件事:maxFileBytes = 单文件「要不要读」的判据;captureInFlightBytes = 并发内存闸门。
|
|
2540
|
+
// 2026-10-16 之前闸门错接到 maxFileBytes(缺省 = 总量预算 512 MiB)⇒ 桌面闪退。
|
|
2510
2541
|
assert.ok(reader.peakSingle <= 64, '单份驻留 = ' + String(reader.peakSingle) + ' > maxFileBytes=64')
|
|
2511
2542
|
assert.ok(
|
|
2512
2543
|
reader.peakLive <= 64,
|
|
2513
|
-
'同时驻留内容 = ' + String(reader.peakLive) + ' 字节,必须 ≤
|
|
2544
|
+
'同时驻留内容 = ' + String(reader.peakLive) + ' 字节,必须 ≤ captureInFlightBytes(64)',
|
|
2514
2545
|
)
|
|
2515
2546
|
})
|
|
2516
2547
|
|
|
@@ -2672,6 +2703,550 @@ test('R-31 采集时间预算:超预算留**部分点**并如实标 truncated
|
|
|
2672
2703
|
assert.equal(track?.payload?.walkIncomplete, true, '走查中断必须标记 ⇒ 删除集随之 fail-closed')
|
|
2673
2704
|
})
|
|
2674
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
|
+
|
|
2675
3250
|
test('R-32 取消信号:已中止 ⇒ 在文件边界停下(部分点 + budget-time 留痕,绝不整份跑完)', async (t) => {
|
|
2676
3251
|
const fx = await makeFixture()
|
|
2677
3252
|
t.after(fx.cleanup)
|