@openclaw/lobster 2026.9.4 → 2026.9.6

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 (367) hide show
  1. package/assets/activity.svg +4 -0
  2. package/assets/icon.png +0 -0
  3. package/dist/index.js +39 -10
  4. package/node_modules/@clawdbot/lobster/README.md +129 -131
  5. package/node_modules/@clawdbot/lobster/VISION.md +15 -238
  6. package/node_modules/@clawdbot/lobster/bin/clawd.invoke.js +2 -15
  7. package/node_modules/@clawdbot/lobster/bin/invoke.js +19 -0
  8. package/node_modules/@clawdbot/lobster/bin/lobster.js +2 -21
  9. package/node_modules/@clawdbot/lobster/bin/openclaw.invoke.js +2 -18
  10. package/node_modules/@clawdbot/lobster/dist/src/abortable_process.js +297 -0
  11. package/node_modules/@clawdbot/lobster/dist/src/abortable_process.js.map +1 -0
  12. package/node_modules/@clawdbot/lobster/dist/src/cli.js +128 -296
  13. package/node_modules/@clawdbot/lobster/dist/src/cli.js.map +1 -1
  14. package/node_modules/@clawdbot/lobster/dist/src/command_tokens.js +81 -0
  15. package/node_modules/@clawdbot/lobster/dist/src/command_tokens.js.map +1 -0
  16. package/node_modules/@clawdbot/lobster/dist/src/commands/commands_list.js.map +1 -1
  17. package/node_modules/@clawdbot/lobster/dist/src/commands/registry.js +2 -0
  18. package/node_modules/@clawdbot/lobster/dist/src/commands/registry.js.map +1 -1
  19. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/approve.js +2 -0
  20. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/approve.js.map +1 -1
  21. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/ask.js +3 -1
  22. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/ask.js.map +1 -1
  23. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/dedupe.js +1 -12
  24. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/dedupe.js.map +1 -1
  25. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/diff_last.js +6 -1
  26. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/diff_last.js.map +1 -1
  27. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/email_triage.js.map +1 -1
  28. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/exec.js +30 -34
  29. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/exec.js.map +1 -1
  30. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_search.js +12 -25
  31. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_search.js.map +1 -1
  32. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_send.js +19 -25
  33. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_send.js.map +1 -1
  34. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/group_by.js +2 -14
  35. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/group_by.js.map +1 -1
  36. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/head.js.map +1 -1
  37. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/json.js.map +1 -1
  38. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js +260 -42
  39. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js.map +1 -1
  40. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js +2 -13
  41. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js.map +1 -1
  42. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_agent.js +139 -0
  43. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_agent.js.map +1 -0
  44. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_invoke.js +20 -2
  45. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_invoke.js.map +1 -1
  46. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/pick.js.map +1 -1
  47. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/sort.js +4 -21
  48. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/sort.js.map +1 -1
  49. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/state.js +48 -20
  50. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/state.js.map +1 -1
  51. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/table.js.map +1 -1
  52. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/template.js +2 -13
  53. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/template.js.map +1 -1
  54. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/where.js.map +1 -1
  55. package/node_modules/@clawdbot/lobster/dist/src/commands/workflows/workflows_list.js.map +1 -1
  56. package/node_modules/@clawdbot/lobster/dist/src/commands/workflows/workflows_run.js +0 -18
  57. package/node_modules/@clawdbot/lobster/dist/src/commands/workflows/workflows_run.js.map +1 -1
  58. package/node_modules/@clawdbot/lobster/dist/src/core/cost_tracker.js +45 -3
  59. package/node_modules/@clawdbot/lobster/dist/src/core/cost_tracker.js.map +1 -1
  60. package/node_modules/@clawdbot/lobster/dist/src/core/filters.js.map +1 -1
  61. package/node_modules/@clawdbot/lobster/dist/src/core/llm_accounting.js +304 -0
  62. package/node_modules/@clawdbot/lobster/dist/src/core/llm_accounting.js.map +1 -0
  63. package/node_modules/@clawdbot/lobster/dist/src/core/retry.js.map +1 -1
  64. package/node_modules/@clawdbot/lobster/dist/src/core/tool_runtime.js +147 -21
  65. package/node_modules/@clawdbot/lobster/dist/src/core/tool_runtime.js.map +1 -1
  66. package/node_modules/@clawdbot/lobster/dist/src/core/value_path.js +11 -0
  67. package/node_modules/@clawdbot/lobster/dist/src/core/value_path.js.map +1 -0
  68. package/node_modules/@clawdbot/lobster/dist/src/input_request.js +4 -2
  69. package/node_modules/@clawdbot/lobster/dist/src/input_request.js.map +1 -1
  70. package/node_modules/@clawdbot/lobster/dist/src/parser.js +5 -69
  71. package/node_modules/@clawdbot/lobster/dist/src/parser.js.map +1 -1
  72. package/node_modules/@clawdbot/lobster/dist/src/pipeline_resume_state.js +241 -74
  73. package/node_modules/@clawdbot/lobster/dist/src/pipeline_resume_state.js.map +1 -1
  74. package/node_modules/@clawdbot/lobster/dist/src/read_line.js +52 -7
  75. package/node_modules/@clawdbot/lobster/dist/src/read_line.js.map +1 -1
  76. package/node_modules/@clawdbot/lobster/dist/src/read_response_text.js +86 -0
  77. package/node_modules/@clawdbot/lobster/dist/src/read_response_text.js.map +1 -0
  78. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/index.js +1 -1
  79. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/pr-monitor.js +9 -66
  80. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/pr-monitor.js.map +1 -1
  81. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/read_pr.js +45 -0
  82. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/read_pr.js.map +1 -0
  83. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/snapshot.js +45 -0
  84. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/snapshot.js.map +1 -0
  85. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/stages/pr-view.js +13 -65
  86. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/stages/pr-view.js.map +1 -1
  87. package/node_modules/@clawdbot/lobster/dist/src/recipes/registry.js.map +1 -1
  88. package/node_modules/@clawdbot/lobster/dist/src/renderers/json.js.map +1 -1
  89. package/node_modules/@clawdbot/lobster/dist/src/resume.js.map +1 -1
  90. package/node_modules/@clawdbot/lobster/dist/src/runtime.js +187 -17
  91. package/node_modules/@clawdbot/lobster/dist/src/runtime.js.map +1 -1
  92. package/node_modules/@clawdbot/lobster/dist/src/sdk/Lobster.js +25 -134
  93. package/node_modules/@clawdbot/lobster/dist/src/sdk/Lobster.js.map +1 -1
  94. package/node_modules/@clawdbot/lobster/dist/src/sdk/index.js +1 -1
  95. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/approve.js +1 -2
  96. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/approve.js.map +1 -1
  97. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/diff.js +11 -86
  98. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/diff.js.map +1 -1
  99. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/exec.js +20 -96
  100. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/exec.js.map +1 -1
  101. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/state.js +8 -106
  102. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/state.js.map +1 -1
  103. package/node_modules/@clawdbot/lobster/dist/src/sdk/runtime.js +2 -13
  104. package/node_modules/@clawdbot/lobster/dist/src/sdk/runtime.js.map +1 -1
  105. package/node_modules/@clawdbot/lobster/dist/src/sdk/state_env.js +6 -0
  106. package/node_modules/@clawdbot/lobster/dist/src/sdk/state_env.js.map +1 -0
  107. package/node_modules/@clawdbot/lobster/dist/src/shell.js.map +1 -1
  108. package/node_modules/@clawdbot/lobster/dist/src/state/atomic_file.js +192 -0
  109. package/node_modules/@clawdbot/lobster/dist/src/state/atomic_file.js.map +1 -0
  110. package/node_modules/@clawdbot/lobster/dist/src/state/store.js +550 -136
  111. package/node_modules/@clawdbot/lobster/dist/src/state/store.js.map +1 -1
  112. package/node_modules/@clawdbot/lobster/dist/src/token.js.map +1 -1
  113. package/node_modules/@clawdbot/lobster/dist/src/validation.js.map +1 -1
  114. package/node_modules/@clawdbot/lobster/dist/src/workflows/dry_run.js +262 -0
  115. package/node_modules/@clawdbot/lobster/dist/src/workflows/dry_run.js.map +1 -0
  116. package/node_modules/@clawdbot/lobster/dist/src/workflows/expressions.js +345 -0
  117. package/node_modules/@clawdbot/lobster/dist/src/workflows/expressions.js.map +1 -0
  118. package/node_modules/@clawdbot/lobster/dist/src/workflows/file.js +950 -1285
  119. package/node_modules/@clawdbot/lobster/dist/src/workflows/file.js.map +1 -1
  120. package/node_modules/@clawdbot/lobster/dist/src/workflows/github_pr_monitor.js +20 -90
  121. package/node_modules/@clawdbot/lobster/dist/src/workflows/github_pr_monitor.js.map +1 -1
  122. package/node_modules/@clawdbot/lobster/dist/src/workflows/graph.js +9 -8
  123. package/node_modules/@clawdbot/lobster/dist/src/workflows/graph.js.map +1 -1
  124. package/node_modules/@clawdbot/lobster/dist/src/workflows/load.js +347 -0
  125. package/node_modules/@clawdbot/lobster/dist/src/workflows/load.js.map +1 -0
  126. package/node_modules/@clawdbot/lobster/dist/src/workflows/registry.js.map +1 -1
  127. package/node_modules/@clawdbot/lobster/dist/src/workflows/types.js +2 -0
  128. package/node_modules/@clawdbot/lobster/dist/src/workflows/types.js.map +1 -0
  129. package/node_modules/@clawdbot/lobster/dist/src/workflows/values.js +93 -0
  130. package/node_modules/@clawdbot/lobster/dist/src/workflows/values.js.map +1 -0
  131. package/node_modules/@clawdbot/lobster/dist/test/approval_id.test.js.map +1 -1
  132. package/node_modules/@clawdbot/lobster/dist/test/approve_preview.test.js.map +1 -1
  133. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js +25 -0
  134. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js.map +1 -0
  135. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js +5171 -0
  136. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js.map +1 -0
  137. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js +22 -0
  138. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js.map +1 -1
  139. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke_legacy.test.js.map +1 -1
  140. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js +53 -0
  141. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js.map +1 -0
  142. package/node_modules/@clawdbot/lobster/dist/test/cli_run_file_args_json.test.js.map +1 -1
  143. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js +82 -0
  144. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js.map +1 -0
  145. package/node_modules/@clawdbot/lobster/dist/test/commands_list.test.js.map +1 -1
  146. package/node_modules/@clawdbot/lobster/dist/test/condition_comparison.test.js.map +1 -1
  147. package/node_modules/@clawdbot/lobster/dist/test/core_tool_runtime.test.js.map +1 -1
  148. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js +1774 -12
  149. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js.map +1 -1
  150. package/node_modules/@clawdbot/lobster/dist/test/dedupe.test.js.map +1 -1
  151. package/node_modules/@clawdbot/lobster/dist/test/diff_last.test.js.map +1 -1
  152. package/node_modules/@clawdbot/lobster/dist/test/doctor.test.js.map +1 -1
  153. package/node_modules/@clawdbot/lobster/dist/test/dry_run.test.js.map +1 -1
  154. package/node_modules/@clawdbot/lobster/dist/test/email_triage.test.js.map +1 -1
  155. package/node_modules/@clawdbot/lobster/dist/test/exec_stdin.test.js.map +1 -1
  156. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js +254 -0
  157. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js.map +1 -1
  158. package/node_modules/@clawdbot/lobster/dist/test/github_pr_notify_format.test.js.map +1 -1
  159. package/node_modules/@clawdbot/lobster/dist/test/github_pr_summary.test.js.map +1 -1
  160. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js +176 -0
  161. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js.map +1 -0
  162. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js +100 -0
  163. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js.map +1 -0
  164. package/node_modules/@clawdbot/lobster/dist/test/group_by.test.js.map +1 -1
  165. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js +17 -0
  166. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js.map +1 -0
  167. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js +19 -0
  168. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js.map +1 -0
  169. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js +318 -0
  170. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js.map +1 -0
  171. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js +948 -1
  172. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js.map +1 -1
  173. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js +103 -0
  174. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js.map +1 -1
  175. package/node_modules/@clawdbot/lobster/dist/test/map.test.js.map +1 -1
  176. package/node_modules/@clawdbot/lobster/dist/test/multi_approval_resume.test.js.map +1 -1
  177. package/node_modules/@clawdbot/lobster/dist/test/on_error.test.js.map +1 -1
  178. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js +169 -0
  179. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js.map +1 -0
  180. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js +96 -0
  181. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js.map +1 -0
  182. package/node_modules/@clawdbot/lobster/dist/test/openclaw_invoke_alias.test.js.map +1 -1
  183. package/node_modules/@clawdbot/lobster/dist/test/parallel.test.js.map +1 -1
  184. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js +57 -0
  185. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js.map +1 -1
  186. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js +65 -0
  187. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js.map +1 -0
  188. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js +175 -0
  189. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js.map +1 -0
  190. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js +54 -0
  191. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js.map +1 -1
  192. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js +39 -0
  193. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js.map +1 -0
  194. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js +1 -1
  195. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js.map +1 -1
  196. package/node_modules/@clawdbot/lobster/dist/test/resume.test.js.map +1 -1
  197. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js +248 -0
  198. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js.map +1 -0
  199. package/node_modules/@clawdbot/lobster/dist/test/sdk_lobster.test.js.map +1 -1
  200. package/node_modules/@clawdbot/lobster/dist/test/shell.test.js.map +1 -1
  201. package/node_modules/@clawdbot/lobster/dist/test/sort.test.js.map +1 -1
  202. package/node_modules/@clawdbot/lobster/dist/test/state.test.js +584 -1
  203. package/node_modules/@clawdbot/lobster/dist/test/state.test.js.map +1 -1
  204. package/node_modules/@clawdbot/lobster/dist/test/step_retry.test.js.map +1 -1
  205. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js +44 -0
  206. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js.map +1 -0
  207. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js +67 -1
  208. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js.map +1 -1
  209. package/node_modules/@clawdbot/lobster/dist/test/template.test.js.map +1 -1
  210. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js +37 -0
  211. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js.map +1 -1
  212. package/node_modules/@clawdbot/lobster/dist/test/tool_envelope_version.test.js.map +1 -1
  213. package/node_modules/@clawdbot/lobster/dist/test/tool_mode.test.js.map +1 -1
  214. package/node_modules/@clawdbot/lobster/dist/test/validation.test.js.map +1 -1
  215. package/node_modules/@clawdbot/lobster/dist/test/where.test.js +94 -0
  216. package/node_modules/@clawdbot/lobster/dist/test/where.test.js.map +1 -0
  217. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js +5 -3
  218. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js.map +1 -1
  219. package/node_modules/@clawdbot/lobster/dist/test/workflow_composition.test.js.map +1 -1
  220. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js +647 -1
  221. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js.map +1 -1
  222. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js +13 -3
  223. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js.map +1 -1
  224. package/node_modules/@clawdbot/lobster/dist/test/workflows.test.js.map +1 -1
  225. package/node_modules/@clawdbot/lobster/package.json +72 -73
  226. package/node_modules/typebox/build/compile/validator.d.mts +1 -1
  227. package/node_modules/typebox/build/compile/validator.mjs +1 -1
  228. package/node_modules/typebox/build/format/idna/idn-hostname.mjs +7 -5
  229. package/node_modules/typebox/build/format/iri.mjs +5 -1
  230. package/node_modules/typebox/build/guard/emit.mjs +2 -4
  231. package/node_modules/typebox/build/guard/guard.mjs +6 -18
  232. package/node_modules/typebox/build/guard/string.mjs +16 -24
  233. package/node_modules/typebox/build/schema/build.mjs +2 -2
  234. package/node_modules/typebox/build/schema/check.mjs +1 -1
  235. package/node_modules/typebox/build/schema/compile.d.mts +3 -2
  236. package/node_modules/typebox/build/schema/compile.mjs +8 -8
  237. package/node_modules/typebox/build/schema/engine/_context.d.mts +6 -7
  238. package/node_modules/typebox/build/schema/engine/_context.mjs +23 -15
  239. package/node_modules/typebox/build/schema/engine/_functions.d.mts +2 -2
  240. package/node_modules/typebox/build/schema/engine/_functions.mjs +1 -1
  241. package/node_modules/typebox/build/schema/engine/_reducer.d.mts +3 -3
  242. package/node_modules/typebox/build/schema/engine/_refine.d.mts +5 -5
  243. package/node_modules/typebox/build/schema/engine/_refine.mjs +4 -7
  244. package/node_modules/typebox/build/schema/engine/_stack.d.mts +34 -16
  245. package/node_modules/typebox/build/schema/engine/_stack.mjs +148 -104
  246. package/node_modules/typebox/build/schema/engine/additionalItems.d.mts +4 -4
  247. package/node_modules/typebox/build/schema/engine/additionalProperties.d.mts +8 -8
  248. package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +26 -18
  249. package/node_modules/typebox/build/schema/engine/allOf.d.mts +4 -4
  250. package/node_modules/typebox/build/schema/engine/allOf.mjs +3 -3
  251. package/node_modules/typebox/build/schema/engine/anyOf.d.mts +4 -4
  252. package/node_modules/typebox/build/schema/engine/anyOf.mjs +5 -10
  253. package/node_modules/typebox/build/schema/engine/boolean.d.mts +4 -4
  254. package/node_modules/typebox/build/schema/engine/boolean.mjs +2 -6
  255. package/node_modules/typebox/build/schema/engine/const.d.mts +4 -4
  256. package/node_modules/typebox/build/schema/engine/const.mjs +2 -6
  257. package/node_modules/typebox/build/schema/engine/contains.d.mts +4 -4
  258. package/node_modules/typebox/build/schema/engine/contains.mjs +2 -6
  259. package/node_modules/typebox/build/schema/engine/dependencies.d.mts +4 -4
  260. package/node_modules/typebox/build/schema/engine/dependencies.mjs +3 -6
  261. package/node_modules/typebox/build/schema/engine/dependentRequired.d.mts +4 -4
  262. package/node_modules/typebox/build/schema/engine/dependentRequired.mjs +2 -6
  263. package/node_modules/typebox/build/schema/engine/dependentSchemas.d.mts +4 -4
  264. package/node_modules/typebox/build/schema/engine/dynamicRef.d.mts +4 -4
  265. package/node_modules/typebox/build/schema/engine/dynamicRef.mjs +11 -7
  266. package/node_modules/typebox/build/schema/engine/enum.d.mts +4 -4
  267. package/node_modules/typebox/build/schema/engine/enum.mjs +2 -6
  268. package/node_modules/typebox/build/schema/engine/exclusiveMaximum.d.mts +4 -4
  269. package/node_modules/typebox/build/schema/engine/exclusiveMaximum.mjs +2 -6
  270. package/node_modules/typebox/build/schema/engine/exclusiveMinimum.d.mts +4 -4
  271. package/node_modules/typebox/build/schema/engine/exclusiveMinimum.mjs +2 -6
  272. package/node_modules/typebox/build/schema/engine/format.d.mts +4 -4
  273. package/node_modules/typebox/build/schema/engine/format.mjs +2 -6
  274. package/node_modules/typebox/build/schema/engine/if.d.mts +4 -4
  275. package/node_modules/typebox/build/schema/engine/if.mjs +7 -15
  276. package/node_modules/typebox/build/schema/engine/items.d.mts +4 -4
  277. package/node_modules/typebox/build/schema/engine/items.mjs +1 -1
  278. package/node_modules/typebox/build/schema/engine/maxContains.d.mts +4 -4
  279. package/node_modules/typebox/build/schema/engine/maxContains.mjs +2 -6
  280. package/node_modules/typebox/build/schema/engine/maxItems.d.mts +4 -4
  281. package/node_modules/typebox/build/schema/engine/maxItems.mjs +2 -6
  282. package/node_modules/typebox/build/schema/engine/maxLength.d.mts +4 -4
  283. package/node_modules/typebox/build/schema/engine/maxLength.mjs +2 -6
  284. package/node_modules/typebox/build/schema/engine/maxProperties.d.mts +4 -4
  285. package/node_modules/typebox/build/schema/engine/maxProperties.mjs +2 -6
  286. package/node_modules/typebox/build/schema/engine/maximum.d.mts +4 -4
  287. package/node_modules/typebox/build/schema/engine/maximum.mjs +2 -6
  288. package/node_modules/typebox/build/schema/engine/minContains.d.mts +4 -4
  289. package/node_modules/typebox/build/schema/engine/minContains.mjs +2 -6
  290. package/node_modules/typebox/build/schema/engine/minItems.d.mts +5 -5
  291. package/node_modules/typebox/build/schema/engine/minItems.mjs +2 -6
  292. package/node_modules/typebox/build/schema/engine/minLength.d.mts +4 -4
  293. package/node_modules/typebox/build/schema/engine/minLength.mjs +2 -6
  294. package/node_modules/typebox/build/schema/engine/minProperties.d.mts +4 -4
  295. package/node_modules/typebox/build/schema/engine/minProperties.mjs +2 -6
  296. package/node_modules/typebox/build/schema/engine/minimum.d.mts +4 -4
  297. package/node_modules/typebox/build/schema/engine/minimum.mjs +2 -6
  298. package/node_modules/typebox/build/schema/engine/multipleOf.d.mts +4 -4
  299. package/node_modules/typebox/build/schema/engine/multipleOf.mjs +2 -6
  300. package/node_modules/typebox/build/schema/engine/not.d.mts +4 -4
  301. package/node_modules/typebox/build/schema/engine/not.mjs +4 -8
  302. package/node_modules/typebox/build/schema/engine/oneOf.d.mts +4 -4
  303. package/node_modules/typebox/build/schema/engine/oneOf.mjs +6 -11
  304. package/node_modules/typebox/build/schema/engine/pattern.d.mts +4 -4
  305. package/node_modules/typebox/build/schema/engine/pattern.mjs +3 -7
  306. package/node_modules/typebox/build/schema/engine/patternProperties.d.mts +4 -4
  307. package/node_modules/typebox/build/schema/engine/patternProperties.mjs +1 -1
  308. package/node_modules/typebox/build/schema/engine/prefixItems.d.mts +4 -4
  309. package/node_modules/typebox/build/schema/engine/prefixItems.mjs +1 -1
  310. package/node_modules/typebox/build/schema/engine/properties.d.mts +4 -4
  311. package/node_modules/typebox/build/schema/engine/properties.mjs +1 -1
  312. package/node_modules/typebox/build/schema/engine/propertyNames.d.mts +4 -4
  313. package/node_modules/typebox/build/schema/engine/propertyNames.mjs +4 -10
  314. package/node_modules/typebox/build/schema/engine/recursiveRef.d.mts +4 -4
  315. package/node_modules/typebox/build/schema/engine/recursiveRef.mjs +11 -7
  316. package/node_modules/typebox/build/schema/engine/ref.d.mts +4 -4
  317. package/node_modules/typebox/build/schema/engine/ref.mjs +19 -15
  318. package/node_modules/typebox/build/schema/engine/required.d.mts +4 -4
  319. package/node_modules/typebox/build/schema/engine/required.mjs +2 -6
  320. package/node_modules/typebox/build/schema/engine/schema.d.mts +7 -7
  321. package/node_modules/typebox/build/schema/engine/schema.mjs +137 -133
  322. package/node_modules/typebox/build/schema/engine/type.d.mts +4 -4
  323. package/node_modules/typebox/build/schema/engine/type.mjs +2 -6
  324. package/node_modules/typebox/build/schema/engine/unevaluatedItems.d.mts +4 -4
  325. package/node_modules/typebox/build/schema/engine/unevaluatedItems.mjs +5 -9
  326. package/node_modules/typebox/build/schema/engine/unevaluatedProperties.d.mts +4 -4
  327. package/node_modules/typebox/build/schema/engine/unevaluatedProperties.mjs +5 -9
  328. package/node_modules/typebox/build/schema/engine/uniqueItems.d.mts +4 -4
  329. package/node_modules/typebox/build/schema/engine/uniqueItems.mjs +2 -6
  330. package/node_modules/typebox/build/schema/errors.d.mts +2 -2
  331. package/node_modules/typebox/build/schema/errors.mjs +8 -14
  332. package/node_modules/typebox/build/schema/intern/index.d.mts +1 -0
  333. package/node_modules/typebox/build/schema/intern/index.mjs +1 -0
  334. package/node_modules/typebox/build/schema/intern/intern.d.mts +11 -0
  335. package/node_modules/typebox/build/schema/intern/intern.mjs +293 -0
  336. package/node_modules/typebox/build/schema/meta/draft_2019_09.d.mts +2 -0
  337. package/node_modules/typebox/build/schema/meta/draft_2019_09.mjs +288 -0
  338. package/node_modules/typebox/build/schema/meta/draft_2020_12.d.mts +2 -0
  339. package/node_modules/typebox/build/schema/meta/draft_2020_12.mjs +300 -0
  340. package/node_modules/typebox/build/schema/meta/draft_3.d.mts +2 -0
  341. package/node_modules/typebox/build/schema/meta/draft_3.mjs +143 -0
  342. package/node_modules/typebox/build/schema/meta/draft_4.d.mts +2 -0
  343. package/node_modules/typebox/build/schema/meta/draft_4.mjs +149 -0
  344. package/node_modules/typebox/build/schema/meta/draft_6.d.mts +2 -0
  345. package/node_modules/typebox/build/schema/meta/draft_6.mjs +155 -0
  346. package/node_modules/typebox/build/schema/meta/draft_7.d.mts +2 -0
  347. package/node_modules/typebox/build/schema/meta/draft_7.mjs +172 -0
  348. package/node_modules/typebox/build/schema/meta/index.d.mts +2 -0
  349. package/node_modules/typebox/build/schema/meta/index.mjs +2 -0
  350. package/node_modules/typebox/build/schema/meta/meta.d.mts +9 -0
  351. package/node_modules/typebox/build/schema/meta/meta.mjs +15 -0
  352. package/node_modules/typebox/build/schema/meta/schema.d.mts +6 -0
  353. package/node_modules/typebox/build/schema/meta/schema.mjs +1 -0
  354. package/node_modules/typebox/build/schema/parse.d.mts +1 -0
  355. package/node_modules/typebox/build/schema/parse.mjs +10 -5
  356. package/node_modules/typebox/build/schema/resolve/resolve.d.mts +8 -3
  357. package/node_modules/typebox/build/schema/resolve/resolve.mjs +268 -139
  358. package/node_modules/typebox/build/schema/schema.d.mts +2 -0
  359. package/node_modules/typebox/build/schema/schema.mjs +2 -0
  360. package/node_modules/typebox/build/value/errors/errors.d.mts +2 -12
  361. package/node_modules/typebox/build/value/errors/errors.mjs +2 -7
  362. package/node_modules/typebox/package.json +1 -1
  363. package/node_modules/typebox/readme.md +59 -89
  364. package/openclaw.plugin.json +1 -1
  365. package/package.json +10 -7
  366. package/node_modules/@clawdbot/lobster/dist/src/sdk/token.js +0 -9
  367. package/node_modules/@clawdbot/lobster/dist/src/sdk/token.js.map +0 -1
