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
@@ -54,11 +54,21 @@ export const zh = {
54
54
  'workdir.missing': '尚未创建(首次同步时克隆)',
55
55
  'workdir.unknown': '读不出工作副本状态',
56
56
  'workdir.relink': '重指向仓库地址',
57
+ // issue #104:换仓库 / 远端历史被重写后,本地与远端两条历史不相交,pull --ff-only 永远失败。
58
+ // adopt = 把远端历史合并进来(不改写本地提交,可 revert;冲突自动撤销),是 reclone 的非破坏替代。
59
+ 'workdir.adopt': '采纳远端历史',
60
+ 'workdir.adoptHint': '把远端的提交历史合并进本机工作副本(用于「换了仓库或远端历史被重写」后无法合并的情况)。不会改写本地提交,合并后可 git revert 撤销;一旦冲突会自动撤销这次合并,工作副本回到操作前。',
57
61
  'workdir.reclone': '重新克隆工作副本',
58
62
  'workdir.recloneConfirmTitle': '重新克隆工作副本?',
59
63
  'workdir.recloneConfirmMessage': '将删除本机工作副本目录并从远端重新克隆。本机配置与远端快照都不受影响,但工作副本里尚未推送的本地提交会被丢弃 —— 需要时请先在同步页执行一次推送。',
60
64
  'workdir.recloneConfirm': '重新克隆',
65
+ // issue #104 建议 3:本地积压与远端快照数(只显示数字;告警文案见下两条)
66
+ 'workdir.ahead': '未上传的本地提交',
67
+ 'workdir.remoteSnapshots': '远端快照数',
68
+ 'workdir.backlogAhead': '有本地提交还没上传到远端',
69
+ 'workdir.backlogNeverSynced': '本地领先,但远端从未收到过快照 —— 这条通道可能一直没在真正备份',
61
70
  'toast.workdirRelinked': '工作副本已重指向当前仓库地址',
71
+ 'toast.workdirAdopted': '已采纳远端历史(本地提交未改写,已生成一次合并提交)',
62
72
  'toast.workdirRecloned': '工作副本已重新克隆',
63
73
  'toast.workdirResetFailed': '重建工作副本失败',
64
74
  'syncStatus.lastSync': '上次同步',
@@ -137,7 +147,11 @@ export const zh = {
137
147
  // 一键同步(方案 A:拉取 → 逐项确认 → apply-items)
138
148
  'syncflow.title': '一键同步',
139
149
  'syncflow.button': '一键同步',
150
+ // 方案 A:有基线且三方合并全部 skip = 远端与本机一致,不必同步
151
+ 'syncflow.upToDate': '已是最新,无需同步',
140
152
  'syncflow.syncing': '正在同步…',
153
+ // 阶段文案:一键同步先拉远端快照做只读合并预览(最慢的一步),必须有可见反馈
154
+ 'syncflow.checkingRemote': '正在检查远端变更…',
141
155
  'syncflow.syncFailed': '同步失败',
142
156
  'syncflow.snapshotOption': '({date} · {count} 分区)',
143
157
  'syncflow.selectSnapshot': '选择历史快照',
@@ -237,7 +251,17 @@ export const zh = {
237
251
  'autosync.description': '开启后,DSH 在后台保持配置一致:本地配置有改动时自动上传,检测到远端有新快照时才自动拉取合并;定时器仅作兜底轮询,无变化不重复动作。仅在无冲突且无需人工干预时才自动写入本地。',
238
252
  'autosync.enable': '启用自动同步',
239
253
  'autosync.interval': '兜底轮询间隔',
240
- 'autosync.intervalHint': '间隔到点 / DSH 启动时检查远端是否有新快照,有才拉取合并(不上传本地);本地配置改动会触发上传。',
254
+ 'autosync.intervalHint': '兜底检查节奏:间隔到点 / DSH 启动时检查远端是否有新快照,有才拉取合并。启用「本地改动即时同步」后,保存配置会立刻触发一轮,不必等这里。',
255
+ 'autosync.realtime': '本地改动即时同步',
256
+ 'autosync.realtimeHint': '监听本机可同步的配置(settings.yaml、cordis.patch.yml、skills、profiles 下的插件清单等),保存后立即上传;关闭则只在兜底间隔到点时检查。',
257
+ 'autosync.autoApplyTier': '自动应用范围',
258
+ 'autosync.autoApplyTierHint': '决定远端改动能自动写回本机的范围:「仅低风险分区」= settings / ui / providers / prompts / skills 等;「含 plugins 与 mcp」= 连中风险分区也自动落盘(缺省)。凭据、插件数据类分区永不自动写入。',
259
+ 'autosync.tierLow': '仅低风险分区',
260
+ 'autosync.tierMedium': '含 plugins 与 mcp(推荐)',
261
+ 'autosync.conflictPolicy': '双侧都改时',
262
+ 'autosync.conflictPolicyHint': '本机与远端都改了同一分区时:「保留本机并推上去」= 本机版本成为新的远端最新(被覆盖的远端快照仍在历史里,可回滚);「保留冲突待我确认」= 上下行都停下,等你在同步页裁决。',
263
+ 'autosync.conflictLocal': '保留本机并推上去',
264
+ 'autosync.conflictAsk': '保留冲突待我确认',
241
265
  'autosync.interval5m': '5 分钟',
242
266
  'autosync.interval15m': '15 分钟',
243
267
  'autosync.interval30m': '30 分钟',
@@ -283,6 +307,7 @@ export const zh = {
283
307
  'history.autosyncReasonEncrypted': '远端快照已加密,自动同步跳过(请手动同步)',
284
308
  'history.autosyncReasonMutationLocked': '环境锁被占用(另一项任务进行中,或存在需回收的残留锁)',
285
309
  'history.autosyncReasonUnknown': '未知',
310
+ 'history.autosyncReasonConflictKeptLocal': '已按策略保留本机并覆盖远端',
286
311
  'history.autosyncReasonUpToDate': '无变化(本地与远端一致)',
287
312
  'history.autosyncReasonUnchanged': '远端无新快照,无需合并',
288
313
  'history.autosyncConflicted': '跳过冲突分区:{sections}',
@@ -401,6 +426,97 @@ export const zh = {
401
426
  'cloud.apiBaseUrlInvalid': 'API 根地址必须是 http(s) 且不含用户名/密码',
402
427
  'cloud.secretWriterRequired': '宿主未提供凭据写入通道,无法保存密钥',
403
428
  'cloud.credentialsWriteFailed': '密钥写入 DSH 凭据失败(配置未保存)',
429
+ // ---------- T-ui-merge:远程合并裁决(**分区级**裁决;冲突明细只读展示) ----------
430
+ 'merge.title': '远程合并',
431
+ 'merge.subtitle': '读取远端快照做三方合并(本地 / 远端 / 共同基准),列出需要你裁决的分区;预览阶段零写入。',
432
+ 'merge.entry': '检查远程合并',
433
+ 'merge.entryHint': '只读:先看远端与本地差在哪、哪些分区需要裁决。不会写任何内容。',
434
+ 'merge.loading': '正在读取远端快照并合并…',
435
+ 'merge.reload': '重新预览',
436
+ 'merge.empty': '没有需要合并的内容',
437
+ 'merge.emptyHint': '远端与本地一致,或当前没有分区参与同步。',
438
+ 'merge.noAncestor': '没有共同基准快照:合并退化为两方比较,两侧不一致的分区一律按冲突处理,需要你逐个裁决。',
439
+ 'merge.summary.auto': '可自动应用',
440
+ 'merge.summary.review': '待裁决',
441
+ 'merge.summary.conflicts': '冲突',
442
+ 'merge.summary.skipped': '无变化',
443
+ 'merge.section.title': '分区裁决',
444
+ 'merge.scopeNotice': '冲突按分区整体裁决(本版不做逐条裁决)。',
445
+ 'merge.skipNotice': '跳过 ≠ 已解决:跳过的冲突不会推进同步基线,下次同步仍会出现。',
446
+ 'merge.decision.useRemote': '采用远端',
447
+ 'merge.decision.keepLocal': '保留本地',
448
+ 'merge.decision.merge': '自动合并',
449
+ 'merge.decision.skip': '无变化',
450
+ 'merge.decision.conflict': '需裁决',
451
+ 'merge.kind.key': '键',
452
+ 'merge.kind.file': '文件',
453
+ 'merge.reason.bothModified': '两侧都改动了同一位置',
454
+ 'merge.reason.modifyDelete': '一侧改动、另一侧删除',
455
+ 'merge.reason.deleteModify': '一侧删除、另一侧改动',
456
+ 'merge.reason.noAncestor': '没有共同基准,无法判断谁改动了',
457
+ 'merge.reason.binary': '二进制内容无法安全合并',
458
+ 'merge.reason.format': '内容格式不支持自动合并',
459
+ 'merge.reason.array': '数组无法按位置合并',
460
+ 'merge.reason.section': '整个分区冲突',
461
+ 'merge.conflictCount': '{n} 处冲突',
462
+ 'merge.autoMergedCount': '已自动并入 {n} 处改动',
463
+ 'merge.col.path': '冲突位置',
464
+ 'merge.col.local': '本地',
465
+ 'merge.col.remote': '远端',
466
+ 'merge.col.ancestor': '基准',
467
+ 'merge.preview.empty': '(空)',
468
+ 'merge.action.useLocal': '采用本地',
469
+ 'merge.action.useRemote': '采用远端',
470
+ 'merge.action.useMerged': '采用自动合并',
471
+ 'merge.action.useMergedDisabled': '该分区没有可用的自动合并结果(两侧改动重叠),只能采用一侧或跳过。',
472
+ 'merge.action.skip': '跳过',
473
+ 'merge.action.current': '当前裁决',
474
+ 'merge.bulk.keepLocal': '全部采用本地',
475
+ 'merge.bulk.useRemote': '全部采用远端',
476
+ 'merge.bulk.useMerged': '全部采用自动合并',
477
+ 'merge.bulk.autoOnly': '仅处理无冲突项',
478
+ 'merge.bulk.leavePending': '冲突留到下次',
479
+ 'merge.bulk.hint': '「全部采用自动合并」只对真有自动合并结果的分区生效;「冲突留到下次」把需要你裁决的分区全部移出本次操作(不写入、不推进基线),下次同步它们还会出现。',
480
+ 'merge.bulk.title': '批量',
481
+ 'merge.apply': '执行合并',
482
+ 'merge.applying': '执行中…',
483
+ 'merge.apply.nothing': '当前没有会产生写入的裁决:所有待裁决分区都停在「跳过」。',
484
+ 'merge.apply.summary': '已裁决 {decided} / 待裁决 {needs} 个分区',
485
+ 'merge.apply.skips': '其中 {n} 个是「跳过」(不会推进同步基线)',
486
+ 'merge.apply.undecided': '还有 {n} 个分区未裁决(未裁决不会被执行,等同跳过)。',
487
+ 'merge.apply.pendingHint': '还有 {n} 个分区待裁决:本次不会应用它们,下次同步仍会出现。',
488
+ 'merge.apply.nothingPending': '所有分区都没有变化,本次不需要执行合并。',
489
+ 'merge.apply.willApply': '本次会应用 {n} 个分区',
490
+ 'merge.result.title': '合并结果',
491
+ 'merge.result.applied': '已应用分区',
492
+ 'merge.result.skipped': '已跳过',
493
+ 'merge.result.warnings': '警告',
494
+ 'merge.result.restoreId': '回滚点',
495
+ 'merge.result.rolledBack': '已整体回滚',
496
+ 'merge.result.rollbackIncomplete': '整体回滚不完整:有项未恢复(见警告)',
497
+ 'merge.result.none': '没有分区被改动',
498
+ 'merge.result.pending': '仍有 {n} 个分区待裁决(跳过 ≠ 已解决,下次同步仍会出现)',
499
+ 'merge.result.rollback': '一键回滚',
500
+ 'merge.result.rollingBack': '回滚中…',
501
+ 'merge.result.rolledBackTo': '已回滚到 {id}',
502
+ 'merge.result.failed': '{n} 项失败',
503
+ 'merge.result.ineffective': '{n} 项未生效',
504
+ 'merge.error.previewFailed': '合并预览失败',
505
+ 'merge.toast.applyFailed': '执行合并失败',
506
+ 'merge.toast.applied': '合并已执行',
507
+ 'merge.toast.rollbackFailed': '回滚失败',
508
+ 'merge.toast.previewFailed': '读取远程合并预览失败',
509
+ // ---- cloud-sync 借鉴项 ②:测试连接(只读连通性自检)----
510
+ 'probe.run': '测试连接',
511
+ 'probe.running': '正在测试连接…',
512
+ 'probe.hint': '只读自检:凭据 / 端点 / 目标容器 / 目录可达。WebDAV 通道可能幂等创建上级目录(那正是它要修的故障);不会推送或改动任何快照。',
513
+ 'probe.createdCollections': '已在远端自动创建目录:{list}',
514
+ 'probe.snapshotCount': '远端现有快照:{count} 份',
515
+ // 「不支持自检」= 能力边界(不是故障):中性提示 + 一句解释,绝不显示「操作失败」
516
+ 'probe.unsupported': '该通道不提供连通性自检',
517
+ 'probe.unsupportedHint': '这是能力边界,不是故障:该通道的同步功能不受影响,只是没有自检这项能力。',
518
+ // ---- cloud-sync 借鉴项 ⑥:快照的推送方设备(旧快照没有该字段 → 不显示设备行)----
519
+ 'syncflow.snapshotDevice': '由 {device} 推送',
404
520
  } as const;
405
521
 
406
522
  export const en: Record<keyof typeof zh, string> = {
@@ -449,11 +565,22 @@ export const en: Record<keyof typeof zh, string> = {
449
565
  'workdir.missing': 'Not created yet (cloned on first sync)',
450
566
  'workdir.unknown': 'Working-copy state unreadable',
451
567
  'workdir.relink': 'Re-point repository address',
568
+ // issue #104: after a repository swap / rewritten remote history, local and remote histories are
569
+ // unrelated, so pull --ff-only can never succeed. adopt merges the remote history in (no local commit
570
+ // is rewritten, revertible; a conflict aborts the merge) — the non-destructive alternative to reclone.
571
+ 'workdir.adopt': 'Adopt remote history',
572
+ 'workdir.adoptHint': 'Merges the remote commit history into this local working copy (for the case where the repository was swapped or the remote history was rewritten and pulls can never merge). No local commit is rewritten and the merge can be undone with git revert; if there are conflicts the merge is aborted and the working copy returns to its previous state.',
452
573
  'workdir.reclone': 'Clone working copy again',
453
574
  'workdir.recloneConfirmTitle': 'Clone the working copy again?',
454
575
  'workdir.recloneConfirmMessage': 'The local working-copy directory will be deleted and cloned again from the remote. Your local config and the remote snapshots are unaffected, but local commits that were never pushed will be lost — push once from the sync page first if you need them.',
455
576
  'workdir.recloneConfirm': 'Clone again',
577
+ // issue #104 suggestion 3: local backlog and remote snapshot count
578
+ 'workdir.ahead': 'Local commits not pushed',
579
+ 'workdir.remoteSnapshots': 'Remote snapshots',
580
+ 'workdir.backlogAhead': 'Local commits are waiting to be pushed',
581
+ 'workdir.backlogNeverSynced': 'Local is ahead but the remote has never received a snapshot - this channel may not be backing up at all',
456
582
  'toast.workdirRelinked': 'Working copy now points at the configured repository address',
583
+ 'toast.workdirAdopted': 'Remote history adopted (no local commit was rewritten; one merge commit was created)',
457
584
  'toast.workdirRecloned': 'Working copy cloned again',
458
585
  'toast.workdirResetFailed': 'Rebuilding the working copy failed',
459
586
  'syncStatus.lastSync': 'Last sync',
@@ -525,7 +652,10 @@ export const en: Record<keyof typeof zh, string> = {
525
652
  'warnings.title': 'Section Warnings',
526
653
  'syncflow.title': 'One-Click Sync',
527
654
  'syncflow.button': 'One-click sync',
655
+ // Plan A: with a baseline, an all-skip three-way merge means local and remote already match
656
+ 'syncflow.upToDate': 'Already up to date; nothing to sync',
528
657
  'syncflow.syncing': 'Syncing…',
658
+ 'syncflow.checkingRemote': 'Checking remote changes…',
529
659
  'syncflow.syncFailed': 'Sync failed',
530
660
  'syncflow.snapshotOption': '({date} · {count} sections)',
531
661
  'syncflow.selectSnapshot': 'Select snapshot',
@@ -624,7 +754,17 @@ export const en: Record<keyof typeof zh, string> = {
624
754
  'autosync.description': 'When enabled, DSH keeps config in sync in the background: uploads locally-changed config automatically, pulls and merges only when it detects a new remote snapshot; the timer is just a fallback poll and no-ops when nothing changed. Local writes happen only when there are no conflicts or manual-decision items.',
625
755
  'autosync.enable': 'Enable auto sync',
626
756
  'autosync.interval': 'Fallback poll interval',
627
- 'autosync.intervalHint': 'On interval / DSH startup, checks for a new remote snapshot and pulls+merges only if one exists (no upload); local config changes trigger an upload.',
757
+ 'autosync.intervalHint': 'Fallback cadence: on interval / DSH startup it checks whether the remote has a newer snapshot and only then pulls and merges (no upload). With "Sync local changes immediately" on, saving a config triggers a run right away.',
758
+ 'autosync.realtime': 'Sync local changes immediately',
759
+ 'autosync.realtimeHint': 'Watches the syncable local config (settings.yaml, cordis.patch.yml, skills, plugin manifests under profiles…) and uploads right after a save; turn it off to only check on the fallback interval.',
760
+ 'autosync.autoApplyTier': 'Auto-apply scope',
761
+ 'autosync.autoApplyTierHint': 'How much of the remote side may be written back automatically: "Low-risk sections only" covers settings / ui / providers / prompts / skills; "Include plugins and MCP" (default) also applies the medium-risk sections. Credential and plugin-data sections are never auto-applied.',
762
+ 'autosync.tierLow': 'Low-risk sections only',
763
+ 'autosync.tierMedium': 'Include plugins and MCP (recommended)',
764
+ 'autosync.conflictPolicy': 'When both sides changed',
765
+ 'autosync.conflictPolicyHint': 'When the same section changed locally and remotely: "Keep local and push it" makes the local version the new remote latest (the replaced remote snapshot stays in history and can be rolled back); "Keep the conflict for review" stops both directions until you resolve it on the sync page.',
766
+ 'autosync.conflictLocal': 'Keep local and push it',
767
+ 'autosync.conflictAsk': 'Keep the conflict for review',
628
768
  'autosync.interval5m': '5 min',
629
769
  'autosync.interval15m': '15 min',
630
770
  'autosync.interval30m': '30 min',
@@ -668,6 +808,7 @@ export const en: Record<keyof typeof zh, string> = {
668
808
  'history.autosyncReasonEncrypted': 'The remote snapshot is encrypted, so auto sync skipped it (sync manually)',
669
809
  'history.autosyncReasonMutationLocked': 'The environment lock is held (another task is running, or a stale lock needs reclaiming)',
670
810
  'history.autosyncReasonUnknown': 'Unknown',
811
+ 'history.autosyncReasonConflictKeptLocal': 'Kept the local version and overwrote the remote as configured',
671
812
  'history.autosyncReasonUpToDate': 'No changes (local and remote match)',
672
813
  'history.autosyncReasonUnchanged': 'No new remote snapshot to merge',
673
814
  'history.autosyncConflicted': 'Skipped conflict sections: {sections}',
@@ -782,6 +923,97 @@ export const en: Record<keyof typeof zh, string> = {
782
923
  'cloud.apiBaseUrlInvalid': 'API base URL must be http(s) and contain no username/password',
783
924
  'cloud.secretWriterRequired': 'The host exposes no credential writer, so the secret cannot be saved',
784
925
  'cloud.credentialsWriteFailed': 'Writing the secret into DSH credentials failed (config was not saved)',
926
+ // ---------- T-ui-merge: remote merge decisions (section-level; conflict rows are read-only) ----------
927
+ 'merge.title': 'Remote merge',
928
+ 'merge.subtitle': 'Reads the remote snapshot and runs a three-way merge (local / remote / common ancestor), then lists the sections that need your decision. Nothing is written while previewing.',
929
+ 'merge.entry': 'Check remote merge',
930
+ 'merge.entryHint': 'Read-only: see where the remote differs from local and which sections need a decision. Nothing is written.',
931
+ 'merge.loading': 'Reading the remote snapshot and merging…',
932
+ 'merge.reload': 'Preview again',
933
+ 'merge.empty': 'Nothing to merge',
934
+ 'merge.emptyHint': 'Local and remote match, or no section participates in this sync.',
935
+ 'merge.noAncestor': 'No common ancestor snapshot: the merge degrades to a two-way comparison, so every section that differs on both sides is treated as a conflict and needs your decision.',
936
+ 'merge.summary.auto': 'Auto-applicable',
937
+ 'merge.summary.review': 'Needs decision',
938
+ 'merge.summary.conflicts': 'Conflicts',
939
+ 'merge.summary.skipped': 'Unchanged',
940
+ 'merge.section.title': 'Section decisions',
941
+ 'merge.scopeNotice': 'Conflicts are decided per section as a whole (this version has no per-item decisions).',
942
+ 'merge.skipNotice': 'Skip is not resolved: a skipped conflict does not advance the sync baseline and will come back on the next sync.',
943
+ 'merge.decision.useRemote': 'Use remote',
944
+ 'merge.decision.keepLocal': 'Keep local',
945
+ 'merge.decision.merge': 'Auto-merged',
946
+ 'merge.decision.skip': 'Unchanged',
947
+ 'merge.decision.conflict': 'Needs decision',
948
+ 'merge.kind.key': 'Key',
949
+ 'merge.kind.file': 'File',
950
+ 'merge.reason.bothModified': 'Both sides changed the same place',
951
+ 'merge.reason.modifyDelete': 'One side changed it, the other deleted it',
952
+ 'merge.reason.deleteModify': 'One side deleted it, the other changed it',
953
+ 'merge.reason.noAncestor': 'No common ancestor, so it is impossible to tell which side changed',
954
+ 'merge.reason.binary': 'Binary content cannot be merged safely',
955
+ 'merge.reason.format': 'This content format does not support auto-merge',
956
+ 'merge.reason.array': 'Arrays cannot be merged position by position',
957
+ 'merge.reason.section': 'The whole section conflicts',
958
+ 'merge.conflictCount': '{n} conflict(s)',
959
+ 'merge.autoMergedCount': '{n} change(s) merged automatically',
960
+ 'merge.col.path': 'Conflict location',
961
+ 'merge.col.local': 'Local',
962
+ 'merge.col.remote': 'Remote',
963
+ 'merge.col.ancestor': 'Ancestor',
964
+ 'merge.preview.empty': '(empty)',
965
+ 'merge.action.useLocal': 'Use local',
966
+ 'merge.action.useRemote': 'Use remote',
967
+ 'merge.action.useMerged': 'Use auto-merge',
968
+ 'merge.action.useMergedDisabled': 'This section has no usable auto-merged result (both sides changed the same place), so pick one side or skip it.',
969
+ 'merge.action.skip': 'Skip',
970
+ 'merge.action.current': 'Current decision',
971
+ 'merge.bulk.keepLocal': 'Use local for all',
972
+ 'merge.bulk.useRemote': 'Use remote for all',
973
+ 'merge.bulk.useMerged': 'Use auto-merge for all',
974
+ 'merge.bulk.autoOnly': 'Handle conflict-free sections only',
975
+ 'merge.bulk.leavePending': 'Leave conflicts for next sync',
976
+ 'merge.bulk.hint': '"Use auto-merge for all" only affects sections that really have an auto-merged result; "Leave conflicts for next sync" moves every section that needs your decision out of this run (nothing is written, the baseline does not advance) — they come back on the next sync.',
977
+ 'merge.bulk.title': 'Bulk',
978
+ 'merge.apply': 'Apply merge',
979
+ 'merge.applying': 'Applying…',
980
+ 'merge.apply.nothing': 'No decision would write anything: every section that needs a decision is still on Skip.',
981
+ 'merge.apply.summary': '{decided} of {needs} section(s) decided',
982
+ 'merge.apply.skips': '{n} of them are skipped (the sync baseline will not advance)',
983
+ 'merge.apply.undecided': '{n} section(s) still have no decision (undecided sections are not applied, which is the same as skipping).',
984
+ 'merge.apply.pendingHint': '{n} section(s) still need a decision: nothing is applied to them now, and they come back on the next sync.',
985
+ 'merge.apply.nothingPending': 'Nothing changed in any section, so there is nothing to apply here.',
986
+ 'merge.apply.willApply': '{n} section(s) will be applied now',
987
+ 'merge.result.title': 'Merge result',
988
+ 'merge.result.applied': 'Applied sections',
989
+ 'merge.result.skipped': 'Skipped',
990
+ 'merge.result.warnings': 'Warnings',
991
+ 'merge.result.restoreId': 'Restore point',
992
+ 'merge.result.rolledBack': 'Rolled back in full',
993
+ 'merge.result.rollbackIncomplete': 'Rollback was incomplete: some items were not restored (see warnings)',
994
+ 'merge.result.none': 'No section was changed',
995
+ 'merge.result.pending': '{n} section(s) still need a decision (skip is not resolved, so they will come back on the next sync)',
996
+ 'merge.result.rollback': 'Roll back',
997
+ 'merge.result.rollingBack': 'Rolling back…',
998
+ 'merge.result.rolledBackTo': 'Rolled back to {id}',
999
+ 'merge.result.failed': '{n} failed',
1000
+ 'merge.result.ineffective': '{n} had no effect',
1001
+ 'merge.error.previewFailed': 'Merge preview failed',
1002
+ 'merge.toast.applyFailed': 'Applying the merge failed',
1003
+ 'merge.toast.applied': 'Merge applied',
1004
+ 'merge.toast.rollbackFailed': 'Rollback failed',
1005
+ 'merge.toast.previewFailed': 'Failed to read the remote merge preview',
1006
+ // ---- cloud-sync borrowing ②: test connection (read-only self-check) ----
1007
+ 'probe.run': 'Test connection',
1008
+ 'probe.running': 'Testing connection…',
1009
+ 'probe.hint': 'Read-only self-check: credentials / endpoint / target container / directory reachability. The WebDAV channel may create the parent directory (that is the failure it fixes); no snapshot is pushed or changed.',
1010
+ 'probe.createdCollections': 'Created remote directories automatically: {list}',
1011
+ 'probe.snapshotCount': 'Snapshots on the remote: {count}',
1012
+ // "Unsupported" is a capability boundary, not a failure: neutral wording, never "operation failed"
1013
+ 'probe.unsupported': 'This channel does not offer a connectivity self-check',
1014
+ 'probe.unsupportedHint': 'This is a capability boundary, not a failure: syncing on this channel is unaffected — it simply has no self-check.',
1015
+ // ---- cloud-sync borrowing ⑥: device that pushed each snapshot (old snapshots have no such field, so no device line is shown) ----
1016
+ 'syncflow.snapshotDevice': 'Pushed by {device}',
785
1017
  };
786
1018
 
787
1019
  /** 字典键联合(注册处 compile-time 校验) */
@@ -11,7 +11,7 @@ import type { AutosyncStatusResponse, GithubPollResponse, SyncSectionInfo, SyncS
11
11
  import {
12
12
  autosyncIntervalMs, computeAutosyncCountdown, computeGithubLoginView, computeSyncButtons, computeSyncStatus,
13
13
  DEFAULT_SYNC_SESSIONS_LIMIT, defaultChannelSyncState, formatDateTime, formatIntervalDuration, formatLastSync,
14
- autosyncSkipDetail,
14
+ autosyncFailureMessage, autosyncSkipDetail,
15
15
  githubPollMessage, initialSyncSections, kindLabel, normalizeSessionsLimit, privateRepoHint,
16
16
  pullReportView, pushReportView, presetById, presetIdForUrl, readStoredChannel, recommendedSyncSections,
17
17
  severityLabel, summarizePullChanges, syncSectionGroups, syncSectionOptions, WEBDAV_PRESETS, writeStoredChannel,
@@ -560,3 +560,20 @@ test('issue #79:autosyncSkipDetail —— en 界面下不得出现中文(文
560
560
  assert.match(String(en), /2/)
561
561
  })
562
562
 
563
+ /* ------------------------------------------------- issue #105:卡片不得渲染上一轮的旧错误 */
564
+
565
+ test('sync-view: autosyncFailureMessage —— 只有本轮失败才渲染错误原文(issue #105)', () => {
566
+ assert.equal(
567
+ autosyncFailureMessage({ lastRunStatus: 'failed', lastRunMessage: '认证失败' }),
568
+ '认证失败',
569
+ 'failed + 有原文 → 渲染',
570
+ );
571
+ // 关键回归:同步已成功,但宿主/文件里还留着十天前的错误 —— 不得渲染
572
+ assert.equal(autosyncFailureMessage({ lastRunStatus: 'success', lastRunMessage: '十天前: 认证失败' }), null);
573
+ assert.equal(autosyncFailureMessage({ lastRunStatus: 'skipped', lastRunMessage: '十天前: 认证失败' }), null);
574
+ assert.equal(autosyncFailureMessage({ lastRunStatus: 'partial', lastRunMessage: '十天前: 认证失败' }), null);
575
+ // 空串 / 缺字段 / 从未运行 → 都不渲染
576
+ assert.equal(autosyncFailureMessage({ lastRunStatus: 'failed', lastRunMessage: '' }), null);
577
+ assert.equal(autosyncFailureMessage({ lastRunStatus: 'failed' }), null);
578
+ assert.equal(autosyncFailureMessage({}), null);
579
+ });
@@ -378,6 +378,8 @@ export interface ChannelSyncState {
378
378
  autosyncEnabled: boolean
379
379
  /** 该通道自动同步间隔(回填自 autosync) */
380
380
  autosyncInterval: AutosyncInterval
381
+ /** 本地改动即时触发(回填自 autosync;缺省 true —— 真·自动同步的开关) */
382
+ autosyncRealtime: boolean
381
383
  }
382
384
 
383
385
  /** 四通道的缺省状态表(`Record<SyncChannel, ChannelSyncState>`;组件 state 与快照复制共用)。 */
@@ -407,6 +409,7 @@ export function defaultChannelSyncState(): ChannelSyncState {
407
409
  autosync: null,
408
410
  autosyncEnabled: false,
409
411
  autosyncInterval: '30m',
412
+ autosyncRealtime: true,
410
413
  }
411
414
  }
412
415
 
@@ -1090,6 +1093,23 @@ export function autosyncSkipDetail(
1090
1093
  return label
1091
1094
  }
1092
1095
 
1096
+ /**
1097
+ * 卡片该不该渲染「上一轮失败原因」(issue #105)。
1098
+ *
1099
+ * 此前卡片只看 lastRunMessage 非空就渲染,而该字段语义历史上是混用的:既可能是本轮错误原文,
1100
+ * 也可能残留十天前那次失败、或被「连续失败通知」文案占用 —— 于是认证早已修好、同步也成功过,
1101
+ * 卡片上仍挂着"认证失败",用户据此去查一个不存在的故障。
1102
+ *
1103
+ * 渲染权只认一个判据:**本轮状态是 failed**。success/skipped/partial 一律不渲染旧错误。
1104
+ * (宿主侧同一判据 = autosync-config.lastRunFailureMessage,此处是渲染层守门,双保险。)
1105
+ */
1106
+ export function autosyncFailureMessage(
1107
+ status: Pick<AutosyncStatusResponse, 'lastRunStatus' | 'lastRunMessage'>,
1108
+ ): string | null {
1109
+ if (status.lastRunStatus !== 'failed') return null
1110
+ const message = status.lastRunMessage
1111
+ return message === undefined || message === '' ? null : message
1112
+ }
1093
1113
  /** 自动同步状态行的可读文案(未运行 / 上次状态 / 连续失败计数)。 */
1094
1114
  export function autosyncStatusText(status: AutosyncStatusResponse, t: UiT): string {
1095
1115
  if (status.lastRunAt === undefined || status.lastRunAt === '' || status.lastRunStatus === undefined) {
@@ -30,6 +30,7 @@ import os from 'node:os';
30
30
  import path from 'node:path';
31
31
 
32
32
  import { FileSnapshotStore, createSnapshot, normalizeDshVersion, resolveFileTarget, verifySnapshot } from './backup.ts';
33
+ import { rollback } from './rollback.ts';
33
34
  import { makeContext, MemSnapshotStore } from '../adapters/test-helpers.ts';
34
35
  import type { ImportPlan, Snapshot, SnapshotStore } from './types.ts';
35
36
  import type { SectionId } from '../schema/types.ts';
@@ -420,3 +421,43 @@ test('保留清理:快照目录名与内部 id 不一致时跳过并留痕(
420
421
  assert.ok(remaining.includes(second.id), '新建的快照当然还在');
421
422
  assert.ok(warned.some((m) => m.includes('不一致')), '跳过必须留痕(绝不静默):' + JSON.stringify(warned));
422
423
  });
424
+
425
+ /* ---------------- 方案 C:MCP 连接器存储文件(整文件快照 / 回滚) ---------------- */
426
+
427
+ test('mcp 连接器:storages/mcp_connector.json 进快照为整文件条目,回滚按原字节还原', async () => {
428
+ const { ctx } = countingCtx();
429
+ const rel = 'storages/mcp_connector.json';
430
+ // 用引擎真正会读的那个路径播种(file 类目标由 resolveFileTarget 拼绝对路径)
431
+ const abs = resolveFileTarget(ctx, 'mcp', rel);
432
+ const original = JSON.stringify({
433
+ unit: { name: 'mcp_connector', version: 1 },
434
+ global: null,
435
+ tables: { connections: { 'a-a': { key: 'a-a', serverName: 'a' } }, grants: { g1: { key: 'g1' } }, tool_catalog: { t: 1 } },
436
+ });
437
+ await ctx.fs.writeFile(abs, Buffer.from(original, 'utf8'));
438
+
439
+ const plan: ImportPlan = {
440
+ items: [{
441
+ id: 'mcp-connector:a-a', kind: 'Create' as const, adapter: 'mcp' as const,
442
+ description: 'a', severity: 'info' as const, target: { adapter: 'mcp' as const, ref: rel },
443
+ }],
444
+ globalStrategy: 'merge', pathMappings: [], missingSecrets: [], needsRestart: false,
445
+ estimatedActions: {} as Record<SectionId, number>,
446
+ };
447
+ const store = new MemSnapshotStore();
448
+ const snapshot = await createSnapshot({ ctx, plan, sourceZip: 'unit.zip', store, adapters: [] });
449
+
450
+ const entry = snapshot.entries.find((e) => e.adapter === 'mcp' && e.ref === rel);
451
+ assert.equal(entry?.kind, 'file', '连接器存储必须走整文件快照(patchLine 语义撤不回它)');
452
+ assert.equal(entry?.existed, true);
453
+ assert.ok(entry?.copiedTo, '必须真的拷出一份 blob,否则回滚无字节可写回');
454
+ const blob = new TextDecoder().decode(await store.readBlob(snapshot.id, entry!.copiedTo!));
455
+ assert.equal(blob, original, '快照必须逐字节含全部表(grants/tool_catalog 不能被裁掉)');
456
+
457
+ // 模拟导入改写(只留 connections)→ 回滚必须回到原字节
458
+ await ctx.fs.writeFile(abs, Buffer.from('{"tables":{"connections":{}}}', 'utf8'));
459
+ const report = await rollback({ ctx, snapshot, store });
460
+ assert.equal(report.full, true, JSON.stringify(report.failed));
461
+ assert.deepEqual(report.restored, ['mcp:' + rel], '回滚必须报告真正被还原的那一条');
462
+ assert.equal(new TextDecoder().decode(await ctx.fs.readFile(abs)), original);
463
+ });
@@ -16,6 +16,7 @@ import { normalizePath } from '../utils/paths.ts';
16
16
  import { atomicWriteFile } from '../utils/atomic-write.ts';
17
17
  import { SECTION_IDS, isFileSection } from '../schema/config.ts';
18
18
  import { isPatchLayerRel, PROFILE_PATCH_FILENAME, USER_PATCH_FILE } from './patch-layers.ts';
19
+ import { MCP_CONNECTOR_STORE_REL } from './mcp-connector-store.ts';
19
20
  import type { SectionId } from '../schema/types.ts';
20
21
  import type {
21
22
  ConfigAdapter, FileSystemFacade, HostContext, HostFileBackup, ImportPlan, PlanItem, Snapshot,
@@ -346,6 +347,25 @@ async function engineSnapshotEntry(ctx: HostContext, target: SnapshotTarget): Pr
346
347
  copiedTo: `blobs/${crypto.randomUUID()}`,
347
348
  };
348
349
  }
350
+ // mcp 分区的第二个数据源(连接器插件的 storage domain 文件):整文件快照。
351
+ // 没有这条分支时,连接器导入的写入撤不回来(该文件不在 patch 层里,走 patchLine 语义会
352
+ // 变成「读过一行 patch」→ 回滚时不还原文件 → 用户的 4 个 MCP 连接被永久改掉)。
353
+ if (target.adapter === 'mcp' && target.ref === MCP_CONNECTOR_STORE_REL) {
354
+ const fsFacade = fsForSection(ctx, target.adapter);
355
+ const abs = resolveFileTarget(ctx, target.adapter, target.ref);
356
+ if (!(await fsFacade.exists(abs))) {
357
+ return { kind: 'file', adapter: target.adapter, ref: target.ref, before: null, existed: false };
358
+ }
359
+ const data = await fsFacade.readFile(abs);
360
+ return {
361
+ kind: 'file',
362
+ adapter: target.adapter,
363
+ ref: target.ref,
364
+ before: { contentHash: sha256Hex(data) },
365
+ existed: true,
366
+ copiedTo: `blobs/${crypto.randomUUID()}`,
367
+ };
368
+ }
349
369
  // patchLine:从**该行所在的 patch 层**读取原行(issue #71)。
350
370
  // target.file 缺省 = 用户层(旧调用方 / 旧计划项),只接受合法层路径,绝不把任意字符串
351
371
  // 当文件名传给宿主门面(那会抛「仅支持管理 …」)。