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,1314 @@
1
+ /**
2
+ * 回滚点台账**存储层**(issue #89)。
3
+ *
4
+ * ## 为什么要有这一层
5
+ * v2 台账是**单文件** index.json:每回合「读全文 -> JSON.parse -> 合并 -> JSON.stringify -> 原子写」。
6
+ * 本机实测该文件涨到 473 285 138 字节(50 条记录)后宿主反复 V8 heap OOM(exit 134)——
7
+ * 根因不是条数(50 条是上限)而是**字节**:ROLLBACK_MAX_RECORDS 只限条数、不限字节,
8
+ * 而每条记录的 workspace.payload.files 数组可达数万项(一次整份读入 = 数百 MB 常驻)。
9
+ *
10
+ * ## v3 布局(清单 + 单条记录 + 内容寻址载荷)
11
+ * rollback-points/
12
+ * index.json # **小清单**:{ schemaVersion: 3, records: LedgerManifestEntry[] }
13
+ * index.v2.backup.json # v2 单文件迁移成功后的改名保留(人要退回旧版仍有原文件)
14
+ * records/<id>.json # 单条完整记录(workspace.payload 换成一个载荷哈希引用)
15
+ * payloads/<sha256 of JSON>.json # 内容寻址的 RollbackWorkspacePayload —— 同一工作区态只落一份
16
+ * objects/<sha256> # 文件内容对象仓(口径不变)
17
+ * path-cache.json # 路径身份缓存(口径不变)
18
+ *
19
+ * 三条硬性质:
20
+ * 1. **清单里绝不含大数组** —— 每条只有元数据 + 段摘要(纯标量)⇒ 序列化字节数与文件条目数无关。
21
+ * 2. **按条解析** —— readRecord(id) 才去读那一条记录文件与它引用的载荷,「一个回合」的内存峰值
22
+ * ∝ 单份载荷,而**不是** ∝ 台账总字节。
23
+ * 3. **载荷去重** —— 内容寻址:同一工作区态只写一次,连续多个回合工作区未变时零额外字节。
24
+ *
25
+ * ## 不静默
26
+ * 载荷文件缺失 / 哈希不符 / 形状不符一律**如实失败**(返回 error,绝不拼半个记录出来);
27
+ * 调用方据此走既有的 record-incomplete / ledger-unreadable 路径。
28
+ *
29
+ * ## 为什么用注入的 summarize / isLedgerRecord
30
+ * 摘要口径(summarizeRollbackPoint)与记录校验(isRollbackPointRecord)是 rollback-point.ts 的
31
+ * **冻结单一事实源**;本模块只做存储。用注入而不是 import,是为了让依赖方向保持单向
32
+ * (rollback-point.ts -> rollback-ledger.ts),避免两个模块互相 import 形成运行期循环。
33
+ */
34
+ import fs from 'node:fs/promises'
35
+ import { createReadStream } from 'node:fs'
36
+ import path from 'node:path'
37
+ import { StringDecoder } from 'node:string_decoder'
38
+
39
+ import { atomicWriteFile } from '../utils/atomic-write.ts'
40
+ import { isRecord } from '../utils/guards.ts'
41
+ import { sha256Hex } from '../utils/hashing.ts'
42
+ import type {
43
+ RollbackCode,
44
+ RollbackPointKind,
45
+ RollbackPointRecord,
46
+ RollbackPointSummary,
47
+ RollbackSegmentSummary,
48
+ RollbackSessionPayload,
49
+ RollbackStateKind,
50
+ RollbackWorkspacePayload,
51
+ } from './rollback-point.ts'
52
+
53
+ /* ------------------------------------------------------------------ 常量 */
54
+
55
+ /** 清单格式版本(与**记录**的 schemaVersion 无关:后者是记录内容的版本,恒为 2) */
56
+ export const ROLLBACK_LEDGER_SCHEMA_VERSION = 3
57
+ /** 单条记录目录名 */
58
+ export const ROLLBACK_RECORDS_DIR_NAME = 'records'
59
+ /** 内容寻址载荷目录名 */
60
+ export const ROLLBACK_PAYLOADS_DIR_NAME = 'payloads'
61
+ /**
62
+ * 回退前像暂存目录名(`<root>/undo/<pid>-<ts36>-<8hex>/`)。
63
+ * 名字以 pid 开头是**清理判据的一部分**(见 cleanupOrphanTempFiles);改命名必须同步改 `UNDO_DIR_RE`。
64
+ */
65
+ export const ROLLBACK_UNDO_DIR_NAME = 'undo'
66
+ /** v2 单文件迁移成功后的保留名(改名,不是删除) */
67
+ export const ROLLBACK_LEDGER_BACKUP_NAME = 'index.v2.backup.json'
68
+ /**
69
+ * 台账(records/ + payloads/)缺省字节预算。
70
+ *
71
+ * 为什么需要它:ROLLBACK_MAX_RECORDS(50)只限**条数**,一条记录的工作区载荷可以到几十 MB
72
+ * (本机实测 50 条 = 473 MB,直接把宿主撑爆)。字节预算与条数预算**互相独立**,两者都生效。
73
+ */
74
+ export const ROLLBACK_DEFAULT_LEDGER_BYTES = 256 * 1024 * 1024
75
+
76
+ /** 迁移锁名(同一 dataDir 被多个引擎实例并发初始化时的互斥) */
77
+ const MIGRATION_LOCK_NAME = '.dshcm-ledger-migrating'
78
+ /** 迁移锁视为陈旧(持锁进程已崩)的阈值 */
79
+ const MIGRATION_LOCK_STALE_MS = 5 * 60 * 1000
80
+ /** 探测清单 schemaVersion 时只读文件头这么多字节(**绝不**为探测而整份读) */
81
+ const SCHEMA_SNIFF_BYTES = 8 * 1024
82
+ /** 迁移时单条记录允许的最大字符数(异常输入的保护;正常记录远小于它) */
83
+ const MIGRATE_MAX_ITEM_CHARS = 512 * 1024 * 1024
84
+ /** v2 头部里 records 数组起始 '[' 的搜索上限(超出 = 形状不符,如实失败) */
85
+ const MIGRATE_MAX_HEAD_CHARS = 64 * 1024
86
+
87
+ const OBJECT_KEY_RE = /^[0-9a-f]{64}$/
88
+ /** 记录文件名 = 记录 id;只允许可自证安全的字符(防篡改清单把路径引出去) */
89
+ export const LEDGER_RECORD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
90
+
91
+ /**
92
+ * atomic-write.ts 产生的临时文件名形态:
93
+ * .dshcm.<safeBase>.<pid>.<16 位 hex>.tmp(见该文件 tmpPath():TMP_PREFIX + base + '.' + pid + '.' + randomHex(8) + '.tmp')。
94
+ * **先读实现再写判据** —— 判不出来就一个都不删(宁可留垃圾也不删用户数据)。
95
+ */
96
+ const TEMP_FILE_RE = /^\.dshcm\.(.+)\.(\d+)\.([0-9a-f]{16})\.tmp$/
97
+ /** 前像暂存目录名:`<pid>-<ts36>-<8hex>`(pid 必须在最前,清理判据要读它) */
98
+ const UNDO_DIR_RE = /^(\d+)-[0-9a-z]+-[0-9a-f]{8}$/
99
+
100
+ /* --------------------------------------------------------------- 类型 */
101
+
102
+ /** 台账里一条记录的**纯标量**头(与 RollbackTrack 的可序列化部分逐字对应) */
103
+ export interface RollbackLedgerTrackHeader {
104
+ kind: RollbackStateKind
105
+ capturedAt: number
106
+ ok: boolean
107
+ degraded: boolean
108
+ fingerprint: string | null
109
+ reasonCode: string | null
110
+ reasonDetail: string | null
111
+ }
112
+
113
+ /**
114
+ * 清单条目:**绝不含大数组**。段摘要(segments)在写入时由注入的 summarize 算出,
115
+ * list() 直接回放 —— 口径只有一份,且清单大小与工作区文件数无关。
116
+ */
117
+ export interface RollbackLedgerManifestEntry {
118
+ id: string
119
+ kind: RollbackPointKind
120
+ createdAt: number
121
+ spreadMs: number
122
+ protected: boolean
123
+ incomplete: boolean
124
+ note: string | null
125
+ sessionId: string | null
126
+ unitId: string | null
127
+ turnSeq: number | null
128
+ boundarySeq: number | null
129
+ /** 该条记录文件的字节数(预算淘汰时用于估算可释放空间) */
130
+ recordBytes: number
131
+ workspace: RollbackLedgerTrackHeader & {
132
+ /** 内容寻址载荷哈希;null = 该态没有载荷(捕获失败) */
133
+ payload: string | null
134
+ /** 载荷文件字节数(**同一哈希只算一次**,预算与统计用) */
135
+ payloadBytes: number
136
+ }
137
+ session: RollbackLedgerTrackHeader & {
138
+ /** 会话字节副本的对象仓键;null = 没有副本(回收对象时用,不必读记录文件) */
139
+ object: string | null
140
+ }
141
+ /** 冻结口径的段摘要(= summarizeRollbackPoint(record).segments) */
142
+ segments: RollbackSegmentSummary[]
143
+ }
144
+
145
+ /** 清单条目 -> 列表投影(逐字等于旧的 summarizeRollbackPoint(record),因为它是写入时算好的) */
146
+ export function entrySummaryOf(entry: RollbackLedgerManifestEntry): RollbackPointSummary {
147
+ return {
148
+ id: entry.id,
149
+ createdAt: entry.createdAt,
150
+ sessionId: entry.sessionId,
151
+ unitId: entry.unitId,
152
+ turn: entry.turnSeq,
153
+ boundarySeq: entry.boundarySeq,
154
+ kind: entry.kind,
155
+ protected: entry.protected,
156
+ incomplete: entry.incomplete,
157
+ note: entry.note,
158
+ spreadMs: entry.spreadMs,
159
+ segments: entry.segments,
160
+ }
161
+ }
162
+
163
+ export interface RollbackLedgerMigration {
164
+ /** 是否真的改了磁盘布局(v2 -> v3,或从 records/ 重建清单) */
165
+ migrated: boolean
166
+ /** 成功落盘的记录条数 */
167
+ records: number
168
+ /** 形状不合格被跳过的条数(如实计数,绝不静默) */
169
+ skipped: number
170
+ /** 非空 = 迁移失败(原文件未动,下次启动重试) */
171
+ error?: string
172
+ }
173
+
174
+ export interface RollbackLedgerOptions {
175
+ /** 回滚点库根目录(= dataDir/rollback-points) */
176
+ root: string
177
+ /** 冻结口径的摘要(rollback-point.ts 的 summarizeRollbackPoint) */
178
+ summarize: (record: RollbackPointRecord) => RollbackPointSummary
179
+ /** 冻结口径的记录校验(rollback-point.ts 的 isRollbackPointRecord) */
180
+ isLedgerRecord: (value: unknown) => value is RollbackPointRecord
181
+ /** 字节预算;缺省 ROLLBACK_DEFAULT_LEDGER_BYTES */
182
+ bytesBudget?: number
183
+ now?: () => number
184
+ /** best-effort 日志(绝不抛给调用方) */
185
+ log?: (line: string) => void
186
+ /**
187
+ * 每次「读一条记录文件 / 一份载荷文件」前的观测钩子(生产不传)。
188
+ * 为什么要有它:内存护栏需要**确定性**证据 —— 「单次 append 绝不读别人的记录文件」
189
+ * 只能靠数读盘次数来钉,而不是靠不可复现的堆采样。
190
+ */
191
+ onFileRead?: (file: string) => void
192
+ /** 写 seam(缺省 = atomicWriteFile);测试用它注入「第 N 次写失败」以验证迁移中断可重试 */
193
+ writeFile?: (target: string, bytes: Uint8Array) => Promise<void>
194
+ }
195
+
196
+ /** 一次「准备写一条记录」的产物(先把清单条目算出来,预算淘汰才能在写之前决策) */
197
+ export interface RollbackLedgerPrepared {
198
+ entry: RollbackLedgerManifestEntry
199
+ /** 记录文件内容(workspace.payload 已换成哈希引用) */
200
+ recordJson: string
201
+ /** 载荷文件内容;null = 该态没有载荷 */
202
+ payloadJson: string | null
203
+ payloadHash: string | null
204
+ }
205
+
206
+ export interface RollbackLedgerStats {
207
+ /** records/ 下的文件数 */
208
+ records: number
209
+ /** records/ + payloads/ 的总字节 */
210
+ bytes: number
211
+ }
212
+
213
+ /* ----------------------------------------------------- 迁移:流式切片 */
214
+
215
+ /**
216
+ * 从 v2 单文件里**流式**切出 records 数组的每个顶层对象,逐个交给 onRecord。
217
+ *
218
+ * 为什么必须流式:451 MiB 的文件 readFile + 整份 JSON.parse 必然 OOM(这正是本 issue 的现场)。
219
+ * 状态机只在一个对象内累积文本(一个对象 = 一条记录),其余字节掠过即丢。
220
+ */
221
+ async function streamLedgerRecords(
222
+ file: string,
223
+ onRecord: (text: string) => Promise<void>,
224
+ ): Promise<{ count: number; skipped: number }> {
225
+ const stream = createReadStream(file)
226
+ const decoder = new StringDecoder('utf8')
227
+ let phase: 'head' | 'array' | 'done' = 'head'
228
+ let head = ''
229
+ let rest = ''
230
+ let depth = 0
231
+ let inString = false
232
+ let item: string[] = []
233
+ let itemChars = 0
234
+ let count = 0
235
+ let skipped = 0
236
+
237
+ const STRUCT_RE = /[{}[\]"]/g
238
+
239
+ /** 从头里找 'records' 键之后的数组起始 '['(v2 文件头部的一小段,有界) */
240
+ const tryFindArray = (): boolean => {
241
+ const key = head.indexOf('"records"')
242
+ if (key < 0) return false
243
+ const open = head.indexOf('[', key + 9)
244
+ if (open < 0) return false
245
+ rest = head.slice(open + 1)
246
+ head = ''
247
+ phase = 'array'
248
+ return true
249
+ }
250
+
251
+ for await (const raw of stream) {
252
+ const text = typeof raw === 'string' ? raw : decoder.write(raw as Buffer)
253
+ if (phase === 'head') {
254
+ head += text
255
+ if (!tryFindArray()) {
256
+ if (head.length > MIGRATE_MAX_HEAD_CHARS) {
257
+ // v2 文件一定在头部就有 records 键;找不到 = 形状不符(如实失败,不猜)
258
+ stream.destroy()
259
+ throw new Error('ledger records array not found in head')
260
+ }
261
+ continue
262
+ }
263
+ } else {
264
+ rest += text
265
+ }
266
+
267
+ let i = 0
268
+ while (i < rest.length) {
269
+ if (itemChars > MIGRATE_MAX_ITEM_CHARS) {
270
+ stream.destroy()
271
+ throw new Error('ledger record too large while migrating')
272
+ }
273
+ if (depth === 0 && !inString) {
274
+ // 数组顶层:两个对象之间的空白 / 逗号是**分隔符**,绝不能混进下一条记录的文本
275
+ // (真机踩过:pretty-print 的 v2 文件里 ,\n 会被拼到下一个 { 前面,导致 JSON.parse 失败并把记录当垃圾跳过)
276
+ if (itemChars > 0) { item = []; itemChars = 0 }
277
+ }
278
+ if (inString) {
279
+ // 字符串内:跳到结束引号(快路径 = indexOf + 反斜杠奇偶判据)
280
+ const end = findStringEnd(rest, i)
281
+ if (end < 0) {
282
+ item.push(rest.slice(i))
283
+ itemChars += rest.length - i
284
+ i = rest.length
285
+ break
286
+ }
287
+ item.push(rest.slice(i, end + 1))
288
+ itemChars += end + 1 - i
289
+ i = end + 1
290
+ inString = false
291
+ continue
292
+ }
293
+ STRUCT_RE.lastIndex = i
294
+ const m = STRUCT_RE.exec(rest)
295
+ if (m === null) {
296
+ if (depth > 0) { item.push(rest.slice(i)); itemChars += rest.length - i }
297
+ i = rest.length
298
+ break
299
+ }
300
+ const at = m.index
301
+ const ch = m[0]
302
+ // 顶层(depth === 0)的空白 / 逗号只是分隔符:push 进去会把下一条记录的 JSON 弄坏
303
+ if (at > i && depth > 0) {
304
+ item.push(rest.slice(i, at))
305
+ itemChars += at - i
306
+ }
307
+ i = at + 1
308
+ if (ch === '"') { inString = true; item.push('"'); continue }
309
+ if (ch === '{' || ch === '[') {
310
+ if (depth === 0 && ch !== '{') {
311
+ // records 数组里的顶层项必须是对象;不是就跳过并计数(形状不符,不猜)
312
+ skipped += 1
313
+ item = []
314
+ itemChars = 0
315
+ continue
316
+ }
317
+ depth += 1
318
+ item.push(ch)
319
+ continue
320
+ }
321
+ if (ch === '}' || ch === ']') {
322
+ if (depth === 0) {
323
+ if (ch === ']') { phase = 'done'; break }
324
+ skipped += 1
325
+ continue
326
+ }
327
+ depth -= 1
328
+ item.push(ch)
329
+ if (depth === 0) {
330
+ const text2 = item.join('')
331
+ item = []
332
+ itemChars = 0
333
+ await onRecord(text2)
334
+ count += 1
335
+ }
336
+ continue
337
+ }
338
+ }
339
+ if (phase === 'done') break
340
+ rest = rest.slice(i)
341
+ }
342
+ return { count, skipped }
343
+ }
344
+
345
+ /** 在 s 的 from 之后找**未被转义**的结束引号;找不到返回 -1 */
346
+ function findStringEnd(s: string, from: number): number {
347
+ let i = from
348
+ for (;;) {
349
+ const q = s.indexOf('"', i)
350
+ if (q < 0) return -1
351
+ let backslashes = 0
352
+ let k = q - 1
353
+ while (k >= 0 && s.charCodeAt(k) === 92) { backslashes += 1; k -= 1 }
354
+ if (backslashes % 2 === 0) return q
355
+ i = q + 1
356
+ }
357
+ }
358
+
359
+ /* --------------------------------------------------- 孤儿临时文件清理 */
360
+
361
+ export interface RollbackTempCleanupResult {
362
+ removed: number
363
+ bytes: number
364
+ /** 形似临时文件但**判据不足**(判不准就不删) */
365
+ skipped: number
366
+ }
367
+
368
+ /** 统计一棵目录树的字节数(只用于清理统计;读不到按 0 计,绝不因为统计失败而不删) */
369
+ async function dirBytes(dir: string): Promise<number> {
370
+ let total = 0
371
+ let entries
372
+ try {
373
+ entries = await fs.readdir(dir, { withFileTypes: true })
374
+ } catch {
375
+ return 0
376
+ }
377
+ for (const entry of entries) {
378
+ const full = path.join(dir, entry.name)
379
+ try {
380
+ if (entry.isDirectory()) total += await dirBytes(full)
381
+ else total += (await fs.stat(full)).size
382
+ } catch {
383
+ /* 读不到就不计 */
384
+ }
385
+ }
386
+ return total
387
+ }
388
+
389
+ function isProcessAlive(pid: number): boolean {
390
+ if (!Number.isInteger(pid) || pid <= 0) return false
391
+ try {
392
+ process.kill(pid, 0)
393
+ return true
394
+ } catch (error) {
395
+ // EPERM = 进程存在但没权限发信号(Windows 上很常见)⇒ 仍然算活着
396
+ return (error as { code?: unknown }).code === 'EPERM'
397
+ }
398
+ }
399
+
400
+ /**
401
+ * 删除 rollback-points/ 下**形状可自证**的崩溃残留原子写临时文件。
402
+ *
403
+ * 判据(三条全中才删,判不准一律不删):
404
+ * 1. 名字逐字符合 atomic-write.ts 的 tmpPath() 形态:.dshcm.<base>.<pid>.<16hex>.tmp;
405
+ * 2. 该 pid **不是本进程**(本进程的 tmp 可能正在写);
406
+ * 3. 该 pid 进程已不存在(崩溃残留)—— 仍然活着的进程可能正在写,绝不抢。
407
+ * 只扫根目录与它的一级子目录(records/ payloads/ objects/),不递归。
408
+ */
409
+ export async function cleanupOrphanTempFiles(root: string): Promise<RollbackTempCleanupResult> {
410
+ const out: RollbackTempCleanupResult = { removed: 0, bytes: 0, skipped: 0 }
411
+ let dirs: string[]
412
+ try {
413
+ const entries = await fs.readdir(root, { withFileTypes: true })
414
+ dirs = [root]
415
+ for (const entry of entries) {
416
+ if (entry.isDirectory()) dirs.push(path.join(root, entry.name))
417
+ }
418
+ } catch {
419
+ return out
420
+ }
421
+ for (const dir of dirs) {
422
+ let names: string[]
423
+ try {
424
+ names = await fs.readdir(dir)
425
+ } catch {
426
+ continue
427
+ }
428
+ for (const name of names) {
429
+ const m = TEMP_FILE_RE.exec(name)
430
+ if (m === null) continue
431
+ const base = m[1] ?? ''
432
+ const pid = Number(m[2])
433
+ if (base === '' || base.includes('/') || base.includes('\\')) { out.skipped += 1; continue }
434
+ if (pid === process.pid || isProcessAlive(pid)) { out.skipped += 1; continue }
435
+ const target = path.join(dir, name)
436
+ let bytes = 0
437
+ try {
438
+ const info = await fs.stat(target)
439
+ if (!info.isFile()) { out.skipped += 1; continue }
440
+ bytes = info.size
441
+ await fs.rm(target, { force: true })
442
+ } catch {
443
+ out.skipped += 1
444
+ continue
445
+ }
446
+ out.removed += 1
447
+ out.bytes += bytes
448
+ }
449
+ }
450
+ // 回退前像暂存目录(<root>/undo/<pid>-<ts>-<hex>/):**进程已死的残留整目录删除**。
451
+ // 判据与 tmp 文件同源:pid 必须不是本进程、且已不存在;判不准一律不碰(skipped)。
452
+ try {
453
+ const undoRoot = path.join(root, ROLLBACK_UNDO_DIR_NAME)
454
+ const names = await fs.readdir(undoRoot)
455
+ for (const name of names) {
456
+ const m = UNDO_DIR_RE.exec(name)
457
+ if (m === null) { out.skipped += 1; continue }
458
+ const pid = Number(m[1])
459
+ if (pid === process.pid || isProcessAlive(pid)) { out.skipped += 1; continue }
460
+ const target = path.join(undoRoot, name)
461
+ try {
462
+ const bytes = await dirBytes(target)
463
+ await fs.rm(target, { recursive: true, force: true })
464
+ out.removed += 1
465
+ out.bytes += bytes
466
+ } catch {
467
+ out.skipped += 1
468
+ }
469
+ }
470
+ } catch {
471
+ /* undo 目录不存在(正常状态) */
472
+ }
473
+ return out
474
+ }
475
+
476
+ /* ------------------------------------------------------------ 校验工具 */
477
+
478
+ function byteLength(text: string): number {
479
+ return Buffer.byteLength(text, 'utf8')
480
+ }
481
+
482
+ function asReasonCode(value: unknown): RollbackCode | null {
483
+ return typeof value === 'string' && value !== '' ? (value as RollbackCode) : null
484
+ }
485
+
486
+ function asNullableString(value: unknown): string | null {
487
+ return typeof value === 'string' ? value : null
488
+ }
489
+
490
+ function asNullableNumber(value: unknown): number | null {
491
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
492
+ }
493
+
494
+ function normalizeTrackHeader(value: unknown, kind: RollbackStateKind): RollbackLedgerTrackHeader | null {
495
+ if (!isRecord(value)) return null
496
+ if (value['kind'] !== kind) return null
497
+ const capturedAt = value['capturedAt']
498
+ if (typeof capturedAt !== 'number' || !Number.isFinite(capturedAt)) return null
499
+ if (typeof value['ok'] !== 'boolean') return null
500
+ return {
501
+ kind,
502
+ capturedAt,
503
+ ok: value['ok'],
504
+ degraded: value['degraded'] === true,
505
+ fingerprint: asNullableString(value['fingerprint']),
506
+ reasonCode: typeof value['reasonCode'] === 'string' ? value['reasonCode'] : null,
507
+ reasonDetail: asNullableString(value['reasonDetail']),
508
+ }
509
+ }
510
+
511
+ function normalizeSegment(value: unknown, state: RollbackStateKind): RollbackSegmentSummary | null {
512
+ if (!isRecord(value)) return null
513
+ if (value['state'] !== state) return null
514
+ const capturedAt = value['capturedAt']
515
+ const entries = value['entries']
516
+ const bytes = value['bytes']
517
+ const skippedCount = value['skippedCount']
518
+ if (typeof capturedAt !== 'number' || !Number.isFinite(capturedAt)) return null
519
+ if (typeof entries !== 'number' || !Number.isFinite(entries)) return null
520
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return null
521
+ if (typeof skippedCount !== 'number' || !Number.isFinite(skippedCount)) return null
522
+ return {
523
+ state,
524
+ ok: value['ok'] === true,
525
+ degraded: value['degraded'] === true,
526
+ capturedAt,
527
+ fingerprint: asNullableString(value['fingerprint']),
528
+ reasonCode: asReasonCode(value['reasonCode']),
529
+ entries,
530
+ bytes,
531
+ cursorBytes: asNullableNumber(value['cursorBytes']),
532
+ restorable: value['restorable'] === true,
533
+ skippedCount,
534
+ truncated: value['truncated'] === true,
535
+ }
536
+ }
537
+
538
+ /** 清单条目校验:形状不对就**丢掉并计数**(与 v2 的 records.filter(isRollbackPointRecord) 同一条路) */
539
+ export function normalizeManifestEntry(value: unknown): RollbackLedgerManifestEntry | null {
540
+ if (!isRecord(value)) return null
541
+ const id = value['id']
542
+ if (typeof id !== 'string' || !LEDGER_RECORD_ID_RE.test(id)) return null
543
+ const kind = value['kind']
544
+ if (kind !== 'auto' && kind !== 'guard') return null
545
+ const createdAt = value['createdAt']
546
+ if (typeof createdAt !== 'number' || !Number.isFinite(createdAt)) return null
547
+ const spreadMs = value['spreadMs']
548
+ if (typeof spreadMs !== 'number' || !Number.isFinite(spreadMs)) return null
549
+ const workspace = normalizeTrackHeader(value['workspace'], 'workspace')
550
+ const session = normalizeTrackHeader(value['session'], 'session')
551
+ if (workspace === null || session === null) return null
552
+ const rawSegments = value['segments']
553
+ if (!Array.isArray(rawSegments) || rawSegments.length !== 2) return null
554
+ const first = normalizeSegment(rawSegments[0], 'workspace')
555
+ const second = normalizeSegment(rawSegments[1], 'session')
556
+ if (first === null || second === null) return null
557
+ const workspaceRaw = value['workspace']
558
+ const sessionRaw = value['session']
559
+ const wsPayload = isRecord(workspaceRaw) ? workspaceRaw['payload'] : null
560
+ if (wsPayload !== null && (typeof wsPayload !== 'string' || !OBJECT_KEY_RE.test(wsPayload))) return null
561
+ const sessionObject = isRecord(sessionRaw) ? sessionRaw['object'] : null
562
+ if (sessionObject !== null && (typeof sessionObject !== 'string' || !OBJECT_KEY_RE.test(sessionObject))) return null
563
+ const rawRecordBytes = value['recordBytes']
564
+ const rawPayloadBytes = isRecord(workspaceRaw) ? workspaceRaw['payloadBytes'] : 0
565
+ return {
566
+ id,
567
+ kind,
568
+ createdAt,
569
+ spreadMs,
570
+ protected: value['protected'] === true,
571
+ incomplete: value['incomplete'] === true,
572
+ note: asNullableString(value['note']),
573
+ sessionId: asNullableString(value['sessionId']),
574
+ unitId: asNullableString(value['unitId']),
575
+ turnSeq: asNullableNumber(value['turnSeq']),
576
+ boundarySeq: asNullableNumber(value['boundarySeq']),
577
+ recordBytes: typeof rawRecordBytes === 'number' && Number.isFinite(rawRecordBytes) && rawRecordBytes >= 0 ? rawRecordBytes : 0,
578
+ workspace: {
579
+ ...workspace,
580
+ payload: typeof wsPayload === 'string' ? wsPayload : null,
581
+ payloadBytes: typeof rawPayloadBytes === 'number' && Number.isFinite(rawPayloadBytes) && rawPayloadBytes >= 0 ? rawPayloadBytes : 0,
582
+ },
583
+ session: { ...session, object: typeof sessionObject === 'string' ? sessionObject : null },
584
+ segments: [first, second],
585
+ }
586
+ }
587
+
588
+ /** 载荷形状校验:**严格**(形状不符 = readRecord 失败,绝不拼半个 payload) */
589
+ function normalizeWorkspacePayload(value: unknown): RollbackWorkspacePayload | null {
590
+ if (!isRecord(value)) return null
591
+ const arrays = ['chunks', 'files', 'skipped', 'excludedDirs', 'unreadableDirs', 'unreadableFiles', 'ignoredDirs']
592
+ for (const key of arrays) {
593
+ if (!Array.isArray(value[key])) return null
594
+ }
595
+ const numbers = ['skippedCount', 'ignoredDirCount', 'bytes']
596
+ for (const key of numbers) {
597
+ if (typeof value[key] !== 'number' || !Number.isFinite(value[key])) return null
598
+ }
599
+ if (typeof value['skippedTruncated'] !== 'boolean') return null
600
+ if (typeof value['truncated'] !== 'boolean') return null
601
+ return value as unknown as RollbackWorkspacePayload
602
+ }
603
+
604
+ function normalizeSessionPayload(value: unknown): RollbackSessionPayload | null {
605
+ if (!isRecord(value)) return null
606
+ if (value['sessionId'] !== null && typeof value['sessionId'] !== 'string') return null
607
+ if (typeof value['bytes'] !== 'number' || !Number.isFinite(value['bytes'])) return null
608
+ if (typeof value['fingerprint'] !== 'string') return null
609
+ return value as unknown as RollbackSessionPayload
610
+ }
611
+
612
+ /* ---------------------------------------------------------------- 存储层 */
613
+
614
+ export class RollbackLedger {
615
+ readonly root: string
616
+ readonly manifestPath: string
617
+ readonly backupPath: string
618
+ readonly recordsDir: string
619
+ readonly payloadsDir: string
620
+ readonly objectsDir: string
621
+ readonly bytesBudget: number
622
+ private readonly summarize: (record: RollbackPointRecord) => RollbackPointSummary
623
+ private readonly isLedgerRecord: (value: unknown) => value is RollbackPointRecord
624
+ private readonly nowFn: () => number
625
+ private readonly log: ((line: string) => void) | undefined
626
+ private readonly onFileRead: ((file: string) => void) | undefined
627
+ private readonly writeSeam: ((target: string, bytes: Uint8Array) => Promise<void>) | undefined
628
+ /**
629
+ * 被**字节预算**淘汰的自动点累计数(可观测;与「条数上限」淘汰分开计数)。
630
+ *
631
+ * 为什么必须**持久化**而不是只记在实例上:宿主每个请求都新建一个引擎(路由侧),
632
+ * 只记在实例上的话这个数字永远是 0 —— 界面就永远看不到「已经因为预算淘汰过 N 个」。
633
+ * 值随清单落盘(`evicted` 字段),读清单时取「磁盘值与内存值的大者」,
634
+ * 这样「先 markEvicted 再读清单」的时序也不会把刚加上的计数抹掉。
635
+ */
636
+ private evicted = 0
637
+
638
+ constructor(options: RollbackLedgerOptions) {
639
+ this.root = path.resolve(options.root)
640
+ this.manifestPath = path.join(this.root, 'index.json')
641
+ this.backupPath = path.join(this.root, ROLLBACK_LEDGER_BACKUP_NAME)
642
+ this.recordsDir = path.join(this.root, ROLLBACK_RECORDS_DIR_NAME)
643
+ this.payloadsDir = path.join(this.root, ROLLBACK_PAYLOADS_DIR_NAME)
644
+ this.objectsDir = path.join(this.root, 'objects')
645
+ this.summarize = options.summarize
646
+ this.isLedgerRecord = options.isLedgerRecord
647
+ this.bytesBudget = options.bytesBudget ?? ROLLBACK_DEFAULT_LEDGER_BYTES
648
+ this.nowFn = options.now ?? (() => Date.now())
649
+ this.log = options.log
650
+ this.onFileRead = options.onFileRead
651
+ this.writeSeam = options.writeFile
652
+ }
653
+
654
+ get evictedCount(): number {
655
+ return this.evicted
656
+ }
657
+
658
+ markEvicted(count: number): void {
659
+ if (count > 0) this.evicted += count
660
+ }
661
+
662
+ private warn(line: string): void {
663
+ try { this.log?.(line) } catch { /* 日志失败绝不影响主流程 */ }
664
+ }
665
+
666
+ private async write(target: string, bytes: Uint8Array): Promise<void> {
667
+ if (this.writeSeam !== undefined) {
668
+ await this.writeSeam(target, bytes)
669
+ return
670
+ }
671
+ await atomicWriteFile(target, bytes)
672
+ }
673
+
674
+ private async readText(file: string): Promise<string> {
675
+ this.onFileRead?.(file)
676
+ return await fs.readFile(file, 'utf8')
677
+ }
678
+
679
+ private async exists(target: string): Promise<boolean> {
680
+ try {
681
+ await fs.access(target)
682
+ return true
683
+ } catch {
684
+ return false
685
+ }
686
+ }
687
+
688
+ private recordPath(id: string): string | null {
689
+ if (!LEDGER_RECORD_ID_RE.test(id)) return null
690
+ const target = path.join(this.recordsDir, id + '.json')
691
+ if (!target.startsWith(this.recordsDir + path.sep)) return null
692
+ return target
693
+ }
694
+
695
+ private payloadPath(hash: string): string | null {
696
+ if (!OBJECT_KEY_RE.test(hash)) return null
697
+ return path.join(this.payloadsDir, hash + '.json')
698
+ }
699
+
700
+ /**
701
+ * 清单文件的**代际戳**(mtimeMs + 字节数)。跨进程乐观并发用:留点侧在读清单前后各取一次,
702
+ * 不一致说明另一个实例写过 ⇒ 重读重试,绝不拿自己的快照覆盖别人的清单。
703
+ * 读不到返回 null = **不参与判定**(可选探针失败绝不阻塞留点)。
704
+ */
705
+ async manifestStamp(): Promise<string | null> {
706
+ try {
707
+ const info = await fs.stat(this.manifestPath)
708
+ return String(info.mtimeMs) + ':' + String(info.size)
709
+ } catch {
710
+ return null
711
+ }
712
+ }
713
+
714
+ /** 这份载荷是否已经落过盘(内容寻址 ⇒ 已存在就是同一份,可零写入;也是「本次新增字节」的判据) */
715
+ async hasPayload(hash: string): Promise<boolean> {
716
+ const target = this.payloadPath(hash)
717
+ if (target === null) return false
718
+ return await this.exists(target)
719
+ }
720
+
721
+ /* ------------------------------------------------------------ 清单 */
722
+
723
+ async readManifest(): Promise<{ entries: RollbackLedgerManifestEntry[]; error?: string }> {
724
+ let raw: string
725
+ try {
726
+ raw = await this.readText(this.manifestPath)
727
+ } catch (error) {
728
+ const code = (error as { code?: unknown }).code
729
+ // 不存在 = 首次使用(不是错误);其它读失败如实回报
730
+ if (code === 'ENOENT') return { entries: [] }
731
+ return { entries: [], error: 'ledger unreadable: ' + errorDetail(error) }
732
+ }
733
+ let parsed: unknown
734
+ try {
735
+ parsed = JSON.parse(raw)
736
+ } catch (error) {
737
+ return { entries: [], error: 'ledger unreadable: ' + errorDetail(error) }
738
+ }
739
+ if (!isRecord(parsed)) return { entries: [], error: 'ledger shape invalid' }
740
+ if (parsed['schemaVersion'] !== ROLLBACK_LEDGER_SCHEMA_VERSION) {
741
+ // v2 单文件(迁移没跑成)或未来版本:绝不拿它当清单用(否则会把记录当垃圾丢掉)
742
+ return { entries: [], error: 'ledger unreadable: unsupported ledger schemaVersion ' + String(parsed['schemaVersion']) }
743
+ }
744
+ // 预算淘汰累计:取磁盘值与内存值的大者(markEvicted 后立刻读清单的时序不得回退计数)
745
+ const persistedEvicted = parsed['evicted']
746
+ if (typeof persistedEvicted === 'number' && Number.isFinite(persistedEvicted) && persistedEvicted > this.evicted) {
747
+ this.evicted = Math.floor(persistedEvicted)
748
+ }
749
+ const list = parsed['records']
750
+ if (!Array.isArray(list)) return { entries: [], error: 'ledger.records invalid' }
751
+ const entries: RollbackLedgerManifestEntry[] = []
752
+ let dropped = list.length
753
+ for (const value of list) {
754
+ const entry = normalizeManifestEntry(value)
755
+ if (entry === null) continue
756
+ entries.push(entry)
757
+ dropped -= 1
758
+ }
759
+ // 自证:清单里的每个 id 都必须在 records/ 里真的有一份(找不到就跳过并计数)
760
+ const missing = await this.findMissingRecordFiles(entries)
761
+ if (missing.size > 0) {
762
+ const errors: string[] = []
763
+ if (dropped > 0) errors.push('ledger dropped ' + String(dropped) + ' invalid record(s)')
764
+ errors.push('ledger missing ' + String(missing.size) + ' record file(s)')
765
+ return { entries: entries.filter((entry) => !missing.has(entry.id)), error: errors.join('; ') }
766
+ }
767
+ return dropped > 0
768
+ ? { entries, error: 'ledger dropped ' + String(dropped) + ' invalid record(s)' }
769
+ : { entries }
770
+ }
771
+
772
+ private async findMissingRecordFiles(entries: readonly RollbackLedgerManifestEntry[]): Promise<Set<string>> {
773
+ const missing = new Set<string>()
774
+ if (entries.length === 0) return missing
775
+ let names: string[]
776
+ try {
777
+ names = await fs.readdir(this.recordsDir)
778
+ } catch {
779
+ // 目录都没有 ⇒ 所有条目都缺文件(诚实计数)
780
+ for (const entry of entries) missing.add(entry.id)
781
+ return missing
782
+ }
783
+ const present = new Set<string>()
784
+ for (const name of names) {
785
+ if (name.endsWith('.json')) present.add(name.slice(0, -5))
786
+ }
787
+ for (const entry of entries) {
788
+ if (!present.has(entry.id)) missing.add(entry.id)
789
+ }
790
+ return missing
791
+ }
792
+
793
+ /** 原子写清单(小文件;条目由调用方决定) */
794
+ async writeManifest(entries: readonly RollbackLedgerManifestEntry[]): Promise<void> {
795
+ await fs.mkdir(this.root, { recursive: true })
796
+ const body = JSON.stringify({
797
+ schemaVersion: ROLLBACK_LEDGER_SCHEMA_VERSION,
798
+ records: entries,
799
+ // 预算淘汰累计(持久化:见 evicted 字段的说明 —— 每请求新实例的场景下必须靠它才看得见)
800
+ evicted: this.evicted,
801
+ }, null, 2)
802
+ await this.write(this.manifestPath, Buffer.from(body, 'utf8'))
803
+ }
804
+
805
+ /* -------------------------------------------------------- 单条记录 */
806
+
807
+ /** 把完整记录拆成「清单条目 + 记录文件内容 + 载荷文件内容」(**不做 IO**,纯函数) */
808
+ prepareRecord(record: RollbackPointRecord): RollbackLedgerPrepared {
809
+ const summary = this.summarize(record)
810
+ const workspaceTrack = record.workspace
811
+ const sessionTrack = record.session
812
+ const payload = workspaceTrack.payload
813
+ let payloadJson: string | null = null
814
+ let payloadHash: string | null = null
815
+ let payloadBytes = 0
816
+ if (payload !== null) {
817
+ payloadJson = JSON.stringify(payload)
818
+ payloadHash = sha256Hex(payloadJson)
819
+ payloadBytes = byteLength(payloadJson)
820
+ }
821
+ const recordJson = JSON.stringify({
822
+ ledgerSchemaVersion: ROLLBACK_LEDGER_SCHEMA_VERSION,
823
+ schemaVersion: record.schemaVersion,
824
+ id: record.id,
825
+ kind: record.kind,
826
+ createdAt: record.createdAt,
827
+ spreadMs: record.spreadMs,
828
+ protected: record.protected,
829
+ incomplete: record.incomplete,
830
+ note: record.note,
831
+ sessionId: record.sessionId,
832
+ unitId: record.unitId,
833
+ turnSeq: record.turnSeq,
834
+ boundarySeq: record.boundarySeq,
835
+ workspace: { ...trackHeader(workspaceTrack), payload: payloadHash },
836
+ session: { ...trackHeader(sessionTrack), payload: sessionTrack.payload },
837
+ })
838
+ const entry: RollbackLedgerManifestEntry = {
839
+ id: record.id,
840
+ kind: record.kind,
841
+ createdAt: record.createdAt,
842
+ spreadMs: record.spreadMs,
843
+ protected: record.protected,
844
+ incomplete: record.incomplete,
845
+ note: record.note,
846
+ sessionId: record.sessionId,
847
+ unitId: record.unitId,
848
+ turnSeq: record.turnSeq,
849
+ boundarySeq: record.boundarySeq,
850
+ recordBytes: byteLength(recordJson),
851
+ workspace: { ...trackHeader(workspaceTrack), payload: payloadHash, payloadBytes },
852
+ session: { ...trackHeader(sessionTrack), object: sessionTrack.payload?.object ?? null },
853
+ segments: summary.segments,
854
+ }
855
+ return { entry, recordJson, payloadJson, payloadHash }
856
+ }
857
+
858
+ /**
859
+ * 落盘:载荷(内容寻址;同一哈希已存在则**零写入**)+ 记录文件。
860
+ * 先载荷后记录:中途失败只留一份没人引用的载荷(垃圾,不是坏数据),下次 prune 回收。
861
+ */
862
+ async writePrepared(prepared: RollbackLedgerPrepared): Promise<void> {
863
+ if (prepared.payloadJson !== null && prepared.payloadHash !== null) {
864
+ const target = this.payloadPath(prepared.payloadHash)
865
+ if (target === null) throw new Error('ledger payload key invalid')
866
+ await fs.mkdir(this.payloadsDir, { recursive: true })
867
+ if (!(await this.exists(target))) {
868
+ await this.write(target, Buffer.from(prepared.payloadJson, 'utf8'))
869
+ }
870
+ }
871
+ const recordFile = this.recordPath(prepared.entry.id)
872
+ if (recordFile === null) throw new Error('ledger record id invalid')
873
+ await fs.mkdir(this.recordsDir, { recursive: true })
874
+ await this.write(recordFile, Buffer.from(prepared.recordJson, 'utf8'))
875
+ }
876
+
877
+ /** 读校验过的载荷(哈希不符 / 形状不符 -> error,绝不返回半个) */
878
+ private async readVerifiedPayload(hash: string): Promise<{ payload?: RollbackWorkspacePayload; error?: string }> {
879
+ const target = this.payloadPath(hash)
880
+ if (target === null) return { error: 'record-incomplete: payload ref invalid' }
881
+ let raw: string
882
+ try {
883
+ raw = await this.readText(target)
884
+ } catch (error) {
885
+ return { error: 'record-incomplete: payload unreadable (' + errorDetail(error) + ')' }
886
+ }
887
+ // 内容寻址 ⇒ 字节一变就对不上:这是「这份载荷是不是我以为的那份」的唯一自证
888
+ if (sha256Hex(raw) !== hash) return { error: 'record-incomplete: payload hash mismatch' }
889
+ let parsed: unknown
890
+ try {
891
+ parsed = JSON.parse(raw)
892
+ } catch (error) {
893
+ return { error: 'record-incomplete: payload unreadable (' + errorDetail(error) + ')' }
894
+ }
895
+ const payload = normalizeWorkspacePayload(parsed)
896
+ if (payload === null) return { error: 'record-incomplete: payload shape invalid' }
897
+ return { payload }
898
+ }
899
+
900
+ /** 读一条**完整**记录(引用解析对调用方透明);任何一步不成立就如实失败 */
901
+ async readRecord(id: string): Promise<{ record?: RollbackPointRecord; error?: string }> {
902
+ const target = this.recordPath(id)
903
+ if (target === null) return { error: 'record-incomplete: record id invalid' }
904
+ let raw: string
905
+ try {
906
+ raw = await this.readText(target)
907
+ } catch (error) {
908
+ return { error: 'record-incomplete: record file unreadable (' + errorDetail(error) + ')' }
909
+ }
910
+ let parsed: unknown
911
+ try {
912
+ parsed = JSON.parse(raw)
913
+ } catch (error) {
914
+ return { error: 'record-incomplete: record unreadable (' + errorDetail(error) + ')' }
915
+ }
916
+ if (!isRecord(parsed) || parsed['id'] !== id) return { error: 'record-incomplete: record shape invalid' }
917
+ const workspace = normalizeTrackHeader(parsed['workspace'], 'workspace')
918
+ const session = normalizeTrackHeader(parsed['session'], 'session')
919
+ if (workspace === null || session === null) return { error: 'record-incomplete: record track invalid' }
920
+ const payloadRef = isRecord(parsed['workspace']) ? parsed['workspace']['payload'] : null
921
+ let payload: RollbackWorkspacePayload | null = null
922
+ if (payloadRef !== null) {
923
+ if (typeof payloadRef !== 'string' || !OBJECT_KEY_RE.test(payloadRef)) {
924
+ return { error: 'record-incomplete: payload ref invalid' }
925
+ }
926
+ const resolved = await this.readVerifiedPayload(payloadRef)
927
+ if (resolved.payload === undefined) return { error: resolved.error ?? 'record-incomplete: payload unreadable' }
928
+ payload = resolved.payload
929
+ }
930
+ const sessionPayloadRaw = isRecord(parsed['session']) ? parsed['session']['payload'] : null
931
+ let sessionPayload: RollbackSessionPayload | null = null
932
+ if (sessionPayloadRaw !== null) {
933
+ sessionPayload = normalizeSessionPayload(sessionPayloadRaw)
934
+ if (sessionPayload === null) return { error: 'record-incomplete: session payload shape invalid' }
935
+ }
936
+ const record: RollbackPointRecord = {
937
+ schemaVersion: typeof parsed['schemaVersion'] === 'number' ? parsed['schemaVersion'] : 0,
938
+ id,
939
+ kind: parsed['kind'] === 'guard' ? 'guard' : 'auto',
940
+ createdAt: typeof parsed['createdAt'] === 'number' ? parsed['createdAt'] : 0,
941
+ spreadMs: typeof parsed['spreadMs'] === 'number' ? parsed['spreadMs'] : 0,
942
+ protected: parsed['protected'] === true,
943
+ incomplete: parsed['incomplete'] === true,
944
+ note: asNullableString(parsed['note']),
945
+ sessionId: asNullableString(parsed['sessionId']),
946
+ unitId: asNullableString(parsed['unitId']),
947
+ turnSeq: asNullableNumber(parsed['turnSeq']),
948
+ boundarySeq: asNullableNumber(parsed['boundarySeq']),
949
+ workspace: { ...workspace, reasonCode: asReasonCode(workspace.reasonCode), payload },
950
+ session: { ...session, reasonCode: asReasonCode(session.reasonCode), payload: sessionPayload },
951
+ }
952
+ if (!this.isLedgerRecord(record)) return { error: 'record-incomplete: record shape invalid' }
953
+ return { record }
954
+ }
955
+
956
+ /** 删记录文件(返回实际释放字节;ENOENT 不计),**不**动载荷与对象 */
957
+ async deleteRecords(ids: readonly string[]): Promise<{ removed: number; bytes: number }> {
958
+ let removed = 0
959
+ let bytes = 0
960
+ for (const id of ids) {
961
+ const target = this.recordPath(id)
962
+ if (target === null) continue
963
+ let size = 0
964
+ try {
965
+ const info = await fs.stat(target)
966
+ size = info.size
967
+ } catch {
968
+ continue
969
+ }
970
+ try {
971
+ await fs.rm(target, { force: true })
972
+ } catch (error) {
973
+ this.warn('rollback ledger record delete failed: ' + id + ' ' + errorDetail(error))
974
+ continue
975
+ }
976
+ removed += 1
977
+ bytes += size
978
+ }
979
+ return { removed, bytes }
980
+ }
981
+
982
+ /* ---------------------------------------------------------- 载荷回收 */
983
+
984
+ /** 没人引用的载荷文件一律删(引用计数靠扫清单得到,**不读载荷内容**) */
985
+ async prunePayloads(entries: readonly RollbackLedgerManifestEntry[]): Promise<{ removed: number; bytes: number }> {
986
+ const referenced = new Set<string>()
987
+ for (const entry of entries) {
988
+ if (entry.workspace.payload !== null) referenced.add(entry.workspace.payload)
989
+ }
990
+ let names: string[]
991
+ try {
992
+ names = await fs.readdir(this.payloadsDir)
993
+ } catch {
994
+ return { removed: 0, bytes: 0 }
995
+ }
996
+ let removed = 0
997
+ let bytes = 0
998
+ for (const name of names) {
999
+ if (!name.endsWith('.json')) continue
1000
+ const hash = name.slice(0, -5)
1001
+ if (!OBJECT_KEY_RE.test(hash) || referenced.has(hash)) continue
1002
+ const target = path.join(this.payloadsDir, name)
1003
+ try {
1004
+ const info = await fs.stat(target)
1005
+ if (!info.isFile()) continue
1006
+ await fs.rm(target, { force: true })
1007
+ removed += 1
1008
+ bytes += info.size
1009
+ } catch (error) {
1010
+ this.warn('rollback ledger payload delete failed: ' + name + ' ' + errorDetail(error))
1011
+ continue
1012
+ }
1013
+ }
1014
+ return { removed, bytes }
1015
+ }
1016
+
1017
+ /* ---------------------------------------------------------- 对象回收 */
1018
+
1019
+ /**
1020
+ * 内容寻址对象仓回收(**重写版,内存 ∝ 单份载荷 + 对象键集合**)。
1021
+ *
1022
+ * 旧实现要先在内存里拿到「所有记录的全部 files 数组」,于是回收一次就等于把整份台账读进内存
1023
+ * (issue #89 的 OOM 现场之一)。现在:扫清单 -> **逐个读引用到的载荷文件(一次只驻留一份)**
1024
+ * -> 累计 referenced 对象键 -> 扫 objects/ 删无引用项。
1025
+ *
1026
+ * 任何一份被引用的载荷读不出来(缺失 / 哈希不符 / 形状不符)⇒ **整体放弃回收**(aborted 非空):
1027
+ * 读不到的载荷里有什么对象谁也不知道,此时删对象可能删掉活数据 —— fail-closed。
1028
+ */
1029
+ async collectObjects(entries: readonly RollbackLedgerManifestEntry[]): Promise<{
1030
+ removed: number
1031
+ bytes: number
1032
+ referencedPaths: Set<string>
1033
+ aborted: string | null
1034
+ }> {
1035
+ const referenced = new Set<string>()
1036
+ const referencedPaths = new Set<string>()
1037
+ for (const entry of entries) {
1038
+ const hash = entry.workspace.payload
1039
+ if (hash !== null) {
1040
+ const resolved = await this.readVerifiedPayload(hash)
1041
+ if (resolved.payload === undefined) {
1042
+ return { removed: 0, bytes: 0, referencedPaths, aborted: resolved.error ?? 'payload unreadable' }
1043
+ }
1044
+ for (const file of resolved.payload.files) {
1045
+ if (typeof file?.object === 'string') referenced.add(file.object)
1046
+ if (typeof file?.path === 'string') referencedPaths.add(file.path)
1047
+ }
1048
+ const index = resolved.payload.pathIndex ?? null
1049
+ if (index !== null && index !== undefined && typeof index.object === 'string') referenced.add(index.object)
1050
+ }
1051
+ if (entry.session.object !== null) referenced.add(entry.session.object)
1052
+ }
1053
+ let names: string[]
1054
+ try {
1055
+ names = await fs.readdir(this.objectsDir)
1056
+ } catch {
1057
+ return { removed: 0, bytes: 0, referencedPaths, aborted: null }
1058
+ }
1059
+ let removed = 0
1060
+ let bytes = 0
1061
+ for (const name of names) {
1062
+ if (!OBJECT_KEY_RE.test(name) || referenced.has(name)) continue
1063
+ const target = path.join(this.objectsDir, name)
1064
+ try {
1065
+ const info = await fs.stat(target)
1066
+ if (!info.isFile()) continue
1067
+ await fs.rm(target, { force: true })
1068
+ removed += 1
1069
+ bytes += info.size
1070
+ } catch (error) {
1071
+ this.warn('rollback gc failed: ' + name + ' ' + errorDetail(error))
1072
+ }
1073
+ }
1074
+ return { removed, bytes, referencedPaths, aborted: null }
1075
+ }
1076
+
1077
+ /* ------------------------------------------------------------ 统计 */
1078
+
1079
+ /** records/ + payloads/ 的**实际**磁盘占用与文件数(诚实:含尚未回收的孤儿文件) */
1080
+ async stats(): Promise<RollbackLedgerStats> {
1081
+ let records = 0
1082
+ let bytes = 0
1083
+ const dirs: Array<{ dir: string; count: boolean }> = [
1084
+ { dir: this.recordsDir, count: true },
1085
+ { dir: this.payloadsDir, count: false },
1086
+ ]
1087
+ for (const item of dirs) {
1088
+ let names: string[]
1089
+ try {
1090
+ names = await fs.readdir(item.dir)
1091
+ } catch {
1092
+ continue
1093
+ }
1094
+ for (const name of names) {
1095
+ try {
1096
+ const info = await fs.stat(path.join(item.dir, name))
1097
+ if (!info.isFile()) continue
1098
+ bytes += info.size
1099
+ if (item.count) records += 1
1100
+ } catch { /* 竞态消失的文件:跳过 */ }
1101
+ }
1102
+ }
1103
+ return { records, bytes }
1104
+ }
1105
+
1106
+ /* ------------------------------------------------------------ 迁移 */
1107
+
1108
+ private async sniffSchemaVersion(): Promise<{ present: boolean; schemaVersion: number | null }> {
1109
+ let handle: Awaited<ReturnType<typeof fs.open>>
1110
+ try {
1111
+ handle = await fs.open(this.manifestPath, 'r')
1112
+ } catch {
1113
+ return { present: false, schemaVersion: null }
1114
+ }
1115
+ try {
1116
+ const buf = Buffer.alloc(SCHEMA_SNIFF_BYTES)
1117
+ const { bytesRead } = await handle.read(buf, 0, SCHEMA_SNIFF_BYTES, 0)
1118
+ const head = buf.subarray(0, bytesRead).toString('utf8')
1119
+ const m = /"schemaVersion"\s*:\s*(\d+)/.exec(head)
1120
+ return { present: true, schemaVersion: m === null ? null : Number(m[1]) }
1121
+ } catch {
1122
+ return { present: true, schemaVersion: null }
1123
+ } finally {
1124
+ await handle.close().catch(() => {})
1125
+ }
1126
+ }
1127
+
1128
+ private async acquireMigrationLock(): Promise<string | null> {
1129
+ const lockPath = path.join(this.root, MIGRATION_LOCK_NAME)
1130
+ await fs.mkdir(this.root, { recursive: true }).catch(() => {})
1131
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1132
+ try {
1133
+ const handle = await fs.open(lockPath, 'wx')
1134
+ await handle.close()
1135
+ return lockPath
1136
+ } catch (error) {
1137
+ if ((error as { code?: unknown }).code !== 'EEXIST') return null
1138
+ let stale = true
1139
+ try {
1140
+ const info = await fs.stat(lockPath)
1141
+ stale = this.nowFn() - info.mtimeMs > MIGRATION_LOCK_STALE_MS
1142
+ } catch { stale = true }
1143
+ if (!stale) return null
1144
+ await fs.rm(lockPath, { force: true }).catch(() => {})
1145
+ }
1146
+ }
1147
+ return null
1148
+ }
1149
+
1150
+ /**
1151
+ * v2 单文件 -> v3:**流式**切记录、逐条落盘,最后才动原文件。
1152
+ *
1153
+ * 幂等与崩溃安全:
1154
+ * · 步骤 1(流式落 records/ + payloads/)可重复执行 —— id 稳定,重复写是覆盖,不产生重复条目;
1155
+ * · 步骤 2 才把 v2 文件改名为 index.v2.backup.json(**成功之前原文件一动不动**);
1156
+ * · 若在步骤 2 与 3 之间崩溃(没有 index.json、有 index.v2.backup.json),下次启动走
1157
+ * rebuildManifest() 从 records/ 重建清单 —— 清单自证:每条 id 必须在 records/ 找得到。
1158
+ */
1159
+ /**
1160
+ * 清单**读不出来**时从 `records/` 重建,并返回重建后重新读到的清单。
1161
+ *
1162
+ * 为什么公开:留点侧(`appendRecord`)在 `readManifest` 报错时**必须**走这里 —— 旧点的最终真相
1163
+ * 在记录文件里,而拿一个空 entries 直接覆盖清单会把旧点整批抹掉、紧接着 `prunePayloads` 还会删掉
1164
+ * 它们的载荷(永久不可恢复)。重建后仍读不出来才由调用方拒写(fail-closed)。
1165
+ */
1166
+ async recoverManifest(): Promise<{ entries: RollbackLedgerManifestEntry[]; error?: string }> {
1167
+ const ids = await this.listRecordIds().catch(() => [] as string[])
1168
+ if (ids.length === 0) {
1169
+ // `records/` 里一条记录都没有 ⇒ 台账**本来就是空的**(或目录还没建):
1170
+ // 落一份空清单让留点继续 —— 这不是「覆盖」,因为没有东西可丢。
1171
+ // (rebuildManifest 在这种情形会刻意什么都不做,于是清单仍读不出来、留点被永久卡死。)
1172
+ try {
1173
+ await this.writeManifest([])
1174
+ } catch (error) {
1175
+ return { entries: [], error: 'ledger manifest reset failed: ' + errorDetail(error) }
1176
+ }
1177
+ return { entries: [] }
1178
+ }
1179
+ try {
1180
+ await this.rebuildManifest()
1181
+ } catch (error) {
1182
+ return { entries: [], error: 'ledger unreadable: rebuild failed (' + errorDetail(error) + ')' }
1183
+ }
1184
+ return await this.readManifest()
1185
+ }
1186
+
1187
+ async migrateIfNeeded(): Promise<RollbackLedgerMigration> {
1188
+ const sniff = await this.sniffSchemaVersion()
1189
+ if (!sniff.present) return await this.rebuildManifest()
1190
+ if (sniff.schemaVersion !== 2) return { migrated: false, records: 0, skipped: 0 }
1191
+ const lock = await this.acquireMigrationLock()
1192
+ if (lock === null) {
1193
+ // 另一个实例正在迁:如实说「进行中」,绝不假装读到了空台账
1194
+ return { migrated: false, records: 0, skipped: 0, error: 'ledger unreadable: migration in progress' }
1195
+ }
1196
+ try {
1197
+ const again = await this.sniffSchemaVersion()
1198
+ if (!again.present) return await this.rebuildManifest()
1199
+ if (again.schemaVersion !== 2) return { migrated: false, records: 0, skipped: 0 }
1200
+ return await this.migrateV2()
1201
+ } finally {
1202
+ await fs.rm(lock, { force: true }).catch(() => {})
1203
+ }
1204
+ }
1205
+
1206
+ private async migrateV2(): Promise<RollbackLedgerMigration> {
1207
+ let written = 0
1208
+ let skipped = 0
1209
+ try {
1210
+ const streamed = await streamLedgerRecords(this.manifestPath, async (text) => {
1211
+ let parsed: unknown
1212
+ try {
1213
+ parsed = JSON.parse(text)
1214
+ } catch {
1215
+ skipped += 1
1216
+ return
1217
+ }
1218
+ if (!this.isLedgerRecord(parsed)) {
1219
+ skipped += 1
1220
+ return
1221
+ }
1222
+ await this.writePrepared(this.prepareRecord(parsed))
1223
+ written += 1
1224
+ })
1225
+ skipped += streamed.skipped
1226
+ } catch (error) {
1227
+ // 原文件仍在:下次启动重试(已写的记录文件会被同 id 覆盖,不产生重复清单条目)
1228
+ return { migrated: false, records: written, skipped, error: 'ledger migration failed: ' + errorDetail(error) }
1229
+ }
1230
+ try {
1231
+ await fs.rename(this.manifestPath, this.backupPath)
1232
+ } catch (error) {
1233
+ return { migrated: false, records: written, skipped, error: 'ledger migration rename failed: ' + errorDetail(error) }
1234
+ }
1235
+ try {
1236
+ const built = await this.readEntriesFromRecords()
1237
+ await this.writeManifest(built.entries)
1238
+ } catch (error) {
1239
+ return { migrated: false, records: written, skipped, error: 'ledger migration manifest failed: ' + errorDetail(error) }
1240
+ }
1241
+ return { migrated: true, records: written, skipped }
1242
+ }
1243
+
1244
+ /** 从 records/ 重建清单(崩溃恢复;清单自证:找不到记录文件的 id 一律跳过并计数) */
1245
+ private async rebuildManifest(): Promise<RollbackLedgerMigration> {
1246
+ const ids = await this.listRecordIds()
1247
+ if (ids.length === 0) return { migrated: false, records: 0, skipped: 0 }
1248
+ const built = await this.readEntriesFromRecords()
1249
+ try {
1250
+ await this.writeManifest(built.entries)
1251
+ } catch (error) {
1252
+ return { migrated: false, records: built.entries.length, skipped: built.skipped, error: 'ledger manifest rebuild failed: ' + errorDetail(error) }
1253
+ }
1254
+ return { migrated: true, records: built.entries.length, skipped: built.skipped }
1255
+ }
1256
+
1257
+ private async listRecordIds(): Promise<string[]> {
1258
+ let names: string[]
1259
+ try {
1260
+ names = await fs.readdir(this.recordsDir)
1261
+ } catch {
1262
+ return []
1263
+ }
1264
+ const out: string[] = []
1265
+ for (const name of names) {
1266
+ if (!name.endsWith('.json')) continue
1267
+ const id = name.slice(0, -5)
1268
+ if (LEDGER_RECORD_ID_RE.test(id)) out.push(id)
1269
+ }
1270
+ return out.sort()
1271
+ }
1272
+
1273
+ private async readEntriesFromRecords(): Promise<{ entries: RollbackLedgerManifestEntry[]; skipped: number }> {
1274
+ const entries: RollbackLedgerManifestEntry[] = []
1275
+ let skipped = 0
1276
+ for (const id of await this.listRecordIds()) {
1277
+ const resolved = await this.readRecord(id)
1278
+ if (resolved.record === undefined) { skipped += 1; continue }
1279
+ entries.push(this.prepareRecord(resolved.record).entry)
1280
+ }
1281
+ entries.sort((a, b) => (a.createdAt > b.createdAt ? -1 : a.createdAt < b.createdAt ? 1 : 0))
1282
+ return { entries, skipped }
1283
+ }
1284
+ }
1285
+
1286
+ /* --------------------------------------------------------------- 工具 */
1287
+
1288
+ function trackHeader(track: {
1289
+ kind: RollbackStateKind
1290
+ capturedAt: number
1291
+ ok: boolean
1292
+ degraded: boolean
1293
+ fingerprint: string | null
1294
+ reasonCode: RollbackCode | null
1295
+ reasonDetail: string | null
1296
+ }): RollbackLedgerTrackHeader {
1297
+ return {
1298
+ kind: track.kind,
1299
+ capturedAt: track.capturedAt,
1300
+ ok: track.ok,
1301
+ degraded: track.degraded,
1302
+ fingerprint: track.fingerprint,
1303
+ reasonCode: track.reasonCode,
1304
+ reasonDetail: track.reasonDetail,
1305
+ }
1306
+ }
1307
+
1308
+ function errorDetail(error: unknown): string {
1309
+ if (error instanceof Error) {
1310
+ const code = (error as { code?: unknown }).code
1311
+ return typeof code === 'string' ? code + ': ' + error.message : error.message
1312
+ }
1313
+ return String(error)
1314
+ }