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
@@ -18,6 +18,7 @@ import type { MutationLockPort, MutationLockToken } from '../utils/env-lock.ts';
18
18
  import type { AutosyncConfig } from './autosync-config.ts';
19
19
  import type { AutosyncHistoryEntry } from './sync-history.ts';
20
20
  import type { MergePlan, MergeSectionResult } from './merge.ts';
21
+ import type { SyncSnapshotMeta } from './transport.ts';
21
22
  import type { SectionId } from '../schema/types.ts';
22
23
  import type { SyncEngine } from './sync-engine.ts';
23
24
  import type { SyncConfig, SyncTransportType } from './sync-config.ts';
@@ -242,6 +243,48 @@ test('runOnce: 历史记录携带触发通道(transport=调用通道 git/webda
242
243
  for (const e of entries) assert.ok(e.transport === 'git' || e.transport === 'webdav', `历史必须带 transport: ${JSON.stringify(e)}`);
243
244
  });
244
245
 
246
+ test('runOnce: 缺省远端检测复用本轮列表(一轮至多一次远端 list,不再白跑一次网络往返)', async () => {
247
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
248
+ const metas: SyncSnapshotMeta[] = [{
249
+ id: 'sync-001',
250
+ createdAt: '2026-08-16T12:00:00.000Z',
251
+ sections: {},
252
+ manifest: {
253
+ schemaVersion: 3, dshVersion: '0.0.0', platform: 'win32',
254
+ sectionIds: [], containsSecrets: false, encrypted: false,
255
+ },
256
+ }];
257
+ let listCalls = 0;
258
+ let seenListed: unknown = 'not-called';
259
+ const engine = {
260
+ listSnapshots: async () => { listCalls += 1; return metas; },
261
+ hasNewRemoteSnapshot: async (listed?: unknown) => { seenListed = listed; return false; },
262
+ hasLocalChanges: async () => false,
263
+ };
264
+ const { scheduler } = makeScheduler({ cfg, engine, history: [] });
265
+ await scheduler.runOnce('git');
266
+ assert.equal(listCalls, 1, '查加密快照已 list 过一次 → 检测必须复用,不得再 list');
267
+ assert.equal(seenListed, metas, '本轮列表要按引用原样透传给检测');
268
+ });
269
+
270
+ test('runOnce: 列表拿不到(网络失败)时检测回退自己 list,行为与改造前一致', async () => {
271
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
272
+ let detectCalls = 0;
273
+ let seenListed: unknown = 'not-called';
274
+ const engine = {
275
+ listSnapshots: async () => { throw new Error('network down'); },
276
+ hasNewRemoteSnapshot: async (listed?: unknown) => { detectCalls += 1; seenListed = listed; return false; },
277
+ hasLocalChanges: async () => false,
278
+ };
279
+ const { scheduler, getEntries } = makeScheduler({ cfg, engine, history: [] });
280
+ const result = await scheduler.runOnce('git');
281
+ assert.equal(detectCalls, 1, '列表失败不阻断检测');
282
+ assert.equal(seenListed, undefined, '没有可用列表 → 传 undefined,让引擎自己 list');
283
+ assert.equal(result.status, 'success');
284
+ assert.equal(result.skipReason, 'upToDate');
285
+ assert.equal(getEntries().length, 1);
286
+ });
287
+
245
288
  test('runOnce: 未配置仓库 → skipped(unconfigured),写历史但不计失败', async () => {
246
289
  const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
247
290
  const scheduler = new AutoSyncScheduler({
@@ -308,10 +351,10 @@ test('runOnce: merge 抛错 → failed,连续失败计数 +1', async () => {
308
351
  assert.ok(getEntries().some((e) => e.status === 'failed'), '写入失败历史');
309
352
  });
310
353
 
311
- test('issue #79:远端冲突只挡下行 —— 不写本地,但仍推送本机改动(partial,不谎报 success/failed)', async () => {
354
+ test('issue #79 + conflictPolicy=local(缺省):冲突只挡下行,本机版本强制推成新远端(partial,不谎报 success/failed)', async () => {
312
355
  const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 1 };
313
356
  const applyCalls: string[] = [];
314
- const pushCalls: string[] = [];
357
+ const pushCalls: Array<{ force?: boolean } | undefined> = [];
315
358
  const engine = {
316
359
  hasNewRemoteSnapshot: async () => true,
317
360
  hasLocalChanges: async () => true,
@@ -320,23 +363,56 @@ test('issue #79:远端冲突只挡下行 —— 不写本地,但仍推送本
320
363
  applyCalls.push('apply');
321
364
  return { ok: true, applied: [] as never[], restoreId: 'r1', rolledBack: false, review: [], warnings: [] };
322
365
  },
323
- push: async () => { pushCalls.push('push'); return { ok: true, snapshotId: 'snap-up', sections: [] as never, warnings: [] }; },
366
+ push: async (opts?: { force?: boolean }) => {
367
+ pushCalls.push(opts);
368
+ return { ok: true, snapshotId: 'snap-up', sections: [] as never, warnings: [] };
369
+ },
324
370
  };
325
371
  const { scheduler, getConfig, getEntries } = makeScheduler({ cfg, engine, history: [] });
326
372
  const result = await scheduler.runOnce('git');
327
- assert.equal(result.status, 'partial', '上行成功 + 下行待确认 → partial');
328
- assert.equal(result.skipReason, 'conflict');
373
+ assert.equal(result.status, 'partial', '上行成功 + 下行被冲突挡下 → partial');
374
+ assert.equal(result.skipReason, 'conflict-kept-local', '本机已按策略覆盖远端,与「待人工裁决」区分开');
329
375
  assert.deepEqual(result.conflictedSections, ['settings']);
330
376
  assert.equal(result.pushedSnapshotId, 'snap-up', '本机快照仍有上行出口(issue #79 主诉)');
331
377
  assert.equal(applyCalls.length, 0, '冲突绝不写本机配置');
332
378
  assert.equal(pushCalls.length, 1, '纯上行不被冲突连坐');
379
+ assert.equal(pushCalls[0]?.force, true, 'local 策略下必须带上 force,否则引擎会因「远端已推进」拒绝上传');
333
380
  assert.equal(result.consecutiveFailures, 0, '冲突跳过不是失败 → 计数归零');
334
- assert.equal(getConfig().lastRunSkipReason, 'conflict', '跳过原因落到状态(界面按码取字典)');
381
+ assert.equal(getConfig().lastRunSkipReason, 'conflict-kept-local', '跳过原因落到状态(界面按码取字典)');
335
382
  assert.equal(getConfig().lastRunSkipCount, 1);
336
- assert.ok(getEntries().some((e) => e.status === 'partial' && e.skipReason === 'conflict'), '写入 partial 历史');
383
+ assert.ok(getEntries().some((e) => e.status === 'partial' && e.skipReason === 'conflict-kept-local'), '写入 partial 历史');
337
384
  assert.ok(getEntries().some((e) => e.pushedSnapshotId === 'snap-up'), '历史带上行快照 id');
338
385
  });
339
386
 
387
+ test('issue #79 + conflictPolicy=ask:冲突只挡下行,绝不强推覆盖远端(partial + skipped 码 conflict)', async () => {
388
+ const cfg: AutosyncConfig = {
389
+ enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 1,
390
+ conflictPolicy: 'ask',
391
+ };
392
+ const applyCalls: string[] = [];
393
+ const pushCalls: Array<{ force?: boolean } | undefined> = [];
394
+ const engine = {
395
+ hasNewRemoteSnapshot: async () => true,
396
+ hasLocalChanges: async () => true,
397
+ merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'conflict']]),
398
+ applyMergePlan: async () => {
399
+ applyCalls.push('apply');
400
+ return { ok: true, applied: [] as never[], restoreId: 'r1', rolledBack: false, review: [], warnings: [] };
401
+ },
402
+ push: async (opts?: { force?: boolean }) => {
403
+ pushCalls.push(opts);
404
+ return { ok: true, snapshotId: 'snap-up', sections: [] as never, warnings: [] };
405
+ },
406
+ };
407
+ const { scheduler, getConfig } = makeScheduler({ cfg, engine, history: [] });
408
+ const result = await scheduler.runOnce('git');
409
+ assert.equal(result.status, 'partial');
410
+ assert.equal(result.skipReason, 'conflict', 'ask 策略保留「待裁决」语义');
411
+ assert.equal(pushCalls[0]?.force, undefined, 'ask 策略绝不 force 覆盖远端');
412
+ assert.equal(applyCalls.length, 0, '冲突绝不写本机配置');
413
+ assert.equal(getConfig().lastRunSkipReason, 'conflict');
414
+ });
415
+
340
416
  test('issue #79:冲突且本机无改动(非 startup)→ skipped(conflict) 且不带 push', async () => {
341
417
  const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
342
418
  const pushCalls: string[] = [];
@@ -377,6 +453,31 @@ test('issue #79:本轮成功必须清掉上一轮的错误文案与跳过码
377
453
  assert.equal(saved.consecutiveFailures, 0, '成功归零连续失败');
378
454
  });
379
455
 
456
+ test('push 被远端推进检测拒绝(conflict)→ skipped(conflict) 而非 failed;不累加连续失败、历史留下引擎说明', async () => {
457
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 2 };
458
+ const engine = {
459
+ hasNewRemoteSnapshot: async () => false,
460
+ hasLocalChanges: async () => true,
461
+ push: async () => ({
462
+ ok: false, snapshotId: '', sections: [] as never, warnings: [],
463
+ conflict: true,
464
+ message: '远端最新快照 sync-other 与本地基线 sync-base 不一致:本次推送未执行',
465
+ }),
466
+ };
467
+ const { scheduler, getConfig, getEntries } = makeScheduler({ cfg, engine, history: [] });
468
+ const result = await scheduler.runOnce('git');
469
+ assert.equal(result.status, 'skipped', '有冲突待处理 ≠ 同步坏了');
470
+ assert.equal(result.skipReason, 'conflict');
471
+ assert.equal(result.consecutiveFailures, 0, '冲突跳过不计失败(否则会误发「连续失败」通知)');
472
+ assert.ok((result.error ?? '').includes('sync-other'), '必须把引擎的说明带给用户: ' + (result.error ?? ''));
473
+ assert.equal(getConfig().lastRunStatus, 'skipped');
474
+ assert.equal(getConfig().lastRunSkipReason, 'conflict');
475
+ assert.ok(
476
+ getEntries().some((e) => e.status === 'skipped' && e.skipReason === 'conflict' && (e.error ?? '').includes('sync-other')),
477
+ '历史里必须能看出「有远端冲突待合并」',
478
+ );
479
+ });
480
+
380
481
  test('runOnce: 无冲突且无变化 → success(pull,unchanged),不上传(本地也无改动)', async () => {
381
482
  const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
382
483
  const engine = {
@@ -496,7 +597,8 @@ test('runOnce: 远端有新快照但本地无改动 → 只 pull 合并,不 pu
496
597
  });
497
598
 
498
599
  test('start(): 定时器触发后自动重排下一次(周期性后台同步)', async () => {
499
- const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
600
+ // realtime:false 关掉「远端轮询定时器」,本用例只测 interval 兜底排期与重排(每个通道 1 个定时器)。
601
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0, realtime: false };
500
602
  const pending: Array<() => void> = [];
501
603
  let timerSeq = 0;
502
604
  const engine = {
@@ -597,12 +699,13 @@ test('runOnce: 远端最新快照为普通 → 加密检测不触发(listSnaps
597
699
  });
598
700
 
599
701
  test('双通道:git/webdav 同时 enabled → 各自独立排期;runOnce 写各自通道配置', async () => {
600
- const gitCfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
601
- const webdavCfg: AutosyncConfig = { enabled: true, interval: '5m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
702
+ // realtime:false → 只有 interval 兜底定时器(realtime 时每通道还会多一个远端轮询定时器)。
703
+ const gitCfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0, realtime: false };
704
+ const webdavCfg: AutosyncConfig = { enabled: true, interval: '5m', startupMinIntervalMs: 300000, consecutiveFailures: 0, realtime: false };
602
705
  // 全通道配置由唯一枚举派生(新增通道不得手写漏项);其余通道保持默认关闭
603
706
  const configs = {} as Record<SyncTransportType, AutosyncConfig>;
604
707
  for (const ch of SYNC_CHANNELS) {
605
- configs[ch] = { enabled: false, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
708
+ configs[ch] = { enabled: false, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0, realtime: false };
606
709
  }
607
710
  configs.git = gitCfg;
608
711
  configs.webdav = webdavCfg;
@@ -664,7 +767,8 @@ test('t32:排期覆盖 SYNC_CHANNELS 的每个通道(长度取自枚举,
664
767
  now: () => new Date(1_000_000_000_000),
665
768
  readConfig: async (channel: SyncTransportType) => {
666
769
  seen.push(channel);
667
- return { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
770
+ // realtime:false → 每个通道只排一个 interval 定时器,断言直接对应「通道数」。
771
+ return { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0, realtime: false };
668
772
  },
669
773
  writeConfig: async () => {},
670
774
  readSyncConfigFn: async () => null,
@@ -680,3 +784,151 @@ test('t32:排期覆盖 SYNC_CHANNELS 的每个通道(长度取自枚举,
680
784
  scheduler.stop();
681
785
  });
682
786
 
787
+ /* ---------------- m-autosync-realtime:即时通道(本地改动监听 + 远端轮询) ---------------- */
788
+
789
+ interface RealtimeHarness {
790
+ scheduler: AutoSyncScheduler;
791
+ timers: Array<() => void>;
792
+ /** 模拟监听器发现本地改动 */
793
+ fireLocalChange: (changed?: readonly string[]) => void;
794
+ /** 监听器生命周期(started/stopped/suspended/resumed 计数) */
795
+ watcher: { started: boolean; stopped: boolean; suspended: number; resumed: number };
796
+ engineCalls: { merge: number; push: number };
797
+ /** 推进夹具时钟(间隔下限的判定依赖真实时间流逝) */
798
+ advance: (ms: number) => void;
799
+ }
800
+
801
+ function makeRealtimeScheduler(cfg: AutosyncConfig, opts: { minTriggerGapMs?: number } = {}): RealtimeHarness {
802
+ const timers: Array<() => void> = [];
803
+ const watcher = { started: false, stopped: false, suspended: 0, resumed: 0 };
804
+ const engineCalls = { merge: 0, push: 0 };
805
+ let flush: ((changed: readonly string[]) => void) | null = null;
806
+ let clockMs = 1_000_000_000_000;
807
+ const engine = {
808
+ hasNewRemoteSnapshot: async () => false,
809
+ hasLocalChanges: async () => true,
810
+ merge: async (): Promise<MergePlan> => { engineCalls.merge += 1; return makeMergePlan([]); },
811
+ push: async () => {
812
+ engineCalls.push += 1;
813
+ return { ok: true, snapshotId: 'snap-' + engineCalls.push, sections: [] as never, warnings: [] };
814
+ },
815
+ };
816
+ const scheduler = new AutoSyncScheduler({
817
+ syncDir: '/tmp',
818
+ host: { log: nullLogger() },
819
+ makeSyncEngine: () => engine as unknown as SyncEngine,
820
+ msg: (k: string) => k,
821
+ runs: new RunRegistry(),
822
+ now: () => new Date(clockMs),
823
+ // 只有 git 通道开启:其余通道回退 enabled:false,否则 refreshTimers 会给四个通道都排定时器
824
+ readConfig: async (channel) => (channel === 'git' ? cfg : { ...cfg, enabled: false }),
825
+ writeConfig: async () => {},
826
+ readSyncConfigFn: async () => ({ schemaVersion: 2, transport: 'git', git: { repoUrl: 'git@github.com:foo/bar.git' } }),
827
+ readHistoryFn: async () => ({ schemaVersion: 1, autosyncEntries: [], updatedAt: '' }),
828
+ appendHistoryFn: async () => {},
829
+ setTimer: (fn) => { timers.push(fn); return timers.length as unknown as ReturnType<typeof setTimeout>; },
830
+ clearTimer: () => {},
831
+ homeDir: '/home/u/.dsh',
832
+ minTriggerGapMs: opts.minTriggerGapMs ?? 3000,
833
+ makeWatcher: (wopts) => {
834
+ flush = wopts.onFlush;
835
+ return {
836
+ start: () => { watcher.started = true; },
837
+ stop: () => { watcher.stopped = true; },
838
+ setTargets: () => {},
839
+ suspend: () => { watcher.suspended += 1; },
840
+ resume: () => { watcher.resumed += 1; },
841
+ isStarted: () => watcher.started,
842
+ attachmentCount: () => 1,
843
+ flushNow: () => {},
844
+ } as never;
845
+ },
846
+ });
847
+ return {
848
+ scheduler, timers, watcher, engineCalls,
849
+ advance: (ms: number) => { clockMs += ms; },
850
+ fireLocalChange: (changed = ['settings.yaml']) => {
851
+ assert.ok(flush !== null, '监听器尚未创建(start() 未跑或 realtime 关闭)');
852
+ flush!(changed);
853
+ },
854
+ };
855
+ }
856
+
857
+ const tick = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
858
+
859
+ test('realtime:本地改动 → 立刻触发一轮(不等 interval),runOnce 期间挂起监听、结束后恢复', async () => {
860
+ const cfg: AutosyncConfig = { enabled: true, interval: '24h', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
861
+ // gap 归零:本用例只测「监听回调 → 立刻跑一轮」,间隔合并另有用例
862
+ const h = makeRealtimeScheduler(cfg, { minTriggerGapMs: 0 });
863
+ h.scheduler.start();
864
+ await tick();
865
+ await tick();
866
+ assert.equal(h.watcher.started, true, '开启实时同步必须挂上本地改动监听');
867
+ const pushesBefore = h.engineCalls.push;
868
+ const suspendedBefore = h.watcher.suspended;
869
+ const resumedBefore = h.watcher.resumed;
870
+ h.fireLocalChange();
871
+ await tick();
872
+ await tick();
873
+ assert.equal(h.engineCalls.push, pushesBefore + 1, '本地一改就上行(不必等 24h 兜底定时器)');
874
+ assert.equal(h.watcher.suspended, suspendedBefore + 1, '同步自家写入必须被挂起,否则自激');
875
+ assert.equal(h.watcher.resumed, resumedBefore + 1, '收尾必须恢复监听');
876
+ h.scheduler.stop();
877
+ assert.equal(h.watcher.stopped, true, 'stop() 必须关掉监听');
878
+ });
879
+
880
+ test('realtime:间隔下限(minTriggerGapMs)内的第二次触发排成延迟触发,不丢改动', async () => {
881
+ const cfg: AutosyncConfig = { enabled: true, interval: '24h', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
882
+ const h = makeRealtimeScheduler(cfg, { minTriggerGapMs: 3000 });
883
+ h.scheduler.start();
884
+ await tick();
885
+ await tick();
886
+ const before = h.timers.length;
887
+ const pushesBefore = h.engineCalls.push;
888
+ h.fireLocalChange(['settings.yaml']);
889
+ await tick();
890
+ assert.equal(h.engineCalls.push, pushesBefore, '距上轮不足 gap 时不得立刻再跑(避免连续保存打满远端)');
891
+ assert.equal(h.timers.length, before + 1, 'gap 内触发必须排一个延迟定时器(绝不丢用户改动)');
892
+ // 同一通道再来一次:复用同一枚定时器(合并成一轮),不得堆积
893
+ h.fireLocalChange(['skills']);
894
+ await tick();
895
+ assert.equal(h.timers.length, before + 1, '同一通道 gap 内只保留一枚定时器(连续保存合并成一轮)');
896
+ const gapTimer = h.timers[h.timers.length - 1];
897
+ assert.ok(gapTimer !== undefined);
898
+ h.advance(3000); // 真实时间里这 3 秒是自然流逝的;夹具时钟被钉死,必须手动推进
899
+ gapTimer();
900
+ await tick();
901
+ await tick();
902
+ assert.equal(h.engineCalls.push, pushesBefore + 1, '延迟触发到点后必须补跑(两处改动合成一轮)');
903
+ h.scheduler.stop();
904
+ });
905
+
906
+ test('realtime=false:不挂监听、不排远端轮询定时器(退回纯 interval 兜底)', async () => {
907
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0, realtime: false };
908
+ const h = makeRealtimeScheduler(cfg);
909
+ h.scheduler.start();
910
+ await tick();
911
+ assert.equal(h.watcher.started, false, 'realtime 关闭时不得挂监听');
912
+ assert.equal(h.timers.length, 1, '只排 interval 兜底定时器(无远端轮询)');
913
+ h.scheduler.stop();
914
+ });
915
+
916
+ test('realtime:远端轮询定时器按 remoteCheckMs 排期,到点触发一轮后立刻重排', async () => {
917
+ const cfg: AutosyncConfig = {
918
+ enabled: true, interval: '24h', startupMinIntervalMs: 300000, consecutiveFailures: 0,
919
+ remoteCheckMs: 60_000,
920
+ };
921
+ const h = makeRealtimeScheduler(cfg);
922
+ h.scheduler.start();
923
+ await tick();
924
+ // interval 兜底 1 个 + 远端轮询 1 个
925
+ assert.equal(h.timers.length, 2, '每通道排 interval + 远端轮询两枚定时器');
926
+ const poll = h.timers[1];
927
+ assert.ok(poll !== undefined);
928
+ poll();
929
+ await tick();
930
+ await tick();
931
+ assert.ok(h.timers.length >= 3, '轮询到点后必须立刻重排下一次');
932
+ h.scheduler.stop();
933
+ });
934
+