dsh-config-manager 0.1.71 → 0.1.73

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 (596) hide show
  1. package/README.md +149 -98
  2. package/README.zh-CN.md +245 -88
  3. package/lib/adapters/agent-instructions.js +1 -1
  4. package/lib/adapters/file-collection.js +1 -1
  5. package/lib/adapters/index.d.ts +2 -1
  6. package/lib/adapters/index.js +6 -1
  7. package/lib/adapters/link-report.d.ts +1 -0
  8. package/lib/adapters/link-report.js +27 -3
  9. package/lib/adapters/messages.d.ts +21 -0
  10. package/lib/adapters/messages.js +57 -0
  11. package/lib/adapters/plugin-data.d.ts +114 -0
  12. package/lib/adapters/plugin-data.js +309 -0
  13. package/lib/adapters/plugin-files.js +3 -2
  14. package/lib/adapters/plugins.js +4 -3
  15. package/lib/adapters/prompts.d.ts +1 -0
  16. package/lib/adapters/prompts.js +7 -1
  17. package/lib/adapters/self.d.ts +8 -2
  18. package/lib/adapters/self.js +3 -2
  19. package/lib/adapters/sessions.d.ts +1 -0
  20. package/lib/adapters/sessions.js +4 -2
  21. package/lib/adapters/test-helpers.d.ts +9 -0
  22. package/lib/adapters/test-helpers.js +14 -0
  23. package/lib/adapters/ui.d.ts +2 -2
  24. package/lib/adapters/ui.js +25 -25
  25. package/lib/adapters/workspaces.d.ts +1 -0
  26. package/lib/adapters/workspaces.js +7 -3
  27. package/lib/cli/actions.d.ts +9 -1
  28. package/lib/cli/actions.js +5 -2
  29. package/lib/cli/help.js +23 -10
  30. package/lib/cli/index.d.ts +23 -0
  31. package/lib/cli/index.js +90 -32
  32. package/lib/cli/sessions-inspect.js +3 -1
  33. package/lib/cli/sessions-repair.js +25 -6
  34. package/lib/cli/web/page.d.ts +12 -0
  35. package/lib/cli/web/page.js +21 -12
  36. package/lib/cli/web/routes.js +2 -2
  37. package/lib/cli/web/server.js +4 -4
  38. package/lib/client.d.ts +1442 -494
  39. package/lib/client.js +15326 -8520
  40. package/lib/core/analyzer.d.ts +11 -2
  41. package/lib/core/analyzer.js +31 -8
  42. package/lib/core/backup-plan.d.ts +10 -0
  43. package/lib/core/backup-plan.js +51 -20
  44. package/lib/core/backup-verify.js +24 -23
  45. package/lib/core/backup.d.ts +25 -1
  46. package/lib/core/backup.js +99 -28
  47. package/lib/core/boot-rescue.js +4 -3
  48. package/lib/core/boot-safety.js +2 -1
  49. package/lib/core/command-rollback.d.ts +182 -0
  50. package/lib/core/command-rollback.js +293 -0
  51. package/lib/core/consult-source.js +7 -6
  52. package/lib/core/disk-usage.d.ts +74 -10
  53. package/lib/core/disk-usage.js +67 -4
  54. package/lib/core/engine-locale.d.ts +87 -0
  55. package/lib/core/engine-locale.js +125 -0
  56. package/lib/core/exporter.js +3 -3
  57. package/lib/core/fork-reconcile.d.ts +128 -0
  58. package/lib/core/fork-reconcile.js +150 -0
  59. package/lib/core/journal.js +6 -5
  60. package/lib/core/local-plugin-host.js +3 -2
  61. package/lib/core/local-plugin-pack.js +11 -6
  62. package/lib/core/messages-core.d.ts +294 -0
  63. package/lib/core/messages-core.js +619 -0
  64. package/lib/core/messages.d.ts +61 -2
  65. package/lib/core/messages.js +118 -13
  66. package/lib/core/migration-consult.js +21 -20
  67. package/lib/core/migration-history.js +19 -5
  68. package/lib/core/model-tools.d.ts +0 -22
  69. package/lib/core/model-tools.js +50 -27
  70. package/lib/core/phase3-host.js +30 -5
  71. package/lib/core/plugin-cli.js +18 -19
  72. package/lib/core/plugin-data-registry.d.ts +124 -0
  73. package/lib/core/plugin-data-registry.js +198 -0
  74. package/lib/core/reconcile.d.ts +2 -14
  75. package/lib/core/reconcile.js +87 -17
  76. package/lib/core/recovery-orchestrator.js +19 -18
  77. package/lib/core/reinstall.d.ts +4 -0
  78. package/lib/core/reinstall.js +26 -21
  79. package/lib/core/restore.js +20 -17
  80. package/lib/core/rollback-gate.d.ts +33 -0
  81. package/lib/core/rollback-gate.js +57 -0
  82. package/lib/core/rollback-ignore-rules.d.ts +31 -0
  83. package/lib/core/rollback-ignore-rules.js +140 -0
  84. package/lib/core/rollback-ledger.d.ts +248 -0
  85. package/lib/core/rollback-ledger.js +1282 -0
  86. package/lib/core/rollback-point.d.ts +1391 -0
  87. package/lib/core/rollback-point.js +3503 -0
  88. package/lib/core/rollback.js +6 -4
  89. package/lib/core/run-registry.js +6 -5
  90. package/lib/core/session-health.d.ts +15 -0
  91. package/lib/core/session-meta.d.ts +42 -2
  92. package/lib/core/session-meta.js +140 -1
  93. package/lib/core/session-title.d.ts +28 -0
  94. package/lib/core/session-title.js +185 -0
  95. package/lib/core/startup-barrier.d.ts +24 -0
  96. package/lib/core/startup-barrier.js +22 -0
  97. package/lib/core/transaction-coordinator.js +4 -3
  98. package/lib/core/types.d.ts +37 -1
  99. package/lib/core/validator.js +5 -4
  100. package/lib/core/verify-recovery.js +31 -30
  101. package/lib/foreign/bundle.js +3 -2
  102. package/lib/foreign/continue.js +2 -1
  103. package/lib/foreign/crush.js +2 -1
  104. package/lib/foreign/gemini.js +2 -1
  105. package/lib/foreign/goose.js +2 -1
  106. package/lib/foreign/kilocode.js +2 -1
  107. package/lib/foreign/kimi.js +2 -1
  108. package/lib/foreign/messages.d.ts +15 -0
  109. package/lib/foreign/messages.js +40 -0
  110. package/lib/foreign/mimocode.js +2 -1
  111. package/lib/foreign/openclaw.js +2 -1
  112. package/lib/foreign/opencode.js +2 -1
  113. package/lib/foreign/pi.js +2 -1
  114. package/lib/foreign/qoder.js +2 -1
  115. package/lib/foreign/qwen.js +2 -1
  116. package/lib/foreign/registry.d.ts +1 -1
  117. package/lib/foreign/registry.js +8 -7
  118. package/lib/foreign/teleagent.js +2 -1
  119. package/lib/foreign/workbuddy.js +2 -1
  120. package/lib/foreign/zcode.js +2 -1
  121. package/lib/foreign/zed.js +2 -1
  122. package/lib/index.d.ts +227 -7
  123. package/lib/index.js +799 -54
  124. package/lib/market/git-file-writer.js +20 -19
  125. package/lib/market/github-repos.js +35 -19
  126. package/lib/market/index-parser.js +31 -30
  127. package/lib/market/messages-market.d.ts +138 -0
  128. package/lib/market/messages-market.js +305 -0
  129. package/lib/market/my-repo.d.ts +2 -2
  130. package/lib/market/my-repo.js +32 -29
  131. package/lib/market/prepare.js +18 -15
  132. package/lib/market/security.js +20 -19
  133. package/lib/market/types.js +2 -1
  134. package/lib/market/url.js +2 -1
  135. package/lib/migrations/index.js +6 -5
  136. package/lib/migrations/messages-migrations.d.ts +32 -0
  137. package/lib/migrations/messages-migrations.js +58 -0
  138. package/lib/profiles/dsh-profile-launcher.js +2 -1
  139. package/lib/profiles/dsh-profile-runtime.js +2 -1
  140. package/lib/profiles/messages.d.ts +7 -0
  141. package/lib/profiles/messages.js +27 -0
  142. package/lib/routes/backup.js +18 -2
  143. package/lib/routes/import.js +3 -3
  144. package/lib/routes/index.d.ts +1 -1
  145. package/lib/routes/kit.d.ts +3 -2
  146. package/lib/routes/kit.js +6 -1
  147. package/lib/routes/market.js +1 -1
  148. package/lib/routes/messages.d.ts +43 -0
  149. package/lib/routes/messages.js +102 -0
  150. package/lib/routes/prefs.js +26 -3
  151. package/lib/routes/profiles.js +12 -6
  152. package/lib/routes/recovery.d.ts +21 -0
  153. package/lib/routes/recovery.js +478 -94
  154. package/lib/routes/snapshots.js +51 -4
  155. package/lib/routes/sync.js +79 -17
  156. package/lib/schema/config.d.ts +11 -1
  157. package/lib/schema/config.js +52 -20
  158. package/lib/schema/index.d.ts +8 -6
  159. package/lib/schema/index.js +8 -6
  160. package/lib/schema/manifest.js +19 -18
  161. package/lib/schema/messages.d.ts +47 -0
  162. package/lib/schema/messages.js +110 -0
  163. package/lib/schema/section-registry.d.ts +42 -15
  164. package/lib/schema/section-registry.js +67 -32
  165. package/lib/schema/types.d.ts +1 -1
  166. package/lib/schema/versions.js +6 -5
  167. package/lib/security/encryption.js +16 -15
  168. package/lib/security/integrity.js +8 -7
  169. package/lib/security/messages.d.ts +30 -0
  170. package/lib/security/messages.js +77 -0
  171. package/lib/security/redaction.js +12 -0
  172. package/lib/security/secret-scanner.js +16 -13
  173. package/lib/security/vault.js +2 -1
  174. package/lib/security/zip-security.js +4 -3
  175. package/lib/sync/ancestor.js +2 -1
  176. package/lib/sync/autosync-config.d.ts +11 -0
  177. package/lib/sync/autosync-config.js +12 -0
  178. package/lib/sync/autosync-scheduler.d.ts +11 -1
  179. package/lib/sync/autosync-scheduler.js +115 -76
  180. package/lib/sync/backup-files.js +3 -2
  181. package/lib/sync/backup-scheduler.js +6 -5
  182. package/lib/sync/blob-store.js +2 -1
  183. package/lib/sync/git/git-transport.d.ts +55 -0
  184. package/lib/sync/git/git-transport.js +152 -2
  185. package/lib/sync/github-auth.js +10 -9
  186. package/lib/sync/layout.js +13 -12
  187. package/lib/sync/messages.d.ts +63 -0
  188. package/lib/sync/messages.js +149 -0
  189. package/lib/sync/review-queue.js +6 -5
  190. package/lib/sync/s3/messages.d.ts +7 -0
  191. package/lib/sync/s3/messages.js +16 -0
  192. package/lib/sync/s3/s3-providers.d.ts +4 -1
  193. package/lib/sync/s3/s3-providers.js +5 -5
  194. package/lib/sync/s3/s3-transport.js +1 -1
  195. package/lib/sync/s3/sigv4.js +2 -1
  196. package/lib/sync/snapshot-crypto.js +6 -5
  197. package/lib/sync/snapshot-json.js +3 -2
  198. package/lib/sync/sync-engine.d.ts +58 -22
  199. package/lib/sync/sync-engine.js +119 -48
  200. package/lib/sync/sync-history.js +7 -6
  201. package/lib/sync/sync-selection.d.ts +15 -4
  202. package/lib/sync/sync-selection.js +17 -5
  203. package/lib/sync/ui-prefs.d.ts +22 -0
  204. package/lib/sync/ui-prefs.js +28 -0
  205. package/lib/sync/webdav/webdav-transport.d.ts +1 -0
  206. package/lib/sync/webdav/webdav-transport.js +4 -1
  207. package/lib/ui/artifact-view.js +4 -3
  208. package/lib/ui/commands.d.ts +1 -1
  209. package/lib/ui/commands.js +10 -0
  210. package/lib/ui/diff-view.d.ts +10 -0
  211. package/lib/ui/diff-view.js +17 -0
  212. package/lib/ui/disk-usage-view.d.ts +33 -4
  213. package/lib/ui/disk-usage-view.js +29 -1
  214. package/lib/ui/errors.d.ts +1 -1
  215. package/lib/ui/errors.js +4 -3
  216. package/lib/ui/export-flow.js +26 -20
  217. package/lib/ui/foreign-view.d.ts +5 -1
  218. package/lib/ui/foreign-view.js +26 -2
  219. package/lib/ui/i18n.d.ts +68 -1
  220. package/lib/ui/i18n.js +120 -2
  221. package/lib/ui/import-wizard.d.ts +7 -0
  222. package/lib/ui/import-wizard.js +13 -10
  223. package/lib/ui/progress.d.ts +2 -2
  224. package/lib/ui/progress.js +1 -2
  225. package/lib/ui/report.d.ts +5 -5
  226. package/lib/ui/report.js +6 -6
  227. package/lib/ui/rollback-view.d.ts +815 -0
  228. package/lib/ui/rollback-view.js +738 -0
  229. package/lib/ui/selection-model.d.ts +41 -0
  230. package/lib/ui/selection-model.js +30 -0
  231. package/lib/ui/session-inventory-view.d.ts +97 -3
  232. package/lib/ui/session-inventory-view.js +76 -2
  233. package/lib/ui/session-issue-labels.d.ts +43 -0
  234. package/lib/ui/session-issue-labels.js +48 -0
  235. package/lib/ui/sync-settings-view.d.ts +33 -0
  236. package/lib/ui/sync-settings-view.js +40 -0
  237. package/lib/ui/test-helpers.js +2 -1
  238. package/lib/ui/types.d.ts +8 -3
  239. package/lib/ui/types.js +1 -1
  240. package/lib/utils/atomic-write.js +6 -5
  241. package/lib/utils/bundle-scan.d.ts +11 -1
  242. package/lib/utils/bundle-scan.js +12 -1
  243. package/lib/utils/env-lock.d.ts +10 -0
  244. package/lib/utils/env-lock.js +73 -46
  245. package/lib/utils/json.js +6 -5
  246. package/lib/utils/messages-utils.d.ts +96 -0
  247. package/lib/utils/messages-utils.js +216 -0
  248. package/lib/utils/proxy.js +18 -5
  249. package/lib/utils/recursive-walk.d.ts +22 -0
  250. package/lib/utils/recursive-walk.js +75 -1
  251. package/lib/utils/session-log-repair.js +10 -9
  252. package/lib/utils/zip.js +27 -26
  253. package/package.json +12 -2
  254. package/src/adapters/agent-instructions.ts +1 -1
  255. package/src/adapters/file-collection.test.ts +1 -0
  256. package/src/adapters/file-collection.ts +1 -1
  257. package/src/adapters/files.test.ts +27 -0
  258. package/src/adapters/index.ts +6 -1
  259. package/src/adapters/link-report.test.ts +22 -2
  260. package/src/adapters/link-report.ts +29 -4
  261. package/src/adapters/messages.ts +61 -0
  262. package/src/adapters/plugin-data.test.ts +225 -0
  263. package/src/adapters/plugin-data.ts +351 -0
  264. package/src/adapters/plugin-files.ts +3 -2
  265. package/src/adapters/plugins.ts +4 -3
  266. package/src/adapters/prompts.ts +7 -1
  267. package/src/adapters/self.test.ts +26 -0
  268. package/src/adapters/self.ts +10 -3
  269. package/src/adapters/sessions.ts +4 -2
  270. package/src/adapters/settings.test.ts +38 -1
  271. package/src/adapters/test-helpers.ts +15 -0
  272. package/src/adapters/ui.test.ts +2 -2
  273. package/src/adapters/ui.ts +27 -26
  274. package/src/adapters/workspaces.ts +7 -3
  275. package/src/cli/actions.test.ts +28 -0
  276. package/src/cli/actions.ts +11 -2
  277. package/src/cli/help.ts +23 -10
  278. package/src/cli/index-guards.test.ts +193 -0
  279. package/src/cli/index.ts +106 -31
  280. package/src/cli/sessions-inspect.test.ts +15 -1
  281. package/src/cli/sessions-inspect.ts +3 -1
  282. package/src/cli/sessions-repair.test.ts +84 -0
  283. package/src/cli/sessions-repair.ts +33 -7
  284. package/src/cli/web/console-route-parity.test.ts +115 -0
  285. package/src/cli/web/page.ts +23 -12
  286. package/src/cli/web/routes.ts +2 -1
  287. package/src/cli/web/server.ts +4 -4
  288. package/src/cli/web/web.test.ts +2 -2
  289. package/src/client/ConfigManagerSection.tsx +30 -2
  290. package/src/client/about/AboutPanel.tsx +2 -2
  291. package/src/client/about/PluginUpdateDialog.tsx +1 -1
  292. package/src/client/about/ReleaseNotesDialog.tsx +2 -2
  293. package/src/client/about/about-view.test.ts +13 -3
  294. package/src/client/about/about-view.ts +10 -6
  295. package/src/client/about/release-notes-view.test.ts +14 -4
  296. package/src/client/about/release-notes-view.ts +4 -2
  297. package/src/client/api.ts +18 -6
  298. package/src/client/chat/ChatEntryView.tsx +411 -0
  299. package/src/client/chat/RollbackDiffTab.tsx +108 -0
  300. package/src/client/chat/RollbackFileIcon.tsx +96 -0
  301. package/src/client/chat/RollbackReviewDialog.tsx +607 -0
  302. package/src/client/chat/chat-entry-view.test.ts +1174 -0
  303. package/src/client/chat/chat-entry-view.ts +758 -0
  304. package/src/client/chat/chat-locales.ts +152 -0
  305. package/src/client/chat/chat-node-registration.ts +64 -0
  306. package/src/client/chat/chat-point-window.ts +183 -0
  307. package/src/client/chat/chat-slots.ts +107 -0
  308. package/src/client/chat/rollback-diff-tab.test.ts +105 -0
  309. package/src/client/chat/rollback-diff-tab.ts +174 -0
  310. package/src/client/chat/rollback-entry-view.test.ts +1469 -0
  311. package/src/client/chat/rollback-entry-view.ts +946 -0
  312. package/src/client/chat/sidebar-slots.ts +59 -0
  313. package/src/client/common/ConfirmDialog.tsx +7 -3
  314. package/src/client/common/ContentPicker.tsx +48 -10
  315. package/src/client/common/DiffTable.tsx +133 -0
  316. package/src/client/common/ErrorBanner.tsx +4 -4
  317. package/src/client/common/Icon.tsx +2 -0
  318. package/src/client/common/Modal.tsx +8 -1
  319. package/src/client/common/ProgressBar.tsx +8 -2
  320. package/src/client/common/ReportView.tsx +4 -4
  321. package/src/client/common/RunsCenter.tsx +1 -0
  322. package/src/client/common/client-route-parity.test.ts +84 -0
  323. package/src/client/common/info-hint-guard.test.ts +46 -13
  324. package/src/client/common/picker-lazy-units.test.ts +42 -0
  325. package/src/client/common/plan-text-redaction.test.ts +17 -6
  326. package/src/client/common/progress-view-edge.test.ts +8 -7
  327. package/src/client/common/progress-view.test.ts +9 -9
  328. package/src/client/common/progress-view.ts +2 -2
  329. package/src/client/common/routes.ts +38 -0
  330. package/src/client/common/section-labels.test.ts +1 -0
  331. package/src/client/common/section-labels.ts +2 -0
  332. package/src/client/common/ui.tsx +73 -10
  333. package/src/client/config-manager.module.css +1109 -31
  334. package/src/client/css-scale-guard.test.ts +30 -0
  335. package/src/client/environment/EnvironmentPanel.tsx +11 -4
  336. package/src/client/environment/maintenance/DiskUsageCard.tsx +23 -2
  337. package/src/client/export/ExportView.tsx +13 -8
  338. package/src/client/history/HistoryPanel.tsx +4 -3
  339. package/src/client/history/history-api.ts +2 -2
  340. package/src/client/history/history-locales.ts +5 -1
  341. package/src/client/home/HomePanel.tsx +29 -8
  342. package/src/client/import/ForeignImportView.tsx +4 -5
  343. package/src/client/import/ImportWizardView.tsx +5 -3
  344. package/src/client/import/import-wizard-split.test.ts +3 -1
  345. package/src/client/import/import-wizard-steps.tsx +9 -9
  346. package/src/client/index.ts +232 -3
  347. package/src/client/library/LibraryActions.tsx +32 -7
  348. package/src/client/library/LibraryPanel.tsx +23 -3
  349. package/src/client/library/RestorePlanView.tsx +9 -66
  350. package/src/client/locales.ts +62 -9
  351. package/src/client/lucide-icons.d.ts +1 -0
  352. package/src/client/market/MarketPanel.tsx +13 -2
  353. package/src/client/market/MyConfigsView.tsx +5 -4
  354. package/src/client/market/MyConfigsWizard.tsx +10 -8
  355. package/src/client/market/market-api.test.ts +9 -8
  356. package/src/client/market/market-api.ts +2 -2
  357. package/src/client/market/market-locales.ts +3 -0
  358. package/src/client/market/market-view.test.ts +6 -6
  359. package/src/client/market/market-view.ts +3 -3
  360. package/src/client/market/my-configs-api.ts +2 -2
  361. package/src/client/market/my-configs-view.test.ts +23 -22
  362. package/src/client/market/my-configs-view.ts +8 -8
  363. package/src/client/recovery/IncompleteCopiesSection.tsx +2 -1
  364. package/src/client/recovery/RecoveryPanel.tsx +442 -283
  365. package/src/client/recovery/RollbackCard.tsx +859 -0
  366. package/src/client/recovery/RollbackDiffPane.tsx +69 -0
  367. package/src/client/recovery/RollbackFileChanges.tsx +163 -0
  368. package/src/client/recovery/incident-api.ts +2 -2
  369. package/src/client/recovery/recovery-api.ts +144 -6
  370. package/src/client/recovery/recovery-locales.test.ts +299 -0
  371. package/src/client/recovery/recovery-locales.ts +520 -149
  372. package/src/client/recovery/recovery-panel-layout.test.ts +132 -2
  373. package/src/client/recovery/rollback-enabled.ts +41 -0
  374. package/src/client/recovery/rollback-file-changes.test.ts +114 -0
  375. package/src/client/recovery/session-dialog-mount.test.ts +54 -0
  376. package/src/client/run-store.ts +84 -21
  377. package/src/client/sync/AutosyncCard.tsx +25 -1
  378. package/src/client/sync/ChannelClearConfirmDialog.tsx +6 -3
  379. package/src/client/sync/ChannelConfigDialog.tsx +10 -10
  380. package/src/client/sync/DecryptPasswordCard.tsx +2 -2
  381. package/src/client/sync/SecurityOptionsCard.tsx +27 -17
  382. package/src/client/sync/SyncChannelEntryCard.tsx +14 -2
  383. package/src/client/sync/SyncConfirmView.tsx +46 -8
  384. package/src/client/sync/SyncLogList.tsx +1 -1
  385. package/src/client/sync/SyncPanel.tsx +31 -23
  386. package/src/client/sync/SyncRepositoryPicker.tsx +3 -5
  387. package/src/client/sync/SyncSectionPickerDialog.tsx +11 -2
  388. package/src/client/sync/SyncSettingsView.test.ts +20 -1
  389. package/src/client/sync/UnreadableSnapshotsBanner.tsx +1 -1
  390. package/src/client/sync/WorkDirCard.tsx +112 -0
  391. package/src/client/sync/history-model.test.ts +42 -9
  392. package/src/client/sync/history-model.ts +30 -15
  393. package/src/client/sync/sync-api.test.ts +57 -33
  394. package/src/client/sync/sync-api.ts +80 -6
  395. package/src/client/sync/sync-locales.ts +104 -3
  396. package/src/client/sync/sync-view-v2.test.ts +65 -13
  397. package/src/client/sync/sync-view.test.ts +107 -52
  398. package/src/client/sync/sync-view.ts +124 -41
  399. package/src/core/analyzer.ts +31 -9
  400. package/src/core/backup-plan.test.ts +48 -5
  401. package/src/core/backup-plan.ts +51 -20
  402. package/src/core/backup-verify.ts +24 -23
  403. package/src/core/backup.test.ts +76 -0
  404. package/src/core/backup.ts +105 -29
  405. package/src/core/boot-rescue.ts +4 -3
  406. package/src/core/boot-safety.ts +2 -1
  407. package/src/core/command-rollback.test.ts +536 -0
  408. package/src/core/command-rollback.ts +406 -0
  409. package/src/core/consult-source.ts +7 -6
  410. package/src/core/disk-usage.test.ts +257 -1
  411. package/src/core/disk-usage.ts +134 -14
  412. package/src/core/engine-locale.test.ts +593 -0
  413. package/src/core/engine-locale.ts +175 -0
  414. package/src/core/exporter.ts +3 -3
  415. package/src/core/fork-reconcile.test.ts +279 -0
  416. package/src/core/fork-reconcile.ts +234 -0
  417. package/src/core/journal.ts +6 -5
  418. package/src/core/local-plugin-host.ts +3 -2
  419. package/src/core/local-plugin-pack.ts +11 -6
  420. package/src/core/messages-core.ts +661 -0
  421. package/src/core/messages.ts +125 -17
  422. package/src/core/migration-consult.ts +21 -20
  423. package/src/core/migration-history.ts +19 -5
  424. package/src/core/model-tools.ts +28 -27
  425. package/src/core/phase3-host.ts +21 -5
  426. package/src/core/phase3-p2b-fingerprint.test.ts +22 -0
  427. package/src/core/plugin-cli.ts +18 -19
  428. package/src/core/plugin-data-registry.test.ts +113 -0
  429. package/src/core/plugin-data-registry.ts +261 -0
  430. package/src/core/reconcile.test.ts +117 -0
  431. package/src/core/reconcile.ts +85 -17
  432. package/src/core/recovery-orchestrator.ts +19 -18
  433. package/src/core/reinstall.ts +26 -21
  434. package/src/core/restore.ts +20 -17
  435. package/src/core/rollback-file-diff.test.ts +159 -0
  436. package/src/core/rollback-gate.test.ts +66 -0
  437. package/src/core/rollback-gate.ts +61 -0
  438. package/src/core/rollback-ignore-rules.ts +126 -0
  439. package/src/core/rollback-ledger.test.ts +655 -0
  440. package/src/core/rollback-ledger.ts +1314 -0
  441. package/src/core/rollback-point.test.ts +2853 -0
  442. package/src/core/rollback-point.ts +4340 -0
  443. package/src/core/rollback.ts +5 -3
  444. package/src/core/run-registry.ts +6 -5
  445. package/src/core/session-health.ts +15 -0
  446. package/src/core/session-meta.test.ts +117 -1
  447. package/src/core/session-meta.ts +149 -2
  448. package/src/core/session-title.test.ts +127 -0
  449. package/src/core/session-title.ts +189 -0
  450. package/src/core/startup-barrier.test.ts +69 -0
  451. package/src/core/startup-barrier.ts +27 -0
  452. package/src/core/transaction-coordinator.ts +4 -3
  453. package/src/core/types.ts +37 -1
  454. package/src/core/validator.ts +5 -4
  455. package/src/core/verify-recovery.ts +31 -30
  456. package/src/foreign/bundle.ts +3 -2
  457. package/src/foreign/continue.ts +2 -1
  458. package/src/foreign/crush.ts +2 -1
  459. package/src/foreign/file-budget.test.ts +2 -0
  460. package/src/foreign/gemini.ts +2 -1
  461. package/src/foreign/goose.ts +2 -1
  462. package/src/foreign/kilocode.ts +2 -1
  463. package/src/foreign/kimi.ts +2 -1
  464. package/src/foreign/messages.ts +44 -0
  465. package/src/foreign/mimocode.ts +2 -1
  466. package/src/foreign/openclaw.ts +2 -1
  467. package/src/foreign/opencode.ts +2 -1
  468. package/src/foreign/pi.ts +2 -1
  469. package/src/foreign/qoder.ts +2 -1
  470. package/src/foreign/qwen.ts +2 -1
  471. package/src/foreign/registry.test.ts +1 -1
  472. package/src/foreign/registry.ts +8 -7
  473. package/src/foreign/teleagent.ts +2 -1
  474. package/src/foreign/workbuddy.ts +2 -1
  475. package/src/foreign/zcode.ts +2 -1
  476. package/src/foreign/zed.ts +2 -1
  477. package/src/index.facade.test.ts +340 -1
  478. package/src/index.ts +876 -57
  479. package/src/market/git-file-writer.ts +20 -19
  480. package/src/market/github-repos.test.ts +23 -0
  481. package/src/market/github-repos.ts +37 -19
  482. package/src/market/index-parser.ts +31 -30
  483. package/src/market/messages-market.ts +325 -0
  484. package/src/market/my-repo.ts +32 -29
  485. package/src/market/prepare.ts +18 -15
  486. package/src/market/security.ts +20 -19
  487. package/src/market/types.ts +2 -1
  488. package/src/market/url.ts +2 -1
  489. package/src/migrations/index.ts +6 -5
  490. package/src/migrations/messages-migrations.ts +59 -0
  491. package/src/profiles/dsh-profile-launcher.ts +2 -1
  492. package/src/profiles/dsh-profile-runtime.ts +2 -1
  493. package/src/profiles/messages.ts +31 -0
  494. package/src/routes/backup.ts +18 -2
  495. package/src/routes/import.ts +3 -3
  496. package/src/routes/index.ts +1 -1
  497. package/src/routes/kit.ts +9 -3
  498. package/src/routes/market.ts +1 -1
  499. package/src/routes/messages.ts +106 -0
  500. package/src/routes/prefs.ts +31 -2
  501. package/src/routes/profiles.ts +12 -6
  502. package/src/routes/recovery.test.ts +405 -1
  503. package/src/routes/recovery.ts +477 -94
  504. package/src/routes/snapshots.ts +51 -4
  505. package/src/routes/sync.ts +82 -17
  506. package/src/schema/config.ts +53 -20
  507. package/src/schema/index.ts +7 -5
  508. package/src/schema/manifest-schema.test.ts +4 -4
  509. package/src/schema/manifest.ts +19 -18
  510. package/src/schema/messages.ts +120 -0
  511. package/src/schema/registry.test.ts +28 -7
  512. package/src/schema/section-registry.ts +96 -42
  513. package/src/schema/types.ts +2 -1
  514. package/src/schema/versions.ts +6 -5
  515. package/src/security/encryption.ts +16 -15
  516. package/src/security/integrity.ts +8 -7
  517. package/src/security/messages.ts +87 -0
  518. package/src/security/redaction.test.ts +17 -1
  519. package/src/security/redaction.ts +13 -0
  520. package/src/security/secret-scanner.ts +19 -12
  521. package/src/security/security.test.ts +47 -0
  522. package/src/security/vault.ts +2 -1
  523. package/src/security/zip-security.ts +4 -3
  524. package/src/sync/ancestor.ts +2 -1
  525. package/src/sync/autosync-config.ts +21 -0
  526. package/src/sync/autosync-scheduler.test.ts +63 -7
  527. package/src/sync/autosync-scheduler.ts +118 -78
  528. package/src/sync/backup-files.ts +3 -2
  529. package/src/sync/backup-scheduler.ts +6 -5
  530. package/src/sync/blob-store.ts +2 -1
  531. package/src/sync/git/git-transport.test.ts +170 -8
  532. package/src/sync/git/git-transport.ts +176 -2
  533. package/src/sync/github-auth.ts +10 -9
  534. package/src/sync/layout.ts +13 -12
  535. package/src/sync/messages.ts +153 -0
  536. package/src/sync/review-queue.ts +6 -5
  537. package/src/sync/s3/messages.ts +16 -0
  538. package/src/sync/s3/s3-providers.ts +9 -6
  539. package/src/sync/s3/s3-transport.ts +1 -1
  540. package/src/sync/s3/sigv4.ts +3 -1
  541. package/src/sync/snapshot-crypto.ts +6 -5
  542. package/src/sync/snapshot-json.ts +3 -2
  543. package/src/sync/sync-credentials.test.ts +119 -0
  544. package/src/sync/sync-engine-download-tombstone.test.ts +1 -1
  545. package/src/sync/sync-engine.test.ts +226 -8
  546. package/src/sync/sync-engine.ts +154 -52
  547. package/src/sync/sync-history.ts +7 -6
  548. package/src/sync/sync-selection.test.ts +12 -2
  549. package/src/sync/sync-selection.ts +18 -5
  550. package/src/sync/transport.test.ts +1 -0
  551. package/src/sync/ui-prefs.test.ts +44 -1
  552. package/src/sync/ui-prefs.ts +39 -0
  553. package/src/sync/webdav/webdav-transport.ts +4 -1
  554. package/src/ui/artifact-view.ts +4 -3
  555. package/src/ui/commands.test.ts +1 -0
  556. package/src/ui/commands.ts +11 -1
  557. package/src/ui/diff-view.test.ts +9 -1
  558. package/src/ui/diff-view.ts +22 -0
  559. package/src/ui/disk-usage-view.test.ts +113 -6
  560. package/src/ui/disk-usage-view.ts +63 -7
  561. package/src/ui/errors.ts +4 -3
  562. package/src/ui/export-flow.ts +26 -20
  563. package/src/ui/foreign-view.test.ts +43 -0
  564. package/src/ui/foreign-view.ts +30 -3
  565. package/src/ui/i18n.ts +131 -2
  566. package/src/ui/import-wizard.ts +18 -10
  567. package/src/ui/progress.ts +2 -2
  568. package/src/ui/report.ts +7 -6
  569. package/src/ui/rollback-view.test.ts +836 -0
  570. package/src/ui/rollback-view.ts +1405 -0
  571. package/src/ui/selection-model.test.ts +33 -1
  572. package/src/ui/selection-model.ts +53 -0
  573. package/src/ui/session-inventory-view.test.ts +109 -0
  574. package/src/ui/session-inventory-view.ts +138 -3
  575. package/src/ui/session-issue-labels.test.ts +51 -0
  576. package/src/ui/session-issue-labels.ts +70 -0
  577. package/src/ui/sync-settings-view.test.ts +36 -0
  578. package/src/ui/sync-settings-view.ts +49 -0
  579. package/src/ui/test-helpers.ts +2 -1
  580. package/src/ui/types.ts +10 -4
  581. package/src/utils/atomic-write.ts +6 -5
  582. package/src/utils/bundle-scan.test.ts +2 -2
  583. package/src/utils/bundle-scan.ts +12 -1
  584. package/src/utils/env-lock.ts +83 -49
  585. package/src/utils/json.ts +7 -5
  586. package/src/utils/messages-utils.ts +232 -0
  587. package/src/utils/proxy.test.ts +25 -0
  588. package/src/utils/proxy.ts +19 -5
  589. package/src/utils/recursive-walk.test.ts +43 -0
  590. package/src/utils/recursive-walk.ts +75 -1
  591. package/src/utils/session-log-repair.ts +10 -9
  592. package/src/utils/zip.ts +27 -26
  593. package/lib/core/checkpoint.d.ts +0 -362
  594. package/lib/core/checkpoint.js +0 -1309
  595. package/src/core/checkpoint.test.ts +0 -665
  596. package/src/core/checkpoint.ts +0 -1614
