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
@@ -41,7 +41,7 @@ import {
41
41
  } from '../transport.ts';
42
42
  import type { FilesSection, SectionData, SectionId } from '../../schema/types.ts';
43
43
  import type {
44
- SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions,
44
+ SyncProbeResult, SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions,
45
45
  } from '../transport.ts';
46
46
  import { parseJsonSafe } from '../../utils/json.ts';
47
47
 
@@ -70,6 +70,40 @@ const BLOBS_INDEX_FILE = 'blobs-index.json';
70
70
  /** 内容哈希形状(sha256 hex):blob 路径只接受它,杜绝路径穿越 */
71
71
  const BLOB_HASH_RE = /^[a-f0-9]{64}$/;
72
72
  const REDACTED = '[REDACTED]';
73
+ /**
74
+ * 幂等 MKCOL 的「已存在 / 已建好」状态码(cloud-sync 借鉴项 ③)。
75
+ * 405 = 资源已存在(WebDAV 规范下的常见回法);301/302/303 = 反向代理把 MKCOL 重定向到规范 URL;
76
+ * 409 = 目标已存在(坚果云等网盘对已存在目录回 409,见 cloud-sync 的兼容注释)。
77
+ */
78
+ const MKCOL_OK_STATUSES = new Set([200, 201, 204, 301, 302, 303, 405, 409]);
79
+ /**
80
+ * 直连目标集合时可判为「已建好 / 已存在」的状态码(**不含 409**)。
81
+ *
82
+ * 为什么把 409 排除:RFC 4918 §9.3.1 规定「父目录不存在」的正确回法就是 **409 Conflict**,
83
+ * 而部分网盘(坚果云等)对「目标已存在」也回 409 —— 首连直连时两种语义无法区分,
84
+ * 一律当「已存在」会让「上级目录没建」这条真实故障被吞掉(紧接着的 PUT 才会失败)。
85
+ * 因此 409 必须先走 ensureAncestors 补齐父目录再重试;只有**重试后**仍 409 才按「已存在」接受。
86
+ */
87
+ const MKCOL_EXISTS_STATUSES = new Set([200, 201, 204, 301, 302, 303, 405]);
88
+
89
+ /**
90
+ * 条件写头(cloud-sync 借鉴项 ①):拿到 ETag 就要求「远端仍是我读到的那一版」(If-Match);
91
+ * 索引**本来就不存在**(首次推送且远端确无内容)时用 If-None-Match: * 防两个设备同时首推;
92
+ * 其余情形(服务端不返回 ETag)返回空对象 ⇒ 退化为无条件写,绝不因为加了头把同步判成失败。
93
+ */
94
+ function conditionalHeaders(etag: string | undefined, indexMissing: boolean): Record<string, string> {
95
+ if (etag !== undefined) return { 'If-Match': etag };
96
+ return indexMissing ? { 'If-None-Match': '*' } : {};
97
+ }
98
+
99
+ /** URL 路径段编码:已百分号编码的段原样保留(先 decode 成功才 re-encode),失败则直接编码。 */
100
+ function encodeSegmentSafe(segment: string): string {
101
+ try {
102
+ return encodeURIComponent(decodeURIComponent(segment));
103
+ } catch {
104
+ return encodeURIComponent(segment);
105
+ }
106
+ }
73
107
  /** 自动跟随的重定向状态码(RFC 7231/9110) */
74
108
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
75
109
  /** 重定向最大跳数(RFC 7231 建议 ≤5;防 302 循环拖死请求) */
@@ -341,7 +375,7 @@ export class WebDavTransport implements SyncTransport {
341
375
  // → 必须照常上传,绝不跳过。
342
376
  const meta = computeSnapshotMeta(snapshot);
343
377
  // audit-sync sync-N1 覆盖面补全(t66,来自 t22 的 C7):upload 也是写路径,**不接受 404 = 空集合**。
344
- const { entries: idxBefore, missing: indexMissing } = await this.readIndexDetailed(pwd);
378
+ const { entries: idxBefore, missing: indexMissing, etag: idxEtag } = await this.readIndexDetailed(pwd);
345
379
  if (indexMissing && await this.remoteHasPriorContent(pwd)) {
346
380
  // 索引 404 只是**缺信息**(不是「远端没有快照」):远端已有历史内容时,按空索引做读改写会把
347
381
  // 权威 index.json PUT 成只有本条目 → 其余快照从列表整体消失,随后 delete + GC 会回收**仍被
@@ -376,7 +410,11 @@ export class WebDavTransport implements SyncTransport {
376
410
  const merged = idxBefore.filter((m) => m.id !== snapshot.id);
377
411
  merged.push(meta);
378
412
  merged.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
379
- const putIdx = await this.send('PUT', idxUrl, pwd, { body: JSON.stringify(merged) });
413
+ const putIdx = await this.send('PUT', idxUrl, pwd, {
414
+ body: JSON.stringify(merged),
415
+ headers: conditionalHeaders(idxEtag, indexMissing),
416
+ });
417
+ if (putIdx.status === 412) throw this.concurrentWrite(idxUrl, putIdx.status);
380
418
  if (!putIdx.ok) {
381
419
  throw new WebDavTransportError(await this.failText('PUT', idxUrl, putIdx, pwd), classifyHttpStatus(putIdx.status));
382
420
  }
@@ -422,7 +460,7 @@ export class WebDavTransport implements SyncTransport {
422
460
  // 是**缺信息**而非事实,沿用空索引继续会把 index.json 覆盖成 [](其余快照从列表整体消失)并让
423
461
  // gcBlobStore 以空引用集回收,删掉仍被现存快照文件引用的会话 blob(不可恢复)。
424
462
  // 与 F1 同口径:宁可显式失败,也不制造不可恢复的远端状态。list() 仍保留「404 = 空集合」语义。
425
- const { entries: idx, missing: indexMissing } = await this.readIndexDetailed(pwd);
463
+ const { entries: idx, missing: indexMissing, etag: idxEtag } = await this.readIndexDetailed(pwd);
426
464
  // delete 快照文件:404 = 不存在,视为成功
427
465
  const url = this.snapshotUrl(id);
428
466
  const res = await this.send('DELETE', url, pwd);
@@ -438,7 +476,11 @@ export class WebDavTransport implements SyncTransport {
438
476
  if (remaining.length === idx.length && res.status === 404) {
439
477
  return; // 文件与 index 都不存在 → 静默成功,无写回
440
478
  }
441
- const putIdx = await this.send('PUT', idxUrl, pwd, { body: JSON.stringify(remaining) });
479
+ const putIdx = await this.send('PUT', idxUrl, pwd, {
480
+ body: JSON.stringify(remaining),
481
+ headers: conditionalHeaders(idxEtag, false),
482
+ });
483
+ if (putIdx.status === 412) throw this.concurrentWrite(idxUrl, putIdx.status);
442
484
  if (!putIdx.ok) {
443
485
  throw new WebDavTransportError(await this.failText('PUT', idxUrl, putIdx, pwd), classifyHttpStatus(putIdx.status));
444
486
  }
@@ -706,13 +748,85 @@ export class WebDavTransport implements SyncTransport {
706
748
  }
707
749
  }
708
750
 
709
- /** 幂等创建 snapshots 集合:MKCOL;已存在(405/2xx/3xx)视为成功。 */
710
- private async ensureCollection(pwd: string): Promise<void> {
751
+ /** 幂等创建 snapshots 集合:MKCOL;已存在(2xx/3xx/405/409)视为成功。
752
+ *
753
+ * cloud-sync 借鉴项 ③(首连自建):目标目录的**上级**不存在时(新账号,或用户填了一个还没建的
754
+ * 目录),按 WebDAV 规范 MKCOL 缺父目录回 409 —— 此前用户必须先去网盘里手工建好目录,否则
755
+ * 首次同步必然失败。现在:直连 409 时逐级补齐 baseUrl 路径上的每一层,再重试一次。
756
+ * 返回**本次真正新建**的层级 URL(已存在的层级不进结果,探针据此如实回报,普通上传忽略返回值)。 */
757
+ private async ensureCollection(pwd: string): Promise<string[]> {
711
758
  const url = this.snapshotsColUrl();
712
- const res = await this.send('MKCOL', url, pwd);
713
- const okStatuses = new Set([200, 201, 204, 301, 302, 303, 405]);
714
- if (okStatuses.has(res.status)) return;
715
- throw new WebDavTransportError(await this.failText('MKCOL', url, res, pwd), classifyHttpStatus(res.status));
759
+ const first = await this.send('MKCOL', url, pwd);
760
+ // 首连只认「确定已存在 / 已建好」的状态;**409 不算**(二义:缺父目录 or 已存在)
761
+ if (MKCOL_EXISTS_STATUSES.has(first.status)) return [];
762
+ if (first.status !== 409) {
763
+ // 401/403/5xx/… 不是「目录缺失」,绝不吞掉:如实报错
764
+ throw new WebDavTransportError(await this.failText('MKCOL', url, first, pwd), classifyHttpStatus(first.status));
765
+ }
766
+ // 409:先逐级补齐 baseUrl 每一层(幂等;已存在的层回 405/3xx/409 都按已存在继续),再重试目标集合。
767
+ const created = await this.ensureAncestors(pwd);
768
+ const retry = await this.send('MKCOL', url, pwd);
769
+ // 重试真建出目标集合(201/200/204)也要如实回报 —— 否则界面会**低报**本次实际创建的目录
770
+ // (用户可见文案「已在远端自动创建目录」,少报一条等于说错)。
771
+ if (retry.status === 200 || retry.status === 201 || retry.status === 204) created.push(url);
772
+ // 上级已确保存在:重试仍是 409 ⇒ 按「已存在」接受(部分网盘行为);真正的权限/路径错误由随后的 PUT 显式报出。
773
+ if (MKCOL_OK_STATUSES.has(retry.status)) return created;
774
+ throw new WebDavTransportError(await this.failText('MKCOL', url, retry, pwd), classifyHttpStatus(retry.status));
775
+ }
776
+
777
+ /** 逐级 MKCOL 补齐 baseUrl 的每一层目录(只有缺父目录时才会走到这里)。 */
778
+ private async ensureAncestors(pwd: string): Promise<string[]> {
779
+ const base = new URL(this.o.baseUrl);
780
+ const segments = base.pathname.split('/').filter((seg) => seg !== '');
781
+ const created: string[] = [];
782
+ for (let depth = 1; depth <= segments.length; depth += 1) {
783
+ const path = segments.slice(0, depth).map((seg) => encodeSegmentSafe(seg)).join('/');
784
+ const url = `${base.origin}/${path}/`;
785
+ const res = await this.send('MKCOL', url, pwd);
786
+ // 201/200/204 = 这次真的建出来了;405/409/3xx = 早就存在 → 不写进结果(不谎报「已创建」)
787
+ if (res.status === 200 || res.status === 201 || res.status === 204) created.push(url);
788
+ else if (MKCOL_OK_STATUSES.has(res.status)) continue;
789
+ else {
790
+ throw new WebDavTransportError(
791
+ this.o.msg('sync.webdav.collectionCreateFailed', { path: url, status: String(res.status) }),
792
+ classifyHttpStatus(res.status),
793
+ );
794
+ }
795
+ }
796
+ return created;
797
+ }
798
+
799
+ /** 条件写失败(HTTP 412):远端在本次读取之后被别人改过 —— 中止写入,让用户重跑(cloud-sync 借鉴项 ①)。 */
800
+ private concurrentWrite(url: string, status: number): WebDavTransportError {
801
+ return new WebDavTransportError(
802
+ this.o.msg('sync.webdav.concurrentWrite', { status: String(status), url }),
803
+ { kind: 'conflict', retryable: false, status },
804
+ );
805
+ }
806
+
807
+ /**
808
+ * 连通性探针(cloud-sync 借鉴项 ②):补齐目标目录 → 证明这真的是一个可读的 WebDAV 集合
809
+ * → 读一次索引回报快照数。只读;唯一写副作用是幂等 MKCOL 补目录(那正是它要修的故障)。
810
+ */
811
+ async probe(): Promise<SyncProbeResult> {
812
+ const pwd = await this.passwordOnce();
813
+ const created = await this.ensureCollection(pwd);
814
+ const url = this.snapshotsColUrl();
815
+ const res = await this.send('PROPFIND', url, pwd, { headers: { Depth: '0' } });
816
+ // 2xx(含 207 Multi-Status)= 真的是 WebDAV 集合;405/501 = 服务端不支持 PROPFIND → 退回「读索引」当证据。
817
+ if (!res.ok && res.status !== 405 && res.status !== 501) {
818
+ throw new WebDavTransportError(
819
+ this.o.msg('sync.webdav.probeUnreachable', { status: String(res.status), url }),
820
+ classifyHttpStatus(res.status),
821
+ );
822
+ }
823
+ const { entries, missing } = await this.readIndexDetailed(pwd);
824
+ const count = missing ? 0 : entries.length;
825
+ return {
826
+ message: this.o.msg('sync.webdav.probeOk', { count: String(count) }),
827
+ snapshotCount: count,
828
+ ...(created.length > 0 ? { createdCollections: created } : {}),
829
+ };
716
830
  }
717
831
 
718
832
  /**
@@ -730,14 +844,21 @@ export class WebDavTransport implements SyncTransport {
730
844
  * 写路径(**upload / delete**)必须用本方法:`missing` 时既不写回索引、也不触发 GC
731
845
  * (upload 另加「远端已有内容 ⇒ 显式中止」的判据,见 upload() 注释)。
732
846
  */
733
- private async readIndexDetailed(pwd: string): Promise<{ entries: SyncSnapshotMeta[]; missing: boolean }> {
847
+ private async readIndexDetailed(pwd: string): Promise<{ entries: SyncSnapshotMeta[]; missing: boolean; etag?: string }> {
734
848
  const url = this.indexUrl();
735
849
  const res = await this.send('GET', url, pwd);
736
850
  if (res.status === 404) return { entries: [], missing: true };
737
851
  if (!res.ok) {
738
852
  throw new WebDavTransportError(await this.failText('GET', url, res, pwd), classifyHttpStatus(res.status));
739
853
  }
740
- return { entries: this.parseIndex(await res.text(), url, pwd), missing: false };
854
+ // ETag 是写路径**条件写**的前置条件(cloud-sync 借鉴项 ①):服务端给了就记下来,没给就退化成无条件写
855
+ // (绝不因为「服务端不返回 ETag」把同步判成失败)。
856
+ const etag = res.headers?.['etag'];
857
+ return {
858
+ entries: this.parseIndex(await res.text(), url, pwd),
859
+ missing: false,
860
+ ...(typeof etag === 'string' && etag !== '' ? { etag } : {}),
861
+ };
741
862
  }
742
863
 
743
864
  private parseIndex(raw: string, url: string, pwd: string): SyncSnapshotMeta[] {
@@ -789,13 +910,15 @@ export class WebDavTransport implements SyncTransport {
789
910
  method: string,
790
911
  url: string,
791
912
  pwd: string,
792
- opts: { body?: string } = {},
913
+ opts: { body?: string; headers?: Record<string, string> } = {},
793
914
  ): Promise<WebDavResponse> {
794
915
  const auth = 'Basic ' + Buffer.from(`${this.o.username}:${pwd}`, 'utf8').toString('base64');
795
916
  const headers: Record<string, string> = {
796
917
  Authorization: auth,
797
918
  'Content-Type': 'application/json',
798
919
  'User-Agent': 'DSH-Config-Manager/0.1.55 (WebDAV Client)',
920
+ // 条件写头(If-Match / If-None-Match)与探针的 Depth 头从这里注入(cloud-sync 借鉴项 ①②)
921
+ ...(opts.headers ?? {}),
799
922
  };
800
923
  if (opts.body !== undefined) headers['Content-Length'] = String(Buffer.byteLength(opts.body, 'utf8'));
801
924
  try {
@@ -62,6 +62,40 @@ test('foreign-view:冻结码逐个有字典键,且键在字典里真实存
62
62
  }
63
63
  });
64
64
 
65
+ /**
66
+ * 本轮(2026-10-18)新增的 8 个冻结码 —— **显式列出**(不从 Object.keys 派生):
67
+ * 全量映射测试只保证「键存在」,本用例额外钉住这几条的 zh/en **都是真文案**(非空、不等于键名、
68
+ * 且中英不同文)。某一条被删或退回键名时,这里会单独变红,而不是被其它码的表绿掩盖。
69
+ */
70
+ const NEW_CODES_2026_10_18: readonly ForeignSkipCode[] = [
71
+ 'unsupported-permissions',
72
+ 'unsupported-settings-keys',
73
+ 'unsupported-agents',
74
+ 'unsupported-rules',
75
+ 'unsupported-plugins',
76
+ 'session-id-mismatch',
77
+ 'mcp-server-id-conflict',
78
+ 'project-scan-truncated',
79
+ ];
80
+
81
+ test('foreign-view:8 个新码在中英两套字典里都有非空文案(缺一即裸机器码)', () => {
82
+ assert.equal(NEW_CODES_2026_10_18.length, 8, '本用例只覆盖本轮那 8 个码(数量变了就必须同步改这里)');
83
+ for (const code of NEW_CODES_2026_10_18) {
84
+ const key = FOREIGN_SKIP_KEY[code];
85
+ assert.equal(key, 'foreign.skip.' + code, code + ' 的键形必须是 foreign.skip.<code>');
86
+ assert.ok(
87
+ Object.prototype.hasOwnProperty.call(uiZh, key),
88
+ code + ' 的字典键 ' + key + ' 在 uiZh 里不存在 —— 界面会显示裸机器码',
89
+ );
90
+ for (const [lang, t] of [['zh', zh], ['en', en]] as const) {
91
+ const text = t(key);
92
+ assert.ok(text.trim().length > 0, code + ' 的 ' + lang + ' 文案为空');
93
+ assert.notEqual(text, key, code + ' 的 ' + lang + ' 文案缺失(回退成了键名)');
94
+ }
95
+ assert.notEqual(zh(key), en(key), code + ' 的 zh/en 不得同文');
96
+ }
97
+ });
98
+
65
99
  test('foreign-view:foreignSkipKey 的未知码回退为可搜索的稳定标识(绝不 undefined)', () => {
66
100
  const line = foreignSkipLine({ code: 'brand-new-code-from-third-party' as ForeignSkipCode }, zh);
67
101
  assert.equal(line.code, 'brand-new-code-from-third-party');
@@ -137,6 +137,16 @@ export const FOREIGN_SKIP_KEY: Record<ForeignSkipCode, UiTextKey> = {
137
137
  // 档 B(t6)新增两条:把「推导出的 cwd」与「没有自动根」也变成可见事实,绝不静默
138
138
  'session-cwd-derived': 'foreign.skip.session-cwd-derived',
139
139
  'source-needs-explicit-path': 'foreign.skip.source-needs-explicit-path',
140
+ // 2026-10-18(8 个新冻结码):Claude Code 项目级 / 插件 / 权限 / 设置键诊断 + 会话 id 不一致 +
141
+ // MCP 同名 + 项目扫描触顶 —— 仍用全量 Record 兜住:新增码而没配文案 = 编译失败。
142
+ 'unsupported-permissions': 'foreign.skip.unsupported-permissions',
143
+ 'unsupported-settings-keys': 'foreign.skip.unsupported-settings-keys',
144
+ 'unsupported-agents': 'foreign.skip.unsupported-agents',
145
+ 'unsupported-rules': 'foreign.skip.unsupported-rules',
146
+ 'unsupported-plugins': 'foreign.skip.unsupported-plugins',
147
+ 'session-id-mismatch': 'foreign.skip.session-id-mismatch',
148
+ 'mcp-server-id-conflict': 'foreign.skip.mcp-server-id-conflict',
149
+ 'project-scan-truncated': 'foreign.skip.project-scan-truncated',
140
150
  }
141
151
 
142
152
  /**
package/src/ui/i18n.ts CHANGED
@@ -94,6 +94,7 @@ export const uiZh = {
94
94
  'report.action.done': '完成',
95
95
  // 错误
96
96
  'error.notMounted': 'config-manager 服务未挂载(插件未加载):请确认 profile 中已安装 dsh-config-manager 并重启 DSH',
97
+ 'error.apiPathMissed': '接口路径未命中(HTTP {status}):请求前缀为 {base},但该路径没有返回 JSON —— 若你经反向代理挂在子路径下,请检查代理是否转发了这个前缀(issue #108)',
97
98
  'error.httpInvalidJson': 'HTTP {status}: 无效的 JSON 响应',
98
99
  'error.fallback': '操作失败',
99
100
  'error.requestTimeout': '请求超时({seconds} 秒):未在时限内收到宿主响应,请重试',
@@ -534,6 +535,17 @@ export const uiZh = {
534
535
  'foreign.skip.legacy-rules-file': '旧式规则文件:DSH 无对等结构,只报告',
535
536
  'foreign.skip.instructions-merged': '多份指令源被合并为一个全局指令文件',
536
537
  'foreign.skip.instructions-override-selected': '命中更高优先级的指令文件(override),按它导入',
538
+ // ---------- 2026-10-18:Claude Code 项目级配置 / 插件 / 权限 / 设置键的如实诊断(8 个新码)----------
539
+ // 纪律:每条都要能被代码逐条证明 —— 只说「什么没搬、为什么、本项是否可补」,
540
+ // 绝不写「已支持 / 完全兼容」这类无法被代码证明的话。
541
+ 'foreign.skip.unsupported-permissions': '权限规则(allow/ask/deny)未迁移:DSH 没有权限规则文件的对等结构,这些规则不在本包内,导入后不会生效',
542
+ 'foreign.skip.unsupported-settings-keys': 'settings.json 里其余未迁移的顶层键:只记录键名与条数,键值一律不读取,也不会写进本包',
543
+ 'foreign.skip.unsupported-agents': '.claude/agents/*.md 子代理定义未迁移:DSH 的子代理是运行时 provider 注册制,没有文件级对等结构',
544
+ 'foreign.skip.unsupported-rules': '.claude/rules/*.md(带 frontmatter 作用域)未迁移:并入全局指令会丢掉作用域,故只报告不合并',
545
+ 'foreign.skip.unsupported-plugins': 'Claude Code 插件本体未迁移:插件的 hooks / 命令靠 Claude 运行时执行,DSH 无法加载,插件代码一律不复制;插件自带的 skills 与 .mcp.json 里的 MCP 定义另行迁移',
546
+ 'foreign.skip.session-id-mismatch': '会话记录里的 sessionId 与文件名不一致:DSH 按目录名/文件名定位会话,本次以文件名为准,记录里的 id 未采用',
547
+ 'foreign.skip.mcp-server-id-conflict': '同名 MCP server 在多处出现(全局 / 项目级 / 插件):先到先得,其余同名项只报告未迁移',
548
+ 'foreign.skip.project-scan-truncated': '项目较多:项目级扫描只覆盖了上限内的项目,其余项目本次未读取,其配置不在本包内',
537
549
  // 三类已知有损(F-5)的**逐条文案**:由 src/ui/foreign-view.ts 的 foreignLossyRows 渲染,
538
550
  // 卡片标题 / 说明 / 适用性徽章属客户端外壳 chrome,键在 src/client/locales.ts。
539
551
  'foreign.lossy.file-section-secrets.title': '文件类分区的明文不做剥离',
@@ -660,6 +672,7 @@ export const uiEn: Record<UiTextKey, string> = {
660
672
  'report.unit.notes': 'note(s)',
661
673
  'report.action.done': 'Done',
662
674
  'error.notMounted': 'config-manager service is not mounted (plugin not loaded): make sure dsh-config-manager is installed in the profile and restart DSH',
675
+ 'error.apiPathMissed': 'API path not matched (HTTP {status}): the request prefix is {base} but that path did not return JSON — if you serve DSH under a reverse-proxy sub-path, check that this prefix is proxied (issue #108)',
663
676
  'error.httpInvalidJson': 'HTTP {status}: invalid JSON response',
664
677
  'error.fallback': 'Operation failed',
665
678
  'error.requestTimeout': 'Request timed out ({seconds}s): the host did not respond in time, please retry',
@@ -1085,6 +1098,15 @@ export const uiEn: Record<UiTextKey, string> = {
1085
1098
  'foreign.skip.legacy-rules-file': 'Legacy rules file: DSH has no equivalent, reported only',
1086
1099
  'foreign.skip.instructions-merged': 'Several instruction sources were merged into one global instruction file',
1087
1100
  'foreign.skip.instructions-override-selected': 'A higher-priority instruction file (override) was found and is the one imported',
1101
+ // 2026-10-18: honest diagnostics for Claude Code project-level config / plugins / permissions / settings keys (8 new codes)
1102
+ 'foreign.skip.unsupported-permissions': 'Permission rules (allow/ask/deny) were not migrated: DSH has no equivalent permission-rule file, so they are not in this bundle and will not take effect after import',
1103
+ 'foreign.skip.unsupported-settings-keys': 'Other unmigrated top-level keys in settings.json: only key names and counts are recorded; values are never read and never enter this bundle',
1104
+ 'foreign.skip.unsupported-agents': 'Subagent definitions in .claude/agents/*.md were not migrated: DSH subagents are registered at runtime by providers and have no file-level equivalent',
1105
+ 'foreign.skip.unsupported-rules': '.claude/rules/*.md files (with frontmatter scoping) were not migrated: merging them into the global instructions would drop their scope, so they are reported only',
1106
+ 'foreign.skip.unsupported-plugins': 'Claude Code plugin bodies were not migrated: their hooks and commands run inside the Claude runtime and cannot be loaded by DSH, and plugin code is never copied; skills and MCP definitions inside a plugin .mcp.json are migrated separately',
1107
+ 'foreign.skip.session-id-mismatch': 'The sessionId inside the session record does not match the file name: DSH locates sessions by directory and file name, so the file name wins and the recorded id is not used',
1108
+ 'foreign.skip.mcp-server-id-conflict': 'The same MCP server name appears in several places (global / project / plugin): the first one wins, and the other duplicates are reported but not migrated',
1109
+ 'foreign.skip.project-scan-truncated': 'Many projects were found: the project-level scan only covered the projects within the limit; the remaining projects were not read and their configuration is not in this bundle',
1088
1110
  // Per-item text for the three known lossy behaviours (F-5), rendered by
1089
1111
  // src/ui/foreign-view.ts; the card chrome keys live in src/client/locales.ts.
1090
1112
  'foreign.lossy.file-section-secrets.title': 'Plaintext in file-based sections is never stripped',
@@ -613,6 +613,12 @@ export interface RollbackSegmentResult {
613
613
  /** 无法安全撤销 ⇒ 未写(宿主回传才有) */
614
614
  unavailableCount?: number
615
615
  unavailablePaths?: string[]
616
+ /**
617
+ * **落点无人认领**(时间窗证据表明那一刻没有任何会话活跃:手改 / 外部工具 / 另一个进程)
618
+ * ⇒ 未写(宿主回传才有)。与 unavailableCount 分开:成因不同,用户要做的事也不同。
619
+ */
620
+ externalCount?: number
621
+ externalPaths?: string[]
616
622
  /** 写入前发现又被改过(并发)⇒ 未写(宿主回传才有) */
617
623
  concurrentCount?: number
618
624
  concurrentPaths?: string[]
@@ -1447,6 +1453,12 @@ export interface RollbackRewindRowView {
1447
1453
  unavailableCount: number
1448
1454
  /** 写入前发现又被改过(并发)未写的文件数 */
1449
1455
  concurrentCount: number
1456
+ /**
1457
+ * **落点无人认领**未写的文件数 + 有界清单(2026-10-18):时间窗表明改这条路径的那一刻
1458
+ * 没有任何会话活跃 ⇒ 手改 / 外部工具 / 另一个 DSH 进程。0 = 没有(或宿主没回传)。
1459
+ */
1460
+ unclaimedCount: number
1461
+ unclaimedPaths: string[]
1450
1462
  }
1451
1463
 
1452
1464
  export function rollbackRewindRows(result: RollbackRewindResult | null): RollbackRewindRowView[] {
@@ -1480,6 +1492,8 @@ export function rollbackRewindRows(result: RollbackRewindResult | null): Rollbac
1480
1492
  conflictPaths: redactLines(segment.conflictPaths),
1481
1493
  unavailableCount: numberOrNull(segment.unavailableCount) ?? 0,
1482
1494
  concurrentCount: numberOrNull(segment.concurrentCount) ?? 0,
1495
+ unclaimedCount: numberOrNull(segment.externalCount) ?? 0,
1496
+ unclaimedPaths: redactLines(segment.externalPaths),
1483
1497
  }
1484
1498
  })
1485
1499
  }
@@ -12,6 +12,7 @@ import test from 'node:test'
12
12
  import assert from 'node:assert/strict'
13
13
 
14
14
  import {
15
+ workdirBacklog,
15
16
  autosyncIntervalKey, buildSelectionRequest, buildSyncChannelBody, buildSyncConfigBody, buildSyncPushBody,
16
17
  channelBackfillFromStatus, computeEncryptInvalid, encryptToggle, formatSyncStatusTimestamp,
17
18
  formatSyncUrlPreview, githubPollDecision, hasSelectedSections, includeSecretsToggle,
@@ -23,8 +24,9 @@ import {
23
24
  validateCloudEndpoint, validateCloudForm, validateCloudPrefix, validateCloudRegion, validateGistForm,
24
25
  validateGistId, validateS3Form, DEFAULT_S3_PROVIDER, S3_PROVIDERS,
25
26
  // issue #79:工作副本自检 → 状态 / 入口判定
26
- workdirStateKey, workdirStateBadgeKind, needsWorkdirRelink, canRecloneWorkdir,
27
+ workdirStateKey, workdirStateBadgeKind, needsWorkdirRelink, canRecloneWorkdir, canAdoptWorkdir,
27
28
  type SaveQueueState, type SyncChannelSettings, type SyncFormSnapshot,
29
+
28
30
  } from './sync-settings-view.ts'
29
31
 
30
32
  /* ------------------------------------------------------------- 夹具 */
@@ -548,3 +550,28 @@ test('issue #79:入口判定 —— 只有地址不一致才给「重指向」
548
550
  assert.equal(canRecloneWorkdir({ error: '读取失败' }), false, '读不出来不得提供破坏性入口')
549
551
  assert.equal(canRecloneWorkdir(undefined), false)
550
552
  })
553
+
554
+ /* ---------------------------------------------------------------- issue #104 采纳远端历史 */
555
+
556
+ test('issue #104:canAdoptWorkdir —— 只在工作副本确实存在时给「采纳远端历史」,且不要求 origin 已跟随', () => {
557
+ // 触发路径恰恰是 mismatch:改仓库地址后 origin 仍指旧地址,pull --ff-only 因两条历史不相交失败。
558
+ assert.equal(canAdoptWorkdir(workdirFacts({ match: false })), true, 'mismatch 才是主场景(内部会先 reconcile origin)')
559
+ assert.equal(canAdoptWorkdir(workdirFacts()), true, '已跟随也能用:远端被重写后同样分叉')
560
+ assert.equal(canAdoptWorkdir(workdirFacts({ exists: false })), false, '目录都不存在 → 没有可合并的工作副本(应走首次克隆)')
561
+ assert.equal(canAdoptWorkdir({ error: '读取失败' }), false, '读不出来就不得提供写入口')
562
+ assert.equal(canAdoptWorkdir(undefined), false, '非 git 通道不得出现该入口')
563
+ })
564
+
565
+ test('issue #104 建议 3:workdirBacklog —— 本地领先且远端零快照才算「从没备份过」', () => {
566
+ // 没有工作副本 / 自检失败 / 数字缺一项:一律不猜,不给告警
567
+ assert.equal(workdirBacklog(undefined).kind, 'unknown');
568
+ assert.equal(workdirBacklog({ error: 'git not found' }).kind, 'unknown');
569
+ assert.equal(workdirBacklog({ facts: { ahead: null, remoteSnapshots: null } }).kind, 'unknown');
570
+ assert.equal(workdirBacklog({ facts: { ahead: 3, remoteSnapshots: null } }).kind, 'unknown');
571
+ // 没有未上传提交 → clean(不是告警)
572
+ assert.deepEqual(workdirBacklog({ facts: { ahead: 0, remoteSnapshots: 14 } }), { kind: 'clean', ahead: 0, remoteSnapshots: 14 });
573
+ // 关键现场:本地攒着提交、远端一个快照都没有
574
+ assert.deepEqual(workdirBacklog({ facts: { ahead: 14, remoteSnapshots: 0 } }), { kind: 'never-synced', ahead: 14 });
575
+ // 远端有过快照 ⇒ 只是没推完,语气不同
576
+ assert.deepEqual(workdirBacklog({ facts: { ahead: 3, remoteSnapshots: 5 } }), { kind: 'ahead', ahead: 3, remoteSnapshots: 5 });
577
+ });
@@ -870,6 +870,49 @@ export function canRecloneWorkdir(
870
870
  return workDir !== undefined && workDir.error === undefined && workDir.facts?.exists === true
871
871
  }
872
872
 
873
+ /**
874
+ * 是否可以「采纳远端历史」(issue #104):前提与「重新克隆」相同(自检成功、目录里确实是 git
875
+ * 工作副本),区别只在破坏性 —— adopt 不删任何本地提交,可以 revert。
876
+ *
877
+ * 为什么**不**要求 workDir.match:issue #104 的触发路径恰恰是 mismatch(用户把仓库地址改成另一个
878
+ * 仓库后,自检看到 origin 仍指旧地址)。adopt 内部第一步就是 reconcileOrigin + fetch 当前配置地址,
879
+ * 所以 mismatch 正是该给这个入口的时候。地址还没跟上时先 relink 也行,但两键并不互斥。
880
+ */
881
+ export function canAdoptWorkdir(
882
+ workDir: { facts?: { exists: boolean; match: boolean }; error?: string } | undefined,
883
+ ): boolean {
884
+ return canRecloneWorkdir(workDir)
885
+ }
886
+
887
+ /**
888
+ * 工作副本的「积压」判定(issue #104 建议 3)。
889
+ *
890
+ * 要回答的是 issue 里那个拖了两周才暴露的问题:本地一直在攒提交,远端从来没收到过快照,
891
+ * 而界面没有任何一处说这件事。判定只用状态接口回传的两个数字(`ahead` / `remoteSnapshots`),
892
+ * 任何一个是 null(没走 detail 自检 / 无远端分支)就不猜 —— 返回 unknown,不渲染告警。
893
+ *
894
+ * - clean:没有未上传的提交;
895
+ * - never-synced:本地领先且远端零快照 ⇒ **醒目告警**(这条通道可能一直没在真正备份);
896
+ * - ahead:本地领先,但远端确实收到过快照(只是还没推完)。
897
+ */
898
+ export type WorkdirBacklog =
899
+ | { kind: 'unknown' }
900
+ | { kind: 'clean'; ahead: number; remoteSnapshots: number }
901
+ | { kind: 'ahead'; ahead: number; remoteSnapshots: number }
902
+ | { kind: 'never-synced'; ahead: number }
903
+
904
+ export function workdirBacklog(
905
+ workDir: { facts?: { ahead?: number | null; remoteSnapshots?: number | null }; error?: string } | undefined,
906
+ ): WorkdirBacklog {
907
+ if (workDir === undefined || workDir.error !== undefined) return { kind: 'unknown' }
908
+ const ahead = workDir.facts?.ahead
909
+ const remoteSnapshots = workDir.facts?.remoteSnapshots
910
+ if (typeof ahead !== 'number' || typeof remoteSnapshots !== 'number') return { kind: 'unknown' }
911
+ if (ahead <= 0) return { kind: 'clean', ahead, remoteSnapshots }
912
+ if (remoteSnapshots === 0) return { kind: 'never-synced', ahead }
913
+ return { kind: 'ahead', ahead, remoteSnapshots }
914
+ }
915
+
873
916
  export function formatSyncUrlPreview(url: string, max = 60): string {
874
917
  return url.slice(0, max)
875
918
  }
package/src/ui/types.ts CHANGED
@@ -303,10 +303,32 @@ export interface RecoveryIncident {
303
303
  createdAt: string;
304
304
  }
305
305
 
306
+ /**
307
+ * 正在**正常执行中**的操作(GET /recovery/status 的 `live`,加法字段)。
308
+ *
309
+ * 与 `incidents` 互斥:同一条 active journal 要么是「正在跑」(GLOBAL 锁由它持有、
310
+ * 且 journal.ownerInstanceId 与锁持有者一致),要么是「一次未正常完成的操作」。
311
+ * 判别只发生在宿主侧(single source = recovery-orchestrator 的 isLiveInFlight),
312
+ * 客户端只负责如实展示。
313
+ *
314
+ * 旧宿主不返回 → undefined(面板按「无进行中操作」处理)。
315
+ */
316
+ export interface RecoveryActiveOperation {
317
+ operationId: string;
318
+ operationType: string;
319
+ state: string;
320
+ startedAt: string;
321
+ }
322
+
306
323
  /** GET /recovery/status 响应。 */
307
324
  export interface RecoveryStatus {
308
325
  incidents: RecoveryIncident[];
309
326
  running: { runId: string; status: string }[];
327
+ /**
328
+ * 正在正常执行的操作(不是 incident)。用途:面板要能解释「为什么现在什么都点不动」——
329
+ * 此前这类操作被误报成事故,「执行恢复」永久灰、放弃恢复一直 423,用户拿不到任何原因。
330
+ */
331
+ live?: RecoveryActiveOperation[];
310
332
  /**
311
333
  * 环境锁状态摘要(issue #31)。纯残留锁**不是** journal:进程在 op 期间被杀时
312
334
  * `journalId: null`、`transactions/active/` 为空,incidents 恒为 [],而 423 文案却
@@ -974,7 +974,7 @@ export class EnvironmentLockManager {
974
974
  * heartbeat expired + PID alive + identity 相同 → LOCKED (owner alive / heartbeat degraded)
975
975
  * probe 无法可靠确定 → UNKNOWN_STATE
976
976
  */
977
- async inspectLockState(): Promise<{ state: LockState; detail?: string; corruptOwnership?: boolean }> {
977
+ async inspectLockState(): Promise<{ state: LockState; detail?: string; corruptOwnership?: boolean; ownerInstanceId?: string }> {
978
978
  const st = await this.readOwnershipState()
979
979
  if (st.kind === 'missing') {
980
980
  // 无所有权文件:可能在创建中、也已被清除 —— **报告**层如实说「没有锁」(issue #79)。
@@ -1002,7 +1002,9 @@ export class EnvironmentLockManager {
1002
1002
  const heartbeatFresh = heartbeat !== null && (now - heartbeat.heartbeatAt) <= this.staleAfterMs
1003
1003
 
1004
1004
  if (heartbeatFresh) {
1005
- return { state: 'LOCKED', detail: `owner op=${rec.op} pid=${rec.owner.pid} (heartbeat fresh)` }
1005
+ // ownerInstanceId 供**宿主内部**做 journal↔锁归属匹配(recovery status 的 in-flight 判定)。
1006
+ // 它不是诊断文案:绝不进 HTTP 响应体(编排器只消费,不回传)。
1007
+ return { state: 'LOCKED', ownerInstanceId: rec.owner.instanceId, detail: `owner op=${rec.op} pid=${rec.owner.pid} (heartbeat fresh)` }
1006
1008
  }
1007
1009
 
1008
1010
  // heartbeat 已过期:先探测 PID liveness(kill(pid,0) 跨平台可靠;ESRCH = 确证不存在)
@@ -1014,7 +1016,7 @@ export class EnvironmentLockManager {
1014
1016
  }
1015
1017
  // 确证死亡(ESRCH)→ STALE(不依赖 OS identity 能力)
1016
1018
  if (!ident.alive) {
1017
- return { state: 'STALE_LOCK_DETECTED', detail: utilsMsg('utils.envLock.ownerConfirmedDead', { pid: rec.owner.pid }) }
1019
+ return { state: 'STALE_LOCK_DETECTED', ownerInstanceId: rec.owner.instanceId, detail: utilsMsg('utils.envLock.ownerConfirmedDead', { pid: rec.owner.pid }) }
1018
1020
  }
1019
1021
  // PID 存活:需 OS identity 区分「reuse」与「同一进程 alive(heartbeat degraded)」
1020
1022
  // —— capability/值缺失 → 无法可靠确定 → UNKNOWN_STATE(保守拒删)
@@ -1027,6 +1029,7 @@ export class EnvironmentLockManager {
1027
1029
  if (longExpired !== null) {
1028
1030
  return {
1029
1031
  state: 'STALE_LOCK_DETECTED',
1032
+ ownerInstanceId: rec.owner.instanceId,
1030
1033
  detail: utilsMsg('utils.envLock.ownerAliveUnverifiable', { pid: rec.owner.pid, longExpired }),
1031
1034
  }
1032
1035
  }
@@ -1036,10 +1039,10 @@ export class EnvironmentLockManager {
1036
1039
  }
1037
1040
  }
1038
1041
  if (rec.owner.osProcessStartIdentity !== ident.osProcessStartIdentity) {
1039
- return { state: 'STALE_LOCK_DETECTED', detail: utilsMsg('utils.envLock.pidReuse', { pid: rec.owner.pid }) }
1042
+ return { state: 'STALE_LOCK_DETECTED', ownerInstanceId: rec.owner.instanceId, detail: utilsMsg('utils.envLock.pidReuse', { pid: rec.owner.pid }) }
1040
1043
  }
1041
1044
  // alive 且 identity 相同 → owner alive(heartbeat degraded 保护)
1042
- return { state: 'LOCKED', detail: utilsMsg('utils.envLock.ownerAliveDegraded') }
1045
+ return { state: 'LOCKED', ownerInstanceId: rec.owner.instanceId, detail: utilsMsg('utils.envLock.ownerAliveDegraded') }
1043
1046
  }
1044
1047
 
1045
1048
  /* ------------------------------------------------------------ recover (explicit) */