@logictan/dsh-config-manager 0.1.60

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 (429) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/cordis.patch.yml +21 -0
  4. package/lib/adapters/agent-instructions.d.ts +13 -0
  5. package/lib/adapters/agent-instructions.js +46 -0
  6. package/lib/adapters/agent-presets.d.ts +13 -0
  7. package/lib/adapters/agent-presets.js +14 -0
  8. package/lib/adapters/credentials.d.ts +37 -0
  9. package/lib/adapters/credentials.js +122 -0
  10. package/lib/adapters/file-collection.d.ts +15 -0
  11. package/lib/adapters/file-collection.js +127 -0
  12. package/lib/adapters/index.d.ts +57 -0
  13. package/lib/adapters/index.js +55 -0
  14. package/lib/adapters/link-report.d.ts +16 -0
  15. package/lib/adapters/link-report.js +47 -0
  16. package/lib/adapters/mcp.d.ts +28 -0
  17. package/lib/adapters/mcp.js +178 -0
  18. package/lib/adapters/plugin-files.d.ts +18 -0
  19. package/lib/adapters/plugin-files.js +142 -0
  20. package/lib/adapters/plugins.d.ts +41 -0
  21. package/lib/adapters/plugins.js +603 -0
  22. package/lib/adapters/pnpm-workspace.d.ts +26 -0
  23. package/lib/adapters/pnpm-workspace.js +147 -0
  24. package/lib/adapters/prompts.d.ts +30 -0
  25. package/lib/adapters/prompts.js +231 -0
  26. package/lib/adapters/providers.d.ts +34 -0
  27. package/lib/adapters/providers.js +155 -0
  28. package/lib/adapters/self.d.ts +17 -0
  29. package/lib/adapters/self.js +71 -0
  30. package/lib/adapters/sessions.d.ts +14 -0
  31. package/lib/adapters/sessions.js +15 -0
  32. package/lib/adapters/settings.d.ts +28 -0
  33. package/lib/adapters/settings.js +165 -0
  34. package/lib/adapters/skills.d.ts +13 -0
  35. package/lib/adapters/skills.js +14 -0
  36. package/lib/adapters/test-helpers.d.ts +125 -0
  37. package/lib/adapters/test-helpers.js +236 -0
  38. package/lib/adapters/ui.d.ts +27 -0
  39. package/lib/adapters/ui.js +89 -0
  40. package/lib/adapters/workspaces.d.ts +13 -0
  41. package/lib/adapters/workspaces.js +106 -0
  42. package/lib/client.d.ts +1026 -0
  43. package/lib/client.js +10047 -0
  44. package/lib/core/analyzer.d.ts +99 -0
  45. package/lib/core/analyzer.js +934 -0
  46. package/lib/core/backup.d.ts +138 -0
  47. package/lib/core/backup.js +513 -0
  48. package/lib/core/boot-rescue.d.ts +146 -0
  49. package/lib/core/boot-rescue.js +618 -0
  50. package/lib/core/cache-cleaner.d.ts +31 -0
  51. package/lib/core/cache-cleaner.js +95 -0
  52. package/lib/core/config-lifecycle.d.ts +180 -0
  53. package/lib/core/config-lifecycle.js +481 -0
  54. package/lib/core/config-snapshot.d.ts +135 -0
  55. package/lib/core/config-snapshot.js +424 -0
  56. package/lib/core/config-state.d.ts +70 -0
  57. package/lib/core/config-state.js +177 -0
  58. package/lib/core/consult-source.d.ts +58 -0
  59. package/lib/core/consult-source.js +260 -0
  60. package/lib/core/crash-report.d.ts +84 -0
  61. package/lib/core/crash-report.js +317 -0
  62. package/lib/core/exporter.d.ts +68 -0
  63. package/lib/core/exporter.js +375 -0
  64. package/lib/core/ghost-sweep.d.ts +19 -0
  65. package/lib/core/ghost-sweep.js +52 -0
  66. package/lib/core/importer.d.ts +60 -0
  67. package/lib/core/importer.js +29 -0
  68. package/lib/core/index.d.ts +14 -0
  69. package/lib/core/index.js +14 -0
  70. package/lib/core/journal.d.ts +197 -0
  71. package/lib/core/journal.js +418 -0
  72. package/lib/core/local-plugin-host.d.ts +35 -0
  73. package/lib/core/local-plugin-host.js +120 -0
  74. package/lib/core/local-plugin-pack.d.ts +135 -0
  75. package/lib/core/local-plugin-pack.js +278 -0
  76. package/lib/core/messages.d.ts +310 -0
  77. package/lib/core/messages.js +621 -0
  78. package/lib/core/migration-consult.d.ts +117 -0
  79. package/lib/core/migration-consult.js +363 -0
  80. package/lib/core/migration-history.d.ts +153 -0
  81. package/lib/core/migration-history.js +411 -0
  82. package/lib/core/model-tools.d.ts +50 -0
  83. package/lib/core/model-tools.js +148 -0
  84. package/lib/core/msg-types.d.ts +8 -0
  85. package/lib/core/msg-types.js +6 -0
  86. package/lib/core/phase3-child-crash.d.ts +1 -0
  87. package/lib/core/phase3-child-crash.js +87 -0
  88. package/lib/core/phase3-host.d.ts +138 -0
  89. package/lib/core/phase3-host.js +359 -0
  90. package/lib/core/phase3-prod-child.d.ts +1 -0
  91. package/lib/core/phase3-prod-child.js +35 -0
  92. package/lib/core/phase4-crash-child.d.ts +1 -0
  93. package/lib/core/phase4-crash-child.js +103 -0
  94. package/lib/core/plugin-cli.d.ts +150 -0
  95. package/lib/core/plugin-cli.js +497 -0
  96. package/lib/core/reconcile.d.ts +120 -0
  97. package/lib/core/reconcile.js +389 -0
  98. package/lib/core/recovery-orchestrator.d.ts +94 -0
  99. package/lib/core/recovery-orchestrator.js +327 -0
  100. package/lib/core/restore.d.ts +162 -0
  101. package/lib/core/restore.js +703 -0
  102. package/lib/core/rollback.d.ts +12 -0
  103. package/lib/core/rollback.js +145 -0
  104. package/lib/core/run-registry.d.ts +85 -0
  105. package/lib/core/run-registry.js +157 -0
  106. package/lib/core/startup-barrier.d.ts +53 -0
  107. package/lib/core/startup-barrier.js +77 -0
  108. package/lib/core/types.d.ts +515 -0
  109. package/lib/core/types.js +27 -0
  110. package/lib/core/undo.d.ts +73 -0
  111. package/lib/core/undo.js +87 -0
  112. package/lib/core/validator.d.ts +18 -0
  113. package/lib/core/validator.js +77 -0
  114. package/lib/core/verify-recovery.d.ts +21 -0
  115. package/lib/core/verify-recovery.js +256 -0
  116. package/lib/core/watcher.d.ts +101 -0
  117. package/lib/core/watcher.js +219 -0
  118. package/lib/index.d.ts +238 -0
  119. package/lib/index.js +2764 -0
  120. package/lib/migrations/index.d.ts +29 -0
  121. package/lib/migrations/index.js +56 -0
  122. package/lib/migrations/v1-to-v2.d.ts +9 -0
  123. package/lib/migrations/v1-to-v2.js +10 -0
  124. package/lib/profiles/index.d.ts +4 -0
  125. package/lib/profiles/index.js +5 -0
  126. package/lib/profiles/profile-manager.d.ts +106 -0
  127. package/lib/profiles/profile-manager.js +543 -0
  128. package/lib/schema/config.d.ts +28 -0
  129. package/lib/schema/config.js +165 -0
  130. package/lib/schema/index.d.ts +24 -0
  131. package/lib/schema/index.js +26 -0
  132. package/lib/schema/manifest.d.ts +28 -0
  133. package/lib/schema/manifest.js +109 -0
  134. package/lib/schema/tombstones.d.ts +46 -0
  135. package/lib/schema/tombstones.js +64 -0
  136. package/lib/schema/types.d.ts +232 -0
  137. package/lib/schema/types.js +8 -0
  138. package/lib/schema/versions.d.ts +27 -0
  139. package/lib/schema/versions.js +50 -0
  140. package/lib/security/encryption.d.ts +85 -0
  141. package/lib/security/encryption.js +279 -0
  142. package/lib/security/index.d.ts +16 -0
  143. package/lib/security/index.js +17 -0
  144. package/lib/security/integrity.d.ts +30 -0
  145. package/lib/security/integrity.js +78 -0
  146. package/lib/security/redaction.d.ts +26 -0
  147. package/lib/security/redaction.js +95 -0
  148. package/lib/security/secret-scanner.d.ts +86 -0
  149. package/lib/security/secret-scanner.js +404 -0
  150. package/lib/security/vault.d.ts +59 -0
  151. package/lib/security/vault.js +151 -0
  152. package/lib/security/zip-security.d.ts +27 -0
  153. package/lib/security/zip-security.js +154 -0
  154. package/lib/sync/autosync-config.d.ts +39 -0
  155. package/lib/sync/autosync-config.js +151 -0
  156. package/lib/sync/autosync-scheduler.d.ts +139 -0
  157. package/lib/sync/autosync-scheduler.js +564 -0
  158. package/lib/sync/fs.d.ts +18 -0
  159. package/lib/sync/fs.js +49 -0
  160. package/lib/sync/git/git-transport.d.ts +100 -0
  161. package/lib/sync/git/git-transport.js +437 -0
  162. package/lib/sync/github-auth.d.ts +131 -0
  163. package/lib/sync/github-auth.js +264 -0
  164. package/lib/sync/layout.d.ts +43 -0
  165. package/lib/sync/layout.js +203 -0
  166. package/lib/sync/snapshot-json.d.ts +9 -0
  167. package/lib/sync/snapshot-json.js +107 -0
  168. package/lib/sync/sync-config.d.ts +81 -0
  169. package/lib/sync/sync-config.js +281 -0
  170. package/lib/sync/sync-engine.d.ts +269 -0
  171. package/lib/sync/sync-engine.js +591 -0
  172. package/lib/sync/sync-history.d.ts +42 -0
  173. package/lib/sync/sync-history.js +78 -0
  174. package/lib/sync/sync-selection.d.ts +29 -0
  175. package/lib/sync/sync-selection.js +127 -0
  176. package/lib/sync/sync-session.d.ts +40 -0
  177. package/lib/sync/sync-session.js +54 -0
  178. package/lib/sync/sync-state.d.ts +37 -0
  179. package/lib/sync/sync-state.js +108 -0
  180. package/lib/sync/transport.d.ts +75 -0
  181. package/lib/sync/transport.js +55 -0
  182. package/lib/sync/ui-prefs.d.ts +34 -0
  183. package/lib/sync/ui-prefs.js +109 -0
  184. package/lib/sync/webdav/webdav-transport.d.ts +77 -0
  185. package/lib/sync/webdav/webdav-transport.js +451 -0
  186. package/lib/ui/errors.d.ts +19 -0
  187. package/lib/ui/errors.js +81 -0
  188. package/lib/ui/export-flow.d.ts +82 -0
  189. package/lib/ui/export-flow.js +98 -0
  190. package/lib/ui/i18n.d.ts +289 -0
  191. package/lib/ui/i18n.js +581 -0
  192. package/lib/ui/migration-consult-view.d.ts +91 -0
  193. package/lib/ui/migration-consult-view.js +115 -0
  194. package/lib/ui/progress.d.ts +38 -0
  195. package/lib/ui/progress.js +87 -0
  196. package/lib/ui/report.d.ts +23 -0
  197. package/lib/ui/report.js +153 -0
  198. package/lib/ui/test-helpers.d.ts +69 -0
  199. package/lib/ui/test-helpers.js +154 -0
  200. package/lib/ui/types.d.ts +257 -0
  201. package/lib/ui/types.js +21 -0
  202. package/lib/utils/atomic-write.d.ts +88 -0
  203. package/lib/utils/atomic-write.js +399 -0
  204. package/lib/utils/env-lock.d.ts +393 -0
  205. package/lib/utils/env-lock.js +930 -0
  206. package/lib/utils/hashing.d.ts +17 -0
  207. package/lib/utils/hashing.js +41 -0
  208. package/lib/utils/json.d.ts +24 -0
  209. package/lib/utils/json.js +76 -0
  210. package/lib/utils/logger.d.ts +39 -0
  211. package/lib/utils/logger.js +82 -0
  212. package/lib/utils/paths.d.ts +42 -0
  213. package/lib/utils/paths.js +189 -0
  214. package/lib/utils/proxy.d.ts +69 -0
  215. package/lib/utils/proxy.js +455 -0
  216. package/lib/utils/recursive-walk.d.ts +24 -0
  217. package/lib/utils/recursive-walk.js +134 -0
  218. package/lib/utils/zip.d.ts +54 -0
  219. package/lib/utils/zip.js +285 -0
  220. package/package.json +188 -0
  221. package/src/adapters/agent-instructions.ts +48 -0
  222. package/src/adapters/agent-presets.ts +14 -0
  223. package/src/adapters/credentials.test.ts +90 -0
  224. package/src/adapters/credentials.ts +143 -0
  225. package/src/adapters/file-collection.ts +137 -0
  226. package/src/adapters/files.test.ts +353 -0
  227. package/src/adapters/index.ts +94 -0
  228. package/src/adapters/link-report.test.ts +65 -0
  229. package/src/adapters/link-report.ts +61 -0
  230. package/src/adapters/mcp.test.ts +96 -0
  231. package/src/adapters/mcp.ts +185 -0
  232. package/src/adapters/plugin-files.ts +146 -0
  233. package/src/adapters/plugins.test.ts +326 -0
  234. package/src/adapters/plugins.ts +615 -0
  235. package/src/adapters/pnpm-workspace.test.ts +87 -0
  236. package/src/adapters/pnpm-workspace.ts +150 -0
  237. package/src/adapters/prompts.test.ts +110 -0
  238. package/src/adapters/prompts.ts +230 -0
  239. package/src/adapters/providers.test.ts +126 -0
  240. package/src/adapters/providers.ts +173 -0
  241. package/src/adapters/roundtrip.test.ts +190 -0
  242. package/src/adapters/self.test.ts +92 -0
  243. package/src/adapters/self.ts +75 -0
  244. package/src/adapters/sessions.ts +15 -0
  245. package/src/adapters/settings.test.ts +100 -0
  246. package/src/adapters/settings.ts +187 -0
  247. package/src/adapters/skills.ts +14 -0
  248. package/src/adapters/test-helpers.ts +248 -0
  249. package/src/adapters/ui.test.ts +57 -0
  250. package/src/adapters/ui.ts +108 -0
  251. package/src/adapters/workspaces.test.ts +106 -0
  252. package/src/adapters/workspaces.ts +109 -0
  253. package/src/client/ConfigManagerSection.tsx +90 -0
  254. package/src/client/api.ts +66 -0
  255. package/src/client/client-types.ts +18 -0
  256. package/src/client/common/ErrorBanner.tsx +73 -0
  257. package/src/client/common/Icon.tsx +129 -0
  258. package/src/client/common/Modal.tsx +173 -0
  259. package/src/client/common/ToastViewport.tsx +73 -0
  260. package/src/client/common/toast-store.test.ts +192 -0
  261. package/src/client/common/toast-store.ts +181 -0
  262. package/src/client/common/toast-types.ts +7 -0
  263. package/src/client/common/ui.tsx +342 -0
  264. package/src/client/config-manager.module.css +2971 -0
  265. package/src/client/css-modules.d.ts +8 -0
  266. package/src/client/index.ts +65 -0
  267. package/src/client/locales.ts +56 -0
  268. package/src/client/lucide-icons.d.ts +38 -0
  269. package/src/client/run-store.test.ts +223 -0
  270. package/src/client/run-store.ts +372 -0
  271. package/src/client/sync/SyncConfirmView.tsx +379 -0
  272. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  273. package/src/client/sync/SyncHistoryView.tsx +220 -0
  274. package/src/client/sync/SyncSettingsView.tsx +1535 -0
  275. package/src/client/sync/history-model.test.ts +194 -0
  276. package/src/client/sync/history-model.ts +195 -0
  277. package/src/client/sync/sync-api.test.ts +585 -0
  278. package/src/client/sync/sync-api.ts +543 -0
  279. package/src/client/sync/sync-locales.ts +416 -0
  280. package/src/client/sync/sync-push-preview.test.ts +52 -0
  281. package/src/client/sync/sync-view-v2.test.ts +213 -0
  282. package/src/client/sync/sync-view.test.ts +487 -0
  283. package/src/client/sync/sync-view.ts +759 -0
  284. package/src/core/analyzer.ts +1068 -0
  285. package/src/core/backup.ts +605 -0
  286. package/src/core/boot-rescue.test.ts +847 -0
  287. package/src/core/boot-rescue.ts +737 -0
  288. package/src/core/cache-cleaner.test.ts +169 -0
  289. package/src/core/cache-cleaner.ts +121 -0
  290. package/src/core/config-lifecycle.test.ts +727 -0
  291. package/src/core/config-lifecycle.ts +573 -0
  292. package/src/core/config-snapshot.test.ts +369 -0
  293. package/src/core/config-snapshot.ts +522 -0
  294. package/src/core/config-state.test.ts +381 -0
  295. package/src/core/config-state.ts +207 -0
  296. package/src/core/consult-source.test.ts +199 -0
  297. package/src/core/consult-source.ts +293 -0
  298. package/src/core/crash-report.test.ts +380 -0
  299. package/src/core/crash-report.ts +369 -0
  300. package/src/core/exporter.ts +417 -0
  301. package/src/core/ghost-sweep.test.ts +94 -0
  302. package/src/core/ghost-sweep.ts +67 -0
  303. package/src/core/importer.ts +83 -0
  304. package/src/core/index.ts +56 -0
  305. package/src/core/journal.test.ts +257 -0
  306. package/src/core/journal.ts +518 -0
  307. package/src/core/local-plugin-host.ts +152 -0
  308. package/src/core/local-plugin-pack.test.ts +359 -0
  309. package/src/core/local-plugin-pack.ts +363 -0
  310. package/src/core/messages.test.ts +54 -0
  311. package/src/core/messages.ts +664 -0
  312. package/src/core/migration-consult.test.ts +315 -0
  313. package/src/core/migration-consult.ts +494 -0
  314. package/src/core/migration-history.test.ts +319 -0
  315. package/src/core/migration-history.ts +484 -0
  316. package/src/core/model-tools.test.ts +190 -0
  317. package/src/core/model-tools.ts +208 -0
  318. package/src/core/msg-types.ts +10 -0
  319. package/src/core/phase3-child-crash.ts +95 -0
  320. package/src/core/phase3-host.ts +411 -0
  321. package/src/core/phase3-p1.test.ts +212 -0
  322. package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
  323. package/src/core/phase3-prod-child.ts +36 -0
  324. package/src/core/phase3-production-integration.test.ts +286 -0
  325. package/src/core/phase4-crash-child.ts +101 -0
  326. package/src/core/phase4-crash-injection.test.ts +166 -0
  327. package/src/core/plugin-cli.fs.test.ts +184 -0
  328. package/src/core/plugin-cli.test.ts +137 -0
  329. package/src/core/plugin-cli.ts +563 -0
  330. package/src/core/reconcile.test.ts +392 -0
  331. package/src/core/reconcile.ts +498 -0
  332. package/src/core/recovery-orchestrator.test.ts +145 -0
  333. package/src/core/recovery-orchestrator.ts +366 -0
  334. package/src/core/restore-manage.test.ts +94 -0
  335. package/src/core/restore.ts +849 -0
  336. package/src/core/rollback.ts +151 -0
  337. package/src/core/run-progress.test.ts +237 -0
  338. package/src/core/run-registry.test.ts +232 -0
  339. package/src/core/run-registry.ts +209 -0
  340. package/src/core/smoke.test.ts +842 -0
  341. package/src/core/startup-barrier.ts +99 -0
  342. package/src/core/types.ts +542 -0
  343. package/src/core/undo.ts +116 -0
  344. package/src/core/validator.ts +84 -0
  345. package/src/core/verify-recovery.ts +265 -0
  346. package/src/core/watcher.test.ts +326 -0
  347. package/src/core/watcher.ts +263 -0
  348. package/src/index.facade.test.ts +265 -0
  349. package/src/index.sync.test.ts +217 -0
  350. package/src/index.ts +2975 -0
  351. package/src/migrations/index.ts +71 -0
  352. package/src/migrations/v1-to-v2.ts +17 -0
  353. package/src/profiles/index.ts +8 -0
  354. package/src/profiles/profile-manager.test.ts +282 -0
  355. package/src/profiles/profile-manager.ts +630 -0
  356. package/src/schema/config.ts +165 -0
  357. package/src/schema/index.ts +71 -0
  358. package/src/schema/manifest-schema.test.ts +519 -0
  359. package/src/schema/manifest.ts +124 -0
  360. package/src/schema/tombstones.test.ts +131 -0
  361. package/src/schema/tombstones.ts +89 -0
  362. package/src/schema/types.ts +245 -0
  363. package/src/schema/versions.ts +55 -0
  364. package/src/security/encryption.ts +335 -0
  365. package/src/security/index.ts +16 -0
  366. package/src/security/integrity.ts +102 -0
  367. package/src/security/redaction.ts +101 -0
  368. package/src/security/secret-scanner.ts +445 -0
  369. package/src/security/security.test.ts +1215 -0
  370. package/src/security/vault.test.ts +164 -0
  371. package/src/security/vault.ts +198 -0
  372. package/src/security/zip-security.ts +180 -0
  373. package/src/sync/autosync-config.test.ts +147 -0
  374. package/src/sync/autosync-config.ts +179 -0
  375. package/src/sync/autosync-scheduler.test.ts +490 -0
  376. package/src/sync/autosync-scheduler.ts +646 -0
  377. package/src/sync/fs.ts +48 -0
  378. package/src/sync/git/git-transport.test.ts +673 -0
  379. package/src/sync/git/git-transport.ts +491 -0
  380. package/src/sync/github-auth.test.ts +280 -0
  381. package/src/sync/github-auth.ts +356 -0
  382. package/src/sync/layout.test.ts +270 -0
  383. package/src/sync/layout.ts +222 -0
  384. package/src/sync/snapshot-json.test.ts +119 -0
  385. package/src/sync/snapshot-json.ts +128 -0
  386. package/src/sync/sync-config.test.ts +310 -0
  387. package/src/sync/sync-config.ts +299 -0
  388. package/src/sync/sync-engine.test.ts +1074 -0
  389. package/src/sync/sync-engine.ts +754 -0
  390. package/src/sync/sync-history.test.ts +116 -0
  391. package/src/sync/sync-history.ts +120 -0
  392. package/src/sync/sync-selection.test.ts +186 -0
  393. package/src/sync/sync-selection.ts +143 -0
  394. package/src/sync/sync-session.test.ts +143 -0
  395. package/src/sync/sync-session.ts +77 -0
  396. package/src/sync/sync-state.test.ts +164 -0
  397. package/src/sync/sync-state.ts +131 -0
  398. package/src/sync/transport.test.ts +111 -0
  399. package/src/sync/transport.ts +118 -0
  400. package/src/sync/ui-prefs.test.ts +152 -0
  401. package/src/sync/ui-prefs.ts +135 -0
  402. package/src/sync/webdav/webdav-transport.test.ts +871 -0
  403. package/src/sync/webdav/webdav-transport.ts +535 -0
  404. package/src/ui/errors.test.ts +47 -0
  405. package/src/ui/errors.ts +100 -0
  406. package/src/ui/export-flow.test.ts +87 -0
  407. package/src/ui/export-flow.ts +156 -0
  408. package/src/ui/i18n.ts +590 -0
  409. package/src/ui/migration-consult-view.test.ts +151 -0
  410. package/src/ui/migration-consult-view.ts +178 -0
  411. package/src/ui/progress.test.ts +47 -0
  412. package/src/ui/progress.ts +102 -0
  413. package/src/ui/report.test.ts +101 -0
  414. package/src/ui/report.ts +146 -0
  415. package/src/ui/test-helpers.ts +184 -0
  416. package/src/ui/types.ts +325 -0
  417. package/src/utils/atomic-write.test.ts +312 -0
  418. package/src/utils/atomic-write.ts +422 -0
  419. package/src/utils/env-lock.test.ts +999 -0
  420. package/src/utils/env-lock.ts +1086 -0
  421. package/src/utils/hashing.ts +53 -0
  422. package/src/utils/json.ts +77 -0
  423. package/src/utils/logger.ts +114 -0
  424. package/src/utils/paths.ts +176 -0
  425. package/src/utils/proxy.test.ts +465 -0
  426. package/src/utils/proxy.ts +493 -0
  427. package/src/utils/recursive-walk.test.ts +148 -0
  428. package/src/utils/recursive-walk.ts +145 -0
  429. package/src/utils/zip.ts +332 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * P2-B(Phase 8):逐计划项 before/after 指纹 + reconcile 判精度测试。
3
+ *
4
+ * 验证:
5
+ * - Phase3Recovery.recoveryHooks.verifyStepFingerprint 对本地文件 step 做真实磁盘指纹判定
6
+ * (after-match / before-match / before-match(缺失) / none / unable);
7
+ * - runJournaled 的 journalCtx.recordStep 把文件/外部 step 持久化进 journal;
8
+ * - reconcile:全部文件项 done + afterFp 可验 → recovered(降低「已应用误判 needs-attention」保守率)
9
+ * - reconcile:任一外部/不可指纹 step → needs-attention(安全边界不放宽)。
10
+ */
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs/promises';
14
+ import fssync from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ import { JournalStore, createJournalEntry, generateOperationId } from './journal.ts';
19
+ import { Phase3Recovery } from './phase3-host.ts';
20
+ import { reconcileActive } from './reconcile.ts';
21
+ import type { ReconcileEnv } from './reconcile.ts';
22
+ import { sha256Hex } from '../utils/hashing.ts';
23
+ import type { MutationLockContext } from '../utils/env-lock.ts';
24
+
25
+ function tmp(t: test.TestContext): string {
26
+ const dir = fssync.mkdtempSync(path.join(os.tmpdir(), 'p2bfp-'));
27
+ t.after(() => fssync.rmSync(dir, { recursive: true, force: true }));
28
+ return dir;
29
+ }
30
+
31
+ function lk(instance = 'owner-p2b'): MutationLockContext {
32
+ return { token: { tokenId: 't', managerId: 'm', instanceId: instance, acquiredAt: Date.now() } };
33
+ }
34
+
35
+ async function makeRecovery(dir: string): Promise<Phase3Recovery> {
36
+ const r = new Phase3Recovery({ dataDir: dir, packageVersion: '0.1.54', environmentFingerprint: 'fp-p2b' });
37
+ await r.store.ensureDirs();
38
+ return r;
39
+ }
40
+
41
+ // ---------- 1. verifyStepFingerprint 指纹判定 ----------
42
+
43
+ test('verifyStepFingerprint:本地文件 step 真实磁盘指纹判定', async (t) => {
44
+ const dir = tmp(t);
45
+ const recovery = await makeRecovery(dir);
46
+ const fx = path.join(dir, 'target.yaml');
47
+ await fssync.writeFileSync(fx, 'content-A');
48
+ const fpA = sha256Hex(Buffer.from('content-A'));
49
+ await fssync.writeFileSync(fx, 'content-B');
50
+ const fpB = sha256Hex(Buffer.from('content-B'));
51
+
52
+ const hooks = recovery.recoveryHooks;
53
+ const local = (ref: string, beforeFp: string | null, afterFp: string | null, status: import('./journal.ts').JournalStep['status'] = 'done') => ({
54
+ adapter: 'skills', ref, kind: 'Update', external: false, beforeFp, afterFp, status, appliedAt: null,
55
+ });
56
+
57
+ // after 匹配 → 已应用
58
+ assert.equal(await hooks.verifyStepFingerprint(local(fx, fpA, fpB)), 'after-match');
59
+ // 磁盘 == beforeFp 且无 afterFp → before-match(未应用)
60
+ assert.equal(await hooks.verifyStepFingerprint(local(fx, fpB, null)), 'before-match');
61
+ // 内容与 before/after 均不符 → none
62
+ assert.equal(await hooks.verifyStepFingerprint(local(fx, fpA, sha256Hex(Buffer.from('other')))), 'none');
63
+ // 文件缺失 + 仅有 afterFp(期望存在)→ 视作未应用
64
+ const missing = path.join(dir, 'nope.yaml');
65
+ assert.equal(await hooks.verifyStepFingerprint(local(missing, null, fpB)), 'before-match');
66
+ // 无指纹 → unable(不可判定)
67
+ assert.equal(await hooks.verifyStepFingerprint(local(fx, null, null)), 'unable');
68
+ // external step → unable(reconcile 走 probeExternal 保守)
69
+ assert.equal(await hooks.verifyStepFingerprint({ adapter: 'plugins', ref: 'plugin:@x', kind: 'Install', external: true, beforeFp: null, afterFp: null, status: 'planned', appliedAt: null }), 'unable');
70
+ });
71
+
72
+ // ---------- 2. recordStep 持久化 ----------
73
+
74
+ test('runJournaled journalCtx.recordStep:文件/外部 step 落 journal', async (t) => {
75
+ const dir = tmp(t);
76
+ const recovery = await makeRecovery(dir);
77
+ const { operationId } = await recovery.runJournaled({
78
+ operationType: 'import-apply', lockCtx: lk(), deferredSnapshot: true,
79
+ fn: async (ctx) => {
80
+ await ctx?.recordStep({ id: 'skills:a.md', adapter: 'skills', kind: 'Update', ref: '/abs/skills/a.md', external: false, status: 'done', beforeFp: null, afterFp: 'fp-after' });
81
+ await ctx?.recordStep({ id: 'plugins:pkg', adapter: 'plugins', kind: 'Install', ref: '', external: true, status: 'attention' });
82
+ // issue #35:结论文案必须落盘——否则事后审计只能看到 status,无法区分
83
+ // 「安装失败」与「用户主动跳过」。
84
+ await ctx?.recordStep({
85
+ id: 'plugin:broken', adapter: 'plugins', kind: 'Install', ref: '', external: true,
86
+ status: 'attention', message: '插件安装失败:Failed to read patch file',
87
+ });
88
+ return 1;
89
+ },
90
+ });
91
+ const j = await recovery.store.load(operationId!);
92
+ assert.ok(j);
93
+ assert.equal(j!.steps['skills:a.md']?.afterFp, 'fp-after');
94
+ assert.equal(j!.steps['skills:a.md']?.external, false);
95
+ assert.equal(j!.steps['plugins:pkg']?.external, true);
96
+ assert.ok(j!.plannedSteps.includes('skills:a.md'));
97
+ assert.ok(j!.plannedSteps.includes('plugins:pkg'));
98
+ assert.equal(j!.steps['plugin:broken']?.status, 'attention');
99
+ assert.equal(j!.steps['plugin:broken']?.message, '插件安装失败:Failed to read patch file');
100
+ });
101
+
102
+ // ---------- 3. reconcile 集成 ----------
103
+
104
+ /** 预置一个 APPLYING 的 active journal(模拟 crash 残留),带指定 steps。 */
105
+ async function seedJournal(store: JournalStore, steps: import('./journal.ts').JournalStep[], opts: { state?: import('./journal.ts').JournalState; ownerInstanceId?: string } = {}): Promise<string> {
106
+ const opId = generateOperationId();
107
+ const owner = opts.ownerInstanceId ?? 'owner-p2b';
108
+ await store.create(createJournalEntry('import-apply',
109
+ { operationId: opId, ownerInstanceId: owner, lockId: owner, packageVersion: '0.1.54', environmentFingerprint: 'fp-p2b' },
110
+ new Date().toISOString()));
111
+ await store.update(opId, (j) => {
112
+ const ids = steps.map((_, i) => `s${i}`);
113
+ const stepsObj: Record<string, import('./journal.ts').JournalStep> = {};
114
+ ids.forEach((id, i) => { stepsObj[id] = steps[i]!; });
115
+ return { ...j, state: opts.state ?? 'APPLYING', plannedSteps: ids, steps: stepsObj };
116
+ });
117
+ return opId;
118
+ }
119
+
120
+ function env(): ReconcileEnv {
121
+ // 不传 expectedOwnershipInstanceId → 跳过 P1-A binding(本测试聚焦指纹行为)
122
+ return { environmentFingerprint: 'fp-p2b', isLiveOwner: async () => false };
123
+ }
124
+
125
+ test('reconcile:全部文件项 done + afterFp 可验 → recovered(降低误判 needs-attention)', async (t) => {
126
+ const dir = tmp(t);
127
+ const recovery = await makeRecovery(dir);
128
+ // 两个已应用文件(磁盘 == afterFp)
129
+ const f1 = path.join(dir, 'f1.yaml'); await fssync.writeFileSync(f1, 'one');
130
+ const f2 = path.join(dir, 'f2.md'); await fssync.writeFileSync(f2, 'two');
131
+ const step = (ref: string, afterFp: string): import('./journal.ts').JournalStep => ({
132
+ adapter: 'skills', ref, kind: 'Update', external: false,
133
+ beforeFp: null, afterFp, status: 'done', appliedAt: new Date().toISOString(),
134
+ });
135
+ const opId = await seedJournal(recovery.store, [
136
+ step(f1, sha256Hex(Buffer.from('one'))),
137
+ step(f2, sha256Hex(Buffer.from('two'))),
138
+ ]);
139
+ const out = await reconcileActive(recovery.store, recovery.recoveryHooks, env());
140
+ // 只处理该 journal
141
+ const dec = out.decisions.find((d) => d.operationId === opId);
142
+ assert.ok(dec, 'decision 存在');
143
+ assert.equal(dec!.kind, 'recovered', `全文件项可验应判 recovered,实际=${dec!.kind}`);
144
+ assert.equal(out.safeModeRequired, false);
145
+ // 已规整出 active
146
+ assert.ok(!(await recovery.store.scanActive()).includes(opId));
147
+ });
148
+
149
+ test('reconcile:任一外部/不可指纹 step → needs-attention(安全边界不放宽)', async (t) => {
150
+ const dir = tmp(t);
151
+ const recovery = await makeRecovery(dir);
152
+ const f1 = path.join(dir, 'f1.yaml'); await fssync.writeFileSync(f1, 'one');
153
+ const fileStep: import('./journal.ts').JournalStep = {
154
+ adapter: 'skills', ref: f1, kind: 'Update', external: false,
155
+ beforeFp: null, afterFp: sha256Hex(Buffer.from('one')), status: 'done', appliedAt: new Date().toISOString(),
156
+ };
157
+ const extStep: import('./journal.ts').JournalStep = {
158
+ adapter: 'plugins', ref: 'plugin:@x', kind: 'Install', external: true,
159
+ beforeFp: null, afterFp: null, status: 'attention', appliedAt: null,
160
+ };
161
+ const opId = await seedJournal(recovery.store, [fileStep, extStep]);
162
+ const out = await reconcileActive(recovery.store, recovery.recoveryHooks, env());
163
+ const dec = out.decisions.find((d) => d.operationId === opId);
164
+ assert.ok(dec, 'decision 存在');
165
+ assert.equal(dec!.kind, 'needs-attention', `外部步骤不得判 recovered(安全边界),实际=${dec!.kind}`);
166
+ assert.equal(out.safeModeRequired, true);
167
+ });
168
+
169
+ test('reconcile:部分文件项 applied(其余 planned 未应用)→ 不判 recovered(保守)', async (t) => {
170
+ const dir = tmp(t);
171
+ const recovery = await makeRecovery(dir);
172
+ const f1 = path.join(dir, 'f1.yaml'); await fssync.writeFileSync(f1, 'one');
173
+ // f2 未写(planned,beforeFp=null,afterFp=null → unable → 不可证 → needs-attention 保守)
174
+ const f2 = path.join(dir, 'f2.md');
175
+ const doneStep: import('./journal.ts').JournalStep = {
176
+ adapter: 'skills', ref: f1, kind: 'Update', external: false,
177
+ beforeFp: null, afterFp: sha256Hex(Buffer.from('one')), status: 'done', appliedAt: new Date().toISOString(),
178
+ };
179
+ const plannedStep: import('./journal.ts').JournalStep = {
180
+ adapter: 'skills', ref: f2, kind: 'Update', external: false,
181
+ beforeFp: null, afterFp: null, status: 'planned', appliedAt: null,
182
+ };
183
+ const opId = await seedJournal(recovery.store, [doneStep, plannedStep]);
184
+ const out = await reconcileActive(recovery.store, recovery.recoveryHooks, env());
185
+ const dec = out.decisions.find((d) => d.operationId === opId);
186
+ assert.ok(dec, 'decision 存在');
187
+ assert.notEqual(dec!.kind, 'recovered', '部分未应用(planned 不可证)不得判 recovered');
188
+ assert.equal(dec!.kind, 'needs-attention');
189
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 生产路径 child crash harness:用真实 Phase3Recovery.runJournaled(生产接线所用的助手)
3
+ * 执行一个 operation;在 fn(真实 side effect 写磁盘)后、COMMITTED 前 SIGKILL。
4
+ * 用于 phase3-production-integration.test.ts 验证「真实操作 crash → 残留非终态 journal → 可 reconcile」。
5
+ */
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { Phase3Recovery } from './phase3-host.ts';
9
+
10
+ const txDir = process.argv[2]!;
11
+ const dataDir = path.dirname(txDir);
12
+ const sideEffect = path.join(dataDir, 'side-effect.txt');
13
+ const FP = 'fp-prod';
14
+
15
+ async function main(): Promise<void> {
16
+ const recovery = new Phase3Recovery({ dataDir, packageVersion: '0.1.54', environmentFingerprint: FP });
17
+ await recovery.store.ensureDirs();
18
+ const lockCtx = { token: { tokenId: 't', managerId: 'm', instanceId: 'child-prod', acquiredAt: Date.now() } };
19
+ try {
20
+ await recovery.runJournaled({
21
+ operationType: 'import-apply',
22
+ lockCtx,
23
+ fn: async () => {
24
+ // 真实 side effect:写磁盘文件
25
+ await fs.writeFile(sideEffect, 'side-effect-content');
26
+ // side effect 已完成、step done 尚未落 journal 前 crash
27
+ process.kill(process.pid, 'SIGKILL');
28
+ },
29
+ });
30
+ } catch {
31
+ // 被杀不会到这;若执行完则正常退出(测试用 before-step 场景)
32
+ }
33
+ process.exit(0);
34
+ }
35
+
36
+ main().catch((err) => { process.stderr.write(String(err)); process.exit(2); });
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Phase 3 生产集成测试(P0-A 关闭验证)。
3
+ * 直接测生产路径助手 `Phase3Recovery.runJournaled` / `runExternalIntent`(Web gate / restore / model-tools 均经它):
4
+ * - 建 journal + Journal→Lock 绑定(ownerInstanceId/lockId)+ operationType + COMMITTED 规整
5
+ * - active≤1:残留非终态 → TransactionRecoveryRequiredError,不建第二个 journal
6
+ * - fn 异常 → NEEDS_ATTENTION + durable SAFE MODE
7
+ * - COMMITTED 持久化失败 → 保持非终态 + SAFE MODE(RECOVERY_REQUIRED 语义,lock 层不发明终态)
8
+ * - runExternalIntent 记录 external step
9
+ * - 真实 child crash(runJournaled fn 中被 SIGKILL → 残留非终态 journal → reconcile 判定)
10
+ */
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs/promises';
14
+ import fssync from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { spawn } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { JournalStore } from './journal.ts';
20
+ import { Phase3Recovery, TransactionRecoveryRequiredError } from './phase3-host.ts';
21
+ import { reconcileActive } from './reconcile.ts';
22
+ import { createSnapshot, FileSnapshotStore, verifySnapshot } from './backup.ts';
23
+ import type { Snapshot } from './types.ts';
24
+ import { makeContext } from '../adapters/test-helpers.ts';
25
+ import type { MutationLockContext } from '../utils/env-lock.ts';
26
+
27
+ function tmp(t: test.TestContext): string {
28
+ const dir = fssync.mkdtempSync(path.join(os.tmpdir(), 'p3prod-'));
29
+ t.after(() => fssync.rmSync(dir, { recursive: true, force: true }));
30
+ return dir;
31
+ }
32
+ const here = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
33
+
34
+ function lk(instance = 'owner-prod'): MutationLockContext {
35
+ return { token: { tokenId: 't', managerId: 'm', instanceId: instance, acquiredAt: Date.now() } };
36
+ }
37
+
38
+ async function makeRecovery(dir: string): Promise<Phase3Recovery> {
39
+ const r = new Phase3Recovery({ dataDir: dir, packageVersion: '0.1.54', environmentFingerprint: 'fp-prod' });
40
+ await r.store.ensureDirs();
41
+ return r;
42
+ }
43
+
44
+ test('P0-A:runJournaled 建 journal(绑定 + operationType + COMMITTED 规整)', async (t) => {
45
+ const dir = tmp(t);
46
+ const recovery = await makeRecovery(dir);
47
+ const { operationId, result } = await recovery.runJournaled<number>({ operationType: 'import-apply', lockCtx: lk(), fn: async () => 42 });
48
+ assert.equal(result, 42);
49
+ const j = await recovery.store.load(operationId);
50
+ assert.ok(j);
51
+ assert.equal(j!.operationType, 'import-apply');
52
+ assert.equal(j!.ownerInstanceId, 'owner-prod'); // Journal→Lock 绑定(真实 lockCtx token instanceId)
53
+ assert.equal(j!.lockId, 'owner-prod'); // lockId = 同 ownership epoch identity(P1-A 真实捕获,非环境稳定串)
54
+ assert.equal(j!.state, 'COMMITTED');
55
+ assert.equal('journalId' in j, false, 'Journal→Lock 单向,不回写 environment.lock');
56
+ assert.deepEqual(await recovery.store.scanActive(), [], 'COMMITTED 后已规整到 completed');
57
+ });
58
+
59
+ test('P0-A:active≤1 → 残留非终态阻断第二 journal', async (t) => {
60
+ const dir = tmp(t);
61
+ const recovery = await makeRecovery(dir);
62
+ // 第一次 run 在 fn 中抛错 → journal 留 NEEDS_ATTENTION(terminal,非终态残留不阻塞)…
63
+ // 用手动预置一个「非终态」active journal(模拟未 reconcile 的 crash residue)
64
+ const { generateOperationId, createJournalEntry } = await import('./journal.ts');
65
+ const opId = generateOperationId();
66
+ await recovery.store.create(createJournalEntry('import-apply', { operationId: opId, ownerInstanceId: 'old', lockId: 'old', packageVersion: '0', environmentFingerprint: 'fp-prod' }, new Date().toISOString()));
67
+ await recovery.store.update(opId, (j) => ({ ...j, state: 'APPLYING' }));
68
+ // 第二次 run(新 transaction)→ 应被 active≤1 阻断
69
+ await assert.rejects(
70
+ () => recovery.runJournaled({ operationType: 'import-apply', lockCtx: lk(), fn: async () => 1 }),
71
+ TransactionRecoveryRequiredError,
72
+ '残留 active 应阻断新 transaction(active≤1)',
73
+ );
74
+ // 只有一个 journal
75
+ assert.equal((await recovery.store.scanActive()).length, 1);
76
+ });
77
+
78
+ test('P0-A:fn 异常 → NEEDS_ATTENTION + durable SAFE MODE + rethrow', async (t) => {
79
+ const dir = tmp(t);
80
+ const recovery = await makeRecovery(dir);
81
+ await assert.rejects(
82
+ () => recovery.runJournaled({ operationType: 'import-apply', lockCtx: lk(), fn: async () => { throw new Error('boom'); } }),
83
+ /boom/,
84
+ );
85
+ assert.equal(await recovery.store.readSafeMode(), true);
86
+ assert.equal(recovery.safeModeActive, true);
87
+ const ops = await recovery.store.scanActive();
88
+ // fn 异常后 journal 进入 NEEDS_ATTENTION(terminal)→ moveToCompleted 可规整,但 SAFE MODE 已 durable
89
+ assert.equal(await recovery.store.readSafeMode(), true);
90
+ });
91
+
92
+ test('P0-A:COMMITTED 持久化失败 → 保持非终态 + SAFE MODE(RECOVERY_REQUIRED,lock 不发明终态)', async (t) => {
93
+ const dir = tmp(t);
94
+ let failCommit = false;
95
+ const store = new JournalStore({
96
+ transactionsDir: path.join(dir, 'transactions'),
97
+ io: {
98
+ mkdir: async (d) => { await fs.mkdir(d, { recursive: true }); },
99
+ readFileText: async (p) => (await fs.readFile(p, 'utf8')).toString(),
100
+ writeAll: async (target, content) => {
101
+ // 注入:仅 COMMITTED 状态写失败(terminal 持久化失败用例)
102
+ if (failCommit && content.includes('"COMMITTED"')) throw new Error('injected commit write fail');
103
+ await fs.writeFile(target, content, { mode: 0o600 });
104
+ },
105
+ rename: async (a, b) => fs.rename(a, b),
106
+ readdirNames: async (d) => { try { return await fs.readdir(d); } catch { return []; } },
107
+ readdirEntries: async (d) => { try { return await fs.readdir(d, { withFileTypes: true }); } catch { return []; } },
108
+ lstat: async (p) => { try { return await fs.lstat(p); } catch { return null; } },
109
+ rm: async (p, o) => fs.rm(p, o as never),
110
+ exists: async (p) => { try { await fs.access(p); return true; } catch { return false; } },
111
+ },
112
+ });
113
+ const recovery = new Phase3Recovery({ dataDir: dir, packageVersion: '0.1.54', environmentFingerprint: 'fp-prod' });
114
+ (recovery as unknown as { store: JournalStore }).store = store;
115
+ failCommit = true;
116
+ // runJournaled 在 COMMITTED 阶段会多次 update;首个 update 失败 → rethrow,journal 留非终态(或缺失)
117
+ await assert.rejects(() => recovery.runJournaled({ operationType: 'import-apply', lockCtx: lk(), fn: async () => 1 }));
118
+ assert.equal(await store.readSafeMode().catch(() => true), true, 'SAFE MODE 已设置(lock 层不发明终态)');
119
+ });
120
+
121
+ test('P0-A:runExternalIntent 记录 external step + COMMITTED', async (t) => {
122
+ const dir = tmp(t);
123
+ const recovery = await makeRecovery(dir);
124
+ const { operationId, result } = await recovery.runExternalIntent({
125
+ operationType: 'sync-push', lockCtx: lk(), intent: { adapter: 'sync', ref: 'git', kind: 'Push' },
126
+ fn: async () => ({ ok: true }),
127
+ });
128
+ assert.equal((result as { ok: boolean }).ok, true);
129
+ const j = await recovery.store.load(operationId);
130
+ assert.ok(j);
131
+ assert.equal(j!.steps['ext']?.external, true);
132
+ assert.equal(j!.state, 'COMMITTED');
133
+ assert.deepEqual(await recovery.store.scanActive(), []);
134
+ });
135
+
136
+ // ---------- 真实 child crash:runJournaled fn 中被 SIGKILL → 残留非终态 journal → reconcile ----------
137
+ test('P0-A:真实 child crash(fn 中 SIGKILL)→ 残留非终态 journal 可 reconcile(不自动恢复)', async (t) => {
138
+ const dir = tmp(t);
139
+ const txDir = path.join(dir, 'transactions');
140
+ const childPath = path.join(here, 'phase3-prod-child.ts');
141
+ await new Promise<void>((resolve) => {
142
+ const child = spawn(process.execPath, [childPath, txDir], { stdio: 'ignore' });
143
+ child.on('close', () => resolve()); // child 在 fn 中被 SIGKILL(crash)是目的;任何退出码都算已完成
144
+ });
145
+ // child 在 runJournaled 的 fn 中写 side effect 后 SIGKILL(finally 未执行)→ journal 残留非终态
146
+ const store = new JournalStore({ transactionsDir: txDir });
147
+ await store.ensureDirs();
148
+ const active = await store.scanActive();
149
+ assert.equal(active.length, 1, 'child crash 后应残留 1 个 active journal');
150
+ const j = await store.loadActive(active[0]!);
151
+ assert.ok(j);
152
+ assert.notEqual(['COMMITTED', 'ROLLED_BACK', 'RECOVERED', 'NEEDS_ATTENTION'].includes(j!.state), true, 'crash 后非终态');
153
+ // reconcile:fn 已执行 side effect(磁盘 afterFp)→ after-match → recovered;不自动恢复外部不适用
154
+ const out = await reconcileActive(store, {
155
+ verifyStepFingerprint: async () => 'after-match',
156
+ probeExternal: async () => 'not-installed',
157
+ snapshotExists: async () => false,
158
+ }, { environmentFingerprint: 'fp-prod', isLiveOwner: async () => false });
159
+ assert.ok(['recovered', 'noop', 'needs-attention', 'rollback-continue'].includes(out.decisions[0]!.kind));
160
+ });
161
+
162
+ // ---------- Phase 4:deferred snapshot 生产接线(F20)----------
163
+
164
+ /** 模拟生产引擎:runJournaled({ deferredSnapshot:true }) 内,用 plan 创建 op-bound snapshot →
165
+ * bindSnapshot(SNAPSHOT_CREATED)→ markApplying(APPLYING)→ 执行副作用。 */
166
+ async function runDeferredApply(
167
+ dir: string,
168
+ opts: { operationType?: string; doBind?: boolean; doMark?: boolean; throwInApply?: boolean },
169
+ ): Promise<{ recovery: Phase3Recovery; operationId: string | null; thrown: unknown }> {
170
+ const { operationType = 'import-apply', doBind = true, doMark = true, throwInApply = false } = opts;
171
+ const recovery = new Phase3Recovery({ dataDir: dir, packageVersion: '0.1.54', environmentFingerprint: 'fp-prod' });
172
+ await recovery.store.ensureDirs();
173
+ const snapDir = path.join(dir, 'snapshots');
174
+ await fssync.mkdirSync(snapDir, { recursive: true });
175
+ const ctx = makeContext('win32', path.join(dir, 'home'), 'web');
176
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
177
+ await ctx.fs.writeFile('settings.yaml', Buffer.from('general:\n theme: dark\n', 'utf8'));
178
+ const store = new FileSnapshotStore({ dir: snapDir });
179
+ const plan = { items: [{ id: 'settings:general', kind: 'Update' as const, adapter: 'settings' as const, description: 'u', severity: 'info' as const, target: { adapter: 'settings' as const, ref: 'general' } }], globalStrategy: 'replace' as const, pathMappings: [], missingSecrets: [], needsRestart: false, estimatedActions: {} as import('./types.ts').ImportPlan['estimatedActions'] };
180
+ try {
181
+ const { operationId } = await recovery.runJournaled({
182
+ operationType,
183
+ lockCtx: lk(),
184
+ deferredSnapshot: true,
185
+ fn: async (journalCtx) => {
186
+ const snapshot: Snapshot = await createSnapshot({
187
+ ctx,
188
+ plan,
189
+ sourceZip: 'x.zip',
190
+ store,
191
+ adapters: [],
192
+ operationId: journalCtx?.operationId,
193
+ operationType: journalCtx?.operationType,
194
+ environmentFingerprint: journalCtx?.environmentFingerprint,
195
+ ownerInstanceId: journalCtx?.ownerInstanceId,
196
+ });
197
+ if (doBind) await journalCtx?.bindSnapshot(snapshot.id);
198
+ if (doMark) await journalCtx?.markApplying();
199
+ if (throwInApply) throw new Error('injected apply failure');
200
+ return snapshot.id;
201
+ },
202
+ });
203
+ return { recovery, operationId, thrown: null };
204
+ } catch (err) {
205
+ // fn 抛错被 runJournaled 重新抛出;recovery 仍可用以读 journal 状态
206
+ return { recovery, operationId: null, thrown: err };
207
+ }
208
+ }
209
+
210
+ test('Phase4: deferredSnapshot 生产接线 → journal 记录 snapshotId + op-bound binding + COMMITTED', async (t) => {
211
+ const dir = tmp(t);
212
+ const { recovery, operationId } = await runDeferredApply(dir, {});
213
+ assert.ok(operationId);
214
+ const j = await recovery.store.load(operationId!);
215
+ assert.ok(j);
216
+ assert.ok(j!.snapshotId, 'journal 应记录 snapshotId(F20 生产接线)');
217
+ const snap = await new FileSnapshotStore({ dir: path.join(dir, 'snapshots') }).load(j!.snapshotId!);
218
+ assert.equal(snap.operationId, operationId, 'snapshot.operationId === journal.operationId');
219
+ assert.equal(snap.operationType, 'import-apply');
220
+ assert.equal(snap.environmentFingerprint, 'fp-prod');
221
+ assert.equal(snap.ownerInstanceId, 'owner-prod');
222
+ assert.equal(snap.readiness, 'READY', '部署快照应为 READY');
223
+ assert.equal(j!.state, 'COMMITTED');
224
+ });
225
+
226
+ test('Phase4: deferredSnapshot 未 bindSnapshot → journal 无 snapshotId(引擎未接线时保守)', async (t) => {
227
+ const dir = tmp(t);
228
+ const { recovery, operationId } = await runDeferredApply(dir, { doBind: false });
229
+ assert.ok(operationId);
230
+ const j = await recovery.store.load(operationId!);
231
+ assert.ok(j);
232
+ assert.equal(j!.snapshotId, null, '引擎未 bindSnapshot 时 journal.snapshotId 保持 null');
233
+ assert.equal(j!.state, 'COMMITTED');
234
+ });
235
+
236
+ test('Phase4: deferredSnapshot + fn 抛错(mutation 未完成)→ NEEDS_ATTENTION + SAFE MODE', async (t) => {
237
+ const dir = tmp(t);
238
+ const { recovery } = await runDeferredApply(dir, { throwInApply: true });
239
+ // fn 抛错 → runJournaled rethrow,返回无 operationId;扫描 active 定位 NEEDS_ATTENTION journal
240
+ const ids = await recovery.store.scanActive();
241
+ let j: import('./journal.ts').OperationJournal | null = null;
242
+ for (const id of ids) {
243
+ const x = await recovery.store.loadActive(id);
244
+ if (x) { j = x; break; }
245
+ }
246
+ assert.ok(j, 'journal 已创建(操作开始后才抛错)');
247
+ assert.equal(j!.state, 'NEEDS_ATTENTION');
248
+ assert.equal(await recovery.store.readSafeMode(), true);
249
+ assert.equal(recovery.safeModeActive, true);
250
+ });
251
+
252
+ // ---------- Phase 4:F21/F11 production snapshotExists 正向校验 ----------
253
+
254
+ test('Phase4: snapshotExists 注入后正向校验(存在+READY+binding 匹配→true)', async (t) => {
255
+ const dir = tmp(t);
256
+ const snapDir = path.join(dir, 'snapshots');
257
+ await fssync.mkdirSync(snapDir, { recursive: true });
258
+ const ctx = makeContext('win32', path.join(dir, 'home'), 'web');
259
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
260
+ await ctx.fs.writeFile('settings.yaml', Buffer.from('general:\n theme: dark\n', 'utf8'));
261
+ const store = new FileSnapshotStore({ dir: snapDir });
262
+ const plan = { items: [{ id: 'settings:general', kind: 'Update' as const, adapter: 'settings' as const, description: 'u', severity: 'info' as const, target: { adapter: 'settings' as const, ref: 'general' } }], globalStrategy: 'replace' as const, pathMappings: [], missingSecrets: [], needsRestart: false, estimatedActions: {} as import('./types.ts').ImportPlan['estimatedActions'] };
263
+ const snap = await createSnapshot({ ctx, plan, sourceZip: 'x', store, adapters: [], operationId: 'op-1', operationType: 'import-apply', environmentFingerprint: 'fp', ownerInstanceId: 'owner' });
264
+ // 未注入 → 保守 false
265
+ const noInj = new Phase3Recovery({ dataDir: path.join(dir, 'nohooks'), packageVersion: '0', environmentFingerprint: 'fp' });
266
+ await noInj.store.ensureDirs();
267
+ assert.equal(await noInj.recoveryHooks.snapshotExists(snap.id, { operationId: 'op-1' }), false, '未注入 snapshotExists → 保守 false');
268
+ // 注入后校验存在+就绪+binding
269
+ const inj = new Phase3Recovery({
270
+ dataDir: path.join(dir, 'hooks'), packageVersion: '0', environmentFingerprint: 'fp',
271
+ snapshotExists: async (id, binding) => {
272
+ if (id === null || id === '') return false;
273
+ const v = await verifySnapshot(snapDir, id);
274
+ if (!v.ok) return false;
275
+ const s = await store.load(id);
276
+ if (s.readiness !== 'READY') return false;
277
+ if (binding?.operationId !== undefined && s.operationId !== binding.operationId) return false;
278
+ return true;
279
+ },
280
+ });
281
+ await inj.store.ensureDirs();
282
+ assert.equal(await inj.recoveryHooks.snapshotExists(snap.id, { operationId: 'op-1' }), true, 'READY + binding 匹配 → true');
283
+ assert.equal(await inj.recoveryHooks.snapshotExists(snap.id, { operationId: 'WRONG' }), false, 'operationId 不匹配 → false(防伪造快照)');
284
+ assert.equal(await inj.recoveryHooks.snapshotExists('bad..id', {}), false, '非法 snapshotId → false');
285
+ assert.equal(await inj.recoveryHooks.snapshotExists(null, {}), false, 'null → false');
286
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Phase 4 crash-injection child harness:用真实 Phase3Recovery.runJournaled({ deferredSnapshot:true })
3
+ * 执行一个 op-bound snapshot + mutation,随后在指定 crash point SIGKILL 自己。
4
+ *
5
+ * 用法(argv):
6
+ * argv[2] = workDir(写入 transactions/ 与 snapshots/ 的根)
7
+ * argv[3] = crashPoint(C1..C10,见 phase4-crash-injection.test.ts)
8
+ *
9
+ * 每个 crash point 在流程的指定边界 SIGKILL,验证「journal 状态 + snapshot 是否存在」反映该点。
10
+ * 用真实磁盘 FileSnapshotStore(manifest + READY),非 throw 替代。
11
+ */
12
+ import fs from 'node:fs/promises';
13
+ import fssync from 'node:fs';
14
+ import path from 'node:path';
15
+ import { Phase3Recovery } from './phase3-host.ts';
16
+ import { createSnapshot, FileSnapshotStore } from './backup.ts';
17
+
18
+ const workDir = process.argv[2]!;
19
+ const crashPoint = process.argv[3]!;
20
+ const dataDir = path.join(workDir, 'data');
21
+ const snapDir = path.join(dataDir, 'snapshots');
22
+ const txDir = path.join(dataDir, 'transactions');
23
+ const FP = 'fp-crash';
24
+ const sideEffect = path.join(workDir, 'home', 'side-effect.txt');
25
+
26
+ function die(): never { process.kill(process.pid, 'SIGKILL'); throw new Error('unreachable'); }
27
+
28
+ async function main(): Promise<void> {
29
+ // 用真实磁盘 FileSnapshotStore(manifest+READY)与真实 plan
30
+ fssync.mkdirSync(dataDir, { recursive: true });
31
+ const recovery = new Phase3Recovery({ dataDir, packageVersion: '0.1.54', environmentFingerprint: FP });
32
+ await recovery.store.ensureDirs();
33
+ const store = new FileSnapshotStore({ dir: snapDir });
34
+ const lockCtx = { token: { tokenId: 't', managerId: 'm', instanceId: 'crash-child', acquiredAt: Date.now() } };
35
+
36
+ const plan = {
37
+ items: [{ id: 'settings:general', kind: 'Update' as const, adapter: 'settings' as const, description: 'u', severity: 'info' as const, target: { adapter: 'settings' as const, ref: 'general' } }],
38
+ globalStrategy: 'replace' as const, pathMappings: [], missingSecrets: [], needsRestart: false,
39
+ estimatedActions: {} as Record<string, number>,
40
+ };
41
+
42
+ // 构造内存 ctx:engineSnapshotEntry 需要 settings.describe;hostFile 备份需要 settings.yaml
43
+ // 这里不复用 makeContext(子进程需独立构造,无 import 依赖测试 helper)。
44
+ const ctx: any = {
45
+ homeDir: path.join(workDir, 'home'),
46
+ platform: 'win32',
47
+ arch: 'x64',
48
+ dshVersion: '0.1.54',
49
+ profile: 'web',
50
+ fs: {
51
+ readFile: async (p: string) => Buffer.from('settings.yaml-content', 'utf8'),
52
+ },
53
+ settings: {
54
+ describe: async (ref: string) => ({ value: { theme: 'dark' }, revision: 1, secrets: [] }),
55
+ },
56
+ credentials: { describe: async () => ({ configured: false }) },
57
+ plugins: { listInstalled: async () => [] },
58
+ workspace: { listRecords: async () => [] },
59
+ patchFile: { readPatchLines: async () => [] },
60
+ log: { warn: () => {} },
61
+ };
62
+
63
+ try {
64
+ await recovery.runJournaled({
65
+ operationType: 'import-apply',
66
+ lockCtx,
67
+ deferredSnapshot: true,
68
+ fn: async (journalCtx) => {
69
+ if (crashPoint === 'C1') die(); // after journal CREATED, before snapshot
70
+ const snap = await createSnapshot({
71
+ ctx, plan, sourceZip: 'crash.zip', store, adapters: [],
72
+ operationId: journalCtx?.operationId, operationType: journalCtx?.operationType,
73
+ environmentFingerprint: journalCtx?.environmentFingerprint, ownerInstanceId: journalCtx?.ownerInstanceId,
74
+ });
75
+ if (crashPoint === 'C2') die(); // during/after snapshot write, before bind
76
+ await journalCtx?.bindSnapshot(snap.id);
77
+ if (crashPoint === 'C3') die(); // after journal binding (SNAPSHOT_CREATED), before APPLYING
78
+ await journalCtx?.markApplying();
79
+ if (crashPoint === 'C4') die(); // after APPLYING, before mutation
80
+ // 真实 local file mutation
81
+ await fs.mkdir(path.dirname(sideEffect), { recursive: true });
82
+ await fs.writeFile(sideEffect, 'mutated');
83
+ if (crashPoint === 'C5') die(); // after first local file mutation
84
+ if (crashPoint === 'C6') die(); // mid-mutation
85
+ if (crashPoint === 'C7') die(); // after mutation, before COMMITTED
86
+ // 业务成功
87
+ },
88
+ });
89
+ if (crashPoint === 'C8' || crashPoint === 'C9' || crashPoint === 'C10') {
90
+ // 已到 COMMITTED(runJournaled 返回),C8=C9=C10 视为「COMMITTED 后不应 rollback」的探针
91
+ process.exit(0);
92
+ }
93
+ // 未指定 crash point → 正常完成不 kill
94
+ process.exit(0);
95
+ } catch {
96
+ // 被杀不会到这
97
+ process.exit(0);
98
+ }
99
+ }
100
+
101
+ main().catch(() => process.exit(2));