@shipfox/api-workflows 2.0.0

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 (661) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +311 -0
  5. package/LICENSE +21 -0
  6. package/dist/core/agent-tool-capability-warning.d.ts +7 -0
  7. package/dist/core/agent-tool-capability-warning.d.ts.map +1 -0
  8. package/dist/core/agent-tool-capability-warning.js +149 -0
  9. package/dist/core/agent-tool-capability-warning.js.map +1 -0
  10. package/dist/core/agent-tools.d.ts +51 -0
  11. package/dist/core/agent-tools.d.ts.map +1 -0
  12. package/dist/core/agent-tools.js +296 -0
  13. package/dist/core/agent-tools.js.map +1 -0
  14. package/dist/core/checkout.d.ts +29 -0
  15. package/dist/core/checkout.d.ts.map +1 -0
  16. package/dist/core/checkout.js +42 -0
  17. package/dist/core/checkout.js.map +1 -0
  18. package/dist/core/condition-trace.d.ts +13 -0
  19. package/dist/core/condition-trace.d.ts.map +1 -0
  20. package/dist/core/condition-trace.js +51 -0
  21. package/dist/core/condition-trace.js.map +1 -0
  22. package/dist/core/entities/index.d.ts +5 -0
  23. package/dist/core/entities/index.d.ts.map +1 -0
  24. package/dist/core/entities/index.js +3 -0
  25. package/dist/core/entities/index.js.map +1 -0
  26. package/dist/core/entities/job-execution.d.ts +30 -0
  27. package/dist/core/entities/job-execution.d.ts.map +1 -0
  28. package/dist/core/entities/job-execution.js +3 -0
  29. package/dist/core/entities/job-execution.js.map +1 -0
  30. package/dist/core/entities/job-listener-event.d.ts +15 -0
  31. package/dist/core/entities/job-listener-event.d.ts.map +1 -0
  32. package/dist/core/entities/job-listener-event.js +3 -0
  33. package/dist/core/entities/job-listener-event.js.map +1 -0
  34. package/dist/core/entities/job.d.ts +54 -0
  35. package/dist/core/entities/job.d.ts.map +1 -0
  36. package/dist/core/entities/job.js +29 -0
  37. package/dist/core/entities/job.js.map +1 -0
  38. package/dist/core/entities/step.d.ts +86 -0
  39. package/dist/core/entities/step.d.ts.map +1 -0
  40. package/dist/core/entities/step.js +12 -0
  41. package/dist/core/entities/step.js.map +1 -0
  42. package/dist/core/entities/workflow-run-attempt.d.ts +20 -0
  43. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -0
  44. package/dist/core/entities/workflow-run-attempt.js +3 -0
  45. package/dist/core/entities/workflow-run-attempt.js.map +1 -0
  46. package/dist/core/entities/workflow-run.d.ts +66 -0
  47. package/dist/core/entities/workflow-run.d.ts.map +1 -0
  48. package/dist/core/entities/workflow-run.js +10 -0
  49. package/dist/core/entities/workflow-run.js.map +1 -0
  50. package/dist/core/errors.d.ts +85 -0
  51. package/dist/core/errors.d.ts.map +1 -0
  52. package/dist/core/errors.js +134 -0
  53. package/dist/core/errors.js.map +1 -0
  54. package/dist/core/index.d.ts +14 -0
  55. package/dist/core/index.d.ts.map +1 -0
  56. package/dist/core/index.js +9 -0
  57. package/dist/core/index.js.map +1 -0
  58. package/dist/core/job-execution-outcome.d.ts +29 -0
  59. package/dist/core/job-execution-outcome.d.ts.map +1 -0
  60. package/dist/core/job-execution-outcome.js +31 -0
  61. package/dist/core/job-execution-outcome.js.map +1 -0
  62. package/dist/core/job-execution.d.ts +37 -0
  63. package/dist/core/job-execution.d.ts.map +1 -0
  64. package/dist/core/job-execution.js +425 -0
  65. package/dist/core/job-execution.js.map +1 -0
  66. package/dist/core/job-transition/decide-job-activation.d.ts +29 -0
  67. package/dist/core/job-transition/decide-job-activation.d.ts.map +1 -0
  68. package/dist/core/job-transition/decide-job-activation.js +51 -0
  69. package/dist/core/job-transition/decide-job-activation.js.map +1 -0
  70. package/dist/core/job-transition/derive-job-execution-outputs.d.ts +18 -0
  71. package/dist/core/job-transition/derive-job-execution-outputs.d.ts.map +1 -0
  72. package/dist/core/job-transition/derive-job-execution-outputs.js +23 -0
  73. package/dist/core/job-transition/derive-job-execution-outputs.js.map +1 -0
  74. package/dist/core/job-transition/derive-job-success.d.ts +16 -0
  75. package/dist/core/job-transition/derive-job-success.d.ts.map +1 -0
  76. package/dist/core/job-transition/derive-job-success.js +47 -0
  77. package/dist/core/job-transition/derive-job-success.js.map +1 -0
  78. package/dist/core/job-transition/index.d.ts +5 -0
  79. package/dist/core/job-transition/index.d.ts.map +1 -0
  80. package/dist/core/job-transition/index.js +6 -0
  81. package/dist/core/job-transition/index.js.map +1 -0
  82. package/dist/core/job-transition/runtime-completion-status-for-job.d.ts +4 -0
  83. package/dist/core/job-transition/runtime-completion-status-for-job.d.ts.map +1 -0
  84. package/dist/core/job-transition/runtime-completion-status-for-job.js +8 -0
  85. package/dist/core/job-transition/runtime-completion-status-for-job.js.map +1 -0
  86. package/dist/core/listener-execution-materialization.d.ts +30 -0
  87. package/dist/core/listener-execution-materialization.d.ts.map +1 -0
  88. package/dist/core/listener-execution-materialization.js +87 -0
  89. package/dist/core/listener-execution-materialization.js.map +1 -0
  90. package/dist/core/run-workflow.d.ts +11 -0
  91. package/dist/core/run-workflow.d.ts.map +1 -0
  92. package/dist/core/run-workflow.js +28 -0
  93. package/dist/core/run-workflow.js.map +1 -0
  94. package/dist/core/source-control.d.ts +5 -0
  95. package/dist/core/source-control.d.ts.map +1 -0
  96. package/dist/core/source-control.js +19 -0
  97. package/dist/core/source-control.js.map +1 -0
  98. package/dist/core/step-config/agent.d.ts +49 -0
  99. package/dist/core/step-config/agent.d.ts.map +1 -0
  100. package/dist/core/step-config/agent.js +337 -0
  101. package/dist/core/step-config/agent.js.map +1 -0
  102. package/dist/core/step-config/assemble-run-context.d.ts +94 -0
  103. package/dist/core/step-config/assemble-run-context.d.ts.map +1 -0
  104. package/dist/core/step-config/assemble-run-context.js +393 -0
  105. package/dist/core/step-config/assemble-run-context.js.map +1 -0
  106. package/dist/core/step-config/complete-step-dispatch-config.d.ts +13 -0
  107. package/dist/core/step-config/complete-step-dispatch-config.d.ts.map +1 -0
  108. package/dist/core/step-config/complete-step-dispatch-config.js +38 -0
  109. package/dist/core/step-config/complete-step-dispatch-config.js.map +1 -0
  110. package/dist/core/step-config/fields.d.ts +38 -0
  111. package/dist/core/step-config/fields.d.ts.map +1 -0
  112. package/dist/core/step-config/fields.js +72 -0
  113. package/dist/core/step-config/fields.js.map +1 -0
  114. package/dist/core/step-config/index.d.ts +8 -0
  115. package/dist/core/step-config/index.d.ts.map +1 -0
  116. package/dist/core/step-config/index.js +7 -0
  117. package/dist/core/step-config/index.js.map +1 -0
  118. package/dist/core/step-config/materialize-job-execution-steps.d.ts +35 -0
  119. package/dist/core/step-config/materialize-job-execution-steps.d.ts.map +1 -0
  120. package/dist/core/step-config/materialize-job-execution-steps.js +92 -0
  121. package/dist/core/step-config/materialize-job-execution-steps.js.map +1 -0
  122. package/dist/core/step-config/materialize-workflow-model.d.ts +42 -0
  123. package/dist/core/step-config/materialize-workflow-model.d.ts.map +1 -0
  124. package/dist/core/step-config/materialize-workflow-model.js +96 -0
  125. package/dist/core/step-config/materialize-workflow-model.js.map +1 -0
  126. package/dist/core/step-config/resolve-job-execution-name.d.ts +18 -0
  127. package/dist/core/step-config/resolve-job-execution-name.d.ts.map +1 -0
  128. package/dist/core/step-config/resolve-job-execution-name.js +36 -0
  129. package/dist/core/step-config/resolve-job-execution-name.js.map +1 -0
  130. package/dist/core/step-config/resolve-step-config.d.ts +33 -0
  131. package/dist/core/step-config/resolve-step-config.d.ts.map +1 -0
  132. package/dist/core/step-config/resolve-step-config.js +164 -0
  133. package/dist/core/step-config/resolve-step-config.js.map +1 -0
  134. package/dist/core/step-config/run.d.ts +39 -0
  135. package/dist/core/step-config/run.d.ts.map +1 -0
  136. package/dist/core/step-config/run.js +360 -0
  137. package/dist/core/step-config/run.js.map +1 -0
  138. package/dist/core/step-config/workflow-evaluation-context.d.ts +6 -0
  139. package/dist/core/step-config/workflow-evaluation-context.d.ts.map +1 -0
  140. package/dist/core/step-config/workflow-evaluation-context.js +3 -0
  141. package/dist/core/step-config/workflow-evaluation-context.js.map +1 -0
  142. package/dist/core/step-transition/apply-step-transition.d.ts +26 -0
  143. package/dist/core/step-transition/apply-step-transition.d.ts.map +1 -0
  144. package/dist/core/step-transition/apply-step-transition.js +141 -0
  145. package/dist/core/step-transition/apply-step-transition.js.map +1 -0
  146. package/dist/core/step-transition/decide-step-transition.d.ts +71 -0
  147. package/dist/core/step-transition/decide-step-transition.d.ts.map +1 -0
  148. package/dist/core/step-transition/decide-step-transition.js +110 -0
  149. package/dist/core/step-transition/decide-step-transition.js.map +1 -0
  150. package/dist/core/step-transition/evaluate-gate.d.ts +28 -0
  151. package/dist/core/step-transition/evaluate-gate.d.ts.map +1 -0
  152. package/dist/core/step-transition/evaluate-gate.js +159 -0
  153. package/dist/core/step-transition/evaluate-gate.js.map +1 -0
  154. package/dist/core/step-transition/read-step-outputs.d.ts +3 -0
  155. package/dist/core/step-transition/read-step-outputs.d.ts.map +1 -0
  156. package/dist/core/step-transition/read-step-outputs.js +24 -0
  157. package/dist/core/step-transition/read-step-outputs.js.map +1 -0
  158. package/dist/core/workflow-run-creation.d.ts +43 -0
  159. package/dist/core/workflow-run-creation.d.ts.map +1 -0
  160. package/dist/core/workflow-run-creation.js +80 -0
  161. package/dist/core/workflow-run-creation.js.map +1 -0
  162. package/dist/core/workflow-scheduling/index.d.ts +5 -0
  163. package/dist/core/workflow-scheduling/index.d.ts.map +1 -0
  164. package/dist/core/workflow-scheduling/index.js +4 -0
  165. package/dist/core/workflow-scheduling/index.js.map +1 -0
  166. package/dist/core/workflow-scheduling/run-progress.d.ts +20 -0
  167. package/dist/core/workflow-scheduling/run-progress.d.ts.map +1 -0
  168. package/dist/core/workflow-scheduling/run-progress.js +40 -0
  169. package/dist/core/workflow-scheduling/run-progress.js.map +1 -0
  170. package/dist/core/workflow-scheduling/runtime-dag.d.ts +10 -0
  171. package/dist/core/workflow-scheduling/runtime-dag.d.ts.map +1 -0
  172. package/dist/core/workflow-scheduling/runtime-dag.js +3 -0
  173. package/dist/core/workflow-scheduling/runtime-dag.js.map +1 -0
  174. package/dist/core/workflow-scheduling/runtime-scheduling-command.d.ts +16 -0
  175. package/dist/core/workflow-scheduling/runtime-scheduling-command.d.ts.map +1 -0
  176. package/dist/core/workflow-scheduling/runtime-scheduling-command.js +3 -0
  177. package/dist/core/workflow-scheduling/runtime-scheduling-command.js.map +1 -0
  178. package/dist/core/workflow-scheduling/schedule-runtime-dag.d.ts +9 -0
  179. package/dist/core/workflow-scheduling/schedule-runtime-dag.d.ts.map +1 -0
  180. package/dist/core/workflow-scheduling/schedule-runtime-dag.js +72 -0
  181. package/dist/core/workflow-scheduling/schedule-runtime-dag.js.map +1 -0
  182. package/dist/db/db.d.ts +4959 -0
  183. package/dist/db/db.d.ts.map +1 -0
  184. package/dist/db/db.js +37 -0
  185. package/dist/db/db.js.map +1 -0
  186. package/dist/db/index.d.ts +9 -0
  187. package/dist/db/index.d.ts.map +1 -0
  188. package/dist/db/index.js +10 -0
  189. package/dist/db/index.js.map +1 -0
  190. package/dist/db/job-listener-events.d.ts +18 -0
  191. package/dist/db/job-listener-events.d.ts.map +1 -0
  192. package/dist/db/job-listener-events.js +59 -0
  193. package/dist/db/job-listener-events.js.map +1 -0
  194. package/dist/db/job-listeners.d.ts +55 -0
  195. package/dist/db/job-listeners.d.ts.map +1 -0
  196. package/dist/db/job-listeners.js +358 -0
  197. package/dist/db/job-listeners.js.map +1 -0
  198. package/dist/db/outbox-writes.d.ts +12 -0
  199. package/dist/db/outbox-writes.d.ts.map +1 -0
  200. package/dist/db/outbox-writes.js +20 -0
  201. package/dist/db/outbox-writes.js.map +1 -0
  202. package/dist/db/runner-lease-table.d.ts +469 -0
  203. package/dist/db/runner-lease-table.d.ts.map +1 -0
  204. package/dist/db/runner-lease-table.js +42 -0
  205. package/dist/db/runner-lease-table.js.map +1 -0
  206. package/dist/db/schema/common.d.ts +2 -0
  207. package/dist/db/schema/common.d.ts.map +1 -0
  208. package/dist/db/schema/common.js +4 -0
  209. package/dist/db/schema/common.js.map +1 -0
  210. package/dist/db/schema/job-executions.d.ts +311 -0
  211. package/dist/db/schema/job-executions.d.ts.map +1 -0
  212. package/dist/db/schema/job-executions.js +76 -0
  213. package/dist/db/schema/job-executions.js.map +1 -0
  214. package/dist/db/schema/job-listener-events.d.ts +200 -0
  215. package/dist/db/schema/job-listener-events.d.ts.map +1 -0
  216. package/dist/db/schema/job-listener-events.js +50 -0
  217. package/dist/db/schema/job-listener-events.js.map +1 -0
  218. package/dist/db/schema/jobs.d.ts +542 -0
  219. package/dist/db/schema/jobs.d.ts.map +1 -0
  220. package/dist/db/schema/jobs.js +121 -0
  221. package/dist/db/schema/jobs.js.map +1 -0
  222. package/dist/db/schema/outbox.d.ts +195 -0
  223. package/dist/db/schema/outbox.d.ts.map +1 -0
  224. package/dist/db/schema/outbox.js +5 -0
  225. package/dist/db/schema/outbox.js.map +1 -0
  226. package/dist/db/schema/step-attempts.d.ts +330 -0
  227. package/dist/db/schema/step-attempts.d.ts.map +1 -0
  228. package/dist/db/schema/step-attempts.js +80 -0
  229. package/dist/db/schema/step-attempts.js.map +1 -0
  230. package/dist/db/schema/steps.d.ts +352 -0
  231. package/dist/db/schema/steps.d.ts.map +1 -0
  232. package/dist/db/schema/steps.js +80 -0
  233. package/dist/db/schema/steps.js.map +1 -0
  234. package/dist/db/schema/workflow-run-attempts.d.ts +239 -0
  235. package/dist/db/schema/workflow-run-attempts.d.ts.map +1 -0
  236. package/dist/db/schema/workflow-run-attempts.js +53 -0
  237. package/dist/db/schema/workflow-run-attempts.js.map +1 -0
  238. package/dist/db/schema/workflow-runs.d.ts +360 -0
  239. package/dist/db/schema/workflow-runs.d.ts.map +1 -0
  240. package/dist/db/schema/workflow-runs.js +85 -0
  241. package/dist/db/schema/workflow-runs.js.map +1 -0
  242. package/dist/db/workflow-runs/job-executions.d.ts +49 -0
  243. package/dist/db/workflow-runs/job-executions.d.ts.map +1 -0
  244. package/dist/db/workflow-runs/job-executions.js +271 -0
  245. package/dist/db/workflow-runs/job-executions.js.map +1 -0
  246. package/dist/db/workflow-runs/jobs.d.ts +60 -0
  247. package/dist/db/workflow-runs/jobs.d.ts.map +1 -0
  248. package/dist/db/workflow-runs/jobs.js +288 -0
  249. package/dist/db/workflow-runs/jobs.js.map +1 -0
  250. package/dist/db/workflow-runs/outbox.d.ts +20 -0
  251. package/dist/db/workflow-runs/outbox.d.ts.map +1 -0
  252. package/dist/db/workflow-runs/outbox.js +68 -0
  253. package/dist/db/workflow-runs/outbox.js.map +1 -0
  254. package/dist/db/workflow-runs/queries.d.ts +71 -0
  255. package/dist/db/workflow-runs/queries.d.ts.map +1 -0
  256. package/dist/db/workflow-runs/queries.js +253 -0
  257. package/dist/db/workflow-runs/queries.js.map +1 -0
  258. package/dist/db/workflow-runs/run-create.d.ts +32 -0
  259. package/dist/db/workflow-runs/run-create.d.ts.map +1 -0
  260. package/dist/db/workflow-runs/run-create.js +288 -0
  261. package/dist/db/workflow-runs/run-create.js.map +1 -0
  262. package/dist/db/workflow-runs/run-graph.d.ts +27 -0
  263. package/dist/db/workflow-runs/run-graph.d.ts.map +1 -0
  264. package/dist/db/workflow-runs/run-graph.js +63 -0
  265. package/dist/db/workflow-runs/run-graph.js.map +1 -0
  266. package/dist/db/workflow-runs/run-rerun.d.ts +8 -0
  267. package/dist/db/workflow-runs/run-rerun.d.ts.map +1 -0
  268. package/dist/db/workflow-runs/run-rerun.js +207 -0
  269. package/dist/db/workflow-runs/run-rerun.js.map +1 -0
  270. package/dist/db/workflow-runs/run-status.d.ts +24 -0
  271. package/dist/db/workflow-runs/run-status.d.ts.map +1 -0
  272. package/dist/db/workflow-runs/run-status.js +249 -0
  273. package/dist/db/workflow-runs/run-status.js.map +1 -0
  274. package/dist/db/workflow-runs/runs.d.ts +7 -0
  275. package/dist/db/workflow-runs/runs.d.ts.map +1 -0
  276. package/dist/db/workflow-runs/runs.js +5 -0
  277. package/dist/db/workflow-runs/runs.js.map +1 -0
  278. package/dist/db/workflow-runs/shared.d.ts +24 -0
  279. package/dist/db/workflow-runs/shared.d.ts.map +1 -0
  280. package/dist/db/workflow-runs/shared.js +50 -0
  281. package/dist/db/workflow-runs/shared.js.map +1 -0
  282. package/dist/db/workflow-runs/steps.d.ts +83 -0
  283. package/dist/db/workflow-runs/steps.d.ts.map +1 -0
  284. package/dist/db/workflow-runs/steps.js +246 -0
  285. package/dist/db/workflow-runs/steps.js.map +1 -0
  286. package/dist/db/workflow-runs.d.ts +12 -0
  287. package/dist/db/workflow-runs.d.ts.map +1 -0
  288. package/dist/db/workflow-runs.js +8 -0
  289. package/dist/db/workflow-runs.js.map +1 -0
  290. package/dist/index.d.ts +10 -0
  291. package/dist/index.d.ts.map +1 -0
  292. package/dist/index.js +54 -0
  293. package/dist/index.js.map +1 -0
  294. package/dist/metrics/index.d.ts +2 -0
  295. package/dist/metrics/index.d.ts.map +1 -0
  296. package/dist/metrics/index.js +3 -0
  297. package/dist/metrics/index.js.map +1 -0
  298. package/dist/metrics/instance.d.ts +20 -0
  299. package/dist/metrics/instance.d.ts.map +1 -0
  300. package/dist/metrics/instance.js +127 -0
  301. package/dist/metrics/instance.js.map +1 -0
  302. package/dist/metrics/service.d.ts +2 -0
  303. package/dist/metrics/service.d.ts.map +1 -0
  304. package/dist/metrics/service.js +30 -0
  305. package/dist/metrics/service.js.map +1 -0
  306. package/dist/presentation/dto/checkout-token.d.ts +6 -0
  307. package/dist/presentation/dto/checkout-token.d.ts.map +1 -0
  308. package/dist/presentation/dto/checkout-token.js +74 -0
  309. package/dist/presentation/dto/checkout-token.js.map +1 -0
  310. package/dist/presentation/dto/index.d.ts +4 -0
  311. package/dist/presentation/dto/index.d.ts.map +1 -0
  312. package/dist/presentation/dto/index.js +5 -0
  313. package/dist/presentation/dto/index.js.map +1 -0
  314. package/dist/presentation/dto/job.d.ts +6 -0
  315. package/dist/presentation/dto/job.d.ts.map +1 -0
  316. package/dist/presentation/dto/job.js +59 -0
  317. package/dist/presentation/dto/job.js.map +1 -0
  318. package/dist/presentation/dto/step.d.ts +6 -0
  319. package/dist/presentation/dto/step.d.ts.map +1 -0
  320. package/dist/presentation/dto/step.js +162 -0
  321. package/dist/presentation/dto/step.js.map +1 -0
  322. package/dist/presentation/dto/workflow-run.d.ts +6 -0
  323. package/dist/presentation/dto/workflow-run.d.ts.map +1 -0
  324. package/dist/presentation/dto/workflow-run.js +35 -0
  325. package/dist/presentation/dto/workflow-run.js.map +1 -0
  326. package/dist/presentation/index.d.ts +3 -0
  327. package/dist/presentation/index.d.ts.map +1 -0
  328. package/dist/presentation/index.js +4 -0
  329. package/dist/presentation/index.js.map +1 -0
  330. package/dist/presentation/routes/agent-runtime-config.d.ts +2 -0
  331. package/dist/presentation/routes/agent-runtime-config.d.ts.map +1 -0
  332. package/dist/presentation/routes/agent-runtime-config.js +71 -0
  333. package/dist/presentation/routes/agent-runtime-config.js.map +1 -0
  334. package/dist/presentation/routes/cancel-run.d.ts +2 -0
  335. package/dist/presentation/routes/cancel-run.d.ts.map +1 -0
  336. package/dist/presentation/routes/cancel-run.js +46 -0
  337. package/dist/presentation/routes/cancel-run.js.map +1 -0
  338. package/dist/presentation/routes/checkout-token.d.ts +2 -0
  339. package/dist/presentation/routes/checkout-token.d.ts.map +1 -0
  340. package/dist/presentation/routes/checkout-token.js +64 -0
  341. package/dist/presentation/routes/checkout-token.js.map +1 -0
  342. package/dist/presentation/routes/get-run-aggregates.d.ts +2 -0
  343. package/dist/presentation/routes/get-run-aggregates.d.ts.map +1 -0
  344. package/dist/presentation/routes/get-run-aggregates.js +60 -0
  345. package/dist/presentation/routes/get-run-aggregates.js.map +1 -0
  346. package/dist/presentation/routes/get-run.d.ts +2 -0
  347. package/dist/presentation/routes/get-run.d.ts.map +1 -0
  348. package/dist/presentation/routes/get-run.js +60 -0
  349. package/dist/presentation/routes/get-run.js.map +1 -0
  350. package/dist/presentation/routes/get-step-secrets.d.ts +2 -0
  351. package/dist/presentation/routes/get-step-secrets.d.ts.map +1 -0
  352. package/dist/presentation/routes/get-step-secrets.js +115 -0
  353. package/dist/presentation/routes/get-step-secrets.js.map +1 -0
  354. package/dist/presentation/routes/index.d.ts +4 -0
  355. package/dist/presentation/routes/index.d.ts.map +1 -0
  356. package/dist/presentation/routes/index.js +41 -0
  357. package/dist/presentation/routes/index.js.map +1 -0
  358. package/dist/presentation/routes/leased-step.d.ts +14 -0
  359. package/dist/presentation/routes/leased-step.d.ts.map +1 -0
  360. package/dist/presentation/routes/leased-step.js +50 -0
  361. package/dist/presentation/routes/leased-step.js.map +1 -0
  362. package/dist/presentation/routes/list-run-attempts.d.ts +2 -0
  363. package/dist/presentation/routes/list-run-attempts.d.ts.map +1 -0
  364. package/dist/presentation/routes/list-run-attempts.js +35 -0
  365. package/dist/presentation/routes/list-run-attempts.js.map +1 -0
  366. package/dist/presentation/routes/list-runs.d.ts +2 -0
  367. package/dist/presentation/routes/list-runs.d.ts.map +1 -0
  368. package/dist/presentation/routes/list-runs.js +70 -0
  369. package/dist/presentation/routes/list-runs.js.map +1 -0
  370. package/dist/presentation/routes/next-step.d.ts +2 -0
  371. package/dist/presentation/routes/next-step.d.ts.map +1 -0
  372. package/dist/presentation/routes/next-step.js +65 -0
  373. package/dist/presentation/routes/next-step.js.map +1 -0
  374. package/dist/presentation/routes/report-step.d.ts +2 -0
  375. package/dist/presentation/routes/report-step.d.ts.map +1 -0
  376. package/dist/presentation/routes/report-step.js +73 -0
  377. package/dist/presentation/routes/report-step.js.map +1 -0
  378. package/dist/presentation/routes/require-accessible-run.d.ts +7 -0
  379. package/dist/presentation/routes/require-accessible-run.d.ts.map +1 -0
  380. package/dist/presentation/routes/require-accessible-run.js +25 -0
  381. package/dist/presentation/routes/require-accessible-run.js.map +1 -0
  382. package/dist/presentation/routes/rerun-run.d.ts +2 -0
  383. package/dist/presentation/routes/rerun-run.d.ts.map +1 -0
  384. package/dist/presentation/routes/rerun-run.js +56 -0
  385. package/dist/presentation/routes/rerun-run.js.map +1 -0
  386. package/dist/presentation/subscribers/index.d.ts +8 -0
  387. package/dist/presentation/subscribers/index.d.ts.map +1 -0
  388. package/dist/presentation/subscribers/index.js +9 -0
  389. package/dist/presentation/subscribers/index.js.map +1 -0
  390. package/dist/presentation/subscribers/on-job-event-delivered.d.ts +3 -0
  391. package/dist/presentation/subscribers/on-job-event-delivered.d.ts.map +1 -0
  392. package/dist/presentation/subscribers/on-job-event-delivered.js +24 -0
  393. package/dist/presentation/subscribers/on-job-event-delivered.js.map +1 -0
  394. package/dist/presentation/subscribers/on-job-steps-settled.d.ts +3 -0
  395. package/dist/presentation/subscribers/on-job-steps-settled.d.ts.map +1 -0
  396. package/dist/presentation/subscribers/on-job-steps-settled.js +36 -0
  397. package/dist/presentation/subscribers/on-job-steps-settled.js.map +1 -0
  398. package/dist/presentation/subscribers/on-runner-job-claimed.d.ts +3 -0
  399. package/dist/presentation/subscribers/on-runner-job-claimed.d.ts.map +1 -0
  400. package/dist/presentation/subscribers/on-runner-job-claimed.js +20 -0
  401. package/dist/presentation/subscribers/on-runner-job-claimed.js.map +1 -0
  402. package/dist/presentation/subscribers/on-runner-job-lease-expired.d.ts +3 -0
  403. package/dist/presentation/subscribers/on-runner-job-lease-expired.d.ts.map +1 -0
  404. package/dist/presentation/subscribers/on-runner-job-lease-expired.js +35 -0
  405. package/dist/presentation/subscribers/on-runner-job-lease-expired.js.map +1 -0
  406. package/dist/presentation/subscribers/on-runner-job-queued.d.ts +3 -0
  407. package/dist/presentation/subscribers/on-runner-job-queued.d.ts.map +1 -0
  408. package/dist/presentation/subscribers/on-runner-job-queued.js +20 -0
  409. package/dist/presentation/subscribers/on-runner-job-queued.js.map +1 -0
  410. package/dist/presentation/subscribers/on-workflow-run-attempt-created.d.ts +3 -0
  411. package/dist/presentation/subscribers/on-workflow-run-attempt-created.d.ts.map +1 -0
  412. package/dist/presentation/subscribers/on-workflow-run-attempt-created.js +35 -0
  413. package/dist/presentation/subscribers/on-workflow-run-attempt-created.js.map +1 -0
  414. package/dist/presentation/subscribers/on-workflow-run-cancelled.d.ts +3 -0
  415. package/dist/presentation/subscribers/on-workflow-run-cancelled.d.ts.map +1 -0
  416. package/dist/presentation/subscribers/on-workflow-run-cancelled.js +25 -0
  417. package/dist/presentation/subscribers/on-workflow-run-cancelled.js.map +1 -0
  418. package/dist/temporal/activities/index.d.ts +23 -0
  419. package/dist/temporal/activities/index.d.ts.map +1 -0
  420. package/dist/temporal/activities/index.js +26 -0
  421. package/dist/temporal/activities/index.js.map +1 -0
  422. package/dist/temporal/activities/orchestration-activities.d.ts +130 -0
  423. package/dist/temporal/activities/orchestration-activities.d.ts.map +1 -0
  424. package/dist/temporal/activities/orchestration-activities.js +188 -0
  425. package/dist/temporal/activities/orchestration-activities.js.map +1 -0
  426. package/dist/temporal/constants.d.ts +7 -0
  427. package/dist/temporal/constants.d.ts.map +1 -0
  428. package/dist/temporal/constants.js +8 -0
  429. package/dist/temporal/constants.js.map +1 -0
  430. package/dist/temporal/index.d.ts +3 -0
  431. package/dist/temporal/index.d.ts.map +1 -0
  432. package/dist/temporal/index.js +4 -0
  433. package/dist/temporal/index.js.map +1 -0
  434. package/dist/temporal/workflow-not-found.d.ts +2 -0
  435. package/dist/temporal/workflow-not-found.d.ts.map +1 -0
  436. package/dist/temporal/workflow-not-found.js +10 -0
  437. package/dist/temporal/workflow-not-found.js.map +1 -0
  438. package/dist/temporal/workflows/deadline.d.ts +3 -0
  439. package/dist/temporal/workflows/deadline.d.ts.map +1 -0
  440. package/dist/temporal/workflows/deadline.js +8 -0
  441. package/dist/temporal/workflows/deadline.js.map +1 -0
  442. package/dist/temporal/workflows/index.d.ts +4 -0
  443. package/dist/temporal/workflows/index.d.ts.map +1 -0
  444. package/dist/temporal/workflows/index.js +5 -0
  445. package/dist/temporal/workflows/index.js.map +1 -0
  446. package/dist/temporal/workflows/job-execution-orchestration.d.ts +27 -0
  447. package/dist/temporal/workflows/job-execution-orchestration.d.ts.map +1 -0
  448. package/dist/temporal/workflows/job-execution-orchestration.js +265 -0
  449. package/dist/temporal/workflows/job-execution-orchestration.js.map +1 -0
  450. package/dist/temporal/workflows/job-listener-orchestration.d.ts +40 -0
  451. package/dist/temporal/workflows/job-listener-orchestration.d.ts.map +1 -0
  452. package/dist/temporal/workflows/job-listener-orchestration.js +332 -0
  453. package/dist/temporal/workflows/job-listener-orchestration.js.map +1 -0
  454. package/dist/temporal/workflows/run-orchestration.d.ts +8 -0
  455. package/dist/temporal/workflows/run-orchestration.d.ts.map +1 -0
  456. package/dist/temporal/workflows/run-orchestration.js +243 -0
  457. package/dist/temporal/workflows/run-orchestration.js.map +1 -0
  458. package/dist/temporal/workflows/test-env.d.ts +114 -0
  459. package/dist/temporal/workflows/test-env.d.ts.map +1 -0
  460. package/dist/temporal/workflows/test-env.js +323 -0
  461. package/dist/temporal/workflows/test-env.js.map +1 -0
  462. package/dist/tsconfig.test.tsbuildinfo +1 -0
  463. package/drizzle/0000_initial.sql +211 -0
  464. package/drizzle/0001_agent_tool_materialization.sql +1 -0
  465. package/drizzle/meta/0000_snapshot.json +1613 -0
  466. package/drizzle/meta/_journal.json +20 -0
  467. package/drizzle.config.ts +7 -0
  468. package/package.json +91 -0
  469. package/src/core/README.md +79 -0
  470. package/src/core/agent-tool-capability-warning.test.ts +280 -0
  471. package/src/core/agent-tool-capability-warning.ts +200 -0
  472. package/src/core/agent-tools.ts +422 -0
  473. package/src/core/checkout.test.ts +112 -0
  474. package/src/core/checkout.ts +63 -0
  475. package/src/core/condition-trace.ts +64 -0
  476. package/src/core/entities/index.ts +9 -0
  477. package/src/core/entities/job-execution.ts +32 -0
  478. package/src/core/entities/job-listener-event.ts +15 -0
  479. package/src/core/entities/job.ts +91 -0
  480. package/src/core/entities/step.ts +126 -0
  481. package/src/core/entities/workflow-run-attempt.ts +21 -0
  482. package/src/core/entities/workflow-run.ts +94 -0
  483. package/src/core/errors.test.ts +45 -0
  484. package/src/core/errors.ts +209 -0
  485. package/src/core/index.ts +54 -0
  486. package/src/core/job-execution-outcome.ts +64 -0
  487. package/src/core/job-execution.test.ts +1560 -0
  488. package/src/core/job-execution.ts +581 -0
  489. package/src/core/job-transition/decide-job-activation.test.ts +196 -0
  490. package/src/core/job-transition/decide-job-activation.ts +79 -0
  491. package/src/core/job-transition/derive-job-execution-outputs.ts +41 -0
  492. package/src/core/job-transition/derive-job-success.test.ts +133 -0
  493. package/src/core/job-transition/derive-job-success.ts +67 -0
  494. package/src/core/job-transition/index.ts +11 -0
  495. package/src/core/job-transition/runtime-completion-status-for-job.ts +14 -0
  496. package/src/core/listener-execution-materialization.test.ts +49 -0
  497. package/src/core/listener-execution-materialization.ts +162 -0
  498. package/src/core/run-workflow.test.ts +229 -0
  499. package/src/core/run-workflow.ts +41 -0
  500. package/src/core/source-control.test.ts +17 -0
  501. package/src/core/source-control.ts +22 -0
  502. package/src/core/step-config/agent.ts +459 -0
  503. package/src/core/step-config/assemble-run-context.test.ts +1021 -0
  504. package/src/core/step-config/assemble-run-context.ts +535 -0
  505. package/src/core/step-config/complete-step-dispatch-config.test.ts +354 -0
  506. package/src/core/step-config/complete-step-dispatch-config.ts +40 -0
  507. package/src/core/step-config/fields.ts +111 -0
  508. package/src/core/step-config/index.ts +41 -0
  509. package/src/core/step-config/materialize-job-execution-steps.test.ts +494 -0
  510. package/src/core/step-config/materialize-job-execution-steps.ts +136 -0
  511. package/src/core/step-config/materialize-workflow-model.test.ts +1214 -0
  512. package/src/core/step-config/materialize-workflow-model.ts +140 -0
  513. package/src/core/step-config/resolve-job-execution-name.test.ts +94 -0
  514. package/src/core/step-config/resolve-job-execution-name.ts +51 -0
  515. package/src/core/step-config/resolve-step-config.ts +199 -0
  516. package/src/core/step-config/run.ts +467 -0
  517. package/src/core/step-config/workflow-evaluation-context.ts +6 -0
  518. package/src/core/step-transition/apply-step-transition.ts +163 -0
  519. package/src/core/step-transition/decide-step-transition.test.ts +307 -0
  520. package/src/core/step-transition/decide-step-transition.ts +200 -0
  521. package/src/core/step-transition/evaluate-gate.test.ts +317 -0
  522. package/src/core/step-transition/evaluate-gate.ts +168 -0
  523. package/src/core/step-transition/read-step-outputs.ts +27 -0
  524. package/src/core/workflow-run-creation.test.ts +155 -0
  525. package/src/core/workflow-run-creation.ts +131 -0
  526. package/src/core/workflow-scheduling/index.ts +15 -0
  527. package/src/core/workflow-scheduling/run-progress.test.ts +46 -0
  528. package/src/core/workflow-scheduling/run-progress.ts +68 -0
  529. package/src/core/workflow-scheduling/runtime-dag.ts +10 -0
  530. package/src/core/workflow-scheduling/runtime-scheduling-command.ts +20 -0
  531. package/src/core/workflow-scheduling/schedule-runtime-dag.test.ts +197 -0
  532. package/src/core/workflow-scheduling/schedule-runtime-dag.ts +115 -0
  533. package/src/db/db.ts +40 -0
  534. package/src/db/index.ts +86 -0
  535. package/src/db/job-listener-events.test.ts +113 -0
  536. package/src/db/job-listener-events.ts +73 -0
  537. package/src/db/job-listeners.test.ts +865 -0
  538. package/src/db/job-listeners.ts +564 -0
  539. package/src/db/outbox-writes.ts +40 -0
  540. package/src/db/rerun-listener.test.ts +64 -0
  541. package/src/db/run-timeout.test.ts +102 -0
  542. package/src/db/runner-lease-table.ts +39 -0
  543. package/src/db/schema/common.ts +3 -0
  544. package/src/db/schema/job-executions.ts +85 -0
  545. package/src/db/schema/job-listener-events.ts +58 -0
  546. package/src/db/schema/jobs.ts +136 -0
  547. package/src/db/schema/outbox.ts +4 -0
  548. package/src/db/schema/step-attempts.ts +99 -0
  549. package/src/db/schema/steps.ts +99 -0
  550. package/src/db/schema/workflow-run-attempts.ts +59 -0
  551. package/src/db/schema/workflow-runs.ts +112 -0
  552. package/src/db/workflow-runs/job-executions.test.ts +99 -0
  553. package/src/db/workflow-runs/job-executions.ts +421 -0
  554. package/src/db/workflow-runs/job-status.test.ts +532 -0
  555. package/src/db/workflow-runs/jobs.test.ts +194 -0
  556. package/src/db/workflow-runs/jobs.ts +418 -0
  557. package/src/db/workflow-runs/outbox.ts +109 -0
  558. package/src/db/workflow-runs/queries.test.ts +256 -0
  559. package/src/db/workflow-runs/queries.ts +437 -0
  560. package/src/db/workflow-runs/run-create.test.ts +1281 -0
  561. package/src/db/workflow-runs/run-create.ts +373 -0
  562. package/src/db/workflow-runs/run-graph.ts +95 -0
  563. package/src/db/workflow-runs/run-rerun.test.ts +625 -0
  564. package/src/db/workflow-runs/run-rerun.ts +266 -0
  565. package/src/db/workflow-runs/run-status.test.ts +425 -0
  566. package/src/db/workflow-runs/run-status.ts +391 -0
  567. package/src/db/workflow-runs/runs.ts +17 -0
  568. package/src/db/workflow-runs/shared.ts +84 -0
  569. package/src/db/workflow-runs/steps.test.ts +191 -0
  570. package/src/db/workflow-runs/steps.ts +476 -0
  571. package/src/db/workflow-runs.ts +111 -0
  572. package/src/index.ts +99 -0
  573. package/src/metrics/index.ts +1 -0
  574. package/src/metrics/instance.ts +160 -0
  575. package/src/metrics/service.ts +30 -0
  576. package/src/presentation/dto/checkout-token.test.ts +107 -0
  577. package/src/presentation/dto/checkout-token.ts +85 -0
  578. package/src/presentation/dto/index.ts +3 -0
  579. package/src/presentation/dto/job.test.ts +108 -0
  580. package/src/presentation/dto/job.ts +64 -0
  581. package/src/presentation/dto/step.test.ts +307 -0
  582. package/src/presentation/dto/step.ts +145 -0
  583. package/src/presentation/dto/workflow-run.ts +38 -0
  584. package/src/presentation/index.ts +10 -0
  585. package/src/presentation/routes/agent-runtime-config.test.ts +478 -0
  586. package/src/presentation/routes/agent-runtime-config.ts +81 -0
  587. package/src/presentation/routes/cancel-run.test.ts +156 -0
  588. package/src/presentation/routes/cancel-run.ts +37 -0
  589. package/src/presentation/routes/checkout-token.test.ts +366 -0
  590. package/src/presentation/routes/checkout-token.ts +61 -0
  591. package/src/presentation/routes/get-run-aggregates.test.ts +114 -0
  592. package/src/presentation/routes/get-run-aggregates.ts +69 -0
  593. package/src/presentation/routes/get-run.test.ts +604 -0
  594. package/src/presentation/routes/get-run.ts +66 -0
  595. package/src/presentation/routes/get-step-secrets.test.ts +260 -0
  596. package/src/presentation/routes/get-step-secrets.ts +117 -0
  597. package/src/presentation/routes/index.test.ts +71 -0
  598. package/src/presentation/routes/index.ts +42 -0
  599. package/src/presentation/routes/leased-step.ts +54 -0
  600. package/src/presentation/routes/list-run-attempts.test.ts +163 -0
  601. package/src/presentation/routes/list-run-attempts.ts +27 -0
  602. package/src/presentation/routes/list-runs.test.ts +244 -0
  603. package/src/presentation/routes/list-runs.ts +83 -0
  604. package/src/presentation/routes/next-step.test.ts +366 -0
  605. package/src/presentation/routes/next-step.ts +62 -0
  606. package/src/presentation/routes/report-step.test.ts +374 -0
  607. package/src/presentation/routes/report-step.ts +61 -0
  608. package/src/presentation/routes/require-accessible-run.ts +27 -0
  609. package/src/presentation/routes/rerun-run.test.ts +202 -0
  610. package/src/presentation/routes/rerun-run.ts +48 -0
  611. package/src/presentation/subscribers/index.ts +7 -0
  612. package/src/presentation/subscribers/on-job-event-delivered.test.ts +71 -0
  613. package/src/presentation/subscribers/on-job-event-delivered.ts +30 -0
  614. package/src/presentation/subscribers/on-job-steps-settled.test.ts +75 -0
  615. package/src/presentation/subscribers/on-job-steps-settled.ts +42 -0
  616. package/src/presentation/subscribers/on-runner-job-claimed.test.ts +57 -0
  617. package/src/presentation/subscribers/on-runner-job-claimed.ts +23 -0
  618. package/src/presentation/subscribers/on-runner-job-lease-expired.test.ts +73 -0
  619. package/src/presentation/subscribers/on-runner-job-lease-expired.ts +36 -0
  620. package/src/presentation/subscribers/on-runner-job-queued.test.ts +57 -0
  621. package/src/presentation/subscribers/on-runner-job-queued.ts +23 -0
  622. package/src/presentation/subscribers/on-workflow-run-attempt-created.test.ts +69 -0
  623. package/src/presentation/subscribers/on-workflow-run-attempt-created.ts +40 -0
  624. package/src/presentation/subscribers/on-workflow-run-cancelled.test.ts +59 -0
  625. package/src/presentation/subscribers/on-workflow-run-cancelled.ts +29 -0
  626. package/src/temporal/activities/index.ts +45 -0
  627. package/src/temporal/activities/orchestration-activities.test.ts +115 -0
  628. package/src/temporal/activities/orchestration-activities.ts +305 -0
  629. package/src/temporal/constants.ts +6 -0
  630. package/src/temporal/index.ts +6 -0
  631. package/src/temporal/workflow-not-found.ts +9 -0
  632. package/src/temporal/workflows/deadline.ts +7 -0
  633. package/src/temporal/workflows/index.ts +11 -0
  634. package/src/temporal/workflows/job-execution-orchestration-timeout.test.ts +165 -0
  635. package/src/temporal/workflows/job-execution-orchestration.test.ts +311 -0
  636. package/src/temporal/workflows/job-execution-orchestration.ts +314 -0
  637. package/src/temporal/workflows/job-listener-orchestration-continuation.test.ts +93 -0
  638. package/src/temporal/workflows/job-listener-orchestration.test.ts +406 -0
  639. package/src/temporal/workflows/job-listener-orchestration.ts +490 -0
  640. package/src/temporal/workflows/run-orchestration.test.ts +409 -0
  641. package/src/temporal/workflows/run-orchestration.ts +283 -0
  642. package/src/temporal/workflows/test-env.ts +409 -0
  643. package/test/env.ts +11 -0
  644. package/test/factories/job.ts +83 -0
  645. package/test/factories/project.ts +16 -0
  646. package/test/factories/workflow-model.test.ts +26 -0
  647. package/test/factories/workflow-model.ts +268 -0
  648. package/test/factories/workflow-run.ts +58 -0
  649. package/test/fixtures/active-lease-token.ts +85 -0
  650. package/test/fixtures/job-with-steps.ts +42 -0
  651. package/test/fixtures/lease-token.ts +44 -0
  652. package/test/fixtures/strip-setup-step.ts +23 -0
  653. package/test/globalSetup.ts +54 -0
  654. package/test/helpers/workflow-runs.ts +210 -0
  655. package/test/index.ts +3 -0
  656. package/test/setup.ts +17 -0
  657. package/tsconfig.build.json +9 -0
  658. package/tsconfig.build.tsbuildinfo +1 -0
  659. package/tsconfig.json +3 -0
  660. package/tsconfig.test.json +8 -0
  661. package/vitest.config.ts +12 -0
@@ -0,0 +1,1560 @@
1
+ import {
2
+ WORKFLOWS_JOB_STEPS_SETTLED,
3
+ WORKFLOWS_STEP_ATTEMPT_TERMINATED,
4
+ WORKFLOWS_STEP_RESTART_ENQUEUED,
5
+ type WorkflowsStepAttemptTerminatedEventDto,
6
+ type WorkflowsStepRestartEnqueuedEventDto,
7
+ } from '@shipfox/api-workflows-dto';
8
+ import {
9
+ createWorkflowExpression,
10
+ parseWorkflowTemplate,
11
+ planInterpolationField,
12
+ } from '@shipfox/expression';
13
+ import {and, eq, sql} from 'drizzle-orm';
14
+ import {db} from '#db/db.js';
15
+ import {workflowsOutbox} from '#db/schema/outbox.js';
16
+ import {stepAttempts as stepAttemptsTable} from '#db/schema/step-attempts.js';
17
+ import {steps as stepsTable} from '#db/schema/steps.js';
18
+ import {getStepAttempts, getStepsByJobId} from '#db/workflow-runs.js';
19
+ import {arrangeJobWithSteps} from '#test/fixtures/job-with-steps.js';
20
+ import {bulkUpdateJobStepStatuses} from '#test/helpers/workflow-runs.js';
21
+ import type {Step} from './entities/step.js';
22
+ import {
23
+ JobNotFoundError,
24
+ StepAttemptAheadError,
25
+ StepNotFoundError,
26
+ StepNotRunningError,
27
+ } from './errors.js';
28
+ import {
29
+ classifyReportedStep,
30
+ nextStepForJob,
31
+ recordStepResult as recordJobExecutionStepResult,
32
+ } from './job-execution.js';
33
+
34
+ async function recordStepResult(
35
+ params: Omit<Parameters<typeof recordJobExecutionStepResult>[0], 'jobExecutionId'> & {
36
+ jobId: string;
37
+ },
38
+ ) {
39
+ const steps = await getStepsByJobId(params.jobId);
40
+ const step = steps.find((candidate) => candidate.id === params.stepId);
41
+ if (!step) throw new StepNotFoundError(params.stepId, params.jobId);
42
+ const {jobId: _jobId, ...rest} = params;
43
+ return recordJobExecutionStepResult({...rest, jobExecutionId: step.jobExecutionId});
44
+ }
45
+
46
+ function plannedField(field: 'run' | 'step.feedback', source: string) {
47
+ const plan = planInterpolationField({field, segments: parseWorkflowTemplate(source)});
48
+ if (!plan.ok) throw new Error('Expected test template to plan');
49
+ return plan.plan.field;
50
+ }
51
+
52
+ async function jobStepsSettledEvents(jobId: string): Promise<Array<{status: string}>> {
53
+ const rows = await db()
54
+ .select({payload: workflowsOutbox.payload})
55
+ .from(workflowsOutbox)
56
+ .where(
57
+ and(
58
+ eq(workflowsOutbox.eventType, WORKFLOWS_JOB_STEPS_SETTLED),
59
+ sql`${workflowsOutbox.payload}->>'jobId' = ${jobId}`,
60
+ ),
61
+ );
62
+ return rows.map((row) => row.payload as {status: string});
63
+ }
64
+
65
+ async function stepAttemptTerminatedEvents(
66
+ jobId: string,
67
+ ): Promise<WorkflowsStepAttemptTerminatedEventDto[]> {
68
+ const rows = await db()
69
+ .select({payload: workflowsOutbox.payload})
70
+ .from(workflowsOutbox)
71
+ .where(
72
+ and(
73
+ eq(workflowsOutbox.eventType, WORKFLOWS_STEP_ATTEMPT_TERMINATED),
74
+ sql`${workflowsOutbox.payload}->>'jobId' = ${jobId}`,
75
+ ),
76
+ );
77
+ return rows.map((row) => row.payload as WorkflowsStepAttemptTerminatedEventDto);
78
+ }
79
+
80
+ function stepForClassification(params: Partial<Step> = {}): Step {
81
+ return {
82
+ id: 'step-1',
83
+ jobExecutionId: 'job-execution-1',
84
+ key: 'build',
85
+ name: 'Build',
86
+ sourceLocation: null,
87
+ status: 'running',
88
+ statusReason: null,
89
+ evaluationTrace: null,
90
+ type: 'run',
91
+ config: {run: 'echo ok'},
92
+ condition: null,
93
+ configPlan: null,
94
+ authoredConfig: null,
95
+ error: null,
96
+ position: 0,
97
+ version: 1,
98
+ currentAttempt: 1,
99
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
100
+ updatedAt: new Date('2026-01-01T00:00:00.000Z'),
101
+ ...params,
102
+ };
103
+ }
104
+
105
+ describe('nextStepForJob', () => {
106
+ test('returns the lowest-position pending step and marks it running', async () => {
107
+ const {jobId, steps} = await arrangeJobWithSteps(3);
108
+
109
+ const result = await nextStepForJob(jobId);
110
+
111
+ expect(result).toEqual({
112
+ kind: 'step',
113
+ step: expect.objectContaining({id: steps[0]?.id}),
114
+ dispatched: true,
115
+ });
116
+ const after = await getStepsByJobId(jobId);
117
+ expect(after[0]?.status).toBe('running');
118
+ expect(after[1]?.status).toBe('pending');
119
+ expect(after[2]?.status).toBe('pending');
120
+ });
121
+
122
+ test('idempotent re-delivery: a second pull returns the same running step', async () => {
123
+ const {jobId} = await arrangeJobWithSteps(3);
124
+ const first = await nextStepForJob(jobId);
125
+
126
+ const second = await nextStepForJob(jobId);
127
+
128
+ expect(first.kind).toBe('step');
129
+ expect(second.kind).toBe('step');
130
+ const firstId = first.kind === 'step' ? first.step.id : null;
131
+ const secondId = second.kind === 'step' ? second.step.id : null;
132
+ expect(secondId).toBe(firstId);
133
+ expect(first.kind === 'step' ? first.dispatched : undefined).toBe(true);
134
+ expect(second.kind === 'step' ? second.dispatched : undefined).toBe(false);
135
+ const running = (await getStepsByJobId(jobId)).filter((s) => s.status === 'running');
136
+ expect(running).toHaveLength(1);
137
+ });
138
+
139
+ test('after a step succeeds, the next pull returns the next pending step', async () => {
140
+ const {jobId, steps} = await arrangeJobWithSteps(3);
141
+ await nextStepForJob(jobId);
142
+ await recordStepResult({jobId, stepId: steps[0]?.id as string, status: 'succeeded'});
143
+
144
+ const next = await nextStepForJob(jobId);
145
+
146
+ expect(next).toEqual({
147
+ kind: 'step',
148
+ step: expect.objectContaining({id: steps[1]?.id}),
149
+ dispatched: true,
150
+ });
151
+ });
152
+
153
+ test('fills dispatch config from terminal step attempt output', async () => {
154
+ const {jobId, steps} = await arrangeJobWithSteps(2);
155
+ const producer = steps[0];
156
+ const consumer = steps[1];
157
+ if (!producer || !consumer) throw new Error('Expected arranged steps');
158
+ const shaPlan = stepOutputField('build', 'sha');
159
+ await db().update(stepsTable).set({key: 'build'}).where(eq(stepsTable.id, producer.id));
160
+ await db()
161
+ .update(stepsTable)
162
+ .set({
163
+ key: 'deploy',
164
+ config: {run: 'echo ok'},
165
+ configPlan: {env: {SHA: shaPlan}},
166
+ })
167
+ .where(eq(stepsTable.id, consumer.id));
168
+ await nextStepForJob(jobId);
169
+ await recordStepResult({
170
+ jobId,
171
+ stepId: producer.id,
172
+ status: 'succeeded',
173
+ output: {sha: 'abc123'},
174
+ });
175
+
176
+ const next = await nextStepForJob(jobId);
177
+ const redelivery = await nextStepForJob(jobId);
178
+
179
+ expect(next).toEqual({
180
+ kind: 'step',
181
+ step: expect.objectContaining({
182
+ id: consumer.id,
183
+ config: {run: 'echo ok', env: {SHA: 'abc123'}},
184
+ configPlan: {env: {SHA: shaPlan}},
185
+ }),
186
+ dispatched: true,
187
+ });
188
+ expect(redelivery).toEqual({
189
+ kind: 'step',
190
+ step: expect.objectContaining({
191
+ id: consumer.id,
192
+ config: {run: 'echo ok', env: {SHA: 'abc123'}},
193
+ configPlan: {env: {SHA: shaPlan}},
194
+ }),
195
+ dispatched: false,
196
+ });
197
+ const attempts = await getStepAttempts(jobId);
198
+ expect(attempts.find((attempt) => attempt.stepId === consumer.id)).toMatchObject({
199
+ status: 'running',
200
+ config: {run: 'echo ok', env: {SHA: 'abc123'}},
201
+ });
202
+ const after = await getStepsByJobId(jobId);
203
+ expect(after.find((step) => step.id === consumer.id)?.configPlan).toEqual({
204
+ env: {SHA: shaPlan},
205
+ });
206
+ });
207
+
208
+ test('re-materializes dispatch config after a gate rewind', async () => {
209
+ const {jobId, steps} = await arrangeJobWithSteps(2);
210
+ const producer = steps[0];
211
+ const consumer = steps[1];
212
+ if (!producer || !consumer) throw new Error('Expected arranged steps');
213
+ const shaPlan = stepOutputField('build', 'sha');
214
+ await db().update(stepsTable).set({key: 'build'}).where(eq(stepsTable.id, producer.id));
215
+ await db()
216
+ .update(stepsTable)
217
+ .set({
218
+ key: 'deploy',
219
+ config: {
220
+ run: 'deploy',
221
+ gate: {
222
+ success: {language: 'cel', check: 'syntax', source: 'step.exit_code == 0'},
223
+ on_failure: {restart_from: 'build'},
224
+ },
225
+ },
226
+ configPlan: {env: {SHA: shaPlan}},
227
+ })
228
+ .where(eq(stepsTable.id, consumer.id));
229
+
230
+ await nextStepForJob(jobId);
231
+ await recordStepResult({
232
+ jobId,
233
+ stepId: producer.id,
234
+ status: 'succeeded',
235
+ output: {sha: 'abc123'},
236
+ });
237
+ const firstConsumer = await nextStepForJob(jobId);
238
+ await recordStepResult({
239
+ jobId,
240
+ stepId: consumer.id,
241
+ status: 'failed',
242
+ error: {message: 'exit 1'},
243
+ exitCode: 1,
244
+ });
245
+ await nextStepForJob(jobId);
246
+ await recordStepResult({
247
+ jobId,
248
+ stepId: producer.id,
249
+ status: 'succeeded',
250
+ output: {sha: 'def456'},
251
+ });
252
+
253
+ const secondConsumer = await nextStepForJob(jobId);
254
+
255
+ expect(firstConsumer).toEqual({
256
+ kind: 'step',
257
+ step: expect.objectContaining({
258
+ id: consumer.id,
259
+ currentAttempt: 1,
260
+ config: expect.objectContaining({env: {SHA: 'abc123'}}),
261
+ }),
262
+ dispatched: true,
263
+ });
264
+ expect(secondConsumer).toEqual({
265
+ kind: 'step',
266
+ step: expect.objectContaining({
267
+ id: consumer.id,
268
+ currentAttempt: 2,
269
+ config: expect.objectContaining({env: {SHA: 'def456'}}),
270
+ configPlan: {env: {SHA: shaPlan}},
271
+ }),
272
+ dispatched: true,
273
+ });
274
+ const attempts = await getStepAttempts(jobId);
275
+ expect(
276
+ attempts.find((attempt) => attempt.stepId === consumer.id && attempt.attempt === 1),
277
+ ).toMatchObject({config: expect.objectContaining({env: {SHA: 'abc123'}})});
278
+ expect(
279
+ attempts.find((attempt) => attempt.stepId === consumer.id && attempt.attempt === 2),
280
+ ).toMatchObject({config: expect.objectContaining({env: {SHA: 'def456'}})});
281
+ });
282
+
283
+ test('fails the job when dispatch config cannot resolve a peer output', async () => {
284
+ const {jobId, steps} = await arrangeJobWithSteps(2);
285
+ const producer = steps[0];
286
+ const consumer = steps[1];
287
+ if (!producer || !consumer) throw new Error('Expected arranged steps');
288
+ await db().update(stepsTable).set({key: 'build'}).where(eq(stepsTable.id, producer.id));
289
+ await db()
290
+ .update(stepsTable)
291
+ .set({
292
+ key: 'deploy',
293
+ config: {run: 'echo ok'},
294
+ configPlan: {env: {SHA: stepOutputField('build', 'sha')}},
295
+ })
296
+ .where(eq(stepsTable.id, consumer.id));
297
+ await nextStepForJob(jobId);
298
+ await recordStepResult({jobId, stepId: producer.id, status: 'succeeded', output: {}});
299
+
300
+ const next = await nextStepForJob(jobId);
301
+
302
+ expect(next).toEqual({kind: 'done', status: 'failed'});
303
+ const after = await getStepsByJobId(jobId);
304
+ expect(after.find((step) => step.id === consumer.id)).toMatchObject({
305
+ status: 'failed',
306
+ error: {
307
+ reason: 'config_unresolvable',
308
+ field: 'env.SHA',
309
+ source: 'steps.build.outputs.sha',
310
+ },
311
+ });
312
+ const attempts = await getStepAttempts(jobId);
313
+ expect(attempts.find((attempt) => attempt.stepId === consumer.id)).toMatchObject({
314
+ status: 'failed',
315
+ config: null,
316
+ error: {
317
+ reason: 'config_unresolvable',
318
+ field: 'env.SHA',
319
+ source: 'steps.build.outputs.sha',
320
+ },
321
+ logOutcome: 'abandoned',
322
+ });
323
+ });
324
+
325
+ test('all steps succeeded → {done, succeeded}', async () => {
326
+ const {jobId, steps} = await arrangeJobWithSteps(2);
327
+ for (const step of steps) {
328
+ await nextStepForJob(jobId);
329
+ await recordStepResult({jobId, stepId: step.id, status: 'succeeded'});
330
+ }
331
+
332
+ const result = await nextStepForJob(jobId);
333
+
334
+ expect(result).toEqual({kind: 'done', status: 'succeeded'});
335
+ });
336
+
337
+ test('unknown jobId → JobNotFoundError (not a vacuous done)', async () => {
338
+ await expect(nextStepForJob(crypto.randomUUID())).rejects.toBeInstanceOf(JobNotFoundError);
339
+ });
340
+
341
+ test('all terminal with a failure → {done, failed}', async () => {
342
+ const {jobId, steps} = await arrangeJobWithSteps(2);
343
+ await nextStepForJob(jobId);
344
+ await recordStepResult({jobId, stepId: steps[0]?.id as string, status: 'failed'});
345
+
346
+ const result = await nextStepForJob(jobId);
347
+
348
+ expect(result).toEqual({kind: 'done', status: 'failed'});
349
+ });
350
+
351
+ test('all cancelled, none failed → {done, succeeded}', async () => {
352
+ const {jobId} = await arrangeJobWithSteps(2);
353
+ await bulkUpdateJobStepStatuses({jobId, status: 'cancelled'});
354
+
355
+ const result = await nextStepForJob(jobId);
356
+
357
+ expect(result).toEqual({kind: 'done', status: 'succeeded'});
358
+ });
359
+
360
+ test('skips a false condition without creating an attempt and dispatches the next step', async () => {
361
+ const {jobId, steps} = await arrangeJobWithSteps(2);
362
+ const skippedStep = steps[0];
363
+ const runnableStep = steps[1];
364
+ if (!skippedStep || !runnableStep) throw new Error('Expected arranged steps');
365
+ await db()
366
+ .update(stepsTable)
367
+ .set({condition: conditionExpression('false')})
368
+ .where(eq(stepsTable.id, skippedStep.id));
369
+
370
+ const result = await nextStepForJob(jobId);
371
+
372
+ expect(result).toEqual({
373
+ kind: 'step',
374
+ step: expect.objectContaining({id: runnableStep.id}),
375
+ dispatched: true,
376
+ });
377
+ const after = await getStepsByJobId(jobId);
378
+ expect(after.find((step) => step.id === skippedStep.id)).toMatchObject({
379
+ status: 'skipped',
380
+ statusReason: 'condition_rejected',
381
+ evaluationTrace: [conditionTrace('step.if', 'false', [], false)],
382
+ });
383
+ expect(after.find((step) => step.id === runnableStep.id)?.status).toBe('running');
384
+ expect(await getStepAttempts(jobId)).toMatchObject([{stepId: runnableStep.id}]);
385
+ });
386
+
387
+ test('skips an errored condition without creating an attempt', async () => {
388
+ const {jobId, steps} = await arrangeJobWithSteps(2);
389
+ const skippedStep = steps[0];
390
+ const runnableStep = steps[1];
391
+ if (!skippedStep || !runnableStep) throw new Error('Expected arranged steps');
392
+ await db()
393
+ .update(stepsTable)
394
+ .set({condition: conditionExpression('1 / 0 == 0')})
395
+ .where(eq(stepsTable.id, skippedStep.id));
396
+
397
+ const result = await nextStepForJob(jobId);
398
+
399
+ expect(result).toEqual({
400
+ kind: 'step',
401
+ step: expect.objectContaining({id: runnableStep.id}),
402
+ dispatched: true,
403
+ });
404
+ const after = await getStepsByJobId(jobId);
405
+ expect(after.find((step) => step.id === skippedStep.id)).toMatchObject({
406
+ status: 'skipped',
407
+ statusReason: 'condition_errored',
408
+ evaluationTrace: [conditionTrace('step.if', '1 / 0 == 0', [], false, true)],
409
+ });
410
+ expect(await getStepAttempts(jobId)).toMatchObject([{stepId: runnableStep.id}]);
411
+ });
412
+
413
+ test('skips adjacent false conditions in one pull and keeps condition out of runner config', async () => {
414
+ const {jobId, steps} = await arrangeJobWithSteps(3);
415
+ const [first, second, third] = steps;
416
+ if (!first || !second || !third) throw new Error('Expected arranged steps');
417
+ await db()
418
+ .update(stepsTable)
419
+ .set({condition: conditionExpression('false')})
420
+ .where(sql`${stepsTable.id} = ${first.id} OR ${stepsTable.id} = ${second.id}`);
421
+
422
+ const result = await nextStepForJob(jobId);
423
+
424
+ expect(result).toEqual({
425
+ kind: 'step',
426
+ step: expect.objectContaining({id: third.id}),
427
+ dispatched: true,
428
+ });
429
+ if (result.kind !== 'step') throw new Error('Expected a runnable step');
430
+ expect(result.step.config).not.toHaveProperty('if');
431
+ expect(result.step.condition).toBeNull();
432
+ const after = await getStepsByJobId(jobId);
433
+ expect(after.map((step) => step.status)).toEqual(['skipped', 'skipped', 'running']);
434
+ expect(await getStepAttempts(jobId)).toMatchObject([{stepId: third.id}]);
435
+ });
436
+
437
+ test('a job with only author-skipped steps resolves succeeded', async () => {
438
+ const {jobId, steps} = await arrangeJobWithSteps(1);
439
+ const [onlyStep] = steps;
440
+ if (!onlyStep) throw new Error('Expected arranged step');
441
+ await db()
442
+ .update(stepsTable)
443
+ .set({condition: conditionExpression('false')})
444
+ .where(eq(stepsTable.id, onlyStep.id));
445
+
446
+ const result = await nextStepForJob(jobId);
447
+
448
+ expect(result).toEqual({kind: 'done', status: 'succeeded'});
449
+ const after = await getStepsByJobId(jobId);
450
+ expect(after).toMatchObject([{status: 'skipped', statusReason: 'condition_rejected'}]);
451
+ expect(await getStepAttempts(jobId)).toHaveLength(0);
452
+ expect(await jobStepsSettledEvents(jobId)).toMatchObject([{status: 'succeeded'}]);
453
+ });
454
+
455
+ test('the implicit default gate skips when execution.failed is true', async () => {
456
+ const {jobId, steps} = await arrangeJobWithSteps(2);
457
+ const failed = steps[0];
458
+ const defaultGated = steps[1];
459
+ if (!failed || !defaultGated) throw new Error('Expected arranged steps');
460
+ await db().update(stepsTable).set({status: 'failed'}).where(eq(stepsTable.id, failed.id));
461
+
462
+ const result = await nextStepForJob(jobId);
463
+
464
+ expect(result).toEqual({kind: 'done', status: 'failed'});
465
+ const after = await getStepsByJobId(jobId);
466
+ expect(after.find((step) => step.id === defaultGated.id)).toMatchObject({
467
+ status: 'skipped',
468
+ statusReason: 'default_gate_rejected',
469
+ evaluationTrace: [
470
+ {
471
+ expression: '!execution.failed',
472
+ roots: ['execution'],
473
+ fillTarget: 'step-dispatch',
474
+ evaluatedAt: 'step-dispatch',
475
+ value: 'false',
476
+ field: 'step.default_gate',
477
+ },
478
+ ],
479
+ });
480
+ });
481
+ });
482
+
483
+ function conditionExpression(source: string) {
484
+ return createWorkflowExpression({source, check: {mode: 'syntax'}});
485
+ }
486
+
487
+ function conditionTrace(
488
+ field: 'step.if',
489
+ expression: string,
490
+ roots: string[],
491
+ value: boolean,
492
+ degraded = false,
493
+ ) {
494
+ return {
495
+ expression,
496
+ roots,
497
+ fillTarget: 'step-dispatch',
498
+ evaluatedAt: 'step-dispatch',
499
+ value: String(value),
500
+ ...(degraded ? {degraded: true} : {}),
501
+ field,
502
+ };
503
+ }
504
+
505
+ function stepOutputField(stepKey: string, outputKey: string) {
506
+ return {
507
+ segments: [
508
+ {
509
+ kind: 'deferred' as const,
510
+ expression: createWorkflowExpression({
511
+ source: `steps.${stepKey}.outputs.${outputKey}`,
512
+ check: {mode: 'syntax'},
513
+ }),
514
+ roots: ['steps'],
515
+ fillTarget: 'step-dispatch' as const,
516
+ },
517
+ ],
518
+ };
519
+ }
520
+
521
+ describe('classifyReportedStep', () => {
522
+ it('allows a report for the current running attempt', () => {
523
+ const step = stepForClassification({status: 'running', currentAttempt: 2});
524
+
525
+ const result = classifyReportedStep(step, 2, 'job-1');
526
+
527
+ expect(result).toBe('proceed');
528
+ });
529
+
530
+ it('rejects a report ahead of the current attempt', () => {
531
+ const step = stepForClassification({currentAttempt: 2});
532
+
533
+ const result = classifyReportedStep(step, 3, 'job-1');
534
+
535
+ expect(result).toBeInstanceOf(StepAttemptAheadError);
536
+ expect(result).toMatchObject({stepId: step.id, jobId: 'job-1'});
537
+ });
538
+
539
+ it('ignores a stale report from an older attempt', () => {
540
+ const step = stepForClassification({currentAttempt: 2});
541
+
542
+ const result = classifyReportedStep(step, 1, 'job-1');
543
+
544
+ expect(result).toBe('noop');
545
+ });
546
+
547
+ it.each([
548
+ 'succeeded',
549
+ 'failed',
550
+ 'cancelled',
551
+ 'skipped',
552
+ ] as const)('ignores duplicate reports for a %s step', (status) => {
553
+ const step = stepForClassification({status});
554
+
555
+ const result = classifyReportedStep(step, 1, 'job-1');
556
+
557
+ expect(result).toBe('noop');
558
+ });
559
+
560
+ it('rejects a report for a pending step', () => {
561
+ const step = stepForClassification({status: 'pending'});
562
+
563
+ const result = classifyReportedStep(step, 1, 'job-1');
564
+
565
+ expect(result).toBeInstanceOf(StepNotRunningError);
566
+ });
567
+ });
568
+
569
+ describe('recordStepResult', () => {
570
+ test('succeeded on a non-final step → {jobFinished:false}', async () => {
571
+ const {jobId, steps} = await arrangeJobWithSteps(2);
572
+ await nextStepForJob(jobId);
573
+
574
+ const outcome = await recordStepResult({
575
+ jobId,
576
+ stepId: steps[0]?.id as string,
577
+ status: 'succeeded',
578
+ });
579
+
580
+ expect(outcome).toEqual({jobFinished: false});
581
+ expect((await getStepsByJobId(jobId))[0]?.status).toBe('succeeded');
582
+ });
583
+
584
+ test('succeeded on the final step → {jobFinished:true, succeeded}', async () => {
585
+ const {jobId, steps} = await arrangeJobWithSteps(1);
586
+ await nextStepForJob(jobId);
587
+
588
+ const outcome = await recordStepResult({
589
+ jobId,
590
+ stepId: steps[0]?.id as string,
591
+ status: 'succeeded',
592
+ });
593
+
594
+ expect(outcome).toEqual({jobFinished: true, status: 'succeeded'});
595
+ });
596
+
597
+ test('failed step → step failed, remaining pending, {jobFinished:false}', async () => {
598
+ const {jobId, steps} = await arrangeJobWithSteps(3);
599
+ await nextStepForJob(jobId);
600
+
601
+ const outcome = await recordStepResult({
602
+ jobId,
603
+ stepId: steps[0]?.id as string,
604
+ status: 'failed',
605
+ error: {message: 'boom'},
606
+ });
607
+
608
+ expect(outcome).toEqual({jobFinished: false});
609
+ const after = await getStepsByJobId(jobId);
610
+ expect(after[0]?.status).toBe('failed');
611
+ expect(after[0]?.error).toEqual({message: 'boom'});
612
+ expect(after[1]?.status).toBe('pending');
613
+ expect(after[2]?.status).toBe('pending');
614
+ });
615
+
616
+ test('after a failure, default-gated pending steps skip and finish the failed job', async () => {
617
+ const {jobId, steps} = await arrangeJobWithSteps(3);
618
+ await nextStepForJob(jobId);
619
+ await recordStepResult({
620
+ jobId,
621
+ stepId: steps[0]?.id as string,
622
+ status: 'failed',
623
+ error: {message: 'boom'},
624
+ });
625
+
626
+ const done = await nextStepForJob(jobId);
627
+
628
+ expect(done).toEqual({kind: 'done', status: 'failed'});
629
+ const after = await getStepsByJobId(jobId);
630
+ expect(after.map((step) => step.status)).toEqual(['failed', 'skipped', 'skipped']);
631
+ expect(after[1]?.statusReason).toBe('default_gate_rejected');
632
+ expect(after[2]?.statusReason).toBe('default_gate_rejected');
633
+ expect(await jobStepsSettledEvents(jobId)).toMatchObject([{status: 'failed'}]);
634
+ });
635
+
636
+ test('after a failure, if:true cleanup still runs before the job resolves failed', async () => {
637
+ const {jobId, steps} = await arrangeJobWithSteps(3);
638
+ const cleanup = steps[1];
639
+ if (!cleanup) throw new Error('Expected cleanup step');
640
+ await db()
641
+ .update(stepsTable)
642
+ .set({condition: conditionExpression('true')})
643
+ .where(eq(stepsTable.id, cleanup.id));
644
+ await nextStepForJob(jobId);
645
+ await recordStepResult({
646
+ jobId,
647
+ stepId: steps[0]?.id as string,
648
+ status: 'failed',
649
+ error: {message: 'boom'},
650
+ });
651
+
652
+ const next = await nextStepForJob(jobId);
653
+
654
+ expect(next).toEqual({
655
+ kind: 'step',
656
+ step: expect.objectContaining({id: cleanup.id}),
657
+ dispatched: true,
658
+ });
659
+ await recordStepResult({jobId, stepId: cleanup.id, status: 'succeeded'});
660
+ const done = await nextStepForJob(jobId);
661
+ expect(done).toEqual({kind: 'done', status: 'failed'});
662
+ const after = await getStepsByJobId(jobId);
663
+ expect(after.map((step) => step.status)).toEqual(['failed', 'succeeded', 'skipped']);
664
+ expect(after[2]?.statusReason).toBe('default_gate_rejected');
665
+ });
666
+
667
+ test('if:execution.failed runs only after an earlier step failed', async () => {
668
+ const {jobId, steps} = await arrangeJobWithSteps(2);
669
+ const upload = steps[1];
670
+ if (!upload) throw new Error('Expected upload step');
671
+ await db()
672
+ .update(stepsTable)
673
+ .set({condition: conditionExpression('execution.failed')})
674
+ .where(eq(stepsTable.id, upload.id));
675
+ await nextStepForJob(jobId);
676
+ await recordStepResult({
677
+ jobId,
678
+ stepId: steps[0]?.id as string,
679
+ status: 'failed',
680
+ error: {message: 'boom'},
681
+ });
682
+
683
+ const next = await nextStepForJob(jobId);
684
+
685
+ expect(next).toEqual({
686
+ kind: 'step',
687
+ step: expect.objectContaining({id: upload.id}),
688
+ dispatched: true,
689
+ });
690
+ await recordStepResult({jobId, stepId: upload.id, status: 'succeeded'});
691
+ expect(await nextStepForJob(jobId)).toEqual({kind: 'done', status: 'failed'});
692
+ });
693
+
694
+ test('never downgrades an already-terminal row', async () => {
695
+ const {jobId, steps} = await arrangeJobWithSteps(2);
696
+ await nextStepForJob(jobId);
697
+ await recordStepResult({jobId, stepId: steps[0]?.id as string, status: 'succeeded'});
698
+
699
+ // A late 'failed' report for the already-succeeded step must not downgrade it.
700
+ await recordStepResult({jobId, stepId: steps[0]?.id as string, status: 'failed'});
701
+
702
+ const after = await getStepsByJobId(jobId);
703
+ expect(after[0]?.status).toBe('succeeded');
704
+ expect(after[1]?.status).toBe('pending');
705
+ });
706
+
707
+ test('unknown stepId → StepNotFoundError', async () => {
708
+ const {jobId} = await arrangeJobWithSteps(1);
709
+
710
+ await expect(
711
+ recordStepResult({jobId, stepId: crypto.randomUUID(), status: 'succeeded'}),
712
+ ).rejects.toBeInstanceOf(StepNotFoundError);
713
+ });
714
+
715
+ test('cross-job stepId → StepNotFoundError', async () => {
716
+ const a = await arrangeJobWithSteps(1);
717
+ const b = await arrangeJobWithSteps(1);
718
+
719
+ await expect(
720
+ recordStepResult({jobId: a.jobId, stepId: b.steps[0]?.id as string, status: 'succeeded'}),
721
+ ).rejects.toBeInstanceOf(StepNotFoundError);
722
+ });
723
+
724
+ test('result for a pending (never-dispatched) step → StepNotRunningError', async () => {
725
+ const {jobId, steps} = await arrangeJobWithSteps(2);
726
+ // Skip nextStepForJob so the step stays pending (never handed out).
727
+
728
+ await expect(
729
+ recordStepResult({jobId, stepId: steps[0]?.id as string, status: 'succeeded'}),
730
+ ).rejects.toBeInstanceOf(StepNotRunningError);
731
+ });
732
+
733
+ test('duplicate succeeded report is a no-op', async () => {
734
+ const {jobId, steps} = await arrangeJobWithSteps(2);
735
+ await nextStepForJob(jobId);
736
+ await recordStepResult({jobId, stepId: steps[0]?.id as string, status: 'succeeded'});
737
+
738
+ const outcome = await recordStepResult({
739
+ jobId,
740
+ stepId: steps[0]?.id as string,
741
+ status: 'succeeded',
742
+ });
743
+
744
+ expect(outcome).toEqual({jobFinished: false});
745
+ const after = await getStepsByJobId(jobId);
746
+ expect(after[0]?.status).toBe('succeeded');
747
+ expect(after[1]?.status).toBe('pending');
748
+ });
749
+
750
+ test('duplicate failed report is a no-op and later steps remain dispatchable', async () => {
751
+ const {jobId, steps} = await arrangeJobWithSteps(3);
752
+ await nextStepForJob(jobId);
753
+ await recordStepResult({jobId, stepId: steps[0]?.id as string, status: 'failed'});
754
+
755
+ const outcome = await recordStepResult({
756
+ jobId,
757
+ stepId: steps[0]?.id as string,
758
+ status: 'failed',
759
+ });
760
+
761
+ expect(outcome).toEqual({jobFinished: false});
762
+ const after = await getStepsByJobId(jobId);
763
+ expect(after[0]?.status).toBe('failed');
764
+ expect(after[1]?.status).toBe('pending');
765
+ expect(after[2]?.status).toBe('pending');
766
+ expect(await jobStepsSettledEvents(jobId)).toHaveLength(0);
767
+ });
768
+
769
+ test('coerces declared output before persisting and filling later step config', async () => {
770
+ const {jobId, steps} = await arrangeJobWithSteps(2);
771
+ const producer = steps[0];
772
+ const consumer = steps[1];
773
+ if (!producer || !consumer) throw new Error('Expected arranged steps');
774
+ const countPlan = stepOutputField('build', 'count');
775
+ await db()
776
+ .update(stepsTable)
777
+ .set({
778
+ key: 'build',
779
+ config: {run: 'build', outputs: {count: {type: 'number'}}},
780
+ })
781
+ .where(eq(stepsTable.id, producer.id));
782
+ await db()
783
+ .update(stepsTable)
784
+ .set({
785
+ key: 'deploy',
786
+ config: {run: 'deploy'},
787
+ configPlan: {env: {COUNT: countPlan}},
788
+ })
789
+ .where(eq(stepsTable.id, consumer.id));
790
+ await nextStepForJob(jobId);
791
+
792
+ await recordStepResult({
793
+ jobId,
794
+ stepId: producer.id,
795
+ status: 'succeeded',
796
+ output: {count: '42'},
797
+ });
798
+ const next = await nextStepForJob(jobId);
799
+
800
+ const attempts = await getStepAttempts(jobId);
801
+ expect(attempts.find((attempt) => attempt.stepId === producer.id)).toMatchObject({
802
+ status: 'succeeded',
803
+ output: {count: 42},
804
+ });
805
+ expect(next).toEqual({
806
+ kind: 'step',
807
+ step: expect.objectContaining({
808
+ id: consumer.id,
809
+ config: {run: 'deploy', env: {COUNT: '42'}},
810
+ }),
811
+ dispatched: true,
812
+ });
813
+ });
814
+
815
+ test('coerces JSON output against its declared schema', async () => {
816
+ const {jobId, steps} = await arrangeJobWithSteps(1);
817
+ const stepId = steps[0]?.id as string;
818
+ await db()
819
+ .update(stepsTable)
820
+ .set({
821
+ config: {
822
+ run: 'build',
823
+ outputs: {
824
+ meta: {
825
+ type: 'json',
826
+ schema: {
827
+ type: 'object',
828
+ properties: {
829
+ registry: {type: 'string'},
830
+ size_bytes: {type: 'integer'},
831
+ },
832
+ required: ['registry', 'size_bytes'],
833
+ additionalProperties: false,
834
+ },
835
+ },
836
+ },
837
+ },
838
+ })
839
+ .where(eq(stepsTable.id, stepId));
840
+ await nextStepForJob(jobId);
841
+
842
+ const outcome = await recordStepResult({
843
+ jobId,
844
+ stepId,
845
+ status: 'succeeded',
846
+ output: {meta: '{"registry":"ghcr.io","size_bytes":"42"}'},
847
+ });
848
+
849
+ expect(outcome).toEqual({jobFinished: true, status: 'succeeded'});
850
+ const [attempt] = await getStepAttempts(jobId);
851
+ expect(attempt?.output).toEqual({meta: {registry: 'ghcr.io', size_bytes: 42}});
852
+ });
853
+
854
+ it.each([
855
+ ['missing declared key', {}, 'outputs.count'],
856
+ ['undeclared emitted key', {count: '1', extra: 'nope'}, 'outputs.extra'],
857
+ ['non-parsing scalar', {count: 'nope'}, 'outputs.count'],
858
+ ])('fails declared output report for %s', async (_label, output, field) => {
859
+ const {jobId, steps} = await arrangeJobWithSteps(1);
860
+ const stepId = steps[0]?.id as string;
861
+ await db()
862
+ .update(stepsTable)
863
+ .set({config: {run: 'build', outputs: {count: {type: 'number'}}}})
864
+ .where(eq(stepsTable.id, stepId));
865
+ await nextStepForJob(jobId);
866
+
867
+ const outcome = await recordStepResult({jobId, stepId, status: 'succeeded', output});
868
+
869
+ expect(outcome).toEqual({jobFinished: true, status: 'failed'});
870
+ const after = await getStepsByJobId(jobId);
871
+ expect(after[0]).toMatchObject({
872
+ status: 'failed',
873
+ error: {reason: 'output_invalid', field},
874
+ });
875
+ const [attempt] = await getStepAttempts(jobId);
876
+ expect(attempt).toMatchObject({
877
+ status: 'failed',
878
+ output: null,
879
+ error: {reason: 'output_invalid', field},
880
+ });
881
+ });
882
+
883
+ test('keeps untyped steps open to arbitrary output keys', async () => {
884
+ const {jobId, steps} = await arrangeJobWithSteps(1);
885
+ const stepId = steps[0]?.id as string;
886
+ await nextStepForJob(jobId);
887
+
888
+ const outcome = await recordStepResult({
889
+ jobId,
890
+ stepId,
891
+ status: 'succeeded',
892
+ output: {count: 'not typed', extra: 'allowed'},
893
+ });
894
+
895
+ expect(outcome).toEqual({jobFinished: true, status: 'succeeded'});
896
+ const [attempt] = await getStepAttempts(jobId);
897
+ expect(attempt?.output).toEqual({count: 'not typed', extra: 'allowed'});
898
+ });
899
+ });
900
+
901
+ describe('nextStepForJob concurrency', () => {
902
+ test('concurrent pulls return the same step', async () => {
903
+ const {jobId} = await arrangeJobWithSteps(3);
904
+
905
+ const [a, b] = await Promise.all([nextStepForJob(jobId), nextStepForJob(jobId)]);
906
+
907
+ expect(a.kind).toBe('step');
908
+ expect(b.kind).toBe('step');
909
+ const idA = a.kind === 'step' ? a.step.id : null;
910
+ const idB = b.kind === 'step' ? b.step.id : null;
911
+ expect(idA).toBe(idB);
912
+ const running = (await getStepsByJobId(jobId)).filter((s) => s.status === 'running');
913
+ expect(running).toHaveLength(1);
914
+ // The onConflictDoNothing backstop keeps a single attempt row under contention.
915
+ expect(await getStepAttempts(jobId)).toMatchObject([{executionOrder: 1}]);
916
+ });
917
+ });
918
+
919
+ describe('recordStepResult job-completion event', () => {
920
+ test('enqueues exactly one job-completed event when the final step finishes the job', async () => {
921
+ const {jobId, steps} = await arrangeJobWithSteps(1);
922
+ await nextStepForJob(jobId);
923
+
924
+ const outcome = await recordStepResult({
925
+ jobId,
926
+ stepId: steps[0]?.id as string,
927
+ status: 'succeeded',
928
+ });
929
+
930
+ expect(outcome).toEqual({jobFinished: true, status: 'succeeded'});
931
+ const events = await jobStepsSettledEvents(jobId);
932
+ expect(events).toHaveLength(1);
933
+ expect(events[0]?.status).toBe('succeeded');
934
+ });
935
+
936
+ test('does not enqueue a completion event while steps remain', async () => {
937
+ const {jobId, steps} = await arrangeJobWithSteps(2);
938
+ await nextStepForJob(jobId);
939
+
940
+ const outcome = await recordStepResult({
941
+ jobId,
942
+ stepId: steps[0]?.id as string,
943
+ status: 'succeeded',
944
+ });
945
+
946
+ expect(outcome).toEqual({jobFinished: false});
947
+ expect(await jobStepsSettledEvents(jobId)).toHaveLength(0);
948
+ });
949
+
950
+ test('a failed final step enqueues one completion event with status failed', async () => {
951
+ const {jobId, steps} = await arrangeJobWithSteps(1);
952
+ await nextStepForJob(jobId);
953
+
954
+ await recordStepResult({
955
+ jobId,
956
+ stepId: steps[0]?.id as string,
957
+ status: 'failed',
958
+ error: {message: 'boom'},
959
+ });
960
+
961
+ const events = await jobStepsSettledEvents(jobId);
962
+ expect(events).toHaveLength(1);
963
+ expect(events[0]?.status).toBe('failed');
964
+ });
965
+
966
+ test('a failed non-final step enqueues completion only after remaining steps skip', async () => {
967
+ const {jobId, steps} = await arrangeJobWithSteps(2);
968
+ await nextStepForJob(jobId);
969
+
970
+ await recordStepResult({
971
+ jobId,
972
+ stepId: steps[0]?.id as string,
973
+ status: 'failed',
974
+ error: {message: 'boom'},
975
+ });
976
+
977
+ expect(await jobStepsSettledEvents(jobId)).toHaveLength(0);
978
+
979
+ await nextStepForJob(jobId);
980
+
981
+ const events = await jobStepsSettledEvents(jobId);
982
+ expect(events).toHaveLength(1);
983
+ expect(events[0]?.status).toBe('failed');
984
+ });
985
+
986
+ test('a duplicate final report does not enqueue a second completion event', async () => {
987
+ const {jobId, steps} = await arrangeJobWithSteps(1);
988
+ await nextStepForJob(jobId);
989
+ await recordStepResult({jobId, stepId: steps[0]?.id as string, status: 'succeeded'});
990
+
991
+ const duplicate = await recordStepResult({
992
+ jobId,
993
+ stepId: steps[0]?.id as string,
994
+ status: 'succeeded',
995
+ });
996
+
997
+ expect(duplicate).toEqual({jobFinished: true, status: 'succeeded'});
998
+ expect(await jobStepsSettledEvents(jobId)).toHaveLength(1);
999
+ });
1000
+
1001
+ test('concurrent final reports enqueue exactly one completion event', async () => {
1002
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1003
+ const stepId = steps[0]?.id as string;
1004
+ await nextStepForJob(jobId);
1005
+
1006
+ const outcomes = await Promise.all([
1007
+ recordStepResult({jobId, stepId, status: 'succeeded', attempt: 1, exitCode: 0}),
1008
+ recordStepResult({jobId, stepId, status: 'succeeded', attempt: 1, exitCode: 0}),
1009
+ ]);
1010
+
1011
+ expect(outcomes).toEqual([
1012
+ {jobFinished: true, status: 'succeeded'},
1013
+ {jobFinished: true, status: 'succeeded'},
1014
+ ]);
1015
+ expect(await jobStepsSettledEvents(jobId)).toHaveLength(1);
1016
+ });
1017
+ });
1018
+
1019
+ describe('step attempts', () => {
1020
+ test('dispatch opens a running attempt row at attempt 1', async () => {
1021
+ const {jobId, steps} = await arrangeJobWithSteps(2);
1022
+
1023
+ await nextStepForJob(jobId);
1024
+
1025
+ const attempts = await getStepAttempts(jobId);
1026
+ expect(attempts).toHaveLength(1);
1027
+ expect(attempts[0]).toMatchObject({
1028
+ stepId: steps[0]?.id,
1029
+ attempt: 1,
1030
+ executionOrder: 1,
1031
+ status: 'running',
1032
+ });
1033
+ });
1034
+
1035
+ test('re-delivery does not open a second attempt row', async () => {
1036
+ const {jobId} = await arrangeJobWithSteps(2);
1037
+
1038
+ await nextStepForJob(jobId);
1039
+ await nextStepForJob(jobId);
1040
+
1041
+ expect(await getStepAttempts(jobId)).toHaveLength(1);
1042
+ });
1043
+
1044
+ test('reporting finalizes the attempt with status and exit code', async () => {
1045
+ const {jobId, steps} = await arrangeJobWithSteps(2);
1046
+ await nextStepForJob(jobId);
1047
+
1048
+ await recordStepResult({
1049
+ jobId,
1050
+ stepId: steps[0]?.id as string,
1051
+ status: 'succeeded',
1052
+ exitCode: 0,
1053
+ });
1054
+
1055
+ const attempts = await getStepAttempts(jobId);
1056
+ expect(attempts).toHaveLength(1);
1057
+ expect(attempts[0]).toMatchObject({attempt: 1, status: 'succeeded', exitCode: 0, error: null});
1058
+ expect(attempts[0]?.finishedAt).not.toBeNull();
1059
+ const [step] = await getStepsByJobId(jobId);
1060
+ expect(step?.currentAttempt).toBe(attempts[0]?.attempt);
1061
+ expect(step?.status).toBe(attempts[0]?.status);
1062
+ });
1063
+
1064
+ test('reporting stores agent response independently of structured output', async () => {
1065
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1066
+ const stepId = steps[0]?.id as string;
1067
+ await nextStepForJob(jobId);
1068
+
1069
+ await recordStepResult({
1070
+ jobId,
1071
+ stepId,
1072
+ status: 'succeeded',
1073
+ response: 'The implementation is complete.',
1074
+ output: {summary: 'done'},
1075
+ });
1076
+
1077
+ const [attempt] = await getStepAttempts(jobId);
1078
+ expect(attempt).toMatchObject({
1079
+ status: 'succeeded',
1080
+ response: 'The implementation is complete.',
1081
+ output: {summary: 'done'},
1082
+ });
1083
+ });
1084
+
1085
+ test('response survives output_invalid coercion failure', async () => {
1086
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1087
+ const stepId = steps[0]?.id as string;
1088
+ await db()
1089
+ .update(stepsTable)
1090
+ .set({config: {run: 'build', outputs: {count: {type: 'number'}}}})
1091
+ .where(eq(stepsTable.id, stepId));
1092
+ await nextStepForJob(jobId);
1093
+
1094
+ await recordStepResult({
1095
+ jobId,
1096
+ stepId,
1097
+ status: 'succeeded',
1098
+ response: 'I could not infer the numeric count.',
1099
+ output: {count: 'not-a-number'},
1100
+ });
1101
+
1102
+ const [attempt] = await getStepAttempts(jobId);
1103
+ expect(attempt).toMatchObject({
1104
+ status: 'failed',
1105
+ response: 'I could not infer the numeric count.',
1106
+ output: null,
1107
+ error: {reason: 'output_invalid'},
1108
+ });
1109
+ });
1110
+
1111
+ test('reporting stores log outcome and emits the terminal attempt log identity once', async () => {
1112
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1113
+ const stepId = steps[0]?.id as string;
1114
+ await nextStepForJob(jobId);
1115
+
1116
+ await recordStepResult({
1117
+ jobId,
1118
+ stepId,
1119
+ status: 'failed',
1120
+ error: {message: 'boom'},
1121
+ logOutcome: 'abandoned',
1122
+ });
1123
+ await recordStepResult({
1124
+ jobId,
1125
+ stepId,
1126
+ status: 'failed',
1127
+ error: {message: 'boom'},
1128
+ logOutcome: 'abandoned',
1129
+ });
1130
+
1131
+ const [attempt] = await getStepAttempts(jobId);
1132
+ expect(attempt).toMatchObject({attempt: 1, status: 'failed', logOutcome: 'abandoned'});
1133
+ expect(await stepAttemptTerminatedEvents(jobId)).toEqual([
1134
+ {
1135
+ jobId,
1136
+ workflowRunId: expect.any(String),
1137
+ workflowRunAttemptId: expect.any(String),
1138
+ workspaceId: expect.any(String),
1139
+ projectId: expect.any(String),
1140
+ stepId,
1141
+ attempt: 1,
1142
+ logOutcome: 'abandoned',
1143
+ },
1144
+ ]);
1145
+ });
1146
+
1147
+ test('a failed report finalizes the attempt with its error and exit code', async () => {
1148
+ const {jobId, steps} = await arrangeJobWithSteps(2);
1149
+ await nextStepForJob(jobId);
1150
+
1151
+ await recordStepResult({
1152
+ jobId,
1153
+ stepId: steps[0]?.id as string,
1154
+ status: 'failed',
1155
+ error: {message: 'boom'},
1156
+ exitCode: 1,
1157
+ });
1158
+
1159
+ const [attempt] = await getStepAttempts(jobId);
1160
+ expect(attempt).toMatchObject({status: 'failed', exitCode: 1, error: {message: 'boom'}});
1161
+ });
1162
+
1163
+ test('reporting creates a missing running attempt before finalization', async () => {
1164
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1165
+ const stepId = steps[0]?.id as string;
1166
+ // A step may already be running before its attempt row exists. Reporting
1167
+ // should backfill the attempt row and finalize it.
1168
+ await db().update(stepsTable).set({status: 'running'}).where(eq(stepsTable.id, stepId));
1169
+
1170
+ const outcome = await recordStepResult({
1171
+ jobId,
1172
+ stepId,
1173
+ status: 'succeeded',
1174
+ output: {summary: 'ok'},
1175
+ exitCode: 0,
1176
+ });
1177
+
1178
+ expect(outcome).toEqual({jobFinished: true, status: 'succeeded'});
1179
+ const [attempt] = await getStepAttempts(jobId);
1180
+ expect(attempt).toMatchObject({
1181
+ stepId,
1182
+ attempt: 1,
1183
+ status: 'succeeded',
1184
+ output: {summary: 'ok'},
1185
+ exitCode: 0,
1186
+ });
1187
+ const [step] = await getStepsByJobId(jobId);
1188
+ expect(step?.currentAttempt).toBe(attempt?.attempt);
1189
+ expect(step?.status).toBe(attempt?.status);
1190
+ });
1191
+
1192
+ test('persists structured output on the attempt row', async () => {
1193
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1194
+ const stepId = steps[0]?.id as string;
1195
+ await nextStepForJob(jobId);
1196
+
1197
+ await recordStepResult({
1198
+ jobId,
1199
+ stepId,
1200
+ status: 'succeeded',
1201
+ output: {artifact: 'dist/app.tgz'},
1202
+ exitCode: 0,
1203
+ });
1204
+
1205
+ const [attempt] = await getStepAttempts(jobId);
1206
+ expect(attempt?.output).toEqual({artifact: 'dist/app.tgz'});
1207
+ });
1208
+
1209
+ test('a duplicate report leaves the finalized attempt unchanged (never-downgrade)', async () => {
1210
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1211
+ const stepId = steps[0]?.id as string;
1212
+ await nextStepForJob(jobId);
1213
+ await recordStepResult({jobId, stepId, status: 'succeeded', exitCode: 0});
1214
+
1215
+ await recordStepResult({jobId, stepId, status: 'failed', exitCode: 9});
1216
+
1217
+ const [attempt] = await getStepAttempts(jobId);
1218
+ expect(attempt).toMatchObject({status: 'succeeded', exitCode: 0});
1219
+ });
1220
+
1221
+ test('rejects non-positive attempt rows at the database boundary', async () => {
1222
+ const {steps} = await arrangeJobWithSteps(1);
1223
+ const jobExecutionId = steps[0]?.jobExecutionId as string;
1224
+
1225
+ await expect(
1226
+ db()
1227
+ .insert(stepAttemptsTable)
1228
+ .values({
1229
+ jobExecutionId,
1230
+ stepId: steps[0]?.id as string,
1231
+ attempt: 0,
1232
+ executionOrder: 1,
1233
+ status: 'running',
1234
+ }),
1235
+ ).rejects.toThrow();
1236
+ });
1237
+
1238
+ test('rejects non-positive execution order rows at the database boundary', async () => {
1239
+ const {steps} = await arrangeJobWithSteps(1);
1240
+ const jobExecutionId = steps[0]?.jobExecutionId as string;
1241
+
1242
+ await expect(
1243
+ db()
1244
+ .insert(stepAttemptsTable)
1245
+ .values({
1246
+ jobExecutionId,
1247
+ stepId: steps[0]?.id as string,
1248
+ attempt: 1,
1249
+ executionOrder: 0,
1250
+ status: 'running',
1251
+ }),
1252
+ ).rejects.toThrow();
1253
+ });
1254
+
1255
+ test('rejects duplicate execution order rows for the same execution', async () => {
1256
+ const {steps} = await arrangeJobWithSteps(2);
1257
+ const jobExecutionId = steps[0]?.jobExecutionId as string;
1258
+
1259
+ await db()
1260
+ .insert(stepAttemptsTable)
1261
+ .values({
1262
+ jobExecutionId,
1263
+ stepId: steps[0]?.id as string,
1264
+ attempt: 1,
1265
+ executionOrder: 1,
1266
+ status: 'running',
1267
+ });
1268
+
1269
+ await expect(
1270
+ db()
1271
+ .insert(stepAttemptsTable)
1272
+ .values({
1273
+ jobExecutionId,
1274
+ stepId: steps[1]?.id as string,
1275
+ attempt: 1,
1276
+ executionOrder: 1,
1277
+ status: 'running',
1278
+ }),
1279
+ ).rejects.toThrow();
1280
+ });
1281
+
1282
+ test('rejects pending attempt rows at the database boundary', async () => {
1283
+ const {steps} = await arrangeJobWithSteps(1);
1284
+ const jobExecutionId = steps[0]?.jobExecutionId as string;
1285
+
1286
+ await expect(
1287
+ db()
1288
+ .insert(stepAttemptsTable)
1289
+ .values({
1290
+ jobExecutionId,
1291
+ stepId: steps[0]?.id as string,
1292
+ attempt: 1,
1293
+ executionOrder: 1,
1294
+ status: 'pending',
1295
+ }),
1296
+ ).rejects.toThrow();
1297
+ });
1298
+
1299
+ test('rejects a report whose attempt is ahead of the current attempt', async () => {
1300
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1301
+ await nextStepForJob(jobId);
1302
+
1303
+ await expect(
1304
+ recordStepResult({jobId, stepId: steps[0]?.id as string, status: 'succeeded', attempt: 2}),
1305
+ ).rejects.toBeInstanceOf(StepAttemptAheadError);
1306
+ });
1307
+
1308
+ test('a stale older-attempt report is an idempotent no-op', async () => {
1309
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1310
+ const stepId = steps[0]?.id as string;
1311
+ await nextStepForJob(jobId);
1312
+
1313
+ await db()
1314
+ .update(stepsTable)
1315
+ .set({currentAttempt: 2, status: 'running'})
1316
+ .where(eq(stepsTable.id, stepId));
1317
+
1318
+ const outcome = await recordStepResult({
1319
+ jobId,
1320
+ stepId,
1321
+ status: 'failed',
1322
+ error: {message: 'late'},
1323
+ attempt: 1,
1324
+ });
1325
+
1326
+ expect(outcome).toEqual({jobFinished: false});
1327
+ const after = await getStepsByJobId(jobId);
1328
+ expect(after[0]?.status).toBe('running'); // projection untouched by the stale report
1329
+ });
1330
+
1331
+ test('a stale report on an already-finished job reports finished without a second completion event', async () => {
1332
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1333
+ const stepId = steps[0]?.id as string;
1334
+ await nextStepForJob(jobId);
1335
+ await recordStepResult({jobId, stepId, status: 'succeeded', exitCode: 0});
1336
+ await db().update(stepsTable).set({currentAttempt: 2}).where(eq(stepsTable.id, stepId));
1337
+
1338
+ const outcome = await recordStepResult({
1339
+ jobId,
1340
+ stepId,
1341
+ status: 'failed',
1342
+ error: {message: 'late'},
1343
+ attempt: 1,
1344
+ });
1345
+
1346
+ expect(outcome).toEqual({jobFinished: true, status: 'succeeded'});
1347
+ // The applied-gated outbox write must not fire on the stale path.
1348
+ expect(await jobStepsSettledEvents(jobId)).toHaveLength(1);
1349
+ });
1350
+ });
1351
+
1352
+ describe('gate evaluation', () => {
1353
+ async function attachGate(stepId: string, gate: Record<string, unknown>): Promise<void> {
1354
+ await db()
1355
+ .update(stepsTable)
1356
+ .set({config: {run: 'echo hi', gate}})
1357
+ .where(eq(stepsTable.id, stepId));
1358
+ }
1359
+
1360
+ test('a passing gate succeeds a step despite a non-zero command exit', async () => {
1361
+ const {jobId, steps} = await arrangeJobWithSteps(1);
1362
+ const stepId = steps[0]?.id as string;
1363
+ await attachGate(stepId, {
1364
+ success: {language: 'cel', check: 'syntax', source: 'step.exit_code == 1'},
1365
+ });
1366
+ await nextStepForJob(jobId);
1367
+
1368
+ const outcome = await recordStepResult({
1369
+ jobId,
1370
+ stepId,
1371
+ status: 'failed',
1372
+ error: {message: 'exit 1'},
1373
+ exitCode: 1,
1374
+ });
1375
+
1376
+ expect(outcome).toEqual({jobFinished: true, status: 'succeeded'});
1377
+ expect((await getStepsByJobId(jobId))[0]?.status).toBe('succeeded');
1378
+ const [attempt] = await getStepAttempts(jobId);
1379
+ expect(attempt?.gateResult).toMatchObject({passed: true});
1380
+ });
1381
+ });
1382
+
1383
+ describe('durable gate restart', () => {
1384
+ async function restartEvents(jobId: string): Promise<WorkflowsStepRestartEnqueuedEventDto[]> {
1385
+ const rows = await db()
1386
+ .select({payload: workflowsOutbox.payload})
1387
+ .from(workflowsOutbox)
1388
+ .where(
1389
+ and(
1390
+ eq(workflowsOutbox.eventType, WORKFLOWS_STEP_RESTART_ENQUEUED),
1391
+ sql`${workflowsOutbox.payload}->>'jobId' = ${jobId}`,
1392
+ ),
1393
+ );
1394
+ return rows.map((row) => row.payload as WorkflowsStepRestartEnqueuedEventDto);
1395
+ }
1396
+
1397
+ async function restartEventCount(jobId: string): Promise<number> {
1398
+ return (await restartEvents(jobId)).length;
1399
+ }
1400
+
1401
+ // producer (named) → reviewer (gated `success: step.exit_code == 0`, on_failure restart_from producer)
1402
+ async function arrangeGatedJob(params: {
1403
+ source: string;
1404
+ outputs?: Record<string, unknown>;
1405
+ feedback?: string;
1406
+ feedbackTemplate?: ReturnType<typeof plannedField>;
1407
+ }): Promise<{jobId: string; producer: string; reviewer: string}> {
1408
+ const {jobId, steps} = await arrangeJobWithSteps(2);
1409
+ const producer = steps[0]?.id as string;
1410
+ const reviewer = steps[1]?.id as string;
1411
+ await db().update(stepsTable).set({key: 'producer'}).where(eq(stepsTable.id, producer));
1412
+ await db()
1413
+ .update(stepsTable)
1414
+ .set({
1415
+ config: {
1416
+ run: 'review',
1417
+ ...(params.outputs === undefined ? {} : {outputs: params.outputs}),
1418
+ gate: {
1419
+ success: {language: 'cel', check: 'syntax', source: params.source},
1420
+ on_failure: {
1421
+ restart_from: 'producer',
1422
+ ...(params.feedback === undefined ? {} : {feedback: params.feedback}),
1423
+ ...(params.feedbackTemplate === undefined
1424
+ ? {}
1425
+ : {feedback_template: params.feedbackTemplate}),
1426
+ },
1427
+ },
1428
+ },
1429
+ })
1430
+ .where(eq(stepsTable.id, reviewer));
1431
+ return {jobId, producer, reviewer};
1432
+ }
1433
+
1434
+ async function runStep(jobId: string, stepId: string, exitCode: number, response?: string) {
1435
+ await nextStepForJob(jobId);
1436
+ return recordStepResult({
1437
+ jobId,
1438
+ stepId,
1439
+ status: exitCode === 0 ? 'succeeded' : 'failed',
1440
+ ...(exitCode === 0 ? {} : {error: {message: `exit ${exitCode}`}}),
1441
+ exitCode,
1442
+ ...(response === undefined ? {} : {response}),
1443
+ });
1444
+ }
1445
+
1446
+ test('a failing gate rewinds the job to the restart_from step, keeping it running', async () => {
1447
+ const {jobId, producer, reviewer} = await arrangeGatedJob({source: 'step.exit_code == 0'});
1448
+
1449
+ await runStep(jobId, producer, 0); // producer succeeds, attempt 1
1450
+ const restart = await runStep(jobId, reviewer, 1, 'Needs another build.'); // reviewer gate fails → restart
1451
+
1452
+ expect(restart).toEqual({jobFinished: false});
1453
+ const after = await getStepsByJobId(jobId);
1454
+ expect(after.map((s) => s.status)).toEqual(['pending', 'pending']); // rewound
1455
+ expect(after.every((s) => s.currentAttempt === 2)).toBe(true); // bumped
1456
+ expect(await restartEventCount(jobId)).toBe(1);
1457
+ // History preserved.
1458
+ const attempts = await getStepAttempts(jobId);
1459
+ expect(attempts.find((a) => a.stepId === producer && a.attempt === 1)?.status).toBe(
1460
+ 'succeeded',
1461
+ );
1462
+ const reviewerAttempt = attempts.find((a) => a.stepId === reviewer && a.attempt === 1);
1463
+ expect(reviewerAttempt?.status).toBe('failed');
1464
+ expect(reviewerAttempt?.response).toBe('Needs another build.');
1465
+ expect(reviewerAttempt?.restartFeedback).toBeTruthy();
1466
+ });
1467
+
1468
+ test('a retried step materializes restart feedback and source attempt output', async () => {
1469
+ const {jobId, producer, reviewer} = await arrangeGatedJob({
1470
+ source: 'step.exit_code == 0',
1471
+ feedback: 'failed',
1472
+ feedbackTemplate: plannedField('step.feedback', `failed: \${{ step.outputs.summary }}`),
1473
+ });
1474
+ await db()
1475
+ .update(stepsTable)
1476
+ .set({
1477
+ configPlan: {
1478
+ run: plannedField(
1479
+ 'run',
1480
+ `fix \${{ step.is_retry ? step.restart.feedback : 'fresh' }} from \${{ step.is_retry ? step.restart.from.outputs.summary : 'none' }}`,
1481
+ ),
1482
+ },
1483
+ })
1484
+ .where(eq(stepsTable.id, producer));
1485
+
1486
+ await runStep(jobId, producer, 0);
1487
+ await nextStepForJob(jobId);
1488
+ const restart = await recordStepResult({
1489
+ jobId,
1490
+ stepId: reviewer,
1491
+ status: 'failed',
1492
+ output: {summary: 'unit failed'},
1493
+ exitCode: 1,
1494
+ });
1495
+ const retry = await nextStepForJob(jobId);
1496
+
1497
+ expect(restart).toEqual({jobFinished: false});
1498
+ expect(retry).toEqual({
1499
+ kind: 'step',
1500
+ step: expect.objectContaining({
1501
+ id: producer,
1502
+ config: {
1503
+ run: `fix "\${__sf_2}" from "\${__sf_3}"`,
1504
+ env: expect.objectContaining({
1505
+ __sf_2: 'failed: unit failed',
1506
+ __sf_3: 'unit failed',
1507
+ }),
1508
+ },
1509
+ }),
1510
+ dispatched: true,
1511
+ });
1512
+ const attempts = await getStepAttempts(jobId);
1513
+ const reviewerAttempt = attempts.find((attempt) => attempt.stepId === reviewer);
1514
+ expect(reviewerAttempt?.restartFeedback).toBe('failed: unit failed');
1515
+ });
1516
+
1517
+ test('a passing rerun after a restart completes the job', async () => {
1518
+ const {jobId, producer, reviewer} = await arrangeGatedJob({source: 'step.exit_code == 0'});
1519
+
1520
+ await runStep(jobId, producer, 0);
1521
+ await runStep(jobId, reviewer, 1); // restart
1522
+ await runStep(jobId, producer, 0); // attempt 2
1523
+ const done = await runStep(jobId, reviewer, 0); // gate passes
1524
+
1525
+ expect(done).toEqual({jobFinished: true, status: 'succeeded'});
1526
+ expect((await getStepsByJobId(jobId)).map((s) => s.status)).toEqual(['succeeded', 'succeeded']);
1527
+ expect(
1528
+ (await getStepAttempts(jobId)).map((attempt) => ({
1529
+ stepId: attempt.stepId,
1530
+ attempt: attempt.attempt,
1531
+ executionOrder: attempt.executionOrder,
1532
+ })),
1533
+ ).toEqual([
1534
+ {stepId: producer, attempt: 1, executionOrder: 1},
1535
+ {stepId: reviewer, attempt: 1, executionOrder: 2},
1536
+ {stepId: producer, attempt: 2, executionOrder: 3},
1537
+ {stepId: reviewer, attempt: 2, executionOrder: 4},
1538
+ ]);
1539
+ });
1540
+
1541
+ test('a duplicate report of a superseded attempt does not restart twice', async () => {
1542
+ const {jobId, producer, reviewer} = await arrangeGatedJob({source: 'step.exit_code == 0'});
1543
+
1544
+ await runStep(jobId, producer, 0);
1545
+ await runStep(jobId, reviewer, 1); // restart (reviewer now pending at attempt 2)
1546
+
1547
+ // Late duplicate of reviewer attempt 1.
1548
+ const dup = await recordStepResult({
1549
+ jobId,
1550
+ stepId: reviewer,
1551
+ status: 'failed',
1552
+ error: {message: 'late'},
1553
+ exitCode: 1,
1554
+ attempt: 1,
1555
+ });
1556
+
1557
+ expect(dup).toEqual({jobFinished: false}); // stale no-op
1558
+ expect(await restartEventCount(jobId)).toBe(1);
1559
+ });
1560
+ });