dsh-config-manager 0.1.71 → 0.1.72

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 (548) hide show
  1. package/README.md +111 -96
  2. package/README.zh-CN.md +207 -86
  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 +1 -1
  6. package/lib/adapters/index.js +1 -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 +18 -0
  10. package/lib/adapters/messages.js +50 -0
  11. package/lib/adapters/plugin-files.js +3 -2
  12. package/lib/adapters/plugins.js +4 -3
  13. package/lib/adapters/prompts.d.ts +1 -0
  14. package/lib/adapters/prompts.js +7 -1
  15. package/lib/adapters/self.d.ts +7 -1
  16. package/lib/adapters/self.js +1 -1
  17. package/lib/adapters/sessions.d.ts +1 -0
  18. package/lib/adapters/sessions.js +4 -2
  19. package/lib/adapters/ui.d.ts +2 -2
  20. package/lib/adapters/ui.js +25 -25
  21. package/lib/adapters/workspaces.d.ts +1 -0
  22. package/lib/adapters/workspaces.js +7 -3
  23. package/lib/cli/actions.d.ts +9 -1
  24. package/lib/cli/actions.js +5 -2
  25. package/lib/cli/help.js +23 -10
  26. package/lib/cli/index.d.ts +23 -0
  27. package/lib/cli/index.js +90 -32
  28. package/lib/cli/sessions-inspect.js +3 -1
  29. package/lib/cli/sessions-repair.js +25 -6
  30. package/lib/cli/web/page.d.ts +12 -0
  31. package/lib/cli/web/page.js +21 -12
  32. package/lib/cli/web/routes.js +2 -2
  33. package/lib/cli/web/server.js +4 -4
  34. package/lib/client.d.ts +1349 -490
  35. package/lib/client.js +43047 -36738
  36. package/lib/core/backup-plan.js +30 -17
  37. package/lib/core/backup-verify.js +24 -23
  38. package/lib/core/backup.d.ts +5 -0
  39. package/lib/core/backup.js +51 -25
  40. package/lib/core/boot-rescue.js +4 -3
  41. package/lib/core/boot-safety.js +2 -1
  42. package/lib/core/command-rollback.d.ts +178 -0
  43. package/lib/core/command-rollback.js +289 -0
  44. package/lib/core/consult-source.js +7 -6
  45. package/lib/core/disk-usage.d.ts +74 -10
  46. package/lib/core/disk-usage.js +67 -4
  47. package/lib/core/engine-locale.d.ts +87 -0
  48. package/lib/core/engine-locale.js +125 -0
  49. package/lib/core/exporter.js +3 -3
  50. package/lib/core/fork-reconcile.d.ts +128 -0
  51. package/lib/core/fork-reconcile.js +150 -0
  52. package/lib/core/journal.js +6 -5
  53. package/lib/core/local-plugin-host.js +3 -2
  54. package/lib/core/local-plugin-pack.js +11 -6
  55. package/lib/core/messages-core.d.ts +292 -0
  56. package/lib/core/messages-core.js +615 -0
  57. package/lib/core/messages.d.ts +60 -2
  58. package/lib/core/messages.js +116 -13
  59. package/lib/core/migration-consult.js +21 -20
  60. package/lib/core/migration-history.js +19 -5
  61. package/lib/core/model-tools.d.ts +0 -22
  62. package/lib/core/model-tools.js +50 -27
  63. package/lib/core/phase3-host.js +30 -5
  64. package/lib/core/plugin-cli.js +18 -19
  65. package/lib/core/reconcile.d.ts +2 -14
  66. package/lib/core/reconcile.js +87 -17
  67. package/lib/core/recovery-orchestrator.js +19 -18
  68. package/lib/core/reinstall.d.ts +4 -0
  69. package/lib/core/reinstall.js +26 -21
  70. package/lib/core/restore.js +20 -17
  71. package/lib/core/rollback-ignore-rules.d.ts +31 -0
  72. package/lib/core/rollback-ignore-rules.js +140 -0
  73. package/lib/core/rollback-point.d.ts +1208 -0
  74. package/lib/core/rollback-point.js +2993 -0
  75. package/lib/core/run-registry.js +6 -5
  76. package/lib/core/session-health.d.ts +15 -0
  77. package/lib/core/session-meta.d.ts +42 -2
  78. package/lib/core/session-meta.js +140 -1
  79. package/lib/core/session-title.d.ts +28 -0
  80. package/lib/core/session-title.js +185 -0
  81. package/lib/core/startup-barrier.d.ts +24 -0
  82. package/lib/core/startup-barrier.js +22 -0
  83. package/lib/core/transaction-coordinator.js +4 -3
  84. package/lib/core/types.d.ts +18 -1
  85. package/lib/core/validator.js +5 -4
  86. package/lib/core/verify-recovery.js +31 -30
  87. package/lib/foreign/bundle.js +3 -2
  88. package/lib/foreign/continue.js +2 -1
  89. package/lib/foreign/crush.js +2 -1
  90. package/lib/foreign/gemini.js +2 -1
  91. package/lib/foreign/goose.js +2 -1
  92. package/lib/foreign/kilocode.js +2 -1
  93. package/lib/foreign/kimi.js +2 -1
  94. package/lib/foreign/messages.d.ts +15 -0
  95. package/lib/foreign/messages.js +40 -0
  96. package/lib/foreign/mimocode.js +2 -1
  97. package/lib/foreign/openclaw.js +2 -1
  98. package/lib/foreign/opencode.js +2 -1
  99. package/lib/foreign/pi.js +2 -1
  100. package/lib/foreign/qoder.js +2 -1
  101. package/lib/foreign/qwen.js +2 -1
  102. package/lib/foreign/registry.js +7 -6
  103. package/lib/foreign/teleagent.js +2 -1
  104. package/lib/foreign/workbuddy.js +2 -1
  105. package/lib/foreign/zcode.js +2 -1
  106. package/lib/foreign/zed.js +2 -1
  107. package/lib/index.d.ts +223 -7
  108. package/lib/index.js +729 -54
  109. package/lib/market/git-file-writer.js +20 -19
  110. package/lib/market/github-repos.js +35 -19
  111. package/lib/market/index-parser.js +31 -30
  112. package/lib/market/messages-market.d.ts +138 -0
  113. package/lib/market/messages-market.js +305 -0
  114. package/lib/market/my-repo.d.ts +2 -2
  115. package/lib/market/my-repo.js +32 -29
  116. package/lib/market/prepare.js +18 -15
  117. package/lib/market/security.js +20 -19
  118. package/lib/market/types.js +2 -1
  119. package/lib/market/url.js +2 -1
  120. package/lib/migrations/index.js +6 -5
  121. package/lib/migrations/messages-migrations.d.ts +32 -0
  122. package/lib/migrations/messages-migrations.js +58 -0
  123. package/lib/profiles/dsh-profile-launcher.js +2 -1
  124. package/lib/profiles/dsh-profile-runtime.js +2 -1
  125. package/lib/profiles/messages.d.ts +7 -0
  126. package/lib/profiles/messages.js +27 -0
  127. package/lib/routes/backup.js +18 -2
  128. package/lib/routes/import.js +3 -3
  129. package/lib/routes/index.d.ts +1 -1
  130. package/lib/routes/kit.d.ts +3 -2
  131. package/lib/routes/kit.js +6 -1
  132. package/lib/routes/market.js +1 -1
  133. package/lib/routes/messages.d.ts +42 -0
  134. package/lib/routes/messages.js +100 -0
  135. package/lib/routes/profiles.js +12 -6
  136. package/lib/routes/recovery.js +378 -114
  137. package/lib/routes/snapshots.js +51 -4
  138. package/lib/routes/sync.js +68 -16
  139. package/lib/schema/config.d.ts +11 -1
  140. package/lib/schema/config.js +52 -20
  141. package/lib/schema/index.d.ts +8 -6
  142. package/lib/schema/index.js +8 -6
  143. package/lib/schema/manifest.js +19 -18
  144. package/lib/schema/messages.d.ts +47 -0
  145. package/lib/schema/messages.js +110 -0
  146. package/lib/schema/section-registry.d.ts +41 -14
  147. package/lib/schema/section-registry.js +35 -29
  148. package/lib/schema/versions.js +6 -5
  149. package/lib/security/encryption.js +16 -15
  150. package/lib/security/integrity.js +8 -7
  151. package/lib/security/messages.d.ts +30 -0
  152. package/lib/security/messages.js +77 -0
  153. package/lib/security/redaction.js +12 -0
  154. package/lib/security/secret-scanner.js +16 -13
  155. package/lib/security/vault.js +2 -1
  156. package/lib/security/zip-security.js +4 -3
  157. package/lib/sync/ancestor.js +2 -1
  158. package/lib/sync/autosync-config.d.ts +11 -0
  159. package/lib/sync/autosync-config.js +12 -0
  160. package/lib/sync/autosync-scheduler.d.ts +11 -1
  161. package/lib/sync/autosync-scheduler.js +115 -76
  162. package/lib/sync/backup-files.js +3 -2
  163. package/lib/sync/backup-scheduler.js +6 -5
  164. package/lib/sync/blob-store.js +2 -1
  165. package/lib/sync/git/git-transport.d.ts +55 -0
  166. package/lib/sync/git/git-transport.js +152 -2
  167. package/lib/sync/github-auth.js +10 -9
  168. package/lib/sync/layout.js +13 -12
  169. package/lib/sync/messages.d.ts +63 -0
  170. package/lib/sync/messages.js +149 -0
  171. package/lib/sync/review-queue.js +6 -5
  172. package/lib/sync/s3/messages.d.ts +7 -0
  173. package/lib/sync/s3/messages.js +16 -0
  174. package/lib/sync/s3/s3-providers.d.ts +4 -1
  175. package/lib/sync/s3/s3-providers.js +5 -5
  176. package/lib/sync/s3/s3-transport.js +1 -1
  177. package/lib/sync/s3/sigv4.js +2 -1
  178. package/lib/sync/snapshot-crypto.js +6 -5
  179. package/lib/sync/snapshot-json.js +3 -2
  180. package/lib/sync/sync-engine.d.ts +37 -15
  181. package/lib/sync/sync-engine.js +67 -37
  182. package/lib/sync/sync-history.js +7 -6
  183. package/lib/sync/sync-selection.d.ts +15 -4
  184. package/lib/sync/sync-selection.js +17 -5
  185. package/lib/sync/webdav/webdav-transport.d.ts +1 -0
  186. package/lib/sync/webdav/webdav-transport.js +4 -1
  187. package/lib/ui/artifact-view.js +4 -3
  188. package/lib/ui/commands.d.ts +1 -1
  189. package/lib/ui/commands.js +10 -0
  190. package/lib/ui/disk-usage-view.d.ts +33 -4
  191. package/lib/ui/disk-usage-view.js +29 -1
  192. package/lib/ui/errors.d.ts +1 -1
  193. package/lib/ui/errors.js +4 -3
  194. package/lib/ui/export-flow.js +24 -20
  195. package/lib/ui/foreign-view.d.ts +5 -1
  196. package/lib/ui/foreign-view.js +26 -2
  197. package/lib/ui/i18n.d.ts +65 -0
  198. package/lib/ui/i18n.js +114 -0
  199. package/lib/ui/import-wizard.d.ts +7 -0
  200. package/lib/ui/import-wizard.js +13 -10
  201. package/lib/ui/progress.d.ts +2 -2
  202. package/lib/ui/progress.js +1 -2
  203. package/lib/ui/report.d.ts +5 -5
  204. package/lib/ui/report.js +6 -6
  205. package/lib/ui/rollback-view.d.ts +756 -0
  206. package/lib/ui/rollback-view.js +724 -0
  207. package/lib/ui/selection-model.d.ts +41 -0
  208. package/lib/ui/selection-model.js +30 -0
  209. package/lib/ui/session-inventory-view.d.ts +97 -3
  210. package/lib/ui/session-inventory-view.js +76 -2
  211. package/lib/ui/session-issue-labels.d.ts +43 -0
  212. package/lib/ui/session-issue-labels.js +48 -0
  213. package/lib/ui/sync-settings-view.d.ts +33 -0
  214. package/lib/ui/sync-settings-view.js +40 -0
  215. package/lib/ui/types.d.ts +8 -3
  216. package/lib/ui/types.js +1 -1
  217. package/lib/utils/atomic-write.js +6 -5
  218. package/lib/utils/bundle-scan.d.ts +11 -1
  219. package/lib/utils/bundle-scan.js +12 -1
  220. package/lib/utils/env-lock.d.ts +10 -0
  221. package/lib/utils/env-lock.js +73 -46
  222. package/lib/utils/json.js +6 -5
  223. package/lib/utils/messages-utils.d.ts +96 -0
  224. package/lib/utils/messages-utils.js +216 -0
  225. package/lib/utils/proxy.js +18 -5
  226. package/lib/utils/recursive-walk.d.ts +22 -0
  227. package/lib/utils/recursive-walk.js +75 -1
  228. package/lib/utils/session-log-repair.js +10 -9
  229. package/lib/utils/zip.js +27 -26
  230. package/package.json +12 -2
  231. package/src/adapters/agent-instructions.ts +1 -1
  232. package/src/adapters/file-collection.test.ts +1 -0
  233. package/src/adapters/file-collection.ts +1 -1
  234. package/src/adapters/files.test.ts +27 -0
  235. package/src/adapters/index.ts +1 -1
  236. package/src/adapters/link-report.test.ts +22 -2
  237. package/src/adapters/link-report.ts +29 -4
  238. package/src/adapters/messages.ts +54 -0
  239. package/src/adapters/plugin-files.ts +3 -2
  240. package/src/adapters/plugins.ts +4 -3
  241. package/src/adapters/prompts.ts +7 -1
  242. package/src/adapters/self.test.ts +26 -0
  243. package/src/adapters/self.ts +8 -2
  244. package/src/adapters/sessions.ts +4 -2
  245. package/src/adapters/settings.test.ts +38 -1
  246. package/src/adapters/ui.test.ts +2 -2
  247. package/src/adapters/ui.ts +27 -26
  248. package/src/adapters/workspaces.ts +7 -3
  249. package/src/cli/actions.test.ts +28 -0
  250. package/src/cli/actions.ts +11 -2
  251. package/src/cli/help.ts +23 -10
  252. package/src/cli/index-guards.test.ts +193 -0
  253. package/src/cli/index.ts +106 -31
  254. package/src/cli/sessions-inspect.test.ts +15 -1
  255. package/src/cli/sessions-inspect.ts +3 -1
  256. package/src/cli/sessions-repair.test.ts +84 -0
  257. package/src/cli/sessions-repair.ts +33 -7
  258. package/src/cli/web/console-route-parity.test.ts +115 -0
  259. package/src/cli/web/page.ts +23 -12
  260. package/src/cli/web/routes.ts +2 -1
  261. package/src/cli/web/server.ts +4 -4
  262. package/src/cli/web/web.test.ts +2 -2
  263. package/src/client/ConfigManagerSection.tsx +30 -2
  264. package/src/client/about/AboutPanel.tsx +2 -2
  265. package/src/client/about/PluginUpdateDialog.tsx +1 -1
  266. package/src/client/about/ReleaseNotesDialog.tsx +2 -2
  267. package/src/client/about/about-view.test.ts +13 -3
  268. package/src/client/about/about-view.ts +10 -6
  269. package/src/client/about/release-notes-view.test.ts +14 -4
  270. package/src/client/about/release-notes-view.ts +4 -2
  271. package/src/client/api.ts +18 -6
  272. package/src/client/chat/ChatEntryView.tsx +391 -0
  273. package/src/client/chat/RollbackDiffTab.tsx +61 -0
  274. package/src/client/chat/RollbackFileIcon.tsx +96 -0
  275. package/src/client/chat/RollbackReviewDialog.tsx +591 -0
  276. package/src/client/chat/chat-entry-view.test.ts +1174 -0
  277. package/src/client/chat/chat-entry-view.ts +758 -0
  278. package/src/client/chat/chat-locales.ts +152 -0
  279. package/src/client/chat/chat-node-registration.ts +64 -0
  280. package/src/client/chat/chat-point-window.ts +173 -0
  281. package/src/client/chat/chat-slots.ts +107 -0
  282. package/src/client/chat/rollback-diff-tab.test.ts +105 -0
  283. package/src/client/chat/rollback-diff-tab.ts +164 -0
  284. package/src/client/chat/rollback-entry-view.test.ts +1469 -0
  285. package/src/client/chat/rollback-entry-view.ts +941 -0
  286. package/src/client/chat/sidebar-slots.ts +37 -0
  287. package/src/client/common/ConfirmDialog.tsx +7 -3
  288. package/src/client/common/ContentPicker.tsx +48 -10
  289. package/src/client/common/DiffTable.tsx +97 -0
  290. package/src/client/common/ErrorBanner.tsx +4 -4
  291. package/src/client/common/Icon.tsx +2 -0
  292. package/src/client/common/Modal.tsx +8 -1
  293. package/src/client/common/ProgressBar.tsx +8 -2
  294. package/src/client/common/ReportView.tsx +4 -4
  295. package/src/client/common/RunsCenter.tsx +1 -0
  296. package/src/client/common/client-route-parity.test.ts +78 -0
  297. package/src/client/common/info-hint-guard.test.ts +46 -13
  298. package/src/client/common/picker-lazy-units.test.ts +42 -0
  299. package/src/client/common/plan-text-redaction.test.ts +17 -6
  300. package/src/client/common/progress-view-edge.test.ts +8 -7
  301. package/src/client/common/progress-view.test.ts +9 -9
  302. package/src/client/common/progress-view.ts +2 -2
  303. package/src/client/common/routes.ts +28 -0
  304. package/src/client/common/ui.tsx +73 -10
  305. package/src/client/config-manager.module.css +996 -21
  306. package/src/client/css-scale-guard.test.ts +30 -0
  307. package/src/client/environment/EnvironmentPanel.tsx +11 -4
  308. package/src/client/environment/maintenance/DiskUsageCard.tsx +23 -2
  309. package/src/client/export/ExportView.tsx +13 -8
  310. package/src/client/history/HistoryPanel.tsx +4 -3
  311. package/src/client/history/history-api.ts +2 -2
  312. package/src/client/history/history-locales.ts +5 -1
  313. package/src/client/home/HomePanel.tsx +29 -8
  314. package/src/client/import/ForeignImportView.tsx +4 -5
  315. package/src/client/import/ImportWizardView.tsx +5 -3
  316. package/src/client/import/import-wizard-split.test.ts +3 -1
  317. package/src/client/import/import-wizard-steps.tsx +9 -9
  318. package/src/client/index.ts +186 -3
  319. package/src/client/library/LibraryActions.tsx +32 -7
  320. package/src/client/library/LibraryPanel.tsx +23 -3
  321. package/src/client/library/RestorePlanView.tsx +9 -66
  322. package/src/client/locales.ts +54 -5
  323. package/src/client/lucide-icons.d.ts +1 -0
  324. package/src/client/market/MarketPanel.tsx +13 -2
  325. package/src/client/market/MyConfigsView.tsx +5 -4
  326. package/src/client/market/MyConfigsWizard.tsx +10 -8
  327. package/src/client/market/market-api.test.ts +9 -8
  328. package/src/client/market/market-api.ts +2 -2
  329. package/src/client/market/market-locales.ts +3 -0
  330. package/src/client/market/market-view.test.ts +6 -6
  331. package/src/client/market/market-view.ts +3 -3
  332. package/src/client/market/my-configs-api.ts +2 -2
  333. package/src/client/market/my-configs-view.test.ts +23 -22
  334. package/src/client/market/my-configs-view.ts +8 -8
  335. package/src/client/recovery/IncompleteCopiesSection.tsx +2 -1
  336. package/src/client/recovery/RecoveryPanel.tsx +442 -283
  337. package/src/client/recovery/RollbackCard.tsx +665 -0
  338. package/src/client/recovery/RollbackDiffPane.tsx +58 -0
  339. package/src/client/recovery/RollbackFileChanges.tsx +163 -0
  340. package/src/client/recovery/incident-api.ts +2 -2
  341. package/src/client/recovery/recovery-api.ts +116 -6
  342. package/src/client/recovery/recovery-locales.test.ts +299 -0
  343. package/src/client/recovery/recovery-locales.ts +488 -149
  344. package/src/client/recovery/recovery-panel-layout.test.ts +132 -2
  345. package/src/client/recovery/rollback-file-changes.test.ts +73 -0
  346. package/src/client/recovery/session-dialog-mount.test.ts +54 -0
  347. package/src/client/run-store.ts +84 -21
  348. package/src/client/sync/AutosyncCard.tsx +25 -1
  349. package/src/client/sync/ChannelClearConfirmDialog.tsx +6 -3
  350. package/src/client/sync/ChannelConfigDialog.tsx +10 -10
  351. package/src/client/sync/DecryptPasswordCard.tsx +2 -2
  352. package/src/client/sync/SecurityOptionsCard.tsx +27 -17
  353. package/src/client/sync/SyncChannelEntryCard.tsx +14 -2
  354. package/src/client/sync/SyncConfirmView.tsx +46 -8
  355. package/src/client/sync/SyncLogList.tsx +1 -1
  356. package/src/client/sync/SyncPanel.tsx +31 -23
  357. package/src/client/sync/SyncRepositoryPicker.tsx +3 -5
  358. package/src/client/sync/SyncSectionPickerDialog.tsx +11 -2
  359. package/src/client/sync/SyncSettingsView.test.ts +20 -1
  360. package/src/client/sync/UnreadableSnapshotsBanner.tsx +1 -1
  361. package/src/client/sync/WorkDirCard.tsx +112 -0
  362. package/src/client/sync/history-model.test.ts +42 -9
  363. package/src/client/sync/history-model.ts +30 -15
  364. package/src/client/sync/sync-api.test.ts +57 -33
  365. package/src/client/sync/sync-api.ts +80 -6
  366. package/src/client/sync/sync-locales.ts +104 -3
  367. package/src/client/sync/sync-view-v2.test.ts +65 -13
  368. package/src/client/sync/sync-view.test.ts +107 -52
  369. package/src/client/sync/sync-view.ts +124 -41
  370. package/src/core/backup-plan.test.ts +28 -0
  371. package/src/core/backup-plan.ts +29 -17
  372. package/src/core/backup-verify.ts +24 -23
  373. package/src/core/backup.test.ts +76 -0
  374. package/src/core/backup.ts +55 -25
  375. package/src/core/boot-rescue.ts +4 -3
  376. package/src/core/boot-safety.ts +2 -1
  377. package/src/core/command-rollback.test.ts +536 -0
  378. package/src/core/command-rollback.ts +402 -0
  379. package/src/core/consult-source.ts +7 -6
  380. package/src/core/disk-usage.test.ts +257 -1
  381. package/src/core/disk-usage.ts +134 -14
  382. package/src/core/engine-locale.test.ts +593 -0
  383. package/src/core/engine-locale.ts +175 -0
  384. package/src/core/exporter.ts +3 -3
  385. package/src/core/fork-reconcile.test.ts +279 -0
  386. package/src/core/fork-reconcile.ts +234 -0
  387. package/src/core/journal.ts +6 -5
  388. package/src/core/local-plugin-host.ts +3 -2
  389. package/src/core/local-plugin-pack.ts +11 -6
  390. package/src/core/messages-core.ts +657 -0
  391. package/src/core/messages.ts +123 -17
  392. package/src/core/migration-consult.ts +21 -20
  393. package/src/core/migration-history.ts +19 -5
  394. package/src/core/model-tools.ts +28 -27
  395. package/src/core/phase3-host.ts +21 -5
  396. package/src/core/phase3-p2b-fingerprint.test.ts +22 -0
  397. package/src/core/plugin-cli.ts +18 -19
  398. package/src/core/reconcile.test.ts +117 -0
  399. package/src/core/reconcile.ts +85 -17
  400. package/src/core/recovery-orchestrator.ts +19 -18
  401. package/src/core/reinstall.ts +26 -21
  402. package/src/core/restore.ts +20 -17
  403. package/src/core/rollback-file-diff.test.ts +159 -0
  404. package/src/core/rollback-ignore-rules.ts +126 -0
  405. package/src/core/rollback-point.test.ts +2603 -0
  406. package/src/core/rollback-point.ts +3755 -0
  407. package/src/core/run-registry.ts +6 -5
  408. package/src/core/session-health.ts +15 -0
  409. package/src/core/session-meta.test.ts +117 -1
  410. package/src/core/session-meta.ts +149 -2
  411. package/src/core/session-title.test.ts +127 -0
  412. package/src/core/session-title.ts +189 -0
  413. package/src/core/startup-barrier.test.ts +69 -0
  414. package/src/core/startup-barrier.ts +27 -0
  415. package/src/core/transaction-coordinator.ts +4 -3
  416. package/src/core/types.ts +18 -1
  417. package/src/core/validator.ts +5 -4
  418. package/src/core/verify-recovery.ts +31 -30
  419. package/src/foreign/bundle.ts +3 -2
  420. package/src/foreign/continue.ts +2 -1
  421. package/src/foreign/crush.ts +2 -1
  422. package/src/foreign/file-budget.test.ts +2 -0
  423. package/src/foreign/gemini.ts +2 -1
  424. package/src/foreign/goose.ts +2 -1
  425. package/src/foreign/kilocode.ts +2 -1
  426. package/src/foreign/kimi.ts +2 -1
  427. package/src/foreign/messages.ts +44 -0
  428. package/src/foreign/mimocode.ts +2 -1
  429. package/src/foreign/openclaw.ts +2 -1
  430. package/src/foreign/opencode.ts +2 -1
  431. package/src/foreign/pi.ts +2 -1
  432. package/src/foreign/qoder.ts +2 -1
  433. package/src/foreign/qwen.ts +2 -1
  434. package/src/foreign/registry.ts +7 -6
  435. package/src/foreign/teleagent.ts +2 -1
  436. package/src/foreign/workbuddy.ts +2 -1
  437. package/src/foreign/zcode.ts +2 -1
  438. package/src/foreign/zed.ts +2 -1
  439. package/src/index.facade.test.ts +340 -1
  440. package/src/index.ts +813 -57
  441. package/src/market/git-file-writer.ts +20 -19
  442. package/src/market/github-repos.test.ts +23 -0
  443. package/src/market/github-repos.ts +37 -19
  444. package/src/market/index-parser.ts +31 -30
  445. package/src/market/messages-market.ts +325 -0
  446. package/src/market/my-repo.ts +32 -29
  447. package/src/market/prepare.ts +18 -15
  448. package/src/market/security.ts +20 -19
  449. package/src/market/types.ts +2 -1
  450. package/src/market/url.ts +2 -1
  451. package/src/migrations/index.ts +6 -5
  452. package/src/migrations/messages-migrations.ts +59 -0
  453. package/src/profiles/dsh-profile-launcher.ts +2 -1
  454. package/src/profiles/dsh-profile-runtime.ts +2 -1
  455. package/src/profiles/messages.ts +31 -0
  456. package/src/routes/backup.ts +18 -2
  457. package/src/routes/import.ts +3 -3
  458. package/src/routes/index.ts +1 -1
  459. package/src/routes/kit.ts +9 -3
  460. package/src/routes/market.ts +1 -1
  461. package/src/routes/messages.ts +104 -0
  462. package/src/routes/profiles.ts +12 -6
  463. package/src/routes/recovery.test.ts +405 -1
  464. package/src/routes/recovery.ts +377 -109
  465. package/src/routes/snapshots.ts +51 -4
  466. package/src/routes/sync.ts +67 -16
  467. package/src/schema/config.ts +53 -20
  468. package/src/schema/index.ts +7 -5
  469. package/src/schema/manifest.ts +19 -18
  470. package/src/schema/messages.ts +120 -0
  471. package/src/schema/registry.test.ts +19 -5
  472. package/src/schema/section-registry.ts +64 -39
  473. package/src/schema/versions.ts +6 -5
  474. package/src/security/encryption.ts +16 -15
  475. package/src/security/integrity.ts +8 -7
  476. package/src/security/messages.ts +87 -0
  477. package/src/security/redaction.test.ts +17 -1
  478. package/src/security/redaction.ts +13 -0
  479. package/src/security/secret-scanner.ts +19 -12
  480. package/src/security/security.test.ts +47 -0
  481. package/src/security/vault.ts +2 -1
  482. package/src/security/zip-security.ts +4 -3
  483. package/src/sync/ancestor.ts +2 -1
  484. package/src/sync/autosync-config.ts +21 -0
  485. package/src/sync/autosync-scheduler.test.ts +63 -7
  486. package/src/sync/autosync-scheduler.ts +118 -78
  487. package/src/sync/backup-files.ts +3 -2
  488. package/src/sync/backup-scheduler.ts +6 -5
  489. package/src/sync/blob-store.ts +2 -1
  490. package/src/sync/git/git-transport.test.ts +170 -8
  491. package/src/sync/git/git-transport.ts +176 -2
  492. package/src/sync/github-auth.ts +10 -9
  493. package/src/sync/layout.ts +13 -12
  494. package/src/sync/messages.ts +153 -0
  495. package/src/sync/review-queue.ts +6 -5
  496. package/src/sync/s3/messages.ts +16 -0
  497. package/src/sync/s3/s3-providers.ts +9 -6
  498. package/src/sync/s3/s3-transport.ts +1 -1
  499. package/src/sync/s3/sigv4.ts +3 -1
  500. package/src/sync/snapshot-crypto.ts +6 -5
  501. package/src/sync/snapshot-json.ts +3 -2
  502. package/src/sync/sync-engine-download-tombstone.test.ts +1 -1
  503. package/src/sync/sync-engine.test.ts +226 -8
  504. package/src/sync/sync-engine.ts +103 -41
  505. package/src/sync/sync-history.ts +7 -6
  506. package/src/sync/sync-selection.test.ts +12 -2
  507. package/src/sync/sync-selection.ts +18 -5
  508. package/src/sync/webdav/webdav-transport.ts +4 -1
  509. package/src/ui/artifact-view.ts +4 -3
  510. package/src/ui/commands.test.ts +1 -0
  511. package/src/ui/commands.ts +11 -1
  512. package/src/ui/disk-usage-view.test.ts +113 -6
  513. package/src/ui/disk-usage-view.ts +63 -7
  514. package/src/ui/errors.ts +4 -3
  515. package/src/ui/export-flow.ts +24 -20
  516. package/src/ui/foreign-view.test.ts +43 -0
  517. package/src/ui/foreign-view.ts +30 -3
  518. package/src/ui/i18n.ts +125 -0
  519. package/src/ui/import-wizard.ts +18 -10
  520. package/src/ui/progress.ts +2 -2
  521. package/src/ui/report.ts +7 -6
  522. package/src/ui/rollback-view.test.ts +819 -0
  523. package/src/ui/rollback-view.ts +1339 -0
  524. package/src/ui/selection-model.test.ts +33 -1
  525. package/src/ui/selection-model.ts +53 -0
  526. package/src/ui/session-inventory-view.test.ts +109 -0
  527. package/src/ui/session-inventory-view.ts +138 -3
  528. package/src/ui/session-issue-labels.test.ts +51 -0
  529. package/src/ui/session-issue-labels.ts +70 -0
  530. package/src/ui/sync-settings-view.test.ts +36 -0
  531. package/src/ui/sync-settings-view.ts +49 -0
  532. package/src/ui/types.ts +10 -4
  533. package/src/utils/atomic-write.ts +6 -5
  534. package/src/utils/bundle-scan.test.ts +2 -2
  535. package/src/utils/bundle-scan.ts +12 -1
  536. package/src/utils/env-lock.ts +83 -49
  537. package/src/utils/json.ts +7 -5
  538. package/src/utils/messages-utils.ts +232 -0
  539. package/src/utils/proxy.test.ts +25 -0
  540. package/src/utils/proxy.ts +19 -5
  541. package/src/utils/recursive-walk.test.ts +43 -0
  542. package/src/utils/recursive-walk.ts +75 -1
  543. package/src/utils/session-log-repair.ts +10 -9
  544. package/src/utils/zip.ts +27 -26
  545. package/lib/core/checkpoint.d.ts +0 -362
  546. package/lib/core/checkpoint.js +0 -1309
  547. package/src/core/checkpoint.test.ts +0 -665
  548. package/src/core/checkpoint.ts +0 -1614
