@bastani/atomic 0.8.28-alpha.1 → 0.8.28-alpha.3

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 (428) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +120 -118
  3. package/dist/builtin/intercom/package.json +1 -1
  4. package/dist/builtin/mcp/package.json +1 -1
  5. package/dist/builtin/subagents/package.json +1 -1
  6. package/dist/builtin/web-access/package.json +1 -1
  7. package/dist/builtin/workflows/CHANGELOG.md +26 -0
  8. package/dist/builtin/workflows/README.md +1 -1
  9. package/dist/builtin/workflows/builtin/open-claude-design.ts +150 -13
  10. package/dist/builtin/workflows/package.json +1 -1
  11. package/dist/builtin/workflows/src/authoring.d.ts +5 -2
  12. package/dist/builtin/workflows/src/extension/dispatcher.ts +2 -0
  13. package/dist/builtin/workflows/src/extension/index.ts +8 -0
  14. package/dist/builtin/workflows/src/extension/render-result.ts +5 -2
  15. package/dist/builtin/workflows/src/extension/workflow-schema.ts +18 -0
  16. package/dist/builtin/workflows/src/runs/background/status.ts +4 -0
  17. package/dist/builtin/workflows/src/runs/foreground/executor.ts +1251 -110
  18. package/dist/builtin/workflows/src/shared/authoring-contract.d.ts +34 -10
  19. package/dist/builtin/workflows/src/shared/expanded-workflow-graph.ts +10 -2
  20. package/dist/builtin/workflows/src/shared/persistence-restore.ts +28 -9
  21. package/dist/builtin/workflows/src/shared/persistence-session-entries.ts +9 -3
  22. package/dist/builtin/workflows/src/shared/store-types.ts +10 -3
  23. package/dist/builtin/workflows/src/shared/store.ts +29 -7
  24. package/dist/builtin/workflows/src/shared/types.ts +12 -10
  25. package/dist/builtin/workflows/src/tui/chat-surface.ts +32 -33
  26. package/dist/builtin/workflows/src/tui/run-detail.ts +23 -4
  27. package/dist/builtin/workflows/src/tui/status-helpers.ts +4 -0
  28. package/dist/builtin/workflows/src/tui/status-list.ts +47 -3
  29. package/dist/builtin/workflows/src/tui/store-widget-installer.ts +1 -1
  30. package/dist/builtin/workflows/src/tui/widget.ts +12 -3
  31. package/dist/builtin/workflows/src/workflows/define-workflow.ts +3 -3
  32. package/dist/cli/args.d.ts +4 -0
  33. package/dist/cli/args.d.ts.map +1 -1
  34. package/dist/cli/args.js +35 -0
  35. package/dist/cli/args.js.map +1 -1
  36. package/dist/cli/project-trust.d.ts +10 -0
  37. package/dist/cli/project-trust.d.ts.map +1 -0
  38. package/dist/cli/project-trust.js +36 -0
  39. package/dist/cli/project-trust.js.map +1 -0
  40. package/dist/cli/startup-ui.d.ts +7 -0
  41. package/dist/cli/startup-ui.d.ts.map +1 -0
  42. package/dist/cli/startup-ui.js +57 -0
  43. package/dist/cli/startup-ui.js.map +1 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/config.js +24 -3
  46. package/dist/config.js.map +1 -1
  47. package/dist/core/agent-session-runtime.d.ts +3 -1
  48. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  49. package/dist/core/agent-session-runtime.js +1 -0
  50. package/dist/core/agent-session-runtime.js.map +1 -1
  51. package/dist/core/agent-session-services.d.ts +3 -1
  52. package/dist/core/agent-session-services.d.ts.map +1 -1
  53. package/dist/core/agent-session-services.js +3 -2
  54. package/dist/core/agent-session-services.js.map +1 -1
  55. package/dist/core/agent-session.d.ts +9 -1
  56. package/dist/core/agent-session.d.ts.map +1 -1
  57. package/dist/core/agent-session.js +70 -21
  58. package/dist/core/agent-session.js.map +1 -1
  59. package/dist/core/auth-storage.d.ts.map +1 -1
  60. package/dist/core/auth-storage.js +4 -3
  61. package/dist/core/auth-storage.js.map +1 -1
  62. package/dist/core/compaction/branch-summarization.d.ts +3 -1
  63. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  64. package/dist/core/compaction/branch-summarization.js +9 -3
  65. package/dist/core/compaction/branch-summarization.js.map +1 -1
  66. package/dist/core/compaction/compaction.d.ts.map +1 -1
  67. package/dist/core/compaction/compaction.js +18 -24
  68. package/dist/core/compaction/compaction.js.map +1 -1
  69. package/dist/core/compaction/utils.d.ts +1 -1
  70. package/dist/core/compaction/utils.d.ts.map +1 -1
  71. package/dist/core/compaction/utils.js +1 -1
  72. package/dist/core/compaction/utils.js.map +1 -1
  73. package/dist/core/experimental.d.ts +2 -0
  74. package/dist/core/experimental.d.ts.map +1 -0
  75. package/dist/core/experimental.js +5 -0
  76. package/dist/core/experimental.js.map +1 -0
  77. package/dist/core/export-html/template.js +19 -6
  78. package/dist/core/extensions/index.d.ts +1 -1
  79. package/dist/core/extensions/index.d.ts.map +1 -1
  80. package/dist/core/extensions/index.js.map +1 -1
  81. package/dist/core/extensions/loader.d.ts +1 -1
  82. package/dist/core/extensions/loader.d.ts.map +1 -1
  83. package/dist/core/extensions/loader.js +6 -4
  84. package/dist/core/extensions/loader.js.map +1 -1
  85. package/dist/core/extensions/runner.d.ts +11 -4
  86. package/dist/core/extensions/runner.d.ts.map +1 -1
  87. package/dist/core/extensions/runner.js +53 -3
  88. package/dist/core/extensions/runner.js.map +1 -1
  89. package/dist/core/extensions/types.d.ts +34 -4
  90. package/dist/core/extensions/types.d.ts.map +1 -1
  91. package/dist/core/extensions/types.js.map +1 -1
  92. package/dist/core/footer-data-provider.d.ts +2 -0
  93. package/dist/core/footer-data-provider.d.ts.map +1 -1
  94. package/dist/core/footer-data-provider.js +27 -1
  95. package/dist/core/footer-data-provider.js.map +1 -1
  96. package/dist/core/index.d.ts +2 -0
  97. package/dist/core/index.d.ts.map +1 -1
  98. package/dist/core/index.js +2 -0
  99. package/dist/core/index.js.map +1 -1
  100. package/dist/core/model-registry.d.ts.map +1 -1
  101. package/dist/core/model-registry.js +64 -7
  102. package/dist/core/model-registry.js.map +1 -1
  103. package/dist/core/model-resolver.d.ts.map +1 -1
  104. package/dist/core/model-resolver.js +1 -0
  105. package/dist/core/model-resolver.js.map +1 -1
  106. package/dist/core/output-guard.d.ts +1 -0
  107. package/dist/core/output-guard.d.ts.map +1 -1
  108. package/dist/core/output-guard.js +52 -22
  109. package/dist/core/output-guard.js.map +1 -1
  110. package/dist/core/package-manager.d.ts +1 -0
  111. package/dist/core/package-manager.d.ts.map +1 -1
  112. package/dist/core/package-manager.js +20 -8
  113. package/dist/core/package-manager.js.map +1 -1
  114. package/dist/core/project-trust.d.ts +15 -0
  115. package/dist/core/project-trust.d.ts.map +1 -0
  116. package/dist/core/project-trust.js +58 -0
  117. package/dist/core/project-trust.js.map +1 -0
  118. package/dist/core/prompt-templates.d.ts +5 -4
  119. package/dist/core/prompt-templates.d.ts.map +1 -1
  120. package/dist/core/prompt-templates.js +30 -29
  121. package/dist/core/prompt-templates.js.map +1 -1
  122. package/dist/core/provider-attribution.d.ts +4 -0
  123. package/dist/core/provider-attribution.d.ts.map +1 -0
  124. package/dist/core/provider-attribution.js +73 -0
  125. package/dist/core/provider-attribution.js.map +1 -0
  126. package/dist/core/provider-display-names.d.ts.map +1 -1
  127. package/dist/core/provider-display-names.js +3 -0
  128. package/dist/core/provider-display-names.js.map +1 -1
  129. package/dist/core/resolve-config-value.d.ts +9 -1
  130. package/dist/core/resolve-config-value.d.ts.map +1 -1
  131. package/dist/core/resolve-config-value.js +134 -11
  132. package/dist/core/resolve-config-value.js.map +1 -1
  133. package/dist/core/resource-loader.d.ts +12 -2
  134. package/dist/core/resource-loader.d.ts.map +1 -1
  135. package/dist/core/resource-loader.js +108 -18
  136. package/dist/core/resource-loader.js.map +1 -1
  137. package/dist/core/sdk.d.ts +4 -2
  138. package/dist/core/sdk.d.ts.map +1 -1
  139. package/dist/core/sdk.js +13 -42
  140. package/dist/core/sdk.js.map +1 -1
  141. package/dist/core/session-manager.d.ts +6 -7
  142. package/dist/core/session-manager.d.ts.map +1 -1
  143. package/dist/core/session-manager.js +99 -35
  144. package/dist/core/session-manager.js.map +1 -1
  145. package/dist/core/settings-manager.d.ts +15 -2
  146. package/dist/core/settings-manager.d.ts.map +1 -1
  147. package/dist/core/settings-manager.js +69 -10
  148. package/dist/core/settings-manager.js.map +1 -1
  149. package/dist/core/slash-commands.d.ts.map +1 -1
  150. package/dist/core/slash-commands.js +1 -0
  151. package/dist/core/slash-commands.js.map +1 -1
  152. package/dist/core/system-prompt.d.ts.map +1 -1
  153. package/dist/core/system-prompt.js +0 -3
  154. package/dist/core/system-prompt.js.map +1 -1
  155. package/dist/core/tools/ask-user-question/state/inline-input.d.ts +28 -0
  156. package/dist/core/tools/ask-user-question/state/inline-input.d.ts.map +1 -0
  157. package/dist/core/tools/ask-user-question/state/inline-input.js +56 -0
  158. package/dist/core/tools/ask-user-question/state/inline-input.js.map +1 -0
  159. package/dist/core/tools/ask-user-question/state/key-router.d.ts.map +1 -1
  160. package/dist/core/tools/ask-user-question/state/key-router.js +30 -4
  161. package/dist/core/tools/ask-user-question/state/key-router.js.map +1 -1
  162. package/dist/core/tools/ask-user-question/state/questionnaire-session.d.ts.map +1 -1
  163. package/dist/core/tools/ask-user-question/state/questionnaire-session.js +9 -8
  164. package/dist/core/tools/ask-user-question/state/questionnaire-session.js.map +1 -1
  165. package/dist/core/tools/ask-user-question/state/row-intent.d.ts +3 -2
  166. package/dist/core/tools/ask-user-question/state/row-intent.d.ts.map +1 -1
  167. package/dist/core/tools/ask-user-question/state/row-intent.js +1 -1
  168. package/dist/core/tools/ask-user-question/state/row-intent.js.map +1 -1
  169. package/dist/core/tools/ask-user-question/state/selectors/contract.d.ts +2 -0
  170. package/dist/core/tools/ask-user-question/state/selectors/contract.d.ts.map +1 -1
  171. package/dist/core/tools/ask-user-question/state/selectors/contract.js.map +1 -1
  172. package/dist/core/tools/ask-user-question/state/selectors/projections.d.ts.map +1 -1
  173. package/dist/core/tools/ask-user-question/state/selectors/projections.js +2 -0
  174. package/dist/core/tools/ask-user-question/state/selectors/projections.js.map +1 -1
  175. package/dist/core/tools/ask-user-question/state/state-reducer.d.ts.map +1 -1
  176. package/dist/core/tools/ask-user-question/state/state-reducer.js +36 -24
  177. package/dist/core/tools/ask-user-question/state/state-reducer.js.map +1 -1
  178. package/dist/core/tools/ask-user-question/state/state.d.ts +8 -0
  179. package/dist/core/tools/ask-user-question/state/state.d.ts.map +1 -1
  180. package/dist/core/tools/ask-user-question/state/state.js.map +1 -1
  181. package/dist/core/tools/ask-user-question/tool/format-answer.d.ts +6 -0
  182. package/dist/core/tools/ask-user-question/tool/format-answer.d.ts.map +1 -1
  183. package/dist/core/tools/ask-user-question/tool/format-answer.js +19 -1
  184. package/dist/core/tools/ask-user-question/tool/format-answer.js.map +1 -1
  185. package/dist/core/tools/ask-user-question/tool/response-envelope.d.ts +3 -2
  186. package/dist/core/tools/ask-user-question/tool/response-envelope.d.ts.map +1 -1
  187. package/dist/core/tools/ask-user-question/tool/response-envelope.js +15 -3
  188. package/dist/core/tools/ask-user-question/tool/response-envelope.js.map +1 -1
  189. package/dist/core/tools/ask-user-question/tool/types.d.ts +2 -1
  190. package/dist/core/tools/ask-user-question/tool/types.d.ts.map +1 -1
  191. package/dist/core/tools/ask-user-question/tool/types.js.map +1 -1
  192. package/dist/core/tools/ask-user-question/view/components/chat-row-view.d.ts +5 -2
  193. package/dist/core/tools/ask-user-question/view/components/chat-row-view.d.ts.map +1 -1
  194. package/dist/core/tools/ask-user-question/view/components/chat-row-view.js +2 -0
  195. package/dist/core/tools/ask-user-question/view/components/chat-row-view.js.map +1 -1
  196. package/dist/core/tools/ask-user-question/view/components/wrapping-select.d.ts +1 -0
  197. package/dist/core/tools/ask-user-question/view/components/wrapping-select.d.ts.map +1 -1
  198. package/dist/core/tools/ask-user-question/view/components/wrapping-select.js +2 -1
  199. package/dist/core/tools/ask-user-question/view/components/wrapping-select.js.map +1 -1
  200. package/dist/core/tools/ask-user-question/view/props-adapter.d.ts +3 -3
  201. package/dist/core/tools/ask-user-question/view/props-adapter.d.ts.map +1 -1
  202. package/dist/core/tools/ask-user-question/view/props-adapter.js +11 -4
  203. package/dist/core/tools/ask-user-question/view/props-adapter.js.map +1 -1
  204. package/dist/core/tools/bash-policy.d.ts +62 -0
  205. package/dist/core/tools/bash-policy.d.ts.map +1 -0
  206. package/dist/core/tools/bash-policy.js +1069 -0
  207. package/dist/core/tools/bash-policy.js.map +1 -0
  208. package/dist/core/tools/bash.d.ts +5 -0
  209. package/dist/core/tools/bash.d.ts.map +1 -1
  210. package/dist/core/tools/bash.js +9 -1
  211. package/dist/core/tools/bash.js.map +1 -1
  212. package/dist/core/tools/edit.d.ts.map +1 -1
  213. package/dist/core/tools/edit.js +7 -10
  214. package/dist/core/tools/edit.js.map +1 -1
  215. package/dist/core/tools/find.d.ts.map +1 -1
  216. package/dist/core/tools/find.js +1 -1
  217. package/dist/core/tools/find.js.map +1 -1
  218. package/dist/core/tools/grep.d.ts.map +1 -1
  219. package/dist/core/tools/grep.js +1 -1
  220. package/dist/core/tools/grep.js.map +1 -1
  221. package/dist/core/tools/index.d.ts +1 -0
  222. package/dist/core/tools/index.d.ts.map +1 -1
  223. package/dist/core/tools/index.js +1 -0
  224. package/dist/core/tools/index.js.map +1 -1
  225. package/dist/core/tools/ls.d.ts.map +1 -1
  226. package/dist/core/tools/ls.js +1 -1
  227. package/dist/core/tools/ls.js.map +1 -1
  228. package/dist/core/tools/oversized-tool-result.d.ts +53 -0
  229. package/dist/core/tools/oversized-tool-result.d.ts.map +1 -0
  230. package/dist/core/tools/oversized-tool-result.js +206 -0
  231. package/dist/core/tools/oversized-tool-result.js.map +1 -0
  232. package/dist/core/tools/read.d.ts +12 -0
  233. package/dist/core/tools/read.d.ts.map +1 -1
  234. package/dist/core/tools/read.js +99 -34
  235. package/dist/core/tools/read.js.map +1 -1
  236. package/dist/core/tools/render-utils.d.ts +6 -0
  237. package/dist/core/tools/render-utils.d.ts.map +1 -1
  238. package/dist/core/tools/render-utils.js +17 -1
  239. package/dist/core/tools/render-utils.js.map +1 -1
  240. package/dist/core/tools/tool-definition-wrapper.d.ts +6 -0
  241. package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
  242. package/dist/core/tools/tool-definition-wrapper.js +2 -0
  243. package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
  244. package/dist/core/tools/tool-limits.d.ts +25 -0
  245. package/dist/core/tools/tool-limits.d.ts.map +1 -0
  246. package/dist/core/tools/tool-limits.js +25 -0
  247. package/dist/core/tools/tool-limits.js.map +1 -0
  248. package/dist/core/tools/write.d.ts.map +1 -1
  249. package/dist/core/tools/write.js +1 -1
  250. package/dist/core/tools/write.js.map +1 -1
  251. package/dist/core/trust-manager.d.ts +31 -0
  252. package/dist/core/trust-manager.d.ts.map +1 -0
  253. package/dist/core/trust-manager.js +196 -0
  254. package/dist/core/trust-manager.js.map +1 -0
  255. package/dist/index.d.ts +11 -6
  256. package/dist/index.d.ts.map +1 -1
  257. package/dist/index.js +6 -2
  258. package/dist/index.js.map +1 -1
  259. package/dist/main.d.ts.map +1 -1
  260. package/dist/main.js +142 -30
  261. package/dist/main.js.map +1 -1
  262. package/dist/migrations.d.ts +3 -1
  263. package/dist/migrations.d.ts.map +1 -1
  264. package/dist/migrations.js +325 -7
  265. package/dist/migrations.js.map +1 -1
  266. package/dist/modes/index.d.ts +1 -1
  267. package/dist/modes/index.d.ts.map +1 -1
  268. package/dist/modes/index.js.map +1 -1
  269. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
  270. package/dist/modes/interactive/components/bash-execution.js +2 -2
  271. package/dist/modes/interactive/components/bash-execution.js.map +1 -1
  272. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  273. package/dist/modes/interactive/components/footer.js +6 -0
  274. package/dist/modes/interactive/components/footer.js.map +1 -1
  275. package/dist/modes/interactive/components/index.d.ts +1 -0
  276. package/dist/modes/interactive/components/index.d.ts.map +1 -1
  277. package/dist/modes/interactive/components/index.js +1 -0
  278. package/dist/modes/interactive/components/index.js.map +1 -1
  279. package/dist/modes/interactive/components/login-dialog.d.ts +1 -1
  280. package/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  281. package/dist/modes/interactive/components/login-dialog.js +9 -16
  282. package/dist/modes/interactive/components/login-dialog.js.map +1 -1
  283. package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
  284. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  285. package/dist/modes/interactive/components/settings-selector.js +20 -0
  286. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  287. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  288. package/dist/modes/interactive/components/tool-execution.js +22 -0
  289. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  290. package/dist/modes/interactive/components/trust-selector.d.ts +23 -0
  291. package/dist/modes/interactive/components/trust-selector.d.ts.map +1 -0
  292. package/dist/modes/interactive/components/trust-selector.js +85 -0
  293. package/dist/modes/interactive/components/trust-selector.js.map +1 -0
  294. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  295. package/dist/modes/interactive/components/user-message.js +1 -1
  296. package/dist/modes/interactive/components/user-message.js.map +1 -1
  297. package/dist/modes/interactive/interactive-mode.d.ts +9 -0
  298. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  299. package/dist/modes/interactive/interactive-mode.js +130 -9
  300. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  301. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  302. package/dist/modes/interactive/theme/theme.js +10 -0
  303. package/dist/modes/interactive/theme/theme.js.map +1 -1
  304. package/dist/modes/print-mode.d.ts.map +1 -1
  305. package/dist/modes/print-mode.js +1 -0
  306. package/dist/modes/print-mode.js.map +1 -1
  307. package/dist/modes/rpc/rpc-client.d.ts +3 -0
  308. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  309. package/dist/modes/rpc/rpc-client.js +50 -6
  310. package/dist/modes/rpc/rpc-client.js.map +1 -1
  311. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  312. package/dist/modes/rpc/rpc-mode.js +23 -4
  313. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  314. package/dist/modes/rpc/rpc-types.d.ts +1 -0
  315. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  316. package/dist/modes/rpc/rpc-types.js.map +1 -1
  317. package/dist/package-manager-cli.d.ts +6 -2
  318. package/dist/package-manager-cli.d.ts.map +1 -1
  319. package/dist/package-manager-cli.js +104 -10
  320. package/dist/package-manager-cli.js.map +1 -1
  321. package/dist/utils/changelog.d.ts +1 -0
  322. package/dist/utils/changelog.d.ts.map +1 -1
  323. package/dist/utils/changelog.js +72 -0
  324. package/dist/utils/changelog.js.map +1 -1
  325. package/dist/utils/deprecation.d.ts +4 -0
  326. package/dist/utils/deprecation.d.ts.map +1 -0
  327. package/dist/utils/deprecation.js +13 -0
  328. package/dist/utils/deprecation.js.map +1 -0
  329. package/dist/utils/git.d.ts.map +1 -1
  330. package/dist/utils/git.js +54 -22
  331. package/dist/utils/git.js.map +1 -1
  332. package/dist/utils/json.d.ts +3 -0
  333. package/dist/utils/json.d.ts.map +1 -0
  334. package/dist/utils/json.js +7 -0
  335. package/dist/utils/json.js.map +1 -0
  336. package/dist/utils/open-browser.d.ts +9 -0
  337. package/dist/utils/open-browser.d.ts.map +1 -0
  338. package/dist/utils/open-browser.js +22 -0
  339. package/dist/utils/open-browser.js.map +1 -0
  340. package/docs/containerization.md +111 -0
  341. package/docs/custom-provider.md +9 -9
  342. package/docs/development.md +1 -1
  343. package/docs/docs.json +2 -0
  344. package/docs/extensions.md +40 -4
  345. package/docs/index.md +2 -0
  346. package/docs/models.md +10 -10
  347. package/docs/packages.md +1 -1
  348. package/docs/prompt-templates.md +9 -2
  349. package/docs/providers.md +18 -5
  350. package/docs/quickstart.md +1 -0
  351. package/docs/rpc.md +3 -2
  352. package/docs/sdk.md +47 -0
  353. package/docs/security.md +58 -0
  354. package/docs/session-format.md +2 -2
  355. package/docs/sessions.md +8 -0
  356. package/docs/settings.md +21 -4
  357. package/docs/skills.md +1 -1
  358. package/docs/terminal-setup.md +44 -2
  359. package/docs/themes.md +1 -1
  360. package/docs/tmux.md +4 -2
  361. package/docs/tui.md +14 -5
  362. package/docs/usage.md +17 -3
  363. package/docs/workflows.md +127 -15
  364. package/examples/README.md +1 -1
  365. package/examples/extensions/README.md +8 -5
  366. package/examples/extensions/bash-spawn-hook.ts +1 -1
  367. package/examples/extensions/built-in-tool-renderer.ts +1 -1
  368. package/examples/extensions/claude-rules.ts +1 -1
  369. package/examples/extensions/commands.ts +1 -1
  370. package/examples/extensions/custom-header.ts +1 -1
  371. package/examples/extensions/custom-provider-anthropic/index.ts +3 -3
  372. package/examples/extensions/custom-provider-anthropic/package-lock.json +4 -4
  373. package/examples/extensions/custom-provider-anthropic/package.json +6 -6
  374. package/examples/extensions/custom-provider-gitlab-duo/index.ts +55 -4
  375. package/examples/extensions/custom-provider-gitlab-duo/package.json +3 -3
  376. package/examples/extensions/doom-overlay/README.md +1 -1
  377. package/examples/extensions/doom-overlay/index.ts +2 -2
  378. package/examples/extensions/git-merge-and-resolve.ts +115 -0
  379. package/examples/extensions/gondolin/index.ts +523 -0
  380. package/examples/extensions/gondolin/package-lock.json +185 -0
  381. package/examples/extensions/gondolin/package.json +19 -0
  382. package/examples/extensions/handoff.ts +1 -1
  383. package/examples/extensions/hidden-thinking-label.ts +1 -1
  384. package/examples/extensions/inline-bash.ts +2 -2
  385. package/examples/extensions/input-transform-streaming.ts +39 -0
  386. package/examples/extensions/input-transform.ts +3 -3
  387. package/examples/extensions/interactive-shell.ts +2 -2
  388. package/examples/extensions/mac-system-theme.ts +2 -2
  389. package/examples/extensions/minimal-mode.ts +1 -1
  390. package/examples/extensions/modal-editor.ts +1 -1
  391. package/examples/extensions/model-status.ts +1 -1
  392. package/examples/extensions/overlay-qa-tests.ts +198 -179
  393. package/examples/extensions/overlay-test.ts +1 -1
  394. package/examples/extensions/pirate.ts +1 -1
  395. package/examples/extensions/preset.ts +14 -12
  396. package/examples/extensions/project-trust.ts +64 -0
  397. package/examples/extensions/prompt-customizer.ts +1 -1
  398. package/examples/extensions/qna.ts +1 -1
  399. package/examples/extensions/question.ts +1 -1
  400. package/examples/extensions/questionnaire.ts +1 -1
  401. package/examples/extensions/rainbow-editor.ts +1 -1
  402. package/examples/extensions/sandbox/index.ts +16 -14
  403. package/examples/extensions/sandbox/package-lock.json +90 -90
  404. package/examples/extensions/sandbox/package.json +17 -17
  405. package/examples/extensions/snake.ts +1 -1
  406. package/examples/extensions/space-invaders.ts +1 -1
  407. package/examples/extensions/ssh.ts +2 -2
  408. package/examples/extensions/subagent/README.md +13 -13
  409. package/examples/extensions/subagent/agents.ts +4 -2
  410. package/examples/extensions/subagent/index.ts +6 -6
  411. package/examples/extensions/summarize.ts +1 -1
  412. package/examples/extensions/tic-tac-toe.ts +1 -1
  413. package/examples/extensions/titlebar-spinner.ts +1 -1
  414. package/examples/extensions/todo.ts +1 -1
  415. package/examples/extensions/tool-override.ts +1 -1
  416. package/examples/extensions/tools.ts +6 -1
  417. package/examples/extensions/with-deps/package-lock.json +4 -4
  418. package/examples/extensions/with-deps/package.json +7 -7
  419. package/examples/extensions/working-indicator.ts +4 -4
  420. package/examples/extensions/working-message-test.ts +1 -1
  421. package/examples/sdk/01-minimal.ts +1 -1
  422. package/examples/sdk/03-custom-prompt.ts +1 -1
  423. package/examples/sdk/04-skills.ts +1 -1
  424. package/examples/sdk/06-extensions.ts +2 -2
  425. package/examples/sdk/08-prompt-templates.ts +1 -1
  426. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  427. package/examples/sdk/README.md +2 -2
  428. package/package.json +4 -4
@@ -40,6 +40,8 @@ import type {
40
40
  WorkflowExecutionMode,
41
41
  WorkflowRunChildOptions,
42
42
  WorkflowChildResult,
43
+ WorkflowExitOptions,
44
+ WorkflowExitStatus,
43
45
  WorkflowOutputSchema,
44
46
  WorkflowOutputValues,
45
47
  WorkflowInputValues,
@@ -98,6 +100,7 @@ import type { WorkflowFailure } from "../../shared/workflow-failures.js";
98
100
  import { classifyWorkflowFailure } from "../../shared/workflow-failures.js";
99
101
  import { selectPromptCallsiteFrame } from "../shared/prompt-callsite.js";
100
102
  import {
103
+ WORKFLOW_SERIALIZABLE_DESCRIPTION,
101
104
  assertWorkflowSerializableObject,
102
105
  workflowSerializableValidationError,
103
106
  workflowSerializableTypeName,
@@ -195,7 +198,7 @@ export interface RunOpts extends Omit<AuthoringContract.RunOpts, "adapters" | "s
195
198
  onRunStart?: (snapshot: RunSnapshot) => void;
196
199
  onStageStart?: (runId: string, snapshot: StageSnapshot) => void;
197
200
  onStageEnd?: (runId: string, snapshot: StageSnapshot) => void;
198
- onRunEnd?: (runId: string, status: RunStatus, result?: WorkflowOutputValues, error?: string) => void;
201
+ onRunEnd?: (runId: string, status: RunStatus, result?: WorkflowOutputValues, error?: string, exitReason?: string) => void;
199
202
  }
200
203
 
201
204
  export interface RunResult {
@@ -203,9 +206,393 @@ export interface RunResult {
203
206
  readonly status: RunStatus;
204
207
  readonly result?: WorkflowOutputValues;
205
208
  readonly error?: string;
209
+ /** True when the run reached its terminal status through ctx.exit(). */
210
+ readonly exited?: boolean;
211
+ readonly exitReason?: string;
206
212
  readonly stages: StageSnapshot[];
207
213
  }
208
214
 
215
+ const WORKFLOW_EXIT_SIGNAL = Symbol("atomic-workflows.workflow-exit-signal");
216
+ const WORKFLOW_EXIT_STATUSES: ReadonlySet<WorkflowExitStatus> = new Set([
217
+ "completed",
218
+ "skipped",
219
+ "cancelled",
220
+ "blocked",
221
+ ]);
222
+
223
+ type WorkflowExitOutputSnapshot =
224
+ | {
225
+ readonly ok: true;
226
+ readonly value: unknown;
227
+ }
228
+ | {
229
+ readonly ok: false;
230
+ readonly error: Error;
231
+ };
232
+
233
+ interface WorkflowExitSignal {
234
+ readonly [WORKFLOW_EXIT_SIGNAL]: true;
235
+ readonly scope: symbol;
236
+ readonly status: WorkflowExitStatus;
237
+ readonly reason?: string;
238
+ readonly outputSnapshot?: WorkflowExitOutputSnapshot;
239
+ readonly validationError?: Error;
240
+ }
241
+
242
+ const WORKFLOW_EXIT_SNAPSHOT_INVALID_VALUE = Symbol("atomic-workflows.workflow-exit-snapshot-invalid-value");
243
+
244
+ interface WorkflowExitSnapshotInvalidValue {
245
+ readonly [WORKFLOW_EXIT_SNAPSHOT_INVALID_VALUE]: true;
246
+ readonly typeName: string;
247
+ }
248
+
249
+ type SafePropertyRead =
250
+ | { readonly ok: true; readonly value: unknown }
251
+ | { readonly ok: false };
252
+
253
+ function safeGetProperty(value: object, key: PropertyKey): SafePropertyRead {
254
+ try {
255
+ return { ok: true, value: (value as Record<PropertyKey, unknown>)[key] };
256
+ } catch {
257
+ return { ok: false };
258
+ }
259
+ }
260
+
261
+ function unknownErrorMessage(error: unknown): string {
262
+ if (error !== null && (typeof error === "object" || typeof error === "function")) {
263
+ const message = safeGetProperty(error, "message");
264
+ if (message.ok && typeof message.value === "string" && message.value.length > 0) {
265
+ return message.value;
266
+ }
267
+ }
268
+ if (typeof error === "string") return error;
269
+ try {
270
+ return String(error);
271
+ } catch {
272
+ return "<unprintable thrown value>";
273
+ }
274
+ }
275
+
276
+ function workflowExitSnapshotError(message: string, cause: unknown): Error {
277
+ return new Error(`${message}: ${unknownErrorMessage(cause)}`, { cause });
278
+ }
279
+
280
+ function workflowExitOptionReadError(key: "status" | "reason" | "outputs", cause: unknown): Error {
281
+ return workflowExitSnapshotError(`atomic-workflows: ctx.exit() ${key} option could not be read`, cause);
282
+ }
283
+
284
+ function readWorkflowExitOption(
285
+ options: { readonly status?: unknown; readonly reason?: unknown; readonly outputs?: unknown } | null | undefined,
286
+ key: "status" | "reason" | "outputs",
287
+ ): { readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly error: Error } {
288
+ try {
289
+ return { ok: true, value: options?.[key] };
290
+ } catch (err) {
291
+ return { ok: false, error: workflowExitOptionReadError(key, err) };
292
+ }
293
+ }
294
+
295
+ function describeWorkflowExitOptionValue(value: unknown): string {
296
+ try {
297
+ const json = JSON.stringify(value);
298
+ if (json !== undefined) return json;
299
+ } catch {
300
+ // Fall back to a coarse type name below. This path is diagnostic only and
301
+ // must never make ctx.exit() throw before workflow-exit cleanup can run.
302
+ }
303
+ return workflowSerializableTypeName(value);
304
+ }
305
+
306
+ function isPlainWorkflowExitSnapshotObject(value: object): boolean {
307
+ const proto = Object.getPrototypeOf(value);
308
+ return proto === Object.prototype || proto === null;
309
+ }
310
+
311
+ function makeWorkflowExitSnapshotInvalidValue(typeName: string): WorkflowExitSnapshotInvalidValue {
312
+ const marker = {} as { [WORKFLOW_EXIT_SNAPSHOT_INVALID_VALUE]?: true; typeName?: string };
313
+ Object.defineProperty(marker, WORKFLOW_EXIT_SNAPSHOT_INVALID_VALUE, {
314
+ value: true,
315
+ enumerable: false,
316
+ });
317
+ Object.defineProperty(marker, "typeName", {
318
+ value: typeName,
319
+ enumerable: false,
320
+ });
321
+ return Object.freeze(marker) as WorkflowExitSnapshotInvalidValue;
322
+ }
323
+
324
+ function isWorkflowExitSnapshotInvalidValue(value: unknown): value is WorkflowExitSnapshotInvalidValue {
325
+ return value !== null && typeof value === "object" &&
326
+ (value as Record<PropertyKey, unknown>)[WORKFLOW_EXIT_SNAPSHOT_INVALID_VALUE] === true;
327
+ }
328
+
329
+ function cloneWorkflowExitSnapshotValue(
330
+ value: unknown,
331
+ seen: Map<object, unknown>,
332
+ stack: Set<object> = new Set(),
333
+ ): unknown {
334
+ if (value === null) return null;
335
+ const valueType = typeof value;
336
+ if (valueType !== "object") {
337
+ return valueType === "function"
338
+ ? makeWorkflowExitSnapshotInvalidValue("function")
339
+ : value;
340
+ }
341
+
342
+ const objectValue = value as object;
343
+ const previousClone = seen.get(objectValue);
344
+ if (previousClone !== undefined) {
345
+ return stack.has(objectValue)
346
+ ? makeWorkflowExitSnapshotInvalidValue("circular object")
347
+ : previousClone;
348
+ }
349
+
350
+ if (Array.isArray(value)) {
351
+ const clone: unknown[] = [];
352
+ seen.set(objectValue, clone);
353
+ stack.add(objectValue);
354
+ try {
355
+ for (let index = 0; index < value.length; index += 1) {
356
+ clone[index] = cloneWorkflowExitSnapshotValue(value[index], seen, stack);
357
+ }
358
+ } finally {
359
+ stack.delete(objectValue);
360
+ }
361
+ return clone;
362
+ }
363
+
364
+ if (!isPlainWorkflowExitSnapshotObject(objectValue)) {
365
+ return makeWorkflowExitSnapshotInvalidValue(workflowSerializableTypeName(value));
366
+ }
367
+
368
+ const clone: Record<string, unknown> = {};
369
+ seen.set(objectValue, clone);
370
+ stack.add(objectValue);
371
+ try {
372
+ for (const key of Object.keys(value as Record<string, unknown>)) {
373
+ clone[key] = cloneWorkflowExitSnapshotValue((value as Record<string, unknown>)[key], seen, stack);
374
+ }
375
+ } finally {
376
+ stack.delete(objectValue);
377
+ }
378
+ return clone;
379
+ }
380
+
381
+ // Recursively freeze the (already-private) deep clone so the snapshot stored on
382
+ // the thrown WorkflowExitSignal is immutable. Combined with freezing the signal
383
+ // object itself, this stops author code that catches ctx.exit()'s signal from
384
+ // rewriting the captured outputs before finalization reads them (finalization
385
+ // recovers the same object via the abort reason / rethrow, and the reconstruction
386
+ // path reads `outputSnapshot.value` by reference). The clone is acyclic — cycles
387
+ // became frozen invalid-value markers — and the `Object.isFrozen` short-circuit
388
+ // keeps shared (DAG) nodes terminating.
389
+ function deepFreezeWorkflowExitSnapshotValue(value: unknown): void {
390
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return;
391
+ Object.freeze(value);
392
+ if (Array.isArray(value)) {
393
+ for (const item of value) deepFreezeWorkflowExitSnapshotValue(item);
394
+ return;
395
+ }
396
+ for (const key of Object.keys(value as Record<string, unknown>)) {
397
+ deepFreezeWorkflowExitSnapshotValue((value as Record<string, unknown>)[key]);
398
+ }
399
+ }
400
+
401
+ function freezeWorkflowExitOutputSnapshot(snapshot: WorkflowExitOutputSnapshot): WorkflowExitOutputSnapshot {
402
+ return Object.freeze(snapshot);
403
+ }
404
+
405
+ function captureWorkflowExitOutputSnapshot(rawOutputs: unknown): WorkflowExitOutputSnapshot {
406
+ let snapshot: WorkflowExitOutputSnapshot;
407
+ try {
408
+ const value = cloneWorkflowExitSnapshotValue(rawOutputs, new Map());
409
+ deepFreezeWorkflowExitSnapshotValue(value);
410
+ snapshot = { ok: true, value };
411
+ } catch (err) {
412
+ snapshot = {
413
+ ok: false,
414
+ error: workflowExitSnapshotError("atomic-workflows: ctx.exit() outputs could not be snapshotted", err),
415
+ };
416
+ }
417
+ return freezeWorkflowExitOutputSnapshot(snapshot);
418
+ }
419
+
420
+ function formatWorkflowExitSnapshotPath(parent: string, key: string): string {
421
+ // `segment` already encodes the structure: bracketed for numeric/non-identifier keys,
422
+ // dotted for identifiers with a parent, and the bare key for an identifier at the root
423
+ // (where `parent === ""` so `segment === key`). Every case therefore reduces to the
424
+ // concatenation below.
425
+ const segment = /^\d+$/.test(key)
426
+ ? `[${key}]`
427
+ : /^[A-Za-z_$][\w$]*$/.test(key)
428
+ ? (parent.length > 0 ? `.${key}` : key)
429
+ : `[${JSON.stringify(key)}]`;
430
+ return `${parent}${segment}`;
431
+ }
432
+
433
+ function findWorkflowExitSnapshotInvalidValue(
434
+ value: unknown,
435
+ path = "",
436
+ seen = new Set<unknown>(),
437
+ ): { readonly path: string; readonly typeName: string } | undefined {
438
+ if (isWorkflowExitSnapshotInvalidValue(value)) {
439
+ return { path, typeName: value.typeName };
440
+ }
441
+ if (value === null || typeof value !== "object") return undefined;
442
+ if (seen.has(value)) return undefined;
443
+ seen.add(value);
444
+
445
+ if (Array.isArray(value)) {
446
+ for (let index = 0; index < value.length; index += 1) {
447
+ const found = findWorkflowExitSnapshotInvalidValue(value[index], `${path}[${index}]`, seen);
448
+ if (found !== undefined) return found;
449
+ }
450
+ return undefined;
451
+ }
452
+
453
+ for (const key of Object.keys(value as Record<string, unknown>)) {
454
+ const found = findWorkflowExitSnapshotInvalidValue(
455
+ (value as Record<string, unknown>)[key],
456
+ formatWorkflowExitSnapshotPath(path, key),
457
+ seen,
458
+ );
459
+ if (found !== undefined) return found;
460
+ }
461
+ return undefined;
462
+ }
463
+
464
+ function workflowExitSnapshotInvalidValueMessage(label: string, value: unknown): string | undefined {
465
+ const invalid = findWorkflowExitSnapshotInvalidValue(value);
466
+ if (invalid === undefined) return undefined;
467
+ const location = invalid.path.length > 0 ? ` at ${invalid.path}` : "";
468
+ return `${label}${location} must be ${WORKFLOW_SERIALIZABLE_DESCRIPTION}, got ${invalid.typeName}`;
469
+ }
470
+
471
+ const PARENT_WORKFLOW_EXIT_ABORT = Symbol("atomic-workflows.parent-workflow-exit-abort");
472
+
473
+ interface ParentWorkflowExitAbortReason extends Error {
474
+ readonly [PARENT_WORKFLOW_EXIT_ABORT]: true;
475
+ readonly workflowExitReason?: string;
476
+ }
477
+
478
+ function parentWorkflowExitRunReason(reason?: string): string {
479
+ return reason === undefined || reason.length === 0
480
+ ? "parent workflow exited"
481
+ : `parent workflow exited: ${reason}`;
482
+ }
483
+
484
+ function makeParentWorkflowExitAbortReason(reason?: string): ParentWorkflowExitAbortReason {
485
+ const error = new Error(parentWorkflowExitRunReason(reason)) as ParentWorkflowExitAbortReason & {
486
+ [PARENT_WORKFLOW_EXIT_ABORT]: true;
487
+ workflowExitReason?: string;
488
+ };
489
+ Object.defineProperty(error, PARENT_WORKFLOW_EXIT_ABORT, {
490
+ value: true,
491
+ enumerable: false,
492
+ });
493
+ if (reason !== undefined) error.workflowExitReason = reason;
494
+ return error;
495
+ }
496
+
497
+ interface ParentWorkflowExitAbortProbe {
498
+ readonly workflowExitReason?: string;
499
+ }
500
+
501
+ function parentWorkflowExitAbortReason(value: unknown): ParentWorkflowExitAbortProbe | undefined {
502
+ if (value === null || (typeof value !== "object" && typeof value !== "function")) return undefined;
503
+ const marker = safeGetProperty(value, PARENT_WORKFLOW_EXIT_ABORT);
504
+ if (!marker.ok || marker.value !== true) return undefined;
505
+
506
+ const reason = safeGetProperty(value, "workflowExitReason");
507
+ return reason.ok && typeof reason.value === "string"
508
+ ? { workflowExitReason: reason.value }
509
+ : {};
510
+ }
511
+
512
+ function isWorkflowExitStatus(value: unknown): value is WorkflowExitStatus {
513
+ return typeof value === "string" && WORKFLOW_EXIT_STATUSES.has(value as WorkflowExitStatus);
514
+ }
515
+
516
+ function safeErrorValue(value: unknown): Error {
517
+ try {
518
+ if (value instanceof Error) return value;
519
+ } catch {
520
+ // Fall through to a safe wrapper below.
521
+ }
522
+ return new Error(unknownErrorMessage(value));
523
+ }
524
+
525
+ function readWorkflowExitOutputSnapshot(value: unknown): WorkflowExitOutputSnapshot | undefined {
526
+ if (value === undefined) return undefined;
527
+ if (value === null || (typeof value !== "object" && typeof value !== "function")) return undefined;
528
+ const ok = safeGetProperty(value, "ok");
529
+ if (!ok.ok) return undefined;
530
+ if (ok.value === true) {
531
+ const snapshotValue = safeGetProperty(value, "value");
532
+ return snapshotValue.ok ? { ok: true, value: snapshotValue.value } : undefined;
533
+ }
534
+ if (ok.value === false) {
535
+ const error = safeGetProperty(value, "error");
536
+ return error.ok ? { ok: false, error: safeErrorValue(error.value) } : undefined;
537
+ }
538
+ return undefined;
539
+ }
540
+
541
+ function readWorkflowExitSignalCandidate(value: object, scope: symbol): WorkflowExitSignal | undefined {
542
+ const marker = safeGetProperty(value, WORKFLOW_EXIT_SIGNAL);
543
+ if (!marker.ok || marker.value !== true) return undefined;
544
+
545
+ const signalScope = safeGetProperty(value, "scope");
546
+ if (!signalScope.ok || signalScope.value !== scope) return undefined;
547
+
548
+ const status = safeGetProperty(value, "status");
549
+ if (!status.ok || !isWorkflowExitStatus(status.value)) return undefined;
550
+
551
+ const reason = safeGetProperty(value, "reason");
552
+ if (!reason.ok || (reason.value !== undefined && typeof reason.value !== "string")) return undefined;
553
+
554
+ const outputSnapshotValue = safeGetProperty(value, "outputSnapshot");
555
+ if (!outputSnapshotValue.ok) return undefined;
556
+ const outputSnapshot = readWorkflowExitOutputSnapshot(outputSnapshotValue.value);
557
+ if (outputSnapshotValue.value !== undefined && outputSnapshot === undefined) return undefined;
558
+
559
+ const validationError = safeGetProperty(value, "validationError");
560
+ if (!validationError.ok) return undefined;
561
+
562
+ return {
563
+ [WORKFLOW_EXIT_SIGNAL]: true,
564
+ scope,
565
+ status: status.value,
566
+ ...(reason.value !== undefined ? { reason: reason.value } : {}),
567
+ ...(outputSnapshot !== undefined ? { outputSnapshot } : {}),
568
+ ...(validationError.value !== undefined ? { validationError: safeErrorValue(validationError.value) } : {}),
569
+ };
570
+ }
571
+
572
+ function findWorkflowExitSignal(error: unknown, scope: symbol, seen = new Set<unknown>()): WorkflowExitSignal | undefined {
573
+ if (error === null || (typeof error !== "object" && typeof error !== "function")) return undefined;
574
+ if (seen.has(error)) return undefined;
575
+ seen.add(error);
576
+
577
+ const directSignal = readWorkflowExitSignalCandidate(error, scope);
578
+ if (directSignal !== undefined) return directSignal;
579
+
580
+ const errors = safeExecutorAggregateErrorItems(error);
581
+ for (const item of errors) {
582
+ const signal = findWorkflowExitSignal(item, scope, seen);
583
+ if (signal !== undefined) return signal;
584
+ }
585
+
586
+ const cause = safeGetProperty(error, "cause");
587
+ if (cause.ok) {
588
+ const causeSignal = findWorkflowExitSignal(cause.value, scope, seen);
589
+ if (causeSignal !== undefined) return causeSignal;
590
+ }
591
+
592
+ const reason = safeGetProperty(error, "reason");
593
+ return reason.ok ? findWorkflowExitSignal(reason.value, scope, seen) : undefined;
594
+ }
595
+
209
596
  // ---------------------------------------------------------------------------
210
597
  // Input resolution / validation
211
598
  // ---------------------------------------------------------------------------
@@ -434,9 +821,12 @@ function mergeHilSignals(primary: AbortSignal, secondary: AbortSignal | undefine
434
821
  };
435
822
  }
436
823
 
437
- function makeUnavailableUIContext(): WorkflowUIContext {
438
- const msg = (primitive: string): string =>
439
- `atomic-workflows: HIL ctx.ui.${primitive} is unavailable because Atomic runtime did not provide a UI adapter`;
824
+ /**
825
+ * Build a UI context whose every interactive primitive rejects with a clear,
826
+ * actionable error. Parameterized by `msg` so the "no UI adapter" and
827
+ * "headless mode" variants share one implementation and never drift (#1339).
828
+ */
829
+ function makeRejectingUIContext(msg: (primitive: string) => string): WorkflowUIContext {
440
830
  return {
441
831
  input: () => Promise.reject(new Error(msg("input"))),
442
832
  confirm: () => Promise.reject(new Error(msg("confirm"))),
@@ -446,24 +836,55 @@ function makeUnavailableUIContext(): WorkflowUIContext {
446
836
  };
447
837
  }
448
838
 
839
+ function makeUnavailableUIContext(): WorkflowUIContext {
840
+ return makeRejectingUIContext(
841
+ (primitive) =>
842
+ `atomic-workflows: HIL ctx.ui.${primitive} is unavailable because Atomic runtime did not provide a UI adapter`,
843
+ );
844
+ }
845
+
846
+ /**
847
+ * UI context for headless (non-interactive) runs without a UI adapter: every
848
+ * interactive primitive fails with a clear, actionable error that names the
849
+ * headless mode instead of surfacing a raw
850
+ * `TypeError: ctx.ui.custom is not a function` from a missing TUI (#1339).
851
+ */
852
+ function makeHeadlessUnavailableUIContext(): WorkflowUIContext {
853
+ return makeRejectingUIContext(
854
+ (primitive) =>
855
+ `atomic-workflows: interactive ctx.ui.${primitive} is unavailable in headless (non-interactive) mode; run the workflow in interactive mode or remove the interactive prompt from this stage`,
856
+ );
857
+ }
858
+
449
859
  function normalizeUIContext(adapter: WorkflowUIAdapter | undefined): WorkflowUIContext {
450
860
  const unavailable = makeUnavailableUIContext();
451
861
  if (adapter === undefined) return unavailable;
862
+ // Guard every method: loosely-typed callers can hand over partial adapters
863
+ // (headless hosts especially), and an unguarded call would surface a raw
864
+ // "x is not a function" TypeError that kills the whole run (#1339).
452
865
  return {
453
866
  input(prompt) {
454
- return adapter.input.call(adapter, prompt);
867
+ return typeof adapter.input === "function"
868
+ ? adapter.input.call(adapter, prompt)
869
+ : unavailable.input(prompt);
455
870
  },
456
871
  confirm(message) {
457
- return adapter.confirm.call(adapter, message);
872
+ return typeof adapter.confirm === "function"
873
+ ? adapter.confirm.call(adapter, message)
874
+ : unavailable.confirm(message);
458
875
  },
459
876
  select<T extends string>(message: string, options: readonly T[]): Promise<T> {
460
- return adapter.select.call(adapter, message, options) as Promise<T>;
877
+ return typeof adapter.select === "function"
878
+ ? adapter.select.call(adapter, message, options) as Promise<T>
879
+ : unavailable.select(message, options);
461
880
  },
462
881
  editor(initial) {
463
- return adapter.editor.call(adapter, initial);
882
+ return typeof adapter.editor === "function"
883
+ ? adapter.editor.call(adapter, initial)
884
+ : unavailable.editor(initial);
464
885
  },
465
886
  custom<T>(factory: WorkflowCustomUiFactory<T>, options?: WorkflowCustomUiOptions): Promise<T> {
466
- return adapter.custom !== undefined
887
+ return typeof adapter.custom === "function"
467
888
  ? adapter.custom.call(adapter, factory, options) as Promise<T>
468
889
  : unavailable.custom(factory, options);
469
890
  },
@@ -955,6 +1376,11 @@ async function mapParallelSteps<T>(
955
1376
  failFast: boolean | undefined,
956
1377
  mapper: (step: WorkflowTaskStep) => Promise<T>,
957
1378
  onFirstFailure?: (error: unknown) => void,
1379
+ control?: {
1380
+ readonly beforeDequeue?: () => void;
1381
+ readonly beforeMap?: () => void;
1382
+ readonly isControlSignal?: (error: unknown) => boolean;
1383
+ },
958
1384
  ): Promise<T[]> {
959
1385
  const limit = positiveConcurrency(concurrency) ?? steps.length;
960
1386
  const failFastEnabled = failFast !== false;
@@ -962,27 +1388,55 @@ async function mapParallelSteps<T>(
962
1388
  const failures: Array<{ readonly index: number; readonly error: unknown }> = [];
963
1389
  let nextIndex = 0;
964
1390
  let firstFailure: unknown;
1391
+ let controlSignal: unknown;
965
1392
  let rejectFirstFailure: (reason: unknown) => void = () => {};
966
1393
  const firstFailurePromise = new Promise<never>((_, reject) => {
967
1394
  rejectFirstFailure = reject;
968
1395
  });
969
1396
 
1397
+ const isControlSignal = (error: unknown): boolean => control?.isControlSignal?.(error) === true;
1398
+ const selectControlSignal = (error: unknown): void => {
1399
+ if (controlSignal !== undefined) return;
1400
+ controlSignal = error;
1401
+ if (failFastEnabled) rejectFirstFailure(error);
1402
+ };
1403
+ const recordFailure = (index: number, error: unknown): void => {
1404
+ failures.push({ index, error });
1405
+ if (firstFailure === undefined) {
1406
+ firstFailure = error;
1407
+ onFirstFailure?.(error);
1408
+ if (failFastEnabled) rejectFirstFailure(error);
1409
+ }
1410
+ };
1411
+
970
1412
  async function worker(): Promise<void> {
971
1413
  while (true) {
1414
+ if (controlSignal !== undefined) return;
972
1415
  if (failFastEnabled && firstFailure !== undefined) return;
1416
+ try {
1417
+ control?.beforeDequeue?.();
1418
+ } catch (err) {
1419
+ if (isControlSignal(err)) {
1420
+ selectControlSignal(err);
1421
+ return;
1422
+ }
1423
+ recordFailure(nextIndex, err);
1424
+ return;
1425
+ }
1426
+ if (controlSignal !== undefined) return;
973
1427
  const index = nextIndex;
974
1428
  nextIndex += 1;
975
1429
  const step = steps[index];
976
1430
  if (step === undefined) return;
977
1431
  try {
1432
+ control?.beforeMap?.();
978
1433
  results[index] = await mapper(step);
979
1434
  } catch (err) {
980
- failures.push({ index, error: err });
981
- if (firstFailure === undefined) {
982
- firstFailure = err;
983
- onFirstFailure?.(err);
984
- if (failFastEnabled) rejectFirstFailure(err);
1435
+ if (isControlSignal(err)) {
1436
+ selectControlSignal(err);
1437
+ return;
985
1438
  }
1439
+ recordFailure(index, err);
986
1440
  if (failFastEnabled) return;
987
1441
  }
988
1442
  }
@@ -1002,6 +1456,10 @@ async function mapParallelSteps<T>(
1002
1456
  }
1003
1457
  }
1004
1458
 
1459
+ if (controlSignal !== undefined) {
1460
+ throw controlSignal;
1461
+ }
1462
+
1005
1463
  if (failures.length > 0) {
1006
1464
  throw new AggregateError(
1007
1465
  failures.map((failure) => failure.error),
@@ -1263,8 +1721,8 @@ function workflowDetailsFromRun(
1263
1721
  mode,
1264
1722
  action: "run",
1265
1723
  runId: runResult.runId,
1266
- status: runResult.status === "completed"
1267
- ? "completed"
1724
+ status: isWorkflowExitStatus(runResult.status)
1725
+ ? runResult.status
1268
1726
  : runResult.status === "failed"
1269
1727
  ? "failed"
1270
1728
  : runResult.status === "killed"
@@ -1277,6 +1735,8 @@ function workflowDetailsFromRun(
1277
1735
  ...(artifacts.length > 0 ? { artifacts } : {}),
1278
1736
  ...(allWarnings.length > 0 ? { warnings: allWarnings } : {}),
1279
1737
  ...(runResult.error !== undefined ? { error: runResult.error } : {}),
1738
+ ...(runResult.exited !== undefined ? { exited: runResult.exited } : {}),
1739
+ ...(runResult.exitReason !== undefined ? { exitReason: runResult.exitReason } : {}),
1280
1740
  };
1281
1741
  }
1282
1742
 
@@ -1560,6 +2020,8 @@ function appendRunEndWhenRecorded(
1560
2020
  readonly status: RunStatus;
1561
2021
  readonly result?: WorkflowOutputValues;
1562
2022
  readonly error?: string;
2023
+ readonly exited?: boolean;
2024
+ readonly exitReason?: string;
1563
2025
  readonly failureKind?: WorkflowFailureKind;
1564
2026
  readonly failureCode?: WorkflowFailureCode;
1565
2027
  readonly failureRecoverability?: WorkflowFailureRecoverability;
@@ -1575,6 +2037,54 @@ function appendRunEndWhenRecorded(
1575
2037
  appendRunEnd(persistence, payload);
1576
2038
  }
1577
2039
 
2040
+ function isTerminalRunStatus(status: RunStatus): boolean {
2041
+ return status === "completed" ||
2042
+ status === "failed" ||
2043
+ status === "killed" ||
2044
+ status === "skipped" ||
2045
+ status === "cancelled" ||
2046
+ status === "blocked";
2047
+ }
2048
+
2049
+ function runResultFromSnapshot(snapshot: RunSnapshot): RunResult {
2050
+ return {
2051
+ runId: snapshot.id,
2052
+ status: snapshot.status,
2053
+ ...(snapshot.result !== undefined ? { result: snapshot.result } : {}),
2054
+ ...(snapshot.error !== undefined ? { error: snapshot.error } : {}),
2055
+ ...(snapshot.exited !== undefined ? { exited: snapshot.exited } : {}),
2056
+ ...(snapshot.exitReason !== undefined ? { exitReason: snapshot.exitReason } : {}),
2057
+ stages: [...snapshot.stages],
2058
+ };
2059
+ }
2060
+
2061
+ function reconcileTerminalRunResult(
2062
+ runId: string,
2063
+ runSnapshot: RunSnapshot,
2064
+ activeStore: Store,
2065
+ fallback: Omit<RunResult, "runId" | "stages">,
2066
+ onRunEnd: RunOpts["onRunEnd"],
2067
+ ): RunResult {
2068
+ const canonical = activeStore.runs().find((snapshot) =>
2069
+ snapshot.id === runId && isTerminalRunStatus(snapshot.status)
2070
+ );
2071
+ const result = canonical !== undefined
2072
+ ? runResultFromSnapshot(canonical)
2073
+ : {
2074
+ runId,
2075
+ ...fallback,
2076
+ stages: [...runSnapshot.stages],
2077
+ };
2078
+ // `recordRunEnd` is the terminal authority. If this finalizer lost because
2079
+ // an external kill or another terminal writer won while async cleanup was
2080
+ // pending, callbacks must observe the canonical store status, not the stale
2081
+ // intent that attempted this write. Persistence remains guarded separately by
2082
+ // the `recordRunEnd` boolean, so losing writes do not append duplicate
2083
+ // run-end entries.
2084
+ onRunEnd?.(runId, result.status, result.result, result.error, result.exitReason);
2085
+ return result;
2086
+ }
2087
+
1578
2088
  interface RunFailureMetadata {
1579
2089
  readonly errorMessage: string;
1580
2090
  readonly failureKind: WorkflowFailureKind;
@@ -1675,23 +2185,40 @@ function runFailureMetadataFromFailure(
1675
2185
  };
1676
2186
  }
1677
2187
 
1678
- function executorAggregateErrorItems(error: unknown): readonly unknown[] {
1679
- const nativeErrors = error instanceof AggregateError ? error.errors as unknown : undefined;
1680
- const errors = nativeErrors ?? (error !== null && typeof error === "object"
1681
- ? (error as Record<string, unknown>)["errors"]
1682
- : undefined);
1683
- return Array.isArray(errors) ? errors : [];
2188
+ function safeArrayItems(value: unknown): readonly unknown[] {
2189
+ try {
2190
+ if (!Array.isArray(value)) return [];
2191
+ const items: unknown[] = [];
2192
+ const { length } = value;
2193
+ for (let index = 0; index < length; index += 1) {
2194
+ try {
2195
+ items.push(value[index]);
2196
+ } catch {
2197
+ // Treat an inaccessible aggregate item as no signal for that item while
2198
+ // preserving other readable items in the same aggregate branch.
2199
+ }
2200
+ }
2201
+ return items;
2202
+ } catch {
2203
+ return [];
2204
+ }
2205
+ }
2206
+
2207
+ function safeExecutorAggregateErrorItems(error: unknown): readonly unknown[] {
2208
+ if (error === null || (typeof error !== "object" && typeof error !== "function")) return [];
2209
+ const errors = safeGetProperty(error, "errors");
2210
+ return errors.ok ? safeArrayItems(errors.value) : [];
1684
2211
  }
1685
2212
 
1686
2213
  function isAggregateWrapper(error: unknown): boolean {
1687
- return executorAggregateErrorItems(error).length > 0;
2214
+ return safeExecutorAggregateErrorItems(error).length > 0;
1688
2215
  }
1689
2216
 
1690
2217
  function aggregateInnerFailures(
1691
2218
  error: unknown,
1692
2219
  classifyFailure: (error: unknown) => WorkflowFailure,
1693
2220
  ): readonly WorkflowFailure[] {
1694
- return executorAggregateErrorItems(error).map((innerError) => classifyFailure(innerError));
2221
+ return safeExecutorAggregateErrorItems(error).map((innerError) => classifyFailure(innerError));
1695
2222
  }
1696
2223
 
1697
2224
  type StageFailureCandidate = {
@@ -2062,7 +2589,6 @@ function finalizeKilled(
2062
2589
  resumable: false,
2063
2590
  };
2064
2591
  const recorded = activeStore.recordRunEnd(runId, "killed", undefined, errorMessage, metadata);
2065
- onRunEnd?.(runId, "killed", undefined, errorMessage);
2066
2592
  appendRunEndWhenRecorded(persistence, recorded, {
2067
2593
  runId,
2068
2594
  status: "killed",
@@ -2070,12 +2596,10 @@ function finalizeKilled(
2070
2596
  ...metadata,
2071
2597
  ts: Date.now(),
2072
2598
  });
2073
- return {
2074
- runId,
2599
+ return reconcileTerminalRunResult(runId, runSnapshot, activeStore, {
2075
2600
  status: "killed",
2076
2601
  error: errorMessage,
2077
- stages: [...runSnapshot.stages],
2078
- };
2602
+ }, onRunEnd);
2079
2603
  }
2080
2604
 
2081
2605
  function finalizeKilledByFailure(
@@ -2087,7 +2611,6 @@ function finalizeKilledByFailure(
2087
2611
  metadata: RunFailureMetadata,
2088
2612
  ): RunResult {
2089
2613
  const recorded = activeStore.recordRunEnd(runId, "killed", undefined, metadata.errorMessage, metadata);
2090
- onRunEnd?.(runId, "killed", undefined, metadata.errorMessage);
2091
2614
  appendRunEndWhenRecorded(persistence, recorded, {
2092
2615
  runId,
2093
2616
  status: "killed",
@@ -2102,12 +2625,10 @@ function finalizeKilledByFailure(
2102
2625
  ...(metadata.retryAfterMs !== undefined ? { retryAfterMs: metadata.retryAfterMs } : {}),
2103
2626
  ts: Date.now(),
2104
2627
  });
2105
- return {
2106
- runId,
2628
+ return reconcileTerminalRunResult(runId, runSnapshot, activeStore, {
2107
2629
  status: "killed",
2108
2630
  error: metadata.errorMessage,
2109
- stages: [...runSnapshot.stages],
2110
- };
2631
+ }, onRunEnd);
2111
2632
  }
2112
2633
 
2113
2634
  function recordActiveBlockedFailure(
@@ -2232,9 +2753,10 @@ function assertWorkflowOutputsExplicit(
2232
2753
  }
2233
2754
  }
2234
2755
 
2235
- function normalizeWorkflowRunOutput(
2756
+ function normalizeWorkflowOutputObject(
2236
2757
  workflowName: string,
2237
2758
  rawOutput: unknown,
2759
+ label: string,
2238
2760
  ): WorkflowOutputValues | undefined {
2239
2761
  if (rawOutput === undefined) return undefined;
2240
2762
  // Drop top-level keys explicitly set to `undefined` so conditional outputs
@@ -2247,10 +2769,33 @@ function normalizeWorkflowRunOutput(
2247
2769
  Object.entries(rawOutput as Record<string, unknown>).filter(([, v]) => v !== undefined),
2248
2770
  )
2249
2771
  : rawOutput;
2250
- assertWorkflowSerializableObject(normalized, `workflow "${workflowName}" .run() return`);
2772
+ assertWorkflowSerializableObject(normalized, `workflow "${workflowName}" ${label}`);
2251
2773
  return normalized;
2252
2774
  }
2253
2775
 
2776
+ function normalizeWorkflowRunOutput(
2777
+ workflowName: string,
2778
+ rawOutput: unknown,
2779
+ ): WorkflowOutputValues | undefined {
2780
+ return normalizeWorkflowOutputObject(workflowName, rawOutput, ".run() return");
2781
+ }
2782
+
2783
+ function normalizeWorkflowExitOutput(
2784
+ workflowName: string,
2785
+ snapshot: WorkflowExitOutputSnapshot | undefined,
2786
+ ): WorkflowOutputValues | undefined {
2787
+ if (snapshot === undefined) return undefined;
2788
+ if (!snapshot.ok) throw snapshot.error;
2789
+ if (isWorkflowExitSnapshotInvalidValue(snapshot.value)) {
2790
+ const invalidMessage = workflowExitSnapshotInvalidValueMessage(
2791
+ `workflow "${workflowName}" ctx.exit() outputs`,
2792
+ snapshot.value,
2793
+ );
2794
+ throw new Error(`atomic-workflows: ${invalidMessage ?? `workflow "${workflowName}" ctx.exit() outputs must be ${WORKFLOW_SERIALIZABLE_DESCRIPTION}, got object`}`);
2795
+ }
2796
+ return normalizeWorkflowOutputObject(workflowName, snapshot.value, "ctx.exit() outputs");
2797
+ }
2798
+
2254
2799
  function assertWorkflowRunOutputs(
2255
2800
  workflowName: string,
2256
2801
  result: WorkflowOutputValues | undefined,
@@ -2263,6 +2808,50 @@ function assertWorkflowRunOutputs(
2263
2808
  );
2264
2809
  }
2265
2810
 
2811
+ function assertWorkflowExitOutputs(
2812
+ workflowName: string,
2813
+ result: WorkflowOutputValues | undefined,
2814
+ declaredOutputs: Readonly<Record<string, WorkflowOutputSchema>> | undefined,
2815
+ ): void {
2816
+ const declarations = declaredOutputs ?? {};
2817
+ const sourceOutput = result ?? {};
2818
+ const scope = `workflow "${workflowName}" ctx.exit()`;
2819
+ for (const key of Object.keys(sourceOutput)) {
2820
+ if (!hasOwnWorkflowOutput(declarations, key)) {
2821
+ throw new Error(
2822
+ `atomic-workflows: ${scope} provided undeclared output "${key}"; declare it with .output("${key}", Type....) or remove it from ctx.exit({ outputs })`,
2823
+ );
2824
+ }
2825
+ }
2826
+ for (const [key, schema] of Object.entries(declarations)) {
2827
+ if (!(key in sourceOutput)) continue;
2828
+ const value = sourceOutput[key];
2829
+ const invalidSnapshotValue = workflowExitSnapshotInvalidValueMessage(`${scope} output "${key}"`, value);
2830
+ if (invalidSnapshotValue !== undefined) {
2831
+ throw new Error(`atomic-workflows: ${invalidSnapshotValue}`);
2832
+ }
2833
+ const kind = schemaFieldKind(schema);
2834
+ if (!Value.Check(schema, value)) {
2835
+ const choices = schemaChoices(schema);
2836
+ if (kind === "select" && choices !== undefined && typeof value === "string") {
2837
+ throw new Error(
2838
+ `atomic-workflows: ${scope} output "${key}" must be one of [${choices.join(", ")}], got ${JSON.stringify(value)}`,
2839
+ );
2840
+ }
2841
+ throw new Error(
2842
+ `atomic-workflows: ${scope} output "${key}" expected ${kind}, got ${workflowSerializableTypeName(value)}`,
2843
+ );
2844
+ }
2845
+ const serializableError = workflowSerializableValidationError(
2846
+ value,
2847
+ `${scope} output "${key}"`,
2848
+ );
2849
+ if (serializableError !== undefined) {
2850
+ throw new Error(`atomic-workflows: ${serializableError}`);
2851
+ }
2852
+ }
2853
+ }
2854
+
2266
2855
  function selectWorkflowOutputs(
2267
2856
  child: WorkflowDefinition,
2268
2857
  rawOutput: WorkflowOutputValues | undefined,
@@ -2317,7 +2906,9 @@ function cloneWorkflowChildReplaySnapshot(snapshot: WorkflowChildReplaySnapshot)
2317
2906
  workflow: snapshot.workflow,
2318
2907
  runId: snapshot.runId,
2319
2908
  status: snapshot.status,
2909
+ ...(snapshot.exited !== undefined ? { exited: snapshot.exited } : {}),
2320
2910
  outputs: cloneWorkflowChildValue(snapshot.outputs),
2911
+ ...(snapshot.exitReason !== undefined ? { exitReason: snapshot.exitReason } : {}),
2321
2912
  };
2322
2913
  }
2323
2914
 
@@ -2338,12 +2929,15 @@ function workflowChildReplaySnapshot(
2338
2929
  }
2339
2930
  }
2340
2931
 
2932
+ const exitReason = childResult.exited === true ? childResult.exitReason : undefined;
2341
2933
  return {
2342
2934
  alias,
2343
2935
  workflow: childResult.workflow,
2344
2936
  runId: childResult.runId,
2345
2937
  status: childResult.status,
2938
+ exited: childResult.exited,
2346
2939
  outputs,
2940
+ ...(exitReason !== undefined ? { exitReason } : {}),
2347
2941
  };
2348
2942
  }
2349
2943
 
@@ -2384,6 +2978,8 @@ export async function run<TInputs extends WorkflowInputValues>(
2384
2978
 
2385
2979
  // 2. Generate runId (or use pre-allocated seam from caller)
2386
2980
  const runId = opts.runId ?? crypto.randomUUID();
2981
+ const exitScope = Symbol(`workflow-exit:${runId}`);
2982
+ let selectedExit: WorkflowExitSignal | undefined;
2387
2983
  const replayIndex = createContinuationReplayIndex(opts.continuation);
2388
2984
 
2389
2985
  // 2a. Create own AbortController; forward caller signal if provided
@@ -2420,7 +3016,16 @@ export async function run<TInputs extends WorkflowInputValues>(
2420
3016
  const classifyExecutorFailure = (error: unknown): WorkflowFailure => {
2421
3017
  const cached = classifiedFailures.get(error);
2422
3018
  if (cached !== undefined) return cached;
2423
- const classified = classifyWorkflowFailure(error);
3019
+ let classified: WorkflowFailure;
3020
+ try {
3021
+ classified = classifyWorkflowFailure(error);
3022
+ } catch {
3023
+ // Failure classification can inspect provider-shaped metadata such as
3024
+ // `cause`/`errors`. If an arbitrary workflow-thrown object uses throwing
3025
+ // accessors for those names, keep the executor catch path on the ordinary
3026
+ // failed-run rail instead of letting the accessor escape and strand the run.
3027
+ classified = classifyWorkflowFailure(new Error(unknownErrorMessage(error)));
3028
+ }
2424
3029
  classifiedFailures.set(error, classified);
2425
3030
  return classified;
2426
3031
  };
@@ -2484,6 +3089,88 @@ export async function run<TInputs extends WorkflowInputValues>(
2484
3089
  const isTerminalStage = (stage: StageSnapshot): boolean =>
2485
3090
  stage.status === "completed" || stage.status === "failed" || stage.status === "skipped";
2486
3091
 
3092
+ interface WorkflowExitCleanup {
3093
+ skipForWorkflowExit(reason?: string): void | Promise<void>;
3094
+ }
3095
+
3096
+ const exitCleanups = new Map<string, WorkflowExitCleanup>();
3097
+ const workflowExitCleanupPromises = new Set<Promise<void>>();
3098
+ const workflowExitSkippedReason = (reason?: string): string =>
3099
+ reason === undefined || reason.length === 0 ? "workflow-exit" : `workflow-exit: ${reason}`;
3100
+ const isWorkflowExitSkippedReason = (reason: string | undefined): boolean =>
3101
+ reason === "workflow-exit" || reason?.startsWith("workflow-exit: ") === true;
3102
+ const currentWorkflowExitAbortReason = (): { readonly reason?: string } | undefined => {
3103
+ const scopedExit = selectedExit ?? findWorkflowExitSignal(ownController.signal.reason, exitScope);
3104
+ if (scopedExit !== undefined) {
3105
+ return scopedExit.reason === undefined ? {} : { reason: scopedExit.reason };
3106
+ }
3107
+ const parentExit = parentWorkflowExitAbortReason(ownController.signal.reason);
3108
+ if (parentExit !== undefined) {
3109
+ return parentExit.workflowExitReason === undefined ? {} : { reason: parentExit.workflowExitReason };
3110
+ }
3111
+ return undefined;
3112
+ };
3113
+ const preserveWorkflowExitSkippedReason = (stage: StageSnapshot, fallback: string): void => {
3114
+ if (isWorkflowExitSkippedReason(stage.skippedReason)) return;
3115
+ const workflowExitAbort = currentWorkflowExitAbortReason();
3116
+ if (workflowExitAbort !== undefined) {
3117
+ stage.skippedReason = workflowExitSkippedReason(workflowExitAbort.reason);
3118
+ return;
3119
+ }
3120
+ stage.skippedReason = fallback;
3121
+ };
3122
+ const trackWorkflowExitCleanup = (operation: void | Promise<void>): void => {
3123
+ if (operation === undefined) return;
3124
+ let tracked: Promise<void>;
3125
+ tracked = Promise.resolve(operation)
3126
+ .catch(() => {
3127
+ // Cleanup is best-effort and must never surface as an unhandled rejection
3128
+ // or convert an intentional workflow exit into a failed run.
3129
+ })
3130
+ .finally(() => {
3131
+ workflowExitCleanupPromises.delete(tracked);
3132
+ });
3133
+ workflowExitCleanupPromises.add(tracked);
3134
+ };
3135
+ const invokeWorkflowExitCleanup = (cleanup: WorkflowExitCleanup, reason?: string): void => {
3136
+ try {
3137
+ trackWorkflowExitCleanup(cleanup.skipForWorkflowExit(reason));
3138
+ } catch (err) {
3139
+ trackWorkflowExitCleanup(Promise.reject(err));
3140
+ }
3141
+ };
3142
+ const registerWorkflowExitCleanup = (stageId: string, cleanup: WorkflowExitCleanup): (() => void) => {
3143
+ if (selectedExit !== undefined) {
3144
+ invokeWorkflowExitCleanup(cleanup, selectedExit.reason);
3145
+ return () => undefined;
3146
+ }
3147
+ exitCleanups.set(stageId, cleanup);
3148
+ return () => {
3149
+ if (exitCleanups.get(stageId) === cleanup) exitCleanups.delete(stageId);
3150
+ };
3151
+ };
3152
+ const runWorkflowExitCleanups = (reason?: string): void => {
3153
+ for (const cleanup of [...exitCleanups.values()]) {
3154
+ invokeWorkflowExitCleanup(cleanup, reason);
3155
+ }
3156
+ };
3157
+ const drainWorkflowExitCleanups = async (reason?: string): Promise<void> => {
3158
+ runWorkflowExitCleanups(reason);
3159
+ while (workflowExitCleanupPromises.size > 0) {
3160
+ await Promise.all([...workflowExitCleanupPromises]);
3161
+ }
3162
+ };
3163
+ const throwIfWorkflowExitSelected = (): void => {
3164
+ if (selectedExit !== undefined) {
3165
+ if (!ownController.signal.aborted) ownController.abort(selectedExit);
3166
+ runWorkflowExitCleanups(selectedExit.reason);
3167
+ throw selectedExit;
3168
+ }
3169
+ if (ownController.signal.aborted) {
3170
+ throw ownController.signal.reason ?? new DOMException("workflow killed", "AbortError");
3171
+ }
3172
+ };
3173
+
2487
3174
  const stageById = (stageId: string): StageSnapshot | undefined =>
2488
3175
  runSnapshot.stages.find((stage) => stage.id === stageId);
2489
3176
 
@@ -2623,21 +3310,161 @@ export async function run<TInputs extends WorkflowInputValues>(
2623
3310
  { once: true },
2624
3311
  );
2625
3312
 
3313
+ const finalizeWorkflowExitValidationFailure = (err: unknown, exitReason?: string): RunResult => {
3314
+ const failure = classifyExecutorFailure(err);
3315
+ const classifiedMetadata = runFailureMetadata(failure, runSnapshot.stages);
3316
+ const metadata = {
3317
+ ...classifiedMetadata,
3318
+ // A selected ctx.exit has already unwound the workflow and run exit cleanup;
3319
+ // invalid exit options/outputs must never be offered as resumable snapshots.
3320
+ resumable: false,
3321
+ ...(exitReason !== undefined ? { exitReason } : {}),
3322
+ } as const;
3323
+ const recorded = activeStore.recordRunEnd(runId, "failed", undefined, metadata.errorMessage, metadata);
3324
+ appendRunEndWhenRecorded(opts.persistence, recorded, {
3325
+ runId,
3326
+ status: "failed",
3327
+ error: metadata.errorMessage,
3328
+ failureKind: metadata.failureKind,
3329
+ ...(metadata.failureCode !== undefined ? { failureCode: metadata.failureCode } : {}),
3330
+ ...(metadata.failureRecoverability !== undefined ? { failureRecoverability: metadata.failureRecoverability } : {}),
3331
+ ...(metadata.failureDisposition !== undefined ? { failureDisposition: metadata.failureDisposition } : {}),
3332
+ failureMessage: metadata.failureMessage,
3333
+ ...(metadata.failedStageId !== undefined ? { failedStageId: metadata.failedStageId } : {}),
3334
+ resumable: false,
3335
+ ...(metadata.exitReason !== undefined ? { exitReason: metadata.exitReason } : {}),
3336
+ ...(metadata.retryAfterMs !== undefined ? { retryAfterMs: metadata.retryAfterMs } : {}),
3337
+ ts: Date.now(),
3338
+ });
3339
+ return reconcileTerminalRunResult(runId, runSnapshot, activeStore, {
3340
+ status: "failed",
3341
+ error: metadata.errorMessage,
3342
+ ...(metadata.exitReason !== undefined ? { exitReason: metadata.exitReason } : {}),
3343
+ }, opts.onRunEnd);
3344
+ };
3345
+
3346
+ const finalizeWorkflowExit = async (signal: WorkflowExitSignal): Promise<RunResult> => {
3347
+ await drainWorkflowExitCleanups(signal.reason);
3348
+ if (signal.validationError !== undefined) {
3349
+ return finalizeWorkflowExitValidationFailure(signal.validationError, signal.reason);
3350
+ }
3351
+
3352
+ let outputs: WorkflowOutputValues | undefined;
3353
+ try {
3354
+ outputs = normalizeWorkflowExitOutput(def.name, signal.outputSnapshot);
3355
+ assertWorkflowExitOutputs(def.name, outputs, def.outputs);
3356
+ } catch (err) {
3357
+ return finalizeWorkflowExitValidationFailure(err, signal.reason);
3358
+ }
3359
+
3360
+ const metadata = {
3361
+ resumable: false,
3362
+ exited: true,
3363
+ ...(signal.reason !== undefined ? { exitReason: signal.reason } : {}),
3364
+ } as const;
3365
+ const recorded = activeStore.recordRunEnd(runId, signal.status, outputs, undefined, metadata);
3366
+ appendRunEndWhenRecorded(opts.persistence, recorded, {
3367
+ runId,
3368
+ status: signal.status,
3369
+ result: outputs,
3370
+ exited: true,
3371
+ ...(signal.reason !== undefined ? { exitReason: signal.reason } : {}),
3372
+ resumable: false,
3373
+ ts: Date.now(),
3374
+ });
3375
+ return reconcileTerminalRunResult(runId, runSnapshot, activeStore, {
3376
+ status: signal.status,
3377
+ result: outputs,
3378
+ exited: true,
3379
+ ...(signal.reason !== undefined ? { exitReason: signal.reason } : {}),
3380
+ }, opts.onRunEnd);
3381
+ };
3382
+
3383
+ const finalizeParentWorkflowExitCancellation = async (abortReason: ParentWorkflowExitAbortProbe): Promise<RunResult> => {
3384
+ const parentReason = abortReason.workflowExitReason;
3385
+ await drainWorkflowExitCleanups(parentReason);
3386
+ const exitReason = parentWorkflowExitRunReason(parentReason);
3387
+ const metadata = {
3388
+ resumable: false,
3389
+ exited: true,
3390
+ exitReason,
3391
+ } as const;
3392
+ const recorded = activeStore.recordRunEnd(runId, "cancelled", undefined, undefined, metadata);
3393
+ appendRunEndWhenRecorded(opts.persistence, recorded, {
3394
+ runId,
3395
+ status: "cancelled",
3396
+ exited: true,
3397
+ exitReason,
3398
+ resumable: false,
3399
+ ts: Date.now(),
3400
+ });
3401
+ return reconcileTerminalRunResult(runId, runSnapshot, activeStore, {
3402
+ status: "cancelled",
3403
+ exited: true,
3404
+ exitReason,
3405
+ }, opts.onRunEnd);
3406
+ };
3407
+
3408
+ interface LinkedChildWorkflowExitState {
3409
+ readonly ref: WorkflowChildRunRef;
3410
+ readonly controller: AbortController;
3411
+ runPromise?: Promise<RunResult>;
3412
+ }
3413
+
3414
+ const requestLinkedChildWorkflowExit = (
3415
+ linkedChild: LinkedChildWorkflowExitState,
3416
+ reason?: string,
3417
+ ): void => {
3418
+ if (!linkedChild.controller.signal.aborted) {
3419
+ linkedChild.controller.abort(makeParentWorkflowExitAbortReason(reason));
3420
+ }
3421
+ };
3422
+
3423
+ const waitForLinkedChildWorkflowExit = async (
3424
+ linkedChild: LinkedChildWorkflowExitState,
3425
+ ): Promise<void> => {
3426
+ const childRun = linkedChild.runPromise;
3427
+ if (childRun === undefined) return;
3428
+ try {
3429
+ await childRun;
3430
+ } catch {
3431
+ // The child workflow call itself observes and reports failures. Parent
3432
+ // exit cleanup only needs to await child-owned teardown and must not leak
3433
+ // an unhandled rejection while the parent is already intentionally exiting.
3434
+ }
3435
+ };
3436
+
2626
3437
  interface WorkflowBoundaryStage {
2627
3438
  readonly id: string;
2628
3439
  readonly replayedChild?: WorkflowChildResult;
2629
3440
  finalizeReplay(): void;
2630
- linkChildRun(ref: WorkflowChildRunRef): void;
3441
+ linkChildRun(ref: WorkflowChildRunRef, childController: AbortController): void;
3442
+ observeChildRun(promise: Promise<RunResult>): void;
2631
3443
  complete(summary: string, workflowChild: WorkflowChildReplaySnapshot): void;
3444
+ skipForWorkflowExit(reason?: string): Promise<void>;
2632
3445
  fail(error: unknown): void;
2633
3446
  }
2634
3447
 
2635
- const workflowChildResultFromReplay = (snapshot: WorkflowChildReplaySnapshot): WorkflowChildResult => ({
2636
- workflow: snapshot.workflow,
2637
- runId: snapshot.runId,
2638
- status: snapshot.status,
2639
- outputs: cloneWorkflowChildValue(snapshot.outputs),
2640
- });
3448
+ const workflowChildResultFromReplay = (snapshot: WorkflowChildReplaySnapshot): WorkflowChildResult => {
3449
+ const outputs = cloneWorkflowChildValue(snapshot.outputs);
3450
+ if (snapshot.exited === true || snapshot.status !== "completed") {
3451
+ return {
3452
+ workflow: snapshot.workflow,
3453
+ runId: snapshot.runId,
3454
+ status: snapshot.status,
3455
+ exited: true,
3456
+ outputs,
3457
+ ...(snapshot.exitReason !== undefined ? { exitReason: snapshot.exitReason } : {}),
3458
+ };
3459
+ }
3460
+ return {
3461
+ workflow: snapshot.workflow,
3462
+ runId: snapshot.runId,
3463
+ status: "completed",
3464
+ exited: false,
3465
+ outputs,
3466
+ };
3467
+ };
2641
3468
 
2642
3469
  const workflowBoundaryReplayCounts = new Map<string, number>();
2643
3470
  const nextWorkflowBoundaryReplayKey = (name: string): string => {
@@ -2687,6 +3514,8 @@ export async function run<TInputs extends WorkflowInputValues>(
2687
3514
  } : {}),
2688
3515
  };
2689
3516
  let finalized = false;
3517
+ let unregisterWorkflowExitCleanup = (): void => {};
3518
+ let linkedChild: LinkedChildWorkflowExitState | undefined;
2690
3519
 
2691
3520
  const appendStageStartOnce = (): void => {
2692
3521
  if (!opts.persistence) return;
@@ -2714,25 +3543,38 @@ export async function run<TInputs extends WorkflowInputValues>(
2714
3543
  ...(stageSnapshot.failureDisposition !== undefined ? { failureDisposition: stageSnapshot.failureDisposition } : {}),
2715
3544
  ...(stageSnapshot.failureMessage !== undefined ? { failureMessage: stageSnapshot.failureMessage } : {}),
2716
3545
  ...(stageSnapshot.retryAfterMs !== undefined ? { retryAfterMs: stageSnapshot.retryAfterMs } : {}),
3546
+ ...(stageSnapshot.skippedReason !== undefined ? { skippedReason: stageSnapshot.skippedReason } : {}),
2717
3547
  ...(stageSnapshot.result !== undefined && stageSnapshot.status === "completed" ? { summary: stageSnapshot.result } : {}),
2718
3548
  ...stageReplayFields(stageSnapshot),
2719
- ...(stageSnapshot.workflowChild !== undefined ? { workflowChild: stageSnapshot.workflowChild } : {}),
3549
+ ...(stageSnapshot.status === "completed" && stageSnapshot.workflowChild !== undefined
3550
+ ? { workflowChild: stageSnapshot.workflowChild }
3551
+ : {}),
2720
3552
  });
2721
3553
  };
2722
3554
 
3555
+ const clearBoundaryChildMetadata = (): void => {
3556
+ delete stageSnapshot.workflowChildRun;
3557
+ delete stageSnapshot.workflowChild;
3558
+ };
3559
+
2723
3560
  const finalize = (
2724
- status: "completed" | "failed",
3561
+ status: "completed" | "failed" | "skipped",
2725
3562
  summaryOrError: string,
2726
3563
  workflowChild?: WorkflowChildReplaySnapshot,
2727
3564
  failureError?: unknown,
2728
3565
  ): void => {
2729
3566
  if (finalized) return;
2730
3567
  finalized = true;
3568
+ unregisterWorkflowExitCleanup();
2731
3569
  stageSnapshot.status = status;
2732
3570
  if (status === "completed") {
2733
3571
  stageSnapshot.result = summaryOrError;
2734
3572
  if (workflowChild !== undefined) stageSnapshot.workflowChild = workflowChild;
3573
+ } else if (status === "skipped") {
3574
+ clearBoundaryChildMetadata();
3575
+ stageSnapshot.skippedReason = summaryOrError;
2735
3576
  } else {
3577
+ clearBoundaryChildMetadata();
2736
3578
  applyFailureToStage(stageSnapshot, classifyExecutorFailure(failureError));
2737
3579
  }
2738
3580
  stageSnapshot.endedAt = Date.now();
@@ -2747,29 +3589,60 @@ export async function run<TInputs extends WorkflowInputValues>(
2747
3589
  opts.onStageStart?.(runId, stageSnapshot);
2748
3590
  appendStageStartOnce();
2749
3591
 
3592
+ unregisterWorkflowExitCleanup = registerWorkflowExitCleanup(stageId, {
3593
+ async skipForWorkflowExit(reason?: string): Promise<void> {
3594
+ const child = linkedChild;
3595
+ if (child !== undefined) {
3596
+ requestLinkedChildWorkflowExit(child, reason);
3597
+ }
3598
+ finalize("skipped", workflowExitSkippedReason(reason));
3599
+ if (child !== undefined) {
3600
+ await waitForLinkedChildWorkflowExit(child);
3601
+ }
3602
+ },
3603
+ });
3604
+
2750
3605
  const finalizeReplay = (): void => {
2751
3606
  if (replayedChild === undefined || finalized) return;
2752
3607
  finalized = true;
3608
+ unregisterWorkflowExitCleanup();
2753
3609
  activeStore.recordStageEnd(runId, stageSnapshot);
2754
3610
  opts.onStageEnd?.(runId, stageSnapshot);
2755
3611
  appendStageEndForSnapshot();
2756
3612
  tracker.onSettle(stageId);
2757
3613
  };
2758
3614
 
2759
- const linkChildRun = (ref: WorkflowChildRunRef): void => {
3615
+ const linkChildRun = (ref: WorkflowChildRunRef, childController: AbortController): void => {
2760
3616
  if (finalized) return;
3617
+ linkedChild = { ref: { ...ref }, controller: childController };
2761
3618
  stageSnapshot.workflowChildRun = { ...ref };
2762
3619
  activeStore.recordStageWorkflowChildRun(runId, stageId, ref);
2763
3620
  };
2764
3621
 
3622
+ const observeChildRun = (promise: Promise<RunResult>): void => {
3623
+ if (linkedChild === undefined || finalized) return;
3624
+ linkedChild.runPromise = promise;
3625
+ };
3626
+
2765
3627
  return {
2766
3628
  id: stageId,
2767
3629
  ...(replayedChild !== undefined ? { replayedChild } : {}),
2768
3630
  finalizeReplay,
2769
3631
  linkChildRun,
3632
+ observeChildRun,
2770
3633
  complete(summary: string, workflowChild: WorkflowChildReplaySnapshot): void {
2771
3634
  finalize("completed", summary, workflowChild);
2772
3635
  },
3636
+ async skipForWorkflowExit(reason?: string): Promise<void> {
3637
+ const child = linkedChild;
3638
+ if (child !== undefined) {
3639
+ requestLinkedChildWorkflowExit(child, reason);
3640
+ }
3641
+ finalize("skipped", workflowExitSkippedReason(reason));
3642
+ if (child !== undefined) {
3643
+ await waitForLinkedChildWorkflowExit(child);
3644
+ }
3645
+ },
2773
3646
  fail(error: unknown): void {
2774
3647
  finalize("failed", error instanceof Error ? error.message : String(error), undefined, error);
2775
3648
  },
@@ -2778,6 +3651,7 @@ export async function run<TInputs extends WorkflowInputValues>(
2778
3651
 
2779
3652
  const buildPromptNodeUiAdapter = (): WorkflowUIContext => {
2780
3653
  const ask = async <T>(descriptor: PromptDescriptor<T>): Promise<unknown> => {
3654
+ throwIfWorkflowExitSelected();
2781
3655
  const isCustom = isCustomPromptDescriptor(descriptor);
2782
3656
  if (ownController.signal.aborted) {
2783
3657
  if (isCustom) throw hilAbortError(ownController.signal);
@@ -2836,9 +3710,11 @@ export async function run<TInputs extends WorkflowInputValues>(
2836
3710
  } : {}),
2837
3711
  };
2838
3712
  let finalized = false;
3713
+ let unregisterWorkflowExitCleanup = (): void => {};
2839
3714
  const finalizePromptStage = (status: "completed" | "failed" | "skipped"): void => {
2840
3715
  if (finalized) return;
2841
3716
  finalized = true;
3717
+ unregisterWorkflowExitCleanup();
2842
3718
  stageSnapshot.status = status;
2843
3719
  stageSnapshot.endedAt = Date.now();
2844
3720
  stageSnapshot.durationMs = elapsedStageMs(stageSnapshot, stageSnapshot.endedAt);
@@ -2867,6 +3743,20 @@ export async function run<TInputs extends WorkflowInputValues>(
2867
3743
 
2868
3744
  activeStore.recordStageStart(runId, stageSnapshot);
2869
3745
  opts.onStageStart?.(runId, stageSnapshot);
3746
+ unregisterWorkflowExitCleanup = registerWorkflowExitCleanup(stageId, {
3747
+ skipForWorkflowExit(reason?: string): void {
3748
+ if (finalized) return;
3749
+ stageSnapshot.skippedReason = workflowExitSkippedReason(reason);
3750
+ if (!shouldReplay) {
3751
+ stageUiBroker.cancelStagePrompt(
3752
+ runId,
3753
+ stageId,
3754
+ new Error(`atomic-workflows: prompt ${stageId} skipped by workflow exit`),
3755
+ );
3756
+ }
3757
+ finalizePromptStage("skipped");
3758
+ },
3759
+ });
2870
3760
  if (opts.persistence) {
2871
3761
  appendStageStart(opts.persistence, {
2872
3762
  runId,
@@ -2879,6 +3769,7 @@ export async function run<TInputs extends WorkflowInputValues>(
2879
3769
  }
2880
3770
  if (shouldReplay) {
2881
3771
  await Promise.resolve();
3772
+ throwIfWorkflowExitSelected();
2882
3773
  finalizePromptStage("completed");
2883
3774
  return replayAnswer.value;
2884
3775
  }
@@ -2917,7 +3808,10 @@ export async function run<TInputs extends WorkflowInputValues>(
2917
3808
  activeStore.recordStageAwaitingInput(runId, stageId, false);
2918
3809
  stageUiBroker.cancelStagePrompt(runId, stageId, err);
2919
3810
  if (mergedSignal.signal.aborted) {
2920
- stageSnapshot.skippedReason = ownController.signal.aborted ? "run-aborted" : "prompt-aborted";
3811
+ preserveWorkflowExitSkippedReason(
3812
+ stageSnapshot,
3813
+ ownController.signal.aborted ? "run-aborted" : "prompt-aborted",
3814
+ );
2921
3815
  finalizePromptStage("skipped");
2922
3816
  throw hilAbortError(mergedSignal.signal);
2923
3817
  }
@@ -2971,7 +3865,7 @@ export async function run<TInputs extends WorkflowInputValues>(
2971
3865
  return response;
2972
3866
  } catch (err) {
2973
3867
  if (ownController.signal.aborted) {
2974
- stageSnapshot.skippedReason = "run-aborted";
3868
+ preserveWorkflowExitSkippedReason(stageSnapshot, "run-aborted");
2975
3869
  finalizePromptStage("skipped");
2976
3870
  } else {
2977
3871
  applyFailureToStage(stageSnapshot, classifyExecutorFailure(err));
@@ -3015,15 +3909,121 @@ export async function run<TInputs extends WorkflowInputValues>(
3015
3909
  };
3016
3910
  };
3017
3911
 
3912
+ const buildExitGatedUiContext = (): WorkflowUIContext => {
3913
+ // Headless (non-interactive) runs without an adapter get a context whose
3914
+ // interactive primitives fail with a clear "unavailable in headless mode"
3915
+ // error instead of a raw TypeError (#1339).
3916
+ const base = opts.usePromptNodesForUi === true
3917
+ ? buildPromptNodeUiAdapter()
3918
+ : opts.executionMode === "non_interactive" && opts.ui === undefined
3919
+ ? makeHeadlessUnavailableUIContext()
3920
+ : normalizeUIContext(opts.ui);
3921
+ return {
3922
+ async input(promptText: string): Promise<string> {
3923
+ throwIfWorkflowExitSelected();
3924
+ return await base.input(promptText);
3925
+ },
3926
+ async confirm(message: string): Promise<boolean> {
3927
+ throwIfWorkflowExitSelected();
3928
+ return await base.confirm(message);
3929
+ },
3930
+ async select<T extends string>(message: string, options: readonly T[]): Promise<T> {
3931
+ throwIfWorkflowExitSelected();
3932
+ return await base.select(message, options);
3933
+ },
3934
+ async editor(initial?: string): Promise<string> {
3935
+ throwIfWorkflowExitSelected();
3936
+ return await base.editor(initial);
3937
+ },
3938
+ async custom<T>(factory: WorkflowCustomUiFactory<T>, options?: WorkflowCustomUiOptions): Promise<T> {
3939
+ throwIfWorkflowExitSelected();
3940
+ return await base.custom(factory, options);
3941
+ },
3942
+ };
3943
+ };
3944
+
3018
3945
  // 5. Build WorkflowRunContext
3019
3946
  const ctx: WorkflowRunContext<TInputs> = {
3020
3947
  inputs: resolvedInputs as TInputs,
3021
3948
  get cwd() { return resolveWorkflowCwd(); },
3949
+ exit(options?: WorkflowExitOptions): never {
3950
+ if (selectedExit !== undefined) {
3951
+ if (!ownController.signal.aborted) ownController.abort(selectedExit);
3952
+ runWorkflowExitCleanups(selectedExit.reason);
3953
+ throw selectedExit;
3954
+ }
3955
+ if (ownController.signal.aborted) {
3956
+ throw ownController.signal.reason ?? new DOMException("workflow killed", "AbortError");
3957
+ }
3958
+
3959
+ const throwNestedSelectedExit = (): void => {
3960
+ if (selectedExit === undefined) return;
3961
+ if (!ownController.signal.aborted) ownController.abort(selectedExit);
3962
+ runWorkflowExitCleanups(selectedExit.reason);
3963
+ throw selectedExit;
3964
+ };
3965
+ const rawOptions = options as { readonly status?: unknown; readonly reason?: unknown; readonly outputs?: unknown } | null | undefined;
3966
+ let validationError: Error | undefined;
3967
+ const captureValidationError = (error: Error): void => {
3968
+ validationError ??= error;
3969
+ };
3970
+
3971
+ const statusRead = readWorkflowExitOption(rawOptions, "status");
3972
+ throwNestedSelectedExit();
3973
+ const rawStatus = statusRead.ok ? statusRead.value ?? "completed" : "completed";
3974
+ if (!statusRead.ok) {
3975
+ captureValidationError(statusRead.error);
3976
+ } else if (!isWorkflowExitStatus(rawStatus)) {
3977
+ captureValidationError(new TypeError(
3978
+ `atomic-workflows: ctx.exit() status must be one of completed, skipped, cancelled, blocked; got ${describeWorkflowExitOptionValue(rawStatus)}`,
3979
+ ));
3980
+ }
3981
+ const status = isWorkflowExitStatus(rawStatus) ? rawStatus : "completed";
3982
+
3983
+ const reasonRead = readWorkflowExitOption(rawOptions, "reason");
3984
+ throwNestedSelectedExit();
3985
+ const rawReason = reasonRead.ok ? reasonRead.value : undefined;
3986
+ if (!reasonRead.ok) {
3987
+ captureValidationError(reasonRead.error);
3988
+ } else if (rawReason !== undefined && typeof rawReason !== "string") {
3989
+ captureValidationError(new TypeError(
3990
+ `atomic-workflows: ctx.exit() reason must be a string when provided; got ${workflowSerializableTypeName(rawReason)}`,
3991
+ ));
3992
+ }
3993
+ const reason = typeof rawReason === "string" ? rawReason : undefined;
3994
+
3995
+ const outputsRead = readWorkflowExitOption(rawOptions, "outputs");
3996
+ throwNestedSelectedExit();
3997
+ const outputSnapshot = !outputsRead.ok
3998
+ ? freezeWorkflowExitOutputSnapshot({ ok: false, error: outputsRead.error })
3999
+ : outputsRead.value !== undefined
4000
+ ? captureWorkflowExitOutputSnapshot(outputsRead.value)
4001
+ : undefined;
4002
+ throwNestedSelectedExit();
4003
+
4004
+ // Freeze the signal so a broad author `catch (signal) { signal.* = ...; throw signal; }`
4005
+ // cannot rewrite the terminal status/reason/outputs. Finalization recovers this exact
4006
+ // object (via the abort reason or the rethrow) and the outputSnapshot value is already
4007
+ // deep-frozen, so the first selected exit is the authoritative terminal result.
4008
+ const signal: WorkflowExitSignal = {
4009
+ [WORKFLOW_EXIT_SIGNAL]: true,
4010
+ scope: exitScope,
4011
+ status,
4012
+ ...(reason !== undefined ? { reason } : {}),
4013
+ ...(outputSnapshot !== undefined ? { outputSnapshot } : {}),
4014
+ ...(validationError !== undefined ? { validationError } : {}),
4015
+ };
4016
+ selectedExit = Object.freeze(signal);
4017
+ ownController.abort(selectedExit);
4018
+ runWorkflowExitCleanups(reason);
4019
+ throw selectedExit;
4020
+ },
3022
4021
  // Prompt nodes and caller-provided UI adapters are mutually exclusive;
3023
4022
  // executor-owned prompt nodes intentionally take precedence when enabled.
3024
- ui: opts.usePromptNodesForUi === true ? buildPromptNodeUiAdapter() : normalizeUIContext(opts.ui),
4023
+ ui: buildExitGatedUiContext(),
3025
4024
 
3026
4025
  stage(name: string, options?: StageOptions, stageFailFastScope?: ParallelFailFastScope) {
4026
+ throwIfWorkflowExitSelected();
3027
4027
  options = stageOptionsWithGitWorktree(stageOptionsWithInputDefaults(options, inputRuntimeDefaults), workflowInvocationCwd);
3028
4028
  // a. Generate stageId
3029
4029
  const stageId = crypto.randomUUID();
@@ -3091,27 +4091,45 @@ export async function run<TInputs extends WorkflowInputValues>(
3091
4091
  opts.onStageStart?.(runId, stageSnapshot);
3092
4092
  appendStageStartOnce();
3093
4093
  let replayFinalized = false;
3094
- const finalizeReplayStage = (): void => {
4094
+ let unregisterWorkflowExitCleanup = (): void => {};
4095
+ const appendReplayStageEnd = (): void => {
4096
+ if (!opts.persistence) return;
4097
+ appendStageEnd(opts.persistence, {
4098
+ runId,
4099
+ stageId,
4100
+ status: stageSnapshot.status,
4101
+ durationMs: stageSnapshot.durationMs ?? 0,
4102
+ ...(stageSnapshot.status === "completed" && stageSnapshot.result !== undefined ? { summary: stageSnapshot.result } : {}),
4103
+ ...(stageSnapshot.skippedReason !== undefined ? { skippedReason: stageSnapshot.skippedReason } : {}),
4104
+ ...stageReplayFields(stageSnapshot),
4105
+ });
4106
+ };
4107
+ const finalizeReplayStage = (status: "completed" | "skipped", reason?: string): void => {
3095
4108
  if (replayFinalized) return;
3096
4109
  replayFinalized = true;
4110
+ unregisterWorkflowExitCleanup();
4111
+ stageSnapshot.status = status;
4112
+ if (status === "skipped") {
4113
+ delete stageSnapshot.result;
4114
+ stageSnapshot.skippedReason = workflowExitSkippedReason(reason);
4115
+ }
4116
+ stageSnapshot.endedAt = Date.now();
4117
+ stageSnapshot.durationMs = elapsedStageMs(stageSnapshot, stageSnapshot.endedAt);
3097
4118
  activeStore.recordStageEnd(runId, stageSnapshot);
3098
4119
  opts.onStageEnd?.(runId, stageSnapshot);
3099
- if (opts.persistence) {
3100
- appendStageEnd(opts.persistence, {
3101
- runId,
3102
- stageId,
3103
- status: "completed",
3104
- durationMs: 0,
3105
- ...(stageSnapshot.result !== undefined ? { summary: stageSnapshot.result } : {}),
3106
- ...stageReplayFields(stageSnapshot),
3107
- });
3108
- }
4120
+ appendReplayStageEnd();
3109
4121
  tracker.onSettle(stageId);
3110
4122
  };
4123
+ unregisterWorkflowExitCleanup = registerWorkflowExitCleanup(stageId, {
4124
+ skipForWorkflowExit(reason?: string): void {
4125
+ finalizeReplayStage("skipped", reason);
4126
+ },
4127
+ });
3111
4128
  const replayResult = replaySource.result ?? "";
3112
4129
  const replayText = async (): Promise<string> => {
3113
4130
  await Promise.resolve();
3114
- finalizeReplayStage();
4131
+ throwIfWorkflowExitSelected();
4132
+ finalizeReplayStage("completed");
3115
4133
  return replayResult;
3116
4134
  };
3117
4135
  const rejectReplayMutation = (action: string): never => {
@@ -3270,6 +4288,14 @@ export async function run<TInputs extends WorkflowInputValues>(
3270
4288
  // cleared by the host.
3271
4289
  dropStageControlHandle();
3272
4290
  };
4291
+ let stageClosedByWorkflowExit = false;
4292
+ const throwIfStageMutationBlocked = (): void => {
4293
+ if (stageClosedByWorkflowExit) {
4294
+ throwIfWorkflowExitSelected();
4295
+ throw new Error(`atomic-workflows: stage "${name}" skipped by workflow exit`);
4296
+ }
4297
+ throwIfWorkflowExitSelected();
4298
+ };
3273
4299
 
3274
4300
  // e. Register a live stage-control handle so attached panes can
3275
4301
  // prompt/steer/pause/resume the underlying Pi session lazily.
@@ -3303,26 +4329,33 @@ export async function run<TInputs extends WorkflowInputValues>(
3303
4329
  return innerCtx.__agentSession();
3304
4330
  },
3305
4331
  async ensureAttached() {
4332
+ throwIfStageMutationBlocked();
3306
4333
  await innerCtx.__ensureSession();
4334
+ throwIfStageMutationBlocked();
3307
4335
  const meta = innerCtx.__sessionMeta();
3308
4336
  if (meta.sessionId !== undefined || meta.sessionFile !== undefined) {
3309
4337
  activeStore.recordStageSession(runId, stageId, meta);
3310
4338
  }
3311
4339
  },
3312
4340
  async prompt(text: string) {
4341
+ throwIfStageMutationBlocked();
3313
4342
  await innerCtx.prompt(text);
4343
+ throwIfStageMutationBlocked();
3314
4344
  const meta = innerCtx.__sessionMeta();
3315
4345
  if (meta.sessionId !== undefined || meta.sessionFile !== undefined) {
3316
4346
  activeStore.recordStageSession(runId, stageId, meta);
3317
4347
  }
3318
4348
  },
3319
4349
  async steer(text: string) {
4350
+ throwIfStageMutationBlocked();
3320
4351
  await innerCtx.steer(text);
3321
4352
  },
3322
4353
  async followUp(text: string) {
4354
+ throwIfStageMutationBlocked();
3323
4355
  await innerCtx.followUp(text);
3324
4356
  },
3325
4357
  async pause() {
4358
+ throwIfStageMutationBlocked();
3326
4359
  const statusBeforePause = stageSnapshot.status;
3327
4360
  const changed = activeStore.recordStagePaused(runId, stageId);
3328
4361
  if (changed) {
@@ -3334,6 +4367,7 @@ export async function run<TInputs extends WorkflowInputValues>(
3334
4367
  }
3335
4368
  },
3336
4369
  async resume(message?: string) {
4370
+ throwIfStageMutationBlocked();
3337
4371
  const changed = activeStore.recordStageResumed(runId, stageId);
3338
4372
  if (changed) {
3339
4373
  releaseStageBarrier(stageId);
@@ -3349,9 +4383,18 @@ export async function run<TInputs extends WorkflowInputValues>(
3349
4383
  },
3350
4384
  };
3351
4385
  let stageFinalized = false;
4386
+ let unregisterWorkflowExitCleanup = (): void => {};
3352
4387
  const finalizeStageSnapshot = (): boolean => {
3353
4388
  if (stageFinalized) return false;
4389
+ if (stageSnapshot.endedAt !== undefined && isTerminalStage(stageSnapshot)) {
4390
+ stageFinalized = true;
4391
+ unregisterWorkflowExitCleanup();
4392
+ stageFailFastScope?.activeStages.delete(stageId);
4393
+ tracker.onSettle(stageId);
4394
+ return false;
4395
+ }
3354
4396
  stageFinalized = true;
4397
+ unregisterWorkflowExitCleanup();
3355
4398
  stageSnapshot.endedAt = Date.now();
3356
4399
  stageSnapshot.durationMs = elapsedStageMs(stageSnapshot, stageSnapshot.endedAt);
3357
4400
 
@@ -3405,6 +4448,18 @@ export async function run<TInputs extends WorkflowInputValues>(
3405
4448
  void dropStageControlForCompletion().catch(() => {});
3406
4449
  };
3407
4450
  stageFailFastScope?.activeStages.set(stageId, { skip: skipForParallelFailFast });
4451
+ unregisterWorkflowExitCleanup = registerWorkflowExitCleanup(stageId, {
4452
+ async skipForWorkflowExit(reason?: string): Promise<void> {
4453
+ stageClosedByWorkflowExit = true;
4454
+ if (!isTerminalStage(stageSnapshot)) {
4455
+ stageSnapshot.status = "skipped";
4456
+ stageSnapshot.skippedReason = workflowExitSkippedReason(reason);
4457
+ finalizeStageSnapshot();
4458
+ }
4459
+ await innerCtx.abort().catch(() => {});
4460
+ await releaseLiveHandle().catch(() => {});
4461
+ },
4462
+ });
3408
4463
 
3409
4464
  let stageControlDropped = false;
3410
4465
  dropStageControlHandle = (): void => {
@@ -3463,6 +4518,7 @@ export async function run<TInputs extends WorkflowInputValues>(
3463
4518
  };
3464
4519
 
3465
4520
  const runTrackedStageCall = async (call: () => Promise<string>, eagerSession = false): Promise<string> => {
4521
+ throwIfWorkflowExitSelected();
3466
4522
  await waitForStageRelease();
3467
4523
  if (stageFinalized) {
3468
4524
  throw parallelFailFastError();
@@ -3473,6 +4529,7 @@ export async function run<TInputs extends WorkflowInputValues>(
3473
4529
 
3474
4530
  try {
3475
4531
  await waitForStageRelease();
4532
+ throwIfWorkflowExitSelected();
3476
4533
  if (stageFinalized) {
3477
4534
  throw parallelFailFastError();
3478
4535
  }
@@ -3615,7 +4672,16 @@ export async function run<TInputs extends WorkflowInputValues>(
3615
4672
  }
3616
4673
  return result;
3617
4674
  } catch (err) {
3618
- if (!ownController.signal.aborted && !skippedForParallelFailFast) {
4675
+ const workflowExitAbort = ownController.signal.aborted
4676
+ ? currentWorkflowExitAbortReason()
4677
+ : undefined;
4678
+ if (workflowExitAbort !== undefined && !skippedForParallelFailFast) {
4679
+ stageClosedByWorkflowExit = true;
4680
+ if (!isTerminalStage(stageSnapshot)) {
4681
+ stageSnapshot.status = "skipped";
4682
+ stageSnapshot.skippedReason = workflowExitSkippedReason(workflowExitAbort.reason);
4683
+ }
4684
+ } else if (!ownController.signal.aborted && !skippedForParallelFailFast) {
3619
4685
  applyFailureToStage(stageSnapshot, classifyExecutorFailure(err));
3620
4686
  }
3621
4687
  throw err;
@@ -3625,11 +4691,15 @@ export async function run<TInputs extends WorkflowInputValues>(
3625
4691
  }
3626
4692
 
3627
4693
  finalizeStageSnapshot();
3628
- // The stage has finished participating in workflow scheduling. Drop it
3629
- // from run-level pause/resume and cascade-pause lookups immediately,
3630
- // while retaining the direct chat handle so completed nodes can be
3631
- // reopened and continued instead of becoming read-only archives.
3632
- await dropStageControlForCompletion().catch(() => {});
4694
+ if (stageClosedByWorkflowExit || currentWorkflowExitAbortReason() !== undefined) {
4695
+ await releaseLiveHandle().catch(() => {});
4696
+ } else {
4697
+ // The stage has finished participating in workflow scheduling. Drop it
4698
+ // from run-level pause/resume and cascade-pause lookups immediately,
4699
+ // while retaining the direct chat handle so completed nodes can be
4700
+ // reopened and continued instead of becoming read-only archives.
4701
+ await dropStageControlForCompletion().catch(() => {});
4702
+ }
3633
4703
  limiter.release();
3634
4704
  }
3635
4705
  };
@@ -3672,29 +4742,46 @@ export async function run<TInputs extends WorkflowInputValues>(
3672
4742
 
3673
4743
  const stageContext: StageContext & Pick<InternalStageContext, "__modelFallbackMeta"> = {
3674
4744
  name: innerCtx.name,
3675
- prompt: (text, promptOptions) => runTrackedStageCall(() => innerCtx.prompt(text, promptOptions), true),
3676
- complete: (text, completeOptions) => runTrackedStageCall(() => innerCtx.complete(text, completeOptions)),
3677
- steer: (text) => innerCtx.steer(text),
3678
- followUp: (text) => innerCtx.followUp(text),
4745
+ prompt: (text, promptOptions) => {
4746
+ throwIfStageMutationBlocked();
4747
+ return runTrackedStageCall(() => innerCtx.prompt(text, promptOptions), true);
4748
+ },
4749
+ complete: (text, completeOptions) => {
4750
+ throwIfStageMutationBlocked();
4751
+ return runTrackedStageCall(() => innerCtx.complete(text, completeOptions));
4752
+ },
4753
+ steer: (text) => {
4754
+ throwIfStageMutationBlocked();
4755
+ return innerCtx.steer(text);
4756
+ },
4757
+ followUp: (text) => {
4758
+ throwIfStageMutationBlocked();
4759
+ return innerCtx.followUp(text);
4760
+ },
3679
4761
  subscribe: (listener) => innerCtx.subscribe(listener),
3680
4762
  get sessionFile() { return innerCtx.sessionFile; },
3681
4763
  get sessionId() { return innerCtx.sessionId; },
3682
4764
  setModel: async (model) => {
4765
+ throwIfStageMutationBlocked();
3683
4766
  await innerCtx.__ensureSession();
4767
+ throwIfStageMutationBlocked();
3684
4768
  recordStageNotice({ kind: "model", from: noticeValue(innerCtx.model), to: noticeValue(model) });
3685
4769
  await innerCtx.setModel(model);
3686
4770
  },
3687
4771
  setThinkingLevel: (level) => {
4772
+ throwIfStageMutationBlocked();
3688
4773
  recordStageNotice({ kind: "thinking", from: noticeValue(innerCtx.thinkingLevel), to: noticeValue(level) });
3689
4774
  innerCtx.setThinkingLevel(level);
3690
4775
  },
3691
4776
  cycleModel: async () => {
4777
+ throwIfStageMutationBlocked();
3692
4778
  const from = noticeValue(innerCtx.model);
3693
4779
  const result = await innerCtx.cycleModel();
3694
4780
  recordStageNotice({ kind: "model", from, to: noticeValue(innerCtx.model) });
3695
4781
  return result;
3696
4782
  },
3697
4783
  cycleThinkingLevel: () => {
4784
+ throwIfStageMutationBlocked();
3698
4785
  const from = noticeValue(innerCtx.thinkingLevel);
3699
4786
  const result = innerCtx.cycleThinkingLevel();
3700
4787
  recordStageNotice({ kind: "thinking", from, to: noticeValue(innerCtx.thinkingLevel) });
@@ -3706,16 +4793,22 @@ export async function run<TInputs extends WorkflowInputValues>(
3706
4793
  get messages() { return innerCtx.messages; },
3707
4794
  get isStreaming() { return innerCtx.isStreaming; },
3708
4795
  navigateTree: async (targetId, treeOptions) => {
4796
+ throwIfStageMutationBlocked();
3709
4797
  recordStageNotice({ kind: "tree", to: targetId });
3710
4798
  return innerCtx.navigateTree(targetId, treeOptions);
3711
4799
  },
3712
4800
  compact: async () => {
4801
+ throwIfStageMutationBlocked();
3713
4802
  const result = await innerCtx.compact();
3714
4803
  recordStageNotice({ kind: "compaction", to: "compacted", meta: compactionMeta(result) });
3715
4804
  return result;
3716
4805
  },
3717
- abortCompaction: () => innerCtx.abortCompaction(),
4806
+ abortCompaction: () => {
4807
+ throwIfStageMutationBlocked();
4808
+ innerCtx.abortCompaction();
4809
+ },
3718
4810
  abort: async () => {
4811
+ throwIfStageMutationBlocked();
3719
4812
  recordStageNotice({ kind: "abort", to: "interrupted" });
3720
4813
  await innerCtx.abort();
3721
4814
  },
@@ -3725,7 +4818,9 @@ export async function run<TInputs extends WorkflowInputValues>(
3725
4818
  },
3726
4819
 
3727
4820
  async task(name: string, options: WorkflowTaskOptions, stageFailFastScope?: ParallelFailFastScope): Promise<WorkflowTaskResult> {
4821
+ throwIfWorkflowExitSelected();
3728
4822
  const runTaskOnce = async (taskOptions: WorkflowTaskOptions): Promise<WorkflowTaskResult> => {
4823
+ throwIfWorkflowExitSelected();
3729
4824
  const resolvedTaskOptions = stageOptionsWithGitWorktree(stageOptionsWithInputDefaults(taskOptions, inputRuntimeDefaults), workflowInvocationCwd) ?? taskOptions;
3730
4825
  const stage = (ctx.stage as typeof ctx.stage & ((stageName: string, stageOptions?: StageOptions, scope?: ParallelFailFastScope) => StageContext))(
3731
4826
  name,
@@ -3780,8 +4875,10 @@ export async function run<TInputs extends WorkflowInputValues>(
3780
4875
  },
3781
4876
 
3782
4877
  async chain(steps: readonly WorkflowTaskStep[], options: WorkflowChainOptions = {}): Promise<WorkflowTaskResult[]> {
4878
+ throwIfWorkflowExitSelected();
3783
4879
  const results: WorkflowTaskResult[] = [];
3784
4880
  for (let index = 0; index < steps.length; index += 1) {
4881
+ throwIfWorkflowExitSelected();
3785
4882
  const step = steps[index]!;
3786
4883
  const explicitPrevious = taskPrevious(step);
3787
4884
  const previous = explicitPrevious ?? (index > 0 ? results[index - 1] : undefined);
@@ -3795,11 +4892,13 @@ export async function run<TInputs extends WorkflowInputValues>(
3795
4892
  },
3796
4893
 
3797
4894
  async parallel(steps: readonly WorkflowTaskStep[], options: WorkflowParallelOptions = {}): Promise<WorkflowTaskResult[]> {
4895
+ throwIfWorkflowExitSelected();
3798
4896
  const fallback = parallelFallbackTask(steps, options);
3799
4897
  const failFastScope: ParallelFailFastScope | undefined = options.failFast === false
3800
4898
  ? undefined
3801
4899
  : { failed: false, activeStages: new Map<string, ParallelFailFastStage>() };
3802
4900
  return mapParallelSteps(steps, options.concurrency, options.failFast, async (step) => {
4901
+ throwIfWorkflowExitSelected();
3803
4902
  const prompt = replaceTaskPlaceholder(step.prompt ?? step.task ?? fallback, options.task ?? fallback);
3804
4903
  return await (ctx.task as typeof ctx.task & ((taskName: string, taskOptions: WorkflowTaskOptions, scope?: ParallelFailFastScope) => Promise<WorkflowTaskResult>))(
3805
4904
  step.name,
@@ -3813,6 +4912,10 @@ export async function run<TInputs extends WorkflowInputValues>(
3813
4912
  for (const stage of failFastScope.activeStages.values()) {
3814
4913
  stage.skip();
3815
4914
  }
4915
+ }, {
4916
+ beforeDequeue: throwIfWorkflowExitSelected,
4917
+ beforeMap: throwIfWorkflowExitSelected,
4918
+ isControlSignal: (error) => findWorkflowExitSignal(error, exitScope) !== undefined,
3816
4919
  });
3817
4920
  },
3818
4921
 
@@ -3820,6 +4923,7 @@ export async function run<TInputs extends WorkflowInputValues>(
3820
4923
  child: WorkflowDefinition<TChildInputs, TChildOutputs>,
3821
4924
  options: WorkflowRunChildOptions<TChildInputs> = {},
3822
4925
  ): Promise<WorkflowChildResult<TChildOutputs>> {
4926
+ throwIfWorkflowExitSelected();
3823
4927
  // The executor operates on type-erased definitions at runtime; the child's
3824
4928
  // declared output contract is validated dynamically by the child run and
3825
4929
  // selectWorkflowOutputs, so the typed result is reconstructed via casts.
@@ -3830,16 +4934,6 @@ export async function run<TInputs extends WorkflowInputValues>(
3830
4934
  const boundaryName = options.stageName ?? `workflow:${childName}`;
3831
4935
  const boundaryReplayKey = nextWorkflowBoundaryReplayKey(boundaryName);
3832
4936
  const boundary = startWorkflowBoundaryStage(boundaryName, boundaryReplayKey);
3833
- if (boundary.replayedChild !== undefined) {
3834
- // Continuation replay returns the persisted child boundary exactly as
3835
- // written; input validation and output remapping are intentionally not
3836
- // re-run against edited workflow code for a completed child boundary.
3837
- // Defer settling by one microtask so concurrent replayed boundaries
3838
- // spawned in the same turn see the same frontier as the source run.
3839
- await Promise.resolve();
3840
- boundary.finalizeReplay();
3841
- return boundary.replayedChild as WorkflowChildResult<TChildOutputs>;
3842
- }
3843
4937
 
3844
4938
  // Tracked so the finally can detach the parent-abort listener and release
3845
4939
  // the pre-registered child controller on every exit path — including the
@@ -3849,28 +4943,48 @@ export async function run<TInputs extends WorkflowInputValues>(
3849
4943
  let childRunId: string | undefined;
3850
4944
  let detachParentAbort: (() => void) | undefined;
3851
4945
  try {
4946
+ if (boundary.replayedChild !== undefined) {
4947
+ // Continuation replay returns the persisted child boundary exactly as
4948
+ // written; input validation and output remapping are intentionally not
4949
+ // re-run against edited workflow code for a completed child boundary.
4950
+ // Defer settling by one microtask so concurrent replayed boundaries
4951
+ // spawned in the same turn see the same frontier as the source run.
4952
+ await Promise.resolve();
4953
+ throwIfWorkflowExitSelected();
4954
+ boundary.finalizeReplay();
4955
+ return boundary.replayedChild as WorkflowChildResult<TChildOutputs>;
4956
+ }
4957
+
3852
4958
  const childInputs = resolveAndValidateInputs(
3853
4959
  child.inputs,
3854
4960
  options.inputs ?? {},
3855
4961
  `child workflow "${childName}" (${child.name})`,
3856
4962
  );
4963
+ throwIfWorkflowExitSelected();
3857
4964
 
3858
4965
  childRunId = crypto.randomUUID();
3859
- boundary.linkChildRun({
4966
+ const childController = new AbortController();
4967
+ const childRef: WorkflowChildRunRef = {
3860
4968
  alias: childName,
3861
4969
  workflow: child.normalizedName,
3862
4970
  runId: childRunId,
3863
- });
4971
+ };
4972
+ boundary.linkChildRun(childRef, childController);
3864
4973
 
3865
- const childController = new AbortController();
4974
+ const abortChildFromParent = (): void => {
4975
+ const parentExit = findWorkflowExitSignal(ownController.signal.reason, exitScope);
4976
+ childController.abort(parentExit !== undefined
4977
+ ? makeParentWorkflowExitAbortReason(parentExit.reason)
4978
+ : ownController.signal.reason);
4979
+ };
3866
4980
  if (ownController.signal.aborted) {
3867
- childController.abort(ownController.signal.reason);
4981
+ abortChildFromParent();
3868
4982
  } else {
3869
- const onParentAbort = () => childController.abort(ownController.signal.reason);
3870
- ownController.signal.addEventListener("abort", onParentAbort, { once: true });
4983
+ ownController.signal.addEventListener("abort", abortChildFromParent, { once: true });
3871
4984
  detachParentAbort = () =>
3872
- ownController.signal.removeEventListener("abort", onParentAbort);
4985
+ ownController.signal.removeEventListener("abort", abortChildFromParent);
3873
4986
  }
4987
+ throwIfWorkflowExitSelected();
3874
4988
  // Pre-register the child controller under its own runId *before* run()
3875
4989
  // so a kill targeting the child runId works even before the nested run
3876
4990
  // would register itself. The nested run() sees opts.signal set and skips
@@ -3878,6 +4992,7 @@ export async function run<TInputs extends WorkflowInputValues>(
3878
4992
  // key) while still running its finally{} unregister(runId) cleanup, so
3879
4993
  // both branches must agree on this key.
3880
4994
  opts.cancellation?.register(childRunId, childController);
4995
+ throwIfWorkflowExitSelected();
3881
4996
 
3882
4997
  const {
3883
4998
  runId: _parentRunId,
@@ -3888,7 +5003,7 @@ export async function run<TInputs extends WorkflowInputValues>(
3888
5003
  onRunEnd: _parentOnRunEnd,
3889
5004
  ...childBaseOpts
3890
5005
  } = opts;
3891
- const childRun = await run(child, childInputs, {
5006
+ const childRunPromise = run(child, childInputs, {
3892
5007
  ...childBaseOpts,
3893
5008
  runId: childRunId,
3894
5009
  cwd: resolveWorkflowCwd(),
@@ -3902,8 +5017,11 @@ export async function run<TInputs extends WorkflowInputValues>(
3902
5017
  signal: childController.signal,
3903
5018
  deferWorkflowStart: false,
3904
5019
  });
5020
+ boundary.observeChildRun(childRunPromise);
5021
+ const childRun = await childRunPromise;
5022
+ throwIfWorkflowExitSelected();
3905
5023
 
3906
- if (childRun.status !== "completed") {
5024
+ if (!isWorkflowExitStatus(childRun.status)) {
3907
5025
  const failedChildStage = childRun.stages.find((stage) => stage.failureKind !== undefined);
3908
5026
  throw new Error(
3909
5027
  `atomic-workflows: child workflow "${childName}" (${child.name}) failed with status ${childRun.status}${childRun.error !== undefined ? `: ${childRun.error}` : ""}`,
@@ -3917,20 +5035,36 @@ export async function run<TInputs extends WorkflowInputValues>(
3917
5035
  }
3918
5036
 
3919
5037
  const outputs = selectWorkflowOutputs(child, childRun.result);
3920
- const childResult: WorkflowChildResult<TChildOutputs> = {
3921
- workflow: child.normalizedName,
3922
- runId: childRun.runId,
3923
- status: "completed",
3924
- outputs: outputs as TChildOutputs,
3925
- };
5038
+ const childExited = childRun.exited === true || childRun.status !== "completed";
5039
+ const childResult: WorkflowChildResult<TChildOutputs> = childExited
5040
+ ? {
5041
+ workflow: child.normalizedName,
5042
+ runId: childRun.runId,
5043
+ status: childRun.status,
5044
+ exited: true,
5045
+ outputs: outputs as Partial<TChildOutputs>,
5046
+ ...(childRun.exitReason !== undefined ? { exitReason: childRun.exitReason } : {}),
5047
+ }
5048
+ : {
5049
+ workflow: child.normalizedName,
5050
+ runId: childRun.runId,
5051
+ status: "completed",
5052
+ exited: false,
5053
+ outputs: outputs as TChildOutputs,
5054
+ };
3926
5055
  const workflowChild = workflowChildReplaySnapshot(childName, childResult);
3927
5056
  const outputKeys = Object.keys(outputs);
3928
5057
  boundary.complete(
3929
- `Workflow "${child.name}" completed (runId: ${childRun.runId}; outputs: ${outputKeys.length > 0 ? outputKeys.join(", ") : "(none)"})`,
5058
+ `Workflow "${child.name}" ${childRun.status} (runId: ${childRun.runId}; outputs: ${outputKeys.length > 0 ? outputKeys.join(", ") : "(none)"})`,
3930
5059
  workflowChild,
3931
5060
  );
3932
5061
  return childResult;
3933
5062
  } catch (err) {
5063
+ const exit = findWorkflowExitSignal(err, exitScope) ?? findWorkflowExitSignal(ownController.signal.reason, exitScope);
5064
+ if (exit !== undefined) {
5065
+ await boundary.skipForWorkflowExit(exit.reason);
5066
+ throw exit;
5067
+ }
3934
5068
  boundary.fail(err);
3935
5069
  throw err;
3936
5070
  } finally {
@@ -3947,6 +5081,10 @@ export async function run<TInputs extends WorkflowInputValues>(
3947
5081
  if (opts.deferWorkflowStart === true) {
3948
5082
  await nextEventLoopTurn();
3949
5083
  if (ownController.signal.aborted) {
5084
+ const exit = findWorkflowExitSignal(ownController.signal.reason, exitScope);
5085
+ if (exit !== undefined) return await finalizeWorkflowExit(exit);
5086
+ const parentExit = parentWorkflowExitAbortReason(ownController.signal.reason);
5087
+ if (parentExit !== undefined) return await finalizeParentWorkflowExitCancellation(parentExit);
3950
5088
  return finalizeKilled(runId, runSnapshot, activeStore, opts.persistence, opts.onRunEnd);
3951
5089
  }
3952
5090
  }
@@ -3954,8 +5092,12 @@ export async function run<TInputs extends WorkflowInputValues>(
3954
5092
  const rawResult = await def.run(ctx);
3955
5093
 
3956
5094
  // Post-body abort check: if signal was aborted at any point before we record
3957
- // completion, the run must be finalized as "killed", never "completed".
5095
+ // completion, classify a scoped author exit before falling back to killed.
3958
5096
  if (ownController.signal.aborted) {
5097
+ const exit = findWorkflowExitSignal(ownController.signal.reason, exitScope);
5098
+ if (exit !== undefined) return await finalizeWorkflowExit(exit);
5099
+ const parentExit = parentWorkflowExitAbortReason(ownController.signal.reason);
5100
+ if (parentExit !== undefined) return await finalizeParentWorkflowExitCancellation(parentExit);
3959
5101
  return finalizeKilled(runId, runSnapshot, activeStore, opts.persistence, opts.onRunEnd);
3960
5102
  }
3961
5103
 
@@ -3965,7 +5107,6 @@ export async function run<TInputs extends WorkflowInputValues>(
3965
5107
  assertWorkflowCreatedStage(runSnapshot);
3966
5108
 
3967
5109
  const recorded = activeStore.recordRunEnd(runId, "completed", result);
3968
- opts.onRunEnd?.(runId, "completed", result);
3969
5110
 
3970
5111
  appendRunEndWhenRecorded(opts.persistence, recorded, {
3971
5112
  runId,
@@ -3974,14 +5115,17 @@ export async function run<TInputs extends WorkflowInputValues>(
3974
5115
  ts: Date.now(),
3975
5116
  });
3976
5117
 
3977
- return {
3978
- runId,
5118
+ return reconcileTerminalRunResult(runId, runSnapshot, activeStore, {
3979
5119
  status: "completed",
3980
5120
  result,
3981
- stages: [...runSnapshot.stages],
3982
- };
5121
+ }, opts.onRunEnd);
3983
5122
  } catch (err) {
5123
+ const exit = findWorkflowExitSignal(err, exitScope) ?? findWorkflowExitSignal(ownController.signal.reason, exitScope);
5124
+ if (exit !== undefined) return await finalizeWorkflowExit(exit);
5125
+
3984
5126
  if (ownController.signal.aborted) {
5127
+ const parentExit = parentWorkflowExitAbortReason(ownController.signal.reason);
5128
+ if (parentExit !== undefined) return await finalizeParentWorkflowExitCancellation(parentExit);
3985
5129
  return finalizeKilled(runId, runSnapshot, activeStore, opts.persistence, opts.onRunEnd);
3986
5130
  }
3987
5131
 
@@ -4020,7 +5164,6 @@ export async function run<TInputs extends WorkflowInputValues>(
4020
5164
  }
4021
5165
 
4022
5166
  const recorded = activeStore.recordRunEnd(runId, "failed", undefined, metadata.errorMessage, metadata);
4023
- opts.onRunEnd?.(runId, "failed", undefined, metadata.errorMessage);
4024
5167
 
4025
5168
  appendRunEndWhenRecorded(opts.persistence, recorded, {
4026
5169
  runId,
@@ -4037,12 +5180,10 @@ export async function run<TInputs extends WorkflowInputValues>(
4037
5180
  ts: Date.now(),
4038
5181
  });
4039
5182
 
4040
- return {
4041
- runId,
5183
+ return reconcileTerminalRunResult(runId, runSnapshot, activeStore, {
4042
5184
  status: "failed",
4043
5185
  error: metadata.errorMessage,
4044
- stages: [...runSnapshot.stages],
4045
- };
5186
+ }, opts.onRunEnd);
4046
5187
  } finally {
4047
5188
  opts.cancellation?.unregister(runId);
4048
5189
  }