@@ -1,19 +1,50 @@
1
+ import { dryRunWorkflow } from "./dry_run.js";
2
+ import { createRecord, mergeEnv, resolveCwd, resolveInputValue, resolveShellStdin, resolveWorkflowStepArgs, } from "./values.js";
3
+ import { loadWorkflowFile, getStepExecution, isApprovalStep, isInputStep } from "./load.js";
4
+ import { evaluateCondition, resolveTemplate } from "./expressions.js";
1
5
  import { promises as fsp } from "node:fs";
2
6
  import path from "node:path";
3
- import { parse as parseYaml } from "yaml";
4
7
  import { randomUUID } from "node:crypto";
5
8
  import { isDeepStrictEqual } from "node:util";
6
9
  import { PassThrough } from "node:stream";
7
10
  import { parsePipeline } from "../parser.js";
8
11
  import { runPipeline } from "../runtime.js";
9
12
  import { encodeToken, decodeToken } from "../token.js";
10
- import { createApprovalIndex, deleteStateJson, readStateJson, writeStateJson, } from "../state/store.js";
13
+ import { cleanupApprovalIndexByStateKey, consumeResumeState, createApprovalIndex, deleteStateJsonWithBoundedResumeCleanup, deleteResumeStateWithRollback, deleteStateJson, deleteUnconsumedResumeState, isConsumedResumeState, readStateJsonWithLock, restoreConsumedResumeState, writeStateJson, stableStringify, } from "../state/store.js";
11
14
  import { readLineFromStream } from "../read_line.js";
12
- import { resolveInlineShellCommand } from "../shell.js";
13
15
  import { compileCached } from "../validation.js";
14
- import { CostTracker } from "../core/cost_tracker.js";
16
+ import { createLlmSpendLedger, carryLlmProvenance, markLlmProvenanceReplayed, llmProvenanceOf, restoreLlmProvenance, } from "../core/llm_accounting.js";
17
+ import { CostLimitExceededError, CostTracker } from "../core/cost_tracker.js";
15
18
  import { withRetry, resolveRetryConfig } from "../core/retry.js";
16
19
  import { RequestInputResumeError, validateCommandInputState, } from "../input_request.js";
20
+ import { runAbortableProcess } from "../abortable_process.js";
21
+ export { loadWorkflowFile } from "./load.js";
22
+ const NON_RETRYABLE_STEP_ERROR = Symbol("lobster.nonRetryableStepError");
23
+ function markStepErrorNonRetryable(error) {
24
+ const normalized = error instanceof Error ? error : new Error(String(error));
25
+ Object.defineProperty(normalized, NON_RETRYABLE_STEP_ERROR, { value: true });
26
+ return normalized;
27
+ }
28
+ function isStepErrorNonRetryable(error) {
29
+ return Boolean(error instanceof Error &&
30
+ error[NON_RETRYABLE_STEP_ERROR]);
31
+ }
32
+ const PARALLEL_BRANCH_ABORT_SETTLE_TIMEOUT_MS = 500;
33
+ async function waitForCancelledParallelBranches(branchesSettled) {
34
+ let timeoutId;
35
+ try {
36
+ await Promise.race([
37
+ branchesSettled,
38
+ new Promise((_resolve, reject) => {
39
+ timeoutId = setTimeout(() => reject(new Error("Parallel branches did not settle after cancellation")), PARALLEL_BRANCH_ABORT_SETTLE_TIMEOUT_MS);
40
+ }),
41
+ ]);
42
+ }
43
+ finally {
44
+ if (timeoutId !== undefined)
45
+ clearTimeout(timeoutId);
46
+ }
47
+ }
17
48
  export class WorkflowResumeArgumentError extends Error {
18
49
  constructor(message) {
19
50
  super(message);
@@ -32,322 +63,8 @@ class WorkflowPipelineInputSuspension extends Error {
32
63
  this.pipelineInput = pipelineInput;
33
64
  }
34
65
  }
35
- export async function loadWorkflowFile(filePath) {
36
- const text = await fsp.readFile(filePath, "utf8");
37
- const ext = path.extname(filePath).toLowerCase();
38
- const parsed = ext === ".json" ? JSON.parse(text) : parseYaml(text);
39
- if (!parsed || typeof parsed !== "object") {
40
- throw new Error("Workflow file must be a JSON/YAML object");
41
- }
42
- const steps = parsed.steps;
43
- if (!Array.isArray(steps) || steps.length === 0) {
44
- throw new Error("Workflow file requires a non-empty steps array");
45
- }
46
- const costLimit = parsed.cost_limit;
47
- if (costLimit !== undefined) {
48
- if (!costLimit || typeof costLimit !== "object" || Array.isArray(costLimit)) {
49
- throw new Error("Workflow cost_limit must be an object");
50
- }
51
- if (!Number.isFinite(Number(costLimit.max_usd)) || Number(costLimit.max_usd) < 0) {
52
- throw new Error("Workflow cost_limit.max_usd must be a non-negative number");
53
- }
54
- if (costLimit.action !== undefined &&
55
- costLimit.action !== "warn" &&
56
- costLimit.action !== "stop") {
57
- throw new Error('Workflow cost_limit.action must be "warn" or "stop"');
58
- }
59
- }
60
- const seen = new Set();
61
- for (const step of steps) {
62
- if (!step || typeof step !== "object") {
63
- throw new Error("Workflow step must be an object");
64
- }
65
- if (!step.id || typeof step.id !== "string") {
66
- throw new Error("Workflow step requires an id");
67
- }
68
- if (step.workflow !== undefined && typeof step.workflow !== "string") {
69
- throw new Error(`Workflow step ${step.id} workflow must be a string (file path)`);
70
- }
71
- if (typeof step.workflow === "string" && !step.workflow.trim()) {
72
- throw new Error(`Workflow step ${step.id} workflow path cannot be blank`);
73
- }
74
- if (step.workflow_args !== undefined) {
75
- if (!step.workflow_args ||
76
- typeof step.workflow_args !== "object" ||
77
- Array.isArray(step.workflow_args)) {
78
- throw new Error(`Workflow step ${step.id} workflow_args must be a plain object`);
79
- }
80
- }
81
- if (step.parallel !== undefined &&
82
- (!step.parallel || typeof step.parallel !== "object" || Array.isArray(step.parallel))) {
83
- throw new Error(`Workflow step ${step.id} parallel must be an object`);
84
- }
85
- const isParallel = Boolean(step.parallel && typeof step.parallel === "object" && !Array.isArray(step.parallel));
86
- if (isParallel) {
87
- const parallel = step.parallel;
88
- if (!Array.isArray(parallel.branches) || parallel.branches.length === 0) {
89
- throw new Error(`Workflow step ${step.id} parallel requires a non-empty branches array`);
90
- }
91
- if (parallel.wait !== undefined && parallel.wait !== "all" && parallel.wait !== "any") {
92
- throw new Error(`Workflow step ${step.id} parallel wait must be "all" or "any"`);
93
- }
94
- if (parallel.timeout_ms !== undefined &&
95
- (typeof parallel.timeout_ms !== "number" ||
96
- !Number.isFinite(parallel.timeout_ms) ||
97
- !Number.isInteger(parallel.timeout_ms) ||
98
- parallel.timeout_ms < 1 ||
99
- parallel.timeout_ms > 2_147_483_647)) {
100
- throw new Error(`Workflow step ${step.id} parallel timeout_ms must be a positive integer between 1 and 2147483647`);
101
- }
102
- const branchIds = new Set();
103
- for (const branch of parallel.branches) {
104
- if (!branch || typeof branch !== "object") {
105
- throw new Error(`Workflow step ${step.id} parallel branches must be objects`);
106
- }
107
- if (!branch.id || typeof branch.id !== "string") {
108
- throw new Error(`Workflow step ${step.id} parallel branch requires an id`);
109
- }
110
- if (branch.id === step.id) {
111
- throw new Error(`Workflow step ${step.id} parallel branch id cannot match the step id`);
112
- }
113
- if (branchIds.has(branch.id)) {
114
- throw new Error(`Workflow step ${step.id} duplicate parallel branch id: ${branch.id}`);
115
- }
116
- if (seen.has(branch.id)) {
117
- throw new Error(`Duplicate workflow id across steps/parallel branches: ${branch.id}`);
118
- }
119
- branchIds.add(branch.id);
120
- const branchShell = typeof branch.run === "string" ? branch.run : branch.command;
121
- const branchPipeline = typeof branch.pipeline === "string" ? branch.pipeline : undefined;
122
- const branchExecCount = Number(Boolean(branchShell)) + Number(Boolean(branchPipeline));
123
- if (branchExecCount === 0) {
124
- throw new Error(`Workflow step ${step.id} parallel branch ${branch.id} requires run, command, or pipeline`);
125
- }
126
- if (branchExecCount > 1) {
127
- throw new Error(`Workflow step ${step.id} parallel branch ${branch.id} can only define one of run, command, or pipeline`);
128
- }
129
- if (branch.run !== undefined && typeof branch.run !== "string") {
130
- throw new Error(`Workflow step ${step.id} parallel branch ${branch.id} run must be a string`);
131
- }
132
- if (branch.command !== undefined && typeof branch.command !== "string") {
133
- throw new Error(`Workflow step ${step.id} parallel branch ${branch.id} command must be a string`);
134
- }
135
- if (branch.pipeline !== undefined && typeof branch.pipeline !== "string") {
136
- throw new Error(`Workflow step ${step.id} parallel branch ${branch.id} pipeline must be a string`);
137
- }
138
- }
139
- }
140
- if (step.for_each !== undefined && typeof step.for_each !== "string") {
141
- throw new Error(`Workflow step ${step.id} for_each must be a string (step reference expression)`);
142
- }
143
- const isForEach = typeof step.for_each === "string";
144
- if (isForEach) {
145
- if (!Array.isArray(step.steps) || step.steps.length === 0) {
146
- throw new Error(`Workflow step ${step.id} for_each requires a non-empty steps array`);
147
- }
148
- if (step.batch_size !== undefined &&
149
- (typeof step.batch_size !== "number" ||
150
- !Number.isInteger(step.batch_size) ||
151
- step.batch_size < 1)) {
152
- throw new Error(`Workflow step ${step.id} batch_size must be a positive integer`);
153
- }
154
- if (step.pause_ms !== undefined &&
155
- (typeof step.pause_ms !== "number" || !Number.isFinite(step.pause_ms) || step.pause_ms < 0)) {
156
- throw new Error(`Workflow step ${step.id} pause_ms must be a finite non-negative number`);
157
- }
158
- if (isApprovalStep(step.approval)) {
159
- throw new Error(`Workflow step ${step.id} for_each steps cannot define approval (use a separate step after the loop)`);
160
- }
161
- if (isInputStep(step.input)) {
162
- throw new Error(`Workflow step ${step.id} for_each steps cannot define input (use a separate step after the loop)`);
163
- }
164
- if (step.stdin !== undefined && step.stdin !== null) {
165
- throw new Error(`Workflow step ${step.id} for_each steps cannot define stdin (loop input comes from the for_each expression)`);
166
- }
167
- const loopShell = typeof step.run === "string" ? step.run : step.command;
168
- const loopPipeline = typeof step.pipeline === "string" ? step.pipeline : undefined;
169
- if (loopShell || loopPipeline || step.workflow || step.parallel) {
170
- throw new Error(`Workflow step ${step.id} for_each cannot also define run, command, pipeline, workflow, or parallel`);
171
- }
172
- if (step.item_var !== undefined && typeof step.item_var !== "string") {
173
- throw new Error(`Workflow step ${step.id} item_var must be a string`);
174
- }
175
- if (step.index_var !== undefined && typeof step.index_var !== "string") {
176
- throw new Error(`Workflow step ${step.id} index_var must be a string`);
177
- }
178
- const loopItemVar = step.item_var ?? "item";
179
- const loopIndexVar = step.index_var ?? "index";
180
- if (loopItemVar === loopIndexVar) {
181
- throw new Error(`Workflow step ${step.id} item_var and index_var cannot be the same`);
182
- }
183
- const subStepIds = new Set();
184
- for (const sub of step.steps) {
185
- if (!sub || typeof sub !== "object" || !sub.id || typeof sub.id !== "string") {
186
- throw new Error(`Workflow step ${step.id} for_each sub-step requires an id`);
187
- }
188
- if (sub.id === loopItemVar || sub.id === loopIndexVar) {
189
- throw new Error(`Workflow step ${step.id} for_each sub-step id '${sub.id}' conflicts with loop variable`);
190
- }
191
- if (subStepIds.has(sub.id)) {
192
- throw new Error(`Workflow step ${step.id} duplicate for_each sub-step id: ${sub.id}`);
193
- }
194
- subStepIds.add(sub.id);
195
- if (isApprovalStep(sub.approval) || isInputStep(sub.input)) {
196
- throw new Error(`Workflow step ${step.id} for_each sub-steps cannot contain approval or input steps`);
197
- }
198
- if (sub.run !== undefined && typeof sub.run !== "string") {
199
- throw new Error(`Workflow step ${step.id} for_each sub-step ${sub.id} run must be a string`);
200
- }
201
- if (sub.command !== undefined && typeof sub.command !== "string") {
202
- throw new Error(`Workflow step ${step.id} for_each sub-step ${sub.id} command must be a string`);
203
- }
204
- if (sub.pipeline !== undefined && typeof sub.pipeline !== "string") {
205
- throw new Error(`Workflow step ${step.id} for_each sub-step ${sub.id} pipeline must be a string`);
206
- }
207
- if (sub.workflow || sub.parallel || sub.for_each) {
208
- throw new Error(`Workflow step ${step.id} for_each sub-step ${sub.id} cannot define workflow, parallel, or for_each`);
209
- }
210
- const subShell = typeof sub.run === "string" && sub.run.trim()
211
- ? sub.run
212
- : typeof sub.command === "string" && sub.command.trim()
213
- ? sub.command
214
- : undefined;
215
- const subPipeline = typeof sub.pipeline === "string" && sub.pipeline.trim() ? sub.pipeline : undefined;
216
- if (!subShell && !subPipeline) {
217
- throw new Error(`Workflow step ${step.id} for_each sub-step ${sub.id} requires run, command, or pipeline`);
218
- }
219
- if (Number(Boolean(subShell)) + Number(Boolean(subPipeline)) > 1) {
220
- throw new Error(`Workflow step ${step.id} for_each sub-step ${sub.id} can only define one of run, command, or pipeline`);
221
- }
222
- }
223
- }
224
- const shellCommand = typeof step.run === "string" ? step.run : step.command;
225
- const pipeline = typeof step.pipeline === "string" ? step.pipeline : undefined;
226
- const workflowRef = typeof step.workflow === "string" && step.workflow.trim() ? step.workflow : undefined;
227
- const executionCount = Number(Boolean(shellCommand)) +
228
- Number(Boolean(pipeline)) +
229
- Number(Boolean(workflowRef)) +
230
- Number(isParallel) +
231
- Number(isForEach);
232
- if (executionCount === 0 && !isApprovalStep(step.approval) && !isInputStep(step.input)) {
233
- throw new Error(`Workflow step ${step.id} requires run, command, pipeline, workflow, parallel, for_each, approval, or input`);
234
- }
235
- if (executionCount > 1) {
236
- throw new Error(`Workflow step ${step.id} can only define one of run, command, pipeline, workflow, parallel, or for_each`);
237
- }
238
- if (executionCount > 0 && isInputStep(step.input)) {
239
- throw new Error(`Workflow step ${step.id} input steps cannot define run, command, pipeline, workflow, parallel, or for_each`);
240
- }
241
- if (isApprovalStep(step.approval) && isInputStep(step.input)) {
242
- throw new Error(`Workflow step ${step.id} cannot define both approval and input`);
243
- }
244
- if (step.run !== undefined && typeof step.run !== "string") {
245
- throw new Error(`Workflow step ${step.id} run must be a string`);
246
- }
247
- if (step.command !== undefined && typeof step.command !== "string") {
248
- throw new Error(`Workflow step ${step.id} command must be a string`);
249
- }
250
- if (step.pipeline !== undefined && typeof step.pipeline !== "string") {
251
- throw new Error(`Workflow step ${step.id} pipeline must be a string`);
252
- }
253
- if (step.input !== undefined && !isInputStep(step.input)) {
254
- throw new Error(`Workflow step ${step.id} input must be an object`);
255
- }
256
- if (step.input && typeof step.input.prompt !== "string") {
257
- throw new Error(`Workflow step ${step.id} input.prompt must be a string`);
258
- }
259
- if (step.input &&
260
- (step.input.responseSchema === undefined || typeof step.input.responseSchema !== "object")) {
261
- throw new Error(`Workflow step ${step.id} input.responseSchema must be an object`);
262
- }
263
- if (step.input) {
264
- try {
265
- compileCached(step.input.responseSchema);
266
- }
267
- catch (err) {
268
- throw new Error(`Workflow step ${step.id} input.responseSchema is invalid: ${err?.message ?? String(err)}`);
269
- }
270
- }
271
- if (step.approval && typeof step.approval === "object" && !Array.isArray(step.approval)) {
272
- const approval = step.approval;
273
- if (approval.initiated_by !== undefined && typeof approval.initiated_by !== "string") {
274
- throw new Error(`Workflow step ${step.id} approval.initiated_by must be a string`);
275
- }
276
- if (approval.initiatedBy !== undefined && typeof approval.initiatedBy !== "string") {
277
- throw new Error(`Workflow step ${step.id} approval.initiatedBy must be a string`);
278
- }
279
- if (approval.required_approver !== undefined &&
280
- typeof approval.required_approver !== "string") {
281
- throw new Error(`Workflow step ${step.id} approval.required_approver must be a string`);
282
- }
283
- if (approval.requiredApprover !== undefined &&
284
- typeof approval.requiredApprover !== "string") {
285
- throw new Error(`Workflow step ${step.id} approval.requiredApprover must be a string`);
286
- }
287
- if (approval.require_different_approver !== undefined &&
288
- typeof approval.require_different_approver !== "boolean") {
289
- throw new Error(`Workflow step ${step.id} approval.require_different_approver must be a boolean`);
290
- }
291
- if (approval.requireDifferentApprover !== undefined &&
292
- typeof approval.requireDifferentApprover !== "boolean") {
293
- throw new Error(`Workflow step ${step.id} approval.requireDifferentApprover must be a boolean`);
294
- }
295
- }
296
- if (step.timeout_ms !== undefined &&
297
- (typeof step.timeout_ms !== "number" ||
298
- !Number.isFinite(step.timeout_ms) ||
299
- !Number.isInteger(step.timeout_ms) ||
300
- step.timeout_ms < 1 ||
301
- step.timeout_ms > 2_147_483_647)) {
302
- throw new Error(`Workflow step ${step.id} timeout_ms must be a positive integer between 1 and 2147483647`);
303
- }
304
- if (step.on_error !== undefined &&
305
- step.on_error !== "stop" &&
306
- step.on_error !== "continue" &&
307
- step.on_error !== "skip_rest") {
308
- throw new Error(`Workflow step ${step.id} on_error must be "stop", "continue", or "skip_rest"`);
309
- }
310
- if (step.retry !== undefined) {
311
- if (!step.retry || typeof step.retry !== "object" || Array.isArray(step.retry)) {
312
- throw new Error(`Workflow step ${step.id} retry must be an object`);
313
- }
314
- const r = step.retry;
315
- if (r.max !== undefined &&
316
- (typeof r.max !== "number" || !Number.isInteger(r.max) || r.max < 1)) {
317
- throw new Error(`Workflow step ${step.id} retry.max must be a positive integer`);
318
- }
319
- if (r.backoff !== undefined && r.backoff !== "fixed" && r.backoff !== "exponential") {
320
- throw new Error(`Workflow step ${step.id} retry.backoff must be "fixed" or "exponential"`);
321
- }
322
- if (r.delay_ms !== undefined &&
323
- (typeof r.delay_ms !== "number" || !Number.isFinite(r.delay_ms) || r.delay_ms < 0)) {
324
- throw new Error(`Workflow step ${step.id} retry.delay_ms must be a finite non-negative number`);
325
- }
326
- if (r.max_delay_ms !== undefined &&
327
- (typeof r.max_delay_ms !== "number" ||
328
- !Number.isFinite(r.max_delay_ms) ||
329
- r.max_delay_ms < 0)) {
330
- throw new Error(`Workflow step ${step.id} retry.max_delay_ms must be a finite non-negative number`);
331
- }
332
- if (r.jitter !== undefined && typeof r.jitter !== "boolean") {
333
- throw new Error(`Workflow step ${step.id} retry.jitter must be a boolean`);
334
- }
335
- }
336
- if (seen.has(step.id)) {
337
- throw new Error(`Duplicate workflow step id: ${step.id}`);
338
- }
339
- if (isParallel) {
340
- const parallel = step.parallel;
341
- for (const branch of parallel.branches) {
342
- seen.add(branch.id);
343
- }
344
- }
345
- seen.add(step.id);
346
- }
347
- return parsed;
348
- }
349
66
  export function resolveWorkflowArgs(argDefs, provided) {
350
- const resolved = {};
67
+ const resolved = createRecord();
351
68
  if (argDefs) {
352
69
  for (const [key, def] of Object.entries(argDefs)) {
353
70
  if (def && typeof def === "object" && "default" in def) {
@@ -364,11 +81,74 @@ export function resolveWorkflowArgs(argDefs, provided) {
364
81
  }
365
82
  export async function runWorkflowFile({ filePath, args, ctx, resume, approved, response, cancel, }) {
366
83
  const consumedResumeStateKey = resume?.stateKey && typeof resume.stateKey === "string"
367
- ? await resolveWorkflowResumeStateKey(ctx.env, resume.stateKey)
84
+ ? await resolveWorkflowResumeStateKey(ctx.env, resume.stateKey, ctx.signal)
368
85
  : null;
86
+ if (consumedResumeStateKey)
87
+ ctx._onResumeStateResolved?.(consumedResumeStateKey);
369
88
  const resumeState = resume?.stateKey
370
- ? await loadWorkflowResumeState(ctx.env, consumedResumeStateKey ?? resume.stateKey)
89
+ ? await loadWorkflowResumeState(ctx.env, consumedResumeStateKey ?? resume.stateKey, ctx.signal)
371
90
  : (resume ?? null);
91
+ let resumedExecutionStarted = false;
92
+ let resumeStateConsumed = false;
93
+ let executionBoundaryStarted = false;
94
+ const executionSignalListeners = [];
95
+ let executionStart;
96
+ const markExecutionStarted = async (signal) => {
97
+ if (!consumedResumeStateKey && !ctx._onExecutionStarted && !ctx._onExecutionInterrupted)
98
+ return;
99
+ if (executionStart)
100
+ return executionStart;
101
+ executionStart = (async () => {
102
+ signal?.throwIfAborted();
103
+ if (consumedResumeStateKey) {
104
+ const consumption = await consumeResumeState({
105
+ env: ctx.env,
106
+ key: consumedResumeStateKey,
107
+ expectedState: resumeState,
108
+ signal,
109
+ });
110
+ if (!consumption.consumed) {
111
+ throw new Error("Workflow resume state not found");
112
+ }
113
+ if (consumption.signalAbortedAfterCommit) {
114
+ await restoreConsumedResumeState({
115
+ env: ctx.env,
116
+ key: consumedResumeStateKey,
117
+ expectedState: resumeState,
118
+ claimId: consumption.claimId,
119
+ });
120
+ signal?.throwIfAborted();
121
+ }
122
+ signal?.throwIfAborted();
123
+ resumeStateConsumed = true;
124
+ resumedExecutionStarted = true;
125
+ }
126
+ await ctx._onExecutionStarted?.();
127
+ executionBoundaryStarted = true;
128
+ if (!signal)
129
+ return;
130
+ const onAbort = () => {
131
+ ctx._onExecutionInterrupted?.();
132
+ };
133
+ if (signal.aborted) {
134
+ onAbort();
135
+ return;
136
+ }
137
+ signal.addEventListener("abort", onAbort, { once: true });
138
+ executionSignalListeners.push({ signal, onAbort });
139
+ })();
140
+ try {
141
+ await executionStart;
142
+ }
143
+ catch (err) {
144
+ // A per-attempt timeout can abort while this invocation is only waiting
145
+ // to claim its resume state. Keep the successful boundary memoized, but
146
+ // let the workflow retry make a fresh claim after that pre-dispatch wait.
147
+ if (!resumeStateConsumed && !executionBoundaryStarted)
148
+ executionStart = undefined;
149
+ throw err;
150
+ }
151
+ };
372
152
  if (resumeState?.approvalStepId && resumeState?.inputStepId) {
373
153
  throw new Error("Invalid workflow resume state");
374
154
  }
@@ -381,14 +161,26 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
381
161
  }
382
162
  if (cancel === true || approved === false) {
383
163
  if (consumedResumeStateKey) {
384
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
164
+ const deletion = await deleteUnconsumedResumeState({
165
+ env: ctx.env,
166
+ key: consumedResumeStateKey,
167
+ signal: ctx.signal,
168
+ });
169
+ if (deletion !== "deleted")
170
+ throw new Error("Workflow resume state not found");
385
171
  }
386
172
  return { status: "cancelled", output: [] };
387
173
  }
388
174
  }
389
175
  if (resumeState?.inputStepId && cancel === true) {
390
176
  if (consumedResumeStateKey) {
391
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
177
+ const deletion = await deleteUnconsumedResumeState({
178
+ env: ctx.env,
179
+ key: consumedResumeStateKey,
180
+ signal: ctx.signal,
181
+ });
182
+ if (deletion !== "deleted")
183
+ throw new Error("Workflow resume state not found");
392
184
  }
393
185
  return { status: "cancelled", output: [] };
394
186
  }
@@ -408,7 +200,7 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
408
200
  const stepIndexById = new Map(steps.map((step, idx) => [step.id, idx]));
409
201
  const results = resumeState?.steps
410
202
  ? cloneResults(resumeState.steps)
411
- : {};
203
+ : createRecord();
412
204
  const startIndex = resumeState?.resumeAtIndex ?? 0;
413
205
  if (resumeState?.approvalStepId && typeof approved === "boolean") {
414
206
  const previous = results[resumeState.approvalStepId] ?? { id: resumeState.approvalStepId };
@@ -459,11 +251,6 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
459
251
  stepId: resumeState.inputStepId,
460
252
  response,
461
253
  pipelineInput: resumeState.pipelineInput,
462
- onConsumed: consumedResumeStateKey
463
- ? async () => {
464
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
465
- }
466
- : undefined,
467
254
  };
468
255
  }
469
256
  else {
@@ -492,8 +279,30 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
492
279
  return dryRunWorkflow({ steps, resolvedArgs, results, startIndex, ctx });
493
280
  }
494
281
  const costTracker = new CostTracker(CostTracker.parsePricingFromEnv(ctx.env, ctx.stderr), ctx.stderr);
282
+ // A gate pauses a run, it does not reset its spend: seed this run with what the paused
283
+ // run recorded, so `_meta.cost` covers the whole workflow and `cost_limit` cannot be
284
+ // walked past one gate at a time. A replay of a call billed before the pause then stays
285
+ // exempt, which is what an empty ledger already means for a charge someone else settled.
286
+ const pausedCost = resumeState && "cost" in resumeState ? resumeState.cost : undefined;
287
+ costTracker.restore(pausedCost?.byStep);
288
+ // One ledger per run: a live call this run paid for can be recovered from the replay a
289
+ // retry produced, and a cache entry written by any other run has nothing to claim here.
290
+ const llmSpendLedger = createLlmSpendLedger(composingSpendLedger(ctx));
291
+ // A pipeline that paused at an `ask` gate had already paid for its LLM call without
292
+ // billing it, so the charge travels in the same resume state and reopens here. It is the
293
+ // same run continuing, which is why this cannot hand a charge to an unrelated one.
294
+ llmSpendLedger.restore(resumeState && "llmSpend" in resumeState ? resumeState.llmSpend : undefined);
295
+ // The completed steps come back as JSON, so an LLM output among them has lost everything
296
+ // this process attached to it. A later step that re-emits one hands on a copy that still
297
+ // names its call, and the restored record is what lets that copy be recognized as one the
298
+ // paused run already paid for rather than billed a second time on top of `cost`.
299
+ llmSpendLedger.restoreSettled(resumeState && "llmBilled" in resumeState ? resumeState.llmBilled : undefined);
300
+ // Completed results lost their symbol keys when the pause was persisted. Restore those
301
+ // private marks only at this trusted resume boundary; ordinary command JSON stays billable.
302
+ restoreLlmProvenance(results, resumeState && "llmBilled" in resumeState ? resumeState.llmBilled : undefined);
495
303
  let lastStepId = resumeState?.inputStepId ?? findLastCompletedStepId(steps, results);
496
304
  for (let idx = startIndex; idx < steps.length; idx++) {
305
+ ctx.signal?.throwIfAborted();
497
306
  const step = steps[idx];
498
307
  if (!evaluateCondition(step.when ?? step.condition, results)) {
499
308
  results[step.id] = { id: step.id, skipped: true };
@@ -515,40 +324,65 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
515
324
  subject,
516
325
  maxEnvelopeBytes: resolveToolEnvelopeMaxBytes(ctx.env),
517
326
  });
518
- const stateKey = await saveWorkflowResumeState(ctx.env, {
519
- filePath: resolvedFilePath,
520
- resumeAtIndex: idx + 1,
521
- steps: results,
522
- args: resolvedArgs,
523
- inputStepId: step.id,
524
- inputSchema: step.input.responseSchema,
525
- // Preserve the full resolved subject for resume semantics; the tool
526
- // envelope may contain a truncated preview to stay within size limits.
527
- inputSubject: subject,
528
- createdAt: new Date().toISOString(),
529
- });
530
- if (consumedResumeStateKey && consumedResumeStateKey !== stateKey) {
531
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
327
+ let stateKey;
328
+ try {
329
+ stateKey = await saveWorkflowResumeState(ctx.env, {
330
+ filePath: resolvedFilePath,
331
+ resumeAtIndex: idx + 1,
332
+ steps: results,
333
+ args: resolvedArgs,
334
+ inputStepId: step.id,
335
+ inputSchema: step.input.responseSchema,
336
+ // Preserve the full resolved subject for resume semantics; the tool
337
+ // envelope may contain a truncated preview to stay within size limits.
338
+ inputSubject: subject,
339
+ ...(costTracker.hasUsage() ? { cost: costTracker.getSummary() } : null),
340
+ ...(llmSpendLedger.outstanding().length
341
+ ? { llmSpend: llmSpendLedger.outstanding() }
342
+ : null),
343
+ ...(llmSpendLedger.settled().length
344
+ ? { llmBilled: llmSpendLedger.settled() }
345
+ : null),
346
+ supersededResumeStateKeys: collectSupersededWorkflowResumeStateKeys(consumedResumeStateKey, resumeState),
347
+ createdAt: new Date().toISOString(),
348
+ }, ctx.signal);
349
+ const replaced = await replaceWorkflowResumeState({
350
+ env: ctx.env,
351
+ previousStateKey: consumedResumeStateKey,
352
+ expectedPreviousState: resumeState,
353
+ previousStateConsumed: resumeStateConsumed,
354
+ replacementStateKey: stateKey,
355
+ signal: ctx.signal,
356
+ });
357
+ if (!replaced) {
358
+ throw new Error("Workflow resume state not found");
359
+ }
360
+ const resumeToken = encodeToken({
361
+ protocolVersion: 1,
362
+ v: 1,
363
+ kind: "workflow-file",
364
+ stateKey,
365
+ });
366
+ return {
367
+ status: "needs_input",
368
+ output: [],
369
+ requiresInput: {
370
+ ...inputRequest,
371
+ resumeToken,
372
+ },
373
+ };
374
+ }
375
+ catch (err) {
376
+ if (stateKey)
377
+ await discardWorkflowResumeState(ctx.env, stateKey);
378
+ throw err;
532
379
  }
533
- const resumeToken = encodeToken({
534
- protocolVersion: 1,
535
- v: 1,
536
- kind: "workflow-file",
537
- stateKey,
538
- });
539
- return {
540
- status: "needs_input",
541
- output: [],
542
- requiresInput: {
543
- ...inputRequest,
544
- resumeToken,
545
- },
546
- };
547
380
  }
548
381
  ctx.stdout.write(`${step.input.prompt}\n`);
549
382
  ctx.stdout.write("Enter JSON response: ");
550
383
  const raw = await readLineFromStream(ctx.stdin, {
551
384
  timeoutMs: parseApprovalTimeoutMs(ctx.env),
385
+ signal: ctx.signal,
552
386
  });
553
387
  const parsed = parseResponseJson(String(raw ?? "").trim());
554
388
  validateInputResponse({
@@ -564,132 +398,170 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
564
398
  lastStepId = step.id;
565
399
  continue;
566
400
  }
567
- if (typeof step.for_each === "string" && Array.isArray(step.steps)) {
568
- const itemsRef = resolveInputValue(step.for_each, resolvedArgs, results);
569
- if (!Array.isArray(itemsRef)) {
570
- throw new Error(`Workflow step ${step.id} for_each: expected array, got ${typeof itemsRef}`);
571
- }
572
- const itemVar = step.item_var ?? "item";
573
- const indexVar = step.index_var ?? "index";
574
- const batchSize = step.batch_size ?? 1;
575
- const iterationResults = [];
576
- for (let itemIdx = 0; itemIdx < itemsRef.length; itemIdx++) {
577
- if (step.pause_ms && itemIdx > 0 && itemIdx % batchSize === 0) {
578
- await abortableSleep(step.pause_ms, ctx.signal);
579
- }
580
- const item = itemsRef[itemIdx];
581
- const scopedResults = { ...results };
582
- scopedResults[itemVar] = {
583
- id: itemVar,
584
- json: item,
585
- stdout: typeof item === "string" ? item : JSON.stringify(item),
586
- };
587
- scopedResults[indexVar] = {
588
- id: indexVar,
589
- json: itemIdx,
590
- stdout: String(itemIdx),
591
- };
592
- for (const subStep of step.steps) {
593
- if (!evaluateCondition(subStep.when ?? subStep.condition, scopedResults)) {
594
- scopedResults[subStep.id] = { id: subStep.id, skipped: true };
595
- continue;
596
- }
597
- const loopEnvBase = mergeEnv(ctx.env, workflow.env, step.env, resolvedArgs, scopedResults);
598
- const subEnv = subStep.env
599
- ? mergeEnv(loopEnvBase, undefined, subStep.env, resolvedArgs, scopedResults)
600
- : loopEnvBase;
601
- const subCwd = resolveCwd(subStep.cwd ?? step.cwd ?? workflow.cwd, resolvedArgs) ?? ctx.cwd;
602
- const subExecution = getStepExecution(subStep);
603
- let subResult;
604
- if (subExecution.kind === "shell") {
605
- const command = resolveTemplate(subExecution.value, resolvedArgs, scopedResults);
606
- const stdinValue = resolveShellStdin(subStep.stdin, resolvedArgs, scopedResults);
607
- const { stdout } = await runShellCommand({
608
- command,
609
- stdin: stdinValue,
610
- env: subEnv,
611
- cwd: subCwd,
612
- signal: ctx.signal,
613
- });
614
- subResult = { id: subStep.id, stdout, json: parseJson(stdout) };
615
- }
616
- else if (subExecution.kind === "pipeline") {
617
- if (!ctx.registry) {
618
- throw new Error(`Workflow step ${step.id} for_each sub-step ${subStep.id} requires a command registry for pipeline execution`);
619
- }
620
- const pipelineText = resolveTemplate(subExecution.value, resolvedArgs, scopedResults);
621
- const inputValue = resolveInputValue(subStep.stdin, resolvedArgs, scopedResults);
622
- subResult = await runPipelineStep({
623
- stepId: subStep.id,
624
- pipelineText,
625
- inputValue,
626
- ctx,
627
- env: subEnv,
628
- cwd: subCwd,
629
- requestInputEnabled: false,
630
- });
631
- }
632
- else {
633
- const inputValue = resolveInputValue(subStep.stdin, resolvedArgs, scopedResults);
634
- subResult = createSyntheticStepResult(subStep.id, inputValue);
635
- }
636
- scopedResults[subStep.id] = subResult;
637
- trackStepCost(costTracker, `${step.id}.${subStep.id}`, subResult);
638
- if (workflow.cost_limit) {
639
- costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
640
- }
641
- }
642
- const iterResult = { [itemVar]: item, [indexVar]: itemIdx };
643
- for (const subStep of step.steps) {
644
- const subResult = scopedResults[subStep.id];
645
- if (subResult && !subResult.skipped) {
646
- iterResult[subStep.id] =
647
- subResult.json !== undefined ? subResult.json : subResult.stdout;
648
- }
649
- }
650
- iterationResults.push(iterResult);
651
- }
652
- const loopResult = {
653
- id: step.id,
654
- json: iterationResults,
655
- stdout: JSON.stringify(iterationResults),
656
- };
657
- results[step.id] = loopResult;
658
- lastStepId = step.id;
659
- trackStepCost(costTracker, step.id, loopResult);
660
- if (workflow.cost_limit) {
661
- costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
662
- }
663
- continue;
664
- }
665
401
  const env = mergeEnv(ctx.env, workflow.env, step.env, resolvedArgs, results);
666
402
  const cwd = resolveCwd(step.cwd ?? workflow.cwd, resolvedArgs) ?? ctx.cwd;
667
403
  const execution = getStepExecution(step);
668
404
  const retryConfig = resolveRetryConfig(step.retry);
669
405
  const executeStepAttempt = async () => {
406
+ let attemptHadNonRetryableSideEffect = false;
407
+ const markNonRetryableSideEffect = () => {
408
+ attemptHadNonRetryableSideEffect = true;
409
+ ctx._onNonRetryableSideEffect?.();
410
+ };
411
+ ctx.signal?.throwIfAborted();
670
412
  // Combine external cancellation and optional per-step timeout into one signal.
671
413
  let stepSignal = ctx.signal;
672
414
  let timeoutId;
415
+ let stepTimeoutController;
673
416
  if (step.timeout_ms) {
674
- const timeoutController = new AbortController();
675
- timeoutId = setTimeout(() => timeoutController.abort(new Error(`Step '${step.id}' timed out after ${step.timeout_ms}ms`)), step.timeout_ms);
417
+ stepTimeoutController = new AbortController();
418
+ timeoutId = setTimeout(() => stepTimeoutController.abort(new Error(`Step '${step.id}' timed out after ${step.timeout_ms}ms`)), step.timeout_ms);
676
419
  stepSignal = ctx.signal
677
- ? AbortSignal.any([ctx.signal, timeoutController.signal])
678
- : timeoutController.signal;
420
+ ? AbortSignal.any([ctx.signal, stepTimeoutController.signal])
421
+ : stepTimeoutController.signal;
679
422
  }
680
423
  let result;
681
424
  let parallelBranchResults = null;
682
425
  try {
683
- if (execution.kind === "parallel") {
426
+ if (typeof step.for_each === "string" && Array.isArray(step.steps)) {
427
+ const itemsRef = resolveInputValue(step.for_each, resolvedArgs, results);
428
+ if (!Array.isArray(itemsRef)) {
429
+ throw new Error(`Workflow step ${step.id} for_each: expected array, got ${typeof itemsRef}`);
430
+ }
431
+ const itemVar = step.item_var ?? "item";
432
+ const indexVar = step.index_var ?? "index";
433
+ const batchSize = step.batch_size ?? 1;
434
+ const iterationResults = [];
435
+ for (let itemIdx = 0; itemIdx < itemsRef.length; itemIdx++) {
436
+ stepSignal?.throwIfAborted();
437
+ if (step.pause_ms && itemIdx > 0 && itemIdx % batchSize === 0) {
438
+ await abortableSleep(step.pause_ms, stepSignal);
439
+ }
440
+ const item = itemsRef[itemIdx];
441
+ const scopedResults = createRecord(results);
442
+ scopedResults[itemVar] = {
443
+ id: itemVar,
444
+ json: item,
445
+ stdout: typeof item === "string" ? item : JSON.stringify(item),
446
+ };
447
+ scopedResults[indexVar] = {
448
+ id: indexVar,
449
+ json: itemIdx,
450
+ stdout: String(itemIdx),
451
+ };
452
+ for (const subStep of step.steps) {
453
+ stepSignal?.throwIfAborted();
454
+ if (!evaluateCondition(subStep.when ?? subStep.condition, scopedResults)) {
455
+ scopedResults[subStep.id] = { id: subStep.id, skipped: true };
456
+ continue;
457
+ }
458
+ const loopEnvBase = mergeEnv(ctx.env, workflow.env, step.env, resolvedArgs, scopedResults);
459
+ const subEnv = subStep.env
460
+ ? mergeEnv(loopEnvBase, undefined, subStep.env, resolvedArgs, scopedResults)
461
+ : loopEnvBase;
462
+ const subCwd = resolveCwd(subStep.cwd ?? step.cwd ?? workflow.cwd, resolvedArgs) ?? ctx.cwd;
463
+ const subExecution = getStepExecution(subStep);
464
+ let subResult;
465
+ if (subExecution.kind === "shell") {
466
+ const command = resolveTemplate(subExecution.value, resolvedArgs, scopedResults);
467
+ const stdinValue = resolveShellStdin(subStep.stdin, resolvedArgs, scopedResults);
468
+ await markExecutionStarted(stepSignal);
469
+ const { stdout } = await runShellCommand({
470
+ command,
471
+ stdin: stdinValue,
472
+ env: subEnv,
473
+ cwd: subCwd,
474
+ signal: stepSignal,
475
+ forceTerminationSignal: ctx.forceTerminationSignal,
476
+ killSignal: () => stepTimeoutController?.signal.aborted
477
+ ? "SIGKILL"
478
+ : undefined,
479
+ });
480
+ subResult = { id: subStep.id, stdout, json: parseJson(stdout) };
481
+ }
482
+ else if (subExecution.kind === "pipeline") {
483
+ if (!ctx.registry) {
484
+ throw new Error(`Workflow step ${step.id} for_each sub-step ${subStep.id} requires a command registry for pipeline execution`);
485
+ }
486
+ const pipelineText = resolveTemplate(subExecution.value, resolvedArgs, scopedResults);
487
+ const inputValue = resolveInputValue(subStep.stdin, resolvedArgs, scopedResults);
488
+ subResult = await runPipelineStep({
489
+ stepId: subStep.id,
490
+ pipelineText,
491
+ inputValue,
492
+ ctx: {
493
+ ...ctx,
494
+ signal: stepSignal,
495
+ _onNonRetryableSideEffect: markNonRetryableSideEffect,
496
+ },
497
+ llmSpendLedger,
498
+ env: subEnv,
499
+ cwd: subCwd,
500
+ requestInputEnabled: false,
501
+ onExecutionStart: () => markExecutionStarted(stepSignal),
502
+ });
503
+ }
504
+ else {
505
+ const inputValue = resolveInputValue(subStep.stdin, resolvedArgs, scopedResults);
506
+ subResult = createSyntheticStepResult(subStep.id, inputValue);
507
+ }
508
+ scopedResults[subStep.id] = subResult;
509
+ trackStepCost(costTracker, `${step.id}.${subStep.id}`, subResult, llmSpendLedger);
510
+ // Before the next step runs: a charge no item carried is spend the step really made,
511
+ // and leaving it for the end would let `cost_limit` wave through every step after the
512
+ // one that blew the budget.
513
+ settleUnbilledCharges(costTracker, llmSpendLedger);
514
+ if (workflow.cost_limit) {
515
+ costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
516
+ }
517
+ }
518
+ const iterResult = createRecord();
519
+ iterResult[itemVar] = item;
520
+ iterResult[indexVar] = itemIdx;
521
+ for (const subStep of step.steps) {
522
+ const subResult = scopedResults[subStep.id];
523
+ if (subResult && !subResult.skipped) {
524
+ iterResult[subStep.id] =
525
+ subResult.json !== undefined ? subResult.json : subResult.stdout;
526
+ }
527
+ }
528
+ iterationResults.push(Object.fromEntries(Object.entries(iterResult)));
529
+ }
530
+ result = {
531
+ id: step.id,
532
+ json: iterationResults,
533
+ stdout: JSON.stringify(iterationResults),
534
+ };
535
+ }
536
+ else if (execution.kind === "parallel") {
684
537
  const parallel = execution.value;
685
538
  const wait = parallel.wait ?? "all";
686
539
  const branchAbortController = new AbortController();
687
- const branchSignal = stepSignal
688
- ? AbortSignal.any([stepSignal, branchAbortController.signal])
689
- : branchAbortController.signal;
690
- const shouldForceKill = Boolean(step.timeout_ms || parallel.timeout_ms);
540
+ const parallelTimeoutController = new AbortController();
541
+ const parallelSignal = stepSignal
542
+ ? AbortSignal.any([stepSignal, parallelTimeoutController.signal])
543
+ : parallelTimeoutController.signal;
544
+ const branchSignal = AbortSignal.any([parallelSignal, branchAbortController.signal]);
545
+ const timeoutKillSignal = () => stepTimeoutController?.signal.aborted || parallelTimeoutController.signal.aborted
546
+ ? "SIGKILL"
547
+ : undefined;
548
+ // `wait: "any"` keeps the first branch to answer and abandons the rest, so a
549
+ // losing branch spends against a buffer of its own. Only the winner's charges
550
+ // reach the run. `wait: "all"` keeps every branch, so those bill straight
551
+ // through as before.
552
+ const branchLedgers = new Map();
553
+ const ledgerFor = (branchId) => {
554
+ if (wait !== "any")
555
+ return llmSpendLedger;
556
+ let buffered = branchLedgers.get(branchId);
557
+ if (!buffered) {
558
+ buffered = branchSpendBuffer(llmSpendLedger);
559
+ branchLedgers.set(branchId, buffered);
560
+ }
561
+ return buffered;
562
+ };
691
563
  const runBranch = async (branch) => {
692
- const mergedBranchEnv = { ...(step.env ?? {}), ...(branch.env ?? {}) };
564
+ const mergedBranchEnv = { ...step.env, ...branch.env };
693
565
  const branchEnv = mergeEnv(ctx.env, workflow.env, mergedBranchEnv, resolvedArgs, results);
694
566
  const branchCwd = resolveCwd(branch.cwd ?? step.cwd ?? workflow.cwd, resolvedArgs) ?? ctx.cwd;
695
567
  const branchShell = typeof branch.run === "string" ? branch.run : branch.command;
@@ -701,13 +573,15 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
701
573
  if (branchExec.kind === "shell") {
702
574
  const command = resolveTemplate(branchExec.value, resolvedArgs, results);
703
575
  const stdinValue = resolveShellStdin(branch.stdin, resolvedArgs, results);
576
+ await markExecutionStarted(branchSignal);
704
577
  const { stdout } = await runShellCommand({
705
578
  command,
706
579
  stdin: stdinValue,
707
580
  env: branchEnv,
708
581
  cwd: branchCwd,
709
582
  signal: branchSignal,
710
- ...(shouldForceKill ? { killSignal: "SIGKILL" } : {}),
583
+ forceTerminationSignal: ctx.forceTerminationSignal,
584
+ killSignal: timeoutKillSignal,
711
585
  });
712
586
  return {
713
587
  branchId: branch.id,
@@ -724,10 +598,16 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
724
598
  stepId: branch.id,
725
599
  pipelineText,
726
600
  inputValue,
727
- ctx: { ...ctx, signal: branchSignal },
601
+ ctx: {
602
+ ...ctx,
603
+ signal: branchSignal,
604
+ _onNonRetryableSideEffect: markNonRetryableSideEffect,
605
+ },
606
+ llmSpendLedger: ledgerFor(branch.id),
728
607
  env: branchEnv,
729
608
  cwd: branchCwd,
730
609
  requestInputEnabled: false,
610
+ onExecutionStart: () => markExecutionStarted(branchSignal),
731
611
  });
732
612
  return { branchId: branch.id, result: branchResult };
733
613
  }
@@ -737,26 +617,27 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
737
617
  const timeoutPromise = parallel.timeout_ms
738
618
  ? new Promise((_resolve, reject) => {
739
619
  parallelTimeoutId = setTimeout(() => {
740
- branchAbortController.abort();
620
+ parallelTimeoutController.abort(new Error(`Parallel step ${step.id} timed out after ${parallel.timeout_ms}ms`));
741
621
  reject(new Error(`Parallel step ${step.id} timed out after ${parallel.timeout_ms}ms`));
742
622
  }, parallel.timeout_ms);
743
623
  })
744
624
  : null;
625
+ const branchPromises = parallel.branches.map((branch) => runBranch(branch));
626
+ const branchesSettled = Promise.allSettled(branchPromises);
745
627
  try {
746
628
  if (wait === "any") {
747
- const branchPromises = parallel.branches.map((branch) => runBranch(branch));
748
629
  const winner = (await (timeoutPromise
749
630
  ? Promise.race([...branchPromises, timeoutPromise])
750
631
  : Promise.race(branchPromises)));
751
- parallelBranchResults = { [winner.branchId]: winner.result };
752
- branchAbortController.abort();
632
+ parallelBranchResults = createRecord();
633
+ parallelBranchResults[winner.branchId] = winner.result;
634
+ branchLedgers.get(winner.branchId)?.release();
753
635
  }
754
636
  else {
755
- const branchPromises = parallel.branches.map((branch) => runBranch(branch));
756
637
  const settled = (await (timeoutPromise
757
638
  ? Promise.race([Promise.allSettled(branchPromises), timeoutPromise])
758
- : Promise.allSettled(branchPromises)));
759
- parallelBranchResults = {};
639
+ : branchesSettled));
640
+ parallelBranchResults = createRecord();
760
641
  for (const entry of settled) {
761
642
  if (entry.status === "rejected") {
762
643
  throw new Error(`Parallel branch failed: ${entry.reason?.message ?? String(entry.reason)}`);
@@ -768,11 +649,18 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
768
649
  finally {
769
650
  if (parallelTimeoutId !== undefined)
770
651
  clearTimeout(parallelTimeoutId);
652
+ const branchesCancelled = wait === "any" || parallelSignal.aborted;
653
+ if (branchesCancelled)
654
+ branchAbortController.abort();
655
+ if (branchesCancelled)
656
+ await waitForCancelledParallelBranches(branchesSettled);
657
+ else
658
+ await branchesSettled;
771
659
  }
772
- const merged = {};
773
- for (const [branchId, branchResult] of Object.entries(parallelBranchResults ?? {})) {
774
- merged[branchId] = branchResult.json;
775
- }
660
+ const merged = Object.fromEntries(Object.entries(parallelBranchResults ?? {}).map(([branchId, branchResult]) => [
661
+ branchId,
662
+ branchResult.json,
663
+ ]));
776
664
  result = {
777
665
  id: step.id,
778
666
  json: merged,
@@ -800,10 +688,25 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
800
688
  const childActive = new Set(activeWorkflows);
801
689
  childActive.add(canonicalWorkflowPath);
802
690
  const subArgs = resolveWorkflowStepArgs(step.workflow_args, resolvedArgs, results);
691
+ stepSignal?.throwIfAborted();
803
692
  const subResult = await runWorkflowFile({
804
693
  filePath: resolvedWorkflowPath,
805
694
  args: subArgs,
806
- ctx: { ...ctx, env, cwd, _activeWorkflows: childActive },
695
+ ctx: {
696
+ ...ctx,
697
+ env,
698
+ cwd,
699
+ signal: stepSignal,
700
+ _activeWorkflows: childActive,
701
+ _llmSpendLedger: stepScopedLedger(llmSpendLedger, step.id),
702
+ _onExecutionStarted: async () => {
703
+ await markExecutionStarted(stepSignal);
704
+ },
705
+ _onExecutionInterrupted: () => {
706
+ ctx._onExecutionInterrupted?.();
707
+ },
708
+ _onNonRetryableSideEffect: markNonRetryableSideEffect,
709
+ },
807
710
  });
808
711
  if (subResult.status === "needs_approval" || subResult.status === "needs_input") {
809
712
  const resumeToken = subResult.requiresApproval?.resumeToken ?? subResult.requiresInput?.resumeToken;
@@ -823,17 +726,26 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
823
726
  const json = subResult.output.length === 1 ? subResult.output[0] : subResult.output;
824
727
  const stdout = subResult.output.length ? serializeValueForStdout(json) : "";
825
728
  result = { id: step.id, stdout, json };
729
+ // A child ending in a rendered pipeline (`llm.invoke | json`) prints its items
730
+ // and returns JSON parsed back out of that print, which no symbol survives.
731
+ // Carry the child's own items across the composition boundary so this run
732
+ // applies to them the same replay rule it applies to its own pipelines.
733
+ const composedItems = pipelineSourceItems(subResult);
734
+ if (composedItems)
735
+ attachPipelineSourceItems(result, composedItems);
826
736
  }
827
737
  else if (execution.kind === "shell") {
828
738
  const command = resolveTemplate(execution.value, resolvedArgs, results);
829
739
  const stdinValue = resolveShellStdin(step.stdin, resolvedArgs, results);
740
+ await markExecutionStarted(stepSignal);
830
741
  const { stdout } = await runShellCommand({
831
742
  command,
832
743
  stdin: stdinValue,
833
744
  env,
834
745
  cwd,
835
746
  signal: stepSignal,
836
- ...(step.timeout_ms ? { killSignal: "SIGKILL" } : {}),
747
+ forceTerminationSignal: ctx.forceTerminationSignal,
748
+ killSignal: () => stepTimeoutController?.signal.aborted ? "SIGKILL" : undefined,
837
749
  });
838
750
  result = { id: step.id, stdout, json: parseJson(stdout) };
839
751
  }
@@ -847,7 +759,12 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
847
759
  stepId: step.id,
848
760
  pipelineText,
849
761
  inputValue,
850
- ctx: { ...ctx, signal: stepSignal },
762
+ ctx: {
763
+ ...ctx,
764
+ signal: stepSignal,
765
+ _onNonRetryableSideEffect: markNonRetryableSideEffect,
766
+ },
767
+ llmSpendLedger,
851
768
  env,
852
769
  cwd,
853
770
  resume: resumedPipelineInput?.stepId === step.id
@@ -857,14 +774,23 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
857
774
  onConsumed: resumedPipelineInput.onConsumed,
858
775
  }
859
776
  : undefined,
777
+ onExecutionStart: () => markExecutionStarted(stepSignal),
860
778
  });
861
779
  }
862
780
  else {
781
+ if (execution.kind !== "none")
782
+ await markExecutionStarted(stepSignal);
863
783
  const inputValue = resolveInputValue(step.stdin, resolvedArgs, results);
864
784
  result = createSyntheticStepResult(step.id, inputValue);
865
785
  }
866
786
  return { result, parallelBranchResults };
867
787
  }
788
+ catch (error) {
789
+ if (attemptHadNonRetryableSideEffect) {
790
+ throw markStepErrorNonRetryable(error);
791
+ }
792
+ throw error;
793
+ }
868
794
  finally {
869
795
  if (timeoutId !== undefined)
870
796
  clearTimeout(timeoutId);
@@ -878,9 +804,20 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
878
804
  signal: ctx.signal,
879
805
  shouldRetry: (error) => {
880
806
  if (error instanceof WorkflowPipelineInputSuspension ||
881
- error instanceof RequestInputResumeError) {
807
+ error instanceof RequestInputResumeError ||
808
+ error instanceof CostLimitExceededError) {
809
+ return false;
810
+ }
811
+ if (isStepErrorNonRetryable(error)) {
882
812
  return false;
883
813
  }
814
+ if (ctx.signal?.aborted)
815
+ return false;
816
+ if (workflow.cost_limit?.action === "stop") {
817
+ // Failed pipelines can discard output after the provider was paid.
818
+ settleUnbilledCharges(costTracker, llmSpendLedger);
819
+ costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
820
+ }
884
821
  const message = error?.message ?? String(error);
885
822
  return !/halted (for approval inside|before completion at) pipeline/.test(message);
886
823
  },
@@ -902,40 +839,67 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
902
839
  subject: err.request.subject,
903
840
  maxEnvelopeBytes: resolveToolEnvelopeMaxBytes(ctx.env),
904
841
  });
905
- const stateKey = await saveWorkflowResumeState(ctx.env, {
906
- filePath: resolvedFilePath,
907
- resumeAtIndex: idx,
908
- steps: results,
909
- args: resolvedArgs,
910
- inputStepId: err.stepId,
911
- inputKind: "pipeline_command",
912
- inputSchema: err.request.responseSchema,
913
- inputSubject: err.request.subject,
914
- pipelineInput: err.pipelineInput,
915
- createdAt: new Date().toISOString(),
916
- });
917
- if (consumedResumeStateKey && consumedResumeStateKey !== stateKey) {
918
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
842
+ let stateKey;
843
+ try {
844
+ stateKey = await saveWorkflowResumeState(ctx.env, {
845
+ filePath: resolvedFilePath,
846
+ resumeAtIndex: idx,
847
+ steps: results,
848
+ args: resolvedArgs,
849
+ inputStepId: err.stepId,
850
+ inputKind: "pipeline_command",
851
+ inputSchema: err.request.responseSchema,
852
+ inputSubject: err.request.subject,
853
+ pipelineInput: err.pipelineInput,
854
+ ...(costTracker.hasUsage() ? { cost: costTracker.getSummary() } : null),
855
+ ...(llmSpendLedger.outstanding().length
856
+ ? { llmSpend: llmSpendLedger.outstanding() }
857
+ : null),
858
+ ...(llmSpendLedger.settled().length
859
+ ? { llmBilled: llmSpendLedger.settled() }
860
+ : null),
861
+ supersededResumeStateKeys: collectSupersededWorkflowResumeStateKeys(consumedResumeStateKey, resumeState),
862
+ createdAt: new Date().toISOString(),
863
+ }, ctx.signal);
864
+ const replaced = await replaceWorkflowResumeState({
865
+ env: ctx.env,
866
+ previousStateKey: consumedResumeStateKey,
867
+ expectedPreviousState: resumeState,
868
+ previousStateConsumed: resumeStateConsumed,
869
+ replacementStateKey: stateKey,
870
+ signal: ctx.signal,
871
+ });
872
+ if (!replaced) {
873
+ throw new Error("Workflow resume state not found");
874
+ }
875
+ const resumeToken = encodeToken({
876
+ protocolVersion: 1,
877
+ v: 1,
878
+ kind: "workflow-file",
879
+ stateKey,
880
+ });
881
+ return {
882
+ status: "needs_input",
883
+ output: [],
884
+ requiresInput: {
885
+ ...inputRequest,
886
+ resumeToken,
887
+ },
888
+ };
889
+ }
890
+ catch (stateError) {
891
+ if (stateKey)
892
+ await discardWorkflowResumeState(ctx.env, stateKey);
893
+ throw stateError;
919
894
  }
920
- const resumeToken = encodeToken({
921
- protocolVersion: 1,
922
- v: 1,
923
- kind: "workflow-file",
924
- stateKey,
925
- });
926
- return {
927
- status: "needs_input",
928
- output: [],
929
- requiresInput: {
930
- ...inputRequest,
931
- resumeToken,
932
- },
933
- };
934
895
  }
935
896
  if (err instanceof RequestInputResumeError) {
936
897
  throw err;
937
898
  }
938
- if (ctx.signal?.aborted && (err?.name === "AbortError" || err?.code === "ABORT_ERR")) {
899
+ if (err instanceof CostLimitExceededError) {
900
+ throw err;
901
+ }
902
+ if (ctx.signal?.aborted) {
939
903
  throw err;
940
904
  }
941
905
  if (err?.message &&
@@ -956,20 +920,36 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
956
920
  error: true,
957
921
  errorMessage,
958
922
  };
923
+ // The step failed after paying a provider: a call it made before failing is spend
924
+ // this run really made, and its items are gone with the failure. Settling here is
925
+ // what stops a budget already blown from letting the next step run its side
926
+ // effects, which is the whole point of `action: stop`.
927
+ settleUnbilledCharges(costTracker, llmSpendLedger);
928
+ if (workflow.cost_limit) {
929
+ costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
930
+ }
959
931
  if (policy === "skip_rest") {
960
932
  break;
961
933
  }
962
934
  continue;
963
935
  }
936
+ // One accounting group per step: its parallel branches and its own result settle
937
+ // together, so no branch can be billed for a charge another branch is about to claim.
938
+ const deferredCharges = [];
964
939
  if (parallelBranchResults) {
965
940
  for (const [branchId, branchResult] of Object.entries(parallelBranchResults)) {
966
941
  results[branchId] = branchResult;
967
- trackStepCost(costTracker, branchId, branchResult);
942
+ trackStepCost(costTracker, branchId, branchResult, llmSpendLedger, deferredCharges);
968
943
  }
969
944
  }
970
945
  results[step.id] = result;
971
946
  lastStepId = step.id;
972
- trackStepCost(costTracker, step.id, result);
947
+ trackStepCost(costTracker, step.id, result, llmSpendLedger, deferredCharges);
948
+ settleDeferredCosts(costTracker, deferredCharges, llmSpendLedger);
949
+ // Before the next step runs: a charge no item carried is spend the step really made,
950
+ // and leaving it for the end would let `cost_limit` wave through every step after the
951
+ // one that blew the budget.
952
+ settleUnbilledCharges(costTracker, llmSpendLedger);
973
953
  if (workflow.cost_limit) {
974
954
  costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
975
955
  }
@@ -980,45 +960,64 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
980
960
  results[step.id].initiatedBy = approvalIdentity.initiatedBy;
981
961
  }
982
962
  if (ctx.mode === "tool" || !isInteractive(ctx.stdin)) {
983
- const stateKey = await saveWorkflowResumeState(ctx.env, {
984
- filePath: resolvedFilePath,
985
- resumeAtIndex: idx + 1,
986
- steps: results,
987
- args: resolvedArgs,
988
- approvalStepId: step.id,
989
- approvalIdentity,
990
- createdAt: new Date().toISOString(),
991
- });
992
- let approvalId;
963
+ let stateKey;
993
964
  try {
994
- approvalId = await createApprovalIndex({ env: ctx.env, stateKey });
965
+ stateKey = await saveWorkflowResumeState(ctx.env, {
966
+ filePath: resolvedFilePath,
967
+ resumeAtIndex: idx + 1,
968
+ steps: results,
969
+ args: resolvedArgs,
970
+ approvalStepId: step.id,
971
+ approvalIdentity,
972
+ ...(costTracker.hasUsage() ? { cost: costTracker.getSummary() } : null),
973
+ ...(llmSpendLedger.outstanding().length
974
+ ? { llmSpend: llmSpendLedger.outstanding() }
975
+ : null),
976
+ ...(llmSpendLedger.settled().length
977
+ ? { llmBilled: llmSpendLedger.settled() }
978
+ : null),
979
+ supersededResumeStateKeys: collectSupersededWorkflowResumeStateKeys(consumedResumeStateKey, resumeState),
980
+ createdAt: new Date().toISOString(),
981
+ }, ctx.signal);
982
+ const approvalId = await createApprovalIndex({ env: ctx.env, stateKey });
983
+ ctx.signal?.throwIfAborted();
984
+ const replaced = await replaceWorkflowResumeState({
985
+ env: ctx.env,
986
+ previousStateKey: consumedResumeStateKey,
987
+ expectedPreviousState: resumeState,
988
+ previousStateConsumed: resumeStateConsumed,
989
+ replacementStateKey: stateKey,
990
+ signal: ctx.signal,
991
+ });
992
+ if (!replaced) {
993
+ throw new Error("Workflow resume state not found");
994
+ }
995
+ const resumeToken = encodeToken({
996
+ protocolVersion: 1,
997
+ v: 1,
998
+ kind: "workflow-file",
999
+ stateKey,
1000
+ });
1001
+ return {
1002
+ status: "needs_approval",
1003
+ output: [],
1004
+ requiresApproval: {
1005
+ ...approval,
1006
+ resumeToken,
1007
+ ...(approvalId ? { approvalId } : null),
1008
+ },
1009
+ };
995
1010
  }
996
1011
  catch (err) {
997
- await deleteStateJson({ env: ctx.env, key: stateKey }).catch(() => { });
1012
+ if (stateKey)
1013
+ await discardWorkflowResumeState(ctx.env, stateKey);
998
1014
  throw err;
999
1015
  }
1000
- if (consumedResumeStateKey && consumedResumeStateKey !== stateKey) {
1001
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
1002
- }
1003
- const resumeToken = encodeToken({
1004
- protocolVersion: 1,
1005
- v: 1,
1006
- kind: "workflow-file",
1007
- stateKey,
1008
- });
1009
- return {
1010
- status: "needs_approval",
1011
- output: [],
1012
- requiresApproval: {
1013
- ...approval,
1014
- resumeToken,
1015
- ...(approvalId ? { approvalId } : null),
1016
- },
1017
- };
1018
1016
  }
1019
1017
  ctx.stdout.write(`${approval.prompt} [y/N] `);
1020
1018
  const answer = await readLineFromStream(ctx.stdin, {
1021
1019
  timeoutMs: parseApprovalTimeoutMs(ctx.env),
1020
+ signal: ctx.signal,
1022
1021
  });
1023
1022
  if (!/^y(es)?$/i.test(String(answer).trim())) {
1024
1023
  throw new Error("Not approved");
@@ -1034,270 +1033,88 @@ export async function runWorkflowFile({ filePath, args, ctx, resume, approved, r
1034
1033
  results[step.id].approvedBy = approvedBy;
1035
1034
  }
1036
1035
  }
1036
+ // Whatever a skipped or failed step left open, and anything from a run with no step left
1037
+ // to settle it against. A paused run does not reach this line -- its charges travel in
1038
+ // the resume state instead -- so this settles only calls with nowhere left to be billed.
1039
+ settleUnbilledCharges(costTracker, llmSpendLedger);
1040
+ if (workflow.cost_limit) {
1041
+ costTracker.checkLimit(workflow.cost_limit, ctx.stderr);
1042
+ }
1037
1043
  const output = lastStepId ? toOutputItems(results[lastStepId]) : [];
1038
1044
  if (consumedResumeStateKey) {
1039
- await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
1045
+ if (resumeStateConsumed) {
1046
+ ctx.signal?.throwIfAborted();
1047
+ await deleteStateJson({
1048
+ env: ctx.env,
1049
+ key: consumedResumeStateKey,
1050
+ signal: ctx.signal,
1051
+ });
1052
+ ctx.signal?.throwIfAborted();
1053
+ }
1054
+ else {
1055
+ const deleted = await deleteResumeStateWithRollback({
1056
+ env: ctx.env,
1057
+ key: consumedResumeStateKey,
1058
+ expectedState: resumeState,
1059
+ signal: ctx.signal,
1060
+ });
1061
+ if (!deleted) {
1062
+ throw new Error("Workflow resume state not found");
1063
+ }
1064
+ }
1065
+ await cleanupSupersededWorkflowResumeStates(ctx.env, resumeState?.supersededResumeStateKeys);
1066
+ }
1067
+ else {
1068
+ ctx.signal?.throwIfAborted();
1040
1069
  }
1041
1070
  const runResult = { status: "ok", output };
1071
+ // `output` is what the last step could serialize; a run composing this one also needs the
1072
+ // items themselves, because that is where replay provenance lives.
1073
+ const lastItems = lastStepId ? pipelineSourceItems(results[lastStepId]) : undefined;
1074
+ if (lastItems)
1075
+ attachPipelineSourceItems(runResult, lastItems);
1042
1076
  if (costTracker.hasUsage()) {
1043
1077
  runResult._meta = { cost: costTracker.getSummary() };
1044
1078
  }
1045
1079
  return runResult;
1046
1080
  }
1047
- finally {
1048
- ctx._activeWorkflows?.delete(canonicalFilePath);
1049
- }
1050
- }
1051
- // Returns a human-readable note if a step.stdin value references a prior step's
1052
- // output. Because dry-run placeholders have no actual stdout/json, we surface
1053
- // this so users know the value is unknown at plan time rather than silently
1054
- // resolving to an empty string.
1055
- function dryRunStdinNote(stdin) {
1056
- if (stdin === null || stdin === undefined)
1057
- return null;
1058
- if (typeof stdin !== "string")
1059
- return null;
1060
- const trimmed = stdin.trim();
1061
- // Strict step ref: '$step-id.stdout' or '$step-id.json'
1062
- if (/^\$[A-Za-z0-9_-]+\.(stdout|json)$/.test(trimmed)) {
1063
- return `${trimmed} [output unknown at plan time]`;
1064
- }
1065
- // Inline template ref: contains '$stepid.stdout' or '$stepid.json'
1066
- if (/\$[A-Za-z0-9_-]+\.(stdout|json)/.test(trimmed)) {
1067
- return `${trimmed} [contains step output refs — unknown at plan time]`;
1068
- }
1069
- return null;
1070
- }
1071
- function dryRunTemplateNote(input) {
1072
- if (/\$[A-Za-z0-9_-]+\.(stdout|json)/.test(input)) {
1073
- return "[contains step output refs — unknown at plan time]";
1074
- }
1075
- return null;
1076
- }
1077
- function hasDeferredDryRunStageName(input) {
1078
- return /\$[A-Za-z0-9_-]+\.(stdout|json)/.test(input);
1079
- }
1080
- function resolveDryRunTemplate(input, args, results) {
1081
- const withArgs = resolveArgsTemplate(input, args);
1082
- return withArgs.replace(/\$([A-Za-z0-9_-]+)\.(stdout|json|approved)/g, (match, id, field) => {
1083
- if (field === "approved") {
1084
- const step = results[id];
1085
- if (!step)
1086
- return match;
1087
- return step.approved === true ? "true" : "false";
1088
- }
1089
- return match;
1090
- });
1091
- }
1092
- function dryRunWorkflow({ steps, resolvedArgs, results, startIndex, ctx, }) {
1093
- const lines = [];
1094
- const totalSteps = steps.length - startIndex;
1095
- lines.push(`[DRY RUN] Would execute ${totalSteps} step${totalSteps !== 1 ? "s" : ""}:\n`);
1096
- for (let idx = startIndex; idx < steps.length; idx++) {
1097
- const step = steps[idx];
1098
- const num = idx - startIndex + 1;
1099
- if (!evaluateCondition(step.when ?? step.condition, results)) {
1100
- results[step.id] = { id: step.id, skipped: true };
1101
- lines.push(` ${num}. ${step.id} [skipped — condition: false]`);
1102
- continue;
1103
- }
1104
- if (isInputStep(step.input)) {
1105
- lines.push(` ${num}. ${step.id} [input]`);
1106
- lines.push(` prompt: ${step.input.prompt}`);
1107
- lines.push(` [input required]`);
1108
- results[step.id] = { id: step.id, response: { pending: true } };
1109
- continue;
1110
- }
1111
- if (typeof step.for_each === "string" && Array.isArray(step.steps)) {
1112
- lines.push(` ${num}. ${step.id} [for_each]`);
1113
- const forEachRef = step.for_each;
1114
- const forEachNote = dryRunTemplateNote(forEachRef);
1115
- lines.push(` for_each: ${forEachRef}${forEachNote ? ` ${forEachNote}` : ""}`);
1116
- if (forEachRef.trim().startsWith("$")) {
1117
- try {
1118
- resolveInputValue(forEachRef, resolvedArgs, results);
1119
- }
1120
- catch (err) {
1121
- throw new Error(`Workflow step ${step.id} for_each: ${err?.message ?? String(err)}`);
1122
- }
1123
- }
1124
- const dryItemVar = step.item_var ?? "item";
1125
- const dryIndexVar = step.index_var ?? "index";
1126
- lines.push(` item_var: ${dryItemVar}, index_var: ${dryIndexVar}`);
1127
- if (step.batch_size)
1128
- lines.push(` batch_size: ${step.batch_size}`);
1129
- if (step.pause_ms)
1130
- lines.push(` pause_ms: ${step.pause_ms}`);
1131
- lines.push(` sub-steps: ${step.steps.length}`);
1132
- const loopScopedResults = { ...results };
1133
- loopScopedResults[dryItemVar] = { id: dryItemVar, json: { _placeholder: true } };
1134
- loopScopedResults[dryIndexVar] = { id: dryIndexVar, json: 0 };
1135
- for (let subIdx = 0; subIdx < step.steps.length; subIdx++) {
1136
- const sub = step.steps[subIdx];
1137
- if (!evaluateCondition(sub.when ?? sub.condition, loopScopedResults)) {
1138
- lines.push(` ${subIdx + 1}. ${sub.id} [skipped — condition: false]`);
1139
- loopScopedResults[sub.id] = { id: sub.id, skipped: true };
1140
- continue;
1141
- }
1142
- if (sub.stdin !== undefined && sub.stdin !== null) {
1143
- try {
1144
- resolveInputValue(sub.stdin, resolvedArgs, loopScopedResults);
1145
- }
1146
- catch (err) {
1147
- throw new Error(`Workflow step ${step.id} for_each sub-step ${sub.id} stdin: ${err?.message ?? String(err)}`);
1148
- }
1149
- }
1150
- const subExec = getStepExecution(sub);
1151
- if (subExec.kind === "shell") {
1152
- const command = resolveDryRunTemplate(subExec.value, resolvedArgs, loopScopedResults);
1153
- lines.push(` ${subIdx + 1}. ${sub.id} [shell] run: ${command}`);
1154
- }
1155
- else if (subExec.kind === "pipeline") {
1156
- if (!ctx.registry) {
1157
- throw new Error(`Workflow step ${step.id} for_each sub-step ${sub.id} requires a command registry for pipeline execution`);
1158
- }
1159
- const pipelineText = resolveDryRunTemplate(subExec.value, resolvedArgs, loopScopedResults);
1160
- const stages = parsePipeline(pipelineText);
1161
- for (const stage of stages) {
1162
- if (hasDeferredDryRunStageName(stage.name))
1163
- continue;
1164
- if (!ctx.registry.get(stage.name)) {
1165
- throw new Error(`Workflow step ${step.id} for_each sub-step ${sub.id} pipeline: unknown command: ${stage.name}`);
1166
- }
1167
- }
1168
- lines.push(` ${subIdx + 1}. ${sub.id} [pipeline] pipeline: ${pipelineText}`);
1169
- }
1170
- else {
1171
- lines.push(` ${subIdx + 1}. ${sub.id} [no-op]`);
1172
- }
1173
- loopScopedResults[sub.id] = { id: sub.id };
1174
- }
1175
- results[step.id] = { id: step.id };
1176
- continue;
1177
- }
1178
- // Validate stdin refs early — throws if a strict ref like '$missing.stdout'
1179
- // points to a step that doesn't exist at all (real execution would also fail).
1180
- // We call resolveInputValue with the current results so refs to steps we've
1181
- // already visited (placeholders) are accepted without throwing.
1182
- if (step.stdin !== undefined && step.stdin !== null) {
1081
+ catch (err) {
1082
+ if (resumedExecutionStarted && consumedResumeStateKey) {
1183
1083
  try {
1184
- resolveInputValue(step.stdin, resolvedArgs, results);
1185
- }
1186
- catch (err) {
1187
- throw new Error(`Workflow step ${step.id} stdin: ${err?.message ?? String(err)}`);
1188
- }
1189
- }
1190
- const execution = getStepExecution(step);
1191
- // Annotate when the resolved command/pipeline references a prior step's output.
1192
- // Since dry-run placeholders have no actual stdout/json, note it explicitly
1193
- // rather than silently collapsing the reference to an empty string.
1194
- const stdinNote = dryRunStdinNote(step.stdin);
1195
- if (execution.kind === "parallel") {
1196
- lines.push(` ${num}. ${step.id} [parallel]`);
1197
- lines.push(` wait: ${step.parallel?.wait ?? "all"}`);
1198
- if (step.parallel?.timeout_ms) {
1199
- lines.push(` timeout: ${step.parallel.timeout_ms}ms`);
1200
- }
1201
- for (const branch of step.parallel?.branches ?? []) {
1202
- const branchShell = typeof branch.run === "string" ? branch.run : branch.command;
1203
- if (typeof branch.pipeline === "string" && branch.pipeline.trim()) {
1204
- const pipelineText = resolveDryRunTemplate(branch.pipeline, resolvedArgs, results);
1205
- const pipelineNote = dryRunTemplateNote(pipelineText);
1206
- if (!ctx.registry) {
1207
- throw new Error(`Parallel branch ${branch.id} requires a command registry for pipeline execution`);
1208
- }
1209
- const stages = parsePipeline(pipelineText);
1210
- for (const stage of stages) {
1211
- if (hasDeferredDryRunStageName(stage.name))
1212
- continue;
1213
- if (!ctx.registry.get(stage.name)) {
1214
- throw new Error(`Parallel branch ${branch.id} pipeline references unknown command: ${stage.name}`);
1215
- }
1216
- }
1217
- lines.push(` branch ${branch.id}: [pipeline] ${pipelineText}${pipelineNote ? ` ${pipelineNote}` : ""}`);
1218
- }
1219
- else if (typeof branchShell === "string" && branchShell.trim()) {
1220
- const command = resolveDryRunTemplate(branchShell, resolvedArgs, results);
1221
- const commandNote = dryRunTemplateNote(command);
1222
- lines.push(` branch ${branch.id}: [shell] ${command}${commandNote ? ` ${commandNote}` : ""}`);
1084
+ if (ctx.signal?.aborted) {
1085
+ await deleteStateJsonWithBoundedResumeCleanup({
1086
+ env: ctx.env,
1087
+ key: consumedResumeStateKey,
1088
+ });
1223
1089
  }
1224
1090
  else {
1225
- lines.push(` branch ${branch.id}: [no-op]`);
1091
+ await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
1226
1092
  }
1227
- results[branch.id] = { id: branch.id };
1228
1093
  }
1229
- }
1230
- else if (execution.kind === "workflow") {
1231
- const workflowPath = resolveDryRunTemplate(execution.value, resolvedArgs, results);
1232
- const pathNote = dryRunTemplateNote(workflowPath);
1233
- lines.push(` ${num}. ${step.id} [workflow]`);
1234
- lines.push(` workflow: ${workflowPath}${pathNote ? ` ${pathNote}` : ""}`);
1235
- if (step.workflow_args && typeof step.workflow_args === "object") {
1236
- const argKeys = Object.keys(step.workflow_args);
1237
- if (argKeys.length) {
1238
- lines.push(` args: ${argKeys.join(", ")}`);
1239
- }
1240
- }
1241
- }
1242
- else if (execution.kind === "shell") {
1243
- const command = resolveDryRunTemplate(execution.value, resolvedArgs, results);
1244
- const commandNote = dryRunTemplateNote(command);
1245
- lines.push(` ${num}. ${step.id} [shell]`);
1246
- lines.push(` run: ${command}${commandNote ? ` ${commandNote}` : ""}`);
1247
- }
1248
- else if (execution.kind === "pipeline") {
1249
- const pipelineText = resolveDryRunTemplate(execution.value, resolvedArgs, results);
1250
- const pipelineNote = dryRunTemplateNote(pipelineText);
1251
- // Validate pipeline syntax and registry even in dry-run so errors surface early.
1252
- if (!ctx.registry) {
1253
- throw new Error(`Workflow step ${step.id} requires a command registry for pipeline execution`);
1254
- }
1255
- // Validate that every stage name is a known command.
1256
- const stages = parsePipeline(pipelineText);
1257
- for (const stage of stages) {
1258
- if (hasDeferredDryRunStageName(stage.name)) {
1259
- continue;
1260
- }
1261
- if (!ctx.registry.get(stage.name)) {
1262
- throw new Error(`Workflow step ${step.id} pipeline references unknown command: ${stage.name}`);
1263
- }
1264
- }
1265
- lines.push(` ${num}. ${step.id} [pipeline]`);
1266
- lines.push(` pipeline: ${pipelineText}${pipelineNote ? ` ${pipelineNote}` : ""}`);
1267
- if (stages.some((stage) => hasDeferredDryRunStageName(stage.name))) {
1268
- lines.push(" [command validation deferred — stage name depends on step output]");
1269
- }
1270
- }
1271
- else {
1272
- lines.push(` ${num}. ${step.id} [no-op]`);
1273
- }
1274
- if (stdinNote)
1275
- lines.push(` stdin: ${stdinNote}`);
1276
- if (step.timeout_ms)
1277
- lines.push(` timeout: ${step.timeout_ms}ms`);
1278
- if (step.on_error && step.on_error !== "stop")
1279
- lines.push(` on_error: ${step.on_error}`);
1280
- if (step.retry && typeof step.retry === "object") {
1281
- const rc = resolveRetryConfig(step.retry);
1282
- if (rc.max > 1) {
1283
- lines.push(` retry: up to ${rc.max} attempts, ${rc.backoff} backoff (base: ${rc.delay_ms}ms${rc.jitter ? ", jitter" : ""})`);
1094
+ catch {
1095
+ // Leave the tombstone in place if cleanup fails: it is the durable
1096
+ // record that prevents the consumed token from replaying an effect.
1284
1097
  }
1098
+ // A consumed marker remains non-replayable even when its bounded cleanup
1099
+ // times out, so its short approval ID can be retired independently.
1100
+ await cleanupApprovalIndexByStateKey({
1101
+ env: ctx.env,
1102
+ stateKey: consumedResumeStateKey,
1103
+ }).catch(() => { });
1285
1104
  }
1286
- if (isApprovalStep(step.approval)) {
1287
- lines.push(` [approval required]`);
1105
+ throw err;
1106
+ }
1107
+ finally {
1108
+ for (const { signal, onAbort } of executionSignalListeners) {
1109
+ signal.removeEventListener("abort", onAbort);
1288
1110
  }
1289
- // Record a placeholder result so later steps can reference this step in conditions.
1290
- // For approval steps, model approval as granted so downstream conditions like
1291
- // $step.approved evaluate correctly in the plan (rather than always being false).
1292
- // We intentionally omit stdout/json — dryRunStdinNote() surfaces that gap.
1293
- results[step.id] = isApprovalStep(step.approval)
1294
- ? { id: step.id, approved: true }
1295
- : { id: step.id };
1296
- }
1297
- lines.push("");
1298
- ctx.stderr.write(lines.join("\n"));
1299
- return { status: "ok", output: [] };
1111
+ ctx._activeWorkflows?.delete(canonicalFilePath);
1112
+ }
1300
1113
  }
1114
+ // Returns a human-readable note if a step.stdin value references a prior step's
1115
+ // output. Because dry-run placeholders have no actual stdout/json, we surface
1116
+ // this so users know the value is unknown at plan time rather than silently
1117
+ // resolving to an empty string.
1301
1118
  export function decodeWorkflowResumePayload(payload) {
1302
1119
  if (!payload || typeof payload !== "object")
1303
1120
  return null;
@@ -1319,12 +1136,87 @@ export function decodeWorkflowResumePayload(payload) {
1319
1136
  throw new Error("Invalid workflow token");
1320
1137
  return data;
1321
1138
  }
1322
- async function saveWorkflowResumeState(env, state) {
1139
+ async function saveWorkflowResumeState(env, state, signal) {
1323
1140
  const stateKey = `workflow_resume_${randomUUID()}`;
1324
- await writeStateJson({ env, key: stateKey, value: state });
1325
- return stateKey;
1141
+ try {
1142
+ signal?.throwIfAborted();
1143
+ await writeStateJson({ env, key: stateKey, value: state, signal });
1144
+ signal?.throwIfAborted();
1145
+ return stateKey;
1146
+ }
1147
+ catch (err) {
1148
+ if (signal?.aborted)
1149
+ await discardWorkflowResumeState(env, stateKey);
1150
+ throw err;
1151
+ }
1326
1152
  }
1327
- function alternateWorkflowResumeStateKey(stateKey) {
1153
+ async function replaceWorkflowResumeState({ env, previousStateKey, expectedPreviousState, previousStateConsumed, replacementStateKey, signal, }) {
1154
+ if (!previousStateKey || previousStateKey === replacementStateKey) {
1155
+ signal?.throwIfAborted();
1156
+ return true;
1157
+ }
1158
+ if (previousStateConsumed) {
1159
+ signal?.throwIfAborted();
1160
+ // The successor is already durable. Retire the predecessor's short ID here,
1161
+ // rather than when the preceding unsafe execution is only being claimed.
1162
+ await cleanupApprovalIndexByStateKey({ env, stateKey: previousStateKey }).catch(() => { });
1163
+ return true;
1164
+ }
1165
+ if (!expectedPreviousState)
1166
+ return false;
1167
+ let claimId;
1168
+ try {
1169
+ const consumption = await consumeResumeState({
1170
+ env,
1171
+ key: previousStateKey,
1172
+ expectedState: expectedPreviousState,
1173
+ signal,
1174
+ });
1175
+ if (!consumption.consumed)
1176
+ return false;
1177
+ claimId = consumption.claimId;
1178
+ // Keep the consumed marker until terminal cleanup. It is the atomic
1179
+ // predecessor-to-successor handoff and prevents another resume from
1180
+ // creating a competing successor after this call returns.
1181
+ signal?.throwIfAborted();
1182
+ await cleanupApprovalIndexByStateKey({ env, stateKey: previousStateKey }).catch(() => { });
1183
+ return true;
1184
+ }
1185
+ catch (err) {
1186
+ if (claimId && signal?.aborted) {
1187
+ await restoreConsumedResumeState({
1188
+ env,
1189
+ key: previousStateKey,
1190
+ expectedState: expectedPreviousState,
1191
+ claimId,
1192
+ }).catch(() => { });
1193
+ }
1194
+ throw err;
1195
+ }
1196
+ }
1197
+ function collectSupersededWorkflowResumeStateKeys(previousStateKey, previousState) {
1198
+ return [
1199
+ ...(previousState?.supersededResumeStateKeys ?? []),
1200
+ ...(previousStateKey ? [previousStateKey] : []),
1201
+ ].filter((stateKey, index, all) => stateKey && all.indexOf(stateKey) === index);
1202
+ }
1203
+ async function cleanupSupersededWorkflowResumeStates(env, stateKeys) {
1204
+ for (const stateKey of stateKeys ?? []) {
1205
+ try {
1206
+ if (isConsumedResumeState(await readStateJsonWithLock({ env, key: stateKey }))) {
1207
+ await deleteStateJson({ env, key: stateKey });
1208
+ }
1209
+ }
1210
+ catch {
1211
+ // The completed successor makes retained ancestor markers safe.
1212
+ }
1213
+ }
1214
+ }
1215
+ async function discardWorkflowResumeState(env, stateKey) {
1216
+ await cleanupApprovalIndexByStateKey({ env, stateKey }).catch(() => { });
1217
+ await deleteStateJson({ env, key: stateKey }).catch(() => { });
1218
+ }
1219
+ export function alternateWorkflowResumeStateKey(stateKey) {
1328
1220
  if (stateKey.includes("workflow-resume_")) {
1329
1221
  return stateKey.replace("workflow-resume_", "workflow_resume_");
1330
1222
  }
@@ -1333,8 +1225,8 @@ function alternateWorkflowResumeStateKey(stateKey) {
1333
1225
  }
1334
1226
  return null;
1335
1227
  }
1336
- async function resolveWorkflowResumeStateKey(env, stateKey) {
1337
- const stored = await readStateJson({ env, key: stateKey });
1228
+ async function resolveWorkflowResumeStateKey(env, stateKey, signal) {
1229
+ const stored = await readStateJsonWithLock({ env, key: stateKey, signal });
1338
1230
  if (stored && typeof stored === "object") {
1339
1231
  return stateKey;
1340
1232
  }
@@ -1342,18 +1234,24 @@ async function resolveWorkflowResumeStateKey(env, stateKey) {
1342
1234
  if (!altKey) {
1343
1235
  return stateKey;
1344
1236
  }
1345
- const altStored = await readStateJson({ env, key: altKey });
1237
+ const altStored = await readStateJsonWithLock({ env, key: altKey, signal });
1346
1238
  if (altStored && typeof altStored === "object") {
1347
1239
  return altKey;
1348
1240
  }
1349
1241
  return stateKey;
1350
1242
  }
1351
- async function loadWorkflowResumeState(env, stateKey) {
1352
- let stored = await readStateJson({ env, key: stateKey });
1243
+ async function loadWorkflowResumeState(env, stateKey, signal) {
1244
+ let stored = await readStateJsonWithLock({ env, key: stateKey, signal });
1245
+ if (isConsumedResumeState(stored)) {
1246
+ throw new Error("Workflow resume state not found");
1247
+ }
1353
1248
  if ((!stored || typeof stored !== "object") && typeof stateKey === "string") {
1354
1249
  const altKey = alternateWorkflowResumeStateKey(stateKey);
1355
1250
  if (altKey) {
1356
- stored = await readStateJson({ env, key: altKey });
1251
+ stored = await readStateJsonWithLock({ env, key: altKey, signal });
1252
+ if (isConsumedResumeState(stored)) {
1253
+ throw new Error("Workflow resume state not found");
1254
+ }
1357
1255
  }
1358
1256
  }
1359
1257
  if (!stored || typeof stored !== "object") {
@@ -1368,6 +1266,11 @@ async function loadWorkflowResumeState(env, stateKey) {
1368
1266
  throw new Error("Invalid workflow resume state");
1369
1267
  if (!data.args || typeof data.args !== "object")
1370
1268
  throw new Error("Invalid workflow resume state");
1269
+ if (data.supersededResumeStateKeys !== undefined &&
1270
+ (!Array.isArray(data.supersededResumeStateKeys) ||
1271
+ data.supersededResumeStateKeys.some((stateKey) => typeof stateKey !== "string"))) {
1272
+ throw new Error("Invalid workflow resume state");
1273
+ }
1371
1274
  if (data.inputKind !== undefined &&
1372
1275
  !["workflow_step", "pipeline_command"].includes(data.inputKind)) {
1373
1276
  throw new Error("Invalid workflow resume state");
@@ -1417,141 +1320,6 @@ function validateWorkflowPipelineShape(pipeline) {
1417
1320
  throw new Error("Invalid workflow resume state");
1418
1321
  }
1419
1322
  }
1420
- function mergeEnv(base, workflowEnv, stepEnv, args, results) {
1421
- const env = { ...base };
1422
- // Expose resolved args as env vars so shell commands can safely reference them
1423
- // without embedding raw values into the command string.
1424
- // Example: $LOBSTER_ARG_TEXT
1425
- env.LOBSTER_ARGS_JSON = JSON.stringify(args ?? {});
1426
- for (const [key, value] of Object.entries(args ?? {})) {
1427
- const normalized = normalizeArgEnvKey(key);
1428
- if (!normalized)
1429
- continue;
1430
- env[`LOBSTER_ARG_${normalized}`] = String(value);
1431
- }
1432
- const apply = (source) => {
1433
- if (!source)
1434
- return;
1435
- for (const [key, value] of Object.entries(source)) {
1436
- if (typeof value === "string") {
1437
- env[key] = resolveTemplate(value, args, results);
1438
- }
1439
- }
1440
- };
1441
- // Allow explicit env blocks to override injected defaults.
1442
- apply(workflowEnv);
1443
- apply(stepEnv);
1444
- return env;
1445
- }
1446
- function normalizeArgEnvKey(key) {
1447
- const trimmed = String(key ?? "").trim();
1448
- if (!trimmed)
1449
- return null;
1450
- // Keep it predictable for shells: uppercase and [A-Z0-9_]
1451
- const up = trimmed.toUpperCase();
1452
- const normalized = up.replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
1453
- return normalized || null;
1454
- }
1455
- function resolveCwd(cwd, args) {
1456
- if (!cwd)
1457
- return undefined;
1458
- return resolveArgsTemplate(cwd, args);
1459
- }
1460
- function resolveInputValue(stdin, args, results) {
1461
- if (stdin === null || stdin === undefined)
1462
- return null;
1463
- if (typeof stdin === "string") {
1464
- const ref = parseStepRef(stdin.trim());
1465
- if (ref)
1466
- return getStepRefValue(ref, results, true);
1467
- return resolveTemplate(stdin, args, results);
1468
- }
1469
- return stdin;
1470
- }
1471
- function resolveShellStdin(stdin, args, results) {
1472
- const value = resolveInputValue(stdin, args, results);
1473
- return encodeShellInput(value);
1474
- }
1475
- function resolveTemplate(input, args, results) {
1476
- const withArgs = resolveArgsTemplate(input, args);
1477
- return resolveStepRefs(withArgs, results);
1478
- }
1479
- function resolveWorkflowStepArgs(workflowArgs, parentArgs, results) {
1480
- if (!workflowArgs)
1481
- return {};
1482
- const resolved = {};
1483
- for (const [key, value] of Object.entries(workflowArgs)) {
1484
- if (typeof value === "string") {
1485
- resolved[key] = resolveTemplate(value, parentArgs, results);
1486
- }
1487
- else {
1488
- resolved[key] = value;
1489
- }
1490
- }
1491
- return resolved;
1492
- }
1493
- function resolveArgsTemplate(input, args) {
1494
- return input.replace(/\$\{([A-Za-z0-9_-]+)\}/g, (match, key) => {
1495
- if (key in args)
1496
- return String(args[key]);
1497
- return match;
1498
- });
1499
- }
1500
- function resolveStepRefs(input, results) {
1501
- return input.replace(/\$([A-Za-z0-9_-]+)\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)/g, (match, id, pathValue) => {
1502
- if (!(id in results)) {
1503
- return match;
1504
- }
1505
- const refValue = getStepRefValue({ id, path: pathValue }, results, false);
1506
- if (refValue === undefined) {
1507
- if (pathValue === "approved" || pathValue === "skipped")
1508
- return "false";
1509
- return "";
1510
- }
1511
- return renderTemplateValue(refValue);
1512
- });
1513
- }
1514
- function parseStepRef(value) {
1515
- const match = value.match(/^\$([A-Za-z0-9_-]+)\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)$/);
1516
- if (!match)
1517
- return null;
1518
- return { id: match[1], path: match[2] };
1519
- }
1520
- function getStepRefValue(ref, results, strict) {
1521
- const step = results[ref.id];
1522
- if (!step) {
1523
- if (strict)
1524
- throw new Error(`Unknown step reference: ${ref.id}.${ref.path}`);
1525
- return undefined;
1526
- }
1527
- return getValueByPath(step, ref.path);
1528
- }
1529
- function evaluateCondition(condition, results) {
1530
- if (condition === undefined || condition === null)
1531
- return true;
1532
- if (typeof condition === "boolean")
1533
- return condition;
1534
- if (typeof condition !== "string")
1535
- throw new Error("Unsupported condition type");
1536
- const trimmed = condition.trim();
1537
- if (trimmed === "true")
1538
- return true;
1539
- if (trimmed === "false")
1540
- return false;
1541
- return evaluateConditionExpression(trimmed, results);
1542
- }
1543
- function isApprovalStep(approval) {
1544
- if (approval === true)
1545
- return true;
1546
- if (typeof approval === "string" && approval.trim().length > 0)
1547
- return true;
1548
- if (approval && typeof approval === "object" && !Array.isArray(approval))
1549
- return true;
1550
- return false;
1551
- }
1552
- function isInputStep(input) {
1553
- return Boolean(input && typeof input === "object" && !Array.isArray(input));
1554
- }
1555
1323
  function extractApprovalRequest(step, result, env) {
1556
1324
  const approvalConfig = normalizeApprovalConfig(step.approval);
1557
1325
  const configIdentity = approvalIdentityFromRaw(approvalConfig);
@@ -1671,21 +1439,226 @@ function parseBoolLike(value) {
1671
1439
  return false;
1672
1440
  return undefined;
1673
1441
  }
1674
- function trackStepCost(costTracker, stepId, result) {
1675
- const json = result.json;
1442
+ // The objects a step's pipeline produced, held apart from the serializable step result.
1443
+ // A rendered pipeline such as `llm.invoke | json` prints its items and returns an empty
1444
+ // stream, so the step's `json` is parsed back out of stdout and only carries what JSON can
1445
+ // express. A symbol key survives neither `JSON.stringify` nor `JSON.parse`, so this holds
1446
+ // exactly the items this process made, and no workflow input can put one here.
1447
+ const PIPELINE_SOURCE_ITEMS = Symbol("lobster.workflow.pipelineSourceItems");
1448
+ function attachPipelineSourceItems(target, items) {
1449
+ Object.defineProperty(target, PIPELINE_SOURCE_ITEMS, {
1450
+ value: items,
1451
+ enumerable: false,
1452
+ configurable: true,
1453
+ });
1454
+ return target;
1455
+ }
1456
+ function pipelineSourceItems(target) {
1457
+ if (!target)
1458
+ return undefined;
1459
+ const items = target[PIPELINE_SOURCE_ITEMS];
1460
+ return Array.isArray(items) ? items : undefined;
1461
+ }
1462
+ // The ledger of the run that invoked this one as a `workflow:` step. A composed run bills its
1463
+ // own steps and the composing run bills the output handed back to it, so a live call has to be
1464
+ // open in both ledgers for each of them to count it once. Read defensively: this arrives
1465
+ // through the same context object a caller supplies.
1466
+ function composingSpendLedger(ctx) {
1467
+ const ledger = ctx._llmSpendLedger;
1468
+ if (!ledger || typeof ledger !== "object")
1469
+ return null;
1470
+ return typeof ledger.record === "function" && typeof ledger.claim === "function" ? ledger : null;
1471
+ }
1472
+ /**
1473
+ * One `wait: "any"` branch's view of the run ledger. The race returns as soon as a branch
1474
+ * answers and leaves the others running, so a losing branch can pay a provider long after the
1475
+ * step settled. Writing that charge straight to the run ledger makes the workflow's own total
1476
+ * depend on how long the run happened to live afterwards: land before the run ends and the
1477
+ * discarded branch's spend is billed, land after and it is billed nowhere.
1478
+ *
1479
+ * So a branch opens its charges here instead, and only the winner releases them into the run.
1480
+ * The output of a losing branch is thrown away; its accounting goes with it. Reads pass
1481
+ * through, because they settle charges the run opened before the race and are made by the
1482
+ * workflow after a branch has returned, never from inside one.
1483
+ */
1484
+ function branchSpendBuffer(ledger) {
1485
+ const held = [];
1486
+ let released = false;
1487
+ return {
1488
+ record: (cacheKey, charge) => {
1489
+ if (released)
1490
+ ledger.record(cacheKey, charge);
1491
+ else
1492
+ held.push({ cacheKey, charge });
1493
+ },
1494
+ claim: (cacheKey, cost) => ledger.claim(cacheKey, cost),
1495
+ billCopy: (cacheKey, model, usage) => ledger.billCopy(cacheKey, model, usage),
1496
+ outstanding: () => ledger.outstanding(),
1497
+ restore: (charges) => ledger.restore(charges),
1498
+ settled: () => ledger.settled(),
1499
+ restoreSettled: (charges) => ledger.restoreSettled(charges),
1500
+ release: () => {
1501
+ if (released)
1502
+ return;
1503
+ released = true;
1504
+ for (const { cacheKey, charge } of held)
1505
+ ledger.record(cacheKey, charge);
1506
+ held.length = 0;
1507
+ },
1508
+ };
1509
+ }
1510
+ /**
1511
+ * The run ledger seen by one step, so a charge opened inside it knows where it came from. A
1512
+ * composed run's charges reach the composing run through this too, which is why the step id is
1513
+ * overwritten rather than kept: in the parent's totals the spend belongs to the `workflow:`
1514
+ * step, not to whatever the child called its own.
1515
+ */
1516
+ function stepScopedLedger(ledger, stepId) {
1517
+ return {
1518
+ record: (cacheKey, charge) => ledger.record(cacheKey, { ...charge, stepId }),
1519
+ claim: (cacheKey, cost) => ledger.claim(cacheKey, cost),
1520
+ billCopy: (cacheKey, model, usage) => ledger.billCopy(cacheKey, model, usage),
1521
+ outstanding: () => ledger.outstanding(),
1522
+ restore: (charges) => ledger.restore(charges),
1523
+ settled: () => ledger.settled(),
1524
+ restoreSettled: (charges) => ledger.restoreSettled(charges),
1525
+ };
1526
+ }
1527
+ /**
1528
+ * Bills the calls that ended the run unclaimed. An item is the usual carrier of usage, but a
1529
+ * renderer can consume the pipeline before a later stage emits items of its own, an `ask` gate
1530
+ * can swallow the only item, and a composed run can end on a step that carries none. The
1531
+ * charge outlives all three, and dropping it would leave real spend out of `_meta.cost` and
1532
+ * out of `cost_limit`.
1533
+ */
1534
+ function settleUnbilledCharges(costTracker, llmSpendLedger) {
1535
+ for (const charge of llmSpendLedger.outstanding()) {
1536
+ if (!charge.usage)
1537
+ continue;
1538
+ if (!llmSpendLedger.claim(charge.cacheKey))
1539
+ continue;
1540
+ costTracker.recordUsage(charge.stepId ?? "llm", charge.model ?? null, charge.usage);
1541
+ }
1542
+ }
1543
+ function trackStepCost(costTracker, stepId, result, llmSpendLedger, deferredCharges) {
1544
+ // Bill the items the pipeline produced rather than the JSON re-read from its stdout: a
1545
+ // renderer serializes them, and replay provenance does not survive that round trip. Steps
1546
+ // without a pipeline (shell commands) have no such items and are read from `json` as before.
1547
+ const json = pipelineSourceItems(result) ?? result.json;
1676
1548
  if (!json || typeof json !== "object")
1677
1549
  return;
1550
+ // Replays wait for the live results accounted with them. Branches of one parallel step are
1551
+ // billed in declaration order, so a cached replay declared before an identical `--refresh`
1552
+ // branch would otherwise claim the charge that branch had just opened — and the live branch,
1553
+ // billed whatever its own claim returns, would be billed for it too. One provider call, two
1554
+ // charges, and a `cost_limit` tripped on spend that never happened.
1555
+ const deferred = deferredCharges ?? [];
1678
1556
  const items = Array.isArray(json) ? json : [json];
1679
1557
  for (const item of items) {
1680
1558
  if (!item || typeof item !== "object")
1681
1559
  continue;
1682
- const usage = item.usage;
1560
+ const record = item;
1561
+ const usage = record.usage;
1683
1562
  if (!usage || typeof usage !== "object")
1684
1563
  continue;
1685
- const modelValue = item.model;
1564
+ const modelValue = record.model;
1686
1565
  const model = typeof modelValue === "string" ? modelValue : null;
1566
+ // Provenance the LLM commands attach in-process, never a field in the JSON: a step that
1567
+ // prints a replay-shaped object stays billed, so `cost_limit` cannot be evaded. A
1568
+ // projection can drop the item's own mark while carrying its usage record across, so the
1569
+ // record is read too — both marks are symbol keys built in this process.
1570
+ const provenance = llmProvenanceOf(record) ?? llmProvenanceOf(usage);
1571
+ if (provenance) {
1572
+ if (provenance.replayed) {
1573
+ deferred.push({
1574
+ kind: "replay",
1575
+ stepId,
1576
+ cacheKey: provenance.cacheKey,
1577
+ model,
1578
+ usage: usage,
1579
+ });
1580
+ continue;
1581
+ }
1582
+ // Settles the charge this live call opened -- the one that cost what this item cost,
1583
+ // not merely the first under the key. A step retried after an attempt that failed
1584
+ // after paying leaves more than one charge open, and they did not cost the same.
1585
+ const settled = llmSpendLedger.claim(provenance.cacheKey, {
1586
+ model,
1587
+ usage: usage,
1588
+ });
1589
+ // Bill what the provider was really asked for, not what the item says by the time it
1590
+ // gets here. A pipeline can drop the model on the way -- `| pick usage` hands on the
1591
+ // marked usage record without it -- or rewrite it, and the step is then priced at the
1592
+ // wrong rate or at nothing at all while `cost_limit` reads the difference as room left.
1593
+ // The charge the call opened is the same witness a replay is already billed from. A
1594
+ // charge restored from resume state written before it carried a cost falls back.
1595
+ if (settled?.usage) {
1596
+ costTracker.recordUsage(stepId, settled.model ?? null, settled.usage);
1597
+ markLlmProvenanceReplayed(record);
1598
+ markLlmProvenanceReplayed(usage);
1599
+ continue;
1600
+ }
1601
+ }
1602
+ else {
1603
+ // No mark, but numbers: a stage that JSON round-trips the stream hands on a copy that
1604
+ // kept `model` and `usage` and lost everything this process attached -- sometimes the
1605
+ // cache key with it. Held back like a replay, and for the same reason: a copy that
1606
+ // lost its key is read by cost alone, and until the marked results have settled their
1607
+ // own charges an identical parallel call's charge is still open to be mistaken for
1608
+ // the one this copy came from.
1609
+ deferred.push({
1610
+ kind: "copy",
1611
+ stepId,
1612
+ cacheKey: typeof record.cacheKey === "string" && record.cacheKey ? record.cacheKey : null,
1613
+ model,
1614
+ usage: usage,
1615
+ });
1616
+ continue;
1617
+ }
1687
1618
  costTracker.recordUsage(stepId, model, usage);
1619
+ markLlmProvenanceReplayed(record);
1620
+ markLlmProvenanceReplayed(usage);
1621
+ }
1622
+ if (!deferredCharges)
1623
+ settleDeferredCosts(costTracker, deferred, llmSpendLedger);
1624
+ }
1625
+ /**
1626
+ * Bills what a step held back, now that every marked live result accounted alongside them has
1627
+ * settled its own charge. Replays go first: one carries the mark of the call it stands in for,
1628
+ * which is better evidence than the cost a copy carries on its own.
1629
+ *
1630
+ * A replay carries the usage of the call that produced it, so counting it again charges a run
1631
+ * for tokens no provider was asked to spend. It is billed only when the call it replays is
1632
+ * still unpaid for: a step is costed once it succeeds, so a step that failed after its LLM
1633
+ * call and was retried leaves its real spend riding on the replay the retry produced.
1634
+ *
1635
+ * A copy is billed either way — it is a record of a call, and nothing in a JSON payload can be
1636
+ * trusted to say otherwise — but the ledger still decides whether it settles a charge, so the
1637
+ * step settlement does not add a second record of the same call.
1638
+ */
1639
+ function settleDeferredCosts(costTracker, deferred, llmSpendLedger) {
1640
+ for (const replay of deferred) {
1641
+ if (replay.kind !== "replay" || replay.cacheKey === null)
1642
+ continue;
1643
+ const settled = llmSpendLedger.claim(replay.cacheKey);
1644
+ if (!settled)
1645
+ continue;
1646
+ // Bill what the call this settles actually cost, not what the replay carries. Identical
1647
+ // calls that raced on a cold cache are separate charges under one key, and every replay
1648
+ // of them repeats whichever single answer was stored -- so costing each replay by its
1649
+ // own copy reports all of those calls at the price of the one that won the cache write.
1650
+ // A charge restored from resume state written before it carried a cost falls back.
1651
+ const cost = typeof settled === "object" && settled.usage ? settled : null;
1652
+ costTracker.recordUsage(replay.stepId, cost ? (cost.model ?? null) : replay.model, cost?.usage ?? replay.usage);
1653
+ }
1654
+ for (const copy of deferred) {
1655
+ if (copy.kind !== "copy")
1656
+ continue;
1657
+ if (llmSpendLedger.billCopy(copy.cacheKey, copy.model, copy.usage)) {
1658
+ costTracker.recordUsage(copy.stepId, copy.model, copy.usage);
1659
+ }
1688
1660
  }
1661
+ deferred.length = 0;
1689
1662
  }
1690
1663
  function parseJson(stdout) {
1691
1664
  const trimmed = stdout.trim();
@@ -1713,7 +1686,7 @@ function toOutputItems(result) {
1713
1686
  return [];
1714
1687
  }
1715
1688
  function cloneResults(results) {
1716
- const out = {};
1689
+ const out = createRecord();
1717
1690
  for (const [key, value] of Object.entries(results)) {
1718
1691
  out[key] = { ...value };
1719
1692
  }
@@ -1852,346 +1825,24 @@ function estimateSerializedBytes(value) {
1852
1825
  return Number.POSITIVE_INFINITY;
1853
1826
  }
1854
1827
  }
1855
- function renderTemplateValue(value) {
1856
- if (value === undefined || value === null)
1857
- return "";
1858
- if (typeof value === "string")
1859
- return value;
1860
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
1861
- return String(value);
1862
- }
1863
- try {
1864
- return JSON.stringify(value);
1865
- }
1866
- catch {
1867
- return String(value);
1868
- }
1869
- }
1870
- function getValueByPath(value, pathValue) {
1871
- const fields = pathValue.split(".");
1872
- let current = value;
1873
- for (const field of fields) {
1874
- if (current === null || current === undefined)
1875
- return undefined;
1876
- if (Array.isArray(current)) {
1877
- const idx = Number(field);
1878
- if (!Number.isInteger(idx) || idx < 0 || idx >= current.length)
1879
- return undefined;
1880
- current = current[idx];
1881
- continue;
1882
- }
1883
- if (typeof current !== "object")
1884
- return undefined;
1885
- current = current[field];
1886
- }
1887
- return current;
1888
- }
1889
- function evaluateConditionExpression(expression, results) {
1890
- const tokens = tokenizeCondition(expression);
1891
- if (tokens.length === 0) {
1892
- throw new Error(`Unsupported condition: ${expression}`);
1893
- }
1894
- let index = 0;
1895
- function parseOr() {
1896
- let left = parseAnd();
1897
- while (match("or")) {
1898
- const right = parseAnd();
1899
- left = Boolean(left) || Boolean(right);
1900
- }
1901
- return left;
1902
- }
1903
- function parseAnd() {
1904
- let left = parseEquality();
1905
- while (match("and")) {
1906
- const right = parseEquality();
1907
- left = Boolean(left) && Boolean(right);
1908
- }
1909
- return left;
1910
- }
1911
- function parseEquality() {
1912
- const left = parseUnary(false);
1913
- if (match("eq")) {
1914
- return compareConditionValues(left, parseUnary(true));
1915
- }
1916
- if (match("neq")) {
1917
- return !compareConditionValues(left, parseUnary(true));
1918
- }
1919
- if (match("lt")) {
1920
- return numericCompare(left, parseUnary(true), (a, b) => a < b);
1921
- }
1922
- if (match("lte")) {
1923
- return numericCompare(left, parseUnary(true), (a, b) => a <= b);
1924
- }
1925
- if (match("gt")) {
1926
- return numericCompare(left, parseUnary(true), (a, b) => a > b);
1927
- }
1928
- if (match("gte")) {
1929
- return numericCompare(left, parseUnary(true), (a, b) => a >= b);
1930
- }
1931
- return left;
1932
- }
1933
- function parseUnary(allowBareIdentifier) {
1934
- if (match("not")) {
1935
- return !Boolean(parseUnary(allowBareIdentifier));
1936
- }
1937
- return parsePrimary(allowBareIdentifier);
1938
- }
1939
- function parsePrimary(allowBareIdentifier) {
1940
- const token = tokens[index];
1941
- if (!token) {
1942
- throw new Error(`Unsupported condition: ${expression}`);
1943
- }
1944
- index += 1;
1945
- if (token.type === "lparen") {
1946
- const value = parseOr();
1947
- expect("rparen");
1948
- return value;
1949
- }
1950
- if (token.type === "step_ref") {
1951
- return getStepRefValue(token.value, results, true);
1952
- }
1953
- if (token.type === "string" ||
1954
- token.type === "number" ||
1955
- token.type === "boolean" ||
1956
- token.type === "null") {
1957
- return token.value;
1958
- }
1959
- if (token.type === "identifier" && allowBareIdentifier) {
1960
- return token.value;
1961
- }
1962
- throw new Error(`Unsupported condition: ${expression}`);
1963
- }
1964
- function match(type) {
1965
- if (tokens[index]?.type !== type)
1966
- return false;
1967
- index += 1;
1968
- return true;
1969
- }
1970
- function expect(type) {
1971
- if (!match(type)) {
1972
- throw new Error(`Unsupported condition: ${expression}`);
1973
- }
1974
- }
1975
- const value = parseOr();
1976
- if (index !== tokens.length) {
1977
- throw new Error(`Unsupported condition: ${expression}`);
1978
- }
1979
- return Boolean(value);
1980
- }
1981
- function compareConditionValues(left, right) {
1982
- if (Array.isArray(left) ||
1983
- Array.isArray(right) ||
1984
- isPlainConditionObject(left) ||
1985
- isPlainConditionObject(right)) {
1986
- return isDeepStrictEqual(left, right);
1987
- }
1988
- return Object.is(left, right);
1989
- }
1990
- function isStrictlyNumeric(value) {
1991
- if (typeof value === "number")
1992
- return !Number.isNaN(value);
1993
- if (typeof value === "string")
1994
- return value.trim() !== "" && !Number.isNaN(Number(value));
1995
- return false;
1996
- }
1997
- function numericCompare(left, right, cmp) {
1998
- if (!isStrictlyNumeric(left) || !isStrictlyNumeric(right))
1999
- return false;
2000
- return cmp(Number(left), Number(right));
2001
- }
2002
- function isPlainConditionObject(value) {
2003
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2004
- }
2005
- function tokenizeCondition(expression) {
2006
- const tokens = [];
2007
- let index = 0;
2008
- while (index < expression.length) {
2009
- const ch = expression[index];
2010
- if (/\s/.test(ch)) {
2011
- index += 1;
2012
- continue;
2013
- }
2014
- if (ch === "(") {
2015
- tokens.push({ type: "lparen" });
2016
- index += 1;
2017
- continue;
2018
- }
2019
- if (ch === ")") {
2020
- tokens.push({ type: "rparen" });
2021
- index += 1;
2022
- continue;
2023
- }
2024
- if (expression.startsWith("&&", index)) {
2025
- tokens.push({ type: "and" });
2026
- index += 2;
2027
- continue;
2028
- }
2029
- if (expression.startsWith("||", index)) {
2030
- tokens.push({ type: "or" });
2031
- index += 2;
2032
- continue;
2033
- }
2034
- if (expression.startsWith("==", index)) {
2035
- tokens.push({ type: "eq" });
2036
- index += 2;
2037
- continue;
2038
- }
2039
- if (expression.startsWith("!=", index)) {
2040
- tokens.push({ type: "neq" });
2041
- index += 2;
2042
- continue;
2043
- }
2044
- if (expression.startsWith("<=", index)) {
2045
- tokens.push({ type: "lte" });
2046
- index += 2;
2047
- continue;
2048
- }
2049
- if (expression.startsWith(">=", index)) {
2050
- tokens.push({ type: "gte" });
2051
- index += 2;
2052
- continue;
2053
- }
2054
- if (ch === "<") {
2055
- tokens.push({ type: "lt" });
2056
- index += 1;
2057
- continue;
2058
- }
2059
- if (ch === ">") {
2060
- tokens.push({ type: "gt" });
2061
- index += 1;
2062
- continue;
2063
- }
2064
- if (ch === "!") {
2065
- tokens.push({ type: "not" });
2066
- index += 1;
2067
- continue;
2068
- }
2069
- if (ch === "$") {
2070
- const matched = matchConditionStepRef(expression, index);
2071
- if (!matched) {
2072
- throw new Error(`Unsupported condition: ${expression}`);
2073
- }
2074
- tokens.push({ type: "step_ref", value: matched.ref });
2075
- index = matched.nextIndex;
2076
- continue;
2077
- }
2078
- if (ch === '"' || ch === "'") {
2079
- const parsed = parseQuotedConditionString(expression, index, ch);
2080
- tokens.push({ type: "string", value: parsed.value });
2081
- index = parsed.nextIndex;
2082
- continue;
2083
- }
2084
- const numberMatch = expression.slice(index).match(/^-?\d+(?:\.\d+)?/);
2085
- if (numberMatch) {
2086
- tokens.push({ type: "number", value: Number(numberMatch[0]) });
2087
- index += numberMatch[0].length;
2088
- continue;
2089
- }
2090
- const identMatch = expression.slice(index).match(/^[A-Za-z_][A-Za-z0-9_-]*/);
2091
- if (identMatch) {
2092
- const raw = identMatch[0];
2093
- if (raw === "true") {
2094
- tokens.push({ type: "boolean", value: true });
2095
- }
2096
- else if (raw === "false") {
2097
- tokens.push({ type: "boolean", value: false });
2098
- }
2099
- else if (raw === "null") {
2100
- tokens.push({ type: "null", value: null });
2101
- }
2102
- else {
2103
- tokens.push({ type: "identifier", value: raw });
2104
- }
2105
- index += raw.length;
2106
- continue;
2107
- }
2108
- throw new Error(`Unsupported condition: ${expression}`);
2109
- }
2110
- return tokens;
2111
- }
2112
- function matchConditionStepRef(expression, startIndex) {
2113
- const match = expression
2114
- .slice(startIndex)
2115
- .match(/^\$([A-Za-z0-9_-]+)\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)/);
2116
- if (!match)
2117
- return null;
2118
- return {
2119
- ref: { id: match[1], path: match[2] },
2120
- nextIndex: startIndex + match[0].length,
2121
- };
2122
- }
2123
- function parseQuotedConditionString(expression, startIndex, quoteChar) {
2124
- let value = "";
2125
- let index = startIndex + 1;
2126
- while (index < expression.length) {
2127
- const ch = expression[index];
2128
- if (ch === "\\") {
2129
- const next = expression[index + 1];
2130
- if (next === undefined)
2131
- break;
2132
- value += next;
2133
- index += 2;
2134
- continue;
2135
- }
2136
- if (ch === quoteChar) {
2137
- return { value, nextIndex: index + 1 };
2138
- }
2139
- value += ch;
2140
- index += 1;
2141
- }
2142
- throw new Error(`Unsupported condition: ${expression}`);
2143
- }
2144
- async function runShellCommand({ command, stdin, env, cwd, signal, killSignal, }) {
2145
- const { spawn } = await import("node:child_process");
2146
- return await new Promise((resolve, reject) => {
2147
- const shell = resolveInlineShellCommand({ command, env });
2148
- const child = spawn(shell.command, shell.argv, {
2149
- env,
2150
- cwd,
2151
- signal,
2152
- killSignal,
2153
- stdio: ["pipe", "pipe", "pipe"],
2154
- });
2155
- let stdout = "";
2156
- let stderr = "";
2157
- child.stdout.setEncoding("utf8");
2158
- child.stderr.setEncoding("utf8");
2159
- child.stdout.on("data", (d) => {
2160
- stdout += d;
2161
- });
2162
- child.stderr.on("data", (d) => {
2163
- stderr += d;
2164
- });
2165
- if (typeof stdin === "string") {
2166
- child.stdin.setDefaultEncoding("utf8");
2167
- child.stdin.write(stdin);
2168
- }
2169
- child.stdin.end();
2170
- child.on("error", reject);
2171
- child.on("close", (code) => {
2172
- if (code === 0)
2173
- return resolve({ stdout, stderr });
2174
- reject(new Error(`workflow command failed (${code}): ${stderr.trim() || stdout.trim() || command}`));
2175
- });
1828
+ async function runShellCommand({ command, stdin, env, cwd, signal, forceTerminationSignal, killSignal, }) {
1829
+ signal?.throwIfAborted();
1830
+ signal?.throwIfAborted();
1831
+ const { stdout, stderr, code } = await runAbortableProcess({
1832
+ shellCommand: command,
1833
+ env,
1834
+ cwd,
1835
+ stdin,
1836
+ signal,
1837
+ forceTerminationSignal,
1838
+ killSignal,
1839
+ notFoundMessage: "workflow shell not found; check LOBSTER_SHELL or ComSpec",
2176
1840
  });
1841
+ if (code === 0)
1842
+ return { stdout, stderr };
1843
+ throw new Error(`workflow command failed (${code}): ${stderr.trim() || stdout.trim() || command}`);
2177
1844
  }
2178
- function getStepExecution(step) {
2179
- if (step.parallel && typeof step.parallel === "object" && !Array.isArray(step.parallel)) {
2180
- return { kind: "parallel", value: step.parallel };
2181
- }
2182
- if (typeof step.workflow === "string" && step.workflow.trim()) {
2183
- return { kind: "workflow", value: step.workflow };
2184
- }
2185
- if (typeof step.pipeline === "string" && step.pipeline.trim()) {
2186
- return { kind: "pipeline", value: step.pipeline };
2187
- }
2188
- const shellCommand = typeof step.run === "string" ? step.run : step.command;
2189
- if (typeof shellCommand === "string" && shellCommand.trim()) {
2190
- return { kind: "shell", value: shellCommand };
2191
- }
2192
- return { kind: "none" };
2193
- }
2194
- async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, env, cwd, resume, requestInputEnabled = true, }) {
1845
+ async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, llmSpendLedger, env, cwd, resume, requestInputEnabled = true, onExecutionStart, }) {
2195
1846
  let pipeline;
2196
1847
  try {
2197
1848
  const currentPipeline = parsePipeline(pipelineText);
@@ -2228,7 +1879,11 @@ async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, env, cwd
2228
1879
  mode: ctx.mode,
2229
1880
  cwd,
2230
1881
  signal: ctx.signal,
1882
+ forceTerminationSignal: ctx.forceTerminationSignal,
1883
+ haltAfterStageOnAbort: true,
2231
1884
  llmAdapters: ctx.llmAdapters,
1885
+ // Scoped so a charge nothing bills can still name the step that opened it.
1886
+ llmSpendLedger: llmSpendLedger ? stepScopedLedger(llmSpendLedger, stepId) : undefined,
2232
1887
  input: resume ? resume.pipelineInput.items : inputValueToPipelineItems(inputValue),
2233
1888
  requestInputEnabled,
2234
1889
  requestInputResume: resume
@@ -2238,8 +1893,11 @@ async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, env, cwd
2238
1893
  onConsumed: resume.onConsumed,
2239
1894
  }
2240
1895
  : undefined,
1896
+ onExecutionStart,
1897
+ onNonRetryableSideEffect: ctx._onNonRetryableSideEffect,
2241
1898
  });
2242
1899
  stdout.end();
1900
+ ctx.signal?.throwIfAborted();
2243
1901
  if (result.halted) {
2244
1902
  const haltedName = result.haltedAt?.stage?.name ?? "unknown";
2245
1903
  if (result.items.length === 1 && result.items[0]?.type === "approval_request") {
@@ -2273,11 +1931,25 @@ async function runPipelineStep({ stepId, pipelineText, inputValue, ctx, env, cwd
2273
1931
  ? result.items[0]
2274
1932
  : result.items
2275
1933
  : parseJson(renderedStdout);
2276
- return {
1934
+ const stepResult = {
2277
1935
  id: stepId,
2278
1936
  stdout: normalizedStdout,
2279
1937
  json,
2280
1938
  };
1939
+ // Only when a renderer consumed the pipeline: otherwise `json` already is those items.
1940
+ if (!result.items.length && result.renderedItems.length) {
1941
+ // Only restore marks from the renderer's own matching objects, never from public JSON fields.
1942
+ const source = Array.isArray(json)
1943
+ ? result.renderedItems
1944
+ : result.renderedItems.length === 1
1945
+ ? result.renderedItems[0]
1946
+ : undefined;
1947
+ if (source !== undefined && stableStringify(source) === stableStringify(json)) {
1948
+ carryLlmProvenance(source, json);
1949
+ }
1950
+ attachPipelineSourceItems(stepResult, result.renderedItems);
1951
+ }
1952
+ return stepResult;
2281
1953
  }
2282
1954
  function createSyntheticStepResult(stepId, value) {
2283
1955
  if (value === null || value === undefined) {
@@ -2317,13 +1989,6 @@ function abortableSleep(ms, signal) {
2317
1989
  signal.addEventListener("abort", onAbort, { once: true });
2318
1990
  });
2319
1991
  }
2320
- function encodeShellInput(value) {
2321
- if (value === null || value === undefined)
2322
- return null;
2323
- if (typeof value === "string")
2324
- return value;
2325
- return JSON.stringify(value);
2326
- }
2327
1992
  function* inputValueToItems(value) {
2328
1993
  if (value === null || value === undefined)
2329
1994
  return;