dsh-config-manager 0.1.78 → 0.1.79
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/adapters/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +400 -112
- package/lib/client.js +2845 -1524
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +15 -1
- package/lib/sync/gist/gist-transport.js +66 -5
- package/lib/sync/gist/messages.d.ts +3 -0
- package/lib/sync/gist/messages.js +7 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/sync-engine.d.ts +193 -4
- package/lib/sync/sync-engine.js +450 -37
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +8 -0
- package/lib/ui/i18n.js +20 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/routes.ts +9 -0
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +219 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +234 -0
- package/src/client/sync/merge-view.ts +371 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +223 -0
- package/src/sync/gist/gist-transport.ts +73 -7
- package/src/sync/gist/messages.ts +7 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +379 -0
- package/src/sync/sync-engine.ts +535 -36
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +20 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨会话回退(回退 A、保留 B)的**真实文件状态**回归测试(task-3)。
|
|
3
|
+
*
|
|
4
|
+
* 与既有 rollback-point.test.ts 的 C-1..C-10 不同:这里每条用例都按**最终文件字节/文本**断言,
|
|
5
|
+
* 并把「12 类复杂场景」逐类钉住:
|
|
6
|
+
* ① 多会话改不同文件 ② 同一文件不同区域 ③ 同一处重叠冲突 ④ B 改 A 新增的代码
|
|
7
|
+
* ⑤ A 删除 + B 同点新增 ⑥ 重排 / 行移动 / 重命名 ⑦ 删除 / 重建
|
|
8
|
+
* ⑧ 无归属的人工手改 ⑨ 三会话交错 ⑩ 顺序回退 / 撤销回退(guard)
|
|
9
|
+
* ⑪ 中途写入失败 / 并发外部写入 ⑫ 对象仓丢对象 / CRLF / 非 UTF-8
|
|
10
|
+
*
|
|
11
|
+
* 纪律(照抄 rollback-point.test.ts 的 fixture 风格):
|
|
12
|
+
* - **真实 os.tmpdir + mkdtemp + 真实读写**,不 mock fs;
|
|
13
|
+
* - 引擎的 now 用步进时钟,保证「目标点之后最早的其他会话点」顺序确定;
|
|
14
|
+
* - 每条回退后同时断言三件事:① 该撤销的撤销;② 不该动的逐字未变;③ 冲突/无法判定时零写入且原因可读。
|
|
15
|
+
*
|
|
16
|
+
* 分类约定:
|
|
17
|
+
* - 预期会失败的用例以 X-<n> [DEFECT] 开头,断言消息里写清 observed / required;
|
|
18
|
+
* - 当前行为安全但并非理想结果的以 [SAFE] 标注,注释说明为什么可接受。
|
|
19
|
+
*
|
|
20
|
+
* 本文件**只读生产代码**:不修改任何 src 实现,也不依赖其它测试文件的 helper。
|
|
21
|
+
*/
|
|
22
|
+
import test from 'node:test'
|
|
23
|
+
import assert from 'node:assert/strict'
|
|
24
|
+
import fs from 'node:fs/promises'
|
|
25
|
+
import os from 'node:os'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
|
|
28
|
+
import { RollbackWriteAttribution } from './rollback-attribution.ts'
|
|
29
|
+
import { RollbackSessionActivity } from './rollback-activity.ts'
|
|
30
|
+
import {
|
|
31
|
+
RollbackPointEngine,
|
|
32
|
+
type RollbackEngineOptions,
|
|
33
|
+
type RollbackPointInput,
|
|
34
|
+
type RollbackRewindResult,
|
|
35
|
+
type RollbackSegmentResult,
|
|
36
|
+
} from './rollback-point.ts'
|
|
37
|
+
import { atomicWriteFile } from '../utils/atomic-write.ts'
|
|
38
|
+
|
|
39
|
+
/* --------------------------------------------------------------- fixture */
|
|
40
|
+
|
|
41
|
+
interface Fixture {
|
|
42
|
+
root: string
|
|
43
|
+
homeDir: string
|
|
44
|
+
dataDir: string
|
|
45
|
+
unitDir: string
|
|
46
|
+
workspaceDir: string
|
|
47
|
+
/** 每条用例只捕获这个子目录内的文件(fixture 其它文件不参与,断言面收敛) */
|
|
48
|
+
projDir: string
|
|
49
|
+
logFile: string
|
|
50
|
+
cleanup: () => Promise<void>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function makeFixture(): Promise<Fixture> {
|
|
54
|
+
// os.tmpdir() 在 Windows/macOS 上可能给未规范化的拼写,而引擎内部一律 realpath ⇒ 先 realpath
|
|
55
|
+
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-cross-session-')))
|
|
56
|
+
const homeDir = path.join(root, 'home')
|
|
57
|
+
const dataDir = path.join(homeDir, 'dsh-config-manager')
|
|
58
|
+
const unitDir = path.join(homeDir, 'sessions', 'proj-key', 'session-1111')
|
|
59
|
+
const workspaceDir = path.join(root, 'ws')
|
|
60
|
+
const projDir = path.join(workspaceDir, 'proj')
|
|
61
|
+
const logFile = path.join(unitDir, 'session-1111.jsonl.zstd')
|
|
62
|
+
await fs.mkdir(unitDir, { recursive: true })
|
|
63
|
+
await fs.mkdir(projDir, { recursive: true })
|
|
64
|
+
await fs.writeFile(logFile, 'frame-one')
|
|
65
|
+
return {
|
|
66
|
+
root, homeDir, dataDir, unitDir, workspaceDir, projDir, logFile,
|
|
67
|
+
cleanup: async () => { await fs.rm(root, { recursive: true, force: true }) },
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeEngine(fx: Fixture, overrides: Partial<RollbackEngineOptions> = {}): RollbackPointEngine {
|
|
72
|
+
return new RollbackPointEngine({ dataDir: fx.dataDir, homeDir: fx.homeDir, sessionQuiescentMs: 0, ...overrides })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeAttribution(fx: Fixture): RollbackWriteAttribution {
|
|
76
|
+
return new RollbackWriteAttribution({
|
|
77
|
+
file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'),
|
|
78
|
+
flushDelayMs: 0,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 严格递增的引擎时钟:保证「目标点之后最早的其他会话点」不会撞同一毫秒。 */
|
|
83
|
+
function steppingClock(start: number, step: number): () => number {
|
|
84
|
+
let value = start - step
|
|
85
|
+
return () => { value += step; return value }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 留一个点:只捕获 proj 子目录、只挂一条会话日志(与 rollback-point.test.ts 同名 helper 同口径)。 */
|
|
89
|
+
async function capture(engine: RollbackPointEngine, fx: Fixture, sessionId = 'session-1111', extra: Partial<RollbackPointInput> = {}) {
|
|
90
|
+
return await engine.capturePoint({
|
|
91
|
+
chunks: [fx.projDir],
|
|
92
|
+
sessionId,
|
|
93
|
+
unitId: 'proj-key/session-1111',
|
|
94
|
+
sessionLogPath: fx.logFile,
|
|
95
|
+
cursorSeq: 3,
|
|
96
|
+
turnSeq: 2,
|
|
97
|
+
boundarySeq: 1,
|
|
98
|
+
boundaryEndSeq: 6,
|
|
99
|
+
...extra,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function pointIdOf(engine: RollbackPointEngine, fx: Fixture, sessionId: string): Promise<string> {
|
|
104
|
+
const captured = await capture(engine, fx, sessionId)
|
|
105
|
+
const point = captured.point
|
|
106
|
+
assert.ok(point !== undefined, '留点失败:' + captured.code + ' ' + (captured.detail ?? ''))
|
|
107
|
+
return point.id
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readText(file: string): Promise<string> {
|
|
111
|
+
return await fs.readFile(file, 'utf8')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function readBytes(file: string): Promise<Buffer> {
|
|
115
|
+
return await fs.readFile(file)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function exists(file: string): Promise<boolean> {
|
|
119
|
+
try { await fs.stat(file); return true } catch { return false }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function wsSegment(result: RollbackRewindResult): RollbackSegmentResult {
|
|
123
|
+
const segment = result.segments.find((item) => item.state === 'workspace')
|
|
124
|
+
assert.ok(segment !== undefined, '缺少 workspace 段')
|
|
125
|
+
return segment
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function samePath(left: string, right: string): boolean {
|
|
129
|
+
const fold = (value: string): string => process.platform === 'win32' ? path.resolve(value).toLowerCase() : path.resolve(value)
|
|
130
|
+
return fold(left) === fold(right)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ================================================ ① 多会话改不同文件 */
|
|
134
|
+
|
|
135
|
+
test('T1-1 多会话改不同文件:回退 A 只影响 A 的文件,B 的文件逐字未变(有归属)', async (t) => {
|
|
136
|
+
const fx = await makeFixture()
|
|
137
|
+
t.after(fx.cleanup)
|
|
138
|
+
const attr = makeAttribution(fx)
|
|
139
|
+
const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
|
|
140
|
+
const fileA = path.join(fx.projDir, 'a.txt')
|
|
141
|
+
const fileB = path.join(fx.projDir, 'b.txt')
|
|
142
|
+
await fs.writeFile(fileA, 'A0')
|
|
143
|
+
await fs.writeFile(fileB, 'B0')
|
|
144
|
+
|
|
145
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
146
|
+
await fs.writeFile(fileA, 'A1')
|
|
147
|
+
await pointIdOf(engine, fx, 'session-B') // B 的回合点:fileB 此时仍是 B0
|
|
148
|
+
await fs.writeFile(fileB, 'B1')
|
|
149
|
+
attr.noteToolCall('session-B', { name: 'write', callId: 'w1', arguments: JSON.stringify({ file_path: fileB }) })
|
|
150
|
+
|
|
151
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
152
|
+
assert.equal(await readText(fileA), 'A0', 'A 的改动被撤销')
|
|
153
|
+
assert.equal(await readText(fileB), 'B1', 'B 的文件必须逐字未变')
|
|
154
|
+
const segment = wsSegment(result)
|
|
155
|
+
assert.equal(segment.restored, 1, '只写了 A 那一条')
|
|
156
|
+
assert.equal(segment.mergedCount, 1, 'A 那条走三方合并(对方没动它)')
|
|
157
|
+
assert.equal(segment.unavailableCount, 1, 'B 的文件因「无可用祖先 + 别的会话写过」被如实跳过')
|
|
158
|
+
assert.equal(segment.conflictCount, undefined)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('X-1 [DEFECT] 无归属时多会话改不同文件:回退 A 会覆盖 B 的文件(B1 → B0)', async (t) => {
|
|
162
|
+
const fx = await makeFixture()
|
|
163
|
+
t.after(fx.cleanup)
|
|
164
|
+
// 刻意不给 attribution:真实环境里归属会因工具白名单 / 跨进程整体覆盖 / 采集时机而缺失
|
|
165
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
166
|
+
const fileA = path.join(fx.projDir, 'a.txt')
|
|
167
|
+
const fileB = path.join(fx.projDir, 'b.txt')
|
|
168
|
+
await fs.writeFile(fileA, 'A0')
|
|
169
|
+
await fs.writeFile(fileB, 'B0')
|
|
170
|
+
|
|
171
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
172
|
+
await fs.writeFile(fileA, 'A1')
|
|
173
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
174
|
+
await fs.writeFile(fileB, 'B1')
|
|
175
|
+
|
|
176
|
+
await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
177
|
+
const observedB = await readText(fileB)
|
|
178
|
+
assert.equal(observedB, 'B1',
|
|
179
|
+
'observed fileB=' + JSON.stringify(observedB) + ';required=逐字未变("B1"):回退 A 绝不能动 A 从未写过的文件。' +
|
|
180
|
+
'根因:baseVersionFor 取到 B 的回合点(内容 = 留点时的 B0)⇒ base===ours,' +
|
|
181
|
+
'在没有归属旁证时 mergeForeignRecord 退回 fallback(整文件覆盖回目标版本),把 B 的改动抹掉。')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
/* ============================================ ② 同一文件不同区域 */
|
|
185
|
+
|
|
186
|
+
test('T2-1 同一文件不同函数:撤销 A 的 alpha、保留 B 的 beta(按最终文本断言)', async (t) => {
|
|
187
|
+
const fx = await makeFixture()
|
|
188
|
+
t.after(fx.cleanup)
|
|
189
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
190
|
+
const file = path.join(fx.projDir, 'mod.ts')
|
|
191
|
+
const v0 = 'export function alpha() {\n return 1\n}\nexport function beta() {\n return 2\n}\n'
|
|
192
|
+
await fs.writeFile(file, v0)
|
|
193
|
+
|
|
194
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
195
|
+
await fs.writeFile(file, 'export function alpha() {\n return 10\n}\nexport function beta() {\n return 2\n}\n')
|
|
196
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
197
|
+
await fs.writeFile(file, 'export function alpha() {\n return 10\n}\nexport function beta() {\n return 20\n}\n')
|
|
198
|
+
|
|
199
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
200
|
+
assert.equal(await readText(file), 'export function alpha() {\n return 1\n}\nexport function beta() {\n return 20\n}\n',
|
|
201
|
+
'alpha 回到 1、beta 保持 B 的 20')
|
|
202
|
+
const segment = wsSegment(result)
|
|
203
|
+
assert.equal(segment.mergedCount, 1)
|
|
204
|
+
assert.equal(segment.conflictCount, undefined)
|
|
205
|
+
assert.equal(segment.restored, 1)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/* ============================================== ③ 同一处重叠修改 */
|
|
209
|
+
|
|
210
|
+
test('X-3 [DEFECT] 同一函数内重叠修改:零写入正确,但冲突原因被 noop 分支整段丢弃', async (t) => {
|
|
211
|
+
const fx = await makeFixture()
|
|
212
|
+
t.after(fx.cleanup)
|
|
213
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
214
|
+
const file = path.join(fx.projDir, 'f.ts')
|
|
215
|
+
const bVersion = 'function f() {\n const a = 11\n const b = 2\n}\n'
|
|
216
|
+
await fs.writeFile(file, 'function f() {\n const a = 1\n const b = 2\n}\n')
|
|
217
|
+
|
|
218
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
219
|
+
await fs.writeFile(file, 'function f() {\n const a = 10\n const b = 2\n}\n')
|
|
220
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
221
|
+
await fs.writeFile(file, bVersion)
|
|
222
|
+
|
|
223
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
224
|
+
const segment = wsSegment(result)
|
|
225
|
+
// 安全性质先断言:冲突时绝不选边、绝不写
|
|
226
|
+
assert.equal(await readText(file), bVersion, '冲突时必须零写入,保持 B 的版本')
|
|
227
|
+
assert.equal(segment.conflictCount, 1, '冲突计数必须如实')
|
|
228
|
+
assert.equal(segment.restored, 0)
|
|
229
|
+
// 可读性:机器码与 detail 必须能说明「是冲突」,而不是「没有可恢复内容」
|
|
230
|
+
const observed = 'code=' + result.code + ' status=' + segment.status + ' detail=' + JSON.stringify(segment.detail)
|
|
231
|
+
assert.notEqual(result.code, 'nothing-to-restore',
|
|
232
|
+
'observed ' + observed + ';required=冲突不得以「没有可恢复内容」收尾(否则用户会以为回退无事可做)。')
|
|
233
|
+
assert.equal(segment.detail.some((line) => line.includes('foreign-conflict')), true,
|
|
234
|
+
'observed ' + observed + ';required=detail 必须带 foreign-conflict(needed 为空走 noopSegment 时把已累积的 detail 整段丢弃了)。')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
/* ======================================== ④ B 改 A 新增的代码 */
|
|
238
|
+
|
|
239
|
+
test('T4-1 [SAFE] B 在 A 新增的函数体里继续改:判冲突、零写入,B 的修改完整保留', async (t) => {
|
|
240
|
+
const fx = await makeFixture()
|
|
241
|
+
t.after(fx.cleanup)
|
|
242
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
243
|
+
const file = path.join(fx.projDir, 'f.ts')
|
|
244
|
+
await fs.writeFile(file, 'const x = 1\nconst y = 2\n')
|
|
245
|
+
|
|
246
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
247
|
+
await fs.writeFile(file, 'const x = 1\nconst y = 2\nfunction added() {\n return 1\n}\n')
|
|
248
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
249
|
+
const bVersion = 'const x = 1\nconst y = 2\nfunction added() {\n return 42\n}\n'
|
|
250
|
+
await fs.writeFile(file, bVersion)
|
|
251
|
+
|
|
252
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
253
|
+
const segment = wsSegment(result)
|
|
254
|
+
// 可接受(不是完美结果):B 改的正是 A 新增的代码,撤销 A 必然要删掉这段代码;
|
|
255
|
+
// 三方合并无法「删掉函数又保留对函数体的编辑」,因此拒绝写入、把选择权交还用户,绝不静默丢弃 B 的 42。
|
|
256
|
+
assert.equal(await readText(file), bVersion, '冲突时零写入,B 的 42 必须还在')
|
|
257
|
+
assert.equal(segment.conflictCount, 1)
|
|
258
|
+
assert.equal(segment.restored, 0)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
/* ================================== ⑤ A 删除 + B 同点新增 */
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* X-5(Lead 2026-10-18 定案后把期望改成 [SAFE]):A 删掉 l3、B 在**同一位置**插入 B-new。
|
|
265
|
+
*
|
|
266
|
+
* base→ours = 「在位置 p 插入 l3」,base→theirs = 「在位置 p 插入 B-new」—— 两个**纯插入起点相同**,
|
|
267
|
+
* 先后顺序没有任何依据可判。引擎绝不猜顺序(猜错就是把 B 的代码插到错位置,或让 l3 顶掉 B 的新增);
|
|
268
|
+
* 按冲突处理:**零写入**、B 的新增原样保留、A 的删除如实报告为「未撤销」,交用户决定。
|
|
269
|
+
* 这与 task 的原则一致:无法安全区分归属/顺序时明确提示冲突,不得擅自覆盖。
|
|
270
|
+
*/
|
|
271
|
+
test('X-5 [SAFE] A 删一行、B 在同一位置新增:判冲突、零写入,两侧内容都不丢', async (t) => {
|
|
272
|
+
const fx = await makeFixture()
|
|
273
|
+
t.after(fx.cleanup)
|
|
274
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
275
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
276
|
+
await fs.writeFile(file, 'l1\nl2\nl3\nl4\nl5\n')
|
|
277
|
+
|
|
278
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
279
|
+
await fs.writeFile(file, 'l1\nl2\nl4\nl5\n') // A 删除 l3
|
|
280
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
281
|
+
await fs.writeFile(file, 'l1\nl2\nB-new\nl4\nl5\n') // B 在 l3 的位置新增
|
|
282
|
+
|
|
283
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
284
|
+
const after = await readText(file)
|
|
285
|
+
const finalLines = after.split('\n').filter((line) => line !== '')
|
|
286
|
+
const segment = wsSegment(result)
|
|
287
|
+
const observed = 'file=' + JSON.stringify(finalLines.join('\n')) + ' conflictCount=' + String(segment.conflictCount)
|
|
288
|
+
assert.equal(segment.conflictCount, 1, '必须如实报冲突:' + observed)
|
|
289
|
+
assert.equal(after, 'l1\nl2\nB-new\nl4\nl5\n', '判冲突 ⇒ 一个字节都不写:' + observed)
|
|
290
|
+
assert.equal(segment.detail.some((line) => line.includes('foreign-conflict')), true,
|
|
291
|
+
'冲突必须逐条可读(X-3 已修):' + JSON.stringify(segment.detail))
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
/* ============================ ⑥ 重排 / 行移动 / 重命名 */
|
|
295
|
+
|
|
296
|
+
test('T6-1 行移动:A 把首行移到末尾、B 改中间行 ⇒ 行序回到原样且 B 的修改保留', async (t) => {
|
|
297
|
+
const fx = await makeFixture()
|
|
298
|
+
t.after(fx.cleanup)
|
|
299
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
300
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
301
|
+
await fs.writeFile(file, 'one\ntwo\nthree\nfour\nfive\n')
|
|
302
|
+
|
|
303
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
304
|
+
await fs.writeFile(file, 'two\nthree\nfour\nfive\none\n') // A 把 one 移到末尾
|
|
305
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
306
|
+
await fs.writeFile(file, 'two\nthree\nB-three\nfour\nfive\none\n') // B 改 three
|
|
307
|
+
|
|
308
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
309
|
+
assert.equal(await readText(file), 'one\ntwo\nthree\nB-three\nfour\nfive\n',
|
|
310
|
+
'A 的移动被撤销、B 的 B-three 保留')
|
|
311
|
+
const segment = wsSegment(result)
|
|
312
|
+
assert.equal(segment.mergedCount, 1)
|
|
313
|
+
assert.equal(segment.conflictCount, undefined)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('T6-2 [SAFE] 整文件重排 + B 的无关行修改:判冲突、零写入(行级合并无法映射重排)', async (t) => {
|
|
317
|
+
const fx = await makeFixture()
|
|
318
|
+
t.after(fx.cleanup)
|
|
319
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
320
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
321
|
+
await fs.writeFile(file, 'a\nb\nc\nd\n')
|
|
322
|
+
|
|
323
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
324
|
+
await fs.writeFile(file, 'd\nc\nb\na\n') // A 整文件倒序
|
|
325
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
326
|
+
const bVersion = 'd\nc\nB\na\n' // B 改中间一行
|
|
327
|
+
await fs.writeFile(file, bVersion)
|
|
328
|
+
|
|
329
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
330
|
+
const segment = wsSegment(result)
|
|
331
|
+
// 可接受(不是完美结果):整文件重排在行级 diff 里等价于「全文删除 + 全文插入」,
|
|
332
|
+
// 任何与它触碰的修改都无法可靠归位;拒绝写入保住 B,不会写坏文件。
|
|
333
|
+
assert.equal(await readText(file), bVersion, '零写入:B 的版本原样保留')
|
|
334
|
+
assert.equal(segment.conflictCount, 1)
|
|
335
|
+
assert.equal(segment.restored, 0)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('T6-3 [SAFE] 重命名:旧名恢复、新名保留(B 已在新名上改过),拒绝删除并如实报告', async (t) => {
|
|
339
|
+
const fx = await makeFixture()
|
|
340
|
+
t.after(fx.cleanup)
|
|
341
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
342
|
+
const oldFile = path.join(fx.projDir, 'old.txt')
|
|
343
|
+
const newFile = path.join(fx.projDir, 'new.txt')
|
|
344
|
+
await fs.writeFile(oldFile, 'v0\nline2\n')
|
|
345
|
+
|
|
346
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
347
|
+
await fs.rm(oldFile)
|
|
348
|
+
await fs.writeFile(newFile, 'v0\nline2\n') // A 改名 old → new
|
|
349
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
350
|
+
await fs.writeFile(newFile, 'v0\nB-line\n') // B 改新名
|
|
351
|
+
|
|
352
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off', removeCreated: true })
|
|
353
|
+
const segment = wsSegment(result)
|
|
354
|
+
// 可接受(不是完美结果):撤销改名需要删 new 并把 B 的改动搬回 old —— 逐路径引擎做不到;
|
|
355
|
+
// 它选择「不删别人的文件」,旧名恢复 + 新名保留,并把该路径计入 createdSkipped 供界面解释。
|
|
356
|
+
assert.equal(await readText(oldFile), 'v0\nline2\n', '旧名被恢复')
|
|
357
|
+
assert.equal(await readText(newFile), 'v0\nB-line\n', 'B 改过的新名不被删除')
|
|
358
|
+
assert.equal((segment.createdSkipped ?? []).some((target) => samePath(target, newFile)), true,
|
|
359
|
+
'createdSkipped 必须如实列出被拒删的新名(observed=' + JSON.stringify(segment.createdSkipped) + ')')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
/* ============================================ ⑦ 删除 / 重新创建 */
|
|
363
|
+
|
|
364
|
+
test('T7-1 A 删除文件:回退按字节重建(内容逐字一致)', async (t) => {
|
|
365
|
+
const fx = await makeFixture()
|
|
366
|
+
t.after(fx.cleanup)
|
|
367
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
368
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
369
|
+
await fs.writeFile(file, 'keep-me\n中文\n')
|
|
370
|
+
|
|
371
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
372
|
+
await fs.rm(file)
|
|
373
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
374
|
+
assert.equal(await exists(file), true, 'A 删掉的文件必须被重建')
|
|
375
|
+
assert.equal(await readText(file), 'keep-me\n中文\n')
|
|
376
|
+
assert.equal(wsSegment(result).restored, 1)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('T7-2 [SAFE] B 在 A 之后删除文件:回退 A 不复活它(theirs-missing ⇒ 零写入)', async (t) => {
|
|
380
|
+
const fx = await makeFixture()
|
|
381
|
+
t.after(fx.cleanup)
|
|
382
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
383
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
384
|
+
await fs.writeFile(file, 'v0\n')
|
|
385
|
+
|
|
386
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
387
|
+
await fs.writeFile(file, 'A1\n')
|
|
388
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
389
|
+
await fs.rm(file) // B 显式删除
|
|
390
|
+
|
|
391
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
392
|
+
// 可接受:复活文件等于撤销 B 的删除;引擎选择「对方改成不存在 ⇒ 无法安全撤销」,一个字节都不写。
|
|
393
|
+
assert.equal(await exists(file), false, '不得凭空复活 B 删除的文件')
|
|
394
|
+
assert.equal(wsSegment(result).unavailableCount, 1)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('X-7 [DEFECT] A 删除文件、B 重新创建它:回退 A 把 B 重建的内容覆盖回旧版本', async (t) => {
|
|
398
|
+
const fx = await makeFixture()
|
|
399
|
+
t.after(fx.cleanup)
|
|
400
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
401
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
402
|
+
await fs.writeFile(file, 'v0\n')
|
|
403
|
+
|
|
404
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
405
|
+
await fs.rm(file) // A 删除
|
|
406
|
+
await pointIdOf(engine, fx, 'session-B') // B 的回合点里没有这个文件
|
|
407
|
+
await fs.writeFile(file, 'B-recreated\n') // B 重新创建
|
|
408
|
+
|
|
409
|
+
await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
410
|
+
const observed = await readText(file)
|
|
411
|
+
assert.equal(observed, 'B-recreated\n',
|
|
412
|
+
'observed=' + JSON.stringify(observed) + ';required=保留 B 重建的内容(或零写入并报告冲突)。' +
|
|
413
|
+
'根因:B 的回合点里该路径「不存在」⇒ baseVersionFor 返回 null;无归属旁证 ⇒ fallback 整文件覆盖回 v0。')
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
/* ================================== ⑧ 无归属的人工手改 */
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* X-8(Lead 2026-10-18 定案后改写为 [SAFE] 并**补上证据来源**):会话之外的手改不得被静默覆盖。
|
|
420
|
+
*
|
|
421
|
+
* 为什么必须喂时间窗:没有归属、也没有中间点的情形在**证据层面**是空的。旧口径(无证据 ⇒ 照常覆盖)
|
|
422
|
+
* 被既有 R-05c / R-01 冻结,不能凭空改成「一律拒绝」—— 那会把正常的纯本会话回退也一起拦掉。
|
|
423
|
+
* 真机之所以能判出来,是因为采集器一直在记每个会话的活跃窗口(哪怕这次回退的会话早已静默):
|
|
424
|
+
* 文件 mtime 落在**谁都不活跃**的时刻 ⇒ 手改 / 外部工具 / 另一个进程 ⇒ 零写入并如实报 unclaimed。
|
|
425
|
+
*/
|
|
426
|
+
test('X-8 [SAFE] 手改落在「无会话活跃」的时间窗 ⇒ 零写入并如实报 unclaimed', async (t) => {
|
|
427
|
+
const fx = await makeFixture()
|
|
428
|
+
t.after(fx.cleanup)
|
|
429
|
+
// 时间窗:只记一个**远早于**手改时刻的窗口 ⇒ 手改落在窗口之外、且那一刻没有任何会话活跃
|
|
430
|
+
const activity = new RollbackSessionActivity({
|
|
431
|
+
file: path.join(fx.dataDir, 'rollback-points', 'activity.json'),
|
|
432
|
+
flushDelayMs: 0,
|
|
433
|
+
})
|
|
434
|
+
activity.noteActivity('session-A', 1_000_000)
|
|
435
|
+
const engine = makeEngine(fx, { activity, now: steppingClock(1_000_000, 1) })
|
|
436
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
437
|
+
await fs.writeFile(file, 'v0\n')
|
|
438
|
+
|
|
439
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
440
|
+
await fs.writeFile(file, 'A-line\n') // A 的改动
|
|
441
|
+
await fs.writeFile(file, 'A-line\nmanual-edit\n') // 第三方手工追加(没有任何归属 / 中间点)
|
|
442
|
+
// 把 mtime 钉到「谁都不活跃」的时刻(窗口之外)—— 这就是真机里「会话结束后被手改」的形状
|
|
443
|
+
const handEditAt = new Date(9_000_000)
|
|
444
|
+
await fs.utimes(file, handEditAt, handEditAt)
|
|
445
|
+
|
|
446
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
447
|
+
const segment = wsSegment(result)
|
|
448
|
+
const observed = await readText(file)
|
|
449
|
+
assert.equal(observed.includes('manual-edit'), true,
|
|
450
|
+
'observed=' + JSON.stringify(observed) + ';required=无人认领的手改不得被覆盖:' + JSON.stringify(segment.detail))
|
|
451
|
+
assert.equal(segment.externalCount, 1, '必须如实计入 externalCount:' + JSON.stringify(segment.detail))
|
|
452
|
+
assert.equal(segment.detail.some((line) => line.includes('foreign-unclaimed')), true,
|
|
453
|
+
'理由必须逐条可读:' + JSON.stringify(segment.detail))
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('T8-2 [SAFE] 第三方手改但有归属旁证:没有共同祖先 ⇒ 零写入并如实报 unavailable', async (t) => {
|
|
457
|
+
const fx = await makeFixture()
|
|
458
|
+
t.after(fx.cleanup)
|
|
459
|
+
const attr = makeAttribution(fx)
|
|
460
|
+
const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
|
|
461
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
462
|
+
await fs.writeFile(file, 'v0\n')
|
|
463
|
+
|
|
464
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
465
|
+
await fs.writeFile(file, 'A-line\n')
|
|
466
|
+
await fs.writeFile(file, 'A-line\nmanual-edit\n')
|
|
467
|
+
attr.noteToolCall('session-B', { name: 'write', callId: 'w-manual', arguments: JSON.stringify({ file_path: file }) })
|
|
468
|
+
|
|
469
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
470
|
+
// 可接受:有「别人写过」的旁证但没有可用祖先 ⇒ 无法安全撤销,宁可不动,也不整文件覆盖。
|
|
471
|
+
assert.equal(await readText(file), 'A-line\nmanual-edit\n', '零写入')
|
|
472
|
+
assert.equal(wsSegment(result).unavailableCount, 1)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
/* ============================================ ⑨ 三会话交错 */
|
|
476
|
+
|
|
477
|
+
test('T9-1 三个会话交错改同一文件:回退 A 只撤销 A 的行,B/C 的行逐字保留', async (t) => {
|
|
478
|
+
const fx = await makeFixture()
|
|
479
|
+
t.after(fx.cleanup)
|
|
480
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
481
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
482
|
+
await fs.writeFile(file, 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\n')
|
|
483
|
+
|
|
484
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
485
|
+
await fs.writeFile(file, 'A1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\n')
|
|
486
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
487
|
+
await fs.writeFile(file, 'A1\nl2\nl3\nl4\nB5\nl6\nl7\nl8\nl9\n')
|
|
488
|
+
await pointIdOf(engine, fx, 'session-C')
|
|
489
|
+
await fs.writeFile(file, 'A1\nl2\nl3\nl4\nB5\nl6\nl7\nl8\nC9\n')
|
|
490
|
+
|
|
491
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
492
|
+
assert.equal(await readText(file), 'l1\nl2\nl3\nl4\nB5\nl6\nl7\nl8\nC9\n',
|
|
493
|
+
'A1 被撤销,B5 与 C9 都保留')
|
|
494
|
+
assert.equal(wsSegment(result).mergedCount, 1)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
/* ================================ ⑩ 顺序回退 / 撤销回退 */
|
|
498
|
+
|
|
499
|
+
test('X-10 [DEFECT] 先回退 A 再回退 B:第二次回退把 A 的改动复活、并抹掉 B 的改动', async (t) => {
|
|
500
|
+
const fx = await makeFixture()
|
|
501
|
+
t.after(fx.cleanup)
|
|
502
|
+
const attr = makeAttribution(fx)
|
|
503
|
+
const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
|
|
504
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
505
|
+
const s0 = 'l1\nl2\nl3\nl4\nl5\n'
|
|
506
|
+
await fs.writeFile(file, s0)
|
|
507
|
+
|
|
508
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
509
|
+
await fs.writeFile(file, 'A1\nl2\nl3\nl4\nl5\n')
|
|
510
|
+
const idB = await pointIdOf(engine, fx, 'session-B')
|
|
511
|
+
await fs.writeFile(file, 'A1\nl2\nl3\nl4\nB5\n')
|
|
512
|
+
attr.noteToolCall('session-B', { name: 'write', callId: 'w-b', arguments: JSON.stringify({ file_path: file }) })
|
|
513
|
+
|
|
514
|
+
// 第一刀按**生产缺省**留救援点(guardPolicy: require):连续回退的第二刀依赖它 ——
|
|
515
|
+
// 它是「A 已改、B 未改」那一刻的状态,也就是第二刀唯一可用的共同祖先(baseVersionFor 第二档)。
|
|
516
|
+
await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'require' })
|
|
517
|
+
assert.equal(await readText(file), 'l1\nl2\nl3\nl4\nB5\n', '第一步:撤销 A、保留 B')
|
|
518
|
+
|
|
519
|
+
await engine.rewind({ id: idB, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
520
|
+
const observed = await readText(file)
|
|
521
|
+
assert.equal(observed, s0,
|
|
522
|
+
'observed=' + JSON.stringify(observed) + ';required=两个会话都被回退后应回到共同起点 ' + JSON.stringify(s0) +
|
|
523
|
+
'(A 的改动不得复活,B 的 B5 不得丢失)。根因:回退 B 时 B 的目标点 P_B 内容 = A 改后的版本,' +
|
|
524
|
+
'且目标点之后没有别的会话点可当 base ⇒ fallback 整文件覆盖,把已回退的 A 改动写回来。')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
test('T10-2 撤销回退(回到救援点)再回退:三态逐步可验证', async (t) => {
|
|
528
|
+
const fx = await makeFixture()
|
|
529
|
+
t.after(fx.cleanup)
|
|
530
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
531
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
532
|
+
await fs.writeFile(file, 'l1\nl2\nl3\n')
|
|
533
|
+
|
|
534
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
535
|
+
await fs.writeFile(file, 'A1\nl2\nl3\n')
|
|
536
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
537
|
+
await fs.writeFile(file, 'A1\nl2\nB3\n') // 回退前状态
|
|
538
|
+
|
|
539
|
+
const first = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'require' })
|
|
540
|
+
assert.equal(await readText(file), 'l1\nl2\nB3\n', '态 1:撤销 A、保留 B')
|
|
541
|
+
const guardId = first.guardPointId
|
|
542
|
+
assert.ok(guardId !== undefined, 'require 档必须留下救援点')
|
|
543
|
+
|
|
544
|
+
await engine.rewind({ id: guardId, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
545
|
+
assert.equal(await readText(file), 'A1\nl2\nB3\n', '态 2:回到救援点 = 回退前状态,逐字一致')
|
|
546
|
+
|
|
547
|
+
await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
548
|
+
assert.equal(await readText(file), 'l1\nl2\nB3\n', '态 3:再次回退 A,结果与态 1 完全一致')
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
/* ============================== ⑪ 中途失败 / 并发写入 */
|
|
552
|
+
|
|
553
|
+
test('T11-1 写第二个文件时失败:已写文件整体回滚,两个文件都保持回退前内容', async (t) => {
|
|
554
|
+
const fx = await makeFixture()
|
|
555
|
+
t.after(fx.cleanup)
|
|
556
|
+
const file1 = path.join(fx.projDir, 'a.txt')
|
|
557
|
+
const file2 = path.join(fx.projDir, 'b.txt')
|
|
558
|
+
await fs.writeFile(file1, 'A0')
|
|
559
|
+
await fs.writeFile(file2, 'B0')
|
|
560
|
+
let injected = false
|
|
561
|
+
const engine = makeEngine(fx, {
|
|
562
|
+
now: steppingClock(1_000_000, 1),
|
|
563
|
+
writeFile: async (target, bytes) => {
|
|
564
|
+
if (!injected && samePath(target, file2)) {
|
|
565
|
+
injected = true
|
|
566
|
+
throw new Error('injected EIO')
|
|
567
|
+
}
|
|
568
|
+
await atomicWriteFile(target, bytes)
|
|
569
|
+
},
|
|
570
|
+
})
|
|
571
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
572
|
+
await fs.writeFile(file1, 'A1')
|
|
573
|
+
await fs.writeFile(file2, 'B1') // 回退前内容
|
|
574
|
+
|
|
575
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
576
|
+
// 整体回滚:两条都必须回到回退前内容(绝不出现「写了一半」)
|
|
577
|
+
assert.equal(await readText(file1), 'A1', '已写的文件必须被前像还原')
|
|
578
|
+
assert.equal(await readText(file2), 'B1', '失败的文件保持原样')
|
|
579
|
+
const segment = wsSegment(result)
|
|
580
|
+
assert.equal(segment.status, 'failed')
|
|
581
|
+
assert.equal(segment.restored, 0)
|
|
582
|
+
assert.equal(segment.detail.some((line) => line.includes('rolledBack=')), true,
|
|
583
|
+
'detail 必须说明整体回滚:' + JSON.stringify(segment.detail))
|
|
584
|
+
assert.equal(result.guidance.some((item) => item.code === 'rollback.guidance.workspaceRolledBack'), true)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
test('T11-2 合并之后、写入之前外部再改一次:放弃写入,绝不覆盖比我们新的内容', async (t) => {
|
|
588
|
+
const fx = await makeFixture()
|
|
589
|
+
t.after(fx.cleanup)
|
|
590
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
591
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
592
|
+
await fs.writeFile(file, 'l1\nl2\nl3\n')
|
|
593
|
+
|
|
594
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
595
|
+
await fs.writeFile(file, 'A1\nl2\nl3\n')
|
|
596
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
597
|
+
await fs.writeFile(file, 'A1\nl2\nB3\n')
|
|
598
|
+
|
|
599
|
+
// 引擎现成的 seam:前像暂存刚好发生在「最终并发校验之后、真正写入之前」
|
|
600
|
+
const self = engine as unknown as { openUndoStager: () => Promise<unknown> }
|
|
601
|
+
const original = self.openUndoStager.bind(engine)
|
|
602
|
+
self.openUndoStager = async () => {
|
|
603
|
+
await fs.writeFile(file, 'EXTERNAL-TOUCH\n')
|
|
604
|
+
return await original()
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
608
|
+
assert.equal(await readText(file), 'EXTERNAL-TOUCH\n', '外部新写入的内容绝不被覆盖')
|
|
609
|
+
assert.equal(wsSegment(result).concurrentCount, 1, '并发冲突必须如实计数')
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
/* ======================= ⑫ 对象仓丢对象 / CRLF / 非 UTF-8 */
|
|
613
|
+
|
|
614
|
+
interface StoredRecordRef { workspace: { payload: string | null } }
|
|
615
|
+
interface StoredPayload { files: { path: string; sha256: string }[] }
|
|
616
|
+
|
|
617
|
+
test('X-12a [DEFECT] 对象仓丢对象:零写入正确,但 reasonCode 被标成 invalid-path 而非 object-missing', async (t) => {
|
|
618
|
+
const fx = await makeFixture()
|
|
619
|
+
t.after(fx.cleanup)
|
|
620
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
621
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
622
|
+
await fs.writeFile(file, 'A0')
|
|
623
|
+
|
|
624
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
625
|
+
await fs.writeFile(file, 'A1') // 当前内容与目标不同 ⇒ 必须读对象
|
|
626
|
+
|
|
627
|
+
const record = JSON.parse(await readText(path.join(engine.storeDir, 'records', idA + '.json'))) as StoredRecordRef
|
|
628
|
+
const payloadHash = record.workspace.payload
|
|
629
|
+
assert.ok(payloadHash !== null, '记录里必须有工作区载荷引用')
|
|
630
|
+
const payload = JSON.parse(await readText(path.join(engine.storeDir, 'payloads', payloadHash + '.json'))) as StoredPayload
|
|
631
|
+
const entry = payload.files.find((item) => samePath(item.path, file))
|
|
632
|
+
assert.ok(entry !== undefined, '载荷里必须有该文件')
|
|
633
|
+
await fs.rm(path.join(engine.storeDir, 'objects', entry.sha256))
|
|
634
|
+
|
|
635
|
+
const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
636
|
+
const segment = wsSegment(result)
|
|
637
|
+
// 安全性质:对象缺失 ⇒ 零写入(这是对的)
|
|
638
|
+
assert.equal(await readText(file), 'A1', '对象缺失时零写入')
|
|
639
|
+
assert.equal(segment.restored, 0)
|
|
640
|
+
assert.equal(segment.detail.some((line) => line.includes('object-missing')), true,
|
|
641
|
+
'detail 必须说明对象缺失:' + JSON.stringify(segment.detail))
|
|
642
|
+
const observed = 'reasonCode=' + String(segment.reasonCode) + ' detail=' + JSON.stringify(segment.detail)
|
|
643
|
+
assert.equal(segment.reasonCode, 'object-missing',
|
|
644
|
+
'observed ' + observed + ';required=段级 reasonCode 应能区分「对象仓丢对象」与「路径非法」;' +
|
|
645
|
+
'现在统一落成 invalid-path(failed>0 的兜底分支),界面/排障会把对象损坏误读成路径问题。')
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
test('X-12b [DEFECT] CRLF 文件的三方合并:行尾被改成 LF(字节保真失败)', async (t) => {
|
|
649
|
+
const fx = await makeFixture()
|
|
650
|
+
t.after(fx.cleanup)
|
|
651
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
652
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
653
|
+
await fs.writeFile(file, 'l1\r\nl2\r\n')
|
|
654
|
+
|
|
655
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
656
|
+
await fs.writeFile(file, 'A1\r\nl2\r\n')
|
|
657
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
658
|
+
await fs.writeFile(file, 'A1\r\nB2\r\n')
|
|
659
|
+
|
|
660
|
+
await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
661
|
+
const actual = await readBytes(file)
|
|
662
|
+
const expected = Buffer.from('l1\r\nB2\r\n', 'utf8')
|
|
663
|
+
assert.deepEqual(actual, expected,
|
|
664
|
+
'observed hex=' + actual.toString('hex') + ';required hex=' + expected.toString('hex') +
|
|
665
|
+
'(撤销 A 的 A1、保留 B 的 B2,且原文件的 CRLF 行尾必须逐字保留)。' +
|
|
666
|
+
'根因:mergeThreeWay 的 splitLinesOf 把 \\r\\n 规范化成 \\n,重新 join 时用 \\n。')
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
test('X-12c [DEFECT] 非 UTF-8 字节的文件合并:非法字节被替换成 U+FFFD(内容损坏)', async (t) => {
|
|
670
|
+
const fx = await makeFixture()
|
|
671
|
+
t.after(fx.cleanup)
|
|
672
|
+
const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
|
|
673
|
+
const file = path.join(fx.projDir, 'f.txt')
|
|
674
|
+
const build = (line1: string, line3: string): Buffer => Buffer.concat([
|
|
675
|
+
Buffer.from(line1 + '\n', 'utf8'),
|
|
676
|
+
Buffer.from([0xff, 0xfe, 0x0a]), // 非法 UTF-8 行(不含 NUL ⇒ 引擎不判二进制)
|
|
677
|
+
Buffer.from(line3 + '\n', 'utf8'),
|
|
678
|
+
])
|
|
679
|
+
await fs.writeFile(file, build('l1', 'l3'))
|
|
680
|
+
|
|
681
|
+
const idA = await pointIdOf(engine, fx, 'session-A')
|
|
682
|
+
await fs.writeFile(file, build('A1', 'l3'))
|
|
683
|
+
await pointIdOf(engine, fx, 'session-B')
|
|
684
|
+
await fs.writeFile(file, build('A1', 'B3'))
|
|
685
|
+
|
|
686
|
+
await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', guardPolicy: 'off' })
|
|
687
|
+
const actual = await readBytes(file)
|
|
688
|
+
const expected = build('l1', 'B3')
|
|
689
|
+
assert.deepEqual(actual, expected,
|
|
690
|
+
'observed hex=' + actual.toString('hex') + ';required hex=' + expected.toString('hex') +
|
|
691
|
+
'(撤销 A 的 A1、保留 B 的 B3,且第 2 行的非法字节 ff fe 必须逐字保留)。' +
|
|
692
|
+
'根因:mergeForeignRecord 用 Buffer.toString("utf8") 解码并用 Buffer.from(text,"utf8") 编码,' +
|
|
693
|
+
'非 UTF-8 字节被解码成 U+FFFD 后写回。')
|
|
694
|
+
})
|