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.
Files changed (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
@@ -36,15 +36,94 @@ import { credentialsMapFromYaml, decryptCredentialsPayload, decryptSectionsPaylo
36
36
  import { OPT_IN_SYNC_SECTIONS } from './sync-selection.js';
37
37
  import { nextSessionTombstones, sessionUnitIdsOfSection, stripTombstonedUnits } from './session-tombstones.js';
38
38
  import { DEFAULT_RETENTION_POLICY, selectPruneCandidatesByPolicy } from './retention-policy.js';
39
- import { hashSection, loadSyncState, saveSyncState } from './sync-state.js';
40
- import { isEncryptedSections } from './transport.js';
39
+ import { hashSection, loadSyncState, resolveDeviceIdentity, saveSyncState } from './sync-state.js';
40
+ import { isEncryptedSections, PROBE_UNSUPPORTED_CODE, SyncTransportError } from './transport.js';
41
41
  import { msgOf, zhMsg } from '../core/messages.js';
42
42
  import { engineMsg } from './messages.js';
43
- import { DEFAULT_ANCESTOR_KEEP, pruneAncestors, tryLoadAncestor, writeAncestor } from './ancestor.js';
43
+ import { DEFAULT_ANCESTOR_KEEP, pruneAncestors, tryLoadAncestor, writeAncestor, writeEncryptedAncestor } from './ancestor.js';
44
44
  import { merge as mergeSections } from './merge.js';
45
+ import { classifyMergePlan } from './risk.js';
46
+ import { redact } from '../security/redaction.js';
47
+ import { registerMessageCatalog } from '../core/engine-locale.js';
45
48
  import { createSnapshot, selectPruneCandidates } from '../core/backup.js';
46
49
  import { rollback } from '../core/rollback.js';
47
50
  import { FileSnapshotStore } from '../core/backup.js';
51
+ /* ------------------------------------------- 合并预览:本域追加文案目录 + 可读预览工具 */
52
+ /**
53
+ * 本模块新增文案的追加目录(zh 源 / en 镜像;与 adapters / foreign 域同模式)。
54
+ *
55
+ * 为什么不写进 `src/sync/messages.ts`:该文件由**并行会话**维护、且不在本任务写入范围内;
56
+ * 追加目录的查找顺序是「既有目录(当前语言)→ 注册目录 → 键名」,新键不可能遮蔽既有键。
57
+ */
58
+ const zh = {
59
+ 'sync.pushRemoteAdvanced': '远端最新快照 {remote} 与本地基线 {base} 不一致:本次推送未执行(远端未写入、基线未改动)。请先同步远端改动;确认要用本机覆盖远端时,再显式选择强制推送。',
60
+ 'sync.pushAdvanceCheckFailed': '无法确认远端是否已被其它设备推进({reason}):本次推送按原样继续',
61
+ 'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine 缺少 importer(需在 options 中注入)',
62
+ 'sync.mergePendingConflicts': '仍有 {count} 个分区未裁决({sections}):已裁决的部分已写入本机,但基线不推进,冲突下一轮会重新出现',
63
+ 'sync.baselineRexportFailed': '写回成功但重新导出本机分区失败({sections}):无法证明基线与本机一致,本次**不记基线**(远端指针保持原值,下一轮重新合并收敛)',
64
+ };
65
+ const en = {
66
+ '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.',
67
+ 'sync.pushAdvanceCheckFailed': 'Cannot tell whether another device advanced the remote ({reason}): this push continues as-is',
68
+ 'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine is missing the importer (must be injected via options)',
69
+ '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',
70
+ '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)',
71
+ };
72
+ registerMessageCatalog({ id: 'sync-engine-merge', zh, en });
73
+ /**
74
+ * 单侧预览文本上限(2 KiB **字符**预算):超出截断并追加省略号。
75
+ *
76
+ * 顺序恒为「先 redact 再截断」:反过来会把跨边界的敏感值切成半个形状,让脱敏匹配不到它
77
+ * (等于把秘密截一半发出去)。redact 之后的剩余文本已不含秘密形状,截断才安全。
78
+ */
79
+ const MERGE_PREVIEW_TEXT_LIMIT = 2048;
80
+ /** 严格 UTF-8 解码器:非法字节序列直接抛错(用于区分文本与二进制文件冲突)。 */
81
+ const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
82
+ /** 把任意冲突侧值转成**可读字符串**;二进制只回传形状与字节数,绝不回传原始值对象。 */
83
+ function previewTextOf(value) {
84
+ if (value === undefined)
85
+ return '';
86
+ if (typeof value === 'string')
87
+ return finalizePreviewText(value);
88
+ if (value instanceof Uint8Array) {
89
+ try {
90
+ return finalizePreviewText(STRICT_UTF8.decode(value));
91
+ }
92
+ catch {
93
+ // 二进制内容解码只会得到乱码(同样误导用户)→ 只回传形状;JSON 是数据形态,不是文案
94
+ return JSON.stringify({ binary: true, bytes: value.length });
95
+ }
96
+ }
97
+ try {
98
+ return finalizePreviewText(JSON.stringify(value) ?? '');
99
+ }
100
+ catch {
101
+ return finalizePreviewText(String(value));
102
+ }
103
+ }
104
+ /** 预览文本统一出口:先 redact(敏感值绝不回传浏览器),再截断到 2 KiB。 */
105
+ function finalizePreviewText(text) {
106
+ const masked = redact(text);
107
+ return masked.length > MERGE_PREVIEW_TEXT_LIMIT ? `${masked.slice(0, MERGE_PREVIEW_TEXT_LIMIT)}…` : masked;
108
+ }
109
+ /** plan 的单个分区结果 → 预览视图(三侧都只回传可读文本,绝不回传原始值)。 */
110
+ function mergePreviewSectionView(result) {
111
+ const conflicts = result.conflicts.map((c) => ({
112
+ path: c.path,
113
+ kind: c.kind,
114
+ ...(c.reason !== undefined ? { reason: c.reason } : {}),
115
+ localPreview: previewTextOf(c.local),
116
+ remotePreview: previewTextOf(c.remote),
117
+ ancestorPreview: previewTextOf(c.ancestor),
118
+ }));
119
+ return {
120
+ id: result.id,
121
+ decision: result.decision,
122
+ ...(result.autoMerged !== undefined ? { autoMerged: result.autoMerged } : {}),
123
+ conflictCount: result.conflicts.length,
124
+ conflicts,
125
+ };
126
+ }
48
127
  /**
49
128
  * 失败路径的**显式兜底回滚**:把 core 的 RollbackReport 翻成「是否完整 + 告警行」。
50
129
  *
@@ -136,8 +215,25 @@ export class SyncEngine {
136
215
  * 「源机 home → 本机 home」的重定基规则;'' = 旧快照/未知(不猜,行为不变)。
137
216
  */
138
217
  lastMergedRemoteSourceHome = '';
218
+ /**
219
+ * 最近一次 merge() 命中的远端快照是否加密(issue #107):
220
+ * applyMergeResolutions → applyMergePlan 写基线时据此决定祖先副本是否也落**密文**。
221
+ * false = 明文通道/未知(保持既有明文副本,行为逐字不变)。
222
+ */
223
+ lastMergedRemoteEncrypted = false;
139
224
  /** 最近一次 merge() 收到的用户路径映射(applyMergePlan 复用;缺省 []) */
140
225
  lastMergedPathMappings = [];
226
+ /**
227
+ * 最近一次 merge() 是否拿到了本地祖先副本(false = 两方合并)。
228
+ * previewMerge 用它如实告诉界面「为什么全都是冲突」,绝不让两方差异看起来像三方结论。
229
+ */
230
+ lastMergeHadAncestor = false;
231
+ /**
232
+ * 最近一次 merge() 的三方输入(仅内存)。
233
+ * applyMergeResolutions 需要它还原「整分区」语义:merge() 的 MergePlan 只携带冲突处的
234
+ * 原始值(键级冲突只给键值),而 'useRemote' / 'keepLocal' 必须落**整分区**数据才能进导入计划。
235
+ */
236
+ lastMergeInputs = null;
141
237
  constructor(opts) {
142
238
  if (opts.ctx === null || typeof opts.ctx !== 'object')
143
239
  throw new Error(zhMsg('sync.missingCtx'));
@@ -408,6 +504,9 @@ export class SyncEngine {
408
504
  * opts.sections:指定仅同步这些分区(高级/自定义模式);缺省 = 全部 portable 推荐分区。
409
505
  * opts.encrypt/password/includeSecrets:加密快照(含可选密钥导出)。
410
506
  * 安全不变量:includeSecrets ⇒ encrypt(密钥绝不明文进入同步通道);密码仅内存。
507
+ *
508
+ * 前置:**远端已被其它设备推进时默认拒绝写入**(见 assertRemoteNotAdvanced)—— 这一条在导出
509
+ * 之前执行,因为「本机这一版要不要推」是被远端状态决定的,先别做无谓的导出(plugins 还会 npm pack)。
411
510
  */
412
511
  async push(opts = {}) {
413
512
  const warnings = [];
@@ -420,6 +519,25 @@ export class SyncEngine {
420
519
  if (encrypt && (opts.password === undefined || opts.password === '')) {
421
520
  throw new Error(this.msg('sync.encryptRequiresPassword'));
422
521
  }
522
+ // 远端推进检测:不一致 = 远端被别的设备推过(或本机基线已被裁剪)。
523
+ // 此时推上去会覆盖/架空别人的改动 → 直接返回冲突(**不抛错**、不 upload、不记基线、不改 sync-state)。
524
+ if (opts.force !== true) {
525
+ const check = await this.remoteAdvancedCheck();
526
+ if (check.reason !== undefined) {
527
+ // 读不到远端列表 ⇒ 无法判定。按原样继续(上传本身会再暴露通道问题),但如实说明。
528
+ warnings.push(this.msg('sync.pushAdvanceCheckFailed', { reason: check.reason }));
529
+ }
530
+ else if (check.blocked) {
531
+ return {
532
+ ok: false,
533
+ snapshotId: '',
534
+ sections: [],
535
+ warnings,
536
+ conflict: true,
537
+ message: this.msg('sync.pushRemoteAdvanced', { remote: check.latest, base: check.base }),
538
+ };
539
+ }
540
+ }
423
541
  const plainSections = {};
424
542
  const targets = this.pushTargets(opts.sections, warnings);
425
543
  for (const adapter of targets) {
@@ -462,6 +580,9 @@ export class SyncEngine {
462
580
  if (bookkeeping.deletedNow.length > 0) {
463
581
  warnings.push(this.msg('sync.sessionDeletionsRecorded', { count: String(bookkeeping.deletedNow.length) }));
464
582
  }
583
+ // ⑥ 设备身份(cloud-sync 借鉴项 ⑥):首次生成后持久化在 sync-state.json(已有则纯读),
584
+ // 随 manifest 上行 → 别的设备在历史列表里能看到「这份快照是哪台机器推的」。
585
+ const device = await resolveDeviceIdentity(this.stateDir, os.hostname(), this.fsx, this.msg);
465
586
  const nowIso = this.now().toISOString();
466
587
  const id = opts.snapshotId ?? this.snapshotIdFn();
467
588
  const snapshot = {
@@ -480,6 +601,9 @@ export class SyncEngine {
480
601
  ...(this.ctx.homeDir !== '' ? { sourceHome: this.ctx.homeDir } : {}),
481
602
  // P1-5:累积墓碑随每份快照传播(拉取侧据此不让旧快照复活已删除的对话)
482
603
  ...(bookkeeping.tombstones.length > 0 ? { deletedSessions: bookkeeping.tombstones } : {}),
604
+ // ⑥:产生该快照的设备身份(历史列表展示「哪台机器推的」;旧读侧忽略未知字段)
605
+ deviceId: device.id,
606
+ deviceName: device.name,
483
607
  ...(encrypted ? { encrypted: true } : {}),
484
608
  },
485
609
  sections,
@@ -499,7 +623,10 @@ export class SyncEngine {
499
623
  // ③ 记录祖先基线:写 sync-state(lastSnapshotId + 每分区 hash/updatedAt + lastSyncAt + transport)+ 裁剪。
500
624
  // 基线 hash 用明文(hasLocalChanges 与本地明文可比);加密快照不写明文祖先副本(密钥不落盘)。
501
625
  await this.recordBaseline(id, plainSections, nowIso, {
502
- writeAncestor: !encrypted,
626
+ // issue #107:加密快照也落**密文**祖先副本(密码只在本进程内存里用;磁盘上只有密文)。
627
+ // 不落任何副本的旧行为会让每轮合并都缺共同祖先 → 三方退化成两方、整分区冲突,
628
+ // 用户被迫反复人工裁决。明文通道不受影响(缺省走 writeAncestor 的明文散文件布局)。
629
+ ...(encrypted ? { ancestorPassword: opts.password } : {}),
503
630
  // 只有本轮真的跟踪了 sessions 才覆盖这两个字段(见 computeSessionBookkeeping)
504
631
  ...(bookkeeping.tracked
505
632
  ? { sessionUnits: bookkeeping.sessionUnits, deletedSessions: bookkeeping.tombstones }
@@ -716,6 +843,33 @@ export class SyncEngine {
716
843
  return DEFAULT_RETENTION_POLICY;
717
844
  }
718
845
  }
846
+ /**
847
+ * push 前置:远端是否已被别的设备推进(防「推上去把别人的改动架空」)。
848
+ *
849
+ * 判据与 hasNewRemoteSnapshot() 同源但方向相反:
850
+ * - 首次同步(lastSnapshotId === '')→ **不拦**(本机没有基线可丢,第一次推送就是建立基线);
851
+ * - 远端为空 → 不拦(没有可比对的快照);
852
+ * - 其余:远端最新快照 id !== lastSnapshotId ⇒ 远端被推进(或本机基线已被裁剪)→ 拦。
853
+ * 两种情况都**无法安全断言「远端没变」**,保守拒绝并给出 force 出口。
854
+ * - list() 抛错 → 返回 reason(调用方按原样继续并告警),绝不把「读不到」当成「没变」。
855
+ */
856
+ async remoteAdvancedCheck() {
857
+ const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
858
+ const base = state.lastSnapshotId;
859
+ if (base === '')
860
+ return { blocked: false, base, latest: '' };
861
+ let metas;
862
+ try {
863
+ metas = await this.transport.list();
864
+ }
865
+ catch (err) {
866
+ return { blocked: false, base, latest: '', reason: err instanceof Error ? err.message : String(err) };
867
+ }
868
+ if (metas.length === 0)
869
+ return { blocked: false, base, latest: '' };
870
+ const latest = metas[metas.length - 1].id; // list 按 createdAt 升序 → 最新在末
871
+ return { blocked: latest !== base, base, latest };
872
+ }
719
873
  /**
720
874
  * pull:拉取远端最新(或指定)快照 → 过滤 portable 分区 → 转临时标准 ZIP →
721
875
  * 复用 Importer 预览流程(analyzeImport/createImportPlan)产出差异报告。
@@ -851,9 +1005,13 @@ export class SyncEngine {
851
1005
  *
852
1006
  * 前提:lastSnapshotId 必须是**远端**快照 id(P0-7)。旧实现把本地生成的快照 id 也写进去,
853
1007
  * 于是合并/应用之后这里恒为 true → 每轮白跑 list + download + merge。
1008
+ *
1009
+ * `listed` 可选:调用方本轮已经 list 过(如自动同步先查加密快照)时直接传进来,
1010
+ * 避免同一个回合对远端做两次 list —— 每条通道的 list 都是一次真实网络往返
1011
+ * (git 通道 = `pull --ff-only`,webdav 通道 = PROPFIND/GET)。
854
1012
  */
855
- async hasNewRemoteSnapshot() {
856
- const metas = await this.transport.list();
1013
+ async hasNewRemoteSnapshot(listed) {
1014
+ const metas = listed ?? await this.transport.list();
857
1015
  if (metas.length === 0)
858
1016
  return false;
859
1017
  const latestId = metas[metas.length - 1].id; // list 按 createdAt 升序 → 最新在末
@@ -862,6 +1020,20 @@ export class SyncEngine {
862
1020
  return true;
863
1021
  return latestId !== state.lastSnapshotId;
864
1022
  }
1023
+ /**
1024
+ * 连通性探针(cloud-sync 借鉴项 ②):把当前通道的**只读**自检透出给宿主路由。
1025
+ *
1026
+ * 通道没实现 probe(如 git 通道按 fast-forward 语义自证)时**如实报不支持**,
1027
+ * 绝不返回「连接正常」—— 谎报成功比不支持更糟。
1028
+ */
1029
+ async probe() {
1030
+ const probe = this.transport.probe;
1031
+ if (typeof probe !== 'function') {
1032
+ // 带稳定错误码:路由/UI 据此把它当**能力边界**(中性提示)而不是「操作失败」
1033
+ throw new SyncTransportError(this.msg('sync.probeUnsupported'), { kind: 'client', retryable: false, code: PROBE_UNSUPPORTED_CODE });
1034
+ }
1035
+ return await probe.call(this.transport);
1036
+ }
865
1037
  /**
866
1038
  * 本地 portable 配置当前内容与上次基线(sync-state.sections hash)相比是否有变化(§3.1 上传「看变化」)。
867
1039
  * - 从未同步(sync-state.sections 为空)→ true;
@@ -932,7 +1104,8 @@ export class SyncEngine {
932
1104
  /**
933
1105
  * 三方合并(不写本地配置、不执行导入):local = 现场 export,remote = 下载的远端快照,
934
1106
  * ancestor = 本地祖先副本;副本缺失 → 退化为两方合并(整分区 conflict 交用户裁决)。
935
- * 唯一副作用是一个进程内备忘:记录本次命中的**远端**快照 id,供紧随其后的 applyMergePlan 写基线。
1107
+ * 副作用只有进程内备忘(远端快照 id / sourceHome / 用户映射 / 祖先可用性 / plan / 三方输入),
1108
+ * 供紧随其后的 previewMerge / applyMergePlan / applyMergeResolutions 复用:同一次调用只下载一次远端。
936
1109
  */
937
1110
  async merge(opts = {}) {
938
1111
  const metas = await this.transport.list();
@@ -940,12 +1113,18 @@ export class SyncEngine {
940
1113
  this.lastMergedRemoteSnapshotId = null;
941
1114
  this.lastMergedRemoteSourceHome = '';
942
1115
  this.lastMergedPathMappings = [];
1116
+ // 远端为空:清掉全部缓存(绝不把上一轮的输入留在内存里被后续调用误用)
1117
+ this.lastMergeHadAncestor = false;
1118
+ this.lastMergeInputs = null;
1119
+ this.lastMergedRemoteEncrypted = false;
943
1120
  return { sections: [] };
944
1121
  }
945
1122
  const targetId = opts.snapshotId ?? metas[metas.length - 1].id;
946
1123
  const remote = await this.transport.download(targetId);
947
1124
  // 加密快照 → 用密码解密;普通快照 containsSecrets=true → 拒绝(防御)
948
1125
  await this.prepareSnapshot(remote, opts.password);
1126
+ // issue #107:记下远端快照是否加密 —— applyMergePlan 据此决定祖先副本也落密文
1127
+ this.lastMergedRemoteEncrypted = remote.manifest.encrypted === true;
949
1128
  // P1-5:按远端墓碑剔除已删除的会话(三方合并的 remote 侧同样不许复活)
950
1129
  this.stripTombstonedSessions(remote);
951
1130
  // 本次合并的远端基准(P0-7):紧随其后的 applyMergePlan() 用它写基线远端指针
@@ -956,7 +1135,7 @@ export class SyncEngine {
956
1135
  this.lastMergedPathMappings = opts.pathMappings ?? [];
957
1136
  // 共同祖先用本地祖先**副本目录名** = state.ancestorId('' = 无本地祖先副本)。
958
1137
  // 不能用 state.lastSnapshotId —— 那是远端快照 id;混用会让 merge 去 load 一个并不存在的目录
959
- // (加密快照 push 不落副本)→ 抛「快照目录缺少 manifest.json」→ 整轮自动同步 failed 且每轮复现。
1138
+ // → 抛「快照目录缺少 manifest.json」→ 整轮自动同步 failed 且每轮复现。
960
1139
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
961
1140
  const ancestorId = state.ancestorId ?? '';
962
1141
  let ancestor;
@@ -964,6 +1143,23 @@ export class SyncEngine {
964
1143
  // 副本缺失/被裁剪/损坏 → undefined(两方合并),绝不上抛成整轮失败(P0-7)
965
1144
  ancestor = await tryLoadAncestor(this.localSnapshotsDir, ancestorId, this.fsx);
966
1145
  }
1146
+ // issue #107:加密祖先副本(manifest.encrypted + 单密文载荷)→ 用本次合并的密码解密回明文。
1147
+ // 密码缺失 / 与副本不同 / 副本损坏,一律按「无祖先」降级为两方合并 —— 祖先只是本地缓存,
1148
+ // 它的形态问题绝不能让整轮合并失败(与 tryLoadAncestor 的 P0-7 契约同一精神)。
1149
+ if (ancestor !== undefined && ancestor.manifest.encrypted) {
1150
+ const ancestorPassword = opts.password ?? '';
1151
+ if (ancestorPassword !== '' && isEncryptedSections(ancestor.sections)) {
1152
+ try {
1153
+ ancestor.sections = await decryptSectionsPayload(ancestor.sections.encrypted, ancestorPassword);
1154
+ }
1155
+ catch {
1156
+ ancestor = undefined;
1157
+ }
1158
+ }
1159
+ else {
1160
+ ancestor = undefined;
1161
+ }
1162
+ }
967
1163
  // 本地当前:现场 export(含 secret 剥离),与 push 同口径
968
1164
  const localSections = {};
969
1165
  const localIds = this.pullSectionIds();
@@ -991,7 +1187,233 @@ export class SyncEngine {
991
1187
  ancestorPortable[id] = data;
992
1188
  }
993
1189
  }
994
- return mergeSections(localSections, remotePortable, ancestorPortable);
1190
+ const plan = mergeSections(localSections, remotePortable, ancestorPortable);
1191
+ // 结果**经返回值**交给调用方(previewMerge / applyMergeResolutions 都用这一份,不会二次下载);
1192
+ // 这里只留两个进程内备忘:祖先可用性(预览要如实说明「为什么全是冲突」)与三方输入
1193
+ // (applyMergeResolutions 要把分区级裁决落回**整分区**数据)。
1194
+ this.lastMergeHadAncestor = ancestor !== undefined;
1195
+ this.lastMergeInputs = { local: localSections, remote: remotePortable, ancestor: ancestorPortable };
1196
+ return plan;
1197
+ }
1198
+ /**
1199
+ * 合并预览(只读、零写入):把 merge() 的三方结果翻成界面可渲染的结构化视图。
1200
+ *
1201
+ * 与 merge() 的关系:**复用**同一次调用(同一实例上只下载一次远端快照、只 export 一次本地),
1202
+ * 不改远端、不写 sync-state、不落祖先副本、不动本机配置。
1203
+ *
1204
+ * 安全:每侧预览文本都先过 redact() 再截断到 2 KiB(敏感值绝不回传浏览器);原始值对象
1205
+ * (对象 / 数组 / 字节数组)**绝不**上抛给调用方。
1206
+ */
1207
+ async previewMerge(opts = {}) {
1208
+ const plan = await this.merge(opts);
1209
+ const summary = { auto: 0, review: 0, conflicts: 0, skipped: 0 };
1210
+ const snapshotId = this.lastMergedRemoteSnapshotId;
1211
+ if (snapshotId === null) {
1212
+ // merge() 已清掉全部缓存;这里如实报「远端无快照」(ok:false,而不是一个空成功)
1213
+ return { ok: false, snapshotId: '', hasAncestor: false, sections: [], summary, message: this.msg('sync.remoteEmpty') };
1214
+ }
1215
+ const split = classifyMergePlan(plan, { firstSync: false });
1216
+ summary.auto = split.autoApply.length;
1217
+ summary.review = split.review.length;
1218
+ summary.skipped = split.skipped.length;
1219
+ summary.conflicts = plan.sections.filter((s) => s.decision === 'conflict').length;
1220
+ return {
1221
+ ok: true,
1222
+ snapshotId,
1223
+ hasAncestor: this.lastMergeHadAncestor,
1224
+ sections: plan.sections.map(mergePreviewSectionView),
1225
+ summary,
1226
+ };
1227
+ }
1228
+ /**
1229
+ * 按用户裁决应用合并结果(**分区粒度**),并严守基线纪律。
1230
+ *
1231
+ * 与 applyMergePlan 的关系:把 resolutions 映射成 SyncApplyPlan 后走**同一条执行路径**
1232
+ * (applyMergePlan:兜底快照 + 失败整体回滚 + 成功才记基线)。本方法只额外负责两件事:
1233
+ * 1. 把 merge() 的分区级结论翻成「远端整分区 / 本机整分区 / 自动合并结果」的实际数据
1234
+ * (键级冲突的 conflicts 只带键值,进不了导入计划,必须回到整分区语义);
1235
+ * 2. 基线纪律 —— 只要还有未裁决分区,就**不推进远端基线指针**并记 pendingMerge
1236
+ * (冲突下一轮重新出现);全部裁决完才清空 pendingMerge 并推进基线。
1237
+ *
1238
+ * 裁决语义(与 MergeResolution 的文档一致):
1239
+ * - 未点名的分区按 merge() 的既有结论处理:可决断(useRemote / keepLocal / merge)→ 应用;
1240
+ * 'skip' → 跳过;'conflict' → 计入 pendingConflicts;
1241
+ * - action='skip'(点名了但不裁决):该分区若有内容可裁决 → 计入 pendingConflicts(**不算解决**,
1242
+ * 否则基线推进会让它下一轮不再被提出 = 静默丢改动);本来就无变化的 'skip' 分区 → 跳过;
1243
+ * - useMerged 而 merge() 没给 merged(典型:'conflict' 分区)→ 计入 pendingConflicts;
1244
+ * - useRemote 而远端没有该分区(= 远端删了它)→ 导入计划表达不了删除 → 计入 pendingConflicts;
1245
+ * - keepLocal 而本机没有该分区 → 保持现状(无写入)→ 跳过。
1246
+ */
1247
+ async applyMergeResolutions(resolutions, opts = {}) {
1248
+ if (!this.importer) {
1249
+ throw new Error(this.msg('sync.applyMergeResolutionsNoImporter'));
1250
+ }
1251
+ // 1) 先合并一次(一次下载):plan / 三方输入留在实例缓存里,供本次裁决取整分区值
1252
+ const plan = await this.merge({ snapshotId: opts.snapshotId, password: opts.password, pathMappings: opts.pathMappings });
1253
+ const remoteSnapshotId = this.lastMergedRemoteSnapshotId;
1254
+ if (remoteSnapshotId === null) {
1255
+ return { ok: true, applied: [], restoreId: '', rolledBack: false, review: [], warnings: [], pendingConflicts: [] };
1256
+ }
1257
+ const inputs = this.lastMergeInputs ?? { local: {}, remote: {}, ancestor: {} };
1258
+ const byId = new Map();
1259
+ for (const r of resolutions)
1260
+ byId.set(r.id, r);
1261
+ const autoApply = [];
1262
+ const skipped = [];
1263
+ const pendingConflicts = [];
1264
+ for (const section of plan.sections) {
1265
+ const res = byId.get(section.id);
1266
+ if (res === undefined) {
1267
+ if (section.decision === 'skip') {
1268
+ skipped.push(section);
1269
+ continue;
1270
+ }
1271
+ if (section.decision === 'conflict' || section.merged === undefined) {
1272
+ pendingConflicts.push(section.id);
1273
+ continue;
1274
+ }
1275
+ autoApply.push(section);
1276
+ continue;
1277
+ }
1278
+ switch (res.action) {
1279
+ case 'skip': {
1280
+ if (section.decision === 'skip')
1281
+ skipped.push(section);
1282
+ else
1283
+ pendingConflicts.push(section.id);
1284
+ break;
1285
+ }
1286
+ case 'useMerged': {
1287
+ if (section.merged === undefined) {
1288
+ pendingConflicts.push(section.id);
1289
+ break;
1290
+ }
1291
+ autoApply.push({ ...section, decision: 'merge', merged: section.merged, conflicts: [] });
1292
+ break;
1293
+ }
1294
+ case 'useRemote': {
1295
+ const remoteData = inputs.remote[section.id];
1296
+ if (remoteData === undefined) {
1297
+ pendingConflicts.push(section.id);
1298
+ break;
1299
+ }
1300
+ autoApply.push({ id: section.id, decision: 'useRemote', merged: remoteData, conflicts: [] });
1301
+ break;
1302
+ }
1303
+ case 'keepLocal': {
1304
+ const localData = inputs.local[section.id];
1305
+ if (localData === undefined) {
1306
+ skipped.push(section);
1307
+ break;
1308
+ } // 本机本就没有 → 无写入
1309
+ autoApply.push({ id: section.id, decision: 'keepLocal', merged: localData, conflicts: [] });
1310
+ break;
1311
+ }
1312
+ }
1313
+ }
1314
+ const applyPlan = { autoApply, review: [], skipped };
1315
+ const hasPending = pendingConflicts.length > 0;
1316
+ const pendingMessage = this.msg('sync.mergePendingConflicts', {
1317
+ count: String(pendingConflicts.length),
1318
+ sections: pendingConflicts.join(', '),
1319
+ });
1320
+ const stateBefore = await loadSyncState(this.stateDir, this.fsx, this.msg);
1321
+ // 2) 无物可应用:只落基线纪律的状态(待裁决记录 / 清空 + 按需推进远端指针),
1322
+ // 不构造临时 ZIP、不碰本机配置、不动祖先副本。
1323
+ if (autoApply.length === 0) {
1324
+ await this.writeMergeBaseline(hasPending
1325
+ ? { pending: this.pendingMergeFor(stateBefore, remoteSnapshotId, pendingConflicts) }
1326
+ // 全部裁决完但无需写入任何分区(全部是本来就无变化的 'skip')→ 基线可以推进到这一版
1327
+ : { advanceRemoteId: remoteSnapshotId });
1328
+ return {
1329
+ ok: true, applied: [], restoreId: '', rolledBack: false, review: [],
1330
+ warnings: hasPending ? [pendingMessage] : [],
1331
+ pendingConflicts,
1332
+ };
1333
+ }
1334
+ // 3) 有物可应用 → 复用 applyMergePlan 的既有执行路径(兜底快照 + 失败整体回滚 + 成功才记基线)
1335
+ const report = await this.applyMergePlan(applyPlan, {
1336
+ ...(hasPending
1337
+ ? {
1338
+ // 待裁决:**不推进**远端指针(首同步时如实留空),并把祖先副本保持为共同基线 ——
1339
+ // 已裁决分区下一轮 local === remote → skip,既不会被重判成冲突,也不会被远端覆盖。
1340
+ remoteSnapshotId: stateBefore.lastSnapshotId,
1341
+ preserveAncestor: true,
1342
+ pendingMerge: this.pendingMergeFor(stateBefore, remoteSnapshotId, pendingConflicts),
1343
+ }
1344
+ : { pendingMerge: null }),
1345
+ ...(opts.snapshotBinding !== undefined ? { snapshotBinding: opts.snapshotBinding } : {}),
1346
+ ...(opts.pathMappings !== undefined ? { pathMappings: opts.pathMappings } : {}),
1347
+ // issue #107:远端是加密快照且本次有密码 → 祖先副本也落密文(下轮三方合并可解密)
1348
+ ...(this.lastMergedRemoteEncrypted && opts.password !== undefined && opts.password !== ''
1349
+ ? { ancestorPassword: opts.password }
1350
+ : {}),
1351
+ });
1352
+ if (!report.ok) {
1353
+ // 失败已整体回滚:基线未动、本机配置没变 → 不写 pendingMerge(冲突下一轮照旧出现)
1354
+ return { ...report, pendingConflicts };
1355
+ }
1356
+ return {
1357
+ ...report,
1358
+ warnings: hasPending ? [...report.warnings, pendingMessage] : report.warnings,
1359
+ pendingConflicts,
1360
+ };
1361
+ }
1362
+ /** 组装 pendingMerge 记录:同一远端快照上的连续待裁决**保留首次时间**(界面据此讲「挂了多久」)。 */
1363
+ pendingMergeFor(state, remoteSnapshotId, sections) {
1364
+ const since = state.pendingMerge !== undefined && state.pendingMerge.remoteSnapshotId === remoteSnapshotId
1365
+ ? state.pendingMerge.since
1366
+ : this.now().toISOString();
1367
+ return { remoteSnapshotId, sections, since };
1368
+ }
1369
+ /**
1370
+ * 基线纪律的**纯状态**写入(不写祖先副本、不碰远端):仅在本机没有任何配置写入时使用。
1371
+ *
1372
+ * - pending 提供 → 写 pendingMerge;undefined → 清除该字段(「全部裁决完」);
1373
+ * - advanceRemoteId 提供 → 把远端指针推进到该快照 id(缺省 = 保持不变)。
1374
+ *
1375
+ * 为什么不走 recordBaseline:那条路径会重建祖先副本并只带本次应用的分区 —— 在「没有应用任何分区」
1376
+ * 或「仍有未裁决冲突」时都会把共同基线改坏(下一轮所有分区都退化成「无祖先 → 冲突」)。
1377
+ */
1378
+ async writeMergeBaseline(opts) {
1379
+ const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
1380
+ if (opts.pending === undefined)
1381
+ delete state.pendingMerge;
1382
+ else
1383
+ state.pendingMerge = opts.pending;
1384
+ if (opts.advanceRemoteId !== undefined) {
1385
+ state.lastSnapshotId = opts.advanceRemoteId;
1386
+ state.lastSyncAt = this.now().toISOString();
1387
+ }
1388
+ await saveSyncState(this.stateDir, state, this.fsx);
1389
+ }
1390
+ /**
1391
+ * 重新导出**已应用**的分区(F-2,2026-10):基线的唯一正确来源是「实际落地的本机内容」。
1392
+ *
1393
+ * 为什么不能用计划/远端的 merged 载荷:写回时 adapter 会自增本机侧字段(如
1394
+ * settings.namespaces[*].revision),而 merged 记录的是**远端快照里的值**;据此记基线会让
1395
+ * 下一轮 merge 同时判「本地改了」与「远端改了」→ both-modified 冲突 → 每轮重复冲突。
1396
+ *
1397
+ * 口径与 merge() 的本地侧导出一致:文件类分区直接取 data,其余过 SecretScanner 剥离。
1398
+ * 任一分区导出失败 → 返回 null(调用方**不推进基线**并明确告警):绝不写半个基线。
1399
+ */
1400
+ async exportAppliedSections(ids) {
1401
+ const wanted = new Set(ids);
1402
+ const out = {};
1403
+ for (const adapter of this.adapters) {
1404
+ if (!wanted.has(adapter.id))
1405
+ continue;
1406
+ try {
1407
+ const section = await adapter.export(this.ctx, { includeSecrets: false });
1408
+ out[adapter.id] = (isFileSection(adapter.id)
1409
+ ? section.data
1410
+ : this.scanner.scanAndRedact(section.data).sanitized);
1411
+ }
1412
+ catch {
1413
+ return null;
1414
+ }
1415
+ }
1416
+ return out;
995
1417
  }
996
1418
  /**
997
1419
  * 记录祖先基线:写本地祖先副本 + 更新 sync-state(每分区 hash/updatedAt、lastSyncAt、transport、
@@ -1003,38 +1425,59 @@ export class SyncEngine {
1003
1425
  * (push 路径两者天然相同);合并/应用路径**必须显式传**,否则 lastSnapshotId 会退化成「一个远端
1004
1426
  * 并不存在的本地 id」→ hasNewRemoteSnapshot() 恒判「远端有新生」,每轮白跑 list + download + merge。
1005
1427
  * 传入 `''` 表示「远端 id 未知」——宁可留空,也不要把本地 id 冒充成远端 id。
1006
- * - `opts.writeAncestor === false`(加密快照 push):不落本地祖先副本,`ancestorId` 一并置 '' ——
1428
+ * - `opts.writeAncestor === false`:不落本地祖先副本,`ancestorId` 一并置 '' ——
1007
1429
  * 绝不把一个本地并不存在的目录名写进状态(旧实现正是这样让下一轮 merge 直接抛错)。
1430
+ * - `opts.ancestorPassword`(issue #107):祖先副本落**密文**(manifest.encrypted + 单密文载荷);
1431
+ * 加密快照 push 必须传,否则下轮三方合并缺共同祖先、整分区冲突。
1432
+ * - `opts.preserveAncestor === true`(合并仍有未裁决冲突):祖先副本与 ancestorId **保持不动** ——
1433
+ * 共同基线必须仍是「本机与远端都认可的上一版」;拿只带本次应用分区的副本去覆盖它,会让
1434
+ * 下一轮所有分区都退化成「无祖先 → 冲突」。
1435
+ * - `opts.pendingMerge`(基线纪律):提供即写入;`null` = 清除该字段;缺省 = 不动(既有行为不变)。
1008
1436
  */
1009
- async recordBaseline(snapshotId, sections, nowIso, opts = {}) {
1437
+ async recordBaseline(snapshotId,
1438
+ // 基线**恒为明文**(加密快照的基线 hash 也用明文算,供 hasLocalChanges 比较);
1439
+ // 需要落盘副本时由 opts.ancestorPassword 决定写明文还是密文目录。
1440
+ sections, nowIso, opts = {}) {
1010
1441
  const ts = nowIso ?? this.now().toISOString();
1011
- const writesAncestorCopy = this.localSnapshotsDir !== undefined && opts.writeAncestor !== false;
1442
+ const preserveAncestor = opts.preserveAncestor === true;
1443
+ const writesAncestorCopy = !preserveAncestor && this.localSnapshotsDir !== undefined && opts.writeAncestor !== false;
1012
1444
  // 1) 写本地祖先副本(如有 localSnapshotsDir)。
1013
- // writeAncestor=false(加密快照基线):不写明文祖先副本(磁盘不落密钥;
1014
- // 基线仅用 sync-state 的明文 hash,供 hasLocalChanges 比较)。
1445
+ // ancestorPassword 提供 → 落**密文**祖先副本(issue #107:加密通道也能做三方合并);
1446
+ // 否则落明文散文件布局(既有行为逐字不变)。
1015
1447
  if (writesAncestorCopy) {
1016
- const snapshot = {
1017
- id: snapshotId,
1018
- createdAt: ts,
1019
- manifest: {
1020
- schemaVersion: CURRENT_SCHEMA_VERSION,
1021
- dshVersion: this.ctx.dshVersion,
1022
- platform: this.ctx.platform,
1023
- sectionIds: Object.keys(sections),
1024
- containsSecrets: false,
1025
- // 记录触发通道(git/webdav),供同步历史展示「由哪个通道触发」
1026
- transport: this.transport.type,
1027
- },
1028
- sections,
1448
+ const ancestorPassword = opts.ancestorPassword;
1449
+ const encryptedAncestor = ancestorPassword !== undefined && ancestorPassword !== '';
1450
+ const manifest = {
1451
+ schemaVersion: CURRENT_SCHEMA_VERSION,
1452
+ dshVersion: this.ctx.dshVersion,
1453
+ platform: this.ctx.platform,
1454
+ sectionIds: Object.keys(sections),
1455
+ containsSecrets: false,
1456
+ // 记录触发通道(git/webdav),供同步历史展示「由哪个通道触发」
1457
+ transport: this.transport.type,
1458
+ ...(encryptedAncestor ? { encrypted: true } : {}),
1029
1459
  };
1030
- await writeAncestor(this.localSnapshotsDir, snapshot, this.fsx);
1460
+ if (encryptedAncestor) {
1461
+ const encryptedSections = await encryptSectionsPayload(sections, ancestorPassword);
1462
+ await writeEncryptedAncestor(this.localSnapshotsDir, { id: snapshotId, createdAt: ts, manifest, sections: encryptedSections }, this.fsx);
1463
+ }
1464
+ else {
1465
+ await writeAncestor(this.localSnapshotsDir, { id: snapshotId, createdAt: ts, manifest, sections }, this.fsx);
1466
+ }
1031
1467
  }
1032
1468
  // 2) 更新 sync-state:远端指针 + 本地祖先目录名 + 每分区 hash/updatedAt + lastSyncAt + transport
1033
1469
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
1034
1470
  state.lastSnapshotId = opts.remoteSnapshotId ?? snapshotId;
1035
- state.ancestorId = writesAncestorCopy ? snapshotId : '';
1471
+ // preserveAncestor:祖先目录名一并保持不动(既不是 snapshotId,也不是 '')
1472
+ if (!preserveAncestor)
1473
+ state.ancestorId = writesAncestorCopy ? snapshotId : '';
1036
1474
  state.lastSyncAt = ts;
1037
1475
  state.transport = { type: this.transport.type, ref: this.transportRef };
1476
+ // 基线纪律:null = 清除(全部裁决完);undefined = 不动该字段(既有调用方零感知)
1477
+ if (opts.pendingMerge === null)
1478
+ delete state.pendingMerge;
1479
+ else if (opts.pendingMerge !== undefined)
1480
+ state.pendingMerge = opts.pendingMerge;
1038
1481
  // P1-5:只在调用方明确提供时覆盖(否则保留既有记录,绝不因某条路径不跟踪而清空)
1039
1482
  if (opts.sessionUnits !== undefined)
1040
1483
  state.sessionUnits = [...opts.sessionUnits];
@@ -1044,8 +1487,8 @@ export class SyncEngine {
1044
1487
  state.sections[sid] = { hash: hashSection(data), updatedAt: ts };
1045
1488
  }
1046
1489
  await saveSyncState(this.stateDir, state, this.fsx);
1047
- // 3) 裁剪祖先副本(最近 N 个)
1048
- if (this.localSnapshotsDir !== undefined) {
1490
+ // 3) 裁剪祖先副本(最近 N 个);preserveAncestor 时本轮没有新副本,不参与裁剪
1491
+ if (!preserveAncestor && this.localSnapshotsDir !== undefined) {
1049
1492
  await pruneAncestors(this.localSnapshotsDir, DEFAULT_ANCESTOR_KEEP, this.fsx);
1050
1493
  }
1051
1494
  }
@@ -1152,14 +1595,28 @@ export class SyncEngine {
1152
1595
  rollbackFull: rb.full,
1153
1596
  };
1154
1597
  }
1155
- // 5) 全成功 → recordBaseline(更新祖先基线指向合并后的快照)
1156
- const mergedSections = apply.autoApply.reduce((acc, r) => {
1157
- if (r.merged !== undefined)
1158
- acc[r.id] = r.merged;
1159
- return acc;
1160
- }, {});
1161
- await this.recordBaseline(tempSnapshot.id, mergedSections, undefined, {
1598
+ // 5) 全成功 → recordBaseline(更新祖先基线)。
1599
+ // **基线必须以「实际落地的本地导出」为准(F-2,2026-10)**:写回时 adapter 会自增本机侧字段
1600
+ // (如 settings.namespaces[*].revision),而 merged 携带的是**远端快照里的值**;用它记基线
1601
+ // 会让下一轮 merge 判「本地也改了」+「远端也改了」→ both-modified 冲突 → 每轮重复冲突。
1602
+ const landed = await this.exportAppliedSections(appliedIds);
1603
+ if (landed === null) {
1604
+ // 重新导出失败 = 无法证明「基线 == 本机现状」→ **不推进基线**(远端指针保持原值,
1605
+ // 下一轮重新合并收敛)并明确告警;绝不写半个基线。
1606
+ return {
1607
+ ok: true,
1608
+ applied: appliedIds,
1609
+ restoreId: snapshot.id,
1610
+ rolledBack: false,
1611
+ review: [],
1612
+ warnings: [...(result.warnings ?? []), this.msg('sync.baselineRexportFailed', { sections: appliedIds.join(', ') })],
1613
+ };
1614
+ }
1615
+ await this.recordBaseline(tempSnapshot.id, landed, undefined, {
1162
1616
  remoteSnapshotId: opts.remoteSnapshotId ?? this.lastMergedRemoteSnapshotId ?? '',
1617
+ ...(opts.preserveAncestor !== undefined ? { preserveAncestor: opts.preserveAncestor } : {}),
1618
+ ...(opts.pendingMerge !== undefined ? { pendingMerge: opts.pendingMerge } : {}),
1619
+ ...(opts.ancestorPassword !== undefined ? { ancestorPassword: opts.ancestorPassword } : {}),
1163
1620
  });
1164
1621
  return {
1165
1622
  ok: true,
@@ -1247,29 +1704,28 @@ export class SyncEngine {
1247
1704
  rollbackFull: rb.full,
1248
1705
  };
1249
1706
  }
1250
- // 成功:recordBaseline 更新祖先基线(合并后的快照)
1707
+ // 成功:recordBaseline 更新祖先基线。
1708
+ // **基线以「实际落地的本地导出」为准(F-2,2026-10;一键同步路径同样适用)**——
1709
+ // 用计划/远端载荷记基线会因为 adapter 自增 revision 等本机字段而在下一轮造成假冲突。
1251
1710
  const appliedIds = [...new Set(subPlan.items.map((i) => i.adapter))];
1252
- const mergedSections = {};
1253
- // 从 subPlan 中按 adapter 收集写入的分区数据(无法直接从 plan item 获取 merged data,
1254
- // 但这里不需要精确的 merged 数据——recordBaseline 只需要 sectionIds 与数据来源;
1255
- // 用现有应用后的 adapter export 作为快照数据更准确)
1256
- // 注意:recordBaseline 需要各分区内容 hash,因此导出当前各分区最新状态。
1257
- for (const adapter of this.portableAdapters()) {
1258
- if (!appliedIds.includes(adapter.id))
1259
- continue;
1260
- try {
1261
- const section = await adapter.export(this.ctx, { includeSecrets: false });
1262
- const data = isFileSection(adapter.id)
1263
- ? section.data
1264
- : this.scanner.scanAndRedact(section.data).sanitized;
1265
- mergedSections[adapter.id] = data;
1266
- }
1267
- catch {
1268
- // 单个分区导出失败不拖垮 recordBaseline(已应用的分区数据从 subPlan 兜底)
1269
- }
1711
+ const landed = await this.exportAppliedSections(appliedIds);
1712
+ if (landed === null) {
1713
+ // 无法证明「基线 == 本机现状」→ 不推进基线(下一轮重新合并收敛)+ 明确告警,绝不写半个基线
1714
+ return {
1715
+ ok: true,
1716
+ applied: appliedIds,
1717
+ skipped: subPlan.items.filter((i) => i.kind === 'Skip').map((i) => i.id),
1718
+ restoreId: snapshot.id,
1719
+ rolledBack: false,
1720
+ warnings: [...(result.warnings ?? []), this.msg('sync.baselineRexportFailed', { sections: appliedIds.join(', ') })],
1721
+ failed: [],
1722
+ ineffective: result.ineffective ?? [],
1723
+ needsRestart: result.needsRestart,
1724
+ result,
1725
+ };
1270
1726
  }
1271
1727
  const snapshotId = this.snapshotIdFn();
1272
- await this.recordBaseline(snapshotId, mergedSections, undefined, {
1728
+ await this.recordBaseline(snapshotId, landed, undefined, {
1273
1729
  remoteSnapshotId: opts.remoteSnapshotId ?? '',
1274
1730
  });
1275
1731
  return {