@@ -0,0 +1,1405 @@
1
+ /**
2
+ * rollback-point(**工作区 + 会话回滚**)的**展示模型**(框架无关纯函数,node 可测)。
3
+ *
4
+ * 数据来自宿主 /recovery/rollback-points 族的 6 条子路径(list / storage / <id> / <id>/preview /
5
+ * <id>/rewind / <id>/delete;第 3 条 <id> 单条详情同样进端口 —— 面板要能深链到某个点)。
6
+ * 宿主把它挂在既有 recovery **prefix 路由**内部按 path 分发,因此不新增注册路由条目。
7
+ *
8
+ * 三条纪律(与旧检查点展示模型同源,改名不改口径):
9
+ * ① **不 import src/core/rollback-point.ts(或任何 core 模块)** —— 那条链经 node:fs 依赖宿主运行时,
10
+ * 一旦进 client bundle,DSH loader 会报 missed the module table(AGENTS.md 的 client bundle 铁律)。
11
+ * 因此这里按「线协议结构子集」重新声明类型(字段只取展示所需,缺字段一律回落安全值)。
12
+ * ② **本模块不产出任何用户可见文案**:只回传字典键(rollback.*),zh/en 在 recovery-locales.ts。
13
+ * ③ **未知码 / 未知态 / 未知来源一律回落 unknown 键,绝不裸渲染机器码**;用户自由文本(note)与
14
+ * 宿主技术细节(detail / guidance.detail / 路径清单)一律先过 redact()(AGENTS.md UI 硬性规则 7)。
15
+ *
16
+ * 两态纪律(S2):只有 `workspace` 与 `session`。第三态 `config` 与「同点判定」已随检查点一起删除:
17
+ * 任何非两态的 state 字符串都回落 rollback.state.unknown,本模块**不出现** config 态。
18
+ *
19
+ * 线协议形状(与 T1 宿主侧 src/core/rollback-point.ts 逐字段对齐,2026-10-10 双人确认):
20
+ * - points[] = { id, createdAt, sessionId, turn, kind, protected, incomplete, note, spreadMs, segments:[…] },
21
+ * segments **恒为 [workspace, session] 两项**;
22
+ * - preview.workspace = { overwrite, different, missing, createdAfter(数), leftovers(路径清单,≤20), leftoversTruncated },
23
+ * preview.session = { from, to, turns, cursorNow, cursorTarget, restorable, active, reasonCode }
24
+ * (from = 目标回合开始前最后一个事件的 seq,即 fork 的 inclusive 边界;turns = 回合号);
25
+ * - rewind.segments[] = { state, status, reasonCode?, detail[], restored, failed, leftovers[], leftoversTruncated, childSessionId?, guidance? }。
26
+ */
27
+ import { redact } from '../security/redaction.ts'
28
+ import type { DiffHunk } from '../utils/line-diff.ts'
29
+
30
+ /* ------------------------------------------------------------ 两态 / 三选一 mode */
31
+
32
+ /** 只保留两态(S2):工作区 + 会话。 */
33
+ export const ROLLBACK_STATES = ['workspace', 'session'] as const
34
+ export type RollbackState = (typeof ROLLBACK_STATES)[number]
35
+
36
+ /** 段名 → 是否为受支持的段(出现别的段一律按未知处理,绝不假设它是第三态)。 */
37
+ export function isRollbackState(value: unknown): value is RollbackState {
38
+ return value === 'workspace' || value === 'session'
39
+ }
40
+
41
+ /**
42
+ * 回退范围三选一(冻结 wire contract 的 `mode`):
43
+ * - `files+session`:工作区文件 + 会话(新子会话)一起回退;
44
+ * - `files`:只回退工作区文件;
45
+ * - `session`:只回退会话(fork 子会话)。
46
+ */
47
+ export const ROLLBACK_MODES = ['files+session', 'files', 'session'] as const
48
+ export type RollbackMode = (typeof ROLLBACK_MODES)[number]
49
+
50
+ export function isRollbackMode(value: unknown): value is RollbackMode {
51
+ return value === 'files+session' || value === 'files' || value === 'session'
52
+ }
53
+
54
+ /** mode → 段集合(workspace 段 = 工作区文件;session 段 = 会话)。 */
55
+ export function rollbackModeSegments(mode: string | null | undefined): RollbackState[] {
56
+ if (mode === 'files') return ['workspace']
57
+ if (mode === 'session') return ['session']
58
+ if (mode === 'files+session') return ['workspace', 'session']
59
+ return []
60
+ }
61
+
62
+ /**
63
+ * 该 mode 在当前可回退段集合下**是否可选**(界面据此禁用注定失败的选项)。
64
+ *
65
+ * 例:某点没有稳定边界(session 段 restorable=false)时,`session` 与 `files+session` 都不可选 ——
66
+ * 绝不发一个宿主一定会拒绝的请求。
67
+ */
68
+ export function rollbackModeAvailable(mode: string | null | undefined, availableSegments: readonly string[]): boolean {
69
+ const needed = rollbackModeSegments(mode)
70
+ return needed.length > 0 && needed.every((state) => availableSegments.includes(state))
71
+ }
72
+
73
+ /** 段集合 → mode(三选一;空集合回落 `files+session`,因为那正是自动点缺省该回退的两态)。 */
74
+ export function rollbackModeFromSegments(segments: readonly string[]): RollbackMode {
75
+ const hasWorkspace = segments.includes('workspace')
76
+ const hasSession = segments.includes('session')
77
+ if (hasWorkspace && !hasSession) return 'files'
78
+ if (hasSession && !hasWorkspace) return 'session'
79
+ return 'files+session'
80
+ }
81
+
82
+ /* ------------------------------------------------------------ 字典键映射(绝不裸渲染) */
83
+
84
+ export const ROLLBACK_STATE_UNKNOWN_KEY = 'rollback.state.unknown'
85
+ export const ROLLBACK_MODE_UNKNOWN_KEY = 'rollback.mode.unknown'
86
+ export const ROLLBACK_CODE_UNKNOWN_KEY = 'rollback.code.unknown'
87
+ export const ROLLBACK_GUIDANCE_UNKNOWN_KEY = 'rollback.guidance.unknown'
88
+ export const ROLLBACK_KIND_UNKNOWN_KEY = 'rollback.kind.unknown'
89
+
90
+ const STATE_KEYS: Record<RollbackState, string> = {
91
+ workspace: 'rollback.state.workspace',
92
+ session: 'rollback.state.session',
93
+ }
94
+
95
+ const MODE_KEYS: Record<RollbackMode, string> = {
96
+ 'files+session': 'rollback.mode.filesAndSession',
97
+ files: 'rollback.mode.filesOnly',
98
+ session: 'rollback.mode.sessionOnly',
99
+ }
100
+
101
+ /**
102
+ * code 白名单:**只有这些码进字典**,其余一律回落 unknown ——
103
+ * 宿主若新增码而字典没跟上,界面显示「未识别的错误码」而不是把机器码拼上屏。
104
+ *
105
+ * 与宿主引擎 `src/core/rollback-point.ts` 的 `RollbackCode` 联合**逐条对应**(core 不能进
106
+ * client bundle,所以只能在这里镜像一份;t3 已按 T1 的最终码表补齐 31 条,t10 补
107
+ * `no-recorded-files` ⇒ 32 条)。顶层 code 与 segments[].reasonCode / warnings 共用同一枚举,
108
+ * 所以一份白名单同时服务三处渲染。
109
+ *
110
+ * ⚠️ **这份镜像与引擎码表的等价性有守卫**:`rollback-view.test.ts` 的「码表镜像 != 引擎码表即红」
111
+ * 用例从 `src/core/rollback-point.ts` 源码级读出 `RollbackCode` union,双向断言集合相等
112
+ * (引擎新增码忘了补这里、或这里多写了引擎没有的码,都会立刻变红 —— 本次 t10 的
113
+ * `no-recorded-files` 就是因为缺这条对照而静默漂移过一轮)。
114
+ */
115
+ export const ROLLBACK_CODES: readonly string[] = [
116
+ 'ok',
117
+ 'deleted',
118
+ // 冻结契约的 9 条
119
+ 'confirmation-required',
120
+ 'protected-point',
121
+ 'record-not-found',
122
+ 'record-incomplete',
123
+ 'invalid-input',
124
+ // 待删的「该点未记录」路径超上限 ⇒ 整套拒绝、零写入(绝不删一半;顶层 code,不是泛化的 invalid-input)
125
+ 'created-removals-limit',
126
+ 'no-boundary',
127
+ 'storage-unavailable',
128
+ 'mutation-locked',
129
+ 'method-not-allowed',
130
+ // 台账 / 存储
131
+ 'ledger-unreadable',
132
+ 'ledger-write-failed',
133
+ // 工作区写回(记录校验 / 对象仓 / 写后复验 / 无事可做)
134
+ 'invalid-path',
135
+ 'object-missing',
136
+ 'object-hash-mismatch',
137
+ 'restore-failed',
138
+ 'verify-failed',
139
+ 'nothing-to-restore',
140
+ // T10:该态**没有记录任何文件**(工作区段 files=0)—— 与「记录了但全部已一致」严格分开,
141
+ // 只出现在 segments[].reasonCode(不是顶层 code);界面据此说两句不同的话(t11)。
142
+ 'no-recorded-files',
143
+ // 救援点(S5 guard)
144
+ 'guard-failed',
145
+ // 会话(fork 子会话;原会话日志只读)
146
+ 'session-missing',
147
+ 'session-out-of-root',
148
+ 'session-too-large',
149
+ 'session-active',
150
+ // 子代理会话(origin=subagent):**不自动留点** —— 出现即代表该会话被有意跳过(宿主日志留痕)
151
+ 'subagent-session',
152
+ 'session-fork-unavailable',
153
+ 'fork-failed',
154
+ // 自动留点(工作区快照)
155
+ 'capture-failed',
156
+ 'capture-truncated',
157
+ 'capture-degraded',
158
+ 'no-chunks',
159
+ 'chunk-not-absolute',
160
+ 'chunk-not-found',
161
+ // 单文件差异(「查看」的数据源):该路径**不在**这个回滚点的记录里
162
+ 'file-not-recorded',
163
+ ]
164
+
165
+ /**
166
+ * 已知指引码后缀(T1 引擎实际产出;白名单外回落 unknown)。
167
+ *
168
+ * 引擎回传的是**完整字典键**(rollback.guidance.workspaceRolledBack),旧检查点引擎回传的是
169
+ * **裸后缀**(workspacePartial)—— 映射两种形态都认,避免把已知指引误判成 unknown
170
+ * (那会让「工作区已回滚 / 已 fork 子会话」这类关键事实消失)。
171
+ */
172
+ export const ROLLBACK_GUIDANCE_CODES: readonly string[] = [
173
+ 'mountStorage', 'fixDataDir', 'readOnlyFallback', 'probeDetail',
174
+ 'workspaceRolledBack', 'forkUnavailable', 'sessionForked', 'sessionNotAttached',
175
+ ]
176
+
177
+ /** 已知告警码后缀(warnings 走独立一族 rollback.warn.*;白名单外回落 rollback.warn.unknown)。 */
178
+ export const ROLLBACK_WARN_CODES: readonly string[] = [
179
+ 'ledgerUnreadable', 'trackMissing', 'trackDegraded', 'protected', 'guardFailed',
180
+ // t2:删除集被整体拒绝的**成因**(忽略目录 / 路径索引缺失 / 目录读不出)—— 必须能说清为什么没删
181
+ 'createdRefused',
182
+ ]
183
+
184
+ export const ROLLBACK_WARN_UNKNOWN_KEY = 'rollback.warn.unknown'
185
+
186
+ /**
187
+ * 指引 / 告警码 → 字典键。三种形态都认:
188
+ * - 完整键 `rollback.guidance.X` / `rollback.warn.X`(T1 引擎);
189
+ * - 裸后缀 `X`(旧检查点引擎)。
190
+ * 未知一律回落 unknown 键,**绝不裸渲染机器码**。
191
+ */
192
+ function rollbackNoticeKey(code: unknown): string {
193
+ if (typeof code !== 'string' || code === '') return ROLLBACK_GUIDANCE_UNKNOWN_KEY
194
+ if (code.startsWith('rollback.warn.')) {
195
+ const bare = code.slice('rollback.warn.'.length)
196
+ return ROLLBACK_WARN_CODES.includes(bare) ? 'rollback.warn.' + bare : ROLLBACK_WARN_UNKNOWN_KEY
197
+ }
198
+ const bare = code.startsWith('rollback.guidance.') ? code.slice('rollback.guidance.'.length) : code
199
+ return ROLLBACK_GUIDANCE_CODES.includes(bare) ? 'rollback.guidance.' + bare : ROLLBACK_GUIDANCE_UNKNOWN_KEY
200
+ }
201
+
202
+ /** 段名 → 字典键(非两态回落 unknown,绝不裸渲染)。 */
203
+ export function rollbackStateKey(state: string | null | undefined): string {
204
+ return isRollbackState(state) ? STATE_KEYS[state] : ROLLBACK_STATE_UNKNOWN_KEY
205
+ }
206
+
207
+ /** mode → 字典键(三选一;其它回落 unknown)。 */
208
+ export function rollbackModeKey(mode: string | null | undefined): string {
209
+ return isRollbackMode(mode) ? MODE_KEYS[mode] : ROLLBACK_MODE_UNKNOWN_KEY
210
+ }
211
+
212
+ const GUARD_KEYS: Record<RollbackGuardPolicy, string> = {
213
+ require: 'rollback.guard.require',
214
+ warn: 'rollback.guard.warn',
215
+ off: 'rollback.guard.off',
216
+ }
217
+
218
+ /** 救援点策略(S5 guard)→ 字典键(未知回落 unknown)。 */
219
+ export function rollbackGuardPolicyKey(policy: string | null | undefined): string {
220
+ return policy === 'require' || policy === 'warn' || policy === 'off' ? GUARD_KEYS[policy] : 'rollback.guard.unknown'
221
+ }
222
+
223
+ /** 机器码 → 字典键(白名单外回落 unknown)。 */
224
+ export function rollbackCodeKey(code: string | null | undefined): string {
225
+ if (typeof code !== 'string' || code === '') return ROLLBACK_CODE_UNKNOWN_KEY
226
+ return ROLLBACK_CODES.includes(code) ? 'rollback.code.' + code : ROLLBACK_CODE_UNKNOWN_KEY
227
+ }
228
+
229
+ export interface RollbackGuidance { code: string; detail?: string }
230
+ export interface RollbackGuidanceRowView { key: string; detail: string | null }
231
+
232
+ /** 指引 / 告警:code → rollback.guidance.* / rollback.warn.*(三种形态都认),detail 过 redact。 */
233
+ export function rollbackGuidanceRows(items: readonly RollbackGuidance[] | undefined): RollbackGuidanceRowView[] {
234
+ return (items ?? []).map((item) => ({
235
+ key: rollbackNoticeKey(item.code),
236
+ detail: item.detail !== undefined && item.detail !== '' ? redact(item.detail) : null,
237
+ }))
238
+ }
239
+
240
+ /* ------------------------------------------------------------ 留存口径(S6) */
241
+
242
+ /**
243
+ * 每会话最多保留的**自动点**(超出只淘汰最旧的自动点 —— 引擎 selectPrune 的自动点桶)。
244
+ * 救援点(guard)不占这个额度、也不被它淘汰,但有自己的**两道**上限
245
+ * (每会话引擎 ROLLBACK_MAX_GUARDS_PER_SESSION / 全局 ROLLBACK_MAX_GUARDS)。
246
+ */
247
+ export const ROLLBACK_MAX_POINTS_PER_SESSION = 20
248
+ /** 全局最多保留的**自动点**(同上,只淘汰最旧的自动点)。 */
249
+ export const ROLLBACK_MAX_POINTS_TOTAL = 50
250
+ /** 每会话最多保留的**救援点**(guard):超出只淘汰最旧的救援点;数值 = 引擎 ROLLBACK_MAX_GUARDS_PER_SESSION。 */
251
+ export const ROLLBACK_MAX_POINTS_GUARDS_PER_SESSION = 20
252
+ /**
253
+ * **全局**最多保留的**救援点**(guard):与 ROLLBACK_MAX_POINTS_TOTAL **互相独立**
254
+ * (两类各计预算,谁也不吃谁额度;数值相同不是耦合),超出只淘汰最旧的救援点。
255
+ * 数值 = 引擎 ROLLBACK_MAX_GUARDS(两侧必须同步,T17 用例逐句钉住)。
256
+ */
257
+ export const ROLLBACK_MAX_POINTS_GUARDS_TOTAL = 50
258
+
259
+ /**
260
+ * 预览不可回退时的原因键(按宿主真实形状分派;**绝不回落 rollback.code.unknown**)。
261
+ *
262
+ * 注意(t22 冻结口径):救援点**可以**作为回退目标 —— 引擎 canRestore = !protected && !incomplete &&
263
+ * 至少一段可恢复(**不含** kind!=='guard',见 src/core/rollback-point.ts:1531)。所以这个键**只在
264
+ * 宿主真的拒绝了一个看起来可恢复的救援点**(canRestore=false 且不是 protected/incomplete/无可用段)时使用,
265
+ * 不是「救援点一律不可回退」。
266
+ */
267
+ export const ROLLBACK_PREVIEW_REASON_GUARD_KEY = 'rollback.preview.reason.guard'
268
+ export const ROLLBACK_PREVIEW_REASON_INCOMPLETE_KEY = 'rollback.preview.reason.incomplete'
269
+ export const ROLLBACK_PREVIEW_REASON_HOST_REFUSED_KEY = 'rollback.preview.reason.hostRefused'
270
+
271
+ /* ------------------------------------------------------------ 线协议类型(结构子集) */
272
+
273
+ export type RollbackPointKind = 'auto' | 'guard'
274
+ export type RollbackOutcome = 'restored' | 'partial' | 'failed' | 'denied'
275
+ export type RollbackSegmentStatus = 'restored' | 'skipped' | 'failed'
276
+ export type RollbackGuardPolicy = 'require' | 'warn' | 'off'
277
+
278
+ /** 单段摘要(恒为 workspace / session 两项;state 用 string 以便未知值也能被映射成 unknown 键)。 */
279
+ export interface RollbackSegmentSummary {
280
+ state: string
281
+ ok?: boolean
282
+ degraded?: boolean
283
+ capturedAt?: number
284
+ reasonCode?: string | null
285
+ /** 该段捕获的条目数(工作区文件数 / 会话 0|1) */
286
+ entries?: number
287
+ bytes?: number
288
+ /** 会话段:游标(seq) */
289
+ cursorBytes?: number | null
290
+ /** 该段**可回退**(有内容副本 / 有可写回条目) */
291
+ restorable?: boolean
292
+ /** 留点时的**跳过条目总数**(工作区态:skipped + excludedDirs;会话态恒 0)—— S4「跳过并如实报告」 */
293
+ skippedCount?: number
294
+ /** 该段捕获是否被上限截断(工作区态 = 单文件上限 / 字节预算 / 文件数预算任一命中) */
295
+ truncated?: boolean
296
+ }
297
+
298
+ export interface RollbackPointSummary {
299
+ id: string
300
+ createdAt: number
301
+ /** 自动留点归属的会话(自动点必有;无会话的旧记录为 null) */
302
+ sessionId?: string | null
303
+ /** 会话单元 id(宿主回传的 <projectKey>/<sessionId>;用于分组表头) */
304
+ unitId?: string | null
305
+ /**
306
+ * 对话标题:宿主从 DSH 会话缓存补的**只读装饰**(与 /recovery/sessions 同一份索引)。
307
+ * 读不到就**不带该字段** —— 界面回落显示 `sessionId`,绝不编造标题。
308
+ */
309
+ title?: string | null
310
+ /** 该点对应的回合号(1-based;无法判定为 null) */
311
+ turn?: number | null
312
+ /** fork 的 inclusive 边界 seq(null = 无前置边界) */
313
+ boundarySeq?: number | null
314
+ /** auto = 回合结束自动留点;guard = 回退前救援点 */
315
+ kind?: string
316
+ protected?: boolean
317
+ /** 两态缺一(缺省不允许回退) */
318
+ incomplete?: boolean
319
+ note?: string | null
320
+ /** 两态 capturedAt 的跨度(诚实事实,无同点判定) */
321
+ spreadMs?: number
322
+ segments?: RollbackSegmentSummary[]
323
+ }
324
+
325
+ /**
326
+ * 台账占用(issue #89 P2:可观测)。
327
+ * `bytes` = records/ + payloads/ 的实际字节数;`budgetBytes` = 触发淘汰的字节预算;
328
+ * `evicted` = 本进程内被预算淘汰的自动点累计数。缺省(老宿主没这个字段)= 界面不渲染这一行。
329
+ */
330
+ export interface RollbackLedgerStatus {
331
+ records: number
332
+ bytes: number
333
+ budgetBytes: number
334
+ evicted: number
335
+ /**
336
+ * 已超预算且**无处可淘汰**(典型:单条点本身就大于预算)。
337
+ * 老宿主没有这个字段 ⇒ `undefined` 与 `false` 同义,绝不因为读不到就报警。
338
+ */
339
+ overBudget?: boolean
340
+ }
341
+
342
+ export interface RollbackStorageStatus {
343
+ available: boolean
344
+ root?: string
345
+ detail?: string
346
+ guidance?: RollbackGuidance[]
347
+ requirements?: readonly string[]
348
+ /** 磁盘偏好:回合自动留点开关(缺省 / false = 关闭) */
349
+ rollbackEnabled?: boolean
350
+ /** 本进程此刻是否真的在留点(关闭后订阅已解绑)。与 rollbackEnabled 正常一致 */
351
+ rollbackActive?: boolean
352
+ /** 台账占用(老宿主没有这个字段) */
353
+ ledger?: RollbackLedgerStatus
354
+ }
355
+
356
+ /** 开关读 / 写的结果(POST 成功才会改进程 gate)。 */
357
+ export interface RollbackPrefsResult {
358
+ ok: boolean
359
+ rollbackEnabled: boolean
360
+ rollbackActive?: boolean
361
+ }
362
+
363
+ /** 台账清理结果(只删自动点;guard 与 protected 保留)。 */
364
+ export interface RollbackPurgeResult {
365
+ ok: boolean
366
+ code?: string
367
+ detail?: string
368
+ removedRecords?: number
369
+ removedPayloads?: number
370
+ removedObjects?: number
371
+ bytes?: number
372
+ }
373
+
374
+ export interface RollbackListResult {
375
+ ok?: boolean
376
+ points: RollbackPointSummary[]
377
+ storage: RollbackStorageStatus
378
+ error?: string
379
+ /** 全局留存上限(S6:50) */
380
+ maxRecords?: number
381
+ /** 每会话留存上限(S6:20) */
382
+ maxRecordsPerSession?: number
383
+ /** **全局**救援点留存上限(S6 第二道 guard 预算:50) */
384
+ maxGuards?: number
385
+ }
386
+
387
+ export interface RollbackDetailResult {
388
+ ok: boolean
389
+ id: string
390
+ point: RollbackPointSummary
391
+ storage: RollbackStorageStatus
392
+ }
393
+
394
+ /** 计数或清单:宿主回传数字就取数字,回传清单就取长度(两者都是「有多少项」)。 */
395
+ export type RollbackCount = number | readonly string[]
396
+
397
+ /**
398
+ * 预览里「回退会改动的一个文件」的动作。
399
+ *
400
+ * 与引擎(`src/core/rollback-point.ts` 的 `RollbackPreviewFileStatus`)**逐字同形**:
401
+ * · `overwrite` = 覆盖已有文件;`create` = 当前不存在(回退会重新创建);
402
+ * · `delete` **不由 `files[]` 产出**:整文件删除走**另一条**清单
403
+ * (`RollbackWorkspacePreview.removals` = 会删的,`createdSkipped` = 拒删的)——
404
+ * 那些路径不在该点的记录里,所以既不进这份逐文件统计、也读不出前后对照。
405
+ * 保留该取值只为契约形状向前兼容 —— 界面不得为它编文案。
406
+ */
407
+ export type RollbackPreviewFileStatus = 'overwrite' | 'create' | 'delete'
408
+
409
+ /**
410
+ * E1:逐文件的行级统计(简洁版预览弹窗 `preview.workspace.files` 的元素形状)。
411
+ *
412
+ * **只列真的会被改动的文件** —— 已与快照一致的条目一个都不列(「353 项里 0 项不同」⇒ 空数组)。
413
+ * `added` / `removed` 可能是 `null`(单文件超上限 / 单张预览累计超限 / Myers 步数超限 /
414
+ * 非文本 / 对象仓读不出),此时 `truncated` 为 true:界面说「行数未统计」,
415
+ * **绝不写成 0**(0 是一个会被证伪的具体数字,而「不知道」不是)。
416
+ */
417
+ export interface RollbackPreviewFile {
418
+ /** 绝对路径(界面只做展示)。 */
419
+ path: string
420
+ status: RollbackPreviewFileStatus
421
+ /** 回退会让该文件**新增**的行数;null = 本次不给数字(见 truncated)。 */
422
+ added: number | null
423
+ /** 回退会从该文件**删除**的行数;null = 同上。 */
424
+ removed: number | null
425
+ /** true = 本次没有可信行数(added/removed 均为 null)。**宁可说「不知道」,也绝不谎报行数。** */
426
+ truncated: boolean
427
+ }
428
+
429
+ export interface RollbackWorkspacePreview {
430
+ /** 将被写回(覆盖)的条目数 */
431
+ overwrite?: RollbackCount
432
+ /** 与当前内容不同、确实会被改动 */
433
+ different?: RollbackCount
434
+ /** 记录里的文件当前已不存在(回退会重新创建) */
435
+ missing?: RollbackCount
436
+ /**
437
+ * 该点**未记录**的条目总数。**不是**「之后新建」,也**不是**「会被删除」——
438
+ * 会被删的是 {@link removals} / {@link removalsCount}。
439
+ */
440
+ createdAfter?: RollbackCount
441
+ /** 该点**未记录**的路径清单(含被拒删的;宿主上限 20 条)—— **不是**删除清单 */
442
+ leftovers?: string[]
443
+ leftoversTruncated?: boolean
444
+ /** **会被删除**的路径(= 未记录 + 过了安全判据;宿主上限 20 条) */
445
+ removals?: string[]
446
+ /** 会被删除的**真实总数**(不受清单上限影响) */
447
+ removalsCount?: RollbackCount
448
+ /** 删除逐条清单不完整(条数超上限 / 遍历被截断) */
449
+ removalsTruncated?: boolean
450
+ /** 按安全判据**拒删**的路径(.git / node_modules / 数据区 / 越界 / 过大 / 捕获不完整时的全部候选) */
451
+ createdSkipped?: string[]
452
+ /** 拒删路径的**真实总数** */
453
+ createdSkippedCount?: RollbackCount
454
+ createdSkippedTruncated?: boolean
455
+ /**
456
+ * 非 null = 该点捕获**不完整** ⇒ **一条都没删**(安全拒删:绝不误删既有文件)。
457
+ * 界面必须如实说,绝不把它渲染成「无事可做 / 已一致」。
458
+ */
459
+ createdRefusedReason?: string | null
460
+ /** 记录在案的分块(绝对路径;界面只做展示,不做判定) */
461
+ chunks?: string[]
462
+ /**
463
+ * E1:逐文件的行级统计 —— **只含会被改动的文件**(内容已一致的条目一个都不列)。
464
+ *
465
+ * 可选(`?`)是**刻意的向后兼容**:老响应 / 老宿主不认识这个字段,缺省必须回落
466
+ * 「没有这条信息」,而不是被当成「空数组=全部一致」或抛错 —— 界面据「字段是否存在」
467
+ * 决定是否显示文件列表,绝不把「缺字段」与「确知为空」混成同一件事。
468
+ */
469
+ files?: RollbackPreviewFile[]
470
+ /** 捕获时被跳过的条目总数(skipped + excludedDirs;逐条列表的上限截断不影响本计数) */
471
+ skippedCount?: RollbackCount
472
+ /** 捕获是否被上限截断(超单文件上限 / 字节预算 / 文件数预算任一命中即为 true) */
473
+ truncated?: boolean
474
+ available?: boolean
475
+ reasonCode?: string | null
476
+ guidance?: RollbackGuidance[]
477
+ }
478
+
479
+ export interface RollbackSessionPreview {
480
+ /** 回退目标边界:目标回合开始前最后一个事件的 seq(fork 的 inclusive 边界;无边界为 null) */
481
+ from?: number | null
482
+ /** 该回合 turn/end 的 seq(无边界为 null) */
483
+ to?: number | null
484
+ /** 该点对应的回合号 */
485
+ turns?: number
486
+ /** 当前事件游标(seq) */
487
+ cursorSeq?: number | null
488
+ /** 当前日志字节数 */
489
+ cursorNowBytes?: number | null
490
+ /** 目标边界的字节数 */
491
+ cursorTargetBytes?: number | null
492
+ /** 该段是否可回退(无边界 / 副本缺失 → false) */
493
+ restorable?: boolean
494
+ /** 日志刚被写过(仅供参考:fork 只读,不会覆盖日志) */
495
+ active?: boolean
496
+ reasonCode?: string | null
497
+ available?: boolean
498
+ guidance?: RollbackGuidance[]
499
+ }
500
+
501
+ export interface RollbackPreviewResult {
502
+ ok: boolean
503
+ code?: string
504
+ detail?: string
505
+ id: string
506
+ storage: RollbackStorageStatus
507
+ workspace?: RollbackWorkspacePreview | null
508
+ session?: RollbackSessionPreview | null
509
+ /** 该点的两态摘要(宿主回传;缺失时界面用列表行里的同一份摘要) */
510
+ point?: RollbackPointSummary | null
511
+ /** 宿主给出的权威判定(本层在此基础上再要求「至少有一段可回退」) */
512
+ canRestore?: boolean
513
+ canRemove?: boolean
514
+ summary?: RollbackPointSummary
515
+ guidance?: RollbackGuidance[]
516
+ warnings?: RollbackGuidance[]
517
+ }
518
+
519
+ /** 单文件差异里一侧(与引擎 `RollbackFileDiffSide` 逐字同形:字段名/含义都一致)。 */
520
+ export interface RollbackFileDiffSide {
521
+ exists: boolean
522
+ bytes: number
523
+ /** 行数;null = 未读取(二进制 / 超限 / 不可读) */
524
+ lines: number | null
525
+ binary: boolean
526
+ oversized: boolean
527
+ unreadable: boolean
528
+ }
529
+
530
+ /** 无法逐行对比的原因(有值时 hunks 恒为空数组 —— 「不知道」不是「没有差异」)。 */
531
+ export type RollbackFileDiffReason = 'missing-blob' | 'binary' | 'too-large' | 'unreadable'
532
+
533
+ /**
534
+ * 单文件前后对照(引擎 `fileDiff` 的镜像;只列一条记录)。
535
+ *
536
+ * 方向:`before` = **当前磁盘**,`after` = **回退点快照**(点下回退之后该文件的样子)——
537
+ * 与预览行数统计同一口径,界面左右双栏与之一致,别读反。
538
+ */
539
+ export interface RollbackFileDiffResult {
540
+ ok: boolean
541
+ code?: string
542
+ detail?: string
543
+ id: string
544
+ path: string
545
+ status: RollbackPreviewFileStatus
546
+ before: RollbackFileDiffSide
547
+ after: RollbackFileDiffSide
548
+ hunks: DiffHunk[]
549
+ added: number
550
+ removed: number
551
+ identical: boolean
552
+ degraded: boolean
553
+ reason?: RollbackFileDiffReason
554
+ }
555
+
556
+ export interface RollbackSegmentResult {
557
+ state: string
558
+ status: string
559
+ reasonCode?: string
560
+ /** 逐条事实(技术细节,非用户可见文案) */
561
+ detail?: string[]
562
+ restored?: number
563
+ failed?: number
564
+ /** 该点**未记录**的路径(含被拒删的;宿主上限 20 条)。**不是**删除清单 —— 那一份是 removedPaths */
565
+ leftovers?: string[]
566
+ leftoversTruncated?: boolean
567
+ /** 本次真的删掉的「该点未记录」路径数(用户 2026-10-11 定案 A) */
568
+ removed?: number
569
+ /** 已删除路径(有界清单;与预览的 workspace.removals 同源同序) */
570
+ removedPaths?: string[]
571
+ /** 想删但按安全判据拒删的路径(越界 / 数据区 / .git / node_modules / 过大 / 捕获不完整),如实报告 */
572
+ createdSkipped?: string[]
573
+ /** 拒删路径的**真实总数**(不受清单上限影响) */
574
+ createdSkippedCount?: RollbackCount
575
+ createdSkippedTruncated?: boolean
576
+ /** 非 null = 该点捕获**不完整** ⇒ 一条都没删(安全拒删) */
577
+ createdRefusedReason?: string | null
578
+ /** 工作区段:实际写回的字节数(宿主回传才有) */
579
+ writtenBytes?: number
580
+ /** 兼容形态:宿主只回传计数时也算得出来 */
581
+ createdAfter?: RollbackCount
582
+ createdAfterTruncated?: boolean
583
+ /** 会话段:回退产生的 fork 子会话 id */
584
+ childSessionId?: string
585
+ guidance?: RollbackGuidance[]
586
+ }
587
+
588
+ export interface RollbackRewindResult {
589
+ ok: boolean
590
+ outcome: string
591
+ code: string
592
+ detail?: string
593
+ id?: string
594
+ segments?: RollbackSegmentResult[]
595
+ /** 因前段失败 / 未请求而未执行的段(只可能是两态) */
596
+ skippedSegments?: string[]
597
+ /** 会话回退结果:fork 出来的子会话 id(S3;原会话日志只读,绝不改写) */
598
+ childSessionId?: string
599
+ /** 回退前自动留的救援点 id(S5 guard) */
600
+ guardPointId?: string
601
+ storage?: RollbackStorageStatus
602
+ guidance?: RollbackGuidance[]
603
+ warnings?: RollbackGuidance[]
604
+ }
605
+
606
+ export interface RollbackRemoveResult { ok: boolean; code: string; detail?: string; id: string }
607
+
608
+ /** 回退输入(冻结 wire contract 的 rewind body)。 */
609
+ export interface RollbackRewindOptions {
610
+ /** fail-closed 确认门:必须**恰好** true(宿主缺省/假值一律 409 confirmation-required) */
611
+ userConfirmed: boolean
612
+ mode?: RollbackMode
613
+ segments?: readonly RollbackState[]
614
+ allowPartial?: boolean
615
+ preRewindGuard?: RollbackGuardPolicy
616
+ }
617
+
618
+ /** 端口:由 src/client/recovery/recovery-api.ts 实现(Partial 消费,缺失时界面如实说能力缺失)。 */
619
+ export interface RollbackPointPort {
620
+ rollbackPoints(): Promise<RollbackListResult>
621
+ rollbackPointStorage(): Promise<RollbackStorageStatus>
622
+ rollbackPoint(id: string): Promise<RollbackDetailResult>
623
+ rollbackPointPreview(id: string): Promise<RollbackPreviewResult>
624
+ /**
625
+ * 只读:该点里**一个记录在案文件**的前后逐行对照(预览弹窗按需拉取;未命中记录一律拒绝)。
626
+ * 可选实现:端口缺失该方法时,界面**不显示**「查看」(绝不给一个点了没反应的入口)。
627
+ */
628
+ rollbackPointFileDiff?(id: string, path: string): Promise<RollbackFileDiffResult>
629
+ restoreRollbackPoint(id: string, opts: RollbackRewindOptions): Promise<RollbackRewindResult>
630
+ removeRollbackPoint(id: string, userConfirmed: boolean): Promise<RollbackRemoveResult>
631
+ /**
632
+ * 回合自动留点开关(issue #88:它跟配置备份/同步是两件事,必须能关)。
633
+ * 可选实现:端口缺失时界面**不渲染**开关(绝不给一个点了没反应的入口)。
634
+ */
635
+ rollbackPrefs?(): Promise<RollbackPrefsResult>
636
+ setRollbackEnabled?(enabled: boolean): Promise<RollbackPrefsResult>
637
+ /**
638
+ * 清理台账(issue #89 P1/P2:把已经涨上去的占用收回来)。
639
+ * **只删自动点**;`includeGuards` 为 true 时连 guard 也删,protected 永不删。
640
+ */
641
+ purgeRollbackPoints?(opts: { includeGuards: boolean }): Promise<RollbackPurgeResult>
642
+ }
643
+
644
+ /**
645
+ * 聊天内「↺ 回退」入口是否可见(issue #88:关闭后**收起**入口,不是置灰)。
646
+ *
647
+ * 判据只有一条:**宿主回传的开关**。老宿主(storage 响应里没有 `rollbackEnabled` 字段)按
648
+ * 「未提供 = 功能默认开」处理 —— 那一版的语义就是默认启用,此时把入口藏起来会让用户
649
+ * 「有点但没有入口」。形状不符(不是布尔)同样按「未提供」处理,绝不因为一个坏字段藏掉入口。
650
+ */
651
+ export function rollbackEntryVisible(storage: RollbackStorageStatus | null | undefined): boolean {
652
+ if (storage === undefined || storage === null) return false
653
+ return storage.rollbackEnabled !== false
654
+ }
655
+
656
+ /* ------------------------------------------------------------ 内部小工具 */
657
+
658
+ function countOf(value: RollbackCount | undefined): number {
659
+ if (typeof value === 'number' && Number.isFinite(value)) return value
660
+ if (Array.isArray(value)) return value.length
661
+ return 0
662
+ }
663
+
664
+ function numberOrNull(value: number | null | undefined): number | null {
665
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
666
+ }
667
+
668
+ /** 路径/事实清单:逐条脱敏(宿主技术细节不得明文上屏)。 */
669
+ function redactLines(values: readonly string[] | undefined): string[] {
670
+ return (values ?? []).map((line) => redact(line))
671
+ }
672
+
673
+ /** 段可用性:显式 available/restorable 为 false 才算不可用(缺字段 = 宿主没给否定信号)。 */
674
+ function segmentAvailable(segment: { available?: boolean; restorable?: boolean }): boolean {
675
+ return segment.available !== false && segment.restorable !== false
676
+ }
677
+
678
+ /* ------------------------------------------------------------ 列表 → 展示行 */
679
+
680
+ export interface RollbackSegmentView {
681
+ state: string
682
+ labelKey: string
683
+ restorable: boolean
684
+ /** ok = 正常;warn = 捕获不完整(degraded);error = 没捕到 / 失败 */
685
+ badgeKind: 'ok' | 'warn' | 'error'
686
+ entries: number
687
+ cursorBytes: number | null
688
+ reasonKey: string | null
689
+ /** 留点时被跳过的条目总数(S4:跳过必须可见,绝不静默) */
690
+ skippedCount: number
691
+ /** 该段捕获被上限截断 */
692
+ truncated: boolean
693
+ /** 该段要显示的告警键(有序;空数组 = 没有需要说的)—— 文案在字典里,本层只回键 */
694
+ noticeKeys: string[]
695
+ }
696
+
697
+ /** 段级 / 点级「跳过 · 截断」告警键(S4:宿主已如实回传,界面必须说出来)。 */
698
+ export const ROLLBACK_NOTICE_SKIPPED_KEY = 'rollback.row.notice.skipped'
699
+ export const ROLLBACK_NOTICE_TRUNCATED_KEY = 'rollback.row.notice.truncated'
700
+ export const ROLLBACK_PREVIEW_NOTICE_SKIPPED_KEY = 'rollback.preview.notice.skipped'
701
+ export const ROLLBACK_PREVIEW_NOTICE_TRUNCATED_KEY = 'rollback.preview.notice.truncated'
702
+
703
+ /** 跳过 / 截断事实 → 告警键(顺序固定:先「跳过多少」,再「被截断」)。 */
704
+ function noticeKeysOf(skippedCount: number, truncated: boolean, skippedKey: string, truncatedKey: string): string[] {
705
+ const keys: string[] = []
706
+ if (skippedCount > 0) keys.push(skippedKey)
707
+ if (truncated) keys.push(truncatedKey)
708
+ return keys
709
+ }
710
+
711
+ export interface RollbackPointRowView {
712
+ id: string
713
+ /** 来源:auto(回合结束自动留点)/ guard(回退前救援点)/ unknown */
714
+ kind: RollbackPointKind | 'unknown'
715
+ kindKey: string
716
+ createdAtMs: number
717
+ sessionId: string | null
718
+ /** 会话单元 id(宿主回传才有;分组表头显示用) */
719
+ unitId: string | null
720
+ /** 对话标题(宿主补的只读装饰,**渲染前已过 redact**;null = 缓存里没有,界面回落显示 sessionId) */
721
+ title: string | null
722
+ /** 该点对应的回合号(null = 未知,界面渲染 rollback.turnUnknown) */
723
+ turn: number | null
724
+ protected: boolean
725
+ incomplete: boolean
726
+ badgeKind: 'ok' | 'warn' | 'error'
727
+ spreadMs: number
728
+ segments: RollbackSegmentView[]
729
+ /**
730
+ * 至少有一段可回退、未保护、且两态齐全 —— 界面据此决定「回退」按钮可用性。
731
+ * **救援点可以为 true**(t22:回退准入只拒 protected / incomplete;救援点正是「回到回退前」的唯一路径)。
732
+ */
733
+ rewindable: boolean
734
+ /**
735
+ * 可删:受保护点与**救援点**都不可删(宿主 remove 一律 protected-point)。
736
+ * 与 rewindable **互相独立**(引擎 canRemove / canRestore 两门,src/core/rollback-point.ts:1530-1531)。
737
+ */
738
+ removable: boolean
739
+ /** 用户自由文本:**渲染前已过 redact** */
740
+ note: string | null
741
+ }
742
+
743
+ function segmentView(segment: RollbackSegmentSummary): RollbackSegmentView {
744
+ const ok = segment.ok !== false
745
+ const degraded = segment.degraded === true
746
+ const badgeKind: 'ok' | 'warn' | 'error' = ok ? (degraded ? 'warn' : 'ok') : 'error'
747
+ const skippedCount = numberOrNull(segment.skippedCount) ?? 0
748
+ const truncated = segment.truncated === true
749
+ return {
750
+ state: segment.state,
751
+ labelKey: rollbackStateKey(segment.state),
752
+ restorable: segment.restorable === true,
753
+ badgeKind,
754
+ entries: numberOrNull(segment.entries) ?? 0,
755
+ cursorBytes: numberOrNull(segment.cursorBytes),
756
+ reasonKey: ok ? null : rollbackCodeKey(segment.reasonCode),
757
+ skippedCount,
758
+ truncated,
759
+ noticeKeys: noticeKeysOf(skippedCount, truncated, ROLLBACK_NOTICE_SKIPPED_KEY, ROLLBACK_NOTICE_TRUNCATED_KEY),
760
+ }
761
+ }
762
+
763
+ /** 列表 → 展示行(台账已按时间倒序;本函数只做映射与判定,不重排)。 */
764
+ export function rollbackPointRows(list: RollbackListResult | null): RollbackPointRowView[] {
765
+ if (list === null) return []
766
+ const points = Array.isArray(list.points) ? list.points : []
767
+ return points.map((point) => {
768
+ const segments = (Array.isArray(point.segments) ? point.segments : []).map(segmentView)
769
+ const kind: RollbackPointKind | 'unknown' = point.kind === 'guard' ? 'guard' : point.kind === 'auto' ? 'auto' : 'unknown'
770
+ const kindKey = kind === 'guard' ? 'rollback.kind.guard' : kind === 'auto' ? 'rollback.kind.auto' : ROLLBACK_KIND_UNKNOWN_KEY
771
+ return {
772
+ id: point.id,
773
+ kind,
774
+ kindKey,
775
+ createdAtMs: point.createdAt,
776
+ sessionId: typeof point.sessionId === 'string' && point.sessionId !== '' ? point.sessionId : null,
777
+ unitId: typeof point.unitId === 'string' && point.unitId !== '' ? point.unitId : null,
778
+ title: typeof point.title === 'string' && point.title.trim() !== '' ? redact(point.title) : null,
779
+ turn: numberOrNull(point.turn),
780
+ protected: point.protected === true,
781
+ incomplete: point.incomplete === true,
782
+ badgeKind: point.incomplete === true ? 'warn' : 'ok',
783
+ spreadMs: numberOrNull(point.spreadMs) ?? 0,
784
+ segments,
785
+ rewindable: point.protected !== true && point.incomplete !== true && segments.some((segment) => segment.restorable),
786
+ // 宿主真值(引擎 preview/remove 同一判据):protected 与 **guard** 一律不可删 ——
787
+ // 只看 protected 会把救援点的「删除」按钮点亮,点了必得 protected-point(按钮与文案自相矛盾)。
788
+ removable: point.protected !== true && kind !== 'guard',
789
+ note: point.note !== null && point.note !== undefined && point.note !== '' ? redact(point.note) : null,
790
+ }
791
+ })
792
+ }
793
+
794
+ /* ------------------------------------------------------------ 按会话分组(时间线) */
795
+
796
+ export interface RollbackPointGroupView {
797
+ /** 会话 id(宿主未回传则为 null —— 界面渲染 rollback.group.unknownSession,绝不猜归属) */
798
+ sessionId: string | null
799
+ /** 会话单元 id(宿主回传才有) */
800
+ unitId: string | null
801
+ /** 对话标题(组内任一记录带标题即取之;null = 无标题,界面回落显示 sessionId) */
802
+ title: string | null
803
+ /** 组内最新时间(空组为 null;组间排序键) */
804
+ latestAtMs: number | null
805
+ rows: RollbackPointRowView[]
806
+ }
807
+
808
+ /**
809
+ * 台账 → **按会话分组**的时间线(组内保持台账顺序 = 时间倒序;组间按各组最新时间倒序)。
810
+ *
811
+ * 分组键 = sessionId;缺 sessionId 的记录归入「未知会话」组并排在最后 —— 绝不猜它属于哪个会话。
812
+ */
813
+ export function rollbackPointGroups(list: RollbackListResult | null): RollbackPointGroupView[] {
814
+ const groups: RollbackPointGroupView[] = []
815
+ const index = new Map<string, number>()
816
+ for (const row of rollbackPointRows(list)) {
817
+ const key = row.sessionId ?? '\u0000unknown'
818
+ let at = index.get(key)
819
+ if (at === undefined) {
820
+ at = groups.length
821
+ index.set(key, at)
822
+ groups.push({ sessionId: row.sessionId, unitId: row.unitId, title: row.title, latestAtMs: null, rows: [] })
823
+ }
824
+ const group = groups[at] as RollbackPointGroupView
825
+ group.rows.push(row)
826
+ if (group.unitId === null && row.unitId !== null) group.unitId = row.unitId
827
+ if (group.title === null && row.title !== null) group.title = row.title
828
+ if (group.latestAtMs === null || row.createdAtMs > group.latestAtMs) group.latestAtMs = row.createdAtMs
829
+ }
830
+ return groups.sort((a, b) => {
831
+ if (a.sessionId === null && b.sessionId !== null) return 1
832
+ if (b.sessionId === null && a.sessionId !== null) return -1
833
+ return (b.latestAtMs ?? 0) - (a.latestAtMs ?? 0)
834
+ })
835
+ }
836
+
837
+ /** 该回滚点记下了多少个工作区文件(时间线上的「文件数」)。 */
838
+ export function rollbackRowFileCount(row: RollbackPointRowView): number {
839
+ let total = 0
840
+ for (const segment of row.segments) if (segment.state === 'workspace') total += segment.entries
841
+ return total
842
+ }
843
+
844
+ /** 该回滚点是否有会话游标(时间线上的「是否有会话游标」—— 没有就只回退工作区)。 */
845
+ export function rollbackRowHasSessionCursor(row: RollbackPointRowView): boolean {
846
+ return row.segments.some((segment) => segment.state === 'session' && segment.cursorBytes !== null)
847
+ }
848
+
849
+ /* ------------------------------------------------------------ 预览 → 展示行 */
850
+
851
+ export interface RollbackCountRowView { key: string; n: number }
852
+
853
+ export interface RollbackPreviewRowView {
854
+ state: string
855
+ labelKey: string
856
+ available: boolean
857
+ /** 宿主给出的原因码键(null = 没给;不可用时至少回落到 unknown,绝不裸渲染) */
858
+ reasonKey: string | null
859
+ /** 有序计数文案:overwrite / different / missing / createdAfter(工作区段才有计数) */
860
+ counts: RollbackCountRowView[]
861
+ /** 该点**未记录**的路径清单(含被拒删的;已脱敏;宿主上限 20 条)—— **不是**删除清单 */
862
+ leftovers: string[]
863
+ leftoversTruncated: boolean
864
+ /**
865
+ * **会被删除**的路径(已脱敏)—— **全量**(引擎不再切 20 条),与执行阶段同一份规划 ⇒ 逐条一致。
866
+ * 界面直接把它渲染成「删除」行,**不需要**任何「清单可能不完整」的说明句。
867
+ */
868
+ removals: string[]
869
+ /** 会被删除的条数(= {@link removals}.length) */
870
+ removalsCount: number
871
+ removalsTruncated: boolean
872
+ /**
873
+ * 按安全判据**拒删**的路径与条数、以及「**证明名单不完整** ⇒ 一条都没删」的原因。
874
+ * **正文不渲染**(用户 2026-10-12:弹窗只要清单,不要说明句;文案克制 → DESIGN.md §7)——
875
+ * 字段保留是引擎事实的投影,将来若放进 ⓘ / 工具提示可直接用。
876
+ */
877
+ createdSkipped: string[]
878
+ createdSkippedCount: number
879
+ createdSkippedTruncated: boolean
880
+ createdRefusedReason: string | null
881
+ /** 留点时被跳过的条目总数(S4:跳过必须可见) */
882
+ skippedCount: number
883
+ /** 捕获被上限截断 */
884
+ truncated: boolean
885
+ /** 该行要显示的「跳过 · 截断」告警键(有序;空数组 = 没有要说的) */
886
+ noticeKeys: string[]
887
+ /**
888
+ * **点级**告警(宿主 preview.warnings;走 rollback.warn.* 映射、未知码回落 unknown)。
889
+ *
890
+ * 引擎不给「告警属于哪一段」的键(detail 是自由文本,解析它等于猜),所以点级告警**两行都带同一份**,
891
+ * 由界面在面板级渲染一次(rollbackPreviewWarnings())—— 本层绝不猜归属。
892
+ */
893
+ warnings: RollbackGuidanceRowView[]
894
+ /** 会话段:边界游标 from → to(seq;缺失为 null) */
895
+ cursor: { from: number | null; to: number | null } | null
896
+ /** 会话段:日志字节数 now → target(宿主给得出才有;界面据此显示「会话 now→target」) */
897
+ cursorBytes: { now: number | null; target: number | null } | null
898
+ /** 会话段:该点对应的回合号(null = 未知,界面渲染 rollback.turnUnknown 口径) */
899
+ turns: number | null
900
+ /** 会话段:日志刚被写过(仅供参考:fork 只读,不会覆盖日志) */
901
+ active: boolean
902
+ guidance: RollbackGuidanceRowView[]
903
+ }
904
+
905
+ /**
906
+ * 预览 → 展示行(<id>/preview 的只读结果)。
907
+ *
908
+ * 计数口径逐条对应「界面能看到什么」:覆盖多少项、其中多少项内容不同、多少项当前已不存在
909
+ * (会被重新创建)、多少项是该点**未记录**的(引擎只统计「不在记录里」的文件,留点时被跳过的
910
+ * 既有文件同样落在这里,所以不得声称「之后新建」)。
911
+ *
912
+ * 「未记录」与「会被删除」是**两件事**:会被删的只有过了安全判据的那一份
913
+ * (`workspace.removals` / `removalsCount`),未记录清单里混着永不删除的 `.git` 等拒删项。
914
+ */
915
+ /**
916
+ * 文件行的**主标签** = 文件名(Cursor「Confirm Undo」式:一眼看是哪几个文件,而不是一屏绝对路径)。
917
+ * 完整路径由组件放进 `title`(悬停可见),**信息一个都不丢**。
918
+ */
919
+ export function rollbackPreviewFileLabel(filePath: string): string {
920
+ const trimmed = filePath.replace(/[\\/]+$/, '')
921
+ const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
922
+ return at < 0 ? trimmed : trimmed.slice(at + 1)
923
+ }
924
+
925
+ /**
926
+ * 文件行的**目录前缀**(弱化色那一段)+ 基名。
927
+ *
928
+ * 为什么必须有它(2026-10-12 用户截图实测):只显示基名时 `.git/index` / `.git/HEAD` /
929
+ * `.git/COMMIT_EDITMSG` 在屏幕上就是三个孤零零的英文单词,用户第一眼读不出「这是什么、在哪」;
930
+ * 而完整路径又太长会把 ±行数推走。折中 = **目录弱化 + 基名主色 + 完整路径仍在 title**(信息不丢)。
931
+ *
932
+ * 语义(两边界都是刻意的):
933
+ * · 路径里没有分隔符(`AGENTS.md` 也在根目录)⇒ `dir === ''` —— 调用方**不画**那一段,
934
+ * 不会出现一颗孤零零的 `/`;
935
+ * · 尾分隔符一律剥掉(`src/a/` ⇒ dir `src`、base `a`),与 rollbackPreviewFileLabel 同一口径;
936
+ * · 只在**最后一个**分隔符处切(Windows 盘符 `C:\\a\\b` ⇒ dir `C:\\a`)。
937
+ */
938
+ export interface RollbackPathParts {
939
+ /** 目录(含尾随分隔符);根目录文件是空串 */
940
+ dir: string
941
+ /** 基名 */
942
+ base: string
943
+ }
944
+
945
+ export function splitRollbackPath(filePath: string): RollbackPathParts {
946
+ const trimmed = filePath.replace(/[\\/]+$/, '')
947
+ const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
948
+ if (at < 0) return { dir: '', base: trimmed }
949
+ return { dir: trimmed.slice(0, at + 1), base: trimmed.slice(at + 1) }
950
+ }
951
+
952
+ /**
953
+ * 预览**汇总**(弹窗顶部那一条):这次回退到底动多少东西。
954
+ *
955
+ * 为什么这是这一屏最该先看的信息:用户点「↺ 回退」后来到弹窗,先要判断「规模」再逐行核文件;
956
+ * 没有汇总就得爬到第 8 行才知道「原来是 8 个文件」。所以它不是装饰,是**决策信息**
957
+ * (DESIGN §7:只留能改变决定的信息 —— 数字留,描述性句子不留)。
958
+ *
959
+ * 口径三条(逐条可证伪,界面不得超出这里说的):
960
+ * · `files` = **会被改动**的文件数(= 传进来的行数本身,不减去任何东西);
961
+ * · `added` / `removed` **只累加可信行数**:任一侧是 null(超限 / 非文本 / 读不出)的文件两列都不计,
962
+ * 否则「行数未统计」的文件会被当成 0 行 —— 那是一个会被证伪的具体断言(见 ChatPreviewFileRow 注释);
963
+ * · 删除集是另一条清单(`removals`,与执行阶段同一份规划),**单独计数**,不与上方混加。
964
+ */
965
+ export interface RollbackPreviewScope {
966
+ files: number
967
+ /** 新增行数合计(只含两侧都可信的行) */
968
+ added: number
969
+ /** 删除行数合计(同上) */
970
+ removed: number
971
+ /** 整文件删除条数(引擎 removals,真的会删) */
972
+ removals: number
973
+ }
974
+
975
+ export function rollbackPreviewScope(
976
+ files: readonly { added: number | null; removed: number | null }[],
977
+ removals: readonly unknown[],
978
+ ): RollbackPreviewScope {
979
+ let added = 0
980
+ let removed = 0
981
+ for (const file of files) {
982
+ if (file.added === null || file.removed === null) continue
983
+ added += file.added
984
+ removed += file.removed
985
+ }
986
+ return { files: files.length, added, removed, removals: removals.length }
987
+ }
988
+
989
+ /**
990
+ * 差异无法逐行展示时的字典键(本层**只回键**,文案在字典里)。
991
+ * 未知 / 缺失原因回落 null —— 调用方按「未知原因」出话,绝不回落成「没有差异」(那是另一件事)。
992
+ */
993
+ export function rollbackFileDiffReasonKey(reason: string | null | undefined): string | null {
994
+ switch (reason) {
995
+ case 'missing-blob': return 'rollback.diff.reason.missingBlob'
996
+ case 'binary': return 'rollback.diff.reason.binary'
997
+ case 'too-large': return 'rollback.diff.reason.tooLarge'
998
+ case 'unreadable': return 'rollback.diff.reason.unreadable'
999
+ default: return null
1000
+ }
1001
+ }
1002
+
1003
+ /** 可渲染的 hunks(缺字段 / 非数组一律当空 —— 渲染层绝不因宿主形态差异崩掉)。 */
1004
+ export function rollbackFileDiffHunks(result: RollbackFileDiffResult | null): DiffHunk[] {
1005
+ const hunks = result?.hunks
1006
+ return Array.isArray(hunks) ? hunks : []
1007
+ }
1008
+
1009
+ /** 两侧是否都真的读到了文本(否则说不出「无差异」)。 */
1010
+ export function rollbackFileDiffComparable(result: RollbackFileDiffResult | null): boolean {
1011
+ if (result === null || result.ok !== true) return false
1012
+ if (result.reason !== undefined) return false
1013
+ return result.before.binary !== true && result.after.binary !== true && result.before.oversized !== true && result.after.oversized !== true
1014
+ }
1015
+
1016
+ export function rollbackPreviewRows(preview: RollbackPreviewResult | null): RollbackPreviewRowView[] {
1017
+ if (preview === null) return []
1018
+ const rows: RollbackPreviewRowView[] = []
1019
+ const pointWarnings = rollbackGuidanceRows(preview.warnings)
1020
+ const workspace = preview.workspace
1021
+ if (workspace !== undefined && workspace !== null) {
1022
+ const skippedCount = countOf(workspace.skippedCount)
1023
+ const truncated = workspace.truncated === true
1024
+ rows.push({
1025
+ state: 'workspace',
1026
+ labelKey: rollbackStateKey('workspace'),
1027
+ available: segmentAvailable(workspace),
1028
+ reasonKey: workspace.reasonCode !== undefined && workspace.reasonCode !== null && workspace.reasonCode !== ''
1029
+ ? rollbackCodeKey(workspace.reasonCode)
1030
+ : null,
1031
+ counts: [
1032
+ { key: 'rollback.preview.overwrite', n: countOf(workspace.overwrite) },
1033
+ { key: 'rollback.preview.different', n: countOf(workspace.different) },
1034
+ { key: 'rollback.preview.missing', n: countOf(workspace.missing) },
1035
+ { key: 'rollback.preview.createdAfter', n: Math.max(countOf(workspace.createdAfter), countOf(workspace.leftovers)) },
1036
+ ],
1037
+ leftovers: redactLines(workspace.leftovers),
1038
+ leftoversTruncated: workspace.leftoversTruncated === true,
1039
+ removals: redactLines(workspace.removals),
1040
+ removalsCount: Math.max(countOf(workspace.removalsCount), Array.isArray(workspace.removals) ? workspace.removals.length : 0),
1041
+ removalsTruncated: workspace.removalsTruncated === true,
1042
+ createdSkipped: redactLines(workspace.createdSkipped),
1043
+ createdSkippedCount: Math.max(countOf(workspace.createdSkippedCount), Array.isArray(workspace.createdSkipped) ? workspace.createdSkipped.length : 0),
1044
+ createdSkippedTruncated: workspace.createdSkippedTruncated === true,
1045
+ createdRefusedReason: typeof workspace.createdRefusedReason === 'string' && workspace.createdRefusedReason !== '' ? workspace.createdRefusedReason : null,
1046
+ skippedCount,
1047
+ truncated,
1048
+ noticeKeys: noticeKeysOf(skippedCount, truncated, ROLLBACK_PREVIEW_NOTICE_SKIPPED_KEY, ROLLBACK_PREVIEW_NOTICE_TRUNCATED_KEY),
1049
+ warnings: pointWarnings,
1050
+ cursor: null,
1051
+ cursorBytes: null,
1052
+ turns: null,
1053
+ active: false,
1054
+ guidance: rollbackGuidanceRows(workspace.guidance),
1055
+ })
1056
+ }
1057
+ const session = preview.session
1058
+ if (session !== undefined && session !== null) {
1059
+ rows.push({
1060
+ state: 'session',
1061
+ labelKey: rollbackStateKey('session'),
1062
+ available: segmentAvailable(session),
1063
+ reasonKey: session.reasonCode !== undefined && session.reasonCode !== null && session.reasonCode !== ''
1064
+ ? rollbackCodeKey(session.reasonCode)
1065
+ : null,
1066
+ counts: [],
1067
+ leftovers: [],
1068
+ leftoversTruncated: false,
1069
+ removals: [],
1070
+ removalsCount: 0,
1071
+ removalsTruncated: false,
1072
+ createdSkipped: [],
1073
+ createdSkippedCount: 0,
1074
+ createdSkippedTruncated: false,
1075
+ createdRefusedReason: null,
1076
+ skippedCount: 0,
1077
+ truncated: false,
1078
+ noticeKeys: [],
1079
+ warnings: pointWarnings,
1080
+ cursor: { from: numberOrNull(session.from), to: numberOrNull(session.to) },
1081
+ cursorBytes: { now: numberOrNull(session.cursorNowBytes), target: numberOrNull(session.cursorTargetBytes) },
1082
+ turns: numberOrNull(session.turns),
1083
+ active: session.active === true,
1084
+ guidance: rollbackGuidanceRows(session.guidance),
1085
+ })
1086
+ }
1087
+ return rows
1088
+ }
1089
+
1090
+ export interface RollbackPreviewVerdict {
1091
+ /** 宿主允许回退 **且** 至少有一段可回退 */
1092
+ canRestore: boolean
1093
+ canRemove: boolean
1094
+ /** canRestore=false 时的原因键(null = 可回退) */
1095
+ reasonKey: string | null
1096
+ /** 三选一 mode(按可用段推导,界面据此作为下拉默认值) */
1097
+ mode: RollbackMode
1098
+ /** 可回退的段(只可能是 workspace / session) */
1099
+ segments: RollbackState[]
1100
+ /** 该点两态不全(缺省不允许回退;界面据此要求显式勾「允许部分回退」) */
1101
+ incomplete: boolean
1102
+ }
1103
+
1104
+ /** 点级告警(preview.warnings)→ 展示行;界面在面板级渲染一次(点级告警不归属任何段,绝不猜)。 */
1105
+ export function rollbackPreviewWarnings(preview: RollbackPreviewResult | null): RollbackGuidanceRowView[] {
1106
+ return rollbackGuidanceRows(preview?.warnings)
1107
+ }
1108
+
1109
+ /** 点级指引(preview.guidance)→ 展示行(同上,面板级渲染一次)。 */
1110
+ export function rollbackPreviewGuidance(preview: RollbackPreviewResult | null): RollbackGuidanceRowView[] {
1111
+ return rollbackGuidanceRows(preview?.guidance)
1112
+ }
1113
+
1114
+ /* ------------------------------------------------------------ T15:预览态「无事可做」的四态分派 */
1115
+
1116
+ /**
1117
+ * 预览态那句「工作区怎样怎样」的**四态**判别(T15)。
1118
+ *
1119
+ * 背景(用户截图抱怨的那一屏就是**预览态**):点「回退」之前,三种**完全不同**的事实
1120
+ * 被同一句「工作区与该回退点一致,无需恢复」概括 —— 其中两种是不实陈述:
1121
+ * · 该点压根没记录任何文件(工作区段 files=0);
1122
+ * · 工作区态没有可用记录(available=false);
1123
+ * · 而真正「一致」时,若**会话仍会回退**(fork 子会话),说「无需恢复」也是假尾句。
1124
+ *
1125
+ * 判据**只来自 preview 的字段**(available / overwrite / different / missing / reasonCode /
1126
+ * session.restorable)与宿主给的 canRestore,**禁止解析 detail**(技术细节不是接口)。
1127
+ * 优先级(与验收逐字一致):① 工作区态不可用 → ⓪ 该点**未记录**的新建内容会被删除
1128
+ * (`removalsCount` > 0 = 真会删,宿主 removeCreated 缺省为真)→ ② available 且 overwrite===0
1129
+ * → ③a 工作区一致但会话仍可回退 → ③b 工作区一致且无事可做。另有前置的一条**不属于四态**:
1130
+ * 工作区**真会改动**(different>0 / missing>0 / 会删掉未记录的新建文件)时不上屏任何
1131
+ * 「一致/无需恢复」句(由文件列表说话)——
1132
+ * 这也是「假尾句禁令」在函数层面的落点:`changesSomething === true` 时,
1133
+ * 要么 `key === null`,要么该键的文案**不含**「无需恢复」。
1134
+ */
1135
+ export type RollbackPreviewNoteKind =
1136
+ /** 工作区真会改动(写文件**或删掉该点未记录的新建文件**)⇒ 没有「一致」句可说,由文件列表说话。 */
1137
+ | 'workspace-changes'
1138
+ /** ① 工作区态没有可用记录(available=false)。 */
1139
+ | 'workspace-unavailable'
1140
+ /** ② 该点没记录任何文件(available=true 且 overwrite===0)。 */
1141
+ | 'no-recorded-files'
1142
+ /** ③a 工作区与该点一致,但会话仍会回退(fork 子会话)。 */
1143
+ | 'workspace-consistent-session'
1144
+ /** ③b 工作区与该点一致,且无事可做(会话也不可回退)。 */
1145
+ | 'workspace-consistent-noop'
1146
+
1147
+ /** 预览态那句文案的判定结果(界面只负责把它翻译上屏,不在界面里重写判据)。 */
1148
+ export interface RollbackPreviewNote {
1149
+ kind: RollbackPreviewNoteKind
1150
+ /** 上屏文案键;**null = 本情形没有该类句子**(工作区有变更 ⇒ 由文件列表说话)。 */
1151
+ key: string | null
1152
+ /** 需要插进 `{reason}` 的**字典键**(只有 ① 用;未知/缺失一律 `rollback.code.unknown`)。 */
1153
+ reasonKey: string | null
1154
+ /** 点「回退」是否**真会改动什么**(工作区会写文件 or 会话会 fork)—— 假尾句禁令的判据。 */
1155
+ changesSomething: boolean
1156
+ /** 会话仍会回退(= 会 fork 出新子会话)。 */
1157
+ sessionForks: boolean
1158
+ }
1159
+
1160
+ /** ① 的原因键来源:workspace.reasonCode 优先 → 段摘要里的 workspace 段 → 未知(绝不编原因)。 */
1161
+ function previewNoteReasonKey(
1162
+ workspace: RollbackWorkspacePreview | null | undefined,
1163
+ segments: readonly { state?: string; reasonCode?: string | null }[] | undefined,
1164
+ ): string {
1165
+ const direct = workspace?.reasonCode
1166
+ if (typeof direct === 'string' && direct !== '') return rollbackCodeKey(direct)
1167
+ const segment = segments?.find((item) => item.state === 'workspace')
1168
+ const code = segment?.reasonCode
1169
+ if (typeof code === 'string' && code !== '') return rollbackCodeKey(code)
1170
+ return ROLLBACK_CODE_UNKNOWN_KEY
1171
+ }
1172
+
1173
+ /**
1174
+ * 预览态文案判定(**一处实现**:聊天对话框调用它;将来设置页若要复用同一口径也走这里)。
1175
+ *
1176
+ * @param input.workspace preview.workspace(缺失按「不可用」处理:没有记录信息 ⇒ 不能说「一致」)
1177
+ * @param input.session preview.session(只有 `restorable === true` 才算「会话仍会回退」)
1178
+ * @param input.segments 段摘要(`preview.point.segments`;只用于 ① 的原因键回落,读 reasonCode 字段)
1179
+ * @param input.canRestore 宿主/判定层的回退门(缺省按 true;传 false 时不得声称「会话仍会回退」)
1180
+ */
1181
+ export function rollbackPreviewNote(input: {
1182
+ workspace?: RollbackWorkspacePreview | null
1183
+ session?: RollbackSessionPreview | null
1184
+ segments?: readonly { state?: string; reasonCode?: string | null }[] | null
1185
+ canRestore?: boolean
1186
+ }): RollbackPreviewNote {
1187
+ const workspace = input.workspace ?? null
1188
+ const session = input.session ?? null
1189
+ const overwrite = countOf(workspace?.overwrite)
1190
+ const different = countOf(workspace?.different)
1191
+ const missing = countOf(workspace?.missing)
1192
+ /**
1193
+ * **会被删除**的条数(= 该点未记录 **且**过了安全判据的那些)。
1194
+ *
1195
+ * 为什么它必须参与「这句话说得说不说得」:宿主 rewind 的 `removeCreated` **缺省为真**
1196
+ * (src/routes/recovery.ts:459),所以「工作区与该点一致,无需恢复」在会删文件的点上同样是**假尾句**
1197
+ * —— 点下去真的会删文件(用户 2026-10-12 第 6 条)。
1198
+ *
1199
+ * 判据**只能**取 `removalsCount` / `removals`(引擎 `planCreatedRemovals` 的同一份规划):
1200
+ * 拿「未记录总数」(`createdAfter` / `leftovers`)当删除数会**双向出错** —— 它把永不删除的
1201
+ * `.git` 路径也算进去,又把证明名单不完整时「一条都不删」的情形说成「工作区会变」。
1202
+ */
1203
+ const removals = Math.max(
1204
+ countOf(workspace?.removalsCount),
1205
+ Array.isArray(workspace?.removals) ? workspace.removals.length : 0,
1206
+ )
1207
+ // 「会话仍会回退」两个条件都要:该段可回退 + 该点整体可回退(受保护点/两态不全时按钮本来就点不了,
1208
+ // 这时说「会话仍会回退到该点之前」同样是假话)
1209
+ const sessionForks = input.canRestore !== false && session?.restorable === true
1210
+
1211
+ // ① 工作区态没有可用记录
1212
+ if (workspace?.available !== true) {
1213
+ const reasonKey = previewNoteReasonKey(workspace, input.segments ?? undefined)
1214
+ return {
1215
+ kind: 'workspace-unavailable',
1216
+ key: sessionForks ? 'rollback.preview.files.unavailableSession' : 'rollback.preview.files.unavailable',
1217
+ reasonKey,
1218
+ changesSomething: sessionForks,
1219
+ sessionForks,
1220
+ }
1221
+ }
1222
+ // ⓪ 工作区态可用,但该点**未记录**的新建内容会被删掉 ⇒ 工作区真会变,
1223
+ // 「一致 / 无需恢复」在这里也是假尾句(宁可不说话:删掉的文件由列表逐条说出来)。
1224
+ if (removals > 0) {
1225
+ return { kind: 'workspace-changes', key: null, reasonKey: null, changesSomething: true, sessionForks }
1226
+ }
1227
+ // ② 该点没记录任何文件
1228
+ if (overwrite === 0) {
1229
+ return {
1230
+ kind: 'no-recorded-files',
1231
+ key: sessionForks ? 'rollback.preview.files.noFilesSession' : 'rollback.preview.result.noFiles',
1232
+ reasonKey: null,
1233
+ changesSomething: sessionForks,
1234
+ sessionForks,
1235
+ }
1236
+ }
1237
+ // 工作区真会写文件 ⇒ 没有「一致」句(由文件列表说话);这也是假尾句禁令的前置保护
1238
+ if (different > 0 || missing > 0) {
1239
+ return { kind: 'workspace-changes', key: null, reasonKey: null, changesSomething: true, sessionForks }
1240
+ }
1241
+ // ③a 工作区一致,但会话仍会回退 ⇒ 只说工作区(**禁止**「无需恢复」:点下去真会 fork)
1242
+ if (sessionForks) {
1243
+ return { kind: 'workspace-consistent-session', key: 'rollback.preview.files.consistentSession', reasonKey: null, changesSomething: true, sessionForks: true }
1244
+ }
1245
+ // ③b 工作区一致且无事可做
1246
+ return { kind: 'workspace-consistent-noop', key: 'rollback.preview.files.empty', reasonKey: null, changesSomething: false, sessionForks: false }
1247
+ }
1248
+
1249
+ /**
1250
+ * 预览里的「可回退 / 可删除」判定。
1251
+ *
1252
+ * **原因键按宿主真实形状分派**(引擎 preview 恒回 `code:'ok'`,靠 canRestore/canRemove 表达拒绝 ——
1253
+ * 见 src/core/rollback-point.ts:1530-1531:`canRemove = !protected && kind!=='guard'`(删除门)与
1254
+ * `canRestore = !protected && !incomplete && (workspaceRestorable || sessionRestorable)`(回退门)**互相独立**):
1255
+ * ① 受保护点 → rollback.code.protected-point(引擎 remove/rewind 都拒)
1256
+ * ② 两态不全 → rollback.preview.reason.incomplete(引擎要 allowPartial 才放行)
1257
+ * ③ 一段都不可回退 → 段自己的 reasonCode;没有就 rollback.preview.none
1258
+ * ④ 宿主给了非 ok 的 code → 该 code 的键
1259
+ * ⑤ 兜底:救援点 → rollback.preview.reason.guard(宿主拒绝了本可回退的救援点);其余 → rollback.preview.reason.hostRefused
1260
+ * —— **绝不回落 rollback.code.unknown**
1261
+ * 救援点(guard)在 ①②③ 之外**是可回退的**(t22):它只在宿主真的拒绝时落到 ⑤。
1262
+ */
1263
+ export function rollbackPreviewVerdict(preview: RollbackPreviewResult | null): RollbackPreviewVerdict {
1264
+ if (preview === null) {
1265
+ return { canRestore: false, canRemove: false, reasonKey: ROLLBACK_CODE_UNKNOWN_KEY, mode: 'files+session', segments: [], incomplete: false }
1266
+ }
1267
+ const segments: RollbackState[] = []
1268
+ if (preview.workspace !== undefined && preview.workspace !== null && segmentAvailable(preview.workspace)) segments.push('workspace')
1269
+ if (preview.session !== undefined && preview.session !== null && segmentAvailable(preview.session)) segments.push('session')
1270
+ const point = preview.point ?? preview.summary ?? null
1271
+ const protectedPoint = point?.protected === true
1272
+ const guardPoint = point?.kind === 'guard'
1273
+ const incomplete = point?.incomplete === true
1274
+ const canRestore = preview.canRestore === true && segments.length > 0
1275
+ let reasonKey: string | null = null
1276
+ if (!canRestore) {
1277
+ const segmentReason = preview.session?.reasonCode ?? preview.workspace?.reasonCode
1278
+ if (protectedPoint) reasonKey = 'rollback.code.protected-point'
1279
+ else if (incomplete) reasonKey = ROLLBACK_PREVIEW_REASON_INCOMPLETE_KEY
1280
+ else if (segments.length === 0) {
1281
+ reasonKey = typeof segmentReason === 'string' && segmentReason !== '' ? rollbackCodeKey(segmentReason) : 'rollback.preview.none'
1282
+ } else if (preview.code !== undefined && preview.code !== '' && preview.code !== 'ok') reasonKey = rollbackCodeKey(preview.code)
1283
+ // 兜底:宿主拒绝了一个「看起来可恢复」的点。救援点在这里单独说清(它本来是可回退目标,t22)——
1284
+ // 绝不把它写成「受保护」或「一律不可回退」。
1285
+ else reasonKey = guardPoint ? ROLLBACK_PREVIEW_REASON_GUARD_KEY : ROLLBACK_PREVIEW_REASON_HOST_REFUSED_KEY
1286
+ }
1287
+ return {
1288
+ canRestore,
1289
+ canRemove: preview.canRemove === true,
1290
+ reasonKey,
1291
+ mode: rollbackModeFromSegments(segments),
1292
+ segments,
1293
+ incomplete,
1294
+ }
1295
+ }
1296
+
1297
+ /* ------------------------------------------------------------ 回退结果 → 展示行 */
1298
+
1299
+ export interface RollbackRewindRowView {
1300
+ state: string
1301
+ labelKey: string
1302
+ statusKey: string
1303
+ badgeKind: 'ok' | 'warn' | 'error'
1304
+ restored: number
1305
+ failed: number
1306
+ /** 该点**未记录**、**未删除**的内容数(引擎 scanLeftovers 语义,不是「之后新建」) */
1307
+ leftoversCount: number
1308
+ /** 真的被删掉的「回退点之后新建」条数(段结果里的 removed) */
1309
+ removedCount: number
1310
+ /** 同上,路径清单(已脱敏;宿主上限 20 条) */
1311
+ leftovers: string[]
1312
+ leftoversTruncated: boolean
1313
+ /** 会话段:fork 出来的子会话 id(无则 null) */
1314
+ childSessionId: string | null
1315
+ /** 逐条事实:**渲染前已逐条过 redact** */
1316
+ detail: string[]
1317
+ guidance: RollbackGuidanceRowView[]
1318
+ }
1319
+
1320
+ export function rollbackRewindRows(result: RollbackRewindResult | null): RollbackRewindRowView[] {
1321
+ if (result === null) return []
1322
+ const segments = Array.isArray(result.segments) ? result.segments : []
1323
+ return segments.map((segment) => {
1324
+ const status: RollbackSegmentStatus | 'unknown' =
1325
+ segment.status === 'restored' ? 'restored' : segment.status === 'skipped' ? 'skipped' : segment.status === 'failed' ? 'failed' : 'unknown'
1326
+ const statusKey = status === 'unknown' ? 'rollback.segment.unknown' : 'rollback.segment.' + status
1327
+ const badgeKind: 'ok' | 'warn' | 'error' = status === 'restored' ? 'ok' : status === 'skipped' ? 'warn' : 'error'
1328
+ const leftovers = redactLines(segment.leftovers)
1329
+ const childSessionId = typeof segment.childSessionId === 'string' && segment.childSessionId !== '' ? segment.childSessionId : null
1330
+ return {
1331
+ state: segment.state,
1332
+ labelKey: rollbackStateKey(segment.state),
1333
+ statusKey,
1334
+ badgeKind,
1335
+ restored: numberOrNull(segment.restored) ?? 0,
1336
+ failed: numberOrNull(segment.failed) ?? 0,
1337
+ leftoversCount: Math.max(leftovers.length, countOf(segment.createdAfter)),
1338
+ removedCount: countOf(segment.removed),
1339
+ leftovers,
1340
+ leftoversTruncated: segment.leftoversTruncated === true || segment.createdAfterTruncated === true,
1341
+ childSessionId,
1342
+ detail: redactLines(segment.detail),
1343
+ guidance: rollbackGuidanceRows(segment.guidance),
1344
+ }
1345
+ })
1346
+ }
1347
+
1348
+ /** 回退总体结论 → 字典键 + 徽章语义(未知回落 unknown,绝不裸渲染 outcome)。 */
1349
+ export function rollbackOutcome(outcome: string | null | undefined): { key: string; badgeKind: 'ok' | 'warn' | 'error' } {
1350
+ if (outcome === 'restored') return { key: 'rollback.outcome.restored', badgeKind: 'ok' }
1351
+ if (outcome === 'partial') return { key: 'rollback.outcome.partial', badgeKind: 'warn' }
1352
+ if (outcome === 'failed' || outcome === 'denied') return { key: 'rollback.outcome.' + outcome, badgeKind: 'error' }
1353
+ return { key: 'rollback.outcome.unknown', badgeKind: 'error' }
1354
+ }
1355
+
1356
+ export interface RollbackRewindCounts {
1357
+ restored: number
1358
+ failed: number
1359
+ leftovers: number
1360
+ /** 本次真的删掉的「回退点之后新建」路径数(用户 2026-10-11 定案 A) */
1361
+ removed: number
1362
+ }
1363
+
1364
+ /** 回退结果汇总计数(界面一句话说清「恢复多少 / 失败多少 / 没删多少」)。 */
1365
+ export function rollbackRewindCounts(result: RollbackRewindResult | null): RollbackRewindCounts {
1366
+ const rows = rollbackRewindRows(result)
1367
+ let restored = 0
1368
+ let failed = 0
1369
+ let leftovers = 0
1370
+ let removed = 0
1371
+ for (const row of rows) {
1372
+ restored += row.restored
1373
+ failed += row.failed
1374
+ leftovers += row.leftoversCount
1375
+ removed += row.removedCount
1376
+ }
1377
+ return { restored, failed, leftovers, removed }
1378
+ }
1379
+
1380
+ /**
1381
+ * 会话回退的 fork 结果(S3):原会话日志**只读**,回退产出的是一个新子会话。
1382
+ * 返回 null = 本次没有会话回退(界面不渲染这一行,绝不编造子会话 id)。
1383
+ */
1384
+ export function rollbackChildSession(result: RollbackRewindResult | null): { key: string; id: string } | null {
1385
+ const direct = result?.childSessionId
1386
+ if (typeof direct === 'string' && direct !== '') return { key: 'rollback.rewind.result.child', id: direct }
1387
+ for (const segment of result?.segments ?? []) {
1388
+ const id = segment.childSessionId
1389
+ if (typeof id === 'string' && id !== '') return { key: 'rollback.rewind.result.child', id }
1390
+ }
1391
+ return null
1392
+ }
1393
+
1394
+ /** 本次因前段失败 / 未请求而未执行的段(只可能是两态;未知段回落 unknown 键)。 */
1395
+ export function rollbackSkippedSegments(result: RollbackRewindResult | null): { state: string; key: string }[] {
1396
+ const segments = Array.isArray(result?.skippedSegments) ? result!.skippedSegments! : []
1397
+ return segments.map((state) => ({ state, key: rollbackStateKey(state) }))
1398
+ }
1399
+
1400
+ /* ------------------------------------------------------------ 文案键常量(面板复用) */
1401
+
1402
+ /** 删除成功文案键(delete 的 `code:'deleted'`)。 */
1403
+ export const ROLLBACK_DELETED_KEY = 'rollback.code.deleted'
1404
+ /** 「没有可回退的内容」文案键(预览判定为否时的兜底原因)。 */
1405
+ export const ROLLBACK_PREVIEW_NONE_KEY = 'rollback.preview.none'