dsh-config-manager 0.1.78 → 0.1.80
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 +401 -112
- package/lib/client.js +2899 -1525
- 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/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -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 +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -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/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- 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 +9 -0
- package/lib/ui/i18n.js +22 -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/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- 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/market/market-api.test.ts +3 -2
- 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 +220 -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 +248 -0
- package/src/client/sync/merge-view.ts +398 -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.test.ts +5 -3
- 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/ancestor.ts +76 -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 +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -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.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- 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 +22 -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
package/src/sync/sync-engine.ts
CHANGED
|
@@ -48,22 +48,107 @@ import { OPT_IN_SYNC_SECTIONS } from './sync-selection.ts';
|
|
|
48
48
|
import { nextSessionTombstones, sessionUnitIdsOfSection, stripTombstonedUnits } from './session-tombstones.ts';
|
|
49
49
|
import { DEFAULT_RETENTION_POLICY, selectPruneCandidatesByPolicy } from './retention-policy.ts';
|
|
50
50
|
import type { RetentionPolicy } from './retention-policy.ts';
|
|
51
|
-
import { hashSection, loadSyncState, saveSyncState } from './sync-state.ts';
|
|
52
|
-
import type {
|
|
53
|
-
import {
|
|
51
|
+
import { hashSection, loadSyncState, resolveDeviceIdentity, saveSyncState } from './sync-state.ts';
|
|
52
|
+
import type { PendingMerge, SyncState } from './sync-state.ts';
|
|
53
|
+
import type { EncryptedCredentials, SyncProbeResult, SyncSnapshot, SyncSnapshotMeta, SyncTransport } from './transport.ts';
|
|
54
|
+
import { isEncryptedSections, PROBE_UNSUPPORTED_CODE, SyncTransportError } from './transport.ts';
|
|
54
55
|
import { msgOf, zhMsg } from '../core/messages.ts';
|
|
55
56
|
import { engineMsg } from './messages.ts';
|
|
56
57
|
import type { MsgFunc } from '../core/messages.ts';
|
|
57
|
-
import { DEFAULT_ANCESTOR_KEEP, pruneAncestors, tryLoadAncestor, writeAncestor } from './ancestor.ts';
|
|
58
|
-
import type { MergePlan } from './merge.ts';
|
|
58
|
+
import { DEFAULT_ANCESTOR_KEEP, pruneAncestors, tryLoadAncestor, writeAncestor, writeEncryptedAncestor } from './ancestor.ts';
|
|
59
|
+
import type { MergeConflictReason, MergePlan, MergeSectionResult } from './merge.ts';
|
|
59
60
|
import { merge as mergeSections } from './merge.ts';
|
|
61
|
+
import { classifyMergePlan } from './risk.ts';
|
|
60
62
|
import type { SyncApplyPlan } from './risk.ts';
|
|
63
|
+
import { redact } from '../security/redaction.ts';
|
|
64
|
+
import { registerMessageCatalog } from '../core/engine-locale.ts';
|
|
61
65
|
import { createSnapshot, selectPruneCandidates } from '../core/backup.ts';
|
|
62
66
|
import { rollback } from '../core/rollback.ts';
|
|
63
67
|
import { FileSnapshotStore } from '../core/backup.ts';
|
|
64
68
|
import type { Snapshot, SnapshotStore, TransactionSnapshotContext } from '../core/types.ts';
|
|
65
69
|
import type { PlanItemProgress } from '../core/analyzer.ts';
|
|
66
70
|
|
|
71
|
+
/* ------------------------------------------- 合并预览:本域追加文案目录 + 可读预览工具 */
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 本模块新增文案的追加目录(zh 源 / en 镜像;与 adapters / foreign 域同模式)。
|
|
75
|
+
*
|
|
76
|
+
* 为什么不写进 `src/sync/messages.ts`:该文件由**并行会话**维护、且不在本任务写入范围内;
|
|
77
|
+
* 追加目录的查找顺序是「既有目录(当前语言)→ 注册目录 → 键名」,新键不可能遮蔽既有键。
|
|
78
|
+
*/
|
|
79
|
+
const zh = {
|
|
80
|
+
'sync.pushRemoteAdvanced': '远端最新快照 {remote} 与本地基线 {base} 不一致:本次推送未执行(远端未写入、基线未改动)。请先同步远端改动;确认要用本机覆盖远端时,再显式选择强制推送。',
|
|
81
|
+
'sync.pushAdvanceCheckFailed': '无法确认远端是否已被其它设备推进({reason}):本次推送按原样继续',
|
|
82
|
+
'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine 缺少 importer(需在 options 中注入)',
|
|
83
|
+
'sync.mergePendingConflicts': '仍有 {count} 个分区未裁决({sections}):已裁决的部分已写入本机,但基线不推进,冲突下一轮会重新出现',
|
|
84
|
+
'sync.baselineRexportFailed': '写回成功但重新导出本机分区失败({sections}):无法证明基线与本机一致,本次**不记基线**(远端指针保持原值,下一轮重新合并收敛)',
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
const en: Record<keyof typeof zh, string> = {
|
|
88
|
+
'sync.pushRemoteAdvanced': 'The newest remote snapshot {remote} differs from the local baseline {base}: this push did not run (nothing written remotely, baseline untouched). Sync the remote changes first, or explicitly force the push if this machine should overwrite the remote.',
|
|
89
|
+
'sync.pushAdvanceCheckFailed': 'Cannot tell whether another device advanced the remote ({reason}): this push continues as-is',
|
|
90
|
+
'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine is missing the importer (must be injected via options)',
|
|
91
|
+
'sync.mergePendingConflicts': '{count} section(s) still have no decision ({sections}): the decided parts were written locally, but the baseline is not advanced, so the conflicts will reappear next round',
|
|
92
|
+
'sync.baselineRexportFailed': 'The write succeeded but re-exporting the local section(s) failed ({sections}): the baseline cannot be proven to match this machine, so no baseline was recorded (the remote pointer keeps its old value and the next merge converges again)',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
registerMessageCatalog({ id: 'sync-engine-merge', zh, en });
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 单侧预览文本上限(2 KiB **字符**预算):超出截断并追加省略号。
|
|
99
|
+
*
|
|
100
|
+
* 顺序恒为「先 redact 再截断」:反过来会把跨边界的敏感值切成半个形状,让脱敏匹配不到它
|
|
101
|
+
* (等于把秘密截一半发出去)。redact 之后的剩余文本已不含秘密形状,截断才安全。
|
|
102
|
+
*/
|
|
103
|
+
const MERGE_PREVIEW_TEXT_LIMIT = 2048;
|
|
104
|
+
|
|
105
|
+
/** 严格 UTF-8 解码器:非法字节序列直接抛错(用于区分文本与二进制文件冲突)。 */
|
|
106
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
107
|
+
|
|
108
|
+
/** 把任意冲突侧值转成**可读字符串**;二进制只回传形状与字节数,绝不回传原始值对象。 */
|
|
109
|
+
function previewTextOf(value: unknown): string {
|
|
110
|
+
if (value === undefined) return '';
|
|
111
|
+
if (typeof value === 'string') return finalizePreviewText(value);
|
|
112
|
+
if (value instanceof Uint8Array) {
|
|
113
|
+
try {
|
|
114
|
+
return finalizePreviewText(STRICT_UTF8.decode(value));
|
|
115
|
+
} catch {
|
|
116
|
+
// 二进制内容解码只会得到乱码(同样误导用户)→ 只回传形状;JSON 是数据形态,不是文案
|
|
117
|
+
return JSON.stringify({ binary: true, bytes: value.length });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return finalizePreviewText(JSON.stringify(value) ?? '');
|
|
122
|
+
} catch {
|
|
123
|
+
return finalizePreviewText(String(value));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 预览文本统一出口:先 redact(敏感值绝不回传浏览器),再截断到 2 KiB。 */
|
|
128
|
+
function finalizePreviewText(text: string): string {
|
|
129
|
+
const masked = redact(text);
|
|
130
|
+
return masked.length > MERGE_PREVIEW_TEXT_LIMIT ? `${masked.slice(0, MERGE_PREVIEW_TEXT_LIMIT)}…` : masked;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** plan 的单个分区结果 → 预览视图(三侧都只回传可读文本,绝不回传原始值)。 */
|
|
134
|
+
function mergePreviewSectionView(result: MergeSectionResult): MergePreviewSectionView {
|
|
135
|
+
const conflicts: MergePreviewConflictView[] = result.conflicts.map((c) => ({
|
|
136
|
+
path: c.path,
|
|
137
|
+
kind: c.kind,
|
|
138
|
+
...(c.reason !== undefined ? { reason: c.reason } : {}),
|
|
139
|
+
localPreview: previewTextOf(c.local),
|
|
140
|
+
remotePreview: previewTextOf(c.remote),
|
|
141
|
+
ancestorPreview: previewTextOf(c.ancestor),
|
|
142
|
+
}));
|
|
143
|
+
return {
|
|
144
|
+
id: result.id,
|
|
145
|
+
decision: result.decision,
|
|
146
|
+
...(result.autoMerged !== undefined ? { autoMerged: result.autoMerged } : {}),
|
|
147
|
+
conflictCount: result.conflicts.length,
|
|
148
|
+
conflicts,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
67
152
|
export interface SyncEngineOptions {
|
|
68
153
|
ctx: HostContext;
|
|
69
154
|
transport: SyncTransport;
|
|
@@ -153,6 +238,14 @@ export interface SyncPushOptions {
|
|
|
153
238
|
* includeSecrets=true 必须同时 encrypt=true,否则拒绝(密钥绝不明文进入同步通道);
|
|
154
239
|
* 自动同步恒 includeSecrets=false(推普通快照)。 */
|
|
155
240
|
includeSecrets?: boolean;
|
|
241
|
+
/**
|
|
242
|
+
* 强制推送(缺省 false):跳过「远端已被其它设备推进」的前置检测。
|
|
243
|
+
*
|
|
244
|
+
* 为什么默认要拦:本机基线 X、别的设备推了 Y、本机再推 Z(不含 Y 的改动)时,
|
|
245
|
+
* 远端保留策略随后会淘汰 Y ⇒ **Y 的改动在远端与本机都消失**(静默丢数据)。
|
|
246
|
+
* 只有用户明确知道「要用本机覆盖远端」时才传 true。
|
|
247
|
+
*/
|
|
248
|
+
force?: boolean;
|
|
156
249
|
}
|
|
157
250
|
|
|
158
251
|
export interface SyncPullOptions {
|
|
@@ -178,6 +271,12 @@ export interface SyncPushReport {
|
|
|
178
271
|
/** 分区级告警(单项失败不拖垮整体,§34.17 语义) */
|
|
179
272
|
warnings: string[];
|
|
180
273
|
message?: string;
|
|
274
|
+
/**
|
|
275
|
+
* 本次推送**被前置检测拒绝**(远端已被其它设备推进):未写远端、未记基线、未改 sync-state。
|
|
276
|
+
* 这是「需要先合并远端」的冲突态,不是通道/凭据故障 —— 调用方(自动同步 / 路由)应按冲突处理,
|
|
277
|
+
* 而不是记成「同步失败」。
|
|
278
|
+
*/
|
|
279
|
+
conflict?: boolean;
|
|
181
280
|
}
|
|
182
281
|
|
|
183
282
|
/** 差异报告中的单个变更项(PlanItem 摘要,不含敏感细节) */
|
|
@@ -360,6 +459,58 @@ function previewCaliberDiffersFromExport(sectionId: SectionId, data: SectionData
|
|
|
360
459
|
return Array.isArray(plugins) && plugins.some((p) => isLocalPluginSpec(p?.spec));
|
|
361
460
|
}
|
|
362
461
|
|
|
462
|
+
/** previewMerge 的单条冲突视图(三侧文本都已截断 + redact;绝不回传原始值对象)。 */
|
|
463
|
+
export interface MergePreviewConflictView {
|
|
464
|
+
/** 冲突位置:JSON 分区是 top-level key;文件类分区是 relativePath;'$' = 整分区冲突。 */
|
|
465
|
+
path: string;
|
|
466
|
+
kind: 'key' | 'file';
|
|
467
|
+
/** 冲突成因(可选:旧数据/旧 merge 结果没有该字段,界面缺省按 'both-modified' 措辞)。 */
|
|
468
|
+
reason?: MergeConflictReason;
|
|
469
|
+
/** 本机侧的可读预览(≤ 2 KiB,超出截断加省略号;已过 redact())。 */
|
|
470
|
+
localPreview: string;
|
|
471
|
+
/** 远端侧的可读预览(同上)。 */
|
|
472
|
+
remotePreview: string;
|
|
473
|
+
/** 共同祖先侧的可读预览(同上;无祖先时为空串)。 */
|
|
474
|
+
ancestorPreview: string;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** previewMerge 的单个分区视图。 */
|
|
478
|
+
export interface MergePreviewSectionView {
|
|
479
|
+
id: SectionId;
|
|
480
|
+
decision: MergeSectionResult['decision'];
|
|
481
|
+
/** 自动合并并入的变更点数量(仅 decision === 'merge' 时有意义;缺省 = 未统计)。 */
|
|
482
|
+
autoMerged?: number;
|
|
483
|
+
conflictCount: number;
|
|
484
|
+
conflicts: MergePreviewConflictView[];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** previewMerge 结果(只读、零写入)。 */
|
|
488
|
+
export interface MergePreview {
|
|
489
|
+
/** 预览是否成功产出(远端无快照 → false + message,分区清单为空)。 */
|
|
490
|
+
ok: boolean;
|
|
491
|
+
/** 本次预览针对的**远端**快照 id;'' = 远端无快照。 */
|
|
492
|
+
snapshotId: string;
|
|
493
|
+
/** 是否拿到了本地祖先副本(false = 退化为两方合并 → 所有双侧差异都会呈现为 conflict)。 */
|
|
494
|
+
hasAncestor: boolean;
|
|
495
|
+
sections: MergePreviewSectionView[];
|
|
496
|
+
/** 与 risk.ts 的 classifyMergePlan 同口径计数:
|
|
497
|
+
* auto = 可自动应用的非冲突项,review = 需人工,conflicts = decision === 'conflict',skipped = 'skip'。 */
|
|
498
|
+
summary: { auto: number; review: number; conflicts: number; skipped: number };
|
|
499
|
+
message?: string;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 用户对**单个分区**的裁决(分区粒度:没有冲突项粒度 / 复合 id)。
|
|
504
|
+
* - useRemote:用远端整分区原值(对键级冲突而言 = 放弃本机该分区里除冲突键以外的改动);远端没有该分区 → 无法表达删除,计入 pendingConflicts;
|
|
505
|
+
* - keepLocal :保持本机整分区原值(本机没有该分区 = 无写入,直接跳过);
|
|
506
|
+
* - useMerged :取 merge() 的 merged 结果 —— 仅 decision === 'merge' 的分区有效;'conflict' 分区没有 merged,按冲突处理并计入 pendingConflicts;
|
|
507
|
+
* - skip :本轮不裁决该分区 → 计入 pendingConflicts,基线不推进(冲突下一轮重新出现)。
|
|
508
|
+
*/
|
|
509
|
+
export type MergeResolution = { id: SectionId; action: 'keepLocal' | 'useRemote' | 'useMerged' | 'skip' };
|
|
510
|
+
|
|
511
|
+
/** applyMergeResolutions 报告:ApplyReport + 仍未裁决的冲突分区(非空 = 基线未推进)。 */
|
|
512
|
+
export type ApplyMergeResolutionsReport = ApplyReport & { pendingConflicts: SectionId[] };
|
|
513
|
+
|
|
363
514
|
export class SyncEngine {
|
|
364
515
|
private readonly ctx: HostContext;
|
|
365
516
|
private readonly transport: SyncTransport;
|
|
@@ -393,8 +544,29 @@ export class SyncEngine {
|
|
|
393
544
|
* 「源机 home → 本机 home」的重定基规则;'' = 旧快照/未知(不猜,行为不变)。
|
|
394
545
|
*/
|
|
395
546
|
private lastMergedRemoteSourceHome = '';
|
|
547
|
+
/**
|
|
548
|
+
* 最近一次 merge() 命中的远端快照是否加密(issue #107):
|
|
549
|
+
* applyMergeResolutions → applyMergePlan 写基线时据此决定祖先副本是否也落**密文**。
|
|
550
|
+
* false = 明文通道/未知(保持既有明文副本,行为逐字不变)。
|
|
551
|
+
*/
|
|
552
|
+
private lastMergedRemoteEncrypted = false;
|
|
396
553
|
/** 最近一次 merge() 收到的用户路径映射(applyMergePlan 复用;缺省 []) */
|
|
397
554
|
private lastMergedPathMappings: PathMapping[] = [];
|
|
555
|
+
/**
|
|
556
|
+
* 最近一次 merge() 是否拿到了本地祖先副本(false = 两方合并)。
|
|
557
|
+
* previewMerge 用它如实告诉界面「为什么全都是冲突」,绝不让两方差异看起来像三方结论。
|
|
558
|
+
*/
|
|
559
|
+
private lastMergeHadAncestor = false;
|
|
560
|
+
/**
|
|
561
|
+
* 最近一次 merge() 的三方输入(仅内存)。
|
|
562
|
+
* applyMergeResolutions 需要它还原「整分区」语义:merge() 的 MergePlan 只携带冲突处的
|
|
563
|
+
* 原始值(键级冲突只给键值),而 'useRemote' / 'keepLocal' 必须落**整分区**数据才能进导入计划。
|
|
564
|
+
*/
|
|
565
|
+
private lastMergeInputs: {
|
|
566
|
+
local: Partial<Record<SectionId, SectionData>>;
|
|
567
|
+
remote: Partial<Record<SectionId, SectionData>>;
|
|
568
|
+
ancestor: Partial<Record<SectionId, SectionData>>;
|
|
569
|
+
} | null = null;
|
|
398
570
|
|
|
399
571
|
constructor(opts: SyncEngineOptions) {
|
|
400
572
|
if (opts.ctx === null || typeof opts.ctx !== 'object') throw new Error(zhMsg('sync.missingCtx'));
|
|
@@ -679,6 +851,9 @@ export class SyncEngine {
|
|
|
679
851
|
* opts.sections:指定仅同步这些分区(高级/自定义模式);缺省 = 全部 portable 推荐分区。
|
|
680
852
|
* opts.encrypt/password/includeSecrets:加密快照(含可选密钥导出)。
|
|
681
853
|
* 安全不变量:includeSecrets ⇒ encrypt(密钥绝不明文进入同步通道);密码仅内存。
|
|
854
|
+
*
|
|
855
|
+
* 前置:**远端已被其它设备推进时默认拒绝写入**(见 assertRemoteNotAdvanced)—— 这一条在导出
|
|
856
|
+
* 之前执行,因为「本机这一版要不要推」是被远端状态决定的,先别做无谓的导出(plugins 还会 npm pack)。
|
|
682
857
|
*/
|
|
683
858
|
async push(opts: SyncPushOptions = {}): Promise<SyncPushReport> {
|
|
684
859
|
const warnings: string[] = [];
|
|
@@ -691,6 +866,24 @@ export class SyncEngine {
|
|
|
691
866
|
if (encrypt && (opts.password === undefined || opts.password === '')) {
|
|
692
867
|
throw new Error(this.msg('sync.encryptRequiresPassword'));
|
|
693
868
|
}
|
|
869
|
+
// 远端推进检测:不一致 = 远端被别的设备推过(或本机基线已被裁剪)。
|
|
870
|
+
// 此时推上去会覆盖/架空别人的改动 → 直接返回冲突(**不抛错**、不 upload、不记基线、不改 sync-state)。
|
|
871
|
+
if (opts.force !== true) {
|
|
872
|
+
const check = await this.remoteAdvancedCheck();
|
|
873
|
+
if (check.reason !== undefined) {
|
|
874
|
+
// 读不到远端列表 ⇒ 无法判定。按原样继续(上传本身会再暴露通道问题),但如实说明。
|
|
875
|
+
warnings.push(this.msg('sync.pushAdvanceCheckFailed', { reason: check.reason }));
|
|
876
|
+
} else if (check.blocked) {
|
|
877
|
+
return {
|
|
878
|
+
ok: false,
|
|
879
|
+
snapshotId: '',
|
|
880
|
+
sections: [],
|
|
881
|
+
warnings,
|
|
882
|
+
conflict: true,
|
|
883
|
+
message: this.msg('sync.pushRemoteAdvanced', { remote: check.latest, base: check.base }),
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
694
887
|
const plainSections: Partial<Record<SectionId, SectionData>> = {};
|
|
695
888
|
const targets = this.pushTargets(opts.sections, warnings);
|
|
696
889
|
for (const adapter of targets) {
|
|
@@ -736,6 +929,10 @@ export class SyncEngine {
|
|
|
736
929
|
warnings.push(this.msg('sync.sessionDeletionsRecorded', { count: String(bookkeeping.deletedNow.length) }));
|
|
737
930
|
}
|
|
738
931
|
|
|
932
|
+
// ⑥ 设备身份(cloud-sync 借鉴项 ⑥):首次生成后持久化在 sync-state.json(已有则纯读),
|
|
933
|
+
// 随 manifest 上行 → 别的设备在历史列表里能看到「这份快照是哪台机器推的」。
|
|
934
|
+
const device = await resolveDeviceIdentity(this.stateDir, os.hostname(), this.fsx, this.msg);
|
|
935
|
+
|
|
739
936
|
const nowIso = this.now().toISOString();
|
|
740
937
|
const id = opts.snapshotId ?? this.snapshotIdFn();
|
|
741
938
|
const snapshot: SyncSnapshot = {
|
|
@@ -754,6 +951,9 @@ export class SyncEngine {
|
|
|
754
951
|
...(this.ctx.homeDir !== '' ? { sourceHome: this.ctx.homeDir } : {}),
|
|
755
952
|
// P1-5:累积墓碑随每份快照传播(拉取侧据此不让旧快照复活已删除的对话)
|
|
756
953
|
...(bookkeeping.tombstones.length > 0 ? { deletedSessions: bookkeeping.tombstones } : {}),
|
|
954
|
+
// ⑥:产生该快照的设备身份(历史列表展示「哪台机器推的」;旧读侧忽略未知字段)
|
|
955
|
+
deviceId: device.id,
|
|
956
|
+
deviceName: device.name,
|
|
757
957
|
...(encrypted ? { encrypted: true } : {}),
|
|
758
958
|
},
|
|
759
959
|
sections,
|
|
@@ -774,7 +974,10 @@ export class SyncEngine {
|
|
|
774
974
|
// ③ 记录祖先基线:写 sync-state(lastSnapshotId + 每分区 hash/updatedAt + lastSyncAt + transport)+ 裁剪。
|
|
775
975
|
// 基线 hash 用明文(hasLocalChanges 与本地明文可比);加密快照不写明文祖先副本(密钥不落盘)。
|
|
776
976
|
await this.recordBaseline(id, plainSections, nowIso, {
|
|
777
|
-
|
|
977
|
+
// issue #107:加密快照也落**密文**祖先副本(密码只在本进程内存里用;磁盘上只有密文)。
|
|
978
|
+
// 不落任何副本的旧行为会让每轮合并都缺共同祖先 → 三方退化成两方、整分区冲突,
|
|
979
|
+
// 用户被迫反复人工裁决。明文通道不受影响(缺省走 writeAncestor 的明文散文件布局)。
|
|
980
|
+
...(encrypted ? { ancestorPassword: opts.password! } : {}),
|
|
778
981
|
// 只有本轮真的跟踪了 sessions 才覆盖这两个字段(见 computeSessionBookkeeping)
|
|
779
982
|
...(bookkeeping.tracked
|
|
780
983
|
? { sessionUnits: bookkeeping.sessionUnits, deletedSessions: bookkeeping.tombstones }
|
|
@@ -998,6 +1201,31 @@ export class SyncEngine {
|
|
|
998
1201
|
}
|
|
999
1202
|
}
|
|
1000
1203
|
|
|
1204
|
+
/**
|
|
1205
|
+
* push 前置:远端是否已被别的设备推进(防「推上去把别人的改动架空」)。
|
|
1206
|
+
*
|
|
1207
|
+
* 判据与 hasNewRemoteSnapshot() 同源但方向相反:
|
|
1208
|
+
* - 首次同步(lastSnapshotId === '')→ **不拦**(本机没有基线可丢,第一次推送就是建立基线);
|
|
1209
|
+
* - 远端为空 → 不拦(没有可比对的快照);
|
|
1210
|
+
* - 其余:远端最新快照 id !== lastSnapshotId ⇒ 远端被推进(或本机基线已被裁剪)→ 拦。
|
|
1211
|
+
* 两种情况都**无法安全断言「远端没变」**,保守拒绝并给出 force 出口。
|
|
1212
|
+
* - list() 抛错 → 返回 reason(调用方按原样继续并告警),绝不把「读不到」当成「没变」。
|
|
1213
|
+
*/
|
|
1214
|
+
private async remoteAdvancedCheck(): Promise<{ blocked: boolean; base: string; latest: string; reason?: string }> {
|
|
1215
|
+
const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
|
|
1216
|
+
const base = state.lastSnapshotId;
|
|
1217
|
+
if (base === '') return { blocked: false, base, latest: '' };
|
|
1218
|
+
let metas: SyncSnapshotMeta[];
|
|
1219
|
+
try {
|
|
1220
|
+
metas = await this.transport.list();
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
return { blocked: false, base, latest: '', reason: err instanceof Error ? err.message : String(err) };
|
|
1223
|
+
}
|
|
1224
|
+
if (metas.length === 0) return { blocked: false, base, latest: '' };
|
|
1225
|
+
const latest = metas[metas.length - 1]!.id; // list 按 createdAt 升序 → 最新在末
|
|
1226
|
+
return { blocked: latest !== base, base, latest };
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1001
1229
|
/**
|
|
1002
1230
|
* pull:拉取远端最新(或指定)快照 → 过滤 portable 分区 → 转临时标准 ZIP →
|
|
1003
1231
|
* 复用 Importer 预览流程(analyzeImport/createImportPlan)产出差异报告。
|
|
@@ -1138,9 +1366,13 @@ export class SyncEngine {
|
|
|
1138
1366
|
*
|
|
1139
1367
|
* 前提:lastSnapshotId 必须是**远端**快照 id(P0-7)。旧实现把本地生成的快照 id 也写进去,
|
|
1140
1368
|
* 于是合并/应用之后这里恒为 true → 每轮白跑 list + download + merge。
|
|
1369
|
+
*
|
|
1370
|
+
* `listed` 可选:调用方本轮已经 list 过(如自动同步先查加密快照)时直接传进来,
|
|
1371
|
+
* 避免同一个回合对远端做两次 list —— 每条通道的 list 都是一次真实网络往返
|
|
1372
|
+
* (git 通道 = `pull --ff-only`,webdav 通道 = PROPFIND/GET)。
|
|
1141
1373
|
*/
|
|
1142
|
-
async hasNewRemoteSnapshot(): Promise<boolean> {
|
|
1143
|
-
const metas = await this.transport.list();
|
|
1374
|
+
async hasNewRemoteSnapshot(listed?: readonly SyncSnapshotMeta[]): Promise<boolean> {
|
|
1375
|
+
const metas = listed ?? await this.transport.list();
|
|
1144
1376
|
if (metas.length === 0) return false;
|
|
1145
1377
|
const latestId = metas[metas.length - 1]!.id; // list 按 createdAt 升序 → 最新在末
|
|
1146
1378
|
const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
|
|
@@ -1148,6 +1380,21 @@ export class SyncEngine {
|
|
|
1148
1380
|
return latestId !== state.lastSnapshotId;
|
|
1149
1381
|
}
|
|
1150
1382
|
|
|
1383
|
+
/**
|
|
1384
|
+
* 连通性探针(cloud-sync 借鉴项 ②):把当前通道的**只读**自检透出给宿主路由。
|
|
1385
|
+
*
|
|
1386
|
+
* 通道没实现 probe(如 git 通道按 fast-forward 语义自证)时**如实报不支持**,
|
|
1387
|
+
* 绝不返回「连接正常」—— 谎报成功比不支持更糟。
|
|
1388
|
+
*/
|
|
1389
|
+
async probe(): Promise<SyncProbeResult> {
|
|
1390
|
+
const probe = this.transport.probe;
|
|
1391
|
+
if (typeof probe !== 'function') {
|
|
1392
|
+
// 带稳定错误码:路由/UI 据此把它当**能力边界**(中性提示)而不是「操作失败」
|
|
1393
|
+
throw new SyncTransportError(this.msg('sync.probeUnsupported'), { kind: 'client', retryable: false, code: PROBE_UNSUPPORTED_CODE });
|
|
1394
|
+
}
|
|
1395
|
+
return await probe.call(this.transport);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1151
1398
|
/**
|
|
1152
1399
|
* 本地 portable 配置当前内容与上次基线(sync-state.sections hash)相比是否有变化(§3.1 上传「看变化」)。
|
|
1153
1400
|
* - 从未同步(sync-state.sections 为空)→ true;
|
|
@@ -1216,7 +1463,8 @@ export class SyncEngine {
|
|
|
1216
1463
|
/**
|
|
1217
1464
|
* 三方合并(不写本地配置、不执行导入):local = 现场 export,remote = 下载的远端快照,
|
|
1218
1465
|
* ancestor = 本地祖先副本;副本缺失 → 退化为两方合并(整分区 conflict 交用户裁决)。
|
|
1219
|
-
*
|
|
1466
|
+
* 副作用只有进程内备忘(远端快照 id / sourceHome / 用户映射 / 祖先可用性 / plan / 三方输入),
|
|
1467
|
+
* 供紧随其后的 previewMerge / applyMergePlan / applyMergeResolutions 复用:同一次调用只下载一次远端。
|
|
1220
1468
|
*/
|
|
1221
1469
|
async merge(opts: { snapshotId?: string; password?: string; pathMappings?: PathMapping[] } = {}): Promise<MergePlan> {
|
|
1222
1470
|
const metas = await this.transport.list();
|
|
@@ -1224,12 +1472,18 @@ export class SyncEngine {
|
|
|
1224
1472
|
this.lastMergedRemoteSnapshotId = null;
|
|
1225
1473
|
this.lastMergedRemoteSourceHome = '';
|
|
1226
1474
|
this.lastMergedPathMappings = [];
|
|
1475
|
+
// 远端为空:清掉全部缓存(绝不把上一轮的输入留在内存里被后续调用误用)
|
|
1476
|
+
this.lastMergeHadAncestor = false;
|
|
1477
|
+
this.lastMergeInputs = null;
|
|
1478
|
+
this.lastMergedRemoteEncrypted = false;
|
|
1227
1479
|
return { sections: [] };
|
|
1228
1480
|
}
|
|
1229
1481
|
const targetId = opts.snapshotId ?? metas[metas.length - 1]!.id;
|
|
1230
1482
|
const remote = await this.transport.download(targetId);
|
|
1231
1483
|
// 加密快照 → 用密码解密;普通快照 containsSecrets=true → 拒绝(防御)
|
|
1232
1484
|
await this.prepareSnapshot(remote, opts.password);
|
|
1485
|
+
// issue #107:记下远端快照是否加密 —— applyMergePlan 据此决定祖先副本也落密文
|
|
1486
|
+
this.lastMergedRemoteEncrypted = remote.manifest.encrypted === true;
|
|
1233
1487
|
// P1-5:按远端墓碑剔除已删除的会话(三方合并的 remote 侧同样不许复活)
|
|
1234
1488
|
this.stripTombstonedSessions(remote);
|
|
1235
1489
|
// 本次合并的远端基准(P0-7):紧随其后的 applyMergePlan() 用它写基线远端指针
|
|
@@ -1240,7 +1494,7 @@ export class SyncEngine {
|
|
|
1240
1494
|
this.lastMergedPathMappings = opts.pathMappings ?? [];
|
|
1241
1495
|
// 共同祖先用本地祖先**副本目录名** = state.ancestorId('' = 无本地祖先副本)。
|
|
1242
1496
|
// 不能用 state.lastSnapshotId —— 那是远端快照 id;混用会让 merge 去 load 一个并不存在的目录
|
|
1243
|
-
//
|
|
1497
|
+
// → 抛「快照目录缺少 manifest.json」→ 整轮自动同步 failed 且每轮复现。
|
|
1244
1498
|
const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
|
|
1245
1499
|
const ancestorId = state.ancestorId ?? '';
|
|
1246
1500
|
let ancestor: SyncSnapshot | undefined;
|
|
@@ -1248,6 +1502,21 @@ export class SyncEngine {
|
|
|
1248
1502
|
// 副本缺失/被裁剪/损坏 → undefined(两方合并),绝不上抛成整轮失败(P0-7)
|
|
1249
1503
|
ancestor = await tryLoadAncestor(this.localSnapshotsDir, ancestorId, this.fsx);
|
|
1250
1504
|
}
|
|
1505
|
+
// issue #107:加密祖先副本(manifest.encrypted + 单密文载荷)→ 用本次合并的密码解密回明文。
|
|
1506
|
+
// 密码缺失 / 与副本不同 / 副本损坏,一律按「无祖先」降级为两方合并 —— 祖先只是本地缓存,
|
|
1507
|
+
// 它的形态问题绝不能让整轮合并失败(与 tryLoadAncestor 的 P0-7 契约同一精神)。
|
|
1508
|
+
if (ancestor !== undefined && ancestor.manifest.encrypted) {
|
|
1509
|
+
const ancestorPassword = opts.password ?? '';
|
|
1510
|
+
if (ancestorPassword !== '' && isEncryptedSections(ancestor.sections)) {
|
|
1511
|
+
try {
|
|
1512
|
+
ancestor.sections = await decryptSectionsPayload(ancestor.sections.encrypted, ancestorPassword);
|
|
1513
|
+
} catch {
|
|
1514
|
+
ancestor = undefined;
|
|
1515
|
+
}
|
|
1516
|
+
} else {
|
|
1517
|
+
ancestor = undefined;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1251
1520
|
// 本地当前:现场 export(含 secret 剥离),与 push 同口径
|
|
1252
1521
|
const localSections: Partial<Record<SectionId, SectionData>> = {};
|
|
1253
1522
|
const localIds = this.pullSectionIds();
|
|
@@ -1272,7 +1541,230 @@ export class SyncEngine {
|
|
|
1272
1541
|
if (portableIds.has(id as SectionId)) ancestorPortable[id as SectionId] = data as SectionData;
|
|
1273
1542
|
}
|
|
1274
1543
|
}
|
|
1275
|
-
|
|
1544
|
+
const plan = mergeSections(localSections, remotePortable, ancestorPortable);
|
|
1545
|
+
// 结果**经返回值**交给调用方(previewMerge / applyMergeResolutions 都用这一份,不会二次下载);
|
|
1546
|
+
// 这里只留两个进程内备忘:祖先可用性(预览要如实说明「为什么全是冲突」)与三方输入
|
|
1547
|
+
// (applyMergeResolutions 要把分区级裁决落回**整分区**数据)。
|
|
1548
|
+
this.lastMergeHadAncestor = ancestor !== undefined;
|
|
1549
|
+
this.lastMergeInputs = { local: localSections, remote: remotePortable, ancestor: ancestorPortable };
|
|
1550
|
+
return plan;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* 合并预览(只读、零写入):把 merge() 的三方结果翻成界面可渲染的结构化视图。
|
|
1555
|
+
*
|
|
1556
|
+
* 与 merge() 的关系:**复用**同一次调用(同一实例上只下载一次远端快照、只 export 一次本地),
|
|
1557
|
+
* 不改远端、不写 sync-state、不落祖先副本、不动本机配置。
|
|
1558
|
+
*
|
|
1559
|
+
* 安全:每侧预览文本都先过 redact() 再截断到 2 KiB(敏感值绝不回传浏览器);原始值对象
|
|
1560
|
+
* (对象 / 数组 / 字节数组)**绝不**上抛给调用方。
|
|
1561
|
+
*/
|
|
1562
|
+
async previewMerge(opts: { snapshotId?: string; password?: string; pathMappings?: PathMapping[] } = {}): Promise<MergePreview> {
|
|
1563
|
+
const plan = await this.merge(opts);
|
|
1564
|
+
const summary = { auto: 0, review: 0, conflicts: 0, skipped: 0 };
|
|
1565
|
+
const snapshotId = this.lastMergedRemoteSnapshotId;
|
|
1566
|
+
if (snapshotId === null) {
|
|
1567
|
+
// merge() 已清掉全部缓存;这里如实报「远端无快照」(ok:false,而不是一个空成功)
|
|
1568
|
+
return { ok: false, snapshotId: '', hasAncestor: false, sections: [], summary, message: this.msg('sync.remoteEmpty') };
|
|
1569
|
+
}
|
|
1570
|
+
const split = classifyMergePlan(plan, { firstSync: false });
|
|
1571
|
+
summary.auto = split.autoApply.length;
|
|
1572
|
+
summary.review = split.review.length;
|
|
1573
|
+
summary.skipped = split.skipped.length;
|
|
1574
|
+
summary.conflicts = plan.sections.filter((s) => s.decision === 'conflict').length;
|
|
1575
|
+
return {
|
|
1576
|
+
ok: true,
|
|
1577
|
+
snapshotId,
|
|
1578
|
+
hasAncestor: this.lastMergeHadAncestor,
|
|
1579
|
+
sections: plan.sections.map(mergePreviewSectionView),
|
|
1580
|
+
summary,
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* 按用户裁决应用合并结果(**分区粒度**),并严守基线纪律。
|
|
1586
|
+
*
|
|
1587
|
+
* 与 applyMergePlan 的关系:把 resolutions 映射成 SyncApplyPlan 后走**同一条执行路径**
|
|
1588
|
+
* (applyMergePlan:兜底快照 + 失败整体回滚 + 成功才记基线)。本方法只额外负责两件事:
|
|
1589
|
+
* 1. 把 merge() 的分区级结论翻成「远端整分区 / 本机整分区 / 自动合并结果」的实际数据
|
|
1590
|
+
* (键级冲突的 conflicts 只带键值,进不了导入计划,必须回到整分区语义);
|
|
1591
|
+
* 2. 基线纪律 —— 只要还有未裁决分区,就**不推进远端基线指针**并记 pendingMerge
|
|
1592
|
+
* (冲突下一轮重新出现);全部裁决完才清空 pendingMerge 并推进基线。
|
|
1593
|
+
*
|
|
1594
|
+
* 裁决语义(与 MergeResolution 的文档一致):
|
|
1595
|
+
* - 未点名的分区按 merge() 的既有结论处理:可决断(useRemote / keepLocal / merge)→ 应用;
|
|
1596
|
+
* 'skip' → 跳过;'conflict' → 计入 pendingConflicts;
|
|
1597
|
+
* - action='skip'(点名了但不裁决):该分区若有内容可裁决 → 计入 pendingConflicts(**不算解决**,
|
|
1598
|
+
* 否则基线推进会让它下一轮不再被提出 = 静默丢改动);本来就无变化的 'skip' 分区 → 跳过;
|
|
1599
|
+
* - useMerged 而 merge() 没给 merged(典型:'conflict' 分区)→ 计入 pendingConflicts;
|
|
1600
|
+
* - useRemote 而远端没有该分区(= 远端删了它)→ 导入计划表达不了删除 → 计入 pendingConflicts;
|
|
1601
|
+
* - keepLocal 而本机没有该分区 → 保持现状(无写入)→ 跳过。
|
|
1602
|
+
*/
|
|
1603
|
+
async applyMergeResolutions(
|
|
1604
|
+
resolutions: MergeResolution[],
|
|
1605
|
+
opts: {
|
|
1606
|
+
snapshotId?: string;
|
|
1607
|
+
password?: string;
|
|
1608
|
+
pathMappings?: PathMapping[];
|
|
1609
|
+
snapshotBinding?: TransactionSnapshotContext;
|
|
1610
|
+
} = {},
|
|
1611
|
+
): Promise<ApplyMergeResolutionsReport> {
|
|
1612
|
+
if (!this.importer) {
|
|
1613
|
+
throw new Error(this.msg('sync.applyMergeResolutionsNoImporter'));
|
|
1614
|
+
}
|
|
1615
|
+
// 1) 先合并一次(一次下载):plan / 三方输入留在实例缓存里,供本次裁决取整分区值
|
|
1616
|
+
const plan = await this.merge({ snapshotId: opts.snapshotId, password: opts.password, pathMappings: opts.pathMappings });
|
|
1617
|
+
const remoteSnapshotId = this.lastMergedRemoteSnapshotId;
|
|
1618
|
+
if (remoteSnapshotId === null) {
|
|
1619
|
+
return { ok: true, applied: [], restoreId: '', rolledBack: false, review: [], warnings: [], pendingConflicts: [] };
|
|
1620
|
+
}
|
|
1621
|
+
const inputs = this.lastMergeInputs ?? { local: {}, remote: {}, ancestor: {} };
|
|
1622
|
+
const byId = new Map<SectionId, MergeResolution>();
|
|
1623
|
+
for (const r of resolutions) byId.set(r.id, r);
|
|
1624
|
+
|
|
1625
|
+
const autoApply: MergeSectionResult[] = [];
|
|
1626
|
+
const skipped: MergeSectionResult[] = [];
|
|
1627
|
+
const pendingConflicts: SectionId[] = [];
|
|
1628
|
+
for (const section of plan.sections) {
|
|
1629
|
+
const res = byId.get(section.id);
|
|
1630
|
+
if (res === undefined) {
|
|
1631
|
+
if (section.decision === 'skip') { skipped.push(section); continue; }
|
|
1632
|
+
if (section.decision === 'conflict' || section.merged === undefined) { pendingConflicts.push(section.id); continue; }
|
|
1633
|
+
autoApply.push(section);
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
switch (res.action) {
|
|
1637
|
+
case 'skip': {
|
|
1638
|
+
if (section.decision === 'skip') skipped.push(section);
|
|
1639
|
+
else pendingConflicts.push(section.id);
|
|
1640
|
+
break;
|
|
1641
|
+
}
|
|
1642
|
+
case 'useMerged': {
|
|
1643
|
+
if (section.merged === undefined) { pendingConflicts.push(section.id); break; }
|
|
1644
|
+
autoApply.push({ ...section, decision: 'merge', merged: section.merged, conflicts: [] });
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
case 'useRemote': {
|
|
1648
|
+
const remoteData = inputs.remote[section.id];
|
|
1649
|
+
if (remoteData === undefined) { pendingConflicts.push(section.id); break; }
|
|
1650
|
+
autoApply.push({ id: section.id, decision: 'useRemote', merged: remoteData, conflicts: [] });
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
case 'keepLocal': {
|
|
1654
|
+
const localData = inputs.local[section.id];
|
|
1655
|
+
if (localData === undefined) { skipped.push(section); break; } // 本机本就没有 → 无写入
|
|
1656
|
+
autoApply.push({ id: section.id, decision: 'keepLocal', merged: localData, conflicts: [] });
|
|
1657
|
+
break;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const applyPlan: SyncApplyPlan = { autoApply, review: [], skipped };
|
|
1663
|
+
const hasPending = pendingConflicts.length > 0;
|
|
1664
|
+
const pendingMessage = this.msg('sync.mergePendingConflicts', {
|
|
1665
|
+
count: String(pendingConflicts.length),
|
|
1666
|
+
sections: pendingConflicts.join(', '),
|
|
1667
|
+
});
|
|
1668
|
+
const stateBefore = await loadSyncState(this.stateDir, this.fsx, this.msg);
|
|
1669
|
+
|
|
1670
|
+
// 2) 无物可应用:只落基线纪律的状态(待裁决记录 / 清空 + 按需推进远端指针),
|
|
1671
|
+
// 不构造临时 ZIP、不碰本机配置、不动祖先副本。
|
|
1672
|
+
if (autoApply.length === 0) {
|
|
1673
|
+
await this.writeMergeBaseline(
|
|
1674
|
+
hasPending
|
|
1675
|
+
? { pending: this.pendingMergeFor(stateBefore, remoteSnapshotId, pendingConflicts) }
|
|
1676
|
+
// 全部裁决完但无需写入任何分区(全部是本来就无变化的 'skip')→ 基线可以推进到这一版
|
|
1677
|
+
: { advanceRemoteId: remoteSnapshotId },
|
|
1678
|
+
);
|
|
1679
|
+
return {
|
|
1680
|
+
ok: true, applied: [], restoreId: '', rolledBack: false, review: [],
|
|
1681
|
+
warnings: hasPending ? [pendingMessage] : [],
|
|
1682
|
+
pendingConflicts,
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// 3) 有物可应用 → 复用 applyMergePlan 的既有执行路径(兜底快照 + 失败整体回滚 + 成功才记基线)
|
|
1687
|
+
const report = await this.applyMergePlan(applyPlan, {
|
|
1688
|
+
...(hasPending
|
|
1689
|
+
? {
|
|
1690
|
+
// 待裁决:**不推进**远端指针(首同步时如实留空),并把祖先副本保持为共同基线 ——
|
|
1691
|
+
// 已裁决分区下一轮 local === remote → skip,既不会被重判成冲突,也不会被远端覆盖。
|
|
1692
|
+
remoteSnapshotId: stateBefore.lastSnapshotId,
|
|
1693
|
+
preserveAncestor: true,
|
|
1694
|
+
pendingMerge: this.pendingMergeFor(stateBefore, remoteSnapshotId, pendingConflicts),
|
|
1695
|
+
}
|
|
1696
|
+
: { pendingMerge: null }),
|
|
1697
|
+
...(opts.snapshotBinding !== undefined ? { snapshotBinding: opts.snapshotBinding } : {}),
|
|
1698
|
+
...(opts.pathMappings !== undefined ? { pathMappings: opts.pathMappings } : {}),
|
|
1699
|
+
// issue #107:远端是加密快照且本次有密码 → 祖先副本也落密文(下轮三方合并可解密)
|
|
1700
|
+
...(this.lastMergedRemoteEncrypted && opts.password !== undefined && opts.password !== ''
|
|
1701
|
+
? { ancestorPassword: opts.password }
|
|
1702
|
+
: {}),
|
|
1703
|
+
});
|
|
1704
|
+
if (!report.ok) {
|
|
1705
|
+
// 失败已整体回滚:基线未动、本机配置没变 → 不写 pendingMerge(冲突下一轮照旧出现)
|
|
1706
|
+
return { ...report, pendingConflicts };
|
|
1707
|
+
}
|
|
1708
|
+
return {
|
|
1709
|
+
...report,
|
|
1710
|
+
warnings: hasPending ? [...report.warnings, pendingMessage] : report.warnings,
|
|
1711
|
+
pendingConflicts,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/** 组装 pendingMerge 记录:同一远端快照上的连续待裁决**保留首次时间**(界面据此讲「挂了多久」)。 */
|
|
1716
|
+
private pendingMergeFor(state: SyncState, remoteSnapshotId: string, sections: SectionId[]): PendingMerge {
|
|
1717
|
+
const since = state.pendingMerge !== undefined && state.pendingMerge.remoteSnapshotId === remoteSnapshotId
|
|
1718
|
+
? state.pendingMerge.since
|
|
1719
|
+
: this.now().toISOString();
|
|
1720
|
+
return { remoteSnapshotId, sections, since };
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* 基线纪律的**纯状态**写入(不写祖先副本、不碰远端):仅在本机没有任何配置写入时使用。
|
|
1725
|
+
*
|
|
1726
|
+
* - pending 提供 → 写 pendingMerge;undefined → 清除该字段(「全部裁决完」);
|
|
1727
|
+
* - advanceRemoteId 提供 → 把远端指针推进到该快照 id(缺省 = 保持不变)。
|
|
1728
|
+
*
|
|
1729
|
+
* 为什么不走 recordBaseline:那条路径会重建祖先副本并只带本次应用的分区 —— 在「没有应用任何分区」
|
|
1730
|
+
* 或「仍有未裁决冲突」时都会把共同基线改坏(下一轮所有分区都退化成「无祖先 → 冲突」)。
|
|
1731
|
+
*/
|
|
1732
|
+
private async writeMergeBaseline(opts: { pending?: PendingMerge; advanceRemoteId?: string }): Promise<void> {
|
|
1733
|
+
const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
|
|
1734
|
+
if (opts.pending === undefined) delete state.pendingMerge;
|
|
1735
|
+
else state.pendingMerge = opts.pending;
|
|
1736
|
+
if (opts.advanceRemoteId !== undefined) {
|
|
1737
|
+
state.lastSnapshotId = opts.advanceRemoteId;
|
|
1738
|
+
state.lastSyncAt = this.now().toISOString();
|
|
1739
|
+
}
|
|
1740
|
+
await saveSyncState(this.stateDir, state, this.fsx);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* 重新导出**已应用**的分区(F-2,2026-10):基线的唯一正确来源是「实际落地的本机内容」。
|
|
1745
|
+
*
|
|
1746
|
+
* 为什么不能用计划/远端的 merged 载荷:写回时 adapter 会自增本机侧字段(如
|
|
1747
|
+
* settings.namespaces[*].revision),而 merged 记录的是**远端快照里的值**;据此记基线会让
|
|
1748
|
+
* 下一轮 merge 同时判「本地改了」与「远端改了」→ both-modified 冲突 → 每轮重复冲突。
|
|
1749
|
+
*
|
|
1750
|
+
* 口径与 merge() 的本地侧导出一致:文件类分区直接取 data,其余过 SecretScanner 剥离。
|
|
1751
|
+
* 任一分区导出失败 → 返回 null(调用方**不推进基线**并明确告警):绝不写半个基线。
|
|
1752
|
+
*/
|
|
1753
|
+
private async exportAppliedSections(ids: readonly SectionId[]): Promise<Record<string, SectionData> | null> {
|
|
1754
|
+
const wanted = new Set<string>(ids);
|
|
1755
|
+
const out: Record<string, SectionData> = {};
|
|
1756
|
+
for (const adapter of this.adapters) {
|
|
1757
|
+
if (!wanted.has(adapter.id)) continue;
|
|
1758
|
+
try {
|
|
1759
|
+
const section = await adapter.export(this.ctx, { includeSecrets: false });
|
|
1760
|
+
out[adapter.id] = (isFileSection(adapter.id)
|
|
1761
|
+
? section.data
|
|
1762
|
+
: this.scanner.scanAndRedact(section.data).sanitized) as SectionData;
|
|
1763
|
+
} catch {
|
|
1764
|
+
return null;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
return out;
|
|
1276
1768
|
}
|
|
1277
1769
|
|
|
1278
1770
|
/**
|
|
@@ -1285,16 +1777,30 @@ export class SyncEngine {
|
|
|
1285
1777
|
* (push 路径两者天然相同);合并/应用路径**必须显式传**,否则 lastSnapshotId 会退化成「一个远端
|
|
1286
1778
|
* 并不存在的本地 id」→ hasNewRemoteSnapshot() 恒判「远端有新生」,每轮白跑 list + download + merge。
|
|
1287
1779
|
* 传入 `''` 表示「远端 id 未知」——宁可留空,也不要把本地 id 冒充成远端 id。
|
|
1288
|
-
* - `opts.writeAncestor === false
|
|
1780
|
+
* - `opts.writeAncestor === false`:不落本地祖先副本,`ancestorId` 一并置 '' ——
|
|
1289
1781
|
* 绝不把一个本地并不存在的目录名写进状态(旧实现正是这样让下一轮 merge 直接抛错)。
|
|
1782
|
+
* - `opts.ancestorPassword`(issue #107):祖先副本落**密文**(manifest.encrypted + 单密文载荷);
|
|
1783
|
+
* 加密快照 push 必须传,否则下轮三方合并缺共同祖先、整分区冲突。
|
|
1784
|
+
* - `opts.preserveAncestor === true`(合并仍有未裁决冲突):祖先副本与 ancestorId **保持不动** ——
|
|
1785
|
+
* 共同基线必须仍是「本机与远端都认可的上一版」;拿只带本次应用分区的副本去覆盖它,会让
|
|
1786
|
+
* 下一轮所有分区都退化成「无祖先 → 冲突」。
|
|
1787
|
+
* - `opts.pendingMerge`(基线纪律):提供即写入;`null` = 清除该字段;缺省 = 不动(既有行为不变)。
|
|
1290
1788
|
*/
|
|
1291
1789
|
async recordBaseline(
|
|
1292
1790
|
snapshotId: string,
|
|
1293
|
-
|
|
1791
|
+
// 基线**恒为明文**(加密快照的基线 hash 也用明文算,供 hasLocalChanges 比较);
|
|
1792
|
+
// 需要落盘副本时由 opts.ancestorPassword 决定写明文还是密文目录。
|
|
1793
|
+
sections: Partial<Record<SectionId, SectionData>>,
|
|
1294
1794
|
nowIso?: string,
|
|
1295
1795
|
opts: {
|
|
1296
1796
|
writeAncestor?: boolean;
|
|
1797
|
+
/** 提供即把祖先副本写成**密文**(密码仅本次内存使用;issue #107) */
|
|
1798
|
+
ancestorPassword?: string;
|
|
1297
1799
|
remoteSnapshotId?: string;
|
|
1800
|
+
/** 保留当前祖先副本与 ancestorId(缺省 false = 既有行为逐字不变) */
|
|
1801
|
+
preserveAncestor?: boolean;
|
|
1802
|
+
/** 待裁决冲突记录:null = 清除;缺省 = 不动该字段 */
|
|
1803
|
+
pendingMerge?: PendingMerge | null;
|
|
1298
1804
|
/** P1-5:本轮**实际带走**的会话单元(提供即覆盖;缺省 = 保留原记录) */
|
|
1299
1805
|
sessionUnits?: string[];
|
|
1300
1806
|
/** P1-5:本轮累积的删除墓碑(提供即覆盖;缺省 = 保留原记录) */
|
|
@@ -1302,33 +1808,41 @@ export class SyncEngine {
|
|
|
1302
1808
|
} = {},
|
|
1303
1809
|
): Promise<void> {
|
|
1304
1810
|
const ts = nowIso ?? this.now().toISOString();
|
|
1305
|
-
const
|
|
1811
|
+
const preserveAncestor = opts.preserveAncestor === true;
|
|
1812
|
+
const writesAncestorCopy = !preserveAncestor && this.localSnapshotsDir !== undefined && opts.writeAncestor !== false;
|
|
1306
1813
|
// 1) 写本地祖先副本(如有 localSnapshotsDir)。
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1814
|
+
// ancestorPassword 提供 → 落**密文**祖先副本(issue #107:加密通道也能做三方合并);
|
|
1815
|
+
// 否则落明文散文件布局(既有行为逐字不变)。
|
|
1309
1816
|
if (writesAncestorCopy) {
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
},
|
|
1322
|
-
sections,
|
|
1817
|
+
const ancestorPassword = opts.ancestorPassword;
|
|
1818
|
+
const encryptedAncestor = ancestorPassword !== undefined && ancestorPassword !== '';
|
|
1819
|
+
const manifest: SyncSnapshot['manifest'] = {
|
|
1820
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
1821
|
+
dshVersion: this.ctx.dshVersion,
|
|
1822
|
+
platform: this.ctx.platform as Platform,
|
|
1823
|
+
sectionIds: Object.keys(sections) as SectionId[],
|
|
1824
|
+
containsSecrets: false,
|
|
1825
|
+
// 记录触发通道(git/webdav),供同步历史展示「由哪个通道触发」
|
|
1826
|
+
transport: this.transport.type,
|
|
1827
|
+
...(encryptedAncestor ? { encrypted: true } : {}),
|
|
1323
1828
|
};
|
|
1324
|
-
|
|
1829
|
+
if (encryptedAncestor) {
|
|
1830
|
+
const encryptedSections = await encryptSectionsPayload(sections, ancestorPassword);
|
|
1831
|
+
await writeEncryptedAncestor(this.localSnapshotsDir, { id: snapshotId, createdAt: ts, manifest, sections: encryptedSections }, this.fsx);
|
|
1832
|
+
} else {
|
|
1833
|
+
await writeAncestor(this.localSnapshotsDir, { id: snapshotId, createdAt: ts, manifest, sections }, this.fsx);
|
|
1834
|
+
}
|
|
1325
1835
|
}
|
|
1326
1836
|
// 2) 更新 sync-state:远端指针 + 本地祖先目录名 + 每分区 hash/updatedAt + lastSyncAt + transport
|
|
1327
1837
|
const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
|
|
1328
1838
|
state.lastSnapshotId = opts.remoteSnapshotId ?? snapshotId;
|
|
1329
|
-
|
|
1839
|
+
// preserveAncestor:祖先目录名一并保持不动(既不是 snapshotId,也不是 '')
|
|
1840
|
+
if (!preserveAncestor) state.ancestorId = writesAncestorCopy ? snapshotId : '';
|
|
1330
1841
|
state.lastSyncAt = ts;
|
|
1331
1842
|
state.transport = { type: this.transport.type, ref: this.transportRef };
|
|
1843
|
+
// 基线纪律:null = 清除(全部裁决完);undefined = 不动该字段(既有调用方零感知)
|
|
1844
|
+
if (opts.pendingMerge === null) delete state.pendingMerge;
|
|
1845
|
+
else if (opts.pendingMerge !== undefined) state.pendingMerge = opts.pendingMerge;
|
|
1332
1846
|
// P1-5:只在调用方明确提供时覆盖(否则保留既有记录,绝不因某条路径不跟踪而清空)
|
|
1333
1847
|
if (opts.sessionUnits !== undefined) state.sessionUnits = [...opts.sessionUnits];
|
|
1334
1848
|
if (opts.deletedSessions !== undefined) state.deletedSessions = [...opts.deletedSessions];
|
|
@@ -1336,8 +1850,8 @@ export class SyncEngine {
|
|
|
1336
1850
|
state.sections[sid as SectionId] = { hash: hashSection(data as SectionData), updatedAt: ts };
|
|
1337
1851
|
}
|
|
1338
1852
|
await saveSyncState(this.stateDir, state, this.fsx);
|
|
1339
|
-
// 3) 裁剪祖先副本(最近 N
|
|
1340
|
-
if (this.localSnapshotsDir !== undefined) {
|
|
1853
|
+
// 3) 裁剪祖先副本(最近 N 个);preserveAncestor 时本轮没有新副本,不参与裁剪
|
|
1854
|
+
if (!preserveAncestor && this.localSnapshotsDir !== undefined) {
|
|
1341
1855
|
await pruneAncestors(this.localSnapshotsDir, DEFAULT_ANCESTOR_KEEP, this.fsx);
|
|
1342
1856
|
}
|
|
1343
1857
|
}
|
|
@@ -1366,6 +1880,21 @@ export class SyncEngine {
|
|
|
1366
1880
|
pathMappings?: PathMapping[];
|
|
1367
1881
|
/** 覆盖自动重定基的源 home(缺省 = merge() 记下的远端 sourceHome) */
|
|
1368
1882
|
sourceHome?: string;
|
|
1883
|
+
/**
|
|
1884
|
+
* 保留祖先副本与 ancestorId(缺省 false = 既有行为逐字不变)。
|
|
1885
|
+
* applyMergeResolutions 在**仍有未裁决冲突**时必须传 true:共同基线仍是「本机与远端都认可的上一版」。
|
|
1886
|
+
*/
|
|
1887
|
+
preserveAncestor?: boolean;
|
|
1888
|
+
/**
|
|
1889
|
+
* 待裁决冲突记录(基线纪律):null = 清除(全部裁决完);缺省 = 不动该字段。
|
|
1890
|
+
* 与 recordBaseline 同语义,随本次成功的基线写入**同一次落盘**(原子,不留中间态)。
|
|
1891
|
+
*/
|
|
1892
|
+
pendingMerge?: PendingMerge | null;
|
|
1893
|
+
/**
|
|
1894
|
+
* issue #107:本次基线对应的远端快照是加密快照时传解密密码 → 祖先副本也落**密文**。
|
|
1895
|
+
* 缺省 = 明文祖先副本(既有行为逐字不变)。密码仅本进程内存使用。
|
|
1896
|
+
*/
|
|
1897
|
+
ancestorPassword?: string;
|
|
1369
1898
|
} = {}): Promise<ApplyReport> {
|
|
1370
1899
|
if (!this.importer) {
|
|
1371
1900
|
throw new Error(this.msg('sync.applyMergePlanNoImporter'));
|
|
@@ -1454,13 +1983,28 @@ export class SyncEngine {
|
|
|
1454
1983
|
rollbackFull: rb.full,
|
|
1455
1984
|
};
|
|
1456
1985
|
}
|
|
1457
|
-
// 5) 全成功 → recordBaseline
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1986
|
+
// 5) 全成功 → recordBaseline(更新祖先基线)。
|
|
1987
|
+
// **基线必须以「实际落地的本地导出」为准(F-2,2026-10)**:写回时 adapter 会自增本机侧字段
|
|
1988
|
+
// (如 settings.namespaces[*].revision),而 merged 携带的是**远端快照里的值**;用它记基线
|
|
1989
|
+
// 会让下一轮 merge 判「本地也改了」+「远端也改了」→ both-modified 冲突 → 每轮重复冲突。
|
|
1990
|
+
const landed = await this.exportAppliedSections(appliedIds);
|
|
1991
|
+
if (landed === null) {
|
|
1992
|
+
// 重新导出失败 = 无法证明「基线 == 本机现状」→ **不推进基线**(远端指针保持原值,
|
|
1993
|
+
// 下一轮重新合并收敛)并明确告警;绝不写半个基线。
|
|
1994
|
+
return {
|
|
1995
|
+
ok: true,
|
|
1996
|
+
applied: appliedIds,
|
|
1997
|
+
restoreId: snapshot.id,
|
|
1998
|
+
rolledBack: false,
|
|
1999
|
+
review: [],
|
|
2000
|
+
warnings: [...(result.warnings ?? []), this.msg('sync.baselineRexportFailed', { sections: appliedIds.join(', ') })],
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
await this.recordBaseline(tempSnapshot.id, landed, undefined, {
|
|
1463
2004
|
remoteSnapshotId: opts.remoteSnapshotId ?? this.lastMergedRemoteSnapshotId ?? '',
|
|
2005
|
+
...(opts.preserveAncestor !== undefined ? { preserveAncestor: opts.preserveAncestor } : {}),
|
|
2006
|
+
...(opts.pendingMerge !== undefined ? { pendingMerge: opts.pendingMerge } : {}),
|
|
2007
|
+
...(opts.ancestorPassword !== undefined ? { ancestorPassword: opts.ancestorPassword } : {}),
|
|
1464
2008
|
});
|
|
1465
2009
|
return {
|
|
1466
2010
|
ok: true,
|
|
@@ -1568,27 +2112,28 @@ export class SyncEngine {
|
|
|
1568
2112
|
};
|
|
1569
2113
|
}
|
|
1570
2114
|
|
|
1571
|
-
// 成功:recordBaseline
|
|
2115
|
+
// 成功:recordBaseline 更新祖先基线。
|
|
2116
|
+
// **基线以「实际落地的本地导出」为准(F-2,2026-10;一键同步路径同样适用)**——
|
|
2117
|
+
// 用计划/远端载荷记基线会因为 adapter 自增 revision 等本机字段而在下一轮造成假冲突。
|
|
1572
2118
|
const appliedIds = [...new Set(subPlan.items.map((i) => i.adapter))];
|
|
1573
|
-
const
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
}
|
|
2119
|
+
const landed = await this.exportAppliedSections(appliedIds);
|
|
2120
|
+
if (landed === null) {
|
|
2121
|
+
// 无法证明「基线 == 本机现状」→ 不推进基线(下一轮重新合并收敛)+ 明确告警,绝不写半个基线
|
|
2122
|
+
return {
|
|
2123
|
+
ok: true,
|
|
2124
|
+
applied: appliedIds,
|
|
2125
|
+
skipped: subPlan.items.filter((i) => i.kind === 'Skip').map((i) => i.id),
|
|
2126
|
+
restoreId: snapshot.id,
|
|
2127
|
+
rolledBack: false,
|
|
2128
|
+
warnings: [...(result.warnings ?? []), this.msg('sync.baselineRexportFailed', { sections: appliedIds.join(', ') })],
|
|
2129
|
+
failed: [],
|
|
2130
|
+
ineffective: result.ineffective ?? [],
|
|
2131
|
+
needsRestart: result.needsRestart,
|
|
2132
|
+
result,
|
|
2133
|
+
};
|
|
1589
2134
|
}
|
|
1590
2135
|
const snapshotId = this.snapshotIdFn();
|
|
1591
|
-
await this.recordBaseline(snapshotId,
|
|
2136
|
+
await this.recordBaseline(snapshotId, landed, undefined, {
|
|
1592
2137
|
remoteSnapshotId: opts.remoteSnapshotId ?? '',
|
|
1593
2138
|
});
|
|
1594
2139
|
|