@@ -0,0 +1,2603 @@
1
+ /**
2
+ * 工作区 + 会话回滚点引擎单测(t1)。
3
+ *
4
+ * 纪律(与本仓既有 core 测试同款):**真实临时目录**(os.tmpdir + fs.mkdtemp)+ 真实读写,
5
+ * 不 mock 文件系统 —— 本模块的正确性几乎全在「路径门 / 原子写 / 指纹 / 绝不删除 / 整体回滚」上,
6
+ * 只有真盘能证明。fork 与写回是宿主注入的 seam,测试用真 seam 替身。
7
+ *
8
+ * 覆盖(对应验收条件):
9
+ * - 两态捕获(workspace + session)且**没有 config 段**(R-01)
10
+ * - 按回合自动留点:turn/start + turn/end → 一个点,边界 = startSeq-1(R-02)
11
+ * - fork 边界 = 目标回合前的稳定边界;首回合无前置边界 → 结构化 no-boundary(R-03)
12
+ * - 每会话 20 / 全局 50 淘汰,只删最旧的非保护点(R-04)
13
+ * - 只覆盖记录在案的路径且绝不删除未记录文件(R-05)
14
+ * - 恢复前自动救援点(guard)(R-06)
15
+ * - 恢复后逐文件校验失败即整体回滚到恢复前状态(R-07)
16
+ * - 安全门:fail-closed 确认(R-08)、保护点(R-09)、两态不全(R-10)、
17
+ * 记录不可成为写原语(R-11)
18
+ * - 会话回退 = fork 子会话、原日志只读(R-12)
19
+ * - 预览零写入(R-13)、mode 三选一(R-14)、存储降级(R-15)、
20
+ * 台账损坏不崩(R-16)、删除 + 对象回收(R-17)、
21
+ * 轮询回退(R-18)、分块不可用(R-19)
22
+ * - E1:nothing-to-restore 是「成功、0 变更」(R-26 / R-27)
23
+ * 与预览逐文件行级统计(R-28 / R-29)
24
+ */
25
+ import test from 'node:test'
26
+ import assert from 'node:assert/strict'
27
+ import fs from 'node:fs/promises'
28
+ import os from 'node:os'
29
+ import path from 'node:path'
30
+
31
+ import {
32
+ ROLLBACK_ANCESTOR_MAX_DEPTH,
33
+ ROLLBACK_DEFAULT_MAX_BYTES,
34
+ ROLLBACK_DEFAULT_MAX_FILES,
35
+ ROLLBACK_LEFTOVER_LIST_LIMIT,
36
+ ROLLBACK_MAX_GUARDS,
37
+ ROLLBACK_MAX_GUARDS_PER_SESSION,
38
+ ROLLBACK_MAX_RECORDS,
39
+ ROLLBACK_MAX_RECORDS_PER_SESSION,
40
+ ROLLBACK_POINTS_DIR_NAME,
41
+ RollbackAutoTracker,
42
+ RollbackPointEngine,
43
+ classifyPreviewStatFailure,
44
+ isInsidePath,
45
+ resolveTurnBoundary,
46
+ rollbackSessionBindingOf,
47
+ rollbackTrackedEventOf,
48
+ summarizeRollbackPoint,
49
+ turnBoundariesOf,
50
+ type RollbackEngineOptions,
51
+ type RollbackForkInput,
52
+ type RollbackPointRecord,
53
+ } from './rollback-point.ts'
54
+ import { projectKeyOf } from './session-select.ts'
55
+ import { atomicWriteFile } from '../utils/atomic-write.ts'
56
+ import { sha256Hex } from '../utils/hashing.ts'
57
+ import { encodeZstdFrame } from '../utils/zstd-frame.ts'
58
+
59
+ /* --------------------------------------------------------------- fixture */
60
+
61
+ interface Fixture {
62
+ root: string
63
+ homeDir: string
64
+ dataDir: string
65
+ sessionsDir: string
66
+ unitDir: string
67
+ logFile: string
68
+ workspaceDir: string
69
+ cleanup: () => Promise<void>
70
+ }
71
+
72
+ async function makeFixture(): Promise<Fixture> {
73
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-rollback-point-'))
74
+ const homeDir = path.join(root, 'home')
75
+ const dataDir = path.join(homeDir, 'dsh-config-manager')
76
+ const sessionsDir = path.join(homeDir, 'sessions')
77
+ const unitDir = path.join(sessionsDir, 'proj-key', 'session-1111')
78
+ const workspaceDir = path.join(root, 'ws')
79
+ const logFile = path.join(unitDir, 'session-1111.jsonl.zstd')
80
+ await fs.mkdir(unitDir, { recursive: true })
81
+ await fs.mkdir(path.join(workspaceDir, 'src'), { recursive: true })
82
+ await fs.writeFile(logFile, 'frame-one')
83
+ await fs.writeFile(path.join(workspaceDir, 'a.txt'), 'A1')
84
+ await fs.writeFile(path.join(workspaceDir, 'src', 'b.txt'), 'B1')
85
+ return {
86
+ root, homeDir, dataDir, sessionsDir, unitDir, logFile, workspaceDir,
87
+ cleanup: async () => { await fs.rm(root, { recursive: true, force: true }) },
88
+ }
89
+ }
90
+
91
+ function makeEngine(fx: Fixture, overrides: Partial<RollbackEngineOptions> = {}): RollbackPointEngine {
92
+ return new RollbackPointEngine({ dataDir: fx.dataDir, homeDir: fx.homeDir, sessionQuiescentMs: 0, ...overrides })
93
+ }
94
+
95
+ /** S1 形态的点:工作区快照(ws 目录)+ 会话游标(单元内日志)。 */
96
+ async function capturePoint(engine: RollbackPointEngine, fx: Fixture, extra: Record<string, unknown> = {}) {
97
+ return await engine.capturePoint({
98
+ chunks: [fx.workspaceDir],
99
+ sessionId: 'session-1111',
100
+ unitId: 'proj-key/session-1111',
101
+ sessionLogPath: fx.logFile,
102
+ cursorSeq: 3,
103
+ turnSeq: 2,
104
+ boundarySeq: 1,
105
+ boundaryEndSeq: 6,
106
+ ...extra,
107
+ })
108
+ }
109
+
110
+ async function readText(file: string): Promise<string> {
111
+ return await fs.readFile(file, 'utf8')
112
+ }
113
+
114
+ async function objectExists(engine: RollbackPointEngine, sha: string): Promise<boolean> {
115
+ try {
116
+ await fs.access(path.join(engine.storeDir, 'objects', sha))
117
+ return true
118
+ } catch {
119
+ return false
120
+ }
121
+ }
122
+
123
+ function steppingClock(start: number, step: number): () => number {
124
+ let value = start - step
125
+ return () => { value += step; return value }
126
+ }
127
+
128
+ async function ledgerOf(engine: RollbackPointEngine): Promise<{ records: RollbackPointRecord[] }> {
129
+ return JSON.parse(await readText(path.join(engine.storeDir, 'index.json'))) as { records: RollbackPointRecord[] }
130
+ }
131
+
132
+ /* =================================================== R-01 两态捕获 / 无 config */
133
+
134
+ test('R-01 两态捕获:workspace + session 都有载荷与指纹,且**没有 config 段**', async (t) => {
135
+ const fx = await makeFixture()
136
+ t.after(fx.cleanup)
137
+ const engine = makeEngine(fx, { now: steppingClock(1_000_000, 10) })
138
+
139
+ const res = await capturePoint(engine, fx)
140
+ assert.equal(res.ok, true, res.detail ?? res.code)
141
+ const record = res.point
142
+ assert.ok(record !== undefined)
143
+ assert.equal(res.point?.kind, 'auto')
144
+
145
+ // 两态都捕上、都有指纹
146
+ for (const track of [record.workspace, record.session]) {
147
+ assert.equal(track.ok, true, track.kind)
148
+ assert.equal(typeof track.fingerprint, 'string', track.kind)
149
+ assert.ok((track.fingerprint ?? '').length >= 8, track.kind)
150
+ assert.ok(track.capturedAt > 0, track.kind)
151
+ }
152
+ // 顺序:会话先于工作区(捕获顺序稳定)
153
+ assert.ok(record.session.capturedAt <= record.workspace.capturedAt)
154
+
155
+ // 工作区:两个文件进仓,指纹 = 内容 SHA-256
156
+ const ws = record.workspace.payload
157
+ assert.ok(ws !== null)
158
+ assert.equal(ws.files.length, 2)
159
+ const aFile = ws.files.find((f) => f.path.endsWith('a.txt'))
160
+ assert.ok(aFile !== undefined)
161
+ assert.equal(aFile.sha256, sha256Hex('A1'))
162
+ assert.equal(aFile.chunk, path.resolve(fx.workspaceDir))
163
+ assert.equal(await objectExists(engine, aFile.object), true)
164
+
165
+ // 会话:游标 + fork 边界(回合开始前最后一个事件)
166
+ const session = record.session.payload
167
+ assert.ok(session !== null)
168
+ assert.equal(session.sessionId, 'session-1111')
169
+ assert.equal(session.logPath, fx.logFile)
170
+ assert.equal(session.cursorBytes, 9)
171
+ assert.equal(session.fingerprint, sha256Hex('frame-one'))
172
+ assert.equal(session.cursorSeq, 3)
173
+ assert.equal(session.turnSeq, 2)
174
+ assert.equal(session.boundarySeq, 1)
175
+ assert.equal(session.boundaryEndSeq, 6)
176
+ assert.equal(record.sessionId, 'session-1111')
177
+ assert.equal(record.turnSeq, 2)
178
+ assert.equal(record.boundarySeq, 1)
179
+
180
+ // **没有第三态**:记录 / 摘要里都不许出现 config
181
+ assert.equal(Object.prototype.hasOwnProperty.call(record, 'config'), false, '记录不得有 config 段')
182
+ const summary = summarizeRollbackPoint(record)
183
+ assert.deepEqual(summary.segments.map((s) => s.state), ['workspace', 'session'])
184
+ assert.equal(JSON.stringify(summary).includes('"config"'), false, '摘要不得有 config 段')
185
+ assert.equal(summary.segments[0]?.entries, 2)
186
+ assert.equal(summary.segments[1]?.cursorBytes, 9)
187
+ assert.equal(summary.segments[1]?.restorable, true)
188
+ assert.equal(JSON.stringify(summary).includes('A1'), false, '摘要不得泄漏载荷')
189
+
190
+ // 列表:新→旧
191
+ const list = await engine.list()
192
+ assert.equal(list.ok, true)
193
+ assert.equal(list.points.length, 1)
194
+ assert.equal(list.maxRecords, ROLLBACK_MAX_RECORDS)
195
+ assert.equal(list.maxRecordsPerSession, ROLLBACK_MAX_RECORDS_PER_SESSION)
196
+ assert.equal(list.maxGuards, ROLLBACK_MAX_GUARDS)
197
+ // 库目录名 = rollback-points
198
+ assert.equal(path.basename(engine.storeDir), ROLLBACK_POINTS_DIR_NAME)
199
+
200
+ // 详情
201
+ const detail = await engine.read(record.id)
202
+ assert.equal(detail.point?.id, record.id)
203
+ assert.equal((await engine.read('rb-nope')).point, undefined)
204
+ })
205
+
206
+ /* ============================== R-02b 排队发送的输入不得被切进子会话(用户 2026-10-11 反馈) */
207
+
208
+ test('R-02b 边界跳过「待消费输入」:上一回合结束后排队发的消息属于下一回合,边界落在它之前', async (t) => {
209
+ const fx = await makeFixture()
210
+ t.after(fx.cleanup)
211
+ const engine = makeEngine(fx, { now: steppingClock(3_000_000, 10) })
212
+ const tracker = new RollbackAutoTracker({ engine })
213
+ tracker.registerSession({
214
+ sessionId: 's-queue',
215
+ unitId: 'proj-key/s-queue',
216
+ logPath: fx.logFile,
217
+ chunks: [fx.workspaceDir],
218
+ })
219
+
220
+ // 回合 1:turn/start(0) … turn/end(5)
221
+ await tracker.noteEvent({ sessionId: 's-queue', seq: 0, type: 'turn/start', data: { turn: 1 } })
222
+ await tracker.noteEvent({ sessionId: 's-queue', seq: 5, type: 'turn/end', data: { turn: 1 } })
223
+ // 回合 1 还在跑时用户排队发了一条:先落 user/message(append,6) + inbox 拼接(7),**再**开回合 2
224
+ assert.equal((await tracker.noteEvent({ sessionId: 's-queue', seq: 6, type: 'user/message', surfaceOp: 'append', data: {} })).captured, false)
225
+ await tracker.noteEvent({ sessionId: 's-queue', seq: 7, type: 'agent/inbox/spliced', data: {} })
226
+ await tracker.noteEvent({ sessionId: 's-queue', seq: 8, type: 'turn/start', data: { turn: 2 } })
227
+ await tracker.noteEvent({ sessionId: 's-queue', seq: 12, type: 'turn/end', data: { turn: 2 } })
228
+
229
+ const byTurn = new Map((await engine.list()).points.map((p) => [p.turn, p]))
230
+ assert.equal(
231
+ byTurn.get(2)?.boundarySeq,
232
+ 5,
233
+ '边界必须是待消费输入的前一条(上一回合的 turn/end=5);取 startSeq-1=7 会把排队那条消息切进子会话',
234
+ )
235
+ })
236
+
237
+ /* =================================================== R-02 按回合自动留点 */
238
+
239
+ test('R-02 按回合自动留点:turn/end 才留点,边界 = turn/start 的 seq − 1,重复事件幂等', async (t) => {
240
+ const fx = await makeFixture()
241
+ t.after(fx.cleanup)
242
+ const engine = makeEngine(fx, { now: steppingClock(2_000_000, 10) })
243
+ const tracker = new RollbackAutoTracker({ engine })
244
+ tracker.registerSession({
245
+ sessionId: 'session-1111',
246
+ unitId: 'proj-key/session-1111',
247
+ logPath: fx.logFile,
248
+ chunks: [fx.workspaceDir],
249
+ })
250
+ assert.equal(tracker.mechanismKind, 'event')
251
+
252
+ // 回合 1 从 seq 0 开始(首回合 → 无前置边界);回合 2 从 seq 2 开始(边界 = 1)
253
+ assert.equal((await tracker.noteEvent({ sessionId: 'session-1111', seq: 0, type: 'turn/start', data: { turn: 1 } })).captured, true)
254
+ assert.equal((await tracker.noteEvent({ sessionId: 'session-1111', seq: 1, type: 'turn/end', data: { turn: 1 } })).captured, false)
255
+ assert.equal((await tracker.noteEvent({ sessionId: 'session-1111', seq: 2, type: 'turn/start', data: { turn: 2 } })).captured, true)
256
+ // 回合中间的事件不触发留点
257
+ assert.equal((await tracker.noteEvent({ sessionId: 'session-1111', seq: 3, type: 'user/message', data: {} })).captured, false)
258
+ assert.equal((await tracker.noteEvent({ sessionId: 'session-1111', seq: 6, type: 'turn/end', data: { turn: 2 } })).captured, false, '回合结束不再拍点')
259
+ // 幂等:同一回合的重复 turn/start 不再留点
260
+ assert.equal((await tracker.noteEvent({ sessionId: 'session-1111', seq: 2, type: 'turn/start', data: { turn: 2 } })).captured, false)
261
+
262
+ const list = await engine.list()
263
+ assert.equal(list.points.length, 2, '两个回合 → 两个点(重复事件不重复留点)')
264
+ const byTurn = new Map(list.points.map((p) => [p.turn, p]))
265
+ assert.equal(byTurn.get(1)?.boundarySeq, null, '首回合无前置边界')
266
+ assert.equal(byTurn.get(2)?.boundarySeq, 1, '回合 2 的边界 = turn/start 的 seq − 1')
267
+ assert.equal(byTurn.get(2)?.sessionId, 'session-1111')
268
+ assert.equal(byTurn.get(2)?.unitId, 'proj-key/session-1111')
269
+ assert.ok((byTurn.get(1)?.createdAt ?? 0) < (byTurn.get(2)?.createdAt ?? 0), '新→旧')
270
+
271
+ // 未登记会话:不抛错,结构化返回
272
+ const unknown = await tracker.noteEvent({ sessionId: 'nope', seq: 9, type: 'turn/end', data: { turn: 9 } })
273
+ assert.equal(unknown.captured, false)
274
+ assert.equal(unknown.code, 'invalid-input')
275
+ assert.equal((await engine.list()).points.length, 2)
276
+ })
277
+
278
+ /* =================================================== R-03 fork 边界 / no-boundary */
279
+
280
+ test('R-03 fork 边界 = 目标回合前的稳定边界;首回合无前置边界 → 结构化 no-boundary', async (t) => {
281
+ const fx = await makeFixture()
282
+ t.after(fx.cleanup)
283
+ // 纯函数层:只认 turn/start 与 turn/end 的 seq
284
+ const events = [
285
+ { seq: 0, type: 'turn/start', data: { turn: 1 } },
286
+ { seq: 1, type: 'turn/end', data: { turn: 1 } },
287
+ { seq: 2, type: 'turn/start', data: { turn: 2 } },
288
+ { seq: 5, type: 'turn/end', data: { turn: 2 } },
289
+ ]
290
+ const boundaries = turnBoundariesOf(events)
291
+ assert.deepEqual(boundaries.map((b) => [b.turn, b.startSeq, b.endSeq, b.boundarySeq]), [
292
+ [1, 0, 1, null],
293
+ [2, 2, 5, 1],
294
+ ])
295
+ assert.deepEqual(resolveTurnBoundary(events, 2), { ok: true, boundarySeq: 1, endSeq: 5 })
296
+ const noBoundary = resolveTurnBoundary(events, 1)
297
+ assert.equal(noBoundary.ok, false)
298
+ assert.equal(noBoundary.code, 'no-boundary')
299
+
300
+ // 引擎层:边界为 null 的点在预览里结构化 no-boundary;会话段回退失败(不猜边界)
301
+ const engine = makeEngine(fx)
302
+ const captured = await capturePoint(engine, fx, { turnSeq: 1, boundarySeq: null, boundaryEndSeq: 1 })
303
+ const record = captured.point
304
+ assert.ok(record !== undefined)
305
+ const preview = await engine.preview(record.id)
306
+ assert.equal(preview.ok, true)
307
+ assert.equal(preview.session.from, null)
308
+ assert.equal(preview.session.reasonCode, 'no-boundary')
309
+ assert.equal(preview.session.restorable, false)
310
+ // 工作区态仍可回退(回滚点本身有效),但会话不能
311
+ assert.equal(preview.workspace.available, true)
312
+
313
+ const forked: RollbackForkInput[] = []
314
+ const engine2 = makeEngine(fx, {
315
+ fork: async (input) => { forked.push(input); return { ok: true, childSessionId: 'child-x' } },
316
+ })
317
+ const rewound = await engine2.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off', mode: 'session' })
318
+ const sessionSegment = rewound.segments.find((s) => s.state === 'session')
319
+ assert.equal(sessionSegment?.status, 'failed')
320
+ assert.equal(sessionSegment?.reasonCode, 'no-boundary')
321
+ assert.deepEqual(forked, [], '无边界时绝不调用 fork')
322
+ })
323
+
324
+ /* =================================================== R-04 留存(20 / 50) */
325
+
326
+ test('R-04 留存:每会话 20、全局 50,淘汰只删最旧的非保护点', async (t) => {
327
+ const fx = await makeFixture()
328
+ t.after(fx.cleanup)
329
+ assert.equal(ROLLBACK_MAX_RECORDS, 50)
330
+ assert.equal(ROLLBACK_MAX_RECORDS_PER_SESSION, 20)
331
+
332
+ // ① 每会话上限:25 个自动点 → 只留最新 20 个
333
+ const engine = makeEngine(fx, { now: steppingClock(5_000_000, 10) })
334
+ const ids: string[] = []
335
+ for (let i = 0; i < 25; i += 1) {
336
+ const res = await capturePoint(engine, fx, { sessionId: 's-a', note: 'n' + String(i) })
337
+ assert.equal(res.ok, true)
338
+ ids.push(res.point?.id ?? '')
339
+ }
340
+ const ledger = await ledgerOf(engine)
341
+ assert.equal(ledger.records.length, 20, '每会话最多 20 个自动点')
342
+ const kept = new Set(ledger.records.map((r) => r.id))
343
+ for (let i = 0; i < 5; i += 1) assert.equal(kept.has(ids[i] ?? ''), false, '最旧的 5 个必须被淘汰')
344
+ for (let i = 5; i < 25; i += 1) assert.equal(kept.has(ids[i] ?? ''), true, '较新的必须保留')
345
+
346
+ // ② 保护点 / guard 点不被淘汰,也不占自动点预算
347
+ const engine2 = makeEngine(fx, { now: steppingClock(6_000_000, 10), maxRecords: 1, maxRecordsPerSession: 1 })
348
+ const protectedPoint = await capturePoint(engine2, fx, { sessionId: 's-b', protect: true })
349
+ const guard = await engine2.capturePoint({
350
+ kind: 'guard', sessionId: 's-b', sessionLogPath: fx.logFile, chunks: [fx.workspaceDir],
351
+ })
352
+ assert.equal(guard.ok, true)
353
+ const auto1 = await capturePoint(engine2, fx, { sessionId: 's-b', note: 'auto-1' })
354
+ const auto2 = await capturePoint(engine2, fx, { sessionId: 's-b', note: 'auto-2' })
355
+ const records = (await ledgerOf(engine2)).records
356
+ const ids2 = records.map((r) => r.id)
357
+ assert.ok(ids2.includes(protectedPoint.point?.id ?? ''), '保护点必须还在')
358
+ assert.ok(ids2.includes(guard.point?.id ?? ''), 'guard 点必须还在')
359
+ assert.ok(ids2.includes(auto2.point?.id ?? ''), '最新的自动点保留')
360
+ assert.equal(ids2.includes(auto1.point?.id ?? ''), false, '最旧的自动点被淘汰')
361
+
362
+ // ③ 全局上限:5 个不同会话的自动点,maxRecords=3 → 只留最新 3 个(独立 dataDir,避免与上面共享台账)
363
+ const fx3 = await makeFixture()
364
+ t.after(fx3.cleanup)
365
+ const engine3 = makeEngine(fx3, { now: steppingClock(7_000_000, 10), maxRecords: 3, maxRecordsPerSession: 20 })
366
+ const ids3: string[] = []
367
+ for (let i = 0; i < 5; i += 1) {
368
+ const res = await capturePoint(engine3, fx3, { sessionId: 's-' + String(i) })
369
+ ids3.push(res.point?.id ?? '')
370
+ }
371
+ const kept3 = new Set((await ledgerOf(engine3)).records.map((r) => r.id))
372
+ assert.equal(kept3.size, 3)
373
+ assert.equal(kept3.has(ids3[0] ?? ''), false)
374
+ assert.equal(kept3.has(ids3[4] ?? ''), true)
375
+ })
376
+
377
+ /* ============================================ t9 fork 子会话的继承链与代际隔离 */
378
+
379
+ test('t9 活会话登记:header 的 parentSession / isSeeded 带进 binding(读不到就不带字段,绝不猜)', () => {
380
+ const seeded = rollbackSessionBindingOf({ id: 'c1', header: { cwd: 'D:/ws', parentSession: 'p1', isSeeded: true } })
381
+ assert.equal(seeded.ok, true)
382
+ if (seeded.ok) {
383
+ assert.equal(seeded.binding.parentSessionId, 'p1', '磁盘 header 名 parentSession')
384
+ assert.equal(seeded.binding.forked, true)
385
+ }
386
+ const projected = rollbackSessionBindingOf({ id: 'c2', header: { cwd: 'D:/ws', parentSessionId: 'p2', seedLength: 12 } })
387
+ if (projected.ok) {
388
+ assert.equal(projected.binding.parentSessionId, 'p2', 'RPC 投影名 parentSessionId 也认')
389
+ assert.equal(projected.binding.forked, true, 'seedLength 也是「播种而来」的证据')
390
+ }
391
+ const subagent = rollbackSessionBindingOf({ id: 'c3', header: { cwd: 'D:/ws', parentSession: 'p3', origin: 'subagent', isSeeded: false } })
392
+ if (subagent.ok) {
393
+ assert.equal(subagent.binding.parentSessionId, 'p3')
394
+ assert.equal(subagent.binding.forked, undefined, '子代理会话不进继承链(它的回合号是自己的)')
395
+ }
396
+ const plain = rollbackSessionBindingOf({ id: 'c4', header: { cwd: 'D:/ws' } })
397
+ if (plain.ok) {
398
+ assert.equal(plain.binding.parentSessionId, undefined)
399
+ assert.equal(plain.binding.forked, undefined)
400
+ }
401
+ })
402
+
403
+ test('t9 祖先链:只上溯 + 环保护 + 深度上限(自指 / 环 / 超深链都不挂起、不误命中)', async () => {
404
+ const fx = await makeFixture()
405
+ try {
406
+ const engine = makeEngine(fx)
407
+ engine.noteSessionBinding({ sessionId: 'a', parentSessionId: 'a', forked: true })
408
+ assert.deepEqual(engine.ancestorIdsOf('a'), [], '自指不产生祖先')
409
+ engine.noteSessionBinding({ sessionId: 'b', parentSessionId: 'c', forked: true })
410
+ engine.noteSessionBinding({ sessionId: 'c', parentSessionId: 'b', forked: true })
411
+ assert.deepEqual(engine.ancestorIdsOf('b'), ['c'], '环里走一步就停(不挂起、也不重复)')
412
+ // 非 fork 关系(子代理 / 只是声明了父)不进链
413
+ engine.noteSessionBinding({ sessionId: 'x', parentSessionId: 'y', forked: false })
414
+ engine.noteSessionBinding({ sessionId: 'z', parentSessionId: 'y' })
415
+ assert.deepEqual(engine.ancestorIdsOf('x'), [])
416
+ assert.deepEqual(engine.ancestorIdsOf('z'), [])
417
+ // 超深链:s0 → s1 → … → s20
418
+ for (let i = 0; i < 20; i += 1) {
419
+ engine.noteSessionBinding({ sessionId: 's' + String(i), parentSessionId: 's' + String(i + 1), forked: true })
420
+ }
421
+ const chain = engine.ancestorIdsOf('s0')
422
+ assert.equal(chain.length, ROLLBACK_ANCESTOR_MAX_DEPTH, '深度上限内的祖先才算数')
423
+ assert.equal(chain[0], 's1')
424
+ assert.equal(chain.includes('s9'), false, '超过上限的祖先不得出现在链里(不误命中)')
425
+ } finally {
426
+ await fx.cleanup()
427
+ }
428
+ })
429
+
430
+ test('t9 ancestorPointForTurn:祖先有**同回合**点才命中;没有就 null(不猜最近的一个)', async () => {
431
+ const fx = await makeFixture()
432
+ try {
433
+ const engine = makeEngine(fx)
434
+ await capturePoint(engine, fx, {}) // session-1111 / turn 2
435
+ engine.noteSessionBinding({ sessionId: 'child-2222', parentSessionId: 'session-1111', forked: true })
436
+ const hit = await engine.ancestorPointForTurn('child-2222', 2)
437
+ assert.equal(hit?.sessionId, 'session-1111')
438
+ assert.equal(hit?.turn, 2)
439
+ assert.equal(await engine.ancestorPointForTurn('child-2222', 7), null, '祖先没有这一回合的点 ⇒ null')
440
+ assert.equal(await engine.ancestorPointForTurn('session-1111', 2), null, '自己的点不算祖先点')
441
+ } finally {
442
+ await fx.cleanup()
443
+ }
444
+ })
445
+
446
+ test('t9 留点器代际隔离:继承回合不补点(无幽灵点),本会话自己的新回合必须留下自己的点', async () => {
447
+ const fx = await makeFixture()
448
+ try {
449
+ const engine = makeEngine(fx)
450
+ const tracker = new RollbackAutoTracker({ engine })
451
+ // 父会话 p1:回合 1、2 各留一个点
452
+ tracker.registerSession({ sessionId: 'p1', chunks: [fx.workspaceDir], unitId: 'proj-key/p1' })
453
+ await tracker.noteEvent({ sessionId: 'p1', seq: 0, type: 'turn/start', data: { turn: 1 } })
454
+ await tracker.noteEvent({ sessionId: 'p1', seq: 3, type: 'turn/end', data: { turn: 1 } })
455
+ await tracker.noteEvent({ sessionId: 'p1', seq: 4, type: 'turn/start', data: { turn: 2 } })
456
+ await tracker.noteEvent({ sessionId: 'p1', seq: 8, type: 'turn/end', data: { turn: 2 } })
457
+
458
+ // fork 子会话 c1:历史从 p1 播种而来,replay 的回合号与父逐字相同
459
+ tracker.registerSession({ sessionId: 'c1', chunks: [fx.workspaceDir], unitId: 'proj-key/c1', parentSessionId: 'p1', forked: true })
460
+ const replay1 = await tracker.noteEvent({ sessionId: 'c1', seq: 0, type: 'turn/start', data: { turn: 1 } })
461
+ assert.equal(replay1.captured, false, '继承回合不得补点(补了就是把「现在」的工作区态贴到那一回合上 = 幽灵点)')
462
+ assert.match(String(replay1.detail), /inherited-turn/)
463
+ const replay2 = await tracker.noteEvent({ sessionId: 'c1', seq: 4, type: 'turn/start', data: { turn: 2 } })
464
+ assert.equal(replay2.captured, false)
465
+ assert.match(String(replay2.detail), /inherited-turn/)
466
+
467
+ // c1 自己的新回合(父没有回合 3)⇒ 必须留下 c1 自己的点
468
+ const own = await tracker.noteEvent({ sessionId: 'c1', seq: 9, type: 'turn/start', data: { turn: 3 } })
469
+ assert.equal(own.captured, true, '子会话跑到的新回合必须留下本会话自己的点(不得被跨代际去重跳过)')
470
+
471
+ const ledger = await ledgerOf(engine)
472
+ const c1Own = ledger.records.filter((r) => r.sessionId === 'c1')
473
+ assert.equal(c1Own.length, 1, '台账里 c1 只有自己的那一个点')
474
+ assert.equal(c1Own[0]?.turnSeq, 3)
475
+ assert.equal(ledger.records.some((r) => r.sessionId === 'c1' && (r.turnSeq === 1 || r.turnSeq === 2)), false, '继承回合绝不在台账里留幽灵点')
476
+ assert.equal(ledger.records.filter((r) => r.sessionId === 'p1').length, 2, '父会话的点不受影响')
477
+ } finally {
478
+ await fx.cleanup()
479
+ }
480
+ })
481
+
482
+ test('t9 继承链与继承视图:磁盘上的 fork 子会话被认出来(inheritLinks + 祖先点的继承视图,台账零写入)', async () => {
483
+ const fx = await makeFixture()
484
+ try {
485
+ const engine = makeEngine(fx)
486
+ const parent = await capturePoint(engine, fx, {}) // session-1111 / turn 2 / unitId proj-key/session-1111
487
+ assert.equal(parent.ok, true)
488
+ const parentPointId = parent.ok && parent.point !== undefined ? parent.point.id : ''
489
+ // 磁盘上造 fork 子会话(header: parentSession + isSeeded)与子代理子会话(origin=subagent)
490
+ const forkedDir = path.join(fx.sessionsDir, 'proj-key', 'child-2222')
491
+ await fs.mkdir(forkedDir, { recursive: true })
492
+ await fs.writeFile(path.join(forkedDir, 'session.jsonl.zstd'), encodeZstdFrame(Buffer.from(
493
+ JSON.stringify({ type: 'session', version: 4, id: 'child-2222', cwd: fx.workspaceDir, parentSession: 'session-1111', isSeeded: true }) + '\n',
494
+ )))
495
+ const subagentDir = path.join(fx.sessionsDir, 'proj-key', 'sub-3333')
496
+ await fs.mkdir(subagentDir, { recursive: true })
497
+ await fs.writeFile(path.join(subagentDir, 'session.jsonl.zstd'), encodeZstdFrame(Buffer.from(
498
+ JSON.stringify({ type: 'session', version: 4, id: 'sub-3333', cwd: fx.workspaceDir, parentSession: 'session-1111', origin: 'subagent', isSeeded: false }) + '\n',
499
+ )))
500
+
501
+ const list = await engine.list()
502
+ assert.equal(list.inheritLinks?.['child-2222'], 'session-1111', 'fork 子会话必须进继承链')
503
+ assert.equal(list.inheritLinks?.['sub-3333'], undefined, '子代理子会话不进继承链(回合号是它自己的)')
504
+ assert.equal(list.inheritLinksComplete, true)
505
+
506
+ const view = list.points.find((p) => p.sessionId === 'child-2222' && p.turn === 2)
507
+ assert.ok(view !== undefined, 'fork 子会话必须拿到祖先同回合点的继承视图')
508
+ assert.equal(view.id, parentPointId, '继承视图与祖先点同 id ⇒ preview/rewind/remove 作用在原点')
509
+ assert.equal(view.inheritedFrom, parentPointId)
510
+ assert.equal(view.inheritedSessionId, 'session-1111')
511
+ assert.equal(view.unitId, 'proj-key/child-2222', '视图归到子会话的单元(同项目键)')
512
+ assert.equal(list.inheritedViewTruncated, undefined, '未超上限就不标记')
513
+
514
+ const ledger = await ledgerOf(engine)
515
+ assert.equal(ledger.records.some((r) => r.sessionId === 'child-2222'), false, '继承视图绝不写进台账(零幽灵点)')
516
+ } finally {
517
+ await fx.cleanup()
518
+ }
519
+ })
520
+
521
+ /* =================================================== R-05 只覆盖不删除 */
522
+
523
+ test('R-05 工作区恢复:只覆盖记录在案的路径,回滚点之后新建的文件绝不删除', async (t) => {
524
+ const fx = await makeFixture()
525
+ t.after(fx.cleanup)
526
+ const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
527
+ const captured = await capturePoint(engine, fx)
528
+ const id = captured.point?.id ?? ''
529
+ assert.notEqual(id, '')
530
+
531
+ // 回滚点之后:改内容、加新文件
532
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
533
+ await fs.writeFile(path.join(fx.workspaceDir, 'src', 'b.txt'), 'B2')
534
+ await fs.writeFile(path.join(fx.workspaceDir, 'new.txt'), 'NEW')
535
+
536
+ const rewound = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off' })
537
+ assert.equal(rewound.outcome, 'restored', JSON.stringify(rewound.segments))
538
+ assert.equal(rewound.ok, true)
539
+ assert.equal(rewound.code, 'ok')
540
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A1')
541
+ assert.equal(await readText(path.join(fx.workspaceDir, 'src', 'b.txt')), 'B1')
542
+ // 绝不删除:未记录 / 之后新建的文件仍在,并被如实报告
543
+ assert.equal(await readText(path.join(fx.workspaceDir, 'new.txt')), 'NEW')
544
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
545
+ assert.equal(ws?.status, 'restored')
546
+ assert.equal(ws?.restored, 2)
547
+ assert.deepEqual(ws?.leftovers, [path.join(fx.workspaceDir, 'new.txt')])
548
+ assert.ok((ws?.detail ?? []).some((line) => line.includes('verified=2')))
549
+ })
550
+
551
+ test('R-05c 留点在回合开始前:这一回合里被改过的文件,回退后必须回到「改之前」的内容', async (t) => {
552
+ const fx = await makeFixture()
553
+ t.after(fx.cleanup)
554
+ const engine = makeEngine(fx, { now: steppingClock(4_000_000, 10) })
555
+ const tracker = new RollbackAutoTracker({ engine })
556
+ tracker.registerSession({
557
+ sessionId: 's-pre',
558
+ unitId: 'proj-key/s-pre',
559
+ logPath: fx.logFile,
560
+ chunks: [fx.workspaceDir],
561
+ })
562
+
563
+ // 回合开始 → 留点(此刻的工作区);随后 agent 在这一回合**改文件 + 加文件**
564
+ await tracker.noteEvent({ sessionId: 's-pre', seq: 0, type: 'turn/start', data: { turn: 1 } })
565
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A-CHANGED-IN-TURN')
566
+ await fs.writeFile(path.join(fx.workspaceDir, 'in-turn.txt'), 'NEW-IN-TURN')
567
+ await tracker.noteEvent({ sessionId: 's-pre', seq: 9, type: 'turn/end', data: { turn: 1 } })
568
+
569
+ const point = (await engine.list()).points.find((p) => p.turn === 1)
570
+ assert.notEqual(point?.id, undefined, '回合开始就该有点(旧口径在回合结束才拍 ⇒ 回退时文件已经是「改之后」的样子)')
571
+ const rewound = await engine.rewind({ id: point?.id ?? '', userConfirmed: true, guardPolicy: 'off', removeCreated: true })
572
+ // 首回合没有前置边界 ⇒ 会话段结构化 no-boundary(既有口径),所以只看工作区段
573
+ const wsSegment = rewound.segments.find((s) => s.state === 'workspace')
574
+ assert.equal(wsSegment?.status, 'restored', JSON.stringify(rewound.segments))
575
+ assert.equal(
576
+ await readText(path.join(fx.workspaceDir, 'a.txt')),
577
+ 'A1',
578
+ '回合内被改过的文件必须退回「这条消息之前」的内容(这正是「工作区与该回退点一致」那个 bug)',
579
+ )
580
+ await assert.rejects(fs.readFile(path.join(fx.workspaceDir, 'in-turn.txt')), { code: 'ENOENT' })
581
+ })
582
+
583
+ /**
584
+ * R-05d(用户 2026-10-12 实测的「跳过中间若干回合」):同一对话 **6 个回合**,中间 3 个回合各新建
585
+ * 文件,从最后一个回合**直接**退回第 2 个回合。
586
+ *
587
+ * 为什么相邻回退暴露不出来、非相邻才暴露 —— 判据是**这一行**:
588
+ * 删除候选 = 「该点未记录」的路径,它随**跳过的回合数**线性增长;只要候选数越过逐条清单上限
589
+ * (`ROLLBACK_LEFTOVER_LIST_LIMIT` = 20)或前 20 条里混进**拒删项**,旧实现的两个删除口径就分叉:
590
+ * 预览列的是「未记录」的前 20 条(含**永不删除**的 `.git` 路径、且漏掉第 21 条之后**真的会删**的路径),
591
+ * 执行删的是「全量 + 过安全判据」的那一份。相邻只退一轮 ⇒ 候选只有这一回合新建的几条,
592
+ * 上限与拒删项都还挤得下 ⇒ 两个集合碰巧相等。
593
+ *
594
+ * 实测(修复前,同一场景):预览列 20 条 = 3 条 `.git/*`(永不删除)+ 17 条会删;实际删 25 条
595
+ * ⇒ 8 条「会删的」根本没被列出,用户看到的就是「预览只列几个,实际删一大堆」。
596
+ *
597
+ * 现状(2026-10-12 同日收敛后):删除集只有一份规划(`planCreatedRemovals`),且清单**全量**
598
+ * (不再切 20 条;超 `ROLLBACK_MAX_CREATED_REMOVALS` 时执行端整套拒绝 ⇒ 预览如实给空集)。
599
+ * 界面直接把这份清单渲染成「删除」行,**不需要**任何「清单可能不完整」的说明句
600
+ * (用户同日要求弹窗只要清单;文案克制 → DESIGN.md §7)。
601
+ */
602
+ test('R-05d 非相邻回退:预览的「会被删除路径集合」必须与实际删除集合逐条一致(拒删项如实)', async (t) => {
603
+ const fx = await makeFixture()
604
+ t.after(fx.cleanup)
605
+ const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
606
+ const tracker = new RollbackAutoTracker({ engine })
607
+ tracker.registerSession({
608
+ sessionId: 's-6', unitId: 'proj-key/s-6', logPath: fx.logFile, chunks: [fx.workspaceDir],
609
+ })
610
+ const event = async (seq: number, type: string, turn: number): Promise<void> => {
611
+ await tracker.noteEvent({ sessionId: 's-6', seq, type, data: { turn } })
612
+ }
613
+
614
+ // 回合 1:改 a.txt(工作区基线)
615
+ await event(0, 'turn/start', 1)
616
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A-turn1')
617
+ await event(1, 'turn/end', 1)
618
+
619
+ // 回合 2:**回退目标**(它的工作区态 = 这一回合开始之前)
620
+ await event(2, 'turn/start', 2)
621
+ await event(3, 'turn/end', 2)
622
+
623
+ // 中间 3 个回合(3/4/5):每回合新建 8 个文件 + 在 .git 里塞一条(拒删项)
624
+ const created: string[] = []
625
+ for (const turn of [3, 4, 5]) {
626
+ await event(turn * 2, 'turn/start', turn)
627
+ for (let i = 0; i < 8; i += 1) {
628
+ const file = path.join(fx.workspaceDir, 'mid-turn' + String(turn) + '-' + String(i) + '.txt')
629
+ await fs.writeFile(file, 'T' + String(turn) + '-' + String(i))
630
+ created.push(file)
631
+ }
632
+ await fs.mkdir(path.join(fx.workspaceDir, '.git'), { recursive: true })
633
+ await fs.writeFile(path.join(fx.workspaceDir, '.git', 'mid' + String(turn)), 'GIT')
634
+ await event(turn * 2 + 1, 'turn/end', turn)
635
+ }
636
+ // 回合 6:最后一个回合再新建一条,并**改掉**目标点记录在案的 a.txt(强制真写回 + 复核)
637
+ await event(12, 'turn/start', 6)
638
+ await fs.writeFile(path.join(fx.workspaceDir, 'mid-turn6.txt'), 'T6')
639
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A-later')
640
+ created.push(path.join(fx.workspaceDir, 'mid-turn6.txt'))
641
+ await event(13, 'turn/end', 6)
642
+
643
+ const target = (await engine.list()).points.find((p) => p.turn === 2)
644
+ assert.notEqual(target?.id, undefined, '第 2 回合必须有点(留点在 turn/start)')
645
+ const id = target?.id ?? ''
646
+
647
+ const preview = await engine.preview(id)
648
+ const rewound = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', removeCreated: true })
649
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
650
+ assert.equal(ws?.status, 'restored', JSON.stringify(rewound.segments))
651
+
652
+ // ① 中间回合新建的路径**全部**被删(25 条),拒删的 .git 路径如实进 createdSkipped
653
+ assert.equal(created.length, 25)
654
+ for (const file of created) {
655
+ await assert.rejects(fs.readFile(file), { code: 'ENOENT' }, '中间回合新建的路径必须被删:' + file)
656
+ }
657
+ assert.equal(ws?.removed, 25, 'removed 必须等于真实删除数(detail=' + JSON.stringify(ws?.detail) + ')')
658
+ const refused = ws?.createdSkipped ?? []
659
+ assert.equal(refused.length, 3, '3 条 .git 路径必须如实进 createdSkipped')
660
+ for (const file of refused) {
661
+ assert.ok(file.split(path.sep).includes('.git'), '拒删的必须只可能是 .git 段内的:' + file)
662
+ assert.equal(await readText(file), 'GIT', '.git 内的文件绝不能被删')
663
+ }
664
+ assert.equal(ws?.createdSkippedCount, 3)
665
+ assert.equal(ws?.createdRefusedReason, undefined, '捕获完整 ⇒ 不是「捕获不完整」拒删')
666
+
667
+ // ② 目标点记录在案的路径写回并通过 SHA-256 复核
668
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A-turn1', 'a.txt 必须回到第 2 回合开始前的内容')
669
+ assert.ok((ws?.detail ?? []).some((line) => line.includes('restored=1') && line.includes('verified=1')), 'detail 必须带 restored/verified:' + JSON.stringify(ws?.detail))
670
+
671
+ // ③ **核心断言**:预览的「会被删除路径集合」与实际删除集合逐条一致(旧实现此处必红)
672
+ const previewWs = preview.workspace
673
+ assert.deepEqual(previewWs.removals, ws?.removedPaths, '预览说会删的 = 实际删的(逐条、同序)')
674
+ assert.equal(previewWs.removalsCount, ws?.removed, '预览的删除总数 = 实际删除数')
675
+ assert.equal(previewWs.removals.some((file) => file.split(path.sep).includes('.git')), false, '预览的删除清单里绝不能混进永不删除的 .git 路径')
676
+ assert.equal(previewWs.createdSkippedCount, 3, '预览必须同样列出 3 条拒删项')
677
+
678
+ // ④ 清单**全量**(不再切 20 条):界面直接把这份清单渲染成「删除」行,不需要任何说明句
679
+ assert.equal(previewWs.removals.length, 25, '25 条会删的必须**全部**列出来(旧实现只列前 20 条,用户看到的正是「预览只列几个」)')
680
+ assert.equal(previewWs.removalsTruncated, false, '清单完整 ⇒ 不完整标记必须为 false')
681
+ // 「相邻不暴露、非相邻才暴露」的那一行判据:候选数越过**旧的**逐条清单上限(20)时旧实现的两个口径才分叉
682
+ assert.ok(
683
+ previewWs.createdAfter > ROLLBACK_LEFTOVER_LIST_LIMIT,
684
+ '非相邻才暴露的判据:候选数越过旧清单上限(' + String(previewWs.createdAfter) + ' > ' + String(ROLLBACK_LEFTOVER_LIST_LIMIT) + ')',
685
+ )
686
+ })
687
+
688
+ test('R-05b 开 removeCreated:回退点之后新建的路径被真的删掉;.git 内的拒删', async (t) => {
689
+ const fx = await makeFixture()
690
+ t.after(fx.cleanup)
691
+ const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
692
+ const captured = await capturePoint(engine, fx)
693
+ const id = captured.point?.id ?? ''
694
+ assert.notEqual(id, '')
695
+
696
+ // 点之后:改内容 + 新建一个可删的 + 在 .git 里塞一个(仓库元数据绝不能被「新建」判据删掉)
697
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
698
+ await fs.mkdir(path.join(fx.workspaceDir, '.git'), { recursive: true })
699
+ await fs.writeFile(path.join(fx.workspaceDir, '.git', 'index'), 'GIT-INDEX')
700
+ await fs.writeFile(path.join(fx.workspaceDir, 'new.txt'), 'NEW')
701
+
702
+ const rewound = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', removeCreated: true })
703
+ assert.equal(rewound.outcome, 'restored', JSON.stringify(rewound.segments))
704
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A1')
705
+ await assert.rejects(
706
+ fs.readFile(path.join(fx.workspaceDir, 'new.txt')),
707
+ { code: 'ENOENT' },
708
+ '开了 removeCreated 就必须真的删掉「该点未记录」的新建路径',
709
+ )
710
+ assert.equal(await readText(path.join(fx.workspaceDir, '.git', 'index')), 'GIT-INDEX', '.git 内一律拒删')
711
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
712
+ assert.equal(ws?.removed, 1)
713
+ assert.deepEqual(ws?.removedPaths, [path.join(fx.workspaceDir, 'new.txt')])
714
+ assert.ok((ws?.detail ?? []).some((line) => line.includes('removed=1')), 'detail 必须带 removed=1')
715
+ })
716
+
717
+ /**
718
+ * R-05e(用户 2026-10-12 真机第二例:工作区文件数超 `maxFiles` 时,**中间回合新建的文件一条都删不掉**)。
719
+ *
720
+ * 判据是**这一行**:删除候选的「证明」曾经只有 `payload.files` 一份,而捕获侧的跳过清单有**记录上限**
721
+ * (`maxSkippedEntries`,缺省 5000)。工作区文件数 > maxFiles 时每个点都 `truncated=true`;一旦超出
722
+ * 的条目多到名单被截断(`skippedTruncated=true`),旧判据(`payload.truncated || unreadableFiles`)
723
+ * 整体放弃删除 ⇒ 用户点某条更早消息的「↺ 回退」时,**中间回合新建的文件全部留在工作区**。
724
+ * 本仓库实测:工作区 88 138 个文件(`.tmp/` + `outputs/` + `.git`)⇒ 每个自动点都被截断。
725
+ *
726
+ * 修法(方向 A+B):捕获侧把**走过**的全部路径写进**路径索引对象**(gzip、内容寻址)⇒
727
+ * 「留点时刻存在哪些路径」永远可证;删除候选 = 当前存在、在 chunks 内、且**不在**
728
+ * files ∪ skipped ∪ unreadableFiles ∪ excludedDirs ∪ 路径索引 任何一份名单里。
729
+ * 索引缺失 / 读不出(老台账 / 对象丢失)⇒ 仍 fail-closed 一条都不删并如实报告(见 R-05f)。
730
+ */
731
+ test('R-05e 预算截断的点:中间回合新建的路径必须被删;被预算挡下的既有文件一条都不删', async (t) => {
732
+ const fx = await makeFixture()
733
+ t.after(fx.cleanup)
734
+ // 9 个既有文件 + maxFiles=4 + maxSkippedEntries=2 ⇒ 跳过名单被截断(这正是本用例的输入)
735
+ const names = Array.from({ length: 9 }, (_, i) => 'old-' + String(i + 1) + '.txt')
736
+ const dir = await makeSizeDir(fx, 'cap-index', Object.fromEntries(names.map((n) => [n, 3])))
737
+ const engine = makeEngine(fx, { maxFiles: 4, maxSkippedEntries: 2 })
738
+ const tracker = new RollbackAutoTracker({ engine })
739
+ tracker.registerSession({
740
+ sessionId: 's-e', unitId: 'proj-key/s-e', logPath: fx.logFile, chunks: [dir],
741
+ })
742
+
743
+ // 回合 1:留点(工作区 = 这 9 个既有文件)
744
+ await tracker.noteEvent({ sessionId: 's-e', seq: 0, type: 'turn/start', data: { turn: 1 } })
745
+ const point = (await engine.list()).points.find((p) => p.turn === 1)
746
+ assert.notEqual(point?.id, undefined, '回合开始就该有点')
747
+ // list() 给的是摘要(没有 payload)—— 证明名单只能从详情里读
748
+ const payload = (await engine.read(point?.id ?? '')).point?.workspace.payload
749
+ assert.ok(payload !== null && payload !== undefined)
750
+ assert.equal(payload.skippedTruncated, true, '前提:跳过名单被记录上限截断')
751
+ const recorded = new Set(payload.files.map((f) => f.path))
752
+ const listedSkip = new Set(payload.skipped.map((s) => s.path))
753
+ const oldFiles = names.map((n) => path.join(dir, n))
754
+ // 前提:存在「既没被记录、也没进逐条跳过名单」的既有文件 —— 旧判据只能全盘放弃删除
755
+ const unprovable = oldFiles.filter((f) => !recorded.has(f) && !listedSkip.has(f))
756
+ assert.ok(unprovable.length > 0, '前提:存在无法逐条证明的既有文件')
757
+
758
+ // 回合 2 / 3(中间回合):新建 a、b,并改掉一个在预算外的既有文件 C
759
+ const createdA = path.join(dir, 'created-a.txt')
760
+ const createdB = path.join(dir, 'created-b.txt')
761
+ const modified = unprovable[0] ?? ''
762
+ await fs.writeFile(createdA, 'A')
763
+ await fs.writeFile(createdB, 'B')
764
+ await fs.writeFile(modified, 'CHANGED-IN-LATER-TURN')
765
+ await tracker.noteEvent({ sessionId: 's-e', seq: 4, type: 'turn/end', data: { turn: 3 } })
766
+
767
+ // 从最后回合**直接**退回第 1 回合那个点
768
+ const rewound = await engine.rewind({
769
+ id: point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true,
770
+ })
771
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
772
+ assert.equal(ws?.status, 'restored', JSON.stringify(rewound.segments))
773
+
774
+ // ① **本次要修的**:中间回合新建的路径必须被删(修复前这里恒为 0 —— truncated ⇒ 一条都不删)
775
+ await assert.rejects(fs.readFile(createdA), { code: 'ENOENT' }, '中间回合新建的 a 必须被删')
776
+ await assert.rejects(fs.readFile(createdB), { code: 'ENOENT' }, '中间回合新建的 b 必须被删')
777
+ assert.equal(ws?.removed, 2, 'removed 必须等于真实删除数(detail=' + JSON.stringify(ws?.detail) + ')')
778
+ assert.deepEqual([...(ws?.removedPaths ?? [])].sort(), [createdA, createdB].sort())
779
+ // 删了 2 个文件就不是「0 变更」—— 顶层不许再说 nothing-to-restore(假尾句)
780
+ assert.equal(rewound.code, 'ok', JSON.stringify({ outcome: rewound.outcome, code: rewound.code, segments: rewound.segments }))
781
+
782
+ // ② 护栏(上一轮口径,不得回归):**留点时就存在**的文件一条都不许删 —— 含被预算挡下、既没进
783
+ // files 也没进 skipped 的那一个;没被记录的既有文件既不能删、也不能凭空还原内容
784
+ for (const file of oldFiles) await fs.access(file)
785
+ assert.equal(await readText(modified), 'CHANGED-IN-LATER-TURN', '没被记录的既有文件既不能删、也不能凭空还原')
786
+
787
+ // ③ 报告如实:这次不是「无法证明」拒删,detail 带真实删除数
788
+ assert.equal(ws?.createdRefusedReason, undefined, JSON.stringify(ws))
789
+ assert.ok((ws?.detail ?? []).some((line) => line.includes('removed=2')), JSON.stringify(ws?.detail))
790
+ assert.equal(ws?.createdSkippedCount, 0, '9 个既有文件都进了证明名单 ⇒ 不该出现在拒删里')
791
+ })
792
+
793
+ /**
794
+ * R-05f(同一判据的另一侧):**证明名单不完整时一条都不删,并如实回传原因**。
795
+ * 索引对象丢失(老台账 / 对象仓损坏)等价于「无法证明」⇒ 绝不按「未记录」猜「新建」。
796
+ */
797
+ test('R-05f 路径索引读不出(老台账 / 对象丢失)⇒ 仍 fail-closed:一条都不删 + 如实报告原因', async (t) => {
798
+ const fx = await makeFixture()
799
+ t.after(fx.cleanup)
800
+ const names = Array.from({ length: 9 }, (_, i) => 'old-' + String(i + 1) + '.txt')
801
+ const dir = await makeSizeDir(fx, 'cap-index-missing', Object.fromEntries(names.map((n) => [n, 3])))
802
+ const engine = makeEngine(fx, { maxFiles: 4, maxSkippedEntries: 2 })
803
+ const tracker = new RollbackAutoTracker({ engine })
804
+ tracker.registerSession({
805
+ sessionId: 's-f', unitId: 'proj-key/s-f', logPath: fx.logFile, chunks: [dir],
806
+ })
807
+ await tracker.noteEvent({ sessionId: 's-f', seq: 0, type: 'turn/start', data: { turn: 1 } })
808
+ const point = (await engine.list()).points.find((p) => p.turn === 1)
809
+ const indexObject = (await engine.read(point?.id ?? '')).point?.workspace.payload?.pathIndex?.object ?? ''
810
+ assert.notEqual(indexObject, '', '前提:被截断的点必须带路径索引对象')
811
+ // 把索引对象从对象仓里拿走 —— 等价于老台账(没有这个字段)或对象仓丢了这一份
812
+ await fs.rm(path.join(engine.storeDir, 'objects', indexObject), { force: true })
813
+
814
+ const created = path.join(dir, 'created.txt')
815
+ await fs.writeFile(created, 'NEW')
816
+ const rewound = await engine.rewind({
817
+ id: point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true,
818
+ })
819
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
820
+ assert.equal(ws?.status, 'restored', JSON.stringify(rewound.segments))
821
+ // 0 变更的段走 noop 口径(removed 缺省不给数字)—— 两种都算「一条都没删」
822
+ assert.equal(ws?.removed ?? 0, 0, '证明不了就一条都不删(绝不按「未记录」猜「新建」)')
823
+ assert.equal(await readText(created), 'NEW')
824
+ assert.equal(ws?.createdRefusedReason, 'capture-incomplete', '拒删原因必须如实回传')
825
+ assert.ok((ws?.createdSkippedCount ?? 0) > 0, '候选必须如实进拒删清单')
826
+ for (const file of names.map((n) => path.join(dir, n))) await fs.access(file)
827
+ })
828
+
829
+ /* =================================================== R-06 恢复前救援点 */
830
+
831
+ test('R-06 恢复前自动留救援点(guard):默认 warn 档必留,且 guard 点不可删', async (t) => {
832
+ const fx = await makeFixture()
833
+ t.after(fx.cleanup)
834
+ const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
835
+ const captured = await capturePoint(engine, fx)
836
+ const id = captured.point?.id ?? ''
837
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
838
+
839
+ const rewound = await engine.rewind({ id, userConfirmed: true })
840
+ assert.equal(rewound.outcome, 'restored', JSON.stringify(rewound.segments))
841
+ const guardId = rewound.guardPointId
842
+ assert.ok(guardId !== undefined, '恢复前必须自动留救援点')
843
+ const list = await engine.list()
844
+ const guard = list.points.find((p) => p.id === guardId)
845
+ assert.equal(guard?.kind, 'guard')
846
+ // guard 点由 kind 保护(不可删、不被清理),不是用户的 protected 标记
847
+ assert.equal((await engine.preview(guardId ?? '')).canRemove, false)
848
+
849
+ const removed = await engine.remove(guardId ?? '')
850
+ assert.equal(removed.ok, false)
851
+ assert.equal(removed.code, 'protected-point')
852
+ assert.equal((await engine.list()).points.some((p) => p.id === guardId), true, 'guard 点必须还在')
853
+
854
+ // preRewindGuard='off' → 不留救援点
855
+ const captured2 = await capturePoint(engine, fx, { sessionId: 's-2' })
856
+ const off = await engine.rewind({ id: captured2.point?.id ?? '', userConfirmed: true, guardPolicy: 'off' })
857
+ assert.equal(off.guardPointId, undefined)
858
+ })
859
+
860
+ /* =================================================== R-07 校验失败整体回滚 */
861
+
862
+ test('R-07 恢复后逐文件校验失败 → 整体回滚到恢复前状态(绝不留下半恢复)', async (t) => {
863
+ const fx = await makeFixture()
864
+ t.after(fx.cleanup)
865
+ const target = path.join(fx.workspaceDir, 'a.txt')
866
+ let corrupted = false
867
+ const engine = makeEngine(fx, {
868
+ // 注入写回 seam:第一次写 a.txt 时故意写坏 → 复核必然失败 → 触发整体回滚
869
+ writeFile: async (file, bytes) => {
870
+ if (!corrupted && file === target) {
871
+ corrupted = true
872
+ await atomicWriteFile(file, Buffer.from('CORRUPTED-BY-SEAM'))
873
+ return
874
+ }
875
+ await atomicWriteFile(file, bytes, { symlink: 'reject' })
876
+ },
877
+ })
878
+ const captured = await capturePoint(engine, fx)
879
+ const id = captured.point?.id ?? ''
880
+ await fs.writeFile(target, 'A2')
881
+ await fs.writeFile(path.join(fx.workspaceDir, 'src', 'b.txt'), 'B2')
882
+
883
+ const rewound = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off' })
884
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
885
+ assert.equal(ws?.status, 'failed')
886
+ assert.equal(ws?.reasonCode, 'verify-failed')
887
+ assert.equal(ws?.restored, 0, '失败不允许把已写条数当成功')
888
+ assert.ok((ws?.detail ?? []).some((line) => line.startsWith('verify-mismatch')), JSON.stringify(ws?.detail))
889
+ assert.ok((ws?.detail ?? []).some((line) => line.includes('rolledBack=')))
890
+ // **整体回滚到恢复前状态**:两个被触碰文件都回到恢复前
891
+ assert.equal(await readText(target), 'A2', '被写坏的文件必须回滚到恢复前内容')
892
+ assert.equal(await readText(path.join(fx.workspaceDir, 'src', 'b.txt')), 'B2', '同一批的其它文件也必须一起回滚')
893
+ assert.equal(rewound.outcome, 'failed')
894
+ assert.equal(rewound.code, 'restore-failed')
895
+ })
896
+
897
+ /* =================================================== R-08 确认门 */
898
+
899
+ test('R-08 确认门 fail-closed:userConfirmed 不恰好为 true 一律拒绝且零写入', async (t) => {
900
+ const fx = await makeFixture()
901
+ t.after(fx.cleanup)
902
+ const engine = makeEngine(fx)
903
+ const captured = await capturePoint(engine, fx)
904
+ const id = captured.point?.id ?? ''
905
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
906
+
907
+ const badInputs: unknown[] = [undefined, null, false, 'true', 1, 0]
908
+ for (const bad of badInputs) {
909
+ const rewound = await engine.rewind({ id, userConfirmed: bad as boolean, guardPolicy: 'off' })
910
+ assert.equal(rewound.outcome, 'denied', String(bad))
911
+ assert.equal(rewound.code, 'confirmation-required', String(bad))
912
+ assert.equal(rewound.segments.length, 0, String(bad))
913
+ }
914
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2')
915
+ assert.equal((await engine.list()).points.length, 1, '拒绝路径不得留下 guard 点(零写入)')
916
+
917
+ // 删除也要确认(未确认 → confirmation-required,记录仍在)
918
+ const remove = await engine.remove(id)
919
+ assert.equal(remove.ok, true)
920
+ assert.equal(remove.code, 'deleted')
921
+ })
922
+
923
+ /* =================================================== R-09 保护点 */
924
+
925
+ test('R-09 保护点:不可回退、不可删除、不被自动清理', async (t) => {
926
+ const fx = await makeFixture()
927
+ t.after(fx.cleanup)
928
+ const engine = makeEngine(fx, { maxRecords: 1, maxRecordsPerSession: 1 })
929
+ const captured = await capturePoint(engine, fx, { protect: true })
930
+ const record = captured.point
931
+ assert.ok(record !== undefined)
932
+ assert.equal(record.protected, true)
933
+
934
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
935
+ const rewound = await engine.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off' })
936
+ assert.equal(rewound.outcome, 'denied')
937
+ assert.equal(rewound.code, 'protected-point')
938
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2')
939
+
940
+ const preview = await engine.preview(record.id)
941
+ assert.equal(preview.canRestore, false)
942
+ assert.equal(preview.canRemove, false)
943
+
944
+ const removed = await engine.remove(record.id)
945
+ assert.equal(removed.ok, false)
946
+ assert.equal(removed.code, 'protected-point')
947
+
948
+ // 自动清理绝不淘汰保护点
949
+ await capturePoint(engine, fx, { sessionId: 's-1', note: 'p1' })
950
+ await capturePoint(engine, fx, { sessionId: 's-2', note: 'p2' })
951
+ const ids = (await engine.list()).points.map((p) => p.id)
952
+ assert.ok(ids.includes(record.id), '保护点必须还在')
953
+ assert.equal(ids.length, 2)
954
+ })
955
+
956
+ /* =================================================== R-10 两态不全 */
957
+
958
+ test('R-10 两态不全(缺会话输入)→ 缺省拒绝回退;allowPartial 才放行', async (t) => {
959
+ const fx = await makeFixture()
960
+ t.after(fx.cleanup)
961
+ const engine = makeEngine(fx)
962
+ const captured = await engine.capturePoint({ chunks: [fx.workspaceDir] })
963
+ const record = captured.point
964
+ assert.ok(record !== undefined)
965
+ assert.equal(record.incomplete, true)
966
+ assert.equal(record.session.ok, false)
967
+ assert.equal(record.session.reasonCode, 'session-missing')
968
+ assert.equal(record.sessionId, null)
969
+
970
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
971
+ const denied = await engine.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off' })
972
+ assert.equal(denied.outcome, 'denied')
973
+ assert.equal(denied.code, 'record-incomplete')
974
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2')
975
+
976
+ const partial = await engine.rewind({
977
+ id: record.id, userConfirmed: true, allowPartial: true, segments: ['workspace'], guardPolicy: 'off',
978
+ })
979
+ assert.equal(partial.outcome, 'restored')
980
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A1')
981
+ })
982
+
983
+ /* =================================================== R-11 记录不可成为写原语 */
984
+
985
+ test('R-11 记录被篡改不能变成写原语:路径越界 / 指纹不符一律零写入', async (t) => {
986
+ const fx = await makeFixture()
987
+ t.after(fx.cleanup)
988
+ const engine = makeEngine(fx)
989
+ const captured = await capturePoint(engine, fx)
990
+ const record = captured.point
991
+ assert.ok(record !== undefined)
992
+ const ledgerPath = path.join(engine.storeDir, 'index.json')
993
+
994
+ const outside = path.join(fx.root, 'outside.txt')
995
+ await fs.writeFile(outside, 'SAFE')
996
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
997
+
998
+ // 篡改 1:把记录里的路径改成分块之外的文件
999
+ const tampered = await ledgerOf(engine) as unknown as { records: RollbackPointRecord[] }
1000
+ const wsFiles = tampered.records[0]?.workspace.payload?.files ?? []
1001
+ const hit = wsFiles.find((f) => f.path.endsWith('a.txt'))
1002
+ assert.ok(hit !== undefined)
1003
+ hit.path = outside
1004
+ await fs.writeFile(ledgerPath, JSON.stringify(tampered, null, 2))
1005
+ const rewound1 = await engine.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off' })
1006
+ assert.equal(rewound1.outcome, 'failed')
1007
+ assert.ok((rewound1.segments[0]?.detail ?? []).some((line) => line.startsWith('invalid-path')))
1008
+ assert.equal(await readText(outside), 'SAFE', '越界路径绝不被写')
1009
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2', '一条不合格 → 零写入')
1010
+
1011
+ // 篡改 2:路径合法但声明的内容指纹不符
1012
+ const tampered2 = await ledgerOf(engine) as unknown as { records: RollbackPointRecord[] }
1013
+ const hit2 = (tampered2.records[0]?.workspace.payload?.files ?? []).find((f) => f.sha256 === sha256Hex('A1'))
1014
+ assert.ok(hit2 !== undefined)
1015
+ hit2.path = path.join(fx.workspaceDir, 'a.txt') // 路径合法(分块内),只有指纹不符
1016
+ hit2.sha256 = sha256Hex('NOT-THE-CAPTURED-CONTENT')
1017
+ await fs.writeFile(ledgerPath, JSON.stringify(tampered2, null, 2))
1018
+ const rewound2 = await engine.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off' })
1019
+ assert.equal(rewound2.outcome, 'failed')
1020
+ assert.ok((rewound2.segments[0]?.detail ?? []).some((line) => line.includes('object-hash-mismatch')))
1021
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2')
1022
+ })
1023
+
1024
+ /* =================================================== R-12 会话回退 = fork */
1025
+
1026
+ test('R-12 会话回退 = 在稳定边界 fork 子会话;原会话日志只读(逐字节未变)', async (t) => {
1027
+ const fx = await makeFixture()
1028
+ t.after(fx.cleanup)
1029
+ const calls: RollbackForkInput[] = []
1030
+ const engine = makeEngine(fx, {
1031
+ fork: async (input) => { calls.push(input); return { ok: true, childSessionId: 'child-1' } },
1032
+ })
1033
+ const captured = await capturePoint(engine, fx, { boundarySeq: 1, turnSeq: 2 })
1034
+ const id = captured.point?.id ?? ''
1035
+ const before = await readText(fx.logFile)
1036
+
1037
+ const rewound = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off' })
1038
+ assert.equal(rewound.outcome, 'restored', JSON.stringify(rewound.segments))
1039
+ assert.equal(rewound.childSessionId, 'child-1')
1040
+ const segment = rewound.segments.find((s) => s.state === 'session')
1041
+ assert.equal(segment?.status, 'restored')
1042
+ assert.equal(segment?.childSessionId, 'child-1')
1043
+ assert.deepEqual(calls, [{ sessionId: 'session-1111', boundarySeq: 1 }], 'fork 边界必须是该回合开始前最后一个事件 seq')
1044
+ assert.ok((segment?.detail ?? []).some((line) => line.includes('logUnchanged=true')))
1045
+ assert.equal(await readText(fx.logFile), before, '原会话日志必须逐字节未变')
1046
+
1047
+ // mode='session' 时只动会话段
1048
+ const captured2 = await capturePoint(engine, fx, { sessionId: 'session-1111' })
1049
+ const onlySession = await engine.rewind({ id: captured2.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'session' })
1050
+ assert.deepEqual(onlySession.segments.map((s) => s.state), ['session'])
1051
+ assert.deepEqual(onlySession.skippedSegments, ['workspace'])
1052
+
1053
+ // 没有注入 fork seam → 结构化降级(不静默、不假装成功)
1054
+ const bare = makeEngine(fx)
1055
+ const degraded = await bare.rewind({ id, userConfirmed: true, guardPolicy: 'off', mode: 'session' })
1056
+ const bareSegment = degraded.segments.find((s) => s.state === 'session')
1057
+ assert.equal(bareSegment?.status, 'failed')
1058
+ assert.equal(bareSegment?.reasonCode, 'session-fork-unavailable')
1059
+
1060
+ // fork 拒绝 → 沿用 fork 的错误码
1061
+ const refusing = makeEngine(fx, { fork: async () => ({ ok: false, code: 'no-boundary', detail: 'OPEN_TURN' }) })
1062
+ const refused = await refusing.rewind({ id, userConfirmed: true, guardPolicy: 'off', mode: 'session' })
1063
+ assert.equal(refused.segments.find((s) => s.state === 'session')?.reasonCode, 'no-boundary')
1064
+
1065
+ // fork 抛错 → 不冒泡,结构化 fork-failed
1066
+ const throwing = makeEngine(fx, { fork: async () => { throw new Error('boom') } })
1067
+ const thrown = await throwing.rewind({ id, userConfirmed: true, guardPolicy: 'off', mode: 'session' })
1068
+ assert.equal(thrown.segments.find((s) => s.state === 'session')?.reasonCode, 'fork-failed')
1069
+
1070
+ // 归位成功:只有「已 fork」一条指引,没有额外噪音(真机 2026-10-11/12)
1071
+ const placedSegment = rewound.segments.find((s) => s.state === 'session')
1072
+ assert.deepEqual((placedSegment?.guidance ?? []).map((g) => g.code), ['rollback.guidance.sessionForked'])
1073
+ assert.ok((placedSegment?.detail ?? []).some((line) => line.includes('workspaceAttached=true')))
1074
+
1075
+ // fork 成功但**没能挂回工作区** → 回退仍算成功,但多一条指引如实说明(绝不静默)
1076
+ const unplaced = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-9', warning: 'workspace-attach-failed' }) })
1077
+ const unplacedResult = await unplaced.rewind({ id, userConfirmed: true, guardPolicy: 'off', mode: 'session' })
1078
+ const unplacedSegment = unplacedResult.segments.find((s) => s.state === 'session')
1079
+ assert.equal(unplacedSegment?.status, 'restored', '归位失败不影响回退本身的成功判定')
1080
+ assert.deepEqual(
1081
+ (unplacedSegment?.guidance ?? []).map((g) => g.code),
1082
+ ['rollback.guidance.sessionForked', 'rollback.guidance.sessionNotAttached'],
1083
+ )
1084
+ assert.ok((unplacedSegment?.detail ?? []).some((line) => line.includes('workspaceAttached=false')))
1085
+ })
1086
+
1087
+ /* =================================================== R-13 预览零写入 */
1088
+
1089
+ test('R-13 preview 零写入:只读影响面(overwrite / different / missing / createdAfter)', async (t) => {
1090
+ const fx = await makeFixture()
1091
+ t.after(fx.cleanup)
1092
+ const engine = makeEngine(fx)
1093
+ const captured = await capturePoint(engine, fx)
1094
+ const id = captured.point?.id ?? ''
1095
+
1096
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2-A-LONGER-CONTENT')
1097
+ await fs.writeFile(path.join(fx.workspaceDir, 'new.txt'), 'NEW')
1098
+ await fs.rm(path.join(fx.workspaceDir, 'src', 'b.txt'))
1099
+
1100
+ const preview = await engine.preview(id)
1101
+ assert.equal(preview.ok, true)
1102
+ assert.equal(preview.code, 'ok')
1103
+ assert.equal(preview.canRestore, true)
1104
+ assert.equal(preview.canRemove, true)
1105
+ assert.equal(preview.workspace.overwrite, 2)
1106
+ assert.equal(preview.workspace.different, 1)
1107
+ assert.equal(preview.workspace.missing, 1)
1108
+ assert.equal(preview.workspace.createdAfter, 1)
1109
+ assert.ok(preview.workspace.leftovers.some((p) => p.endsWith('new.txt')))
1110
+ assert.equal(preview.session.from, 1)
1111
+ assert.equal(preview.session.to, 6)
1112
+ assert.equal(preview.session.turns, 2)
1113
+ assert.equal(preview.session.restorable, true)
1114
+ assert.equal(preview.point?.segments.length, 2)
1115
+
1116
+ // 零写入
1117
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2-A-LONGER-CONTENT')
1118
+ assert.equal(await readText(fx.logFile), 'frame-one')
1119
+ assert.equal((await engine.list()).points.length, 1, '预览不得留点')
1120
+
1121
+ // 坏 id
1122
+ const missing = await engine.preview('rb-nope')
1123
+ assert.equal(missing.ok, false)
1124
+ assert.equal(missing.code, 'record-not-found')
1125
+
1126
+ // 已知但两态不全 → canRestore=false
1127
+ const incomplete = await engine.capturePoint({ chunks: [fx.workspaceDir] })
1128
+ assert.equal((await engine.preview(incomplete.point?.id ?? '')).canRestore, false)
1129
+ })
1130
+
1131
+ /* =================================================== R-14 mode 三选一 */
1132
+
1133
+ test('R-14 mode 三选一:files+session / files / session;显式 segments 收窄', async (t) => {
1134
+ const fx = await makeFixture()
1135
+ t.after(fx.cleanup)
1136
+ const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
1137
+ const captured = await capturePoint(engine, fx)
1138
+ const id = captured.point?.id ?? ''
1139
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
1140
+
1141
+ const files = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', mode: 'files' })
1142
+ assert.deepEqual(files.segments.map((s) => s.state), ['workspace'])
1143
+ assert.deepEqual(files.skippedSegments, ['session'])
1144
+ assert.equal(files.outcome, 'restored')
1145
+
1146
+ const session = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', mode: 'session' })
1147
+ assert.deepEqual(session.segments.map((s) => s.state), ['session'])
1148
+ assert.equal(session.childSessionId, 'child-1')
1149
+
1150
+ const both = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off' })
1151
+ assert.deepEqual(both.segments.map((s) => s.state), ['workspace', 'session'])
1152
+ assert.equal(both.outcome, 'restored')
1153
+
1154
+ // segments 显式给空数组 = 非法输入(不静默回落到 mode)
1155
+ const invalid = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', segments: [] })
1156
+ assert.equal(invalid.outcome, 'denied')
1157
+ assert.equal(invalid.code, 'invalid-input')
1158
+ })
1159
+
1160
+ /* =================================================== R-15 存储降级 */
1161
+
1162
+ test('R-15 存储栈缺失:不抛错、结构化组合指引、各接口都能给结论', async (t) => {
1163
+ const fx = await makeFixture()
1164
+ t.after(fx.cleanup)
1165
+ const engine = makeEngine(fx, { storage: async () => ({ ok: false, detail: 'EACCES: permission denied' }) })
1166
+
1167
+ const status = await engine.storageStatus()
1168
+ assert.equal(status.available, false)
1169
+ assert.equal(status.detail, 'EACCES: permission denied')
1170
+ assert.ok(status.requirements.includes('@deepseek-ai/dsh-storage'))
1171
+ const codes = status.guidance.map((g) => g.code)
1172
+ assert.ok(codes.includes('rollback.guidance.mountStorage'))
1173
+ assert.ok(codes.includes('rollback.guidance.fixDataDir'))
1174
+ assert.ok(codes.includes('rollback.guidance.readOnlyFallback'))
1175
+
1176
+ const captured = await capturePoint(engine, fx)
1177
+ assert.equal(captured.ok, false)
1178
+ assert.equal(captured.code, 'storage-unavailable')
1179
+ assert.ok(captured.guidance.length > 0)
1180
+
1181
+ const rewound = await engine.rewind({ id: 'rb-x', userConfirmed: true })
1182
+ assert.equal(rewound.outcome, 'failed')
1183
+ assert.equal(rewound.code, 'storage-unavailable')
1184
+
1185
+ const list = await engine.list()
1186
+ assert.equal(list.ok, false)
1187
+ assert.equal(list.storage.available, false)
1188
+ assert.deepEqual(list.points, [])
1189
+
1190
+ const preview = await engine.preview('rb-x')
1191
+ assert.equal(preview.ok, false)
1192
+ assert.equal(preview.code, 'storage-unavailable')
1193
+ assert.equal(preview.storage.available, false)
1194
+
1195
+ const removed = await engine.remove('rb-x')
1196
+ assert.equal(removed.ok, false)
1197
+ assert.equal(removed.code, 'storage-unavailable')
1198
+
1199
+ // 探针自己抛异常也不能把调用方炸掉
1200
+ const throwing = makeEngine(fx, { storage: async () => { throw new Error('probe exploded') } })
1201
+ const thrown = await throwing.storageStatus()
1202
+ assert.equal(thrown.available, false)
1203
+ assert.equal(thrown.detail, 'probe exploded')
1204
+ })
1205
+
1206
+ /* =================================================== R-16 台账损坏 */
1207
+
1208
+ test('R-16 台账损坏:list 如实报错、留点仍可继续(不崩、不静默)', async (t) => {
1209
+ const fx = await makeFixture()
1210
+ t.after(fx.cleanup)
1211
+ const engine = makeEngine(fx)
1212
+ await fs.mkdir(engine.storeDir, { recursive: true })
1213
+ await fs.writeFile(path.join(engine.storeDir, 'index.json'), '{ broken-json')
1214
+
1215
+ const list = await engine.list()
1216
+ assert.equal(list.ok, true)
1217
+ assert.equal(list.points.length, 0)
1218
+ assert.ok((list.error ?? '').includes('ledger unreadable'))
1219
+
1220
+ const captured = await capturePoint(engine, fx)
1221
+ assert.equal(captured.ok, true)
1222
+ const after = await engine.list()
1223
+ assert.equal(after.points.length, 1)
1224
+ assert.equal(after.error, undefined)
1225
+ })
1226
+
1227
+ /* =================================================== R-17 删除 + 对象回收 */
1228
+
1229
+ test('R-17 删除普通回滚点(对象回收);坏 id 结构化 404 语义', async (t) => {
1230
+ const fx = await makeFixture()
1231
+ t.after(fx.cleanup)
1232
+ const engine = makeEngine(fx)
1233
+ await fs.writeFile(path.join(fx.workspaceDir, 'uniq.txt'), 'UNIQ-DOOMED')
1234
+ const uniqSha = sha256Hex('UNIQ-DOOMED')
1235
+ const captured = await capturePoint(engine, fx)
1236
+ const id = captured.point?.id ?? ''
1237
+ assert.equal(await objectExists(engine, uniqSha), true)
1238
+
1239
+ const removed = await engine.remove(id)
1240
+ assert.equal(removed.ok, true)
1241
+ assert.equal(removed.code, 'deleted')
1242
+ assert.equal(removed.id, id)
1243
+ assert.equal((await engine.list()).points.length, 0)
1244
+ assert.equal(await objectExists(engine, uniqSha), false, '无人引用的对象被回收')
1245
+
1246
+ const missing = await engine.remove('rb-does-not-exist')
1247
+ assert.equal(missing.ok, false)
1248
+ assert.equal(missing.code, 'record-not-found')
1249
+ })
1250
+
1251
+ /* =================================================== R-18 轮询回退 */
1252
+
1253
+ test('R-18 轮询回退:日志 size/mtime 变了才留点,且**无回合边界**(结构化 no-boundary)', async (t) => {
1254
+ const fx = await makeFixture()
1255
+ t.after(fx.cleanup)
1256
+ const engine = makeEngine(fx)
1257
+ const tracker = new RollbackAutoTracker({ engine, pollIntervalMs: 1 })
1258
+ tracker.registerSession({ sessionId: 'session-1111', logPath: fx.logFile, chunks: [fx.workspaceDir] })
1259
+
1260
+ // 首次只记基线
1261
+ assert.deepEqual(await tracker.pollOnce(), [])
1262
+ // 没变 → 不留
1263
+ assert.deepEqual(await tracker.pollOnce(), [])
1264
+ // 变了 → 留一个点(无边界)
1265
+ await fs.appendFile(fx.logFile, '+MORE')
1266
+ const result = await tracker.pollOnce()
1267
+ assert.equal(result.length, 1)
1268
+ assert.equal(result[0]?.captured, true)
1269
+
1270
+ const points = (await engine.list()).points
1271
+ assert.equal(points.length, 1)
1272
+ assert.equal(points[0]?.turn, null)
1273
+ assert.equal(points[0]?.boundarySeq, null)
1274
+ const preview = await engine.preview(points[0]?.id ?? '')
1275
+ assert.equal(preview.session.reasonCode, 'no-boundary')
1276
+ assert.equal(preview.workspace.available, true)
1277
+
1278
+ // start/stop 幂等,且机制标记为 poll
1279
+ tracker.start()
1280
+ assert.equal(tracker.mechanismKind, 'poll')
1281
+ tracker.start()
1282
+ tracker.stop()
1283
+ tracker.stop()
1284
+ })
1285
+
1286
+ /* =================================================== R-19 分块 / 会话不可用 */
1287
+
1288
+ test('R-19 分块不可用 / 非绝对路径 / 会话越界:该态结构化失败,其余态照常', async (t) => {
1289
+ const fx = await makeFixture()
1290
+ t.after(fx.cleanup)
1291
+ const engine = makeEngine(fx)
1292
+
1293
+ const missingChunk = await engine.capturePoint({ chunks: [path.join(fx.root, 'nope')], sessionId: 'session-1111' })
1294
+ assert.equal(missingChunk.point?.workspace.ok, false)
1295
+ assert.equal(missingChunk.point?.workspace.reasonCode, 'chunk-not-found')
1296
+ assert.equal(missingChunk.point?.incomplete, true)
1297
+ assert.equal(missingChunk.ok, true, '留点动作本身成功(记录不完整由 incomplete 表达)')
1298
+
1299
+ const relative = await engine.capturePoint({ chunks: ['relative/path'] })
1300
+ assert.equal(relative.point?.workspace.reasonCode, 'chunk-not-absolute')
1301
+
1302
+ const noChunks = await engine.capturePoint({ sessionId: 'session-1111' })
1303
+ assert.equal(noChunks.point?.workspace.reasonCode, 'no-chunks')
1304
+
1305
+ // 会话日志在会话根之外 → 拒绝(越界不猜)
1306
+ const outsideFile = path.join(fx.root, 'outside-log.jsonl.zstd')
1307
+ await fs.writeFile(outsideFile, 'x')
1308
+ const outOfRoot = await engine.capturePoint({ chunks: [fx.workspaceDir], sessionId: 'session-1111', sessionLogPath: outsideFile })
1309
+ assert.equal(outOfRoot.point?.session.ok, false)
1310
+ assert.equal(outOfRoot.point?.session.reasonCode, 'session-out-of-root')
1311
+
1312
+ // 日志不存在 → 降级但**不失败**(fork 只要 sessionId + 边界)
1313
+ const gone = await engine.capturePoint({
1314
+ chunks: [fx.workspaceDir], sessionId: 'session-1111',
1315
+ sessionLogPath: path.join(fx.unitDir, 'nope.jsonl.zstd'), boundarySeq: 1,
1316
+ })
1317
+ assert.equal(gone.point?.session.ok, true)
1318
+ assert.equal(gone.point?.session.degraded, true)
1319
+ assert.equal(gone.point?.session.reasonCode, 'capture-degraded')
1320
+ assert.equal(gone.point?.session.payload?.boundarySeq, 1)
1321
+
1322
+ // 超大日志 → 只记弱指纹(对象仓不进副本)
1323
+ const tiny = makeEngine(fx, { sessionMaxBytes: 2 })
1324
+ const capped = await capturePoint(tiny, fx)
1325
+ assert.equal(capped.point?.session.reasonCode, 'session-too-large')
1326
+ assert.equal(capped.point?.session.payload?.fingerprintKind, 'size')
1327
+ assert.equal(capped.point?.session.payload?.object, null)
1328
+
1329
+ // 降级不静默:链接跳过留痕
1330
+ const junction = path.join(fx.workspaceDir, 'link-in')
1331
+ try {
1332
+ await fs.symlink(path.join(fx.workspaceDir, 'src'), junction, 'junction')
1333
+ } catch {
1334
+ t.diagnostic('本机不支持创建 junction:跳过链接留痕断言')
1335
+ return
1336
+ }
1337
+ const linked = await capturePoint(engine, fx)
1338
+ const track = linked.point?.workspace
1339
+ assert.equal(track?.ok, true)
1340
+ assert.equal(track?.degraded, true)
1341
+ assert.ok((track?.payload?.skipped ?? []).some((s) => s.reason === 'link' && s.path === junction))
1342
+ assert.equal((track?.payload?.files ?? []).some((f) => isInsidePath(f.path, junction)), false)
1343
+ })
1344
+
1345
+ /* ============================================= R-20 忽略目录(node_modules) */
1346
+
1347
+ test('R-20 忽略规则:运行时依赖目录整棵不进快照、留痕、不算降级', async (t) => {
1348
+ const fx = await makeFixture()
1349
+ t.after(fx.cleanup)
1350
+ const depDir = path.join(fx.workspaceDir, 'node_modules', 'pkg')
1351
+ await fs.mkdir(depDir, { recursive: true })
1352
+ await fs.writeFile(path.join(depDir, 'index.js'), 'module.exports = 1')
1353
+ const engine = makeEngine(fx)
1354
+
1355
+ const captured = await capturePoint(engine, fx)
1356
+ const track = captured.point?.workspace
1357
+ assert.equal(track?.ok, true)
1358
+ // 预期内的忽略 → 不算降级(否则每个 JS 工程的每个回滚点都是 degraded)
1359
+ assert.equal(track?.degraded, false)
1360
+ assert.equal((track?.payload?.files ?? []).some((f) => f.path.includes('node_modules')), false)
1361
+ assert.ok((track?.payload?.excludedDirs ?? []).some((s) => s.reason === 'dep-dir' && s.path.includes('node_modules')),
1362
+ JSON.stringify(track?.payload?.excludedDirs))
1363
+
1364
+ // 预览的 createdAfter 也不把 node_modules 里的文件算成「之后新建」
1365
+ const preview = await engine.preview(captured.point?.id ?? '')
1366
+ assert.equal(preview.workspace.createdAfter, 0)
1367
+
1368
+ // 回退也不碰它(没有记录在案的路径)
1369
+ const rewound = await engine.rewind({ id: captured.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files' })
1370
+ assert.equal(rewound.outcome, 'restored')
1371
+ assert.equal(await readText(path.join(depDir, 'index.js')), 'module.exports = 1')
1372
+ })
1373
+
1374
+ /* ================================ R-21 捕获边界(t13:单文件上限 / 预算耗尽 / 截断留痕) */
1375
+
1376
+ /** 在 ws 下单独开一个子目录放固定大小的文件(只把这个目录当分块,断言才不被夹具自带文件干扰)。 */
1377
+ async function makeSizeDir(fx: Fixture, name: string, files: Record<string, number>): Promise<string> {
1378
+ const dir = path.join(fx.workspaceDir, name)
1379
+ await fs.mkdir(dir, { recursive: true })
1380
+ for (const [file, size] of Object.entries(files)) {
1381
+ await fs.writeFile(path.join(dir, file), 'x'.repeat(size))
1382
+ }
1383
+ return dir
1384
+ }
1385
+
1386
+ test('R-21① 单文件上限:**首个**文件超限也先 stat 后跳过(不进对象仓、不整份读进内存)', async (t) => {
1387
+ const fx = await makeFixture()
1388
+ t.after(fx.cleanup)
1389
+ const big = 'x'.repeat(400)
1390
+ const dir = await makeSizeDir(fx, 'cap-single', { 'a-big.txt': 400, 'b-small.txt': 5 })
1391
+ // 单文件上限 64:a-big.txt 先被 stat 判定超限 → 跳过;b-small.txt 正常入仓
1392
+ const engine = makeEngine(fx, { maxFileBytes: 64, maxBytes: 1_000_000 })
1393
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 'session-1111' })
1394
+ assert.equal(captured.ok, true)
1395
+ const track = captured.point?.workspace
1396
+ const payload = track?.payload
1397
+ assert.ok(payload !== null && payload !== undefined)
1398
+ assert.deepEqual(payload.files.map((f) => path.basename(f.path)), ['b-small.txt'])
1399
+ const skipped = payload.skipped.find((s) => s.path.endsWith('a-big.txt'))
1400
+ assert.equal(skipped?.reason, 'too-large', JSON.stringify(payload.skipped))
1401
+ assert.equal(payload.truncated, true)
1402
+ assert.equal(track?.reasonCode, 'capture-truncated')
1403
+ assert.equal(payload.skippedCount, 1)
1404
+ // 「先 stat 判大小、绝不整份读进内存」的硬证据:它的内容从未进对象仓(没被读/没被哈希)
1405
+ assert.equal(await objectExists(engine, sha256Hex(big)), false, '超限文件不得被读入并落对象仓')
1406
+ })
1407
+
1408
+ test('R-21② 字节预算耗尽:其后**每一条**都进 skipped(budget-bytes),不再 break 静默丢弃', async (t) => {
1409
+ const fx = await makeFixture()
1410
+ t.after(fx.cleanup)
1411
+ const dir = await makeSizeDir(fx, 'cap-bytes', {
1412
+ 'f1.txt': 10, 'f2.txt': 10, 'f3.txt': 10, 'f4.txt': 10,
1413
+ })
1414
+ // 预算 25:f1(10) + f2(10) 进;f3/f4 都会超预算 → 逐条记 budget-bytes
1415
+ const engine = makeEngine(fx, { maxBytes: 25, maxFileBytes: 1000 })
1416
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 'session-1111' })
1417
+ const payload = captured.point?.workspace.payload
1418
+ assert.ok(payload !== null && payload !== undefined)
1419
+ assert.deepEqual(payload.files.map((f) => path.basename(f.path)), ['f1.txt', 'f2.txt'])
1420
+ const skippedNames = payload.skipped.map((s) => path.basename(s.path))
1421
+ assert.deepEqual(skippedNames, ['f3.txt', 'f4.txt'], JSON.stringify(payload.skipped))
1422
+ assert.deepEqual(payload.skipped.map((s) => s.reason), ['budget-bytes', 'budget-bytes'])
1423
+ assert.equal(payload.truncated, true)
1424
+ assert.equal(payload.skippedCount, 2)
1425
+ assert.equal(captured.point?.workspace.reasonCode, 'capture-truncated')
1426
+ })
1427
+
1428
+ test('R-21③ maxFiles 截断:超预算文件(含遍历器溢出)逐条进 skipped', async (t) => {
1429
+ const fx = await makeFixture()
1430
+ t.after(fx.cleanup)
1431
+ const dir = await makeSizeDir(fx, 'cap-files', { 'g1.txt': 3, 'g2.txt': 3, 'g3.txt': 3 })
1432
+ const engine = makeEngine(fx, { maxFiles: 1 })
1433
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 'session-1111' })
1434
+ const payload = captured.point?.workspace.payload
1435
+ assert.ok(payload !== null && payload !== undefined)
1436
+ assert.equal(payload.files.length, 1)
1437
+ assert.equal(payload.files[0]?.path, path.join(dir, 'g1.txt'))
1438
+ assert.deepEqual(payload.skipped.map((s) => path.basename(s.path)), ['g2.txt', 'g3.txt'], JSON.stringify(payload.skipped))
1439
+ assert.deepEqual(payload.skipped.map((s) => s.reason), ['budget-files', 'budget-files'])
1440
+ assert.equal(payload.truncated, true)
1441
+ assert.equal(payload.skippedCount, 2)
1442
+ })
1443
+
1444
+ test('R-21④ 截断标记与跳过计数在列表 / 详情 / 预览三处如实回传', async (t) => {
1445
+ const fx = await makeFixture()
1446
+ t.after(fx.cleanup)
1447
+ const dir = await makeSizeDir(fx, 'cap-surface', { 'h1.txt': 10, 'h2.txt': 10, 'h3.txt': 10 })
1448
+ const engine = makeEngine(fx, { maxBytes: 15, maxFileBytes: 1000 })
1449
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 'session-1111' })
1450
+ const id = captured.point?.id ?? ''
1451
+ assert.notEqual(id, '')
1452
+
1453
+ // 列表:摘要段带 skippedCount / truncated / capture-truncated
1454
+ const list = await engine.list()
1455
+ const wsSummary = list.points[0]?.segments.find((s) => s.state === 'workspace')
1456
+ assert.equal(wsSummary?.skippedCount, 2, JSON.stringify(wsSummary))
1457
+ assert.equal(wsSummary?.truncated, true)
1458
+ assert.equal(wsSummary?.reasonCode, 'capture-truncated')
1459
+ // 会话段恒 0 / false(不该把工作区的截断算到会话头上)
1460
+ const sessionSummary = list.points[0]?.segments.find((s) => s.state === 'session')
1461
+ assert.equal(sessionSummary?.skippedCount, 0)
1462
+ assert.equal(sessionSummary?.truncated, false)
1463
+
1464
+ // 详情:完整记录里逐条可见 + 计数一致
1465
+ const detail = await engine.read(id)
1466
+ const wsPayload = detail.point?.workspace.payload
1467
+ assert.ok(wsPayload !== null && wsPayload !== undefined)
1468
+ assert.equal(wsPayload.skippedCount, 2)
1469
+ assert.deepEqual(wsPayload.skipped.map((s) => path.basename(s.path)), ['h2.txt', 'h3.txt'])
1470
+ assert.equal(wsPayload.skippedTruncated, false)
1471
+
1472
+ // 预览:同一口径(零写入)
1473
+ const preview = await engine.preview(id)
1474
+ assert.equal(preview.workspace.skippedCount, 2)
1475
+ assert.equal(preview.workspace.truncated, true)
1476
+ assert.equal(preview.workspace.overwrite, 1)
1477
+ assert.equal(await readText(path.join(dir, 'h2.txt')), 'xxxxxxxxxx', '预览必须零写入')
1478
+ })
1479
+
1480
+ test('R-21⑤ 逐条记录有上限,但计数与截断标记绝不静默(skippedCount 仍为真实总数)', async (t) => {
1481
+ const fx = await makeFixture()
1482
+ t.after(fx.cleanup)
1483
+ const dir = await makeSizeDir(fx, 'cap-skiplist', { 'k1.txt': 3, 'k2.txt': 3, 'k3.txt': 3, 'k4.txt': 3 })
1484
+ // 只允许记 1 条:剩下 3 条只计数
1485
+ const engine = makeEngine(fx, { maxFiles: 1, maxSkippedEntries: 1 })
1486
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 'session-1111' })
1487
+ const payload = captured.point?.workspace.payload
1488
+ assert.ok(payload !== null && payload !== undefined)
1489
+ assert.equal(payload.files.length, 1)
1490
+ assert.equal(payload.skipped.length, 1, '逐条列表按上限只记 1 条')
1491
+ assert.equal(payload.skippedCount, 3, '计数必须仍是真实总数(不静默)')
1492
+ assert.equal(payload.skippedTruncated, true, '列表被截断必须显式置位')
1493
+ assert.equal(payload.truncated, true)
1494
+ assert.equal(captured.point?.workspace.degraded, true)
1495
+ assert.ok((captured.point?.workspace.reasonDetail ?? '').includes('skipped=3(list-capped)'), String(captured.point?.workspace.reasonDetail))
1496
+ })
1497
+
1498
+ /* ============================ R-22 台账单写者(t14-F1:并发 capture/remove/rewind ≥20 轮) */
1499
+
1500
+ test('R-22 并发:两个引擎实例(tracker 与路由)交错 capture / remove / rewind,无丢失更新、无复活', async (t) => {
1501
+ const fx = await makeFixture()
1502
+ t.after(fx.cleanup)
1503
+ // 生产形状:宿主自动留点器持一个引擎,路由**每个请求新建**一个引擎 —— 两个实例写同一份 index.json
1504
+ const tracker = makeEngine(fx)
1505
+ const route = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-r' }) })
1506
+ const ROUNDS = 20
1507
+ const problems: string[] = []
1508
+
1509
+ for (let i = 0; i < ROUNDS; i += 1) {
1510
+ const session = 's-race-' + String(i)
1511
+ const base = { sessionId: session, sessionLogPath: fx.logFile, boundarySeq: 1 }
1512
+ const ids: string[] = []
1513
+ for (let k = 0; k < 3; k += 1) {
1514
+ const p = await tracker.capturePoint({ chunks: [fx.workspaceDir], unitId: session, ...base, turnSeq: k })
1515
+ assert.equal(p.ok, true, p.detail ?? p.code)
1516
+ ids.push(p.point?.id ?? '')
1517
+ }
1518
+ const victim = ids[1] ?? ''
1519
+ const victim2 = ids[2] ?? ''
1520
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A-r' + String(i))
1521
+
1522
+ // 三条并发写:自动留点 + 删除 + 回退(回退内部要留 guard 救援点)
1523
+ const [captured, removed, rewound] = await Promise.all([
1524
+ tracker.capturePoint({ chunks: [fx.workspaceDir], unitId: session, ...base, turnSeq: 99 }),
1525
+ route.remove(victim),
1526
+ route.rewind({ id: victim2, userConfirmed: true, mode: 'files' }),
1527
+ ])
1528
+
1529
+ // 每轮**立刻**读台账(不等后续轮次,避免跨轮淘汰干扰判据)
1530
+ const records = (await ledgerOf(tracker)).records
1531
+ const kept = records.map((r) => r.id)
1532
+ assert.equal(new Set(kept).size, kept.length, '台账不得出现重复 id(丢更新的典型症状)')
1533
+ if (removed.ok && kept.includes(victim)) problems.push('#' + String(i) + ' 删除成功却又复活')
1534
+ const newId = captured.point?.id ?? ''
1535
+ if (captured.ok && newId !== '' && !kept.includes(newId)) problems.push('#' + String(i) + ' 留点返回 ok 但台账里没有')
1536
+ if (rewound.guardPointId !== undefined && !kept.includes(rewound.guardPointId)) {
1537
+ problems.push('#' + String(i) + ' 响应回了 guardPointId 但台账里已不存在')
1538
+ }
1539
+ }
1540
+
1541
+ assert.deepEqual(problems, [], JSON.stringify(problems))
1542
+ assert.equal(ROUNDS >= 20, true)
1543
+ })
1544
+
1545
+ /* ============================ R-23 窄化函数(t14-F2:形状不符走显式机器码) */
1546
+
1547
+ test('R-23 事件窄化:非对象 / 缺 seq(非有限数)/ 缺 type → 显式 invalid-input;data 原样透传', () => {
1548
+ const SID = 'session-abc'
1549
+ // 形状不符(历史实现返回 null = 静默什么都不做)
1550
+ for (const bad of [undefined, null, 1, 'x', [], true]) {
1551
+ const r = rollbackTrackedEventOf(SID, bad)
1552
+ assert.equal(r.ok, false, String(bad))
1553
+ if (!r.ok) assert.equal(r.code, 'invalid-input')
1554
+ }
1555
+ const seqCases: [string, unknown][] = [
1556
+ ['缺 seq', { type: 'turn/end', data: { turn: 1 } }],
1557
+ ['seq 是字符串', { seq: '3', type: 'turn/end' }],
1558
+ ['seq=NaN', { seq: Number.NaN, type: 'turn/end' }],
1559
+ ['seq=Infinity', { seq: Number.POSITIVE_INFINITY, type: 'turn/end' }],
1560
+ ]
1561
+ for (const [label, ev] of seqCases) {
1562
+ const r = rollbackTrackedEventOf(SID, ev)
1563
+ assert.equal(r.ok, false, label)
1564
+ if (!r.ok) assert.deepEqual({ code: r.code, detail: r.detail }, { code: 'invalid-input', detail: 'event-seq-missing' }, label)
1565
+ }
1566
+ for (const badType of [{ seq: 1 }, { seq: 1, type: '' }, { seq: 1, type: 7 }]) {
1567
+ const r = rollbackTrackedEventOf(SID, badType)
1568
+ assert.equal(r.ok, false, JSON.stringify(badType))
1569
+ if (!r.ok) assert.equal(r.detail, 'event-type-missing')
1570
+ }
1571
+ const noSession = rollbackTrackedEventOf('', { seq: 0, type: 'turn/start' })
1572
+ assert.equal(noSession.ok, false)
1573
+ if (!noSession.ok) assert.equal(noSession.detail, 'session-id-missing')
1574
+
1575
+ // 合法事件:data 原样透传(turn/end 的 data.turn 必须到得了留点器)
1576
+ const ok = rollbackTrackedEventOf(SID, { seq: 7, type: 'turn/end', data: { turn: 3, reason: 'success' } })
1577
+ assert.equal(ok.ok, true)
1578
+ if (ok.ok) {
1579
+ assert.equal(ok.event.seq, 7)
1580
+ assert.equal(ok.event.type, 'turn/end')
1581
+ assert.equal(ok.event.sessionId, SID)
1582
+ assert.deepEqual(ok.event.data, { turn: 3, reason: 'success' })
1583
+ }
1584
+ const noData = rollbackTrackedEventOf(SID, { seq: 1, type: 'turn/start' })
1585
+ assert.equal(noData.ok, true)
1586
+ if (noData.ok) assert.equal('data' in noData.event, false, '没有 data 时不得造一个假的')
1587
+ })
1588
+
1589
+ test('R-23b 会话登记窄化:cwd 缺失 / 非绝对 → 不留 chunks 且给机器码;绝对 cwd → chunks+unitId', () => {
1590
+ for (const bad of [undefined, null, 1, 'x', true]) {
1591
+ const r = rollbackSessionBindingOf(bad)
1592
+ assert.equal(r.ok, false, String(bad))
1593
+ if (!r.ok) assert.deepEqual({ code: r.code, detail: r.detail }, { code: 'invalid-input', detail: 'session-not-object' })
1594
+ }
1595
+ // 数组是 object 但没有 id → 更精确的 session-id-missing(同样是显式机器码,不静默)
1596
+ for (const bad of [{}, { id: '' }, { id: 7 }, []]) {
1597
+ const r = rollbackSessionBindingOf(bad)
1598
+ assert.equal(r.ok, false, JSON.stringify(bad))
1599
+ if (!r.ok) assert.equal(r.detail, 'session-id-missing')
1600
+ }
1601
+ const noCwd = rollbackSessionBindingOf({ id: 's1', header: {} })
1602
+ assert.equal(noCwd.ok, true)
1603
+ if (noCwd.ok) {
1604
+ assert.equal(noCwd.chunkless, 'cwd-missing')
1605
+ assert.equal(noCwd.binding.sessionId, 's1')
1606
+ assert.equal('chunks' in noCwd.binding, false, 'cwd 缺失 → 绝不留 chunks')
1607
+ }
1608
+ const noHeader = rollbackSessionBindingOf({ id: 's1' })
1609
+ assert.equal(noHeader.ok, true)
1610
+ if (noHeader.ok) assert.equal(noHeader.chunkless, 'cwd-missing')
1611
+ const relative = rollbackSessionBindingOf({ id: 's1', header: { cwd: path.join('relative', 'ws') } })
1612
+ assert.equal(relative.ok, true)
1613
+ if (relative.ok) {
1614
+ assert.equal(relative.chunkless, 'cwd-not-absolute')
1615
+ assert.equal('chunks' in relative.binding, false)
1616
+ }
1617
+ const cwd = path.resolve('.')
1618
+ const ok = rollbackSessionBindingOf({ id: 's1', header: { cwd } })
1619
+ assert.equal(ok.ok, true)
1620
+ if (ok.ok) {
1621
+ assert.equal(ok.chunkless, null)
1622
+ assert.deepEqual(ok.binding.chunks, [cwd])
1623
+ assert.equal(ok.binding.unitId, projectKeyOf(cwd) + '/s1')
1624
+ }
1625
+ })
1626
+
1627
+ test('R-23c 集成:chunkless 会话留点 → 该点 incomplete=true 且 workspace.reasonCode=no-chunks(显式可断言)', async (t) => {
1628
+ const fx = await makeFixture()
1629
+ t.after(fx.cleanup)
1630
+ const engine = makeEngine(fx)
1631
+ const tracker = new RollbackAutoTracker({ engine })
1632
+ // cwd 非绝对 → 登记里没有 chunks
1633
+ const bindingResult = rollbackSessionBindingOf({ id: 'session-1111', header: { cwd: 'relative/path' } })
1634
+ assert.equal(bindingResult.ok, true)
1635
+ if (!bindingResult.ok) return
1636
+ assert.equal(bindingResult.chunkless, 'cwd-not-absolute')
1637
+ tracker.registerSession(bindingResult.binding)
1638
+ const captured = await tracker.noteEvent({ sessionId: 'session-1111', seq: 2, type: 'turn/start', data: { turn: 2 } })
1639
+ await tracker.noteEvent({ sessionId: 'session-1111', seq: 6, type: 'turn/end', data: { turn: 2 } })
1640
+ assert.equal(captured.captured, true)
1641
+ const point = (await engine.list()).points[0]
1642
+ assert.equal(point?.incomplete, true, '会话态-only 的点必须显式 incomplete,不许静默降级')
1643
+ const wsSegment = point?.segments.find((s) => s.state === 'workspace')
1644
+ assert.equal(wsSegment?.reasonCode, 'no-chunks')
1645
+ assert.equal(wsSegment?.ok, false)
1646
+ assert.equal(point?.sessionId, 'session-1111')
1647
+ })
1648
+
1649
+ /* ============================ R-24 guard 留存例外(t14-F3:不许无界增长) */
1650
+
1651
+ test('R-24 guard 留存:不被自动点的 20/50 淘汰,但受自身两道上限约束(每会话 20 → 全局 50,超出淘汰最旧 guard)', async (t) => {
1652
+ const fx = await makeFixture()
1653
+ t.after(fx.cleanup)
1654
+ assert.equal(ROLLBACK_MAX_GUARDS_PER_SESSION, 20)
1655
+ assert.equal(ROLLBACK_MAX_GUARDS, 50, '引擎全局救援点上限应为 50')
1656
+
1657
+ // ① guard 不占自动点预算:maxRecords=1 / maxRecordsPerSession=1 时 3 个自动点只留最新 1 个,3 个 guard 全留
1658
+ const engine = makeEngine(fx, { maxRecords: 1, maxRecordsPerSession: 1, now: steppingClock(9_000_000, 10) })
1659
+ const guardIds: string[] = []
1660
+ for (let i = 0; i < 3; i += 1) {
1661
+ const g = await engine.capturePoint({ kind: 'guard', sessionId: 's-g', sessionLogPath: fx.logFile, chunks: [fx.workspaceDir], boundarySeq: 1, turnSeq: i })
1662
+ guardIds.push(g.point?.id ?? '')
1663
+ }
1664
+ const autoIds: string[] = []
1665
+ for (let i = 0; i < 3; i += 1) {
1666
+ const a = await capturePoint(engine, fx, { sessionId: 's-g' })
1667
+ autoIds.push(a.point?.id ?? '')
1668
+ }
1669
+ let kept = (await ledgerOf(engine)).records.map((r) => r.id)
1670
+ for (const id of guardIds) assert.ok(kept.includes(id), 'guard 不许被自动点的 20/50 淘汰')
1671
+ assert.equal(kept.includes(autoIds[0] ?? ''), false, '最旧的自动点仍按 20/50 淘汰')
1672
+ assert.equal(kept.includes(autoIds[2] ?? ''), true)
1673
+
1674
+ // ② guard 有自己的上限:每会话 25 个 → 只留最近 20 个(淘汰最旧 5 个)
1675
+ const capped = makeEngine(fx, { now: steppingClock(10_000_000, 10) })
1676
+ const ids: string[] = []
1677
+ for (let i = 0; i < 25; i += 1) {
1678
+ const g = await capped.capturePoint({ kind: 'guard', sessionId: 's-cap', sessionLogPath: fx.logFile, chunks: [fx.workspaceDir], boundarySeq: 1, turnSeq: i })
1679
+ ids.push(g.point?.id ?? '')
1680
+ }
1681
+ kept = (await ledgerOf(capped)).records.map((r) => r.id)
1682
+ const guardsOfCap = kept.filter((id) => ids.includes(id))
1683
+ assert.equal(guardsOfCap.length, 20, '每会话 guard 上限 = 20(不许无界增长)')
1684
+ for (let i = 0; i < 5; i += 1) assert.equal(kept.includes(ids[i] ?? ''), false, '最旧的 guard 被淘汰')
1685
+ assert.equal(kept.includes(ids[24] ?? ''), true, '最新的 guard 保留')
1686
+
1687
+ // ③ 全局上限(缺省 50,跨会话合计):独立 dataDir(不与上面共享台账);3 会话 × 22 个
1688
+ // → 每会话只留最近 20(共 60)→ 全局再留最近 50 ⇒ 淘汰最旧的 10 个
1689
+ const fxG = await makeFixture()
1690
+ t.after(fxG.cleanup)
1691
+ const global = makeEngine(fxG, { now: steppingClock(11_000_000, 10) })
1692
+ assert.equal((await global.list()).maxGuards, ROLLBACK_MAX_GUARDS, 'list 必须回传全局 guard 上限')
1693
+ const s0: string[] = []
1694
+ const s1: string[] = []
1695
+ const s2: string[] = []
1696
+ for (let s = 0; s < 3; s += 1) {
1697
+ const bucket = s === 0 ? s0 : s === 1 ? s1 : s2
1698
+ for (let i = 0; i < 22; i += 1) {
1699
+ const g = await global.capturePoint({ kind: 'guard', sessionId: 's-global-' + String(s), sessionLogPath: fxG.logFile, chunks: [fxG.workspaceDir], boundarySeq: 1, turnSeq: i })
1700
+ bucket.push(g.point?.id ?? '')
1701
+ }
1702
+ }
1703
+ const globalKept = (await ledgerOf(global)).records.map((r) => r.id)
1704
+ const keptS0 = s0.filter((id) => globalKept.includes(id))
1705
+ const keptS1 = s1.filter((id) => globalKept.includes(id))
1706
+ const keptS2 = s2.filter((id) => globalKept.includes(id))
1707
+ assert.equal(keptS0.length + keptS1.length + keptS2.length, 50, '全局 guard 上限 = 50(跨会话合计,不许无界增长)')
1708
+ assert.equal(keptS1.length, 20, '各会话自己的 20 上限不变')
1709
+ assert.equal(keptS2.length, 20, '各会话自己的 20 上限不变')
1710
+ assert.equal(keptS0.length, 10, '全局超限时继续淘汰最旧的 guard(本轮全落在最早的会话)')
1711
+ for (let i = 0; i < 12; i += 1) assert.equal(globalKept.includes(s0[i] ?? ''), false, '最旧的 guard 被淘汰(含每会话 20 已挡下的那批)')
1712
+ assert.equal(globalKept.includes(s0[12] ?? ''), true, '跨过全局线的最新 guard 保留')
1713
+ assert.equal(globalKept.includes(s2[21] ?? ''), true, '最新会话的最新 guard 保留')
1714
+
1715
+ // ④ 全局上限可注入(独立台账,不牵连上面用例的 guard):maxGuards=3 → 4 个 guard 只留最新 3 个
1716
+ const fxT = await makeFixture()
1717
+ t.after(fxT.cleanup)
1718
+ const tight = makeEngine(fxT, { maxGuards: 3, now: steppingClock(12_000_000, 10) })
1719
+ const tightAuto = await capturePoint(tight, fxT, { sessionId: 's-tight' })
1720
+ const tightIds: string[] = []
1721
+ for (let i = 0; i < 4; i += 1) {
1722
+ const g = await tight.capturePoint({ kind: 'guard', sessionId: 's-tight', sessionLogPath: fxT.logFile, chunks: [fxT.workspaceDir], boundarySeq: 1, turnSeq: i })
1723
+ tightIds.push(g.point?.id ?? '')
1724
+ }
1725
+ const tightLedger = await ledgerOf(tight)
1726
+ const tightGuards = tightLedger.records.filter((r) => r.kind === 'guard')
1727
+ assert.equal(tightGuards.length, 3, '全局 guard 上限 = 注入的 3')
1728
+ assert.deepEqual(tightGuards.map((r) => r.id).sort(), [tightIds[1], tightIds[2], tightIds[3]].sort(), '只留最新的 3 个 guard')
1729
+ assert.equal(tightLedger.records.some((r) => r.id === (tightAuto.point?.id ?? '')), true, '自动点不被 guard 的淘汰牵连')
1730
+
1731
+ // ⑤ 用户仍然删不掉 guard(自动淘汰 ≠ 用户删除)
1732
+ const removed = await capped.remove(ids[24] ?? '')
1733
+ assert.equal(removed.ok, false)
1734
+ assert.equal(removed.code, 'protected-point')
1735
+ })
1736
+
1737
+ /* ============ R-25 canRestore 与 canRemove 解耦(t22:救援点可作回退目标) */
1738
+
1739
+ test('R-25 preview 准入:guard 可回退不可删、protected 两者皆否、incomplete 不可回退(逐条钉住)', async (t) => {
1740
+ const fx = await makeFixture()
1741
+ t.after(fx.cleanup)
1742
+ const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
1743
+
1744
+ // ① guard(救援点):canRestore=true、canRemove=false —— 这正是本次修复的矛盾点
1745
+ const guard = await engine.capturePoint({
1746
+ kind: 'guard', chunks: [fx.workspaceDir], sessionId: 'session-1111',
1747
+ sessionLogPath: fx.logFile, boundarySeq: 1, turnSeq: 2, boundaryEndSeq: 6,
1748
+ })
1749
+ const guardId = guard.point?.id ?? ''
1750
+ const guardPreview = await engine.preview(guardId)
1751
+ assert.equal(guardPreview.ok, true)
1752
+ assert.equal(guardPreview.canRestore, true, '救援点必须可作回退目标(回到回退前那一刻)')
1753
+ assert.equal(guardPreview.canRemove, false, '救援点仍不可被用户删除')
1754
+ assert.equal(guardPreview.point?.kind, 'guard')
1755
+ assert.equal(guard.point?.protected, false, 'guard 的 protected 标记恒 false(保护靠 kind)')
1756
+
1757
+ // ② protected(用户显式保护):两者皆否
1758
+ const prot = await capturePoint(engine, fx, { sessionId: 's-prot', protect: true })
1759
+ const protPreview = await engine.preview(prot.point?.id ?? '')
1760
+ assert.equal(protPreview.canRestore, false)
1761
+ assert.equal(protPreview.canRemove, false)
1762
+
1763
+ // ③ incomplete(没有会话态):不可回退(缺省口径;allowPartial 是 rewind 的显式放行,不在 preview 里)
1764
+ const incomplete = await engine.capturePoint({ chunks: [fx.workspaceDir] })
1765
+ const incompletePreview = await engine.preview(incomplete.point?.id ?? '')
1766
+ assert.equal(incompletePreview.point?.incomplete, true)
1767
+ assert.equal(incompletePreview.canRestore, false)
1768
+ assert.equal(incompletePreview.canRemove, true, '两态不全不影响「可删除」')
1769
+
1770
+ // ④ preview 说能恢复 ⇒ rewind 必须真能成功(一份口径,不是两套判定)
1771
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A-BROKEN')
1772
+ const rewound = await engine.rewind({ id: guardId, userConfirmed: true })
1773
+ assert.equal(rewound.outcome, 'restored', JSON.stringify({ o: rewound.outcome, c: rewound.code, seg: rewound.segments.map((s) => [s.state, s.status]) }))
1774
+ assert.equal(rewound.code, 'ok')
1775
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A1', '救援点确实是「回退前那一刻」的完整快照')
1776
+ // S5 不变:回退前仍必须先留一个新的救援点
1777
+ assert.ok(rewound.guardPointId !== undefined && rewound.guardPointId !== guardId, '回退前必须再留一个救援点')
1778
+ const ledger = await ledgerOf(engine)
1779
+ assert.ok(ledger.records.some((r) => r.id === rewound.guardPointId), '新救援点必须真的落进台账')
1780
+ // 而用户依旧删不掉它
1781
+ const removed = await engine.remove(guardId)
1782
+ assert.equal(removed.ok, false)
1783
+ assert.equal(removed.code, 'protected-point')
1784
+ })
1785
+
1786
+ /* ================================== E1 nothing-to-restore 不再是失败 */
1787
+
1788
+ test('R-26 E1:工作区段无可恢复内容(files=0)不再中止整体 —— 会话照常 fork 出子会话', async (t) => {
1789
+ const fx = await makeFixture()
1790
+ t.after(fx.cleanup)
1791
+ const emptyDir = path.join(fx.root, 'empty-ws')
1792
+ await fs.mkdir(emptyDir, { recursive: true })
1793
+ const calls: RollbackForkInput[] = []
1794
+ const engine = makeEngine(fx, {
1795
+ fork: async (input) => { calls.push(input); return { ok: true, childSessionId: 'child-empty' } },
1796
+ })
1797
+ const captured = await engine.capturePoint({
1798
+ chunks: [emptyDir],
1799
+ sessionId: 'session-1111',
1800
+ unitId: 'proj-key/session-1111',
1801
+ sessionLogPath: fx.logFile,
1802
+ cursorSeq: 3,
1803
+ turnSeq: 2,
1804
+ boundarySeq: 1,
1805
+ boundaryEndSeq: 6,
1806
+ })
1807
+ const record = captured.point
1808
+ assert.ok(record !== undefined)
1809
+ assert.equal(record.workspace.ok, true)
1810
+ assert.equal(record.workspace.payload?.files.length, 0, '夹具必须真的记录 0 个文件')
1811
+ assert.equal(record.incomplete, false, '两态都在,记录不算不全')
1812
+
1813
+ const rewound = await engine.rewind({ id: record.id, userConfirmed: true, guardPolicy: 'off' })
1814
+ // 工作区段 = 「成功、0 变更」:不是失败、也不进 skippedSegments(它**执行过**)
1815
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
1816
+ assert.equal(ws?.status, 'restored', JSON.stringify(ws))
1817
+ assert.equal(ws?.restored, 0)
1818
+ // T10:该点**没有记录任何文件** ⇒ 独立码(与「有记录但全部一致」分开,界面要说两句不同的话)
1819
+ assert.equal(ws?.reasonCode, 'no-recorded-files')
1820
+ assert.deepEqual(rewound.skippedSegments, [], '「执行过、0 变更」不同于「没执行」')
1821
+ // 会话段照常执行:子会话真的 fork 了,整体仍是成功(不是 500)
1822
+ assert.deepEqual(calls, [{ sessionId: 'session-1111', boundarySeq: 1 }], '会话段必须继续执行')
1823
+ assert.equal(rewound.segments.find((s) => s.state === 'session')?.status, 'restored')
1824
+ assert.equal(rewound.childSessionId, 'child-empty')
1825
+ assert.equal(rewound.outcome, 'restored', JSON.stringify(rewound.segments))
1826
+ assert.equal(rewound.code, 'ok')
1827
+ assert.equal(rewound.ok, true)
1828
+ })
1829
+
1830
+ test('R-27 E1:所有段都无可恢复内容 → outcome=restored + code=nothing-to-restore(不是 failed)', async (t) => {
1831
+ const fx = await makeFixture()
1832
+ t.after(fx.cleanup)
1833
+ const emptyDir = path.join(fx.root, 'empty-ws')
1834
+ await fs.mkdir(emptyDir, { recursive: true })
1835
+ const engine = makeEngine(fx)
1836
+ // 只录工作区(无会话输入)⇒ 会话段没有可用捕获(skipped),工作区段文件数 = 0
1837
+ const captured = await engine.capturePoint({ chunks: [emptyDir] })
1838
+ const record = captured.point
1839
+ assert.ok(record !== undefined)
1840
+ assert.equal(record.incomplete, true)
1841
+
1842
+ // ① 只请求工作区段:该段「执行过、0 变更」⇒ 整体成功、0 变更
1843
+ const onlyWorkspace = await engine.rewind({
1844
+ id: record.id, userConfirmed: true, allowPartial: true, segments: ['workspace'], guardPolicy: 'off',
1845
+ })
1846
+ assert.equal(onlyWorkspace.outcome, 'restored', JSON.stringify(onlyWorkspace.segments))
1847
+ assert.equal(onlyWorkspace.code, 'nothing-to-restore')
1848
+ assert.equal(onlyWorkspace.ok, true)
1849
+ assert.equal(onlyWorkspace.segments[0]?.reasonCode, 'no-recorded-files')
1850
+
1851
+ // ② 两段都无事可做(工作区 0 文件 + 会话没有可用捕获)⇒ 同样是成功、0 变更
1852
+ const both = await engine.rewind({ id: record.id, userConfirmed: true, allowPartial: true, guardPolicy: 'off' })
1853
+ assert.equal(both.outcome, 'restored', JSON.stringify(both.segments))
1854
+ assert.equal(both.code, 'nothing-to-restore')
1855
+ assert.equal(both.ok, true)
1856
+ assert.equal(both.segments.length, 2)
1857
+ })
1858
+
1859
+ /* ================================== E1 预览:逐文件行级统计 */
1860
+
1861
+ test('R-28 E1:预览只列会被改动的文件,added/removed 来自「快照对象 vs 当前内容」的行级 diff', async (t) => {
1862
+ const fx = await makeFixture()
1863
+ t.after(fx.cleanup)
1864
+ await fs.writeFile(path.join(fx.workspaceDir, 'empty.txt'), '')
1865
+ await fs.writeFile(path.join(fx.workspaceDir, 'multi.txt'), 'L1\nL2\nL3\n')
1866
+ const engine = makeEngine(fx)
1867
+ const captured = await capturePoint(engine, fx)
1868
+ const id = captured.point?.id ?? ''
1869
+ assert.equal(captured.point?.workspace.payload?.files.length, 4)
1870
+
1871
+ // ① 完全一致(截图里「要盖 353 项 / 其中 0 项内容不同」那一幕)⇒ **空数组**,而不是逐条列出
1872
+ const untouched = await engine.preview(id)
1873
+ assert.equal(untouched.workspace.overwrite, 4, '既有计数字段保持向后兼容')
1874
+ assert.equal(untouched.workspace.different, 0)
1875
+ assert.equal(untouched.workspace.missing, 0)
1876
+ assert.equal(untouched.workspace.createdAfter, 0)
1877
+ assert.deepEqual(untouched.workspace.files, [])
1878
+ assert.equal(untouched.canRestore, true)
1879
+
1880
+ // ② 三种真实改动:行增减 / 清空(回退要补行)/ 删文件(回退会重建)
1881
+ await fs.writeFile(path.join(fx.workspaceDir, 'multi.txt'), 'L1\nL3\nL4\n')
1882
+ await fs.writeFile(path.join(fx.workspaceDir, 'empty.txt'), 'X\nY\n')
1883
+ await fs.rm(path.join(fx.workspaceDir, 'src', 'b.txt'))
1884
+
1885
+ const preview = await engine.preview(id)
1886
+ assert.equal(preview.workspace.different, 2)
1887
+ assert.equal(preview.workspace.missing, 1)
1888
+ assert.equal(preview.workspace.createdAfter, 0)
1889
+ const files = preview.workspace.files
1890
+ assert.deepEqual(files.map((f) => path.basename(f.path)), ['empty.txt', 'multi.txt', 'b.txt'], '只列会被改动的文件(a.txt 一致 ⇒ 不列)')
1891
+ const byName = new Map(files.map((f) => [path.basename(f.path), f]))
1892
+ assert.deepEqual(
1893
+ byName.get('multi.txt'),
1894
+ { path: path.join(fx.workspaceDir, 'multi.txt'), status: 'overwrite', added: 1, removed: 1, truncated: false },
1895
+ 'L1/L3/L4 → L1/L2/L3:新增 1 行(L2)、删除 1 行(L4)',
1896
+ )
1897
+ assert.deepEqual(
1898
+ byName.get('empty.txt'),
1899
+ { path: path.join(fx.workspaceDir, 'empty.txt'), status: 'overwrite', added: 0, removed: 2, truncated: false },
1900
+ '快照是空文件(0 行)⇒ 覆盖回退会删掉当前的 2 行',
1901
+ )
1902
+ assert.deepEqual(
1903
+ byName.get('b.txt'),
1904
+ { path: path.join(fx.workspaceDir, 'src', 'b.txt'), status: 'create', added: 1, removed: 0, truncated: false },
1905
+ '当前已不存在 ⇒ create,新增 = 快照行数',
1906
+ )
1907
+ for (const f of files) {
1908
+ assert.ok(f.status === 'overwrite' || f.status === 'create', '回退绝不删除文件 ⇒ 列表里不会出现 delete')
1909
+ }
1910
+ // 预览零写入:内容与统计都来自只读
1911
+ assert.equal(await readText(path.join(fx.workspaceDir, 'multi.txt')), 'L1\nL3\nL4\n')
1912
+ })
1913
+
1914
+ test('R-29 E1:空文件边界 + 超限保护(status 照给,added/removed 置 null 且 truncated=true,绝不谎报)', async (t) => {
1915
+ const fx = await makeFixture()
1916
+ t.after(fx.cleanup)
1917
+ await fs.writeFile(path.join(fx.workspaceDir, 'empty.txt'), '')
1918
+ await fs.writeFile(path.join(fx.workspaceDir, 'multi.txt'), 'L1\nL2\nL3\n')
1919
+ await fs.writeFile(path.join(fx.workspaceDir, 'swap.txt'), 'S1\nS2\nS3\n')
1920
+ const engine = makeEngine(fx)
1921
+ const captured = await capturePoint(engine, fx)
1922
+ const id = captured.point?.id ?? ''
1923
+
1924
+ // ① 空文件边界(快照 0 字节)→ 当前 2 行:覆盖回退会删掉这 2 行(不是「没变化」)
1925
+ await fs.writeFile(path.join(fx.workspaceDir, 'empty.txt'), 'X\nY\n')
1926
+ // ② 反向边界(快照 3 行)→ 当前被清空:覆盖回退会补回 3 行
1927
+ await fs.writeFile(path.join(fx.workspaceDir, 'multi.txt'), '')
1928
+ // ③ 当前多出的行 → removed;④ 纯换序(行集合相同、顺序不同)→ 真 diff 仍报 1/1
1929
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A1\nEXTRA1\nEXTRA2\n')
1930
+ await fs.writeFile(path.join(fx.workspaceDir, 'swap.txt'), 'S1\nS3\nS2\n')
1931
+
1932
+ const boundary = await engine.preview(id)
1933
+ const pick = (name: string) => boundary.workspace.files.find((f) => f.path.endsWith(name))
1934
+ assert.deepEqual([pick('empty.txt')?.added, pick('empty.txt')?.removed], [0, 2])
1935
+ assert.deepEqual([pick('multi.txt')?.added, pick('multi.txt')?.removed], [3, 0])
1936
+ assert.deepEqual([pick('a.txt')?.added, pick('a.txt')?.removed], [0, 2])
1937
+ assert.deepEqual([pick('swap.txt')?.added, pick('swap.txt')?.removed], [1, 1], '行级 diff 不是「集合差」:换序也算 1 增 1 删')
1938
+ for (const f of boundary.workspace.files) assert.equal(f.truncated, false)
1939
+
1940
+ // ⑤ 单文件超限:status 照给、数字不给(不谎报)
1941
+ const tiny = makeEngine(fx, { previewDiffMaxFileBytes: 4 })
1942
+ const capped = (await tiny.preview(id)).workspace.files.find((f) => f.path.endsWith('swap.txt'))
1943
+ assert.equal(capped?.status, 'overwrite', '超限也必须照给 status')
1944
+ assert.equal(capped?.added, null)
1945
+ assert.equal(capped?.removed, null)
1946
+ assert.equal(capped?.truncated, true)
1947
+
1948
+ // ⑥ 单张预览累计字节超限:其后每一条都只给 status
1949
+ const totalPreview = await makeEngine(fx, { previewDiffMaxTotalBytes: 1 }).preview(id)
1950
+ assert.ok(totalPreview.workspace.files.length > 0)
1951
+ assert.ok(totalPreview.workspace.files.every((f) => f.truncated && f.added === null && f.removed === null))
1952
+
1953
+ // ⑦ 行级比对步数超限:需要真比对的条目放弃(null + truncated),但「一侧为空」的边界步数为 0,仍给数字
1954
+ const noWork = await makeEngine(fx, { previewDiffMaxWork: 0 }).preview(id)
1955
+ const noWorkSwap = noWork.workspace.files.find((f) => f.path.endsWith('swap.txt'))
1956
+ assert.equal(noWorkSwap?.truncated, true)
1957
+ assert.equal(noWorkSwap?.added, null)
1958
+ const noWorkMulti = noWork.workspace.files.find((f) => f.path.endsWith('multi.txt'))
1959
+ assert.deepEqual([noWorkMulti?.added, noWorkMulti?.removed], [3, 0])
1960
+
1961
+ // ⑧ 非文本内容不做行统计(编不出来的行数 ⇒ null + truncated,而不是猜一个数)
1962
+ const binary = Buffer.from([0x42, 0x49, 0x4e, 0x00, 0x01, 0x02])
1963
+ await fs.writeFile(path.join(fx.workspaceDir, 'bin.dat'), binary)
1964
+ const engine2 = makeEngine(fx)
1965
+ const recaptured = await capturePoint(engine2, fx)
1966
+ await fs.writeFile(path.join(fx.workspaceDir, 'bin.dat'), Buffer.from([0x42, 0x49, 0x4e, 0x00, 0x09, 0x08]))
1967
+ const bin = (await engine2.preview(recaptured.point?.id ?? '')).workspace.files.find((f) => f.path.endsWith('bin.dat'))
1968
+ assert.equal(bin?.status, 'overwrite')
1969
+ assert.equal(bin?.truncated, true)
1970
+ assert.equal(bin?.added, null)
1971
+ assert.equal(bin?.removed, null)
1972
+ })
1973
+
1974
+ /* ==================== T10:安全链(幽灵 guardPointId)与 stat 失败语义 ==================== */
1975
+
1976
+ /**
1977
+ * T10 台账写失败的注入(跨平台)—— 返回「还原」回调;返回 `null` 表示本机造不出来,调用方 skip。
1978
+ *
1979
+ * 为什么不能只 `chmod(index.json, 0o444)`(2026-10-13 CI ubuntu 首跑实测 R-30/R-32/R8-8 三红):台账走
1980
+ * **原子写**(同目录 tmp → rename,见 `utils/atomic-write.ts`),而 POSIX 上 rename 覆盖只读文件是
1981
+ * **允许**的(只要目录可写)⇒「文件只读」在 Linux 挡不住写,断言实际测的是「台账写成功了」;
1982
+ * Windows 的只读属性会挡住 rename,所以本机(Windows)一直绿。也不能改成把目录设只读:
1983
+ * `capturePoint` 的第一道门是存储探针(写 `<root>/.dshcm-probe-*`),目录只读会先变成
1984
+ * `storage-unavailable`,那测的就不是台账写失败了。
1985
+ *
1986
+ * 现在:把台账**指向只读目录里的真实文件**(index.json 换成 symlink)。原子写按 `follow` 策略
1987
+ * realpath 到真实文件、tmp 落进只读目录 ⇒ 写必然失败;读路径(readFile 跟随 symlink)不受影响。
1988
+ * 探测 = 用**生产写入器**按台账同形态真写一次(内容就是原内容):写得进去就还原并返回 `null`
1989
+ * (调用方 skip),绝不伪造结论。
1990
+ */
1991
+ async function blockLedgerWrite(file: string): Promise<(() => Promise<void>) | null> {
1992
+ const dir = path.dirname(file)
1993
+ const readOnlyDir = path.join(dir, 'ledger-readonly')
1994
+ const readOnlyFile = path.join(readOnlyDir, 'index.json')
1995
+ let original: string
1996
+ try {
1997
+ original = await fs.readFile(file, 'utf8')
1998
+ } catch {
1999
+ return null
2000
+ }
2001
+ const restore = async (): Promise<void> => {
2002
+ await fs.chmod(readOnlyDir, 0o755).catch(() => {})
2003
+ await fs.chmod(readOnlyFile, 0o666).catch(() => {})
2004
+ await fs.rm(file, { force: true }).catch(() => {})
2005
+ await fs.writeFile(file, original).catch(() => {})
2006
+ await fs.rm(readOnlyDir, { recursive: true, force: true }).catch(() => {})
2007
+ }
2008
+ try {
2009
+ await fs.mkdir(readOnlyDir, { recursive: true })
2010
+ await fs.writeFile(readOnlyFile, original)
2011
+ await fs.rm(file, { force: true })
2012
+ await fs.symlink(readOnlyFile, file, 'file')
2013
+ await fs.chmod(readOnlyFile, 0o444)
2014
+ await fs.chmod(readOnlyDir, 0o555)
2015
+ } catch {
2016
+ await restore()
2017
+ return null
2018
+ }
2019
+ try {
2020
+ await atomicWriteFile(file, original)
2021
+ await restore()
2022
+ return null
2023
+ } catch {
2024
+ return restore
2025
+ }
2026
+ }
2027
+
2028
+ test('R-30 T10:台账写失败 ⇒ 没有幽灵 guardPointId(require 档整体中止、零写入、不 fork)', async (t) => {
2029
+ const fx = await makeFixture()
2030
+ t.after(fx.cleanup)
2031
+ const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
2032
+ const captured = await capturePoint(engine, fx)
2033
+ const id = captured.point?.id ?? ''
2034
+ assert.notEqual(id, '')
2035
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
2036
+
2037
+ const ledgerPath = path.join(engine.storeDir, 'index.json')
2038
+ const before = await readText(ledgerPath)
2039
+ const unblock = await blockLedgerWrite(ledgerPath)
2040
+ if (unblock === null) {
2041
+ t.skip('本机无法构造台账写失败(symlink 不可用,或只读目录挡不住原子写的 rename)')
2042
+ return
2043
+ }
2044
+ try {
2045
+ // 前置事实:写台账失败时 capturePoint **不再**回传内存 record
2046
+ // (旧实现回传它,消费方拿 point.id 当「留点成功」⇒ 幽灵 id)
2047
+ const blocked = await capturePoint(engine, fx)
2048
+ assert.equal(blocked.ok, false, JSON.stringify(blocked))
2049
+ assert.equal(blocked.code, 'ledger-write-failed')
2050
+ assert.equal(blocked.point, undefined, '写失败不得回传一个可被误当成功的 record')
2051
+
2052
+ // S5 硬门:留不下救援点 ⇒ 整体中止
2053
+ const rewound = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'require' })
2054
+ assert.equal(rewound.outcome, 'failed', JSON.stringify(rewound))
2055
+ assert.equal(rewound.code, 'guard-failed', String(rewound.detail ?? ''))
2056
+ assert.equal(rewound.guardPointId, undefined, '留不下救援点就绝不能报 guardPointId')
2057
+ assert.equal(rewound.childSessionId, undefined, '中止 ⇒ 不 fork')
2058
+ assert.deepEqual(rewound.segments, [], '中止 ⇒ 一段都不执行(零写入)')
2059
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A2', '零写入')
2060
+
2061
+ // warn 档:按既有策略只要告警继续,但同样**不报** guardPointId
2062
+ const warned = await engine.rewind({ id, userConfirmed: true, segments: ['workspace'] })
2063
+ assert.equal(warned.outcome, 'restored', JSON.stringify(warned))
2064
+ assert.equal(warned.guardPointId, undefined, 'warn 档也不得报幽灵救援点')
2065
+ assert.ok(warned.warnings.some((w) => w.code === 'rollback.warn.guardFailed'), JSON.stringify(warned.warnings))
2066
+ assert.equal(await readText(path.join(fx.workspaceDir, 'a.txt')), 'A1', 'warn 档照常回退工作区')
2067
+ } finally {
2068
+ await unblock()
2069
+ }
2070
+ assert.equal(await readText(ledgerPath), before, '整个过程台账一个字节都没被写')
2071
+ })
2072
+
2073
+ test('R-31 T10:留点成功时 guardPointId 必然可 read(台账里真有它)', async (t) => {
2074
+ const fx = await makeFixture()
2075
+ t.after(fx.cleanup)
2076
+ const engine = makeEngine(fx, { fork: async () => ({ ok: true, childSessionId: 'child-1' }) })
2077
+ const captured = await capturePoint(engine, fx)
2078
+ const id = captured.point?.id ?? ''
2079
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
2080
+
2081
+ // 缺省 warn 档也要求「真的留下救援点」
2082
+ const rewound = await engine.rewind({ id, userConfirmed: true })
2083
+ assert.equal(rewound.outcome, 'restored', JSON.stringify(rewound))
2084
+ const guardId = rewound.guardPointId
2085
+ assert.equal(typeof guardId, 'string', JSON.stringify(rewound))
2086
+ assert.notEqual(guardId, '')
2087
+ const readBack = await engine.read(guardId ?? '')
2088
+ assert.equal(readBack.error, undefined, String(readBack.error ?? ''))
2089
+ assert.equal(readBack.point?.id, guardId, 'guardPointId 必须能在台账里查到')
2090
+ assert.equal(readBack.point?.kind, 'guard')
2091
+ assert.ok((await ledgerOf(engine)).records.some((r) => r.id === guardId))
2092
+
2093
+ // 再回退一次:报出来的每个 id 都要查得到(不是同一个,也不是幽灵)
2094
+ const again = await engine.rewind({ id, userConfirmed: true, segments: ['workspace'] })
2095
+ assert.notEqual(again.guardPointId, guardId)
2096
+ assert.equal((await engine.read(again.guardPointId ?? '')).point?.id, again.guardPointId)
2097
+ })
2098
+
2099
+ test('R-32 T10:台账写失败时 tracker 不得报 captured=true(不能记住幽灵 pointId)', async (t) => {
2100
+ const fx = await makeFixture()
2101
+ t.after(fx.cleanup)
2102
+ const engine = makeEngine(fx)
2103
+ const tracker = new RollbackAutoTracker({ engine })
2104
+ tracker.registerSession({
2105
+ sessionId: 'session-1111',
2106
+ unitId: 'proj-key/session-1111',
2107
+ logPath: fx.logFile,
2108
+ chunks: [fx.workspaceDir],
2109
+ })
2110
+ const first = await tracker.noteEvent({ sessionId: 'session-1111', seq: 5, type: 'turn/start', data: { turn: 1 } })
2111
+ await tracker.noteEvent({ sessionId: 'session-1111', seq: 6, type: 'turn/end', data: { turn: 1 } })
2112
+ assert.equal(first.captured, true, first.detail ?? '')
2113
+ assert.equal(typeof first.pointId, 'string')
2114
+
2115
+ const ledgerPath = path.join(engine.storeDir, 'index.json')
2116
+ const unblock = await blockLedgerWrite(ledgerPath)
2117
+ if (unblock === null) {
2118
+ t.skip('本机无法构造台账写失败(symlink 不可用,或只读目录挡不住原子写的 rename)')
2119
+ return
2120
+ }
2121
+ try {
2122
+ const second = await tracker.noteEvent({ sessionId: 'session-1111', seq: 11, type: 'turn/start', data: { turn: 2 } })
2123
+ assert.equal(second.captured, false, JSON.stringify(second))
2124
+ assert.equal(second.pointId, undefined, '写失败不得报 pointId')
2125
+ assert.equal(second.code, 'ledger-write-failed')
2126
+ assert.equal((await ledgerOf(engine)).records.length, 1, '台账里绝不能出现第二个点')
2127
+ } finally {
2128
+ await unblock()
2129
+ }
2130
+ })
2131
+
2132
+ test('R-33 T10:preview 的 stat 失败语义 —— 只有 ENOENT 才算「当前不存在」', async (t) => {
2133
+ // ① 纯函数口径(本机无法确定性构造 EACCES,故把判定函数本身钉死)
2134
+ assert.equal(classifyPreviewStatFailure({ code: 'ENOENT' }), 'missing')
2135
+ assert.equal(
2136
+ classifyPreviewStatFailure(new Error('ENOENT: no such file or directory, stat x')),
2137
+ 'unavailable',
2138
+ '只看 errno code,不看 message',
2139
+ )
2140
+ for (const code of ['EACCES', 'EPERM', 'ELOOP', 'ENOTDIR', 'EBUSY', 'ERR_INVALID_ARG_VALUE', 'UNKNOWN']) {
2141
+ assert.equal(classifyPreviewStatFailure({ code }), 'unavailable', code)
2142
+ }
2143
+ assert.equal(classifyPreviewStatFailure(null), 'unavailable')
2144
+ assert.equal(classifyPreviewStatFailure('ENOENT'), 'unavailable')
2145
+ assert.equal(classifyPreviewStatFailure({ code: 2 }), 'unavailable')
2146
+
2147
+ // ② 真 fs.stat 的**非 ENOENT** 失败贯穿整条预览:含 NUL 的非法路径 → ERR_INVALID_ARG_VALUE
2148
+ const fx = await makeFixture()
2149
+ t.after(fx.cleanup)
2150
+ const engine = makeEngine(fx)
2151
+ const captured = await capturePoint(engine, fx)
2152
+ const id = captured.point?.id ?? ''
2153
+ const ledgerPath = path.join(engine.storeDir, 'index.json')
2154
+ interface TamperableRecord { id: string; workspace: { payload: { files: { path: string }[] } | null } }
2155
+ const ledger = JSON.parse(await readText(ledgerPath)) as { records: TamperableRecord[] }
2156
+ const record = ledger.records.find((r) => r.id === id)
2157
+ assert.ok(record !== undefined && record.workspace.payload !== null)
2158
+ const victim = record.workspace.payload?.files.find((f) => f.path.endsWith('a.txt'))
2159
+ assert.ok(victim !== undefined)
2160
+ victim.path = path.join(fx.workspaceDir, 'a' + String.fromCharCode(0) + '.txt')
2161
+ await fs.writeFile(ledgerPath, JSON.stringify(ledger))
2162
+
2163
+ const preview = await engine.preview(id)
2164
+ assert.equal(preview.workspace.missing, 0, '非 ENOENT 的 stat 失败**不是**「当前不存在」')
2165
+ assert.equal(preview.workspace.different, 1)
2166
+ assert.equal(preview.workspace.files.length, 1)
2167
+ const entry = preview.workspace.files[0]
2168
+ assert.equal(entry?.status, 'overwrite', '存在性未知 ⇒ 绝不报成 create')
2169
+ assert.equal(entry?.added, null)
2170
+ assert.equal(entry?.removed, null)
2171
+ assert.equal(entry?.truncated, true)
2172
+
2173
+ // ③ 对照:真的不存在(ENOENT)仍然是 create + 快照行数
2174
+ const fresh = await makeFixture()
2175
+ t.after(fresh.cleanup)
2176
+ const engine2 = makeEngine(fresh)
2177
+ const captured2 = await capturePoint(engine2, fresh)
2178
+ await fs.rm(path.join(fresh.workspaceDir, 'a.txt'))
2179
+ const preview2 = await engine2.preview(captured2.point?.id ?? '')
2180
+ assert.equal(preview2.workspace.missing, 1)
2181
+ const gone = preview2.workspace.files.find((f) => f.path.endsWith('a.txt'))
2182
+ assert.equal(gone?.status, 'create')
2183
+ assert.equal(gone?.added, 1)
2184
+ assert.equal(gone?.removed, 0)
2185
+ assert.equal(gone?.truncated, false)
2186
+ })
2187
+
2188
+ test('R-34 T10:工作区段「没记录文件」与「记录但全部一致」必须给不同的 reasonCode', async (t) => {
2189
+ const fx = await makeFixture()
2190
+ t.after(fx.cleanup)
2191
+
2192
+ // ① 记录在案的条目数 = 0(空分块)→ no-recorded-files
2193
+ const emptyDir = path.join(fx.root, 'empty-ws')
2194
+ await fs.mkdir(emptyDir, { recursive: true })
2195
+ const engine = makeEngine(fx)
2196
+ const empty = await engine.capturePoint({ chunks: [emptyDir] })
2197
+ assert.equal(empty.point?.workspace.payload?.files.length, 0)
2198
+ const noFiles = await engine.rewind({
2199
+ id: empty.point?.id ?? '', userConfirmed: true, allowPartial: true, segments: ['workspace'], guardPolicy: 'off',
2200
+ })
2201
+ const noFilesSegment = noFiles.segments.find((s) => s.state === 'workspace')
2202
+ assert.equal(noFilesSegment?.reasonCode, 'no-recorded-files', JSON.stringify(noFilesSegment))
2203
+ assert.equal(noFilesSegment?.status, 'restored')
2204
+ assert.equal(noFilesSegment?.restored, 0)
2205
+ assert.equal(noFiles.outcome, 'restored')
2206
+ assert.equal(noFiles.code, 'nothing-to-restore', '整体码不变(「没有可恢复的内容」)')
2207
+
2208
+ // ② 有记录、但全部已与快照一致 → nothing-to-restore(**不是**同一个码)
2209
+ const sameDir = path.join(fx.root, 'same-ws')
2210
+ await fs.mkdir(sameDir, { recursive: true })
2211
+ await fs.writeFile(path.join(sameDir, 'x.txt'), 'X1\n')
2212
+ const engine2 = makeEngine(fx)
2213
+ const same = await engine2.capturePoint({ chunks: [sameDir] })
2214
+ assert.equal(same.point?.workspace.payload?.files.length, 1)
2215
+ const unchanged = await engine2.rewind({
2216
+ id: same.point?.id ?? '', userConfirmed: true, allowPartial: true, segments: ['workspace'], guardPolicy: 'off',
2217
+ })
2218
+ const unchangedSegment = unchanged.segments.find((s) => s.state === 'workspace')
2219
+ assert.equal(unchangedSegment?.reasonCode, 'nothing-to-restore', JSON.stringify(unchangedSegment))
2220
+ assert.equal(unchangedSegment?.restored, 0)
2221
+ assert.notEqual(
2222
+ unchangedSegment?.reasonCode,
2223
+ noFilesSegment?.reasonCode,
2224
+ '两条路径必须是不同的码(界面据此说两句不同的话,不靠 detail 分派)',
2225
+ )
2226
+ })
2227
+
2228
+ /* ============ t2:路径身份缓存 / 忽略规则 / 容量上限(回退引擎性能与安全) ============ */
2229
+
2230
+ /**
2231
+ * 注入「工作区内容读取」计数器:每次真读都记一条,并(可选)记录并发驻留量。
2232
+ * 只在真读那一刻记录 —— 缓存命中时本函数根本不会被调用,这正是「零重读」的判据。
2233
+ */
2234
+ function countingReader(): {
2235
+ seam: (file: string) => Promise<Uint8Array>
2236
+ reads: string[]
2237
+ reset: () => void
2238
+ peakLive: number
2239
+ peakSingle: number
2240
+ } {
2241
+ const reads: string[] = []
2242
+ const state = { live: 0, peakLive: 0, peakSingle: 0 }
2243
+ return {
2244
+ reads,
2245
+ get peakLive() { return state.peakLive },
2246
+ get peakSingle() { return state.peakSingle },
2247
+ reset() { reads.length = 0; state.peakLive = 0; state.peakSingle = 0 },
2248
+ seam: async (file: string) => {
2249
+ const bytes = await fs.readFile(file)
2250
+ reads.push(file)
2251
+ state.live += bytes.byteLength
2252
+ state.peakLive = Math.max(state.peakLive, state.live)
2253
+ state.peakSingle = Math.max(state.peakSingle, bytes.byteLength)
2254
+ state.live -= bytes.byteLength
2255
+ return bytes
2256
+ },
2257
+ }
2258
+ }
2259
+
2260
+ async function cacheFileOf(engine: RollbackPointEngine): Promise<{ version: number; entries: Record<string, { path: string; sha256: string; bytes: number }> }> {
2261
+ return JSON.parse(await readText(path.join(engine.storeDir, 'path-cache.json'))) as { version: number; entries: Record<string, { path: string; sha256: string; bytes: number }> }
2262
+ }
2263
+
2264
+ test('T2-A 路径身份缓存:身份未变 ⇒ 第二次留点零重读;只改一个文件 ⇒ 只有该文件重读', async (t) => {
2265
+ const fx = await makeFixture()
2266
+ t.after(fx.cleanup)
2267
+ const reader = countingReader()
2268
+
2269
+ // ① 首次留点:两个文件都必须真读(还没有任何缓存)
2270
+ const engine1 = makeEngine(fx, { readFile: reader.seam })
2271
+ const first = await capturePoint(engine1, fx)
2272
+ assert.equal(first.ok, true, first.detail ?? first.code)
2273
+ assert.deepEqual(
2274
+ [...reader.reads].sort(),
2275
+ [path.join(fx.workspaceDir, 'a.txt'), path.join(fx.workspaceDir, 'src', 'b.txt')].sort(),
2276
+ '首次留点必须真读两个文件(缓存为空)',
2277
+ )
2278
+ const firstFiles = first.point?.workspace.payload?.files ?? []
2279
+ assert.equal(firstFiles.length, 2)
2280
+
2281
+ // 缓存必须落盘(只读不写的缓存等于没有)
2282
+ const cache1 = await cacheFileOf(engine1)
2283
+ assert.equal(cache1.version, 1)
2284
+ assert.equal(Object.keys(cache1.entries).length, 2, '两条身份都要进缓存')
2285
+ for (const entry of Object.values(cache1.entries)) {
2286
+ assert.match(entry.sha256, /^[0-9a-f]{64}$/)
2287
+ assert.ok(entry.bytes > 0)
2288
+ assert.ok(entry.path.startsWith(fx.workspaceDir))
2289
+ }
2290
+
2291
+ // ② 换一个**新引擎实例**(只能从盘上读缓存)再留一次点:身份未变 ⇒ 0 次读
2292
+ const engine2 = makeEngine(fx, { readFile: reader.seam })
2293
+ reader.reset()
2294
+ const second = await capturePoint(engine2, fx, { sessionId: 's-cache-2' })
2295
+ assert.equal(second.ok, true, second.detail ?? second.code)
2296
+ assert.equal(reader.reads.length, 0, '身份未变的文件必须零重读(缓存命中):' + JSON.stringify(reader.reads))
2297
+ // 复用的 sha256 / 字节数与首次逐条一致(不是重算出来的,是缓存里的)
2298
+ const secondFiles = second.point?.workspace.payload?.files ?? []
2299
+ assert.deepEqual(
2300
+ secondFiles.map((f) => [f.path, f.bytes, f.sha256]).sort(),
2301
+ firstFiles.map((f) => [f.path, f.bytes, f.sha256]).sort(),
2302
+ '缓存命中的条目必须与首次逐条一致(复用 sha256,不重算)',
2303
+ )
2304
+
2305
+ // ③ 只改一个文件 ⇒ 只有它重读(另一个仍然命中)
2306
+ await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2-CHANGED')
2307
+ const engine3 = makeEngine(fx, { readFile: reader.seam })
2308
+ reader.reset()
2309
+ const third = await capturePoint(engine3, fx, { sessionId: 's-cache-3' })
2310
+ assert.equal(third.ok, true, third.detail ?? third.code)
2311
+ assert.deepEqual(reader.reads, [path.join(fx.workspaceDir, 'a.txt')], '只有被改的那个文件重读')
2312
+ const aFile = (third.point?.workspace.payload?.files ?? []).find((f) => f.path.endsWith('a.txt'))
2313
+ assert.equal(aFile?.sha256, sha256Hex('A2-CHANGED'), '重读后 sha256 必须更新(缓存绝不返回旧哈希)')
2314
+
2315
+ // ④ 缓存是纯优化:形状不符(版本不是 1)必须整体丢弃并回落全量读
2316
+ await fs.writeFile(path.join(engine3.storeDir, 'path-cache.json'), JSON.stringify({ version: 2, entries: {} }))
2317
+ const engine4 = makeEngine(fx, { readFile: reader.seam })
2318
+ reader.reset()
2319
+ const fourth = await capturePoint(engine4, fx, { sessionId: 's-cache-4' })
2320
+ assert.equal(fourth.ok, true)
2321
+ assert.equal(reader.reads.length, 2, '坏缓存必须整体丢弃 ⇒ 两个文件都真读')
2322
+ })
2323
+
2324
+ test('T2-B 缓存命中与未命中两条路径下,删除候选判定(预览 removals / createdSkipped)逐条相同', async (t) => {
2325
+ const fx = await makeFixture()
2326
+ t.after(fx.cleanup)
2327
+ const reader = countingReader()
2328
+ const engine = makeEngine(fx, { readFile: reader.seam })
2329
+ // 留点时:既有文件若干(含 .git 拒删项)
2330
+ await fs.mkdir(path.join(fx.workspaceDir, '.git'), { recursive: true })
2331
+ await fs.writeFile(path.join(fx.workspaceDir, '.git', 'index'), 'GIT')
2332
+ const captured = await capturePoint(engine, fx)
2333
+ const id = captured.point?.id ?? ''
2334
+ assert.notEqual(id, '')
2335
+
2336
+ // 点之后:新建两条可删的 + 一条 .git 内的拒删项
2337
+ const created = [
2338
+ path.join(fx.workspaceDir, 'new-1.txt'),
2339
+ path.join(fx.workspaceDir, 'new-2.txt'),
2340
+ ]
2341
+ await fs.writeFile(created[0] ?? '', 'N1')
2342
+ await fs.writeFile(created[1] ?? '', 'N2')
2343
+ await fs.writeFile(path.join(fx.workspaceDir, '.git', 'mid'), 'GIT2')
2344
+
2345
+ // ① 命中路径:缓存可用
2346
+ const hit = await engine.preview(id)
2347
+ assert.equal(hit.ok, true)
2348
+ assert.deepEqual(hit.workspace.removals, created, '两条新建路径都必须被列为会删')
2349
+ assert.equal(hit.workspace.createdSkippedCount, 1, '.git 内的拒删项如实计入')
2350
+
2351
+ // ② 未命中路径:删掉缓存文件 + 用新实例(只能全量真读)
2352
+ await fs.rm(path.join(engine.storeDir, 'path-cache.json'), { force: true })
2353
+ const miss = makeEngine(fx, { readFile: reader.seam })
2354
+ const missed = await miss.preview(id)
2355
+ assert.equal(missed.ok, true)
2356
+ assert.deepEqual(missed.workspace.removals, hit.workspace.removals, '命中 / 未命中两条路径的删除清单必须逐条相同')
2357
+ assert.deepEqual(missed.workspace.createdSkipped, hit.workspace.createdSkipped, '拒删清单也必须逐条相同')
2358
+ assert.equal(missed.workspace.createdAfter, hit.workspace.createdAfter)
2359
+
2360
+ // ③ 执行端同样一致:真的删掉的就是预览列的那两条
2361
+ const rewound = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true })
2362
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
2363
+ assert.deepEqual(ws?.removedPaths, hit.workspace.removals, '预览说会删的 = 实际删的')
2364
+ for (const file of created) await assert.rejects(fs.readFile(file), { code: 'ENOENT' })
2365
+ })
2366
+
2367
+ test('T2-C 忽略规则:整个目录被忽略 ⇒ 证明名单不完整 ⇒ 删除整体拒绝(fail-closed,预览如实说明)', async (t) => {
2368
+ const fx = await makeFixture()
2369
+ t.after(fx.cleanup)
2370
+ const dir = await makeSizeDir(fx, 'cap-ignore-dir', { 'keep.txt': 3, 'note.md': 3 })
2371
+ const buildDir = path.join(dir, 'build')
2372
+ await fs.mkdir(buildDir, { recursive: true })
2373
+ await fs.writeFile(path.join(buildDir, 'artifact.bin'), 'BIN')
2374
+ // 分块根的忽略规则:整个 build/ 目录被忽略
2375
+ await fs.writeFile(path.join(dir, '.dsh-rollback-ignore'), 'build/' + '\n')
2376
+
2377
+ const engine = makeEngine(fx)
2378
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-ig' })
2379
+ const payload = captured.point?.workspace.payload
2380
+ assert.ok(payload !== null && payload !== undefined)
2381
+ // 被忽略目录如实留痕,并且**没有**被走查(其下文件不进 files / skipped)
2382
+ assert.equal(payload.ignoreIncomplete, true, '整目录被忽略 ⇒ 标记证明名单不完整')
2383
+ assert.equal(payload.ignoredDirCount, 1)
2384
+ assert.deepEqual((payload.ignoredDirs ?? []).map((e) => e.path), [buildDir])
2385
+ assert.equal(payload.files.some((f) => f.path.startsWith(buildDir)), false, '被忽略目录整棵不进快照')
2386
+
2387
+ // 点之后:新建一条根级文件 + 一条 build 内的文件
2388
+ const createdRoot = path.join(dir, 'created-root.txt')
2389
+ const createdInBuild = path.join(buildDir, 'created-in-build.txt')
2390
+ await fs.writeFile(createdRoot, 'NEW')
2391
+ await fs.writeFile(createdInBuild, 'NEW-IN-BUILD')
2392
+
2393
+ // 预览如实说明:删除被整体拒绝、原因 = 忽略目录
2394
+ const preview = await engine.preview(captured.point?.id ?? '')
2395
+ assert.equal(preview.workspace.createdRefusedReason, 'capture-incomplete')
2396
+ assert.deepEqual(preview.workspace.removals, [], '证明名单不完整 ⇒ 预览的删除清单必须是空的')
2397
+ const warn = preview.warnings.find((w) => w.code === 'rollback.warn.createdRefused')
2398
+ assert.equal(warn?.detail, 'ignore-dirs', '成因必须如实回传(忽略目录)')
2399
+
2400
+ // 执行:一条都不删
2401
+ const rewound = await engine.rewind({
2402
+ id: captured.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true,
2403
+ })
2404
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
2405
+ assert.equal(ws?.createdRefusedReason, 'capture-incomplete')
2406
+ assert.equal(ws?.removed ?? 0, 0, '证明名单不完整 ⇒ 一条都不删')
2407
+ assert.equal(await readText(createdRoot), 'NEW')
2408
+ assert.equal(await readText(createdInBuild), 'NEW-IN-BUILD')
2409
+ assert.ok((ws?.createdSkippedCount ?? 0) > 0, '候选必须如实进拒删清单')
2410
+ })
2411
+
2412
+ test('T2-D 忽略规则:文件级忽略仍保持证明名单完整 ⇒ 删除照常;内置 node_modules 规则不可反向取消', async (t) => {
2413
+ const fx = await makeFixture()
2414
+ t.after(fx.cleanup)
2415
+ const dir = await makeSizeDir(fx, 'cap-ignore-file', { 'keep.txt': 3 })
2416
+ await fs.writeFile(path.join(dir, 'old.log'), 'OLD')
2417
+ // 文件级忽略 + 一条试图反向取消内置 node_modules 的规则
2418
+ await fs.writeFile(path.join(dir, '.dsh-rollback-ignore'), '*.log' + '\n' + '!node_modules' + '\n')
2419
+ const nm = path.join(dir, 'node_modules', 'dep')
2420
+ await fs.mkdir(nm, { recursive: true })
2421
+ await fs.writeFile(path.join(nm, 'index.js'), 'DEP')
2422
+
2423
+ const engine = makeEngine(fx)
2424
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-igf' })
2425
+ const payload = captured.point?.workspace.payload
2426
+ assert.ok(payload !== null && payload !== undefined)
2427
+ assert.equal(payload.ignoreIncomplete ?? false, false, '文件级忽略不使证明名单不完整')
2428
+ // 文件级忽略逐条进 skipped(沿用 skipped 语义),于是它仍在证明名单里
2429
+ assert.ok(payload.skipped.some((e) => e.path.endsWith('old.log') && e.reason === 'ignored'), JSON.stringify(payload.skipped))
2430
+ // 内置规则不可反向取消:node_modules 仍然整棵忽略(走 excludedDirs)
2431
+ assert.ok(payload.excludedDirs.some((e) => e.path === path.join(dir, 'node_modules')), 'node_modules 必须仍被内置规则整棵忽略')
2432
+ assert.equal(payload.files.some((f) => f.path.startsWith(path.join(dir, 'node_modules'))), false)
2433
+
2434
+ // 点之后:新建 .txt(可删)+ 新建 .log(被忽略 ⇒ 不进候选 ⇒ 不删)
2435
+ const createdTxt = path.join(dir, 'created.txt')
2436
+ const createdLog = path.join(dir, 'created.log')
2437
+ await fs.writeFile(createdTxt, 'NEW')
2438
+ await fs.writeFile(createdLog, 'NEW-LOG')
2439
+ const preview = await engine.preview(captured.point?.id ?? '')
2440
+ assert.equal(preview.workspace.createdRefusedReason, null, '证明名单完整 ⇒ 不拒删')
2441
+ assert.deepEqual(preview.workspace.removals, [createdTxt], '只有非忽略的新建路径进删除清单')
2442
+
2443
+ const rewound = await engine.rewind({
2444
+ id: captured.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true,
2445
+ })
2446
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
2447
+ assert.equal(ws?.removed, 1)
2448
+ await assert.rejects(fs.readFile(createdTxt), { code: 'ENOENT' })
2449
+ assert.equal(await readText(createdLog), 'NEW-LOG', '被忽略的路径不进候选 ⇒ 绝不删它')
2450
+ })
2451
+
2452
+ test('T2-E 内存硬约束:同时驻留的文件内容 ≤ maxFileBytes(绝不把整仓一次读进内存)', async (t) => {
2453
+ const fx = await makeFixture()
2454
+ t.after(fx.cleanup)
2455
+ // 10 个小文件 + 1 个超单文件上限的大文件;上限 64 字节
2456
+ const sizes: Record<string, number> = {}
2457
+ for (let i = 0; i < 10; i += 1) sizes['small-' + String(i) + '.txt'] = 32
2458
+ sizes['huge.txt'] = 4096
2459
+ const dir = await makeSizeDir(fx, 'cap-mem', sizes)
2460
+ const reader = countingReader()
2461
+ const engine = makeEngine(fx, { maxFileBytes: 64, maxBytes: 1024 * 1024, readFile: reader.seam })
2462
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-mem' })
2463
+ const payload = captured.point?.workspace.payload
2464
+ assert.ok(payload !== null && payload !== undefined)
2465
+ // 超限文件必须先 stat 判大小后跳过 —— 计数器里绝不能出现它(「先 stat 再决定读不读」)
2466
+ assert.equal(reader.reads.includes(path.join(dir, 'huge.txt')), false, '超限文件绝不整份读进内存')
2467
+ assert.ok(payload.skipped.some((e) => e.path.endsWith('huge.txt') && e.reason === 'too-large'))
2468
+ assert.equal(payload.files.length, 10)
2469
+ // 同时驻留:单份 ≤ maxFileBytes,且从不并发驻留两份(走查是逐条串行的)
2470
+ assert.ok(reader.peakSingle <= 64, '单份驻留 = ' + String(reader.peakSingle) + ' > maxFileBytes=64')
2471
+ assert.ok(
2472
+ reader.peakLive <= 64,
2473
+ '同时驻留内容 = ' + String(reader.peakLive) + ' 字节,必须 ≤ maxFileBytes(64) + 常量(0)',
2474
+ )
2475
+ })
2476
+
2477
+ test('T2-F 容量缺省:maxFiles 20000 / maxBytes 512 MiB,且超单文件上限继续跳过并逐条留痕', async (t) => {
2478
+ const fx = await makeFixture()
2479
+ t.after(fx.cleanup)
2480
+ assert.equal(ROLLBACK_DEFAULT_MAX_FILES, 20000)
2481
+ assert.equal(ROLLBACK_DEFAULT_MAX_BYTES, 512 * 1024 * 1024)
2482
+
2483
+ // 行为面:2100 个文件(超过**旧**缺省 2000)在缺省引擎下必须全部收进快照、且不截断
2484
+ const dir = await makeSizeDir(fx, 'cap-default', {})
2485
+ const names = Array.from({ length: 2100 }, (_, i) => 'f-' + String(i).padStart(5, '0') + '.txt')
2486
+ for (const name of names) await fs.writeFile(path.join(dir, name), 'y')
2487
+ const engine = makeEngine(fx)
2488
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-cap' })
2489
+ const payload = captured.point?.workspace.payload
2490
+ assert.ok(payload !== null && payload !== undefined)
2491
+ assert.equal(payload.files.length, 2100, '缺省 20000 ⇒ 2100 个文件全部入快照(旧缺省 2000 会截断)')
2492
+ assert.equal(payload.truncated, false)
2493
+ // 超单文件上限的条目仍逐条留痕(行为不变)
2494
+ const big = path.join(dir, 'big.bin')
2495
+ await fs.writeFile(big, 'z'.repeat(200))
2496
+ const engine2 = makeEngine(fx, { maxFileBytes: 64 })
2497
+ const captured2 = await engine2.capturePoint({ chunks: [dir], sessionId: 's-cap-2' })
2498
+ const payload2 = captured2.point?.workspace.payload
2499
+ assert.ok(payload2 !== null && payload2 !== undefined)
2500
+ assert.ok(payload2.skipped.some((e) => e.path === big && e.reason === 'too-large'), '超单文件上限必须逐条留痕')
2501
+ assert.equal(payload2.files.some((f) => f.path === big), false)
2502
+ })
2503
+
2504
+ /**
2505
+ * t12 回归(F-1):文件级忽略命中的路径也必须进 walked(路径索引来源)。
2506
+ * 否则 skipped 被记录上限截断后,「留点时已存在但被忽略」的文件会被当成新建删掉。
2507
+ * 复现:maxSkippedEntries=2 + 10 个被忽略的既有 *.log + 捕获后删掉规则文件。
2508
+ */
2509
+ test('t12 文件级忽略 + 记录上限:既有文件绝不因忽略规则变更被当成新建删掉', async (t) => {
2510
+ const fx = await makeFixture()
2511
+ t.after(fx.cleanup)
2512
+ const dir = await makeSizeDir(fx, 'cap-ignore-trunc', { 'keep.txt': 1 })
2513
+ const oldLogs: string[] = []
2514
+ for (let i = 0; i < 10; i += 1) {
2515
+ const p = path.join(dir, 'old-' + String(i) + '.log')
2516
+ await fs.writeFile(p, 'LOG' + String(i))
2517
+ oldLogs.push(p)
2518
+ }
2519
+ const rules = path.join(dir, '.dsh-rollback-ignore')
2520
+ await fs.writeFile(rules, '*.log\n')
2521
+ const engine = makeEngine(fx, { maxSkippedEntries: 2 })
2522
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-ig-trunc' })
2523
+ const payload = captured.point?.workspace.payload
2524
+ assert.ok(payload !== null && payload !== undefined)
2525
+ assert.equal(payload.skippedTruncated, true, '10 个被忽略文件打满 2 条记录上限 ⇒ 名单被截断')
2526
+ // 关键:路径索引必须覆盖被忽略的既有文件(F-1 修复点)
2527
+ assert.ok((payload.pathIndex?.count ?? 0) >= 12, '路径索引必须含 10 个被忽略文件 + 规则文件 + keep.txt,实际 ' + String(payload.pathIndex?.count))
2528
+ // 用户随后不再忽略 *.log(删规则)——这 10 个文件在留点时刻确实存在
2529
+ await fs.rm(rules, { force: true })
2530
+ const created = path.join(dir, 'brand-new.txt')
2531
+ await fs.writeFile(created, 'NEW')
2532
+ const rewound = await engine.rewind({ id: captured.point?.id ?? '', userConfirmed: true, guardPolicy: 'off', mode: 'files', removeCreated: true })
2533
+ const ws = rewound.segments.find((s) => s.state === 'workspace')
2534
+ const deletedOld = (ws?.removedPaths ?? []).filter((f) => f.endsWith('.log'))
2535
+ assert.deepEqual(deletedOld, [], '留点时已存在的 .log 一条都不许删(实际删了 ' + String(deletedOld.length) + ' 条)')
2536
+ for (const p of oldLogs) assert.equal(await readText(p) !== '', true, '既有 .log 必须原样保留:' + p)
2537
+ assert.ok((ws?.removedPaths ?? []).includes(created), '留点后新建的 brand-new.txt 仍必须被删(不过度拒绝)')
2538
+ })
2539
+
2540
+ /**
2541
+ * t14 回归(F-6):两个桶受记录上限截断时,**计数必须取恒增计数器**(绝不等于被截断的列表长度);
2542
+ * 且 maxSkippedEntries=0 时 ignoreIncomplete 仍必须为 true —— 否则删除集的 fail-closed 会静默失效。
2543
+ */
2544
+ test('t14 忽略目录计数取恒增计数器:列表可截断、计数与 ignoreIncomplete 仍如实', async (t) => {
2545
+ const fx = await makeFixture()
2546
+ t.after(fx.cleanup)
2547
+ const dir = await makeSizeDir(fx, 'cap-ignore-count', { 'keep.txt': 1 })
2548
+ const buildDirs = ['build-a', 'build-b', 'build-c']
2549
+ for (const name of buildDirs) {
2550
+ const full = path.join(dir, name)
2551
+ await fs.mkdir(full, { recursive: true })
2552
+ await fs.writeFile(path.join(full, 'artifact.bin'), 'BIN')
2553
+ }
2554
+ await fs.writeFile(path.join(dir, '.dsh-rollback-ignore'), buildDirs.map((n) => n + '/').join('\n') + '\n')
2555
+
2556
+ // ① 记录上限 1:逐条列表被截断,计数与 ignoreIncomplete 仍如实
2557
+ const engine = makeEngine(fx, { maxSkippedEntries: 1 })
2558
+ const captured = await engine.capturePoint({ chunks: [dir], sessionId: 's-f6' })
2559
+ const payload = captured.point?.workspace.payload
2560
+ assert.ok(payload !== null && payload !== undefined)
2561
+ assert.equal(payload.ignoredDirs.length, 1, '逐条列表受记录上限约束(只留 1 条)')
2562
+ assert.equal(payload.ignoredDirCount, 3, '计数必须是真实总数(恒增计数器),不是被截断的列表长度')
2563
+ assert.equal(payload.ignoreIncomplete, true, '有目录被忽略 ⇒ 证明名单不完整 ⇒ 删除整体拒绝')
2564
+
2565
+ // ② 记录上限 0:列表为空但 ignoreIncomplete 绝不静默变 false(fail-closed)
2566
+ const engine0 = makeEngine(fx, { maxSkippedEntries: 0 })
2567
+ const captured0 = await engine0.capturePoint({ chunks: [dir], sessionId: 's-f6b' })
2568
+ const payload0 = captured0.point?.workspace.payload
2569
+ assert.ok(payload0 !== null && payload0 !== undefined)
2570
+ assert.equal(payload0.ignoredDirs.length, 0, '上限 0 ⇒ 一条列表都不留')
2571
+ assert.equal(payload0.ignoredDirCount, 3, '计数仍必须是 3')
2572
+ assert.equal(payload0.ignoreIncomplete, true, 'maxSkippedEntries=0 时 fail-closed 不得静默失效')
2573
+ // ③ 删除仍整体拒绝(不看列表长度)
2574
+ await fs.rm(path.join(dir, '.dsh-rollback-ignore'), { force: true })
2575
+ await fs.writeFile(path.join(dir, 'brand-new.txt'), 'NEW')
2576
+ const preview = await engine0.preview(captured0.point?.id ?? '')
2577
+ assert.equal(preview.workspace.createdRefusedReason, 'capture-incomplete', '上限 0 也必须拒删')
2578
+ assert.deepEqual(preview.workspace.removals, [], '拒删 ⇒ 删除清单为空')
2579
+ })
2580
+
2581
+ test('T2-G 路径身份缓存随 gc 回收:删除回滚点后,缓存里不再留它的条目', async (t) => {
2582
+ const fx = await makeFixture()
2583
+ t.after(fx.cleanup)
2584
+ const engine = makeEngine(fx)
2585
+ const first = await capturePoint(engine, fx, { sessionId: 's-gc-1' })
2586
+ const cacheAfterFirst = await cacheFileOf(engine)
2587
+ assert.equal(Object.keys(cacheAfterFirst.entries).length, 2)
2588
+
2589
+ // 第二个点(换会话,避免被每会话 20 条上限裁掉)
2590
+ const second = await capturePoint(engine, fx, { sessionId: 's-gc-2' })
2591
+ assert.equal(Object.keys((await cacheFileOf(engine)).entries).length, 2, '同一批路径的身份键复用同两条')
2592
+
2593
+ // 删掉第一个点:它引用的路径不再被台账引用 ⇒ 缓存条目随之回收
2594
+ const removed = await engine.remove(first.point?.id ?? '')
2595
+ assert.equal(removed.ok, true)
2596
+ const cacheAfterRemove = await cacheFileOf(engine)
2597
+ assert.equal(Object.keys(cacheAfterRemove.entries).length, 2, '第二个点仍引用同一批路径 ⇒ 条目保留')
2598
+ assert.equal((await engine.list()).points.some((pt) => pt.id === second.point?.id), true)
2599
+
2600
+ // 删掉最后一个点:台账里没有任何引用 ⇒ 缓存清空(不无界增长)
2601
+ assert.equal((await engine.remove(second.point?.id ?? '')).ok, true)
2602
+ assert.equal(Object.keys((await cacheFileOf(engine)).entries).length, 0, '没有引用者 ⇒ 缓存条目必须被回收')
2603
+ })