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
@@ -12,6 +12,12 @@
12
12
  * 互不干扰;同一时刻至多执行一个通道的 runOnce(runs.register('autosync') 全局防重,
13
13
  * 避免两个引擎并发写本地配置),另一通道的定时触发在本轮结束后自然补跑。
14
14
  *
15
+ * 「真·自动」(VS Code 式)两条即时通道(realtime=true 时生效):
16
+ * - **本地改动即时上行**:SyncWatcher 监听本机可同步分区(watch-scope.ts 的清单,已排除
17
+ * sessions/、sync-autosync.json 等自写回声路径)→ 去抖后 trigger(channel,'local-change');
18
+ * - **远端改动即时下行**:每 remoteCheckMs(缺省 60s,可 15s–1h)廉价探测一次远端是否有新快照
19
+ * → trigger(channel,'remote-poll')。定时器 interval 只作兜底。
20
+ *
15
21
  * 核心逻辑(runOnce):
16
22
  * - 读该通道配置;若 !enabled → return
17
23
  * - runs.register('autosync') 防重复;同 kind running → 跳过
@@ -19,11 +25,16 @@
19
25
  * → 记 skipped(未配置) → return
20
26
  * - Phase A: engine.merge() 三方合并 → 判定 needsReview(冲突/缺失依赖/Install/Error)
21
27
  * - 冲突 → 只挡**下行**:跳过 Phase B(绝不写本地)+ 记 conflictedSections[],
22
- * 但**继续**走到 Phase C 做纯上行导出(issue #79:远端冲突不该连坐「把本机配置推上去当备份」)
28
+ * 但**继续**走到 Phase C(issue #79:远端冲突不该连坐「把本机配置推上去当备份」)
23
29
  * - Phase B: 无冲突 → 按 risk.ts 的 classifyMergePlan 分流(audit-sync sync-F5):
24
- * 低风险项自动应用;medium/high 项**不自动应用**并进 conflictedSections(待人工确认,绝不静默)
30
+ * 低风险自动应用;medium 仅当 autoApplyTier='medium'(缺省)自动应用;
31
+ * high/未注册/冲突项**不自动应用**并进 conflictedSections(待人工确认,绝不静默)
25
32
  * → engine.applyMergePlan(autoApply) 写入本地
26
- * - Phase C: 完整双向 → engine.push() 上传(本轮有冲突且推送成功 → 状态 partial,不谎报 success)
33
+ * - Phase C: 完整双向 → engine.push() 上传。两处策略:
34
+ * ① 无冲突且推送被远端推进挡下 → skipped/partial + skipReason='conflict'(等人工);
35
+ * ② conflictPolicy='local'(缺省)且本轮有冲突 → **不写本地**,改 engine.push({force:true})
36
+ * 把本机版本推成新的远端最新(被覆盖的远端旧版本仍在远端历史快照里可回滚),
37
+ * 状态记 partial + skipReason='conflict-kept-local'(绝不谎报 success)。
27
38
  * - 收尾:写该通道 autosync-config(lastRunAt, lastRunStatus, consecutiveFailures,
28
39
  * lastRunHistoryId, lastRunMessage/跳过原因 —— 每轮覆盖上一轮,绝不残留旧错误)
29
40
  *
@@ -39,18 +50,25 @@ import type { SectionId } from '../schema/types.ts';
39
50
  import type { RunRegistry } from '../core/run-registry.ts';
40
51
  import type { JournalRunContext } from '../core/phase3-host.ts';
41
52
  import type { SyncEngine } from './sync-engine.ts';
53
+ import type { SyncSnapshotMeta } from './transport.ts';
42
54
  import type { MutationLockPort, MutationLockContext } from '../utils/env-lock.ts';
43
55
  import { withMutationLock, LOCK_BLOCK_MESSAGE } from '../utils/env-lock.ts';
44
56
  import { engineMsg } from './messages.ts';
45
- import { readAutosyncConfig, writeAutosyncConfig } from './autosync-config.ts';
57
+ import {
58
+ readAutosyncConfig, writeAutosyncConfig, clampRemoteCheckMs,
59
+ DEFAULT_AUTOSYNC_REALTIME, DEFAULT_AUTOSYNC_REMOTE_CHECK_MS, DEFAULT_AUTOSYNC_AUTO_APPLY_TIER,
60
+ DEFAULT_AUTOSYNC_CONFLICT_POLICY,
61
+ } from './autosync-config.ts';
46
62
  import type { AutosyncConfig, AutosyncInterval } from './autosync-config.ts';
47
63
  import { readSyncConfigFor, isGitConfig, isWebDavConfig, isS3Config, isGistConfig, SYNC_CHANNELS } from './sync-config.ts';
48
64
  import type { SyncConfig, SyncTransportType } from './sync-config.ts';
49
- import { readSyncHistory, appendAutosyncEntry } from './sync-history.ts';
65
+ import { readSyncHistory, appendAutosyncEntry, markAutosyncNotified } from './sync-history.ts';
50
66
  import { classifyMergePlan } from './risk.ts';
51
67
  import type { AutosyncHistoryEntry } from './sync-history.ts';
52
68
  import type { MergePlan } from './merge.ts';
53
69
  import type { SyncApplyPlan } from './risk.ts';
70
+ import { SyncWatcher } from './sync-watcher.ts';
71
+ import { buildWatchTargets } from './watch-scope.ts';
54
72
 
55
73
  /** 间隔 → ms 换算(§4.3) */
56
74
  export function intervalToMs(interval: AutosyncInterval): number {
@@ -95,6 +113,26 @@ export interface AutosyncRunResult {
95
113
  consecutiveFailures: number;
96
114
  }
97
115
 
116
+ /**
117
+ * runOnce 的触发语义(只影响「做什么」的两个分支,不改变同步流程本身)。
118
+ * reason 只进日志,用来说明这一轮是被谁叫醒的(排障时能区分「定时兜底」与「真有事发生」)。
119
+ */
120
+ export interface AutosyncRunOptions {
121
+ /** true = 启动触发变体:只做下行合并,不上传。 */
122
+ startup?: boolean;
123
+ /** 触发来源:interval / remote-poll / local-change / manual / startup / enable-toggle。 */
124
+ reason?: string;
125
+ }
126
+
127
+ /** watcher 工厂入参(缺省实现 = 真实 SyncWatcher;测试注入内存实现)。 */
128
+ export interface WatcherFactoryOptions {
129
+ homeDir: string;
130
+ profile: string | undefined;
131
+ log: Logger;
132
+ /** 一批本地改动落地(已去抖)→ 交给调度器触发一轮同步。 */
133
+ onFlush: (changed: readonly string[]) => void;
134
+ }
135
+
98
136
  export interface AutoSyncSchedulerOptions {
99
137
  syncDir: string;
100
138
  host: { log: Logger };
@@ -119,8 +157,9 @@ export interface AutoSyncSchedulerOptions {
119
157
  /**
120
158
  * 远端新快照检测(§3.2「下载=检测到远端新快照才拉取」)。
121
159
  * 缺省:若 engine 实现了 hasNewRemoteSnapshot() 则调用;否则视为 true(保持旧行为=每次拉取)。
160
+ * `listed` = 本轮已取到的远端列表(查加密快照时顺手拿到)—— 传下去就不必再 list 一次。
122
161
  */
123
- detectRemoteNew?: (engine: SyncEngine) => Promise<boolean>;
162
+ detectRemoteNew?: (engine: SyncEngine, listed?: readonly SyncSnapshotMeta[]) => Promise<boolean>;
124
163
  /**
125
164
  * 本地配置变化检测(§3.1「上传=本地改动才推」)。
126
165
  * 缺省:若 engine 实现了 hasLocalChanges() 则调用;否则视为 true(保持旧行为=每次都推)。
@@ -144,6 +183,17 @@ export interface AutoSyncSchedulerOptions {
144
183
  fn: (ctx?: JournalRunContext) => Promise<unknown>;
145
184
  }): Promise<{ operationId: string; result: unknown }>;
146
185
  };
186
+ /**
187
+ * 家目录(= DSH 的 $DSH_HOME)。提供时才启用「本地改动即时上行」的文件监听;
188
+ * 缺省(测试/未接线)→ 只靠定时器与远端轮询,行为退化为改造前。
189
+ */
190
+ homeDir?: string;
191
+ /** 当前 profile(决定监听 profiles/<profile>/ 下的组合 patch 与插件清单)。 */
192
+ profile?: string;
193
+ /** 注入 watcher 工厂(缺省 = 真实 SyncWatcher;测试可传内存实现)。 */
194
+ makeWatcher?: (opts: WatcherFactoryOptions) => SyncWatcher;
195
+ /** 触发合并窗口(ms):连续保存/连续远端探测只跑一轮。缺省 3000。 */
196
+ minTriggerGapMs?: number;
147
197
  }
148
198
 
149
199
  export class AutoSyncScheduler {
@@ -159,7 +209,7 @@ export class AutoSyncScheduler {
159
209
  private readonly appendHistoryFn: (entry: AutosyncHistoryEntry) => Promise<void>;
160
210
  private readonly setTimer: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
161
211
  private readonly clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
162
- private readonly detectRemoteNew: (engine: SyncEngine) => Promise<boolean>;
212
+ private readonly detectRemoteNew: (engine: SyncEngine, listed?: readonly SyncSnapshotMeta[]) => Promise<boolean>;
163
213
  private readonly detectLocalChange: (engine: SyncEngine) => Promise<boolean>;
164
214
  private readonly mutationLock: MutationLockPort | undefined;
165
215
  private readonly isBlocked: (() => boolean) | undefined;
@@ -169,6 +219,22 @@ export class AutoSyncScheduler {
169
219
 
170
220
  /** 每通道一个定时器(git/webdav 各自 enabled 时独立排期)。 */
171
221
  private readonly timers = new Map<SyncTransportType, ReturnType<typeof setTimeout>>();
222
+ /** realtime 通道的远端轮询定时器:与 interval 定时器**分开**,否则每次轮询都会重置兜底间隔。 */
223
+ private readonly remoteTimers = new Map<SyncTransportType, ReturnType<typeof setTimeout>>();
224
+ /** 本地改动监听(只有 enabled && realtime 的通道才挂;全关 → null)。 */
225
+ private watcher: SyncWatcher | null = null;
226
+ /** 当前需要监听的通道(setupWatchers 计算)。 */
227
+ private realtimeChannels: SyncTransportType[] = [];
228
+ /** 运行期到达的触发:本轮收尾后立刻补跑(绝不并发写本地配置,也绝不丢)。 */
229
+ private pendingTrigger: { channel: SyncTransportType; reason: string } | null = null;
230
+ /** 上次开始运行的时刻(trigger 的最小间隔合并用)。 */
231
+ private readonly lastRunAtMs = new Map<SyncTransportType, number>();
232
+ /** 因最小间隔被推迟的触发(每通道至多一个)。 */
233
+ private readonly gapTimers = new Map<SyncTransportType, ReturnType<typeof setTimeout>>();
234
+ private readonly homeDir: string | undefined;
235
+ private readonly profile: string | undefined;
236
+ private readonly makeWatcher: ((opts: WatcherFactoryOptions) => SyncWatcher) | undefined;
237
+ private readonly minTriggerGapMs: number;
172
238
  private stopped = false;
173
239
  private running = false;
174
240
 
@@ -190,6 +256,10 @@ export class AutoSyncScheduler {
190
256
  this.phase3Recovery = opts.phase3Recovery;
191
257
  this.detectRemoteNew = opts.detectRemoteNew ?? defaultDetectRemoteNew;
192
258
  this.detectLocalChange = opts.detectLocalChange ?? defaultDetectLocalChange;
259
+ this.homeDir = opts.homeDir;
260
+ this.profile = opts.profile;
261
+ this.makeWatcher = opts.makeWatcher ?? defaultMakeWatcher;
262
+ this.minTriggerGapMs = opts.minTriggerGapMs ?? 3000;
193
263
  }
194
264
 
195
265
  /** 追加一条自动同步历史(自动带上触发通道;0x 安全:绝不覆盖调用方显式 transport)。 */
@@ -204,11 +274,19 @@ export class AutoSyncScheduler {
204
274
  void this.startupRuns();
205
275
  }
206
276
 
207
- /** 停止:清全部定时器、标记不再调度;正在执行的任务允许自然结束。 */
277
+ /** 停止:清全部定时器、拆掉文件监听、标记不再调度;正在执行的任务允许自然结束。 */
208
278
  stop(): void {
209
279
  this.stopped = true;
210
280
  for (const timer of this.timers.values()) this.clearTimer(timer);
211
281
  this.timers.clear();
282
+ for (const timer of this.remoteTimers.values()) this.clearTimer(timer);
283
+ this.remoteTimers.clear();
284
+ for (const timer of this.gapTimers.values()) this.clearTimer(timer);
285
+ this.gapTimers.clear();
286
+ this.pendingTrigger = null;
287
+ this.watcher?.stop();
288
+ this.watcher = null;
289
+ this.realtimeChannels = [];
212
290
  }
213
291
 
214
292
  /** 重新加载配置(路由 POST /sync/autosync 后调用;重排所有通道定时器)。 */
@@ -218,6 +296,40 @@ export class AutoSyncScheduler {
218
296
  }
219
297
 
220
298
  private refreshTimers(): void {
299
+ this.scheduleIntervalTimers();
300
+ this.scheduleRemoteTimers();
301
+ void this.setupWatchers();
302
+ }
303
+
304
+ /**
305
+ * 外部触发一次运行(本地改动 / 远端探测 / 另一通道让位)。
306
+ *
307
+ * - 正在跑 → 记 pendingTrigger,本轮收尾后**立刻补跑**(不并发写本地配置,也绝不丢);
308
+ * - 距上次开始不足 minTriggerGapMs → 合并成一次(连续保存只跑一轮,每通道至多一个定时器)。
309
+ */
310
+ trigger(channel: SyncTransportType, reason: string = 'manual'): void {
311
+ if (this.stopped) return;
312
+ if (this.running) {
313
+ this.pendingTrigger = { channel, reason };
314
+ return;
315
+ }
316
+ const waitMs = this.minTriggerGapMs - (this.now().getTime() - (this.lastRunAtMs.get(channel) ?? 0));
317
+ if (waitMs > 0) {
318
+ if (this.gapTimers.has(channel)) return;
319
+ const timer = this.setTimer(() => {
320
+ this.gapTimers.delete(channel);
321
+ this.trigger(channel, reason);
322
+ }, waitMs);
323
+ this.gapTimers.set(channel, timer);
324
+ return;
325
+ }
326
+ void this.runOnce(channel, { reason }).catch((err) => {
327
+ this.host.log.error(`自动同步触发失败(${channel}/${reason})`, { error: err instanceof Error ? err.message : String(err) });
328
+ });
329
+ }
330
+
331
+ /** 兜底间隔定时器:每个 enabled 通道按各自 interval 排一次性定时器,触发后只重排自己。 */
332
+ private scheduleIntervalTimers(): void {
221
333
  for (const timer of this.timers.values()) this.clearTimer(timer);
222
334
  this.timers.clear();
223
335
  // 通道清单唯一来源 = SYNC_CHANNELS(此前同一数组在本文件写了两遍:漏改一处即某通道永不排期)
@@ -230,14 +342,13 @@ export class AutoSyncScheduler {
230
342
  // 一次性定时器:触发后立即从表移除,避免 reload() 清理到已失效的句柄
231
343
  this.timers.delete(channel);
232
344
  if (this.stopped) return;
233
- void this.runOnce(channel)
345
+ void this.runOnce(channel, { reason: 'interval' })
234
346
  .catch((err) => {
235
347
  this.host.log.error(`自动同步定时触发失败(${channel})`, { error: err instanceof Error ? err.message : String(err) });
236
348
  })
237
349
  .then(() => {
238
- // 本轮结束(成功 / 跳过 / 失败)后重新排定下一次;refreshTimers
239
- // 内部重读配置,若期间被关闭(enabled=false)则不再排期。
240
- if (!this.stopped) this.refreshTimers();
350
+ // 只重排 interval 定时器(远端轮询有自己的节奏,绝不互相重置)
351
+ if (!this.stopped) this.scheduleIntervalTimers();
241
352
  });
242
353
  }, ms);
243
354
  this.timers.set(channel, timer);
@@ -245,6 +356,66 @@ export class AutoSyncScheduler {
245
356
  }
246
357
  }
247
358
 
359
+ /**
360
+ * realtime 通道的远端轮询:按 remoteCheckMs(缺省 60s)排一次性定时器。
361
+ * 轮询只是「叫醒一轮 runOnce」—— 是否真的下载仍由 detectRemoteNew 判定,
362
+ * 所以哪怕每 60s 叫醒一次,无变化时也只会记一条 upToDate 而不会反复传输。
363
+ */
364
+ private scheduleRemoteTimers(): void {
365
+ for (const timer of this.remoteTimers.values()) this.clearTimer(timer);
366
+ this.remoteTimers.clear();
367
+ const channels = SYNC_CHANNELS;
368
+ for (const channel of channels) {
369
+ void this.readConfig(channel).then((cfg) => {
370
+ if (this.stopped || !cfg.enabled || !(cfg.realtime ?? DEFAULT_AUTOSYNC_REALTIME)) return;
371
+ const ms = clampRemoteCheckMs(cfg.remoteCheckMs ?? DEFAULT_AUTOSYNC_REMOTE_CHECK_MS);
372
+ const timer = this.setTimer(() => {
373
+ this.remoteTimers.delete(channel);
374
+ if (this.stopped) return;
375
+ this.trigger(channel, 'remote-poll');
376
+ // 固定节奏:轮询不等本轮跑完就排下一次(trigger 自己负责合并/排队)
377
+ if (!this.stopped) this.scheduleRemoteTimers();
378
+ }, ms);
379
+ this.remoteTimers.set(channel, timer);
380
+ }).catch(() => { /* 读配置失败静默 */ });
381
+ }
382
+ }
383
+
384
+ /** 按「enabled && realtime」重算监听集合:需要就挂、全关就拆(绝不留下空转句柄)。 */
385
+ private async setupWatchers(): Promise<void> {
386
+ const realtime: SyncTransportType[] = [];
387
+ for (const channel of SYNC_CHANNELS) {
388
+ try {
389
+ const cfg = await this.readConfig(channel);
390
+ if (cfg.enabled && (cfg.realtime ?? DEFAULT_AUTOSYNC_REALTIME)) realtime.push(channel);
391
+ } catch { /* 读配置失败 → 该通道不挂监听 */ }
392
+ }
393
+ this.realtimeChannels = realtime;
394
+ if (realtime.length === 0) {
395
+ this.watcher?.stop();
396
+ this.watcher = null;
397
+ return;
398
+ }
399
+ if (this.homeDir === undefined || this.makeWatcher === undefined) return; // 未接线(测试)→ 只靠定时器
400
+ if (this.watcher === null) {
401
+ this.watcher = this.makeWatcher({
402
+ homeDir: this.homeDir,
403
+ profile: this.profile,
404
+ log: this.host.log,
405
+ onFlush: (changed) => this.onLocalChange(changed),
406
+ });
407
+ this.watcher.start();
408
+ this.host.log.info(`自动同步已开启本地改动监听(${realtime.join('/')})`);
409
+ }
410
+ }
411
+
412
+ /** watcher 回调:任一批改动 → 让所有 realtime 通道各跑一轮(各自有变化检测,不会空转传输)。 */
413
+ private onLocalChange(changed: readonly string[]): void {
414
+ if (this.stopped || changed.length === 0) return;
415
+ this.host.log.debug(`检测到本地配置改动(${changed.length} 个路径)→ 触发自动同步`);
416
+ for (const channel of this.realtimeChannels) this.trigger(channel, 'local-change');
417
+ }
418
+
248
419
  /** 启动触发下载合并(每个 enabled 通道;受各自 startupMinIntervalMs 阈值约束)。 */
249
420
  private async startupRuns(): Promise<void> {
250
421
  // 通道清单唯一来源 = SYNC_CHANNELS(此前同一数组在本文件写了两遍:漏改一处即某通道永不排期)
@@ -257,7 +428,7 @@ export class AutoSyncScheduler {
257
428
  this.host.log.info(`自动同步启动触发跳过(${channel}):距上次运行未达阈值`);
258
429
  continue;
259
430
  }
260
- await this.runOnce(channel, { startup: true });
431
+ await this.runOnce(channel, { startup: true, reason: 'startup' });
261
432
  } catch (err) {
262
433
  this.host.log.error(`启动触发下载合并失败(${channel})`, { error: err instanceof Error ? err.message : String(err) });
263
434
  }
@@ -269,7 +440,17 @@ export class AutoSyncScheduler {
269
440
  * @param channel - 同步通道(git / webdav;各自独立的配置与运行状态)
270
441
  * @param opts.startup - true 表示启动触发变体(只做 Phase A+B,不做 Phase C push)
271
442
  */
272
- async runOnce(channel: SyncTransportType, opts: { startup?: boolean } = {}): Promise<AutosyncRunResult> {
443
+ async runOnce(channel: SyncTransportType, opts: AutosyncRunOptions = {}): Promise<AutosyncRunResult> {
444
+ const result = await this.runOnceInternal(channel, opts);
445
+ // 收尾后补跑:运行期到达的触发(watcher 连续保存 / 远端轮询 / 定时器到期)不会丢。
446
+ // 放在内部实现之外(而不是 finally 里)是为了让锁与 run 账**完全释放之后**才开始下一轮。
447
+ const pending = this.pendingTrigger;
448
+ this.pendingTrigger = null;
449
+ if (pending !== null && !this.stopped) this.trigger(pending.channel, pending.reason);
450
+ return result;
451
+ }
452
+
453
+ private async runOnceInternal(channel: SyncTransportType, opts: AutosyncRunOptions = {}): Promise<AutosyncRunResult> {
273
454
  if (this.running) return { status: 'skipped', direction: 'none', skipReason: 'running', historyId: '', consecutiveFailures: 0 };
274
455
  const cfg = await this.readConfig(channel);
275
456
  if (!cfg.enabled) {
@@ -277,6 +458,9 @@ export class AutoSyncScheduler {
277
458
  }
278
459
 
279
460
  this.running = true;
461
+ this.lastRunAtMs.set(channel, this.now().getTime());
462
+ // 本轮会写本机配置(apply)→ 暂停监听,别把自己的写入当成「用户改了配置」(否则自激循环)。
463
+ this.watcher?.suspend();
280
464
  const nowIso = this.now().toISOString();
281
465
  const historyId = `autosync-${crypto.randomUUID()}`;
282
466
 
@@ -386,8 +570,13 @@ export class AutoSyncScheduler {
386
570
  // 加密快照只通过手动推送/拉取产生与消费;自动同步仅处理普通快照。
387
571
  // list 失败宽容处理:视为无加密快照,继续正常流程(网络问题由后续链路暴露为 failed)。
388
572
  let encryptedRemote = false;
573
+ // 本轮远端列表:加密快照检查与「远端是否有新快照」共用同一次 list。
574
+ // 每次 list 都是一次真实网络往返(git = `pull --ff-only`,webdav = PROPFIND/GET),
575
+ // 缺省 60s 轮询下多 list 一次 = 一天白跑 1440 次往返。
576
+ let remoteMetas: readonly SyncSnapshotMeta[] | null = null;
389
577
  try {
390
578
  const metas = await engine.listSnapshots();
579
+ remoteMetas = metas;
391
580
  const latestMeta = metas.length > 0 ? metas[metas.length - 1]! : null;
392
581
  encryptedRemote = latestMeta !== null && latestMeta.manifest.encrypted === true;
393
582
  } catch {
@@ -410,7 +599,8 @@ export class AutoSyncScheduler {
410
599
  // - remoteNew:远端是否出现比本地祖先更新的快照 → 决定是否做下载合并(Phase A);
411
600
  // - localDirty:本地 portable 配置相对基线是否真的变了 → 决定是否上传(Phase C)。
412
601
  // 定时器只是兜底轮询;真正驱动是这两个「变化」信号。
413
- const remoteNew = await this.detectRemoteNew(engine);
602
+ // 列表拿不到(网络失败)时 remoteMetas 为 null → 检测自己再 list 一次,行为与改造前一致
603
+ const remoteNew = await this.detectRemoteNew(engine, remoteMetas ?? undefined);
414
604
  const localDirty = await this.detectLocalChange(engine);
415
605
 
416
606
  // 两端都无变化 → 什么都不做,记为 upToDate(不重复拉取/不空转)。
@@ -466,11 +656,15 @@ export class AutoSyncScheduler {
466
656
  // 风险分级(audit-sync sync-F5)照旧只在无冲突时执行。
467
657
  if (conflictedSections.length === 0) {
468
658
  // 无冲突:按 risk.ts 的风险分级契约分流(audit-sync sync-F5)。
469
- // 此前用 buildAutoApplyPlan 无条件应用全部非冲突分区 → medium/high 分区(plugins/mcp/
470
- // workspaces 等)在无用户确认时被自动写进本地,与 classifyMergePlan 的既定口径
471
- // (低风险自动应用;medium/high → 待审)分叉,「首次同步强制预览」在自动路径上也形同虚设。
659
+ // autoApplyTier(缺省 'medium')= 「真·自动同步所有改动」的开关:low 档只自动落
660
+ // 低风险分区(settings/ui/providers/prompts/skills/agentPresets/agentInstructions/self),
661
+ // medium 档连 plugins / mcp 这两个中风险分区也自动落盘;**high 档永不自动**
662
+ // (类型上限即 medium:secrets/credentialsStatus/pluginData/pluginFiles 根本不在同步面内)。
472
663
  // firstSync 恒 false:真正的首次同步没有祖先基线,merge 已把两侧差异判成 conflict → 走人工确认。
473
- const apply = classifyMergePlan(mergePlan, { firstSync: false });
664
+ const apply = classifyMergePlan(mergePlan, {
665
+ firstSync: false,
666
+ maxAutoTier: cfg.autoApplyTier ?? DEFAULT_AUTOSYNC_AUTO_APPLY_TIER,
667
+ });
474
668
  // 待审项(medium/high:决策明确但不该自动应用)必须可见 —— 进历史 conflictedSections,
475
669
  // 绝不静默丢弃;真正的冲突已在上方记入 conflictedSections。
476
670
  deferredSections = apply.review.map((s) => s.id);
@@ -514,14 +708,45 @@ export class AutoSyncScheduler {
514
708
  // Phase C: push 上传(完整双向)—— 仅当本地真有改动才上传(§3.1);startup 变体不上传。
515
709
  if (!opts.startup && localDirty) {
516
710
  try {
711
+ /**
712
+ * 冲突策略(conflictPolicy,缺省 'local'):
713
+ * - 'ask':不强制,推送会被引擎的「远端已推进」前置检测挡下 → skipped/partial(conflict);
714
+ * - 'local':**不写本地**(下行被冲突挡下),但把本机版本强制推成新的远端最新。
715
+ * force 只跳过「基线是否落后」这一道检查,仍走正常快照上传与基线记录;
716
+ * 被覆盖的远端旧版本留在远端历史快照里,可手动回滚。
717
+ */
718
+ const forcePush = conflictedSections.length > 0 && (cfg.conflictPolicy ?? DEFAULT_AUTOSYNC_CONFLICT_POLICY) === 'local';
517
719
  // Phase C: 完整双向 → engine.push() 上传。P0-A:外部 push 包 intent journal。
518
- const rawPush = async () => engine.push();
720
+ const rawPush = async () => engine.push(forcePush ? { force: true } : {});
519
721
  const pushReport = (this.phase3Recovery !== undefined && this.lockCtxForJournal !== null)
520
722
  ? (await this.phase3Recovery.runExternalIntent({
521
723
  operationType: 'autosync-push', lockCtx: this.lockCtxForJournal,
522
724
  intent: { adapter: 'sync', ref: channel, kind: 'Push' }, fn: rawPush,
523
725
  })).result as import('./sync-engine.ts').SyncPushReport
524
726
  : await rawPush();
727
+ if (!pushReport.ok && pushReport.conflict === true) {
728
+ // 引擎前置检测拒绝写入:远端已被别的设备推进 —— 这是**有冲突待处理**,不是通道/凭据故障。
729
+ // 绝不记 failed(那会累加连续失败计数并对用户发通知,把「需要用『检查远程合并』处理」
730
+ // 谎报成「同步坏了」);错误摘要保留引擎给出的说明(用户能看见到底为什么没推上去)。
731
+ const message = pushReport.message ?? engineMsg('sync.autosyncPushFailed');
732
+ const conflictResult: AutosyncRunResult = {
733
+ status: conflictedSections.length > 0 ? 'partial' : 'skipped',
734
+ direction: appliedSections.length ? 'both' : 'push',
735
+ skipReason: 'conflict', error: message, historyId,
736
+ appliedSections,
737
+ ...(conflictedSections.length > 0 ? { conflictedSections } : {}),
738
+ consecutiveFailures: 0,
739
+ };
740
+ await this.appendHistory(channel, {
741
+ direction: appliedSections.length ? 'both' : 'push', status: conflictResult.status,
742
+ skipReason: 'conflict', error: message,
743
+ appliedSections,
744
+ ...(conflictedSections.length > 0 ? { conflictedSections } : {}),
745
+ createdAt: nowIso, failureCountAtRun: 0,
746
+ });
747
+ await this.writeFinalConfig(cfg, conflictResult, nowIso, historyId, channel);
748
+ return conflictResult;
749
+ }
525
750
  if (!pushReport.ok) {
526
751
  const error = pushReport.message ?? engineMsg('sync.autosyncPushFailed');
527
752
  const result: AutosyncRunResult = {
@@ -540,22 +765,34 @@ export class AutoSyncScheduler {
540
765
  }
541
766
  // issue #79:上行成功但下行被冲突挡下 → partial(绝不把「半个方向完成」报成 success,
542
767
  // 也绝不因为它报成 failed:网络与凭据都是好的,只是远端有内容要用户确认)。
768
+ // conflictPolicy='local' 时冲突已被「本机覆盖远端」消解,但远端那一版确实被盖掉了 →
769
+ // 同样记 partial,skipReason 换成 conflict-kept-local(界面据此说清「本机已覆盖远端」)。
770
+ const keptLocal = forcePush && conflictedSections.length > 0;
543
771
  const partial = conflictedSections.length > 0;
772
+ const skipReason = keptLocal
773
+ ? ('conflict-kept-local' as const)
774
+ : partial
775
+ ? ('conflict' as const)
776
+ : undefined;
777
+ const reportedConflicted = keptLocal
778
+ ? conflictedSections
779
+ : partial
780
+ ? conflictedSections
781
+ : deferredSections;
544
782
  const result: AutosyncRunResult = {
545
783
  status: partial ? 'partial' : 'success',
546
784
  direction: appliedSections.length ? 'both' : 'push',
547
785
  appliedSections, pushedSnapshotId: pushReport.snapshotId, historyId,
548
- ...(partial ? { skipReason: 'conflict' as const } : {}),
549
- ...(deferredSections.length > 0 ? { conflictedSections: deferredSections } : {}),
550
- ...(partial ? { conflictedSections } : {}),
786
+ ...(skipReason !== undefined ? { skipReason } : {}),
787
+ ...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
551
788
  consecutiveFailures: 0,
552
789
  };
553
790
  await this.appendHistory(channel, {
554
791
  direction: appliedSections.length ? 'both' : 'push',
555
792
  status: partial ? 'partial' : 'success',
556
793
  appliedSections, pushedSnapshotId: pushReport.snapshotId,
557
- ...(partial ? { skipReason: 'conflict' as const, conflictedSections } : {}),
558
- ...(!partial && deferredSections.length > 0 ? { conflictedSections: deferredSections } : {}),
794
+ ...(skipReason !== undefined ? { skipReason } : {}),
795
+ ...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
559
796
  createdAt: nowIso, failureCountAtRun: 0,
560
797
  });
561
798
  await this.writeFinalConfig(cfg, result, nowIso, historyId, channel);
@@ -622,6 +859,7 @@ export class AutoSyncScheduler {
622
859
  return result;
623
860
  } finally {
624
861
  this.running = false;
862
+ this.watcher?.resume();
625
863
  // 收尾 RunRegistry:不 finish 会让 autosync 的 running 记录滞留,期间任何再次 runOnce
626
864
  // 都会 register('autosync') → RunConflictError → 永远 skip(conflict),后台同步就此停摆。
627
865
  // 同一收敛点也必须在**主 try 之外**的早退路径上调用(见 finishRun 的注释)。
@@ -652,7 +890,8 @@ export class AutoSyncScheduler {
652
890
  channel: SyncTransportType,
653
891
  ): Promise<void> {
654
892
  const skipReason = result.status === 'failed' ? undefined : result.skipReason;
655
- const skipCount = skipReason !== 'conflict' || result.conflictedSections === undefined
893
+ const countsConflicts = skipReason === 'conflict' || skipReason === 'conflict-kept-local';
894
+ const skipCount = !countsConflicts || result.conflictedSections === undefined
656
895
  ? undefined
657
896
  : result.conflictedSections.length;
658
897
  await this.writeConfig(channel, {
@@ -667,22 +906,21 @@ export class AutoSyncScheduler {
667
906
  });
668
907
  }
669
908
 
670
- /** 连续失败 ≥ 3 → 通知(host.log.warn)。 */
909
+ /**
910
+ * 连续失败 ≥ 3 → 通知(host.log.warn)。
911
+ *
912
+ * issue #105:通知的持久痕迹只留历史 entry 的 notifiedAt。
913
+ * 此前这里把通知文案写进 autosync-config.lastRunMessage —— 那是「本轮错误原文」的字段,
914
+ * 一个字段两种语义,界面就无法判断该显示什么;而且 entry.notifiedAt 只在内存对象上改、
915
+ * 从未落盘(sync-history 当时只有 append),通知事实其实没留下。现在两者都走一处:
916
+ * 通知文案只进日志,notifiedAt 落盘到历史记录。
917
+ */
671
918
  private maybeNotify(failures: number, historyId: string, nowIso: string, channel: SyncTransportType): void {
672
- if (failures >= 3) {
673
- this.host.log.warn(`自动同步连续失败 ${failures} 次,请检查仓库配置/凭据(${channel})`);
674
- // 记录 notifiedAt(更新历史 entry)
675
- void this.readHistoryFn().then(async (hist) => {
676
- const entry = hist.autosyncEntries.find((e) => e.createdAt === nowIso);
677
- if (entry) {
678
- entry.notifiedAt = nowIso;
679
- await this.writeConfig(channel, {
680
- ...(await this.readConfig(channel)),
681
- lastRunMessage: engineMsg('sync.autosyncConsecutiveFailureNotified', { count: failures }),
682
- });
683
- }
684
- }).catch(() => { /* 尽力而为 */ });
685
- }
919
+ if (failures < 3) return;
920
+ this.host.log.warn(`自动同步连续失败 ${failures} 次,请检查仓库配置/凭据(${channel})`);
921
+ // readHistory 走注入的 readHistoryFn(测试可替换),写入仍是原子落盘
922
+ void markAutosyncNotified(this.syncDir, nowIso, nowIso, { readHistory: this.readHistoryFn })
923
+ .catch(() => { /* 尽力而为 */ });
686
924
  }
687
925
  }
688
926
 
@@ -735,12 +973,13 @@ export function syncIsConfigured(cfg: SyncConfig | null): boolean {
735
973
  /**
736
974
  * 缺省远端新快照检测(§3.2):engine 实现了 hasNewRemoteSnapshot() → 调用;
737
975
  * 否则(测试 mock 未实现)保守返回 true(假设有新生,保持旧行为=每次都尝试拉取)。
976
+ * `listed` 透传给引擎:本轮已 list 过就直接复用,不再多跑一次网络往返。
738
977
  */
739
- async function defaultDetectRemoteNew(engine: SyncEngine): Promise<boolean> {
740
- const fn = (engine as unknown as { hasNewRemoteSnapshot?: () => Promise<boolean> }).hasNewRemoteSnapshot;
978
+ async function defaultDetectRemoteNew(engine: SyncEngine, listed?: readonly SyncSnapshotMeta[]): Promise<boolean> {
979
+ const fn = (engine as unknown as { hasNewRemoteSnapshot?: (listed?: readonly SyncSnapshotMeta[]) => Promise<boolean> }).hasNewRemoteSnapshot;
741
980
  if (typeof fn === 'function') {
742
981
  try {
743
- return await fn.call(engine);
982
+ return await fn.call(engine, listed);
744
983
  } catch {
745
984
  return true; // 检测失败保守视为有新生,避免漏拉
746
985
  }
@@ -752,6 +991,19 @@ async function defaultDetectRemoteNew(engine: SyncEngine): Promise<boolean> {
752
991
  * 缺省本地变化检测(§3.1):engine 实现了 hasLocalChanges() → 调用;
753
992
  * 否则(测试 mock 未实现)保守返回 true(假设有本地改动,保持旧行为=每次都尝试上传)。
754
993
  */
994
+ /**
995
+ * 缺省 watcher 工厂 = 真实 fs.watch(SyncWatcher)。
996
+ * targets 由 watch-scope 的静态清单派生(纯函数、不读磁盘),profile 只影响 profiles/ 下的路径。
997
+ */
998
+ function defaultMakeWatcher(opts: WatcherFactoryOptions): SyncWatcher {
999
+ return new SyncWatcher({
1000
+ homeDir: opts.homeDir,
1001
+ targets: buildWatchTargets({ homeDir: opts.homeDir, profile: opts.profile }),
1002
+ log: opts.log,
1003
+ onFlush: opts.onFlush,
1004
+ });
1005
+ }
1006
+
755
1007
  async function defaultDetectLocalChange(engine: SyncEngine): Promise<boolean> {
756
1008
  const fn = (engine as unknown as { hasLocalChanges?: () => Promise<boolean> }).hasLocalChanges;
757
1009
  if (typeof fn === 'function') {