@shipfox/api-definitions 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 (401) 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 +215 -0
  5. package/LICENSE +21 -0
  6. package/dist/config.d.ts +10 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +35 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/core/entities/index.d.ts +5 -0
  11. package/dist/core/entities/index.d.ts.map +1 -0
  12. package/dist/core/entities/index.js +6 -0
  13. package/dist/core/entities/index.js.map +1 -0
  14. package/dist/core/entities/integration-context.d.ts +11 -0
  15. package/dist/core/entities/integration-context.d.ts.map +1 -0
  16. package/dist/core/entities/integration-context.js +3 -0
  17. package/dist/core/entities/integration-context.js.map +1 -0
  18. package/dist/core/entities/sync-state.d.ts +19 -0
  19. package/dist/core/entities/sync-state.d.ts.map +1 -0
  20. package/dist/core/entities/sync-state.js +20 -0
  21. package/dist/core/entities/sync-state.js.map +1 -0
  22. package/dist/core/entities/workflow-definition.d.ts +35 -0
  23. package/dist/core/entities/workflow-definition.d.ts.map +1 -0
  24. package/dist/core/entities/workflow-definition.js +3 -0
  25. package/dist/core/entities/workflow-definition.js.map +1 -0
  26. package/dist/core/entities/workflow-model.d.ts +138 -0
  27. package/dist/core/entities/workflow-model.d.ts.map +1 -0
  28. package/dist/core/entities/workflow-model.js +3 -0
  29. package/dist/core/entities/workflow-model.js.map +1 -0
  30. package/dist/core/errors.d.ts +10 -0
  31. package/dist/core/errors.d.ts.map +1 -0
  32. package/dist/core/errors.js +14 -0
  33. package/dist/core/errors.js.map +1 -0
  34. package/dist/core/has-agent-step-integrations.d.ts +3 -0
  35. package/dist/core/has-agent-step-integrations.d.ts.map +1 -0
  36. package/dist/core/has-agent-step-integrations.js +5 -0
  37. package/dist/core/has-agent-step-integrations.js.map +1 -0
  38. package/dist/core/index.d.ts +9 -0
  39. package/dist/core/index.d.ts.map +1 -0
  40. package/dist/core/index.js +9 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/core/parse-definition.d.ts +9 -0
  43. package/dist/core/parse-definition.d.ts.map +1 -0
  44. package/dist/core/parse-definition.js +17 -0
  45. package/dist/core/parse-definition.js.map +1 -0
  46. package/dist/core/sync-definitions.d.ts +44 -0
  47. package/dist/core/sync-definitions.d.ts.map +1 -0
  48. package/dist/core/sync-definitions.js +140 -0
  49. package/dist/core/sync-definitions.js.map +1 -0
  50. package/dist/core/validate-definition.d.ts +20 -0
  51. package/dist/core/validate-definition.d.ts.map +1 -0
  52. package/dist/core/validate-definition.js +61 -0
  53. package/dist/core/validate-definition.js.map +1 -0
  54. package/dist/core/workflow-model/constants.d.ts +2 -0
  55. package/dist/core/workflow-model/constants.d.ts.map +1 -0
  56. package/dist/core/workflow-model/constants.js +3 -0
  57. package/dist/core/workflow-model/constants.js.map +1 -0
  58. package/dist/core/workflow-model/cron-trigger.d.ts +16 -0
  59. package/dist/core/workflow-model/cron-trigger.d.ts.map +1 -0
  60. package/dist/core/workflow-model/cron-trigger.js +85 -0
  61. package/dist/core/workflow-model/cron-trigger.js.map +1 -0
  62. package/dist/core/workflow-model/index.d.ts +4 -0
  63. package/dist/core/workflow-model/index.d.ts.map +1 -0
  64. package/dist/core/workflow-model/index.js +5 -0
  65. package/dist/core/workflow-model/index.js.map +1 -0
  66. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +15 -0
  67. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -0
  68. package/dist/core/workflow-model/invalid-workflow-model-error.js +9 -0
  69. package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -0
  70. package/dist/core/workflow-model/map-job-ids.d.ts +4 -0
  71. package/dist/core/workflow-model/map-job-ids.d.ts.map +1 -0
  72. package/dist/core/workflow-model/map-job-ids.js +33 -0
  73. package/dist/core/workflow-model/map-job-ids.js.map +1 -0
  74. package/dist/core/workflow-model/normalize-agent-integrations.d.ts +12 -0
  75. package/dist/core/workflow-model/normalize-agent-integrations.d.ts.map +1 -0
  76. package/dist/core/workflow-model/normalize-agent-integrations.js +157 -0
  77. package/dist/core/workflow-model/normalize-agent-integrations.js.map +1 -0
  78. package/dist/core/workflow-model/normalize-dependencies.d.ts +6 -0
  79. package/dist/core/workflow-model/normalize-dependencies.d.ts.map +1 -0
  80. package/dist/core/workflow-model/normalize-dependencies.js +119 -0
  81. package/dist/core/workflow-model/normalize-dependencies.js.map +1 -0
  82. package/dist/core/workflow-model/normalize-env.d.ts +17 -0
  83. package/dist/core/workflow-model/normalize-env.d.ts.map +1 -0
  84. package/dist/core/workflow-model/normalize-env.js +40 -0
  85. package/dist/core/workflow-model/normalize-env.js.map +1 -0
  86. package/dist/core/workflow-model/normalize-if-condition.d.ts +14 -0
  87. package/dist/core/workflow-model/normalize-if-condition.d.ts.map +1 -0
  88. package/dist/core/workflow-model/normalize-if-condition.js +75 -0
  89. package/dist/core/workflow-model/normalize-if-condition.js.map +1 -0
  90. package/dist/core/workflow-model/normalize-job-checkout.d.ts +5 -0
  91. package/dist/core/workflow-model/normalize-job-checkout.d.ts.map +1 -0
  92. package/dist/core/workflow-model/normalize-job-checkout.js +16 -0
  93. package/dist/core/workflow-model/normalize-job-checkout.js.map +1 -0
  94. package/dist/core/workflow-model/normalize-job-listening.d.ts +10 -0
  95. package/dist/core/workflow-model/normalize-job-listening.d.ts.map +1 -0
  96. package/dist/core/workflow-model/normalize-job-listening.js +125 -0
  97. package/dist/core/workflow-model/normalize-job-listening.js.map +1 -0
  98. package/dist/core/workflow-model/normalize-job-success.d.ts +11 -0
  99. package/dist/core/workflow-model/normalize-job-success.d.ts.map +1 -0
  100. package/dist/core/workflow-model/normalize-job-success.js +23 -0
  101. package/dist/core/workflow-model/normalize-job-success.js.map +1 -0
  102. package/dist/core/workflow-model/normalize-jobs.d.ts +12 -0
  103. package/dist/core/workflow-model/normalize-jobs.d.ts.map +1 -0
  104. package/dist/core/workflow-model/normalize-jobs.js +935 -0
  105. package/dist/core/workflow-model/normalize-jobs.js.map +1 -0
  106. package/dist/core/workflow-model/normalize-needs.d.ts +2 -0
  107. package/dist/core/workflow-model/normalize-needs.d.ts.map +1 -0
  108. package/dist/core/workflow-model/normalize-needs.js +16 -0
  109. package/dist/core/workflow-model/normalize-needs.js.map +1 -0
  110. package/dist/core/workflow-model/normalize-step-gate.d.ts +15 -0
  111. package/dist/core/workflow-model/normalize-step-gate.d.ts.map +1 -0
  112. package/dist/core/workflow-model/normalize-step-gate.js +89 -0
  113. package/dist/core/workflow-model/normalize-step-gate.js.map +1 -0
  114. package/dist/core/workflow-model/normalize-step-outputs.d.ts +10 -0
  115. package/dist/core/workflow-model/normalize-step-outputs.d.ts.map +1 -0
  116. package/dist/core/workflow-model/normalize-step-outputs.js +32 -0
  117. package/dist/core/workflow-model/normalize-step-outputs.js.map +1 -0
  118. package/dist/core/workflow-model/normalize-triggers.d.ts +11 -0
  119. package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -0
  120. package/dist/core/workflow-model/normalize-triggers.js +128 -0
  121. package/dist/core/workflow-model/normalize-triggers.js.map +1 -0
  122. package/dist/core/workflow-model/normalize-workflow-document.d.ts +11 -0
  123. package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -0
  124. package/dist/core/workflow-model/normalize-workflow-document.js +43 -0
  125. package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -0
  126. package/dist/core/workflow-model/parse-duration-ms.d.ts +11 -0
  127. package/dist/core/workflow-model/parse-duration-ms.d.ts.map +1 -0
  128. package/dist/core/workflow-model/parse-duration-ms.js +47 -0
  129. package/dist/core/workflow-model/parse-duration-ms.js.map +1 -0
  130. package/dist/core/workflow-model/parse-interpolation-field.d.ts +14 -0
  131. package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -0
  132. package/dist/core/workflow-model/parse-interpolation-field.js +377 -0
  133. package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -0
  134. package/dist/core/workflow-model/stable-id.d.ts +2 -0
  135. package/dist/core/workflow-model/stable-id.d.ts.map +1 -0
  136. package/dist/core/workflow-model/stable-id.js +8 -0
  137. package/dist/core/workflow-model/stable-id.js.map +1 -0
  138. package/dist/core/workflow-model/validate-job-references.d.ts +10 -0
  139. package/dist/core/workflow-model/validate-job-references.d.ts.map +1 -0
  140. package/dist/core/workflow-model/validate-job-references.js +42 -0
  141. package/dist/core/workflow-model/validate-job-references.js.map +1 -0
  142. package/dist/core/workflow-model/validate-predicate-expression.d.ts +14 -0
  143. package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -0
  144. package/dist/core/workflow-model/validate-predicate-expression.js +273 -0
  145. package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -0
  146. package/dist/core/workflow-model/validation-issue.d.ts +8 -0
  147. package/dist/core/workflow-model/validation-issue.d.ts.map +1 -0
  148. package/dist/core/workflow-model/validation-issue.js +17 -0
  149. package/dist/core/workflow-model/validation-issue.js.map +1 -0
  150. package/dist/core/workflow-model/workflow-field-label.d.ts +3 -0
  151. package/dist/core/workflow-model/workflow-field-label.d.ts.map +1 -0
  152. package/dist/core/workflow-model/workflow-field-label.js +47 -0
  153. package/dist/core/workflow-model/workflow-field-label.js.map +1 -0
  154. package/dist/core/workflow-yaml/index.d.ts +3 -0
  155. package/dist/core/workflow-yaml/index.d.ts.map +1 -0
  156. package/dist/core/workflow-yaml/index.js +4 -0
  157. package/dist/core/workflow-yaml/index.js.map +1 -0
  158. package/dist/core/workflow-yaml/invalid-workflow-yaml-error.d.ts +16 -0
  159. package/dist/core/workflow-yaml/invalid-workflow-yaml-error.d.ts.map +1 -0
  160. package/dist/core/workflow-yaml/invalid-workflow-yaml-error.js +12 -0
  161. package/dist/core/workflow-yaml/invalid-workflow-yaml-error.js.map +1 -0
  162. package/dist/core/workflow-yaml/parse-workflow-yaml.d.ts +9 -0
  163. package/dist/core/workflow-yaml/parse-workflow-yaml.d.ts.map +1 -0
  164. package/dist/core/workflow-yaml/parse-workflow-yaml.js +52 -0
  165. package/dist/core/workflow-yaml/parse-workflow-yaml.js.map +1 -0
  166. package/dist/core/workflow-yaml/source-locations.d.ts +3 -0
  167. package/dist/core/workflow-yaml/source-locations.d.ts.map +1 -0
  168. package/dist/core/workflow-yaml/source-locations.js +48 -0
  169. package/dist/core/workflow-yaml/source-locations.js.map +1 -0
  170. package/dist/db/db.d.ts +1277 -0
  171. package/dist/db/db.d.ts.map +1 -0
  172. package/dist/db/db.js +22 -0
  173. package/dist/db/db.js.map +1 -0
  174. package/dist/db/definition-triggers.d.ts +4 -0
  175. package/dist/db/definition-triggers.d.ts.map +1 -0
  176. package/dist/db/definition-triggers.js +17 -0
  177. package/dist/db/definition-triggers.js.map +1 -0
  178. package/dist/db/definitions.d.ts +63 -0
  179. package/dist/db/definitions.d.ts.map +1 -0
  180. package/dist/db/definitions.js +208 -0
  181. package/dist/db/definitions.js.map +1 -0
  182. package/dist/db/index.d.ts +8 -0
  183. package/dist/db/index.d.ts.map +1 -0
  184. package/dist/db/index.js +10 -0
  185. package/dist/db/index.js.map +1 -0
  186. package/dist/db/schema/common.d.ts +2 -0
  187. package/dist/db/schema/common.d.ts.map +1 -0
  188. package/dist/db/schema/common.js +4 -0
  189. package/dist/db/schema/common.js.map +1 -0
  190. package/dist/db/schema/definitions.d.ts +237 -0
  191. package/dist/db/schema/definitions.d.ts.map +1 -0
  192. package/dist/db/schema/definitions.js +63 -0
  193. package/dist/db/schema/definitions.js.map +1 -0
  194. package/dist/db/schema/outbox.d.ts +195 -0
  195. package/dist/db/schema/outbox.d.ts.map +1 -0
  196. package/dist/db/schema/outbox.js +5 -0
  197. package/dist/db/schema/outbox.js.map +1 -0
  198. package/dist/db/schema/sync-states.d.ts +218 -0
  199. package/dist/db/schema/sync-states.d.ts.map +1 -0
  200. package/dist/db/schema/sync-states.js +68 -0
  201. package/dist/db/schema/sync-states.js.map +1 -0
  202. package/dist/db/sync-states.d.ts +21 -0
  203. package/dist/db/sync-states.d.ts.map +1 -0
  204. package/dist/db/sync-states.js +47 -0
  205. package/dist/db/sync-states.js.map +1 -0
  206. package/dist/index.d.ts +14 -0
  207. package/dist/index.d.ts.map +1 -0
  208. package/dist/index.js +63 -0
  209. package/dist/index.js.map +1 -0
  210. package/dist/presentation/dto/definition.d.ts +6 -0
  211. package/dist/presentation/dto/definition.d.ts.map +1 -0
  212. package/dist/presentation/dto/definition.js +35 -0
  213. package/dist/presentation/dto/definition.js.map +1 -0
  214. package/dist/presentation/dto/index.d.ts +2 -0
  215. package/dist/presentation/dto/index.d.ts.map +1 -0
  216. package/dist/presentation/dto/index.js +3 -0
  217. package/dist/presentation/dto/index.js.map +1 -0
  218. package/dist/presentation/index.d.ts +2 -0
  219. package/dist/presentation/index.d.ts.map +1 -0
  220. package/dist/presentation/index.js +3 -0
  221. package/dist/presentation/index.js.map +1 -0
  222. package/dist/presentation/routes/create-definition.d.ts +9 -0
  223. package/dist/presentation/routes/create-definition.d.ts.map +1 -0
  224. package/dist/presentation/routes/create-definition.js +76 -0
  225. package/dist/presentation/routes/create-definition.js.map +1 -0
  226. package/dist/presentation/routes/get-definition.d.ts +2 -0
  227. package/dist/presentation/routes/get-definition.d.ts.map +1 -0
  228. package/dist/presentation/routes/get-definition.js +43 -0
  229. package/dist/presentation/routes/get-definition.js.map +1 -0
  230. package/dist/presentation/routes/index.d.ts +7 -0
  231. package/dist/presentation/routes/index.d.ts.map +1 -0
  232. package/dist/presentation/routes/index.js +22 -0
  233. package/dist/presentation/routes/index.js.map +1 -0
  234. package/dist/presentation/routes/list-definitions.d.ts +2 -0
  235. package/dist/presentation/routes/list-definitions.d.ts.map +1 -0
  236. package/dist/presentation/routes/list-definitions.js +56 -0
  237. package/dist/presentation/routes/list-definitions.js.map +1 -0
  238. package/dist/presentation/routes/validate-definition.d.ts +2 -0
  239. package/dist/presentation/routes/validate-definition.d.ts.map +1 -0
  240. package/dist/presentation/routes/validate-definition.js +43 -0
  241. package/dist/presentation/routes/validate-definition.js.map +1 -0
  242. package/dist/presentation/subscribers/index.d.ts +3 -0
  243. package/dist/presentation/subscribers/index.d.ts.map +1 -0
  244. package/dist/presentation/subscribers/index.js +4 -0
  245. package/dist/presentation/subscribers/index.js.map +1 -0
  246. package/dist/presentation/subscribers/on-project-source-bound.d.ts +3 -0
  247. package/dist/presentation/subscribers/on-project-source-bound.d.ts.map +1 -0
  248. package/dist/presentation/subscribers/on-project-source-bound.js +11 -0
  249. package/dist/presentation/subscribers/on-project-source-bound.js.map +1 -0
  250. package/dist/presentation/subscribers/on-project-source-commit-observed.d.ts +3 -0
  251. package/dist/presentation/subscribers/on-project-source-commit-observed.d.ts.map +1 -0
  252. package/dist/presentation/subscribers/on-project-source-commit-observed.js +13 -0
  253. package/dist/presentation/subscribers/on-project-source-commit-observed.js.map +1 -0
  254. package/dist/presentation/subscribers/start-definition-sync.d.ts +10 -0
  255. package/dist/presentation/subscribers/start-definition-sync.d.ts.map +1 -0
  256. package/dist/presentation/subscribers/start-definition-sync.js +26 -0
  257. package/dist/presentation/subscribers/start-definition-sync.js.map +1 -0
  258. package/dist/temporal/activities/index.d.ts +3 -0
  259. package/dist/temporal/activities/index.d.ts.map +1 -0
  260. package/dist/temporal/activities/index.js +3 -0
  261. package/dist/temporal/activities/index.js.map +1 -0
  262. package/dist/temporal/activities/sync-activities.d.ts +47 -0
  263. package/dist/temporal/activities/sync-activities.d.ts.map +1 -0
  264. package/dist/temporal/activities/sync-activities.js +128 -0
  265. package/dist/temporal/activities/sync-activities.js.map +1 -0
  266. package/dist/temporal/constants.d.ts +3 -0
  267. package/dist/temporal/constants.d.ts.map +1 -0
  268. package/dist/temporal/constants.js +4 -0
  269. package/dist/temporal/constants.js.map +1 -0
  270. package/dist/temporal/index.d.ts +3 -0
  271. package/dist/temporal/index.d.ts.map +1 -0
  272. package/dist/temporal/index.js +4 -0
  273. package/dist/temporal/index.js.map +1 -0
  274. package/dist/temporal/workflows/definition-sync.d.ts +20 -0
  275. package/dist/temporal/workflows/definition-sync.d.ts.map +1 -0
  276. package/dist/temporal/workflows/definition-sync.js +95 -0
  277. package/dist/temporal/workflows/definition-sync.js.map +1 -0
  278. package/dist/temporal/workflows/index.d.ts +2 -0
  279. package/dist/temporal/workflows/index.d.ts.map +1 -0
  280. package/dist/temporal/workflows/index.js +3 -0
  281. package/dist/temporal/workflows/index.js.map +1 -0
  282. package/dist/tsconfig.test.tsbuildinfo +1 -0
  283. package/drizzle/0000_initial.sql +64 -0
  284. package/drizzle/0001_definition_list_pagination.sql +1 -0
  285. package/drizzle/meta/0000_snapshot.json +527 -0
  286. package/drizzle/meta/_journal.json +20 -0
  287. package/drizzle.config.ts +11 -0
  288. package/package.json +89 -0
  289. package/src/config.test.ts +23 -0
  290. package/src/config.ts +50 -0
  291. package/src/core/entities/index.ts +4 -0
  292. package/src/core/entities/integration-context.ts +21 -0
  293. package/src/core/entities/sync-state.ts +40 -0
  294. package/src/core/entities/workflow-definition.ts +38 -0
  295. package/src/core/entities/workflow-model.ts +162 -0
  296. package/src/core/errors.ts +21 -0
  297. package/src/core/has-agent-step-integrations.ts +7 -0
  298. package/src/core/index.ts +23 -0
  299. package/src/core/parse-definition.test.ts +200 -0
  300. package/src/core/parse-definition.ts +28 -0
  301. package/src/core/sync-definitions.test.ts +390 -0
  302. package/src/core/sync-definitions.ts +216 -0
  303. package/src/core/validate-definition.test.ts +152 -0
  304. package/src/core/validate-definition.ts +72 -0
  305. package/src/core/workflow-model/README.md +129 -0
  306. package/src/core/workflow-model/constants.ts +1 -0
  307. package/src/core/workflow-model/cron-trigger.test.ts +29 -0
  308. package/src/core/workflow-model/cron-trigger.ts +83 -0
  309. package/src/core/workflow-model/index.ts +9 -0
  310. package/src/core/workflow-model/invalid-workflow-model-error.ts +71 -0
  311. package/src/core/workflow-model/map-job-ids.ts +33 -0
  312. package/src/core/workflow-model/normalize-agent-integrations.ts +208 -0
  313. package/src/core/workflow-model/normalize-dependencies.ts +139 -0
  314. package/src/core/workflow-model/normalize-env.ts +48 -0
  315. package/src/core/workflow-model/normalize-if-condition.ts +126 -0
  316. package/src/core/workflow-model/normalize-job-checkout.ts +19 -0
  317. package/src/core/workflow-model/normalize-job-listening.ts +121 -0
  318. package/src/core/workflow-model/normalize-job-success.ts +28 -0
  319. package/src/core/workflow-model/normalize-jobs.ts +1010 -0
  320. package/src/core/workflow-model/normalize-needs.ts +12 -0
  321. package/src/core/workflow-model/normalize-step-gate.ts +90 -0
  322. package/src/core/workflow-model/normalize-step-outputs.ts +36 -0
  323. package/src/core/workflow-model/normalize-triggers.ts +130 -0
  324. package/src/core/workflow-model/normalize-workflow-document.test.ts +4463 -0
  325. package/src/core/workflow-model/normalize-workflow-document.ts +63 -0
  326. package/src/core/workflow-model/parse-duration-ms.ts +58 -0
  327. package/src/core/workflow-model/parse-interpolation-field.ts +566 -0
  328. package/src/core/workflow-model/stable-id.ts +12 -0
  329. package/src/core/workflow-model/validate-job-references.ts +54 -0
  330. package/src/core/workflow-model/validate-predicate-expression.test.ts +161 -0
  331. package/src/core/workflow-model/validate-predicate-expression.ts +406 -0
  332. package/src/core/workflow-model/validation-issue.ts +27 -0
  333. package/src/core/workflow-model/workflow-field-label.ts +50 -0
  334. package/src/core/workflow-yaml/README.md +77 -0
  335. package/src/core/workflow-yaml/index.ts +11 -0
  336. package/src/core/workflow-yaml/invalid-workflow-yaml-error.ts +23 -0
  337. package/src/core/workflow-yaml/parse-workflow-yaml.test.ts +71 -0
  338. package/src/core/workflow-yaml/parse-workflow-yaml.ts +74 -0
  339. package/src/core/workflow-yaml/source-locations.ts +62 -0
  340. package/src/db/db.ts +18 -0
  341. package/src/db/definition-triggers.test.ts +95 -0
  342. package/src/db/definition-triggers.ts +17 -0
  343. package/src/db/definitions.test.ts +1309 -0
  344. package/src/db/definitions.ts +357 -0
  345. package/src/db/index.ts +31 -0
  346. package/src/db/schema/common.ts +3 -0
  347. package/src/db/schema/definitions.test.ts +36 -0
  348. package/src/db/schema/definitions.ts +76 -0
  349. package/src/db/schema/outbox.ts +4 -0
  350. package/src/db/schema/sync-states.ts +79 -0
  351. package/src/db/sync-states.test.ts +90 -0
  352. package/src/db/sync-states.ts +86 -0
  353. package/src/index.ts +104 -0
  354. package/src/presentation/dto/definition.test.ts +59 -0
  355. package/src/presentation/dto/definition.ts +41 -0
  356. package/src/presentation/dto/index.ts +1 -0
  357. package/src/presentation/index.ts +1 -0
  358. package/src/presentation/routes/create-definition.test.ts +356 -0
  359. package/src/presentation/routes/create-definition.ts +101 -0
  360. package/src/presentation/routes/get-definition.test.ts +79 -0
  361. package/src/presentation/routes/get-definition.ts +37 -0
  362. package/src/presentation/routes/index.test.ts +41 -0
  363. package/src/presentation/routes/index.ts +28 -0
  364. package/src/presentation/routes/list-definitions.test.ts +195 -0
  365. package/src/presentation/routes/list-definitions.ts +49 -0
  366. package/src/presentation/routes/validate-definition.test.ts +63 -0
  367. package/src/presentation/routes/validate-definition.ts +46 -0
  368. package/src/presentation/subscribers/index.ts +2 -0
  369. package/src/presentation/subscribers/on-project-source-bound.test.ts +65 -0
  370. package/src/presentation/subscribers/on-project-source-bound.ts +11 -0
  371. package/src/presentation/subscribers/on-project-source-commit-observed.test.ts +56 -0
  372. package/src/presentation/subscribers/on-project-source-commit-observed.ts +15 -0
  373. package/src/presentation/subscribers/start-definition-sync.ts +38 -0
  374. package/src/temporal/activities/index.ts +10 -0
  375. package/src/temporal/activities/sync-activities.test.ts +337 -0
  376. package/src/temporal/activities/sync-activities.ts +206 -0
  377. package/src/temporal/constants.ts +2 -0
  378. package/src/temporal/index.ts +2 -0
  379. package/src/temporal/workflows/definition-sync.test.ts +59 -0
  380. package/src/temporal/workflows/definition-sync.ts +116 -0
  381. package/src/temporal/workflows/index.ts +1 -0
  382. package/test/env.ts +7 -0
  383. package/test/factories/definition.ts +66 -0
  384. package/test/fixtures/invalid-cycle.yml +13 -0
  385. package/test/fixtures/invalid-missing-name.yml +4 -0
  386. package/test/fixtures/invalid-yaml-syntax.yml +6 -0
  387. package/test/fixtures/valid-dag.yml +25 -0
  388. package/test/fixtures/valid-listening-job.yml +21 -0
  389. package/test/fixtures/valid-simple.yml +18 -0
  390. package/test/fixtures/workflow-yaml/invalid/invalid-yaml-syntax.yaml +6 -0
  391. package/test/fixtures/workflow-yaml/invalid/missing-step-run.yaml +5 -0
  392. package/test/fixtures/workflow-yaml/valid/simple-build.document.json +26 -0
  393. package/test/fixtures/workflow-yaml/valid/simple-build.yaml +14 -0
  394. package/test/globalSetup.ts +25 -0
  395. package/test/index.ts +1 -0
  396. package/test/setup.ts +17 -0
  397. package/tsconfig.build.json +9 -0
  398. package/tsconfig.build.tsbuildinfo +1 -0
  399. package/tsconfig.json +3 -0
  400. package/tsconfig.test.json +8 -0
  401. package/vitest.config.ts +11 -0
@@ -0,0 +1,4463 @@
1
+ import type {WorkflowDocument} from '@shipfox/workflow-document';
2
+ import type {IntegrationValidationContext} from '../entities/integration-context.js';
3
+ import {InvalidWorkflowModelError} from './invalid-workflow-model-error.js';
4
+ import {DEFAULT_JOB_CHECKOUT} from './normalize-job-checkout.js';
5
+ import {normalizeWorkflowDocument as normalizeWorkflowDocumentBase} from './normalize-workflow-document.js';
6
+
7
+ function normalizeWorkflowDocument(
8
+ document: WorkflowDocument,
9
+ options?: Parameters<typeof normalizeWorkflowDocumentBase>[1],
10
+ ) {
11
+ return normalizeWorkflowDocumentBase({runner: 'ubuntu-latest', ...document}, options);
12
+ }
13
+
14
+ function expectInvalid(
15
+ document: WorkflowDocument,
16
+ options?: Parameters<typeof normalizeWorkflowDocumentBase>[1],
17
+ ): InvalidWorkflowModelError {
18
+ try {
19
+ normalizeWorkflowDocument(document, options);
20
+ expect.fail('Expected InvalidWorkflowModelError');
21
+ } catch (error) {
22
+ expect(error).toBeInstanceOf(InvalidWorkflowModelError);
23
+ return error as InvalidWorkflowModelError;
24
+ }
25
+ }
26
+
27
+ function interpolation(source: string): string {
28
+ return '$'.concat('{{ ', source, ' }}');
29
+ }
30
+
31
+ const workflowInterpolation = interpolation;
32
+
33
+ const integrationValidationContext = {
34
+ agentToolSelectionCatalogs: new Map([
35
+ [
36
+ 'github',
37
+ {
38
+ selectors: [
39
+ {token: 'issue_read', kind: 'family', sensitivity: 'read', sensitive: false},
40
+ {token: 'issue_read.*', kind: 'family_wildcard', sensitivity: 'read', sensitive: false},
41
+ {token: 'issue_read.get', kind: 'method', sensitivity: 'read', sensitive: false},
42
+ {token: 'issue_write', kind: 'family', sensitivity: 'write', sensitive: false},
43
+ {token: 'issue_write.create', kind: 'method', sensitivity: 'write', sensitive: false},
44
+ {token: 'list_issues', kind: 'standalone', sensitivity: 'read', sensitive: false},
45
+ {
46
+ token: 'merge_pull_request',
47
+ kind: 'standalone',
48
+ sensitivity: 'write',
49
+ sensitive: true,
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ [
55
+ 'linear',
56
+ {
57
+ selectors: [
58
+ {token: 'get_issue', kind: 'standalone', sensitivity: 'read', sensitive: false},
59
+ {token: 'save_comment', kind: 'standalone', sensitivity: 'write', sensitive: false},
60
+ ],
61
+ },
62
+ ],
63
+ ]),
64
+ workspaceConnectionSnapshot: new Map([
65
+ ['github-main', {id: 'conn_1', provider: 'github', capabilities: ['agent_tools']}],
66
+ ['sentry-main', {id: 'conn_2', provider: 'sentry', capabilities: []}],
67
+ ['linear-main', {id: 'conn_3', provider: 'linear', capabilities: ['agent_tools']}],
68
+ ]),
69
+ defaultConnectionSlug: 'github-main',
70
+ } satisfies IntegrationValidationContext;
71
+
72
+ describe('normalizeWorkflowDocument', () => {
73
+ it('normalizes a workflow document into a WorkflowModel', () => {
74
+ const document: WorkflowDocument = {
75
+ name: 'simple build',
76
+ triggers: {
77
+ main_push: {
78
+ source: 'github',
79
+ event: 'push',
80
+ filter: 'event.ref == "refs/heads/main"',
81
+ },
82
+ },
83
+ jobs: {
84
+ build: {
85
+ steps: [{run: 'npm install'}, {key: 'build', run: 'npm run build'}],
86
+ },
87
+ },
88
+ };
89
+
90
+ const model = normalizeWorkflowDocument(document);
91
+
92
+ expect(model).toEqual({
93
+ kind: 'workflow',
94
+ name: 'simple build',
95
+ triggers: [
96
+ {
97
+ id: 'main-push',
98
+ key: 'main_push',
99
+ source: 'github',
100
+ event: 'push',
101
+ filter: 'event.ref == "refs/heads/main"',
102
+ },
103
+ ],
104
+ jobs: [
105
+ {
106
+ id: 'build',
107
+ mode: 'one_shot',
108
+ key: 'build',
109
+ runner: ['ubuntu-latest'],
110
+ checkout: DEFAULT_JOB_CHECKOUT,
111
+ dependencies: [],
112
+ steps: [
113
+ {
114
+ id: 'build-step-1',
115
+ kind: 'run',
116
+ command: {kind: 'shell', value: 'npm install'},
117
+ },
118
+ {
119
+ id: 'build-build',
120
+ key: 'build',
121
+ kind: 'run',
122
+ command: {kind: 'shell', value: 'npm run build'},
123
+ },
124
+ ],
125
+ },
126
+ ],
127
+ dependencies: [],
128
+ });
129
+ });
130
+
131
+ it('normalizes inline agent steps without resolving contextual defaults', () => {
132
+ const document: WorkflowDocument = {
133
+ name: 'agent build',
134
+ jobs: {
135
+ fix: {
136
+ steps: [
137
+ {key: 'plan', prompt: 'Plan the fix.'},
138
+ {
139
+ key: 'implement',
140
+ harness: 'claude',
141
+ model: 'claude-opus-4-8',
142
+ prompt: 'Fix the failing tests.',
143
+ tools: ['Read', 'Grep'],
144
+ },
145
+ {
146
+ key: 'review',
147
+ harness: 'claude',
148
+ model: 'gpt-5.5-pro',
149
+ provider: 'anthropic',
150
+ prompt: 'Review the fix.',
151
+ thinking: 'low',
152
+ gate: {success: 'step.exit_code == 0', on_failure: {restart_from: 'implement'}},
153
+ },
154
+ ],
155
+ },
156
+ },
157
+ };
158
+
159
+ const model = normalizeWorkflowDocument(document);
160
+
161
+ expect(model.jobs[0]?.steps[0]).toEqual({
162
+ id: 'fix-plan',
163
+ key: 'plan',
164
+ kind: 'agent',
165
+ prompt: 'Plan the fix.',
166
+ });
167
+ expect(model.jobs[0]?.steps[1]).toEqual({
168
+ id: 'fix-implement',
169
+ key: 'implement',
170
+ kind: 'agent',
171
+ harness: 'claude',
172
+ model: 'claude-opus-4-8',
173
+ prompt: 'Fix the failing tests.',
174
+ tools: ['Read', 'Grep'],
175
+ });
176
+ expect(model.jobs[0]?.steps[2]).toMatchObject({
177
+ id: 'fix-review',
178
+ kind: 'agent',
179
+ harness: 'claude',
180
+ provider: 'anthropic',
181
+ thinking: 'low',
182
+ gate: {onFailure: {restartFrom: 'implement'}},
183
+ });
184
+ });
185
+
186
+ it('normalizes agent step integrations after catalog validation', () => {
187
+ const document: WorkflowDocument = {
188
+ name: 'agent integrations',
189
+ jobs: {
190
+ fix: {
191
+ steps: [
192
+ {
193
+ prompt: 'Fix the issue.',
194
+ integrations: [
195
+ {
196
+ include: [
197
+ 'issue_read',
198
+ 'issue_read',
199
+ 'issue_read.*',
200
+ 'issue_read.get',
201
+ 'list_issues',
202
+ ],
203
+ exclude: ['issue_read.get', 'issue_read.get'],
204
+ },
205
+ ],
206
+ },
207
+ ],
208
+ },
209
+ },
210
+ };
211
+
212
+ const model = normalizeWorkflowDocument(document, {integrationValidationContext});
213
+
214
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
215
+ kind: 'agent',
216
+ integrations: [
217
+ {
218
+ include: ['issue_read', 'issue_read.*', 'issue_read.get', 'list_issues'],
219
+ exclude: ['issue_read.get'],
220
+ allowWrite: false,
221
+ },
222
+ ],
223
+ });
224
+ });
225
+
226
+ it('validates authored Linear integration selections against the Linear catalog', () => {
227
+ const document: WorkflowDocument = {
228
+ name: 'linear integrations',
229
+ jobs: {
230
+ fix: {
231
+ steps: [
232
+ {
233
+ prompt: 'Use Linear context.',
234
+ integrations: [{connection: 'linear-main', include: ['get_issue']}],
235
+ },
236
+ ],
237
+ },
238
+ },
239
+ };
240
+
241
+ const model = normalizeWorkflowDocument(document, {integrationValidationContext});
242
+
243
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
244
+ kind: 'agent',
245
+ integrations: [
246
+ {
247
+ connection: 'linear-main',
248
+ include: ['get_issue'],
249
+ allowWrite: false,
250
+ },
251
+ ],
252
+ });
253
+ });
254
+
255
+ it('skips integration catalog and connection checks when no context is injected', () => {
256
+ const document: WorkflowDocument = {
257
+ name: 'validate only',
258
+ jobs: {
259
+ fix: {
260
+ steps: [{prompt: 'Fix.', integrations: [{include: ['unknown.write']}]}],
261
+ },
262
+ },
263
+ };
264
+
265
+ const model = normalizeWorkflowDocument(document);
266
+
267
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
268
+ kind: 'agent',
269
+ integrations: [{include: ['unknown.write'], allowWrite: false}],
270
+ });
271
+ });
272
+
273
+ it('classifies unknown integration tools and methods', () => {
274
+ const document: WorkflowDocument = {
275
+ name: 'bad integrations',
276
+ jobs: {
277
+ fix: {
278
+ steps: [
279
+ {
280
+ prompt: 'Fix.',
281
+ integrations: [{include: ['issue_read.missing', 'list_issues.extra']}],
282
+ },
283
+ ],
284
+ },
285
+ },
286
+ };
287
+
288
+ const error = expectInvalid(document, {integrationValidationContext});
289
+
290
+ expect(error.issues.map((issue) => issue.code)).toEqual([
291
+ 'unknown-integration-method',
292
+ 'unknown-integration-tool',
293
+ ]);
294
+ });
295
+
296
+ it('anchors unknown integration tokens to authored selection indexes', () => {
297
+ const document: WorkflowDocument = {
298
+ name: 'bad integrations',
299
+ jobs: {
300
+ fix: {
301
+ steps: [
302
+ {
303
+ prompt: 'Fix.',
304
+ integrations: [
305
+ {
306
+ include: ['issue_read', 'issue_read', 'issue_read.missing'],
307
+ exclude: ['issue_read.get', 'issue_read.get', 'list_issues.extra'],
308
+ },
309
+ ],
310
+ },
311
+ ],
312
+ },
313
+ },
314
+ };
315
+
316
+ const error = expectInvalid(document, {integrationValidationContext});
317
+
318
+ expect(error.issues).toMatchObject([
319
+ {
320
+ code: 'unknown-integration-method',
321
+ path: ['jobs', 'fix', 'steps', 0, 'integrations', 0, 'include', 2],
322
+ },
323
+ {
324
+ code: 'unknown-integration-tool',
325
+ path: ['jobs', 'fix', 'steps', 0, 'integrations', 0, 'exclude', 2],
326
+ },
327
+ ]);
328
+ });
329
+
330
+ it('requires allow_write for write-capable authored include tokens', () => {
331
+ const document: WorkflowDocument = {
332
+ name: 'write integrations',
333
+ jobs: {
334
+ fix: {
335
+ steps: [
336
+ {
337
+ prompt: 'Fix.',
338
+ integrations: [{include: ['issue_write', 'merge_pull_request']}],
339
+ },
340
+ ],
341
+ },
342
+ },
343
+ };
344
+
345
+ const error = expectInvalid(document, {integrationValidationContext});
346
+
347
+ expect(error.issues).toMatchObject([
348
+ {
349
+ code: 'integration-write-not-allowed',
350
+ details: {tokens: ['issue_write', 'merge_pull_request']},
351
+ },
352
+ ]);
353
+ });
354
+
355
+ it('does not let exclude mask a write-capable include token', () => {
356
+ const document: WorkflowDocument = {
357
+ name: 'write integrations',
358
+ jobs: {
359
+ fix: {
360
+ steps: [
361
+ {
362
+ prompt: 'Fix.',
363
+ integrations: [{include: ['issue_write'], exclude: ['issue_write']}],
364
+ },
365
+ ],
366
+ },
367
+ },
368
+ };
369
+
370
+ const error = expectInvalid(document, {integrationValidationContext});
371
+
372
+ expect(error.issues.map((issue) => issue.code)).toEqual(['integration-write-not-allowed']);
373
+ });
374
+
375
+ it('accepts write-capable include tokens when allow_write is true', () => {
376
+ const document: WorkflowDocument = {
377
+ name: 'write integrations',
378
+ jobs: {
379
+ fix: {
380
+ steps: [
381
+ {
382
+ prompt: 'Fix.',
383
+ integrations: [{include: ['issue_write.create'], allow_write: true}],
384
+ },
385
+ ],
386
+ },
387
+ },
388
+ };
389
+
390
+ const model = normalizeWorkflowDocument(document, {integrationValidationContext});
391
+
392
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
393
+ kind: 'agent',
394
+ integrations: [{include: ['issue_write.create'], allowWrite: true}],
395
+ });
396
+ });
397
+
398
+ it('requires allow_write for Linear write tools', () => {
399
+ const document: WorkflowDocument = {
400
+ name: 'linear write integrations',
401
+ jobs: {
402
+ fix: {
403
+ steps: [
404
+ {
405
+ prompt: 'Comment on the issue.',
406
+ integrations: [{connection: 'linear-main', include: ['save_comment']}],
407
+ },
408
+ ],
409
+ },
410
+ },
411
+ };
412
+
413
+ const error = expectInvalid(document, {integrationValidationContext});
414
+
415
+ expect(error.issues).toMatchObject([
416
+ {
417
+ code: 'integration-write-not-allowed',
418
+ details: {tokens: ['save_comment']},
419
+ },
420
+ ]);
421
+ });
422
+
423
+ it('accepts Linear write tools when allow_write is true', () => {
424
+ const document: WorkflowDocument = {
425
+ name: 'linear write integrations',
426
+ jobs: {
427
+ fix: {
428
+ steps: [
429
+ {
430
+ prompt: 'Comment on the issue.',
431
+ integrations: [
432
+ {connection: 'linear-main', include: ['save_comment'], allow_write: true},
433
+ ],
434
+ },
435
+ ],
436
+ },
437
+ },
438
+ };
439
+
440
+ const model = normalizeWorkflowDocument(document, {integrationValidationContext});
441
+
442
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
443
+ kind: 'agent',
444
+ integrations: [{connection: 'linear-main', include: ['save_comment'], allowWrite: true}],
445
+ });
446
+ });
447
+
448
+ it('reports connection failures before token checks', () => {
449
+ const document: WorkflowDocument = {
450
+ name: 'missing connection',
451
+ jobs: {
452
+ fix: {
453
+ steps: [
454
+ {
455
+ prompt: 'Fix.',
456
+ integrations: [{connection: 'missing', include: ['bad.token']}],
457
+ },
458
+ ],
459
+ },
460
+ },
461
+ };
462
+
463
+ const error = expectInvalid(document, {integrationValidationContext});
464
+
465
+ expect(error.issues.map((issue) => issue.code)).toEqual(['integration-connection-not-found']);
466
+ });
467
+
468
+ it('requires an explicit connection when no default exists', () => {
469
+ const document: WorkflowDocument = {
470
+ name: 'missing default',
471
+ jobs: {
472
+ fix: {
473
+ steps: [{prompt: 'Fix.', integrations: [{include: ['issue_read']}]}],
474
+ },
475
+ },
476
+ };
477
+ const context = {...integrationValidationContext, defaultConnectionSlug: undefined};
478
+
479
+ const error = expectInvalid(document, {integrationValidationContext: context});
480
+
481
+ expect(error.issues.map((issue) => issue.code)).toEqual(['missing-connection-for-integration']);
482
+ });
483
+
484
+ it('rejects connections whose provider has no agent tools catalog', () => {
485
+ const document: WorkflowDocument = {
486
+ name: 'non-capable connection',
487
+ jobs: {
488
+ fix: {
489
+ steps: [
490
+ {prompt: 'Fix.', integrations: [{connection: 'sentry-main', include: ['issue_read']}]},
491
+ ],
492
+ },
493
+ },
494
+ };
495
+
496
+ const error = expectInvalid(document, {integrationValidationContext});
497
+
498
+ expect(error.issues.map((issue) => issue.code)).toEqual(['integration-connection-not-capable']);
499
+ });
500
+
501
+ it('reports unsupported explicit providers', () => {
502
+ const document: WorkflowDocument = {
503
+ name: 'agent build',
504
+ jobs: {
505
+ fix: {
506
+ steps: [{provider: 'github-copilot', prompt: 'Fix it.'}],
507
+ },
508
+ },
509
+ };
510
+
511
+ const error = expectInvalid(document);
512
+
513
+ expect(error.issues).toEqual([
514
+ {
515
+ code: 'invalid-provider',
516
+ message: 'Provider "github-copilot" is not supported.',
517
+ path: ['jobs', 'fix', 'steps', 0, 'provider'],
518
+ details: {provider: 'github-copilot'},
519
+ },
520
+ ]);
521
+ });
522
+
523
+ it('allows explicit custom providers for pi agent steps', () => {
524
+ const document: WorkflowDocument = {
525
+ name: 'agent build',
526
+ jobs: {
527
+ fix: {
528
+ steps: [
529
+ {
530
+ harness: 'pi',
531
+ provider: 'workspace-openai-compatible',
532
+ prompt: 'Fix it.',
533
+ },
534
+ ],
535
+ },
536
+ },
537
+ };
538
+
539
+ const model = normalizeWorkflowDocument(document);
540
+
541
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
542
+ kind: 'agent',
543
+ harness: 'pi',
544
+ provider: 'workspace-openai-compatible',
545
+ });
546
+ });
547
+
548
+ it('reports explicit harness/provider incompatibility', () => {
549
+ const document: WorkflowDocument = {
550
+ name: 'agent build',
551
+ jobs: {
552
+ fix: {
553
+ steps: [{harness: 'claude', provider: 'openai', prompt: 'Fix it.'}],
554
+ },
555
+ },
556
+ };
557
+
558
+ const error = expectInvalid(document);
559
+
560
+ expect(error.issues).toEqual([
561
+ {
562
+ code: 'harness-provider-incompatible',
563
+ message:
564
+ 'Harness "claude" does not support provider: openai. Supported providers: anthropic.',
565
+ path: ['jobs', 'fix', 'steps', 0, 'provider'],
566
+ details: {harness: 'claude', provider: 'openai', supportedProviders: ['anthropic']},
567
+ },
568
+ ]);
569
+ });
570
+
571
+ it('reports explicit harness/tool incompatibility with a precise tool path', () => {
572
+ const document: WorkflowDocument = {
573
+ name: 'agent build',
574
+ jobs: {
575
+ fix: {
576
+ steps: [{harness: 'pi', prompt: 'Search it.', tools: ['read', 'WebSearch']}],
577
+ },
578
+ },
579
+ };
580
+
581
+ const error = expectInvalid(document, {
582
+ harnessToolDeploymentConfig: {
583
+ pi: {enabledToolPackages: ['pi-web-access'], webSearchEnabled: true},
584
+ claude: {enabledToolPackages: []},
585
+ },
586
+ });
587
+
588
+ expect(error.issues).toEqual([
589
+ {
590
+ code: 'harness-tool-incompatible',
591
+ message:
592
+ 'Harness "pi" does not support tool: WebSearch. Supported tools: read, bash, edit, write, grep, find, ls, web_search, fetch_content, get_search_content.',
593
+ path: ['jobs', 'fix', 'steps', 0, 'tools', 1],
594
+ details: {
595
+ harness: 'pi',
596
+ tool: 'WebSearch',
597
+ supportedTools: [
598
+ 'read',
599
+ 'bash',
600
+ 'edit',
601
+ 'write',
602
+ 'grep',
603
+ 'find',
604
+ 'ls',
605
+ 'web_search',
606
+ 'fetch_content',
607
+ 'get_search_content',
608
+ ],
609
+ },
610
+ },
611
+ ]);
612
+ });
613
+
614
+ it('accepts Claude WebSearch as a harness-native tool name', () => {
615
+ const document: WorkflowDocument = {
616
+ name: 'agent build',
617
+ jobs: {
618
+ fix: {
619
+ steps: [{harness: 'claude', prompt: 'Search it.', tools: ['Read', 'WebSearch']}],
620
+ },
621
+ },
622
+ };
623
+
624
+ const model = normalizeWorkflowDocument(document);
625
+
626
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
627
+ kind: 'agent',
628
+ harness: 'claude',
629
+ tools: ['Read', 'WebSearch'],
630
+ });
631
+ });
632
+
633
+ it('rejects tools without an explicit harness', () => {
634
+ const document: WorkflowDocument = {
635
+ name: 'agent build',
636
+ jobs: {
637
+ fix: {
638
+ steps: [{prompt: 'Search it.', tools: ['Read']}],
639
+ },
640
+ },
641
+ };
642
+
643
+ const error = expectInvalid(document);
644
+
645
+ expect(error.issues).toEqual([
646
+ {
647
+ code: 'missing-harness-for-tools',
648
+ message:
649
+ 'Agent step tools require an explicit harness because tool names are harness-specific.',
650
+ path: ['jobs', 'fix', 'steps', 0, 'tools'],
651
+ details: {tools: ['Read']},
652
+ },
653
+ ]);
654
+ });
655
+
656
+ it('rejects Pi search tools when deployment search is disabled but keeps fetch_content', () => {
657
+ const document: WorkflowDocument = {
658
+ name: 'agent build',
659
+ jobs: {
660
+ fix: {
661
+ steps: [
662
+ {
663
+ harness: 'pi',
664
+ prompt: 'Fetch without search.',
665
+ tools: ['fetch_content', 'web_search', 'get_search_content'],
666
+ },
667
+ ],
668
+ },
669
+ },
670
+ };
671
+
672
+ const error = expectInvalid(document, {
673
+ harnessToolDeploymentConfig: {
674
+ pi: {enabledToolPackages: ['pi-web-access'], webSearchEnabled: false},
675
+ claude: {enabledToolPackages: []},
676
+ },
677
+ });
678
+
679
+ expect(error.issues).toEqual([
680
+ expect.objectContaining({
681
+ code: 'harness-tool-incompatible',
682
+ path: ['jobs', 'fix', 'steps', 0, 'tools', 1],
683
+ details: expect.objectContaining({
684
+ tool: 'web_search',
685
+ supportedTools: ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'fetch_content'],
686
+ }),
687
+ }),
688
+ expect.objectContaining({
689
+ code: 'harness-tool-incompatible',
690
+ path: ['jobs', 'fix', 'steps', 0, 'tools', 2],
691
+ details: expect.objectContaining({
692
+ tool: 'get_search_content',
693
+ supportedTools: ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'fetch_content'],
694
+ }),
695
+ }),
696
+ ]);
697
+ });
698
+
699
+ it('reports harness/thinking incompatibility even with a templated provider', () => {
700
+ const document: WorkflowDocument = {
701
+ name: 'agent build',
702
+ jobs: {
703
+ fix: {
704
+ steps: [
705
+ {
706
+ harness: 'claude',
707
+ provider: workflowInterpolation('vars.provider'),
708
+ prompt: 'Fix it.',
709
+ thinking: 'off',
710
+ },
711
+ ],
712
+ },
713
+ },
714
+ };
715
+
716
+ const error = expectInvalid(document);
717
+
718
+ expect(error.issues).toContainEqual({
719
+ code: 'harness-thinking-incompatible',
720
+ message:
721
+ 'Harness "claude" does not support thinking: off. Supported levels: low, medium, high, xhigh, max.',
722
+ path: ['jobs', 'fix', 'steps', 0, 'thinking'],
723
+ details: {
724
+ harness: 'claude',
725
+ thinking: 'off',
726
+ supportedLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
727
+ },
728
+ });
729
+ });
730
+
731
+ it('normalizes job success expressions and execution timeouts', () => {
732
+ const document: WorkflowDocument = {
733
+ name: 'job controls',
734
+ jobs: {
735
+ test: {
736
+ success: 'executions.exists(e, e.index == 0 && e.status == "succeeded")',
737
+ execution_timeout: '90m',
738
+ steps: [{run: 'npm test'}],
739
+ },
740
+ },
741
+ };
742
+
743
+ const model = normalizeWorkflowDocument(document);
744
+
745
+ expect(model.jobs[0]).toMatchObject({
746
+ success: 'executions.exists(e, e.index == 0 && e.status == "succeeded")',
747
+ executionTimeoutMs: 90 * 60 * 1000,
748
+ });
749
+ });
750
+
751
+ it('normalizes job and step if predicates', () => {
752
+ const document: WorkflowDocument = {
753
+ name: 'conditional controls',
754
+ jobs: {
755
+ build: {
756
+ steps: [
757
+ {key: 'compile', run: 'npm run build'},
758
+ {
759
+ if: interpolation('steps.compile.status == "succeeded" && !execution.failed'),
760
+ run: 'npm test',
761
+ },
762
+ ],
763
+ },
764
+ notify: {
765
+ needs: 'build',
766
+ if: interpolation('needs.exists(n, n.key == "build" && n.status == "failed")'),
767
+ steps: [{prompt: 'Notify the team.'}],
768
+ },
769
+ },
770
+ };
771
+
772
+ const model = normalizeWorkflowDocument(document);
773
+
774
+ expect(model.jobs[0]?.steps[1]?.if).toEqual({
775
+ language: 'cel',
776
+ source: 'steps.compile.status == "succeeded" && !execution.failed',
777
+ check: 'typed',
778
+ resultType: 'bool',
779
+ });
780
+ expect(model.jobs[1]?.if).toEqual({
781
+ language: 'cel',
782
+ source: 'needs.exists(n, n.key == "build" && n.status == "failed")',
783
+ check: 'typed',
784
+ resultType: 'bool',
785
+ });
786
+ });
787
+
788
+ it('defaults omitted job checkout to read permissions and persisted credentials', () => {
789
+ const document: WorkflowDocument = {
790
+ name: 'default checkout',
791
+ jobs: {
792
+ build: {
793
+ steps: [{run: 'npm test'}],
794
+ },
795
+ },
796
+ };
797
+
798
+ const model = normalizeWorkflowDocument(document);
799
+
800
+ expect(model.jobs[0]?.checkout).toEqual(DEFAULT_JOB_CHECKOUT);
801
+ });
802
+
803
+ it('defaults empty job checkout to read permissions and persisted credentials', () => {
804
+ const document: WorkflowDocument = {
805
+ name: 'empty checkout',
806
+ jobs: {
807
+ build: {
808
+ checkout: {},
809
+ steps: [{run: 'npm test'}],
810
+ },
811
+ },
812
+ };
813
+
814
+ const model = normalizeWorkflowDocument(document);
815
+
816
+ expect(model.jobs[0]?.checkout).toEqual(DEFAULT_JOB_CHECKOUT);
817
+ });
818
+
819
+ it('normalizes checkout contents write and defaults persisted credentials', () => {
820
+ const document: WorkflowDocument = {
821
+ name: 'write checkout',
822
+ jobs: {
823
+ build: {
824
+ checkout: {
825
+ permissions: {
826
+ contents: 'write',
827
+ },
828
+ },
829
+ steps: [{run: 'npm test'}],
830
+ },
831
+ },
832
+ };
833
+
834
+ const model = normalizeWorkflowDocument(document);
835
+
836
+ expect(model.jobs[0]?.checkout).toEqual({
837
+ permissions: {contents: 'write'},
838
+ persistCredentials: true,
839
+ });
840
+ });
841
+
842
+ it('normalizes checkout persist credentials false and defaults contents read', () => {
843
+ const document: WorkflowDocument = {
844
+ name: 'no persisted credentials',
845
+ jobs: {
846
+ build: {
847
+ checkout: {'persist-credentials': false},
848
+ steps: [{run: 'npm test'}],
849
+ },
850
+ },
851
+ };
852
+
853
+ const model = normalizeWorkflowDocument(document);
854
+
855
+ expect(model.jobs[0]?.checkout).toEqual({
856
+ permissions: {contents: 'read'},
857
+ persistCredentials: false,
858
+ });
859
+ });
860
+
861
+ it('normalizes listening job configuration', () => {
862
+ const displayName = ['PR review $', '{{ execution.index }}'].join('');
863
+ const promptTemplate = ['Review $', '{{ execution.events[0].data.body }}'].join('');
864
+ const document: WorkflowDocument = {
865
+ name: 'listen for reviews',
866
+ jobs: {
867
+ review: {
868
+ name: displayName,
869
+ listening: {
870
+ on: [
871
+ {
872
+ source: 'github',
873
+ event: 'pull_request_review',
874
+ filter: 'event.action == "submitted"',
875
+ },
876
+ ],
877
+ until: [{source: 'github', event: 'pull_request', filter: 'event.action == "closed"'}],
878
+ timeout: '30d',
879
+ max_executions: 10,
880
+ batch: {debounce: '5s', max_size: 20, max_wait: '1h'},
881
+ on_resolve: 'cancel',
882
+ },
883
+ steps: [{prompt: promptTemplate}],
884
+ },
885
+ },
886
+ };
887
+
888
+ const model = normalizeWorkflowDocument(document);
889
+
890
+ expect(model.jobs[0]).toMatchObject({
891
+ mode: 'listening',
892
+ listening: {
893
+ on: [
894
+ {source: 'github', event: 'pull_request_review', filter: 'event.action == "submitted"'},
895
+ ],
896
+ until: [{source: 'github', event: 'pull_request', filter: 'event.action == "closed"'}],
897
+ timeoutMs: 30 * 24 * 60 * 60 * 1000,
898
+ maxExecutions: 10,
899
+ batch: {debounceMs: 5000, maxSize: 20, maxWaitMs: 60 * 60 * 1000},
900
+ onResolve: 'cancel',
901
+ },
902
+ });
903
+ expect(model.jobs[0]?.name?.[1]).toMatchObject({
904
+ kind: 'deferred',
905
+ expression: {source: 'execution.index', check: 'typed'},
906
+ });
907
+ });
908
+
909
+ it('reports listening jobs without a resolution source', () => {
910
+ const document: WorkflowDocument = {
911
+ name: 'listen forever',
912
+ jobs: {
913
+ review: {
914
+ listening: {
915
+ on: [{source: 'github', event: 'pull_request_review'}],
916
+ },
917
+ steps: [{run: 'echo ok'}],
918
+ },
919
+ },
920
+ };
921
+
922
+ const error = expectInvalid(document);
923
+
924
+ expect(error.issues).toEqual([
925
+ expect.objectContaining({
926
+ code: 'listening-job-missing-resolution-source',
927
+ path: ['jobs', 'review', 'listening'],
928
+ }),
929
+ ]);
930
+ });
931
+
932
+ it('reports listening timeouts above the run timeout', () => {
933
+ const document: WorkflowDocument = {
934
+ name: 'too long',
935
+ jobs: {
936
+ review: {
937
+ listening: {
938
+ on: [{source: 'github', event: 'pull_request_review'}],
939
+ timeout: '31d',
940
+ },
941
+ steps: [{run: 'echo ok'}],
942
+ },
943
+ },
944
+ };
945
+
946
+ const error = expectInvalid(document);
947
+
948
+ expect(error.issues).toEqual([
949
+ expect.objectContaining({
950
+ code: 'listening-timeout-exceeds-run-timeout',
951
+ path: ['jobs', 'review', 'listening', 'timeout'],
952
+ }),
953
+ ]);
954
+ });
955
+
956
+ it('validates listener filters against job-activation roots', () => {
957
+ const document: WorkflowDocument = {
958
+ name: 'listener filter roots',
959
+ jobs: {
960
+ build: {
961
+ steps: [{run: 'npm run build'}],
962
+ },
963
+ review: {
964
+ needs: ['build'],
965
+ listening: {
966
+ on: [
967
+ {
968
+ source: 'github',
969
+ event: 'pull_request_review',
970
+ filter:
971
+ 'run.id != "" && inputs.target == event.issue.number && job.key == "review" && executions.all(execution, execution.status != "") && matrix.os == "linux" && jobs.build.outputs.pr_number == event.issue.number',
972
+ },
973
+ ],
974
+ until: [
975
+ {
976
+ source: 'github',
977
+ event: 'pull_request',
978
+ filter: 'event.action == "closed"',
979
+ },
980
+ ],
981
+ },
982
+ steps: [{run: 'echo ok'}],
983
+ },
984
+ },
985
+ };
986
+
987
+ const model = normalizeWorkflowDocument(document);
988
+
989
+ expect(model.jobs[1]?.listening?.on[0]?.filter).toBe(
990
+ 'run.id != "" && inputs.target == event.issue.number && job.key == "review" && executions.all(execution, execution.status != "") && matrix.os == "linux" && jobs.build.outputs.pr_number == event.issue.number',
991
+ );
992
+ expect(model.jobs[1]?.listening?.until?.[0]?.filter).toBe('event.action == "closed"');
993
+ });
994
+
995
+ it.each([
996
+ ['step root', 'step.status == "succeeded"', 'context-unavailable-at-predicate-site'],
997
+ ['steps root', 'steps.build.outputs.sha == "abc"', 'context-unavailable-at-predicate-site'],
998
+ ['runner root', 'runner.os == "linux"', 'runner-context-in-server-predicate'],
999
+ ['non-boolean source', 'event.action', 'invalid-listener-filter'],
1000
+ ] as const)('reports invalid listener on filters for %s', (_label, filter, code) => {
1001
+ const document: WorkflowDocument = {
1002
+ name: 'invalid listener filter',
1003
+ jobs: {
1004
+ review: {
1005
+ listening: {
1006
+ on: [{source: 'github', event: 'pull_request_review', filter}],
1007
+ max_executions: 1,
1008
+ },
1009
+ steps: [{run: 'echo ok'}],
1010
+ },
1011
+ },
1012
+ };
1013
+
1014
+ const error = expectInvalid(document);
1015
+
1016
+ expect(error.issues).toEqual([
1017
+ expect.objectContaining({
1018
+ code,
1019
+ path: ['jobs', 'review', 'listening', 'on', 0, 'filter'],
1020
+ details: expect.objectContaining({
1021
+ field: 'listener.on',
1022
+ source: filter,
1023
+ }),
1024
+ }),
1025
+ ]);
1026
+ });
1027
+
1028
+ it('rejects listener filters that reference jobs without a direct needs edge', () => {
1029
+ const document: WorkflowDocument = {
1030
+ name: 'listener missing needs',
1031
+ jobs: {
1032
+ build: {
1033
+ steps: [{run: 'npm run build'}],
1034
+ },
1035
+ review: {
1036
+ listening: {
1037
+ on: [
1038
+ {
1039
+ source: 'github',
1040
+ event: 'pull_request_review',
1041
+ filter: 'jobs.build.outputs.pr_number == event.issue.number',
1042
+ },
1043
+ ],
1044
+ max_executions: 1,
1045
+ },
1046
+ steps: [{run: 'echo ok'}],
1047
+ },
1048
+ },
1049
+ };
1050
+
1051
+ const error = expectInvalid(document);
1052
+
1053
+ expect(error.issues).toEqual([
1054
+ expect.objectContaining({
1055
+ code: 'missing-job-needs-edge',
1056
+ path: ['jobs', 'review', 'listening', 'on', 0, 'filter'],
1057
+ details: expect.objectContaining({
1058
+ field: 'listener.on',
1059
+ job: 'build',
1060
+ }),
1061
+ }),
1062
+ ]);
1063
+ });
1064
+
1065
+ it('rejects computed job keys in listener until filters', () => {
1066
+ const document: WorkflowDocument = {
1067
+ name: 'computed listener job key',
1068
+ jobs: {
1069
+ build: {
1070
+ steps: [{run: 'npm run build'}],
1071
+ },
1072
+ review: {
1073
+ needs: ['build'],
1074
+ listening: {
1075
+ on: [{source: 'github', event: 'pull_request_review'}],
1076
+ until: [
1077
+ {
1078
+ source: 'github',
1079
+ event: 'pull_request',
1080
+ filter: 'jobs[event.job].outputs.sha == "abc"',
1081
+ },
1082
+ ],
1083
+ },
1084
+ steps: [{run: 'echo ok'}],
1085
+ },
1086
+ },
1087
+ };
1088
+
1089
+ const error = expectInvalid(document);
1090
+
1091
+ expect(error.issues).toEqual([
1092
+ expect.objectContaining({
1093
+ code: 'computed-context-key',
1094
+ path: ['jobs', 'review', 'listening', 'until', 0, 'filter'],
1095
+ details: expect.objectContaining({
1096
+ field: 'listener.until',
1097
+ }),
1098
+ }),
1099
+ ]);
1100
+ });
1101
+
1102
+ it('preserves explicit model ids even when the seed catalog only knows provider defaults', () => {
1103
+ const document: WorkflowDocument = {
1104
+ name: 'agent build',
1105
+ jobs: {
1106
+ fix: {
1107
+ steps: [{provider: 'openai', model: 'gpt-4.1', prompt: 'Fix it.'}],
1108
+ },
1109
+ },
1110
+ };
1111
+
1112
+ const model = normalizeWorkflowDocument(document);
1113
+
1114
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
1115
+ kind: 'agent',
1116
+ provider: 'openai',
1117
+ model: 'gpt-4.1',
1118
+ prompt: 'Fix it.',
1119
+ });
1120
+ });
1121
+
1122
+ it('applies top-level runner defaults to jobs without runner overrides', () => {
1123
+ const document: WorkflowDocument = {
1124
+ name: 'runner defaults',
1125
+ runner: ['ubuntu-latest', 'node-22'],
1126
+ jobs: {
1127
+ build: {
1128
+ steps: [{run: 'npm run build'}],
1129
+ },
1130
+ test: {
1131
+ runner: 'ubuntu-latest',
1132
+ steps: [{run: 'npm test'}],
1133
+ },
1134
+ },
1135
+ };
1136
+
1137
+ const model = normalizeWorkflowDocument(document);
1138
+
1139
+ expect(model.jobs).toMatchObject([
1140
+ {id: 'build', runner: ['node-22', 'ubuntu-latest']},
1141
+ {id: 'test', runner: ['ubuntu-latest']},
1142
+ ]);
1143
+ });
1144
+
1145
+ it('canonicalizes runner labels', () => {
1146
+ const document: WorkflowDocument = {
1147
+ name: 'canonical runners',
1148
+ runner: [' Ubuntu-Latest ', 'gpu', 'ubuntu-latest', ' Node-22 '],
1149
+ jobs: {
1150
+ build: {
1151
+ steps: [{run: 'npm run build'}],
1152
+ },
1153
+ },
1154
+ };
1155
+
1156
+ const model = normalizeWorkflowDocument(document);
1157
+
1158
+ expect(model.jobs).toMatchObject([{id: 'build', runner: ['gpu', 'node-22', 'ubuntu-latest']}]);
1159
+ });
1160
+
1161
+ it('reports a missing runner label when no default exists', () => {
1162
+ const document: WorkflowDocument = {
1163
+ name: 'missing runner',
1164
+ jobs: {
1165
+ build: {
1166
+ steps: [{run: 'npm run build'}],
1167
+ },
1168
+ },
1169
+ };
1170
+
1171
+ try {
1172
+ normalizeWorkflowDocumentBase(document);
1173
+ expect.fail('Expected InvalidWorkflowModelError');
1174
+ } catch (error) {
1175
+ expect(error).toBeInstanceOf(InvalidWorkflowModelError);
1176
+ expect((error as InvalidWorkflowModelError).issues).toEqual([
1177
+ expect.objectContaining({
1178
+ code: 'missing-runner-label',
1179
+ path: ['jobs', 'build', 'runner'],
1180
+ }),
1181
+ ]);
1182
+ }
1183
+ });
1184
+
1185
+ it('uses canonicalized default runner labels when no runner is declared', () => {
1186
+ const document: WorkflowDocument = {
1187
+ name: 'default runner',
1188
+ jobs: {
1189
+ build: {
1190
+ steps: [{run: 'npm run build'}],
1191
+ },
1192
+ },
1193
+ };
1194
+
1195
+ const model = normalizeWorkflowDocumentBase(document, {
1196
+ defaultRunnerLabels: [' Ubuntu ', 'ubuntu'],
1197
+ });
1198
+
1199
+ expect(model.jobs).toMatchObject([{id: 'build', runner: ['ubuntu']}]);
1200
+ });
1201
+
1202
+ it('does not fall back to defaults for explicit whitespace-only runner labels', () => {
1203
+ const document: WorkflowDocument = {
1204
+ name: 'empty explicit runner',
1205
+ runner: ' ',
1206
+ jobs: {
1207
+ build: {
1208
+ steps: [{run: 'npm run build'}],
1209
+ },
1210
+ },
1211
+ };
1212
+
1213
+ try {
1214
+ normalizeWorkflowDocumentBase(document, {defaultRunnerLabels: ['ubuntu-latest']});
1215
+ expect.fail('Expected InvalidWorkflowModelError');
1216
+ } catch (error) {
1217
+ expect(error).toBeInstanceOf(InvalidWorkflowModelError);
1218
+ expect((error as InvalidWorkflowModelError).issues).toEqual([
1219
+ expect.objectContaining({
1220
+ code: 'missing-runner-label',
1221
+ path: ['jobs', 'build', 'runner'],
1222
+ }),
1223
+ ]);
1224
+ }
1225
+ });
1226
+
1227
+ it('canonicalizes job-level runner overrides over workflow-level runner labels', () => {
1228
+ const document: WorkflowDocument = {
1229
+ name: 'runner overrides',
1230
+ runner: ['ubuntu-latest'],
1231
+ jobs: {
1232
+ build: {
1233
+ runner: [' Node-22 ', 'node-22', 'GPU'],
1234
+ steps: [{run: 'npm run build'}],
1235
+ },
1236
+ },
1237
+ };
1238
+
1239
+ const model = normalizeWorkflowDocument(document);
1240
+
1241
+ expect(model.jobs).toMatchObject([{id: 'build', runner: ['gpu', 'node-22']}]);
1242
+ });
1243
+
1244
+ it('reports invalid runner labels', () => {
1245
+ const document: WorkflowDocument = {
1246
+ name: 'invalid runner',
1247
+ runner: 'ci,gpu',
1248
+ jobs: {
1249
+ build: {
1250
+ steps: [{run: 'npm run build'}],
1251
+ },
1252
+ },
1253
+ };
1254
+
1255
+ try {
1256
+ normalizeWorkflowDocumentBase(document);
1257
+ expect.fail('Expected InvalidWorkflowModelError');
1258
+ } catch (error) {
1259
+ expect(error).toBeInstanceOf(InvalidWorkflowModelError);
1260
+ expect((error as InvalidWorkflowModelError).issues).toEqual([
1261
+ expect.objectContaining({
1262
+ code: 'invalid-runner-label',
1263
+ path: ['jobs', 'build', 'runner'],
1264
+ details: {labels: ['ci,gpu']},
1265
+ }),
1266
+ ]);
1267
+ }
1268
+ });
1269
+
1270
+ it('reports too many runner labels', () => {
1271
+ const document: WorkflowDocument = {
1272
+ name: 'too many runners',
1273
+ runner: Array.from({length: 21}, (_, index) => `label-${index}`),
1274
+ jobs: {
1275
+ build: {
1276
+ steps: [{run: 'npm run build'}],
1277
+ },
1278
+ },
1279
+ };
1280
+
1281
+ try {
1282
+ normalizeWorkflowDocumentBase(document);
1283
+ expect.fail('Expected InvalidWorkflowModelError');
1284
+ } catch (error) {
1285
+ expect(error).toBeInstanceOf(InvalidWorkflowModelError);
1286
+ expect((error as InvalidWorkflowModelError).issues).toEqual([
1287
+ expect.objectContaining({
1288
+ code: 'too-many-runner-labels',
1289
+ path: ['jobs', 'build', 'runner'],
1290
+ }),
1291
+ ]);
1292
+ }
1293
+ });
1294
+
1295
+ it('counts runner templates toward the runner label limit', () => {
1296
+ const document: WorkflowDocument = {
1297
+ name: 'too many templated runners',
1298
+ runner: [
1299
+ ...Array.from({length: 20}, (_, index) => `label-${index}`),
1300
+ workflowInterpolation('execution.name'),
1301
+ ],
1302
+ jobs: {
1303
+ build: {
1304
+ steps: [{run: 'npm run build'}],
1305
+ },
1306
+ },
1307
+ };
1308
+
1309
+ const error = expectInvalid(document);
1310
+
1311
+ expect(error.issues).toEqual([
1312
+ expect.objectContaining({
1313
+ code: 'too-many-runner-labels',
1314
+ path: ['jobs', 'build', 'runner'],
1315
+ }),
1316
+ ]);
1317
+ });
1318
+
1319
+ it('reports invalid runner templates without redundant label issues', () => {
1320
+ const document: WorkflowDocument = {
1321
+ name: 'invalid templated runner',
1322
+ runner: workflowInterpolation('missing.runner'),
1323
+ jobs: {
1324
+ build: {
1325
+ steps: [{run: 'npm run build'}],
1326
+ },
1327
+ },
1328
+ };
1329
+
1330
+ const error = expectInvalid(document);
1331
+
1332
+ expect(error.issues).toEqual([
1333
+ expect.objectContaining({
1334
+ code: 'unknown-interpolation-context',
1335
+ path: ['jobs', 'build', 'runner', 0],
1336
+ }),
1337
+ ]);
1338
+ });
1339
+
1340
+ it('reports invalid labels and too many labels together', () => {
1341
+ const document: WorkflowDocument = {
1342
+ name: 'invalid and too many runners',
1343
+ runner: ['has space', ...Array.from({length: 20}, (_, index) => `label-${index}`)],
1344
+ jobs: {
1345
+ build: {
1346
+ steps: [{run: 'npm run build'}],
1347
+ },
1348
+ },
1349
+ };
1350
+
1351
+ try {
1352
+ normalizeWorkflowDocumentBase(document);
1353
+ expect.fail('Expected InvalidWorkflowModelError');
1354
+ } catch (error) {
1355
+ expect(error).toBeInstanceOf(InvalidWorkflowModelError);
1356
+ expect((error as InvalidWorkflowModelError).issues).toEqual([
1357
+ expect.objectContaining({
1358
+ code: 'invalid-runner-label',
1359
+ path: ['jobs', 'build', 'runner'],
1360
+ details: {labels: ['has space']},
1361
+ }),
1362
+ expect.objectContaining({
1363
+ code: 'too-many-runner-labels',
1364
+ path: ['jobs', 'build', 'runner'],
1365
+ }),
1366
+ ]);
1367
+ }
1368
+ });
1369
+
1370
+ it('accepts the maximum runner label count', () => {
1371
+ const document: WorkflowDocument = {
1372
+ name: 'maximum runner count',
1373
+ runner: Array.from({length: 20}, (_, index) => `label-${index}`),
1374
+ jobs: {
1375
+ build: {
1376
+ steps: [{run: 'npm run build'}],
1377
+ },
1378
+ },
1379
+ };
1380
+
1381
+ const model = normalizeWorkflowDocumentBase(document);
1382
+
1383
+ expect(model.jobs[0]?.runner).toHaveLength(20);
1384
+ });
1385
+
1386
+ it('expands a top-level runner string shorthand', () => {
1387
+ const document: WorkflowDocument = {
1388
+ name: 'runner shorthand',
1389
+ runner: 'ubuntu-latest',
1390
+ jobs: {
1391
+ build: {
1392
+ steps: [{run: 'npm run build'}],
1393
+ },
1394
+ },
1395
+ };
1396
+
1397
+ const model = normalizeWorkflowDocument(document);
1398
+
1399
+ expect(model.jobs).toMatchObject([{id: 'build', runner: ['ubuntu-latest']}]);
1400
+ });
1401
+
1402
+ it('stringifies env at workflow, job, and run-step scope', () => {
1403
+ const document: WorkflowDocument = {
1404
+ name: 'env build',
1405
+ env: {NODE_ENV: 'test', PORT: 3000, CI: true},
1406
+ jobs: {
1407
+ build: {
1408
+ env: {JOB_SCOPE: 'build'},
1409
+ steps: [{run: 'npm test', env: {STEP_SCOPE: 'test', DEBUG: false}}],
1410
+ },
1411
+ },
1412
+ };
1413
+
1414
+ const model = normalizeWorkflowDocument(document);
1415
+
1416
+ expect(model.env).toEqual({NODE_ENV: 'test', PORT: '3000', CI: 'true'});
1417
+ expect(model.jobs[0]?.env).toEqual({JOB_SCOPE: 'build'});
1418
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
1419
+ kind: 'run',
1420
+ env: {STEP_SCOPE: 'test', DEBUG: 'false'},
1421
+ });
1422
+ });
1423
+
1424
+ it('omits empty env maps and does not attach inherited env to agent steps', () => {
1425
+ const document: WorkflowDocument = {
1426
+ name: 'agent env',
1427
+ env: {},
1428
+ jobs: {
1429
+ fix: {
1430
+ env: {JOB_SCOPE: 'fix'},
1431
+ steps: [{model: 'claude-opus-4-8', prompt: 'Fix it.'}],
1432
+ },
1433
+ },
1434
+ };
1435
+
1436
+ const model = normalizeWorkflowDocument(document);
1437
+
1438
+ expect(model).not.toHaveProperty('env');
1439
+ expect(model.jobs[0]?.env).toEqual({JOB_SCOPE: 'fix'});
1440
+ expect(model.jobs[0]?.steps[0]).not.toHaveProperty('env');
1441
+ });
1442
+
1443
+ it('expands needs into job dependencies and explicit graph edges', () => {
1444
+ const document: WorkflowDocument = {
1445
+ name: 'graph',
1446
+ jobs: {
1447
+ 'build app': {
1448
+ steps: [{run: 'npm run build'}],
1449
+ },
1450
+ test: {
1451
+ needs: 'build app',
1452
+ steps: [{run: 'npm test'}],
1453
+ },
1454
+ deploy: {
1455
+ needs: ['build app', 'test'],
1456
+ steps: [{run: 'npm run deploy'}],
1457
+ },
1458
+ },
1459
+ };
1460
+
1461
+ const model = normalizeWorkflowDocument(document);
1462
+
1463
+ expect(model.jobs.map((job) => ({id: job.id, dependencies: job.dependencies}))).toEqual([
1464
+ {id: 'build-app', dependencies: []},
1465
+ {id: 'test', dependencies: ['build-app']},
1466
+ {id: 'deploy', dependencies: ['build-app', 'test']},
1467
+ ]);
1468
+ expect(model.dependencies).toEqual([
1469
+ {from: 'build-app', to: 'test'},
1470
+ {from: 'build-app', to: 'deploy'},
1471
+ {from: 'test', to: 'deploy'},
1472
+ ]);
1473
+ });
1474
+
1475
+ it('deduplicates repeated needs before building graph edges', () => {
1476
+ const document: WorkflowDocument = {
1477
+ name: 'dedupe graph',
1478
+ jobs: {
1479
+ build: {
1480
+ steps: [{run: 'npm run build'}],
1481
+ },
1482
+ test: {
1483
+ needs: ['build', 'build'],
1484
+ steps: [{run: 'npm test'}],
1485
+ },
1486
+ },
1487
+ };
1488
+
1489
+ const model = normalizeWorkflowDocument(document);
1490
+
1491
+ expect(model.jobs).toMatchObject([
1492
+ {id: 'build', dependencies: []},
1493
+ {id: 'test', dependencies: ['build']},
1494
+ ]);
1495
+ expect(model.dependencies).toEqual([{from: 'build', to: 'test'}]);
1496
+ });
1497
+
1498
+ it('normalizes step gates with success conditions and failure actions', () => {
1499
+ const reviewFeedback = 'Agent rejected the PR $' + '{{ step.outputs.review }}';
1500
+ const document: WorkflowDocument = {
1501
+ name: 'review loop',
1502
+ jobs: {
1503
+ review: {
1504
+ steps: [
1505
+ {key: 'producer', run: 'npm run build'},
1506
+ {
1507
+ key: 'reviewer',
1508
+ run: 'npm run review',
1509
+ gate: {
1510
+ success: 'step.exit_code == 0',
1511
+ on_failure: {
1512
+ restart_from: 'producer',
1513
+ feedback: reviewFeedback,
1514
+ },
1515
+ },
1516
+ },
1517
+ ],
1518
+ },
1519
+ },
1520
+ };
1521
+
1522
+ const model = normalizeWorkflowDocument(document);
1523
+
1524
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
1525
+ id: 'review-reviewer',
1526
+ key: 'reviewer',
1527
+ gate: {
1528
+ success: {
1529
+ language: 'cel',
1530
+ source: 'step.exit_code == 0',
1531
+ check: 'typed',
1532
+ resultType: 'bool',
1533
+ },
1534
+ onFailure: {
1535
+ restartFrom: 'producer',
1536
+ feedback: reviewFeedback,
1537
+ feedbackTemplate: [
1538
+ {kind: 'literal', value: 'Agent rejected the PR '},
1539
+ expect.objectContaining({kind: 'deferred'}),
1540
+ ],
1541
+ },
1542
+ },
1543
+ });
1544
+ });
1545
+
1546
+ it('normalizes run step exit-code gates', () => {
1547
+ const document: WorkflowDocument = {
1548
+ name: 'simple build',
1549
+ jobs: {
1550
+ build: {
1551
+ steps: [{name: 'build', run: 'npm run build', gate: {success: 'step.exit_code == 0'}}],
1552
+ },
1553
+ },
1554
+ };
1555
+
1556
+ const model = normalizeWorkflowDocument(document);
1557
+
1558
+ expect(model.jobs[0]?.steps[0]?.gate?.success).toEqual({
1559
+ language: 'cel',
1560
+ source: 'step.exit_code == 0',
1561
+ check: 'typed',
1562
+ resultType: 'bool',
1563
+ });
1564
+ });
1565
+
1566
+ it('accepts step.status in a gate success', () => {
1567
+ const document: WorkflowDocument = {
1568
+ name: 'status gate',
1569
+ jobs: {
1570
+ build: {
1571
+ steps: [{run: 'npm run build', gate: {success: 'step.status == "succeeded"'}}],
1572
+ },
1573
+ },
1574
+ };
1575
+
1576
+ const model = normalizeWorkflowDocument(document);
1577
+
1578
+ expect(model.jobs[0]?.steps[0]?.gate?.success).toEqual({
1579
+ language: 'cel',
1580
+ source: 'step.status == "succeeded"',
1581
+ check: 'typed',
1582
+ resultType: 'bool',
1583
+ });
1584
+ });
1585
+
1586
+ it('accepts server roots available at step reporting in gate success', () => {
1587
+ const document: WorkflowDocument = {
1588
+ name: 'server-context gate',
1589
+ jobs: {
1590
+ build: {steps: [{run: 'npm run build', gate: {success: 'run.id != ""'}}]},
1591
+ },
1592
+ };
1593
+
1594
+ const model = normalizeWorkflowDocument(document);
1595
+
1596
+ expect(model.jobs[0]?.steps[0]?.gate?.success).toEqual({
1597
+ language: 'cel',
1598
+ source: 'run.id != ""',
1599
+ check: 'typed',
1600
+ resultType: 'bool',
1601
+ });
1602
+ });
1603
+
1604
+ it('rejects runner-host roots in gate success with a server-predicate issue', () => {
1605
+ const document: WorkflowDocument = {
1606
+ name: 'runner-context gate',
1607
+ jobs: {
1608
+ build: {
1609
+ steps: [{run: 'npm run build', gate: {success: 'runner.os == "linux"'}}],
1610
+ },
1611
+ },
1612
+ };
1613
+
1614
+ const error = expectInvalid(document);
1615
+
1616
+ expect(error.issues).toEqual([
1617
+ expect.objectContaining({
1618
+ code: 'runner-context-in-server-predicate',
1619
+ message: expect.stringContaining('cannot reference runner context "runner"'),
1620
+ path: ['jobs', 'build', 'steps', 0, 'gate', 'success'],
1621
+ details: expect.objectContaining({
1622
+ field: 'step.success',
1623
+ source: 'runner.os == "linux"',
1624
+ runnerRoots: ['runner'],
1625
+ site: 'step-report',
1626
+ }),
1627
+ }),
1628
+ ]);
1629
+ });
1630
+
1631
+ it('rejects vars in gate success with a server-predicate issue', () => {
1632
+ const document: WorkflowDocument = {
1633
+ name: 'vars-context gate',
1634
+ jobs: {
1635
+ build: {
1636
+ steps: [{run: 'npm run build', gate: {success: 'vars.REQUIRED == "true"'}}],
1637
+ },
1638
+ },
1639
+ };
1640
+
1641
+ const error = expectInvalid(document);
1642
+
1643
+ expect(error.issues).toEqual([
1644
+ expect.objectContaining({
1645
+ code: 'vars-context-in-server-predicate',
1646
+ message: expect.stringContaining('cannot reference vars'),
1647
+ path: ['jobs', 'build', 'steps', 0, 'gate', 'success'],
1648
+ details: expect.objectContaining({
1649
+ field: 'step.success',
1650
+ source: 'vars.REQUIRED == "true"',
1651
+ rejectedRoots: ['vars'],
1652
+ site: 'step-report',
1653
+ }),
1654
+ }),
1655
+ ]);
1656
+ });
1657
+
1658
+ it('accepts jobs root references at the gate predicate site', () => {
1659
+ const document: WorkflowDocument = {
1660
+ name: 'jobs-context gate',
1661
+ jobs: {
1662
+ build: {
1663
+ steps: [{run: 'npm run build'}],
1664
+ },
1665
+ deploy: {
1666
+ needs: ['build'],
1667
+ steps: [{run: 'npm run deploy', gate: {success: 'jobs.build.status == "succeeded"'}}],
1668
+ },
1669
+ },
1670
+ };
1671
+
1672
+ const model = normalizeWorkflowDocument(document);
1673
+
1674
+ expect(model.jobs[1]?.steps[0]?.gate?.success).toEqual({
1675
+ language: 'cel',
1676
+ source: 'jobs.build.status == "succeeded"',
1677
+ check: 'syntax',
1678
+ });
1679
+ });
1680
+
1681
+ it('parses job output mappings at execution resolution', () => {
1682
+ const document: WorkflowDocument = {
1683
+ name: 'job outputs',
1684
+ jobs: {
1685
+ build: {
1686
+ steps: [{key: 'build', run: 'npm run build'}],
1687
+ outputs: {
1688
+ image_sha: interpolation('steps.build.outputs.sha'),
1689
+ registry: 'registry.example.com',
1690
+ },
1691
+ },
1692
+ },
1693
+ };
1694
+
1695
+ const model = normalizeWorkflowDocument(document);
1696
+
1697
+ expect(model.jobs[0]?.outputs).toEqual({
1698
+ image_sha: [
1699
+ {
1700
+ kind: 'deferred',
1701
+ expression: {
1702
+ language: 'cel',
1703
+ source: 'steps.build.outputs.sha',
1704
+ check: 'syntax',
1705
+ },
1706
+ roots: ['steps'],
1707
+ fillTarget: 'execution-resolution',
1708
+ },
1709
+ ],
1710
+ registry: [{kind: 'literal', value: 'registry.example.com'}],
1711
+ });
1712
+ });
1713
+
1714
+ it('normalizes typed step output declarations', () => {
1715
+ const schema = {
1716
+ type: 'object',
1717
+ additionalProperties: false,
1718
+ required: ['digest'],
1719
+ properties: {digest: {type: 'string'}},
1720
+ };
1721
+ const document: WorkflowDocument = {
1722
+ name: 'typed outputs',
1723
+ jobs: {
1724
+ build: {
1725
+ steps: [
1726
+ {
1727
+ key: 'build',
1728
+ run: 'npm run build',
1729
+ outputs: {
1730
+ count: {type: 'number'},
1731
+ metadata: {type: 'json', schema},
1732
+ },
1733
+ },
1734
+ ],
1735
+ },
1736
+ },
1737
+ };
1738
+
1739
+ const model = normalizeWorkflowDocument(document);
1740
+
1741
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
1742
+ outputs: {
1743
+ count: {type: 'number'},
1744
+ metadata: {type: 'json', schema},
1745
+ },
1746
+ });
1747
+ });
1748
+
1749
+ it('reports invalid JSON schemas on typed step outputs', () => {
1750
+ const document: WorkflowDocument = {
1751
+ name: 'bad output schema',
1752
+ jobs: {
1753
+ build: {
1754
+ steps: [
1755
+ {
1756
+ key: 'build',
1757
+ run: 'npm run build',
1758
+ outputs: {
1759
+ metadata: {type: 'json', schema: {type: 'wat'}},
1760
+ },
1761
+ },
1762
+ ],
1763
+ },
1764
+ },
1765
+ };
1766
+
1767
+ const error = expectInvalid(document);
1768
+
1769
+ expect(error.issues).toEqual([
1770
+ expect.objectContaining({
1771
+ code: 'invalid-output-schema',
1772
+ path: ['jobs', 'build', 'steps', 0, 'outputs', 'metadata', 'schema'],
1773
+ }),
1774
+ ]);
1775
+ });
1776
+
1777
+ it('type-checks declared step outputs in later step config', () => {
1778
+ const document: WorkflowDocument = {
1779
+ name: 'typed step output config',
1780
+ jobs: {
1781
+ build: {
1782
+ steps: [
1783
+ {
1784
+ key: 'collect',
1785
+ run: 'npm run collect',
1786
+ outputs: {
1787
+ count: {type: 'number'},
1788
+ ready: {type: 'boolean'},
1789
+ },
1790
+ },
1791
+ {
1792
+ prompt: `Review ${interpolation('steps.collect.outputs.count > 5')}`,
1793
+ },
1794
+ {
1795
+ run: 'echo ok',
1796
+ env: {READY: interpolation('steps.collect.outputs.ready')},
1797
+ },
1798
+ ],
1799
+ },
1800
+ },
1801
+ };
1802
+
1803
+ const model = normalizeWorkflowDocument(document);
1804
+
1805
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
1806
+ kind: 'agent',
1807
+ templates: {
1808
+ prompt: [
1809
+ {kind: 'literal', value: 'Review '},
1810
+ {
1811
+ kind: 'deferred',
1812
+ expression: {
1813
+ source: 'steps.collect.outputs.count > 5',
1814
+ check: 'typed',
1815
+ resultType: 'bool',
1816
+ },
1817
+ roots: ['steps'],
1818
+ },
1819
+ ],
1820
+ },
1821
+ });
1822
+ expect(model.jobs[0]?.steps[2]).toMatchObject({
1823
+ kind: 'run',
1824
+ templates: {
1825
+ env: {
1826
+ READY: [
1827
+ {
1828
+ kind: 'deferred',
1829
+ expression: {
1830
+ source: 'steps.collect.outputs.ready',
1831
+ check: 'typed',
1832
+ resultType: 'bool',
1833
+ },
1834
+ roots: ['steps'],
1835
+ },
1836
+ ],
1837
+ },
1838
+ },
1839
+ });
1840
+ });
1841
+
1842
+ it('rejects undeclared typed step output keys', () => {
1843
+ const document: WorkflowDocument = {
1844
+ name: 'bad typed output key',
1845
+ jobs: {
1846
+ build: {
1847
+ steps: [
1848
+ {key: 'collect', run: 'npm run collect', outputs: {count: {type: 'number'}}},
1849
+ {run: 'echo ok', env: {COUNT: interpolation('steps.collect.outputs.missing')}},
1850
+ ],
1851
+ },
1852
+ },
1853
+ };
1854
+
1855
+ const error = expectInvalid(document);
1856
+
1857
+ expect(error.issues).toEqual([
1858
+ expect.objectContaining({
1859
+ code: 'invalid-interpolation-expression',
1860
+ path: ['jobs', 'build', 'steps', 1, 'env', 'COUNT'],
1861
+ }),
1862
+ ]);
1863
+ });
1864
+
1865
+ it('rejects typed step output references to unknown steps', () => {
1866
+ const document: WorkflowDocument = {
1867
+ name: 'bad typed step key',
1868
+ jobs: {
1869
+ build: {
1870
+ steps: [
1871
+ {key: 'collect', run: 'npm run collect', outputs: {count: {type: 'number'}}},
1872
+ {run: 'echo ok', env: {COUNT: interpolation('steps.other.outputs.count')}},
1873
+ ],
1874
+ },
1875
+ },
1876
+ };
1877
+
1878
+ const error = expectInvalid(document);
1879
+
1880
+ expect(error.issues).toEqual([
1881
+ expect.objectContaining({
1882
+ code: 'invalid-interpolation-expression',
1883
+ path: ['jobs', 'build', 'steps', 1, 'env', 'COUNT'],
1884
+ }),
1885
+ ]);
1886
+ });
1887
+
1888
+ it('rejects typed step output references to later steps', () => {
1889
+ const document: WorkflowDocument = {
1890
+ name: 'later typed step output',
1891
+ jobs: {
1892
+ build: {
1893
+ steps: [
1894
+ {run: 'echo ok', env: {COUNT: interpolation('steps.collect.outputs.count')}},
1895
+ {key: 'collect', run: 'npm run collect', outputs: {count: {type: 'number'}}},
1896
+ ],
1897
+ },
1898
+ },
1899
+ };
1900
+
1901
+ const error = expectInvalid(document);
1902
+
1903
+ expect(error.issues).toEqual([
1904
+ expect.objectContaining({
1905
+ code: 'invalid-interpolation-expression',
1906
+ path: ['jobs', 'build', 'steps', 0, 'env', 'COUNT'],
1907
+ }),
1908
+ ]);
1909
+ });
1910
+
1911
+ it('allows typed step self outputs in gate success expressions', () => {
1912
+ const document: WorkflowDocument = {
1913
+ name: 'typed self gate',
1914
+ jobs: {
1915
+ build: {
1916
+ steps: [
1917
+ {
1918
+ key: 'collect',
1919
+ run: 'npm run collect',
1920
+ outputs: {count: {type: 'number'}},
1921
+ gate: {success: 'step.outputs.count > 5'},
1922
+ },
1923
+ ],
1924
+ },
1925
+ },
1926
+ };
1927
+
1928
+ const model = normalizeWorkflowDocument(document);
1929
+
1930
+ expect(model.jobs[0]?.steps[0]?.gate?.success).toEqual({
1931
+ language: 'cel',
1932
+ source: 'step.outputs.count > 5',
1933
+ check: 'typed',
1934
+ resultType: 'bool',
1935
+ });
1936
+ });
1937
+
1938
+ it('rejects untrusted step outputs in trusted-only run fields', () => {
1939
+ const document: WorkflowDocument = {
1940
+ name: 'untrusted step output',
1941
+ jobs: {
1942
+ build: {
1943
+ steps: [
1944
+ {key: 'collect', run: 'npm run collect', outputs: {value: {type: 'string'}}},
1945
+ {run: `echo ${interpolation('steps.collect.outputs.value')}`},
1946
+ ],
1947
+ },
1948
+ },
1949
+ };
1950
+
1951
+ const error = expectInvalid(document);
1952
+
1953
+ expect(error.issues).toEqual([
1954
+ expect.objectContaining({
1955
+ code: 'untrusted-context-in-field',
1956
+ path: ['jobs', 'build', 'steps', 1, 'run'],
1957
+ details: expect.objectContaining({field: 'run', rejectedRoots: ['steps']}),
1958
+ }),
1959
+ ]);
1960
+ });
1961
+
1962
+ it('infers typed job output scalars for downstream job overlays', () => {
1963
+ const document: WorkflowDocument = {
1964
+ name: 'typed job outputs',
1965
+ jobs: {
1966
+ build: {
1967
+ steps: [{key: 'collect', run: 'npm run collect', outputs: {count: {type: 'number'}}}],
1968
+ outputs: {
1969
+ count: interpolation('steps.collect.outputs.count'),
1970
+ },
1971
+ },
1972
+ deploy: {
1973
+ needs: 'build',
1974
+ success: 'jobs.build.outputs.count > 5',
1975
+ steps: [{prompt: `Deploy ${interpolation('jobs.build.outputs.count')}`}],
1976
+ },
1977
+ },
1978
+ };
1979
+
1980
+ const model = normalizeWorkflowDocument(document);
1981
+
1982
+ expect(model.jobs[0]?.outputTypes).toEqual({count: 'double'});
1983
+ expect(model.jobs[1]?.steps[0]).toMatchObject({
1984
+ kind: 'agent',
1985
+ templates: {
1986
+ prompt: [
1987
+ {kind: 'literal', value: 'Deploy '},
1988
+ {
1989
+ kind: 'deferred',
1990
+ expression: {
1991
+ source: 'jobs.build.outputs.count',
1992
+ check: 'typed',
1993
+ resultType: 'double',
1994
+ },
1995
+ roots: ['jobs'],
1996
+ },
1997
+ ],
1998
+ },
1999
+ });
2000
+ });
2001
+
2002
+ it('rejects downstream references to undeclared typed job outputs', () => {
2003
+ const document: WorkflowDocument = {
2004
+ name: 'bad typed job output',
2005
+ jobs: {
2006
+ build: {
2007
+ steps: [{key: 'collect', run: 'npm run collect', outputs: {count: {type: 'number'}}}],
2008
+ outputs: {
2009
+ count: interpolation('steps.collect.outputs.count'),
2010
+ },
2011
+ },
2012
+ deploy: {
2013
+ needs: 'build',
2014
+ steps: [
2015
+ {
2016
+ prompt: interpolation('jobs.build.outputs.missing'),
2017
+ },
2018
+ ],
2019
+ },
2020
+ },
2021
+ };
2022
+
2023
+ const error = expectInvalid(document);
2024
+
2025
+ expect(error.issues).toEqual([
2026
+ expect.objectContaining({
2027
+ code: 'invalid-interpolation-expression',
2028
+ path: ['jobs', 'deploy', 'steps', 0, 'prompt'],
2029
+ }),
2030
+ ]);
2031
+ });
2032
+
2033
+ it('keeps untyped direct-needs outputs open when another upstream job is typed', () => {
2034
+ const document: WorkflowDocument = {
2035
+ name: 'mixed upstream output typing',
2036
+ jobs: {
2037
+ build: {
2038
+ steps: [{key: 'collect', run: 'npm run collect', outputs: {count: {type: 'number'}}}],
2039
+ outputs: {
2040
+ count: interpolation('steps.collect.outputs.count'),
2041
+ },
2042
+ },
2043
+ lint: {
2044
+ steps: [{run: 'npm run lint'}],
2045
+ outputs: {
2046
+ summary: interpolation('steps.step_1.outputs.summary'),
2047
+ },
2048
+ },
2049
+ deploy: {
2050
+ needs: ['build', 'lint'],
2051
+ steps: [
2052
+ {
2053
+ prompt: interpolation('jobs.lint.outputs.summary'),
2054
+ },
2055
+ ],
2056
+ },
2057
+ },
2058
+ };
2059
+
2060
+ const model = normalizeWorkflowDocument(document);
2061
+
2062
+ expect(model.jobs[2]?.steps[0]).toMatchObject({
2063
+ kind: 'agent',
2064
+ templates: {
2065
+ prompt: [
2066
+ {
2067
+ kind: 'deferred',
2068
+ expression: {source: 'jobs.lint.outputs.summary', check: 'typed'},
2069
+ roots: ['jobs'],
2070
+ },
2071
+ ],
2072
+ },
2073
+ });
2074
+ });
2075
+
2076
+ it('reports non-scalar typed job output mappings', () => {
2077
+ const document: WorkflowDocument = {
2078
+ name: 'bad job output type',
2079
+ jobs: {
2080
+ build: {
2081
+ steps: [
2082
+ {
2083
+ key: 'collect',
2084
+ run: 'npm run collect',
2085
+ outputs: {
2086
+ metadata: {
2087
+ type: 'json',
2088
+ schema: {
2089
+ type: 'object',
2090
+ additionalProperties: false,
2091
+ required: ['digest'],
2092
+ properties: {digest: {type: 'string'}},
2093
+ },
2094
+ },
2095
+ },
2096
+ },
2097
+ ],
2098
+ outputs: {
2099
+ metadata: interpolation('steps.collect.outputs.metadata'),
2100
+ },
2101
+ },
2102
+ },
2103
+ };
2104
+
2105
+ const error = expectInvalid(document);
2106
+
2107
+ expect(error.issues).toEqual([
2108
+ expect.objectContaining({
2109
+ code: 'invalid-job-output',
2110
+ path: ['jobs', 'build', 'outputs', 'metadata'],
2111
+ }),
2112
+ ]);
2113
+ });
2114
+
2115
+ it('rejects job output references without a direct needs edge', () => {
2116
+ const document: WorkflowDocument = {
2117
+ name: 'missing output edge',
2118
+ jobs: {
2119
+ build: {
2120
+ steps: [{run: 'npm run build'}],
2121
+ },
2122
+ deploy: {
2123
+ steps: [{run: 'npm run deploy'}],
2124
+ outputs: {
2125
+ image_sha: interpolation('jobs.build.outputs.image_sha'),
2126
+ },
2127
+ },
2128
+ },
2129
+ };
2130
+
2131
+ const error = expectInvalid(document);
2132
+
2133
+ expect(error.issues).toEqual([
2134
+ expect.objectContaining({
2135
+ code: 'missing-job-needs-edge',
2136
+ path: ['jobs', 'deploy', 'outputs', 'image_sha'],
2137
+ details: expect.objectContaining({
2138
+ field: 'job.outputs',
2139
+ job: 'build',
2140
+ }),
2141
+ }),
2142
+ ]);
2143
+ });
2144
+
2145
+ it('rejects config references without a direct needs edge', () => {
2146
+ const document: WorkflowDocument = {
2147
+ name: 'missing config edge',
2148
+ jobs: {
2149
+ build: {
2150
+ steps: [{run: 'npm run build'}],
2151
+ },
2152
+ deploy: {
2153
+ env: {IMAGE_SHA: interpolation('jobs.build.outputs.image_sha')},
2154
+ steps: [{run: 'npm run deploy'}],
2155
+ },
2156
+ },
2157
+ };
2158
+
2159
+ const error = expectInvalid(document);
2160
+
2161
+ expect(error.issues).toEqual([
2162
+ expect.objectContaining({
2163
+ code: 'missing-job-needs-edge',
2164
+ path: ['jobs', 'deploy', 'env', 'IMAGE_SHA'],
2165
+ details: expect.objectContaining({
2166
+ field: 'env.value',
2167
+ job: 'build',
2168
+ }),
2169
+ }),
2170
+ ]);
2171
+ });
2172
+
2173
+ it('accepts execution fields and event data in job success expressions', () => {
2174
+ const document: WorkflowDocument = {
2175
+ name: 'full-shape job success',
2176
+ jobs: {
2177
+ build: {
2178
+ success:
2179
+ 'executions.all(e, e.name != "") && executions.all(e, e.events.all(ev, ev.data.ok == true))',
2180
+ steps: [{run: 'npm run build'}],
2181
+ },
2182
+ },
2183
+ };
2184
+
2185
+ const model = normalizeWorkflowDocument(document);
2186
+
2187
+ expect(model.jobs[0]?.success).toBe(
2188
+ 'executions.all(e, e.name != "") && executions.all(e, e.events.all(ev, ev.data.ok == true))',
2189
+ );
2190
+ });
2191
+
2192
+ it('rejects unwrapped if predicates', () => {
2193
+ const document: WorkflowDocument = {
2194
+ name: 'unwrapped condition',
2195
+ jobs: {
2196
+ build: {
2197
+ if: 'true',
2198
+ steps: [{run: 'npm run build'}],
2199
+ },
2200
+ },
2201
+ };
2202
+
2203
+ const error = expectInvalid(document);
2204
+
2205
+ expect(error.issues).toEqual([
2206
+ expect.objectContaining({
2207
+ code: 'invalid-job-if',
2208
+ path: ['jobs', 'build', 'if'],
2209
+ details: expect.objectContaining({
2210
+ field: 'job.if',
2211
+ source: 'true',
2212
+ }),
2213
+ }),
2214
+ ]);
2215
+ });
2216
+
2217
+ it('rejects non-boolean if predicates', () => {
2218
+ const document: WorkflowDocument = {
2219
+ name: 'non boolean condition',
2220
+ jobs: {
2221
+ build: {
2222
+ steps: [{run: 'npm run build'}],
2223
+ },
2224
+ deploy: {
2225
+ needs: 'build',
2226
+ if: interpolation('jobs.build.status'),
2227
+ steps: [{run: 'npm run deploy'}],
2228
+ },
2229
+ },
2230
+ };
2231
+
2232
+ const error = expectInvalid(document);
2233
+
2234
+ expect(error.issues).toEqual([
2235
+ expect.objectContaining({
2236
+ code: 'invalid-job-if',
2237
+ path: ['jobs', 'deploy', 'if'],
2238
+ details: expect.objectContaining({
2239
+ field: 'job.if',
2240
+ source: 'jobs.build.status',
2241
+ }),
2242
+ }),
2243
+ ]);
2244
+ });
2245
+
2246
+ it('rejects runner and secrets roots in if predicates', () => {
2247
+ const document: WorkflowDocument = {
2248
+ name: 'server-only conditions',
2249
+ jobs: {
2250
+ build: {
2251
+ if: interpolation('secrets.local.TOKEN != ""'),
2252
+ steps: [{if: interpolation('runner.os == "linux"'), run: 'npm run build'}],
2253
+ },
2254
+ },
2255
+ };
2256
+
2257
+ const error = expectInvalid(document);
2258
+
2259
+ expect(error.issues).toEqual(
2260
+ expect.arrayContaining([
2261
+ expect.objectContaining({
2262
+ code: 'runner-context-in-server-predicate',
2263
+ path: ['jobs', 'build', 'if'],
2264
+ details: expect.objectContaining({field: 'job.if', runnerRoots: ['secrets']}),
2265
+ }),
2266
+ expect.objectContaining({
2267
+ code: 'runner-context-in-server-predicate',
2268
+ path: ['jobs', 'build', 'steps', 0, 'if'],
2269
+ details: expect.objectContaining({field: 'step.if', runnerRoots: ['runner']}),
2270
+ }),
2271
+ ]),
2272
+ );
2273
+ });
2274
+
2275
+ it('rejects vars roots in if predicates', () => {
2276
+ const document: WorkflowDocument = {
2277
+ name: 'vars condition',
2278
+ jobs: {
2279
+ build: {
2280
+ if: interpolation('vars.REQUIRED == "true"'),
2281
+ steps: [{run: 'npm run build'}],
2282
+ },
2283
+ },
2284
+ };
2285
+
2286
+ const error = expectInvalid(document);
2287
+
2288
+ expect(error.issues).toEqual([
2289
+ expect.objectContaining({
2290
+ code: 'vars-context-in-server-predicate',
2291
+ path: ['jobs', 'build', 'if'],
2292
+ details: expect.objectContaining({
2293
+ field: 'job.if',
2294
+ rejectedRoots: ['vars'],
2295
+ }),
2296
+ }),
2297
+ ]);
2298
+ });
2299
+
2300
+ it('rejects if job references without a direct needs edge', () => {
2301
+ const document: WorkflowDocument = {
2302
+ name: 'missing condition edge',
2303
+ jobs: {
2304
+ build: {
2305
+ steps: [{run: 'npm run build'}],
2306
+ },
2307
+ deploy: {
2308
+ if: interpolation('jobs.build.status == "succeeded"'),
2309
+ steps: [{run: 'npm run deploy'}],
2310
+ },
2311
+ },
2312
+ };
2313
+
2314
+ const error = expectInvalid(document);
2315
+
2316
+ expect(error.issues).toEqual([
2317
+ expect.objectContaining({
2318
+ code: 'missing-job-needs-edge',
2319
+ path: ['jobs', 'deploy', 'if'],
2320
+ details: expect.objectContaining({
2321
+ field: 'job.if',
2322
+ job: 'build',
2323
+ }),
2324
+ }),
2325
+ ]);
2326
+ });
2327
+
2328
+ it('rejects needs aggregation when the job has no direct needs', () => {
2329
+ const document: WorkflowDocument = {
2330
+ name: 'missing needs',
2331
+ jobs: {
2332
+ notify: {
2333
+ if: interpolation('needs.exists(n, n.status == "failed")'),
2334
+ steps: [{run: 'npm run notify'}],
2335
+ },
2336
+ },
2337
+ };
2338
+
2339
+ const error = expectInvalid(document);
2340
+
2341
+ expect(error.issues).toEqual([
2342
+ expect.objectContaining({
2343
+ code: 'invalid-job-if',
2344
+ path: ['jobs', 'notify', 'if'],
2345
+ details: expect.objectContaining({
2346
+ field: 'job.if',
2347
+ rejectedRoots: ['needs'],
2348
+ }),
2349
+ }),
2350
+ ]);
2351
+ });
2352
+
2353
+ it('rejects step if references to later steps', () => {
2354
+ const document: WorkflowDocument = {
2355
+ name: 'forward condition',
2356
+ jobs: {
2357
+ build: {
2358
+ steps: [
2359
+ {if: interpolation('steps.deploy.status == "succeeded"'), run: 'npm test'},
2360
+ {key: 'deploy', run: 'npm run deploy'},
2361
+ ],
2362
+ },
2363
+ },
2364
+ };
2365
+
2366
+ const error = expectInvalid(document);
2367
+
2368
+ expect(error.issues).toEqual([
2369
+ expect.objectContaining({
2370
+ code: 'invalid-step-if',
2371
+ path: ['jobs', 'build', 'steps', 0, 'if'],
2372
+ details: expect.objectContaining({
2373
+ field: 'step.if',
2374
+ source: 'steps.deploy.status == "succeeded"',
2375
+ }),
2376
+ }),
2377
+ ]);
2378
+ });
2379
+
2380
+ it('rejects execution.failed before step dispatch', () => {
2381
+ const document: WorkflowDocument = {
2382
+ name: 'early execution failed',
2383
+ jobs: {
2384
+ build: {
2385
+ if: interpolation('execution.failed'),
2386
+ steps: [{run: 'npm run build'}],
2387
+ },
2388
+ },
2389
+ };
2390
+
2391
+ const error = expectInvalid(document);
2392
+
2393
+ expect(error.issues).toEqual([
2394
+ expect.objectContaining({
2395
+ code: 'context-unavailable-at-predicate-site',
2396
+ path: ['jobs', 'build', 'if'],
2397
+ details: expect.objectContaining({
2398
+ field: 'job.if',
2399
+ unavailableRoots: ['execution.failed'],
2400
+ }),
2401
+ }),
2402
+ ]);
2403
+ });
2404
+
2405
+ it('reports invalid job success expressions', () => {
2406
+ const document: WorkflowDocument = {
2407
+ name: 'invalid job success',
2408
+ jobs: {
2409
+ build: {
2410
+ success: 'executions.exists(e, e.status == )',
2411
+ steps: [{run: 'npm run build'}],
2412
+ },
2413
+ },
2414
+ };
2415
+
2416
+ const error = expectInvalid(document);
2417
+
2418
+ expect(error.issues).toEqual([
2419
+ expect.objectContaining({
2420
+ code: 'invalid-job-success',
2421
+ path: ['jobs', 'build', 'success'],
2422
+ details: expect.objectContaining({source: 'executions.exists(e, e.status == )'}),
2423
+ }),
2424
+ ]);
2425
+ });
2426
+
2427
+ it('reports non-boolean job success expressions', () => {
2428
+ const document: WorkflowDocument = {
2429
+ name: 'non-boolean job success',
2430
+ jobs: {
2431
+ build: {
2432
+ success: 'executions.size()',
2433
+ steps: [{run: 'npm run build'}],
2434
+ },
2435
+ },
2436
+ };
2437
+
2438
+ const error = expectInvalid(document);
2439
+
2440
+ expect(error.issues).toEqual([
2441
+ expect.objectContaining({
2442
+ code: 'invalid-job-success',
2443
+ path: ['jobs', 'build', 'success'],
2444
+ details: expect.objectContaining({
2445
+ source: 'executions.size()',
2446
+ reason: expect.stringContaining('must return bool'),
2447
+ }),
2448
+ }),
2449
+ ]);
2450
+ });
2451
+
2452
+ it('reports rootless non-boolean job success expressions', () => {
2453
+ const document: WorkflowDocument = {
2454
+ name: 'rootless non-boolean job success',
2455
+ jobs: {
2456
+ build: {
2457
+ success: '1 + 2',
2458
+ steps: [{run: 'npm run build'}],
2459
+ },
2460
+ },
2461
+ };
2462
+
2463
+ const error = expectInvalid(document);
2464
+
2465
+ expect(error.issues).toEqual([
2466
+ expect.objectContaining({
2467
+ code: 'invalid-job-success',
2468
+ path: ['jobs', 'build', 'success'],
2469
+ details: expect.objectContaining({
2470
+ source: '1 + 2',
2471
+ reason: expect.stringContaining('must return bool'),
2472
+ }),
2473
+ }),
2474
+ ]);
2475
+ });
2476
+
2477
+ it('reports misspelled execution fields in job success expressions', () => {
2478
+ const document: WorkflowDocument = {
2479
+ name: 'misspelled job success',
2480
+ jobs: {
2481
+ build: {
2482
+ success: 'executions.all(e, e.statsu == "succeeded")',
2483
+ steps: [{run: 'npm run build'}],
2484
+ },
2485
+ },
2486
+ };
2487
+
2488
+ const error = expectInvalid(document);
2489
+
2490
+ expect(error.issues).toEqual([
2491
+ expect.objectContaining({
2492
+ code: 'invalid-job-success',
2493
+ path: ['jobs', 'build', 'success'],
2494
+ details: expect.objectContaining({
2495
+ source: 'executions.all(e, e.statsu == "succeeded")',
2496
+ reason: expect.stringContaining('statsu'),
2497
+ }),
2498
+ }),
2499
+ ]);
2500
+ });
2501
+
2502
+ it('rejects runner-host roots in job success with a server-predicate issue', () => {
2503
+ const document: WorkflowDocument = {
2504
+ name: 'runner-context job success',
2505
+ jobs: {
2506
+ build: {
2507
+ success: 'runner.os == "linux"',
2508
+ steps: [{run: 'npm run build'}],
2509
+ },
2510
+ },
2511
+ };
2512
+
2513
+ const error = expectInvalid(document);
2514
+
2515
+ expect(error.issues).toEqual([
2516
+ expect.objectContaining({
2517
+ code: 'runner-context-in-server-predicate',
2518
+ message: expect.stringContaining('cannot reference runner context "runner"'),
2519
+ path: ['jobs', 'build', 'success'],
2520
+ details: expect.objectContaining({
2521
+ field: 'job.success',
2522
+ source: 'runner.os == "linux"',
2523
+ runnerRoots: ['runner'],
2524
+ site: 'job-resolution',
2525
+ }),
2526
+ }),
2527
+ ]);
2528
+ });
2529
+
2530
+ it('rejects vars in job success with a server-predicate issue', () => {
2531
+ const document: WorkflowDocument = {
2532
+ name: 'vars-context job success',
2533
+ jobs: {
2534
+ build: {
2535
+ success: 'vars.ENVIRONMENT == "prod"',
2536
+ steps: [{run: 'npm run build'}],
2537
+ },
2538
+ },
2539
+ };
2540
+
2541
+ const error = expectInvalid(document);
2542
+
2543
+ expect(error.issues).toEqual([
2544
+ expect.objectContaining({
2545
+ code: 'vars-context-in-server-predicate',
2546
+ message: expect.stringContaining('cannot reference vars'),
2547
+ path: ['jobs', 'build', 'success'],
2548
+ details: expect.objectContaining({
2549
+ field: 'job.success',
2550
+ source: 'vars.ENVIRONMENT == "prod"',
2551
+ rejectedRoots: ['vars'],
2552
+ site: 'job-resolution',
2553
+ }),
2554
+ }),
2555
+ ]);
2556
+ });
2557
+
2558
+ it('reports malformed job execution timeouts', () => {
2559
+ const document: WorkflowDocument = {
2560
+ name: 'invalid timeout',
2561
+ jobs: {
2562
+ build: {
2563
+ execution_timeout: 'ten minutes',
2564
+ steps: [{run: 'npm run build'}],
2565
+ },
2566
+ },
2567
+ };
2568
+
2569
+ const error = expectInvalid(document);
2570
+
2571
+ expect(error.issues).toEqual([
2572
+ {
2573
+ code: 'invalid-duration',
2574
+ message: 'Duration must be an integer followed by ms, s, m, h, or d.',
2575
+ path: ['jobs', 'build', 'execution_timeout'],
2576
+ details: {source: 'ten minutes'},
2577
+ },
2578
+ ]);
2579
+ });
2580
+
2581
+ it('reports job execution timeouts below 1s', () => {
2582
+ const document: WorkflowDocument = {
2583
+ name: 'short timeout',
2584
+ jobs: {
2585
+ build: {
2586
+ execution_timeout: '999ms',
2587
+ steps: [{run: 'npm run build'}],
2588
+ },
2589
+ },
2590
+ };
2591
+
2592
+ const error = expectInvalid(document);
2593
+
2594
+ expect(error.issues).toEqual([
2595
+ {
2596
+ code: 'invalid-duration',
2597
+ message: 'Duration must be between 1s and 24h.',
2598
+ path: ['jobs', 'build', 'execution_timeout'],
2599
+ details: {source: '999ms', min_ms: 1000, max_ms: 24 * 60 * 60 * 1000},
2600
+ },
2601
+ ]);
2602
+ });
2603
+
2604
+ it('reports job execution timeouts above 24h', () => {
2605
+ const document: WorkflowDocument = {
2606
+ name: 'long timeout',
2607
+ jobs: {
2608
+ build: {
2609
+ execution_timeout: '25h',
2610
+ steps: [{run: 'npm run build'}],
2611
+ },
2612
+ },
2613
+ };
2614
+
2615
+ const error = expectInvalid(document);
2616
+
2617
+ expect(error.issues).toEqual([
2618
+ {
2619
+ code: 'invalid-duration',
2620
+ message: 'Duration must be between 1s and 24h.',
2621
+ path: ['jobs', 'build', 'execution_timeout'],
2622
+ details: {source: '25h', min_ms: 1000, max_ms: 24 * 60 * 60 * 1000},
2623
+ },
2624
+ ]);
2625
+ });
2626
+
2627
+ it('reports day-based job execution timeouts above 24h', () => {
2628
+ const document: WorkflowDocument = {
2629
+ name: 'long timeout',
2630
+ jobs: {
2631
+ build: {
2632
+ execution_timeout: '30d',
2633
+ steps: [{run: 'npm run build'}],
2634
+ },
2635
+ },
2636
+ };
2637
+
2638
+ const error = expectInvalid(document);
2639
+
2640
+ expect(error.issues).toEqual([
2641
+ {
2642
+ code: 'invalid-duration',
2643
+ message: 'Duration must be between 1s and 24h.',
2644
+ path: ['jobs', 'build', 'execution_timeout'],
2645
+ details: {source: '30d', min_ms: 1000, max_ms: 24 * 60 * 60 * 1000},
2646
+ },
2647
+ ]);
2648
+ });
2649
+
2650
+ it('normalizes on_failure-only gates', () => {
2651
+ const document: WorkflowDocument = {
2652
+ name: 'retry build',
2653
+ jobs: {
2654
+ build: {
2655
+ steps: [
2656
+ {key: 'install', run: 'npm install'},
2657
+ {key: 'build', run: 'npm run build', gate: {on_failure: {restart_from: 'install'}}},
2658
+ ],
2659
+ },
2660
+ },
2661
+ };
2662
+
2663
+ const model = normalizeWorkflowDocument(document);
2664
+
2665
+ expect(model.jobs[0]?.steps[1]?.gate).toEqual({
2666
+ onFailure: {restartFrom: 'install'},
2667
+ });
2668
+ });
2669
+
2670
+ it('accepts step outputs in gate success expressions', () => {
2671
+ const document: WorkflowDocument = {
2672
+ name: 'output gate',
2673
+ jobs: {
2674
+ build: {
2675
+ steps: [{run: 'npm run build', gate: {success: 'step.outputs.pass == true'}}],
2676
+ },
2677
+ },
2678
+ };
2679
+
2680
+ const model = normalizeWorkflowDocument(document);
2681
+
2682
+ expect(model.jobs[0]?.steps[0]?.gate?.success).toEqual({
2683
+ language: 'cel',
2684
+ source: 'step.outputs.pass == true',
2685
+ check: 'typed',
2686
+ resultType: 'bool',
2687
+ });
2688
+ });
2689
+
2690
+ it('reports non-boolean gate success expressions', () => {
2691
+ const document: WorkflowDocument = {
2692
+ name: 'non-boolean gate',
2693
+ jobs: {
2694
+ build: {
2695
+ steps: [{run: 'npm run build', gate: {success: 'step.exit_code + 1'}}],
2696
+ },
2697
+ },
2698
+ };
2699
+
2700
+ const error = expectInvalid(document);
2701
+
2702
+ expect(error.issues).toEqual([
2703
+ expect.objectContaining({
2704
+ code: 'invalid-step-gate-success',
2705
+ path: ['jobs', 'build', 'steps', 0, 'gate', 'success'],
2706
+ details: expect.objectContaining({
2707
+ source: 'step.exit_code + 1',
2708
+ reason: expect.stringContaining('must return bool'),
2709
+ }),
2710
+ }),
2711
+ ]);
2712
+ });
2713
+
2714
+ it('reports gate restart_from references to unknown steps', () => {
2715
+ const document: WorkflowDocument = {
2716
+ name: 'invalid gate',
2717
+ jobs: {
2718
+ build: {
2719
+ steps: [
2720
+ {
2721
+ key: 'review',
2722
+ run: 'npm run review',
2723
+ gate: {on_failure: {restart_from: 'producer'}},
2724
+ },
2725
+ ],
2726
+ },
2727
+ },
2728
+ };
2729
+
2730
+ const error = expectInvalid(document);
2731
+
2732
+ expect(error.issues).toEqual([
2733
+ {
2734
+ code: 'invalid-step-gate-restart-from',
2735
+ message: 'Step "build-review" must restart from an earlier keyed step; found "producer".',
2736
+ path: ['jobs', 'build', 'steps', 0, 'gate', 'on_failure'],
2737
+ details: {stepId: 'build-review', restartFrom: 'producer'},
2738
+ },
2739
+ ]);
2740
+ });
2741
+
2742
+ it('reports gate restart_from references to the same step', () => {
2743
+ const document: WorkflowDocument = {
2744
+ name: 'invalid gate',
2745
+ jobs: {
2746
+ build: {
2747
+ steps: [
2748
+ {
2749
+ key: 'review',
2750
+ run: 'npm run review',
2751
+ gate: {on_failure: {restart_from: 'review'}},
2752
+ },
2753
+ ],
2754
+ },
2755
+ },
2756
+ };
2757
+
2758
+ const error = expectInvalid(document);
2759
+
2760
+ expect(error.issues).toEqual([
2761
+ {
2762
+ code: 'invalid-step-gate-restart-from',
2763
+ message: 'Step "build-review" must restart from an earlier keyed step; found "review".',
2764
+ path: ['jobs', 'build', 'steps', 0, 'gate', 'on_failure'],
2765
+ details: {stepId: 'build-review', restartFrom: 'review'},
2766
+ },
2767
+ ]);
2768
+ });
2769
+
2770
+ it('reports gate restart_from references to later steps', () => {
2771
+ const document: WorkflowDocument = {
2772
+ name: 'invalid gate',
2773
+ jobs: {
2774
+ build: {
2775
+ steps: [
2776
+ {
2777
+ key: 'review',
2778
+ run: 'npm run review',
2779
+ gate: {on_failure: {restart_from: 'producer'}},
2780
+ },
2781
+ {key: 'producer', run: 'npm run build'},
2782
+ ],
2783
+ },
2784
+ },
2785
+ };
2786
+
2787
+ const error = expectInvalid(document);
2788
+
2789
+ expect(error.issues).toEqual([
2790
+ {
2791
+ code: 'invalid-step-gate-restart-from',
2792
+ message: 'Step "build-review" must restart from an earlier keyed step; found "producer".',
2793
+ path: ['jobs', 'build', 'steps', 0, 'gate', 'on_failure'],
2794
+ details: {stepId: 'build-review', restartFrom: 'producer'},
2795
+ },
2796
+ ]);
2797
+ });
2798
+
2799
+ it('reports unknown dependencies', () => {
2800
+ const document: WorkflowDocument = {
2801
+ name: 'unknown dependency',
2802
+ jobs: {
2803
+ test: {
2804
+ needs: 'build',
2805
+ steps: [{run: 'npm test'}],
2806
+ },
2807
+ },
2808
+ };
2809
+
2810
+ const error = expectInvalid(document);
2811
+
2812
+ expect(error.issues).toEqual([
2813
+ {
2814
+ code: 'unknown-job-dependency',
2815
+ message: 'Job "test" depends on unknown job "build".',
2816
+ path: ['jobs', 'test', 'needs'],
2817
+ details: {job: 'test', dependency: 'build'},
2818
+ },
2819
+ ]);
2820
+ });
2821
+
2822
+ it('reports self dependencies', () => {
2823
+ const document: WorkflowDocument = {
2824
+ name: 'self dependency',
2825
+ jobs: {
2826
+ test: {
2827
+ needs: 'test',
2828
+ steps: [{run: 'npm test'}],
2829
+ },
2830
+ },
2831
+ };
2832
+
2833
+ const error = expectInvalid(document);
2834
+
2835
+ expect(error.issues).toEqual([
2836
+ {
2837
+ code: 'self-job-dependency',
2838
+ message: 'Job "test" depends on itself.',
2839
+ path: ['jobs', 'test', 'needs'],
2840
+ details: {job: 'test'},
2841
+ },
2842
+ ]);
2843
+ });
2844
+
2845
+ it('reports dependency cycles', () => {
2846
+ const document: WorkflowDocument = {
2847
+ name: 'cycle',
2848
+ jobs: {
2849
+ build: {
2850
+ needs: 'test',
2851
+ steps: [{run: 'npm run build'}],
2852
+ },
2853
+ test: {
2854
+ needs: 'build',
2855
+ steps: [{run: 'npm test'}],
2856
+ },
2857
+ },
2858
+ };
2859
+
2860
+ const error = expectInvalid(document);
2861
+
2862
+ expect(error.issues).toEqual([
2863
+ {
2864
+ code: 'job-dependency-cycle',
2865
+ message: 'Circular dependency detected among jobs: build, test.',
2866
+ path: ['jobs'],
2867
+ details: {cycleSourceNames: ['build', 'test'], cycleJobIds: ['build', 'test']},
2868
+ },
2869
+ ]);
2870
+ });
2871
+
2872
+ it('reports only cycle members for dependencies blocked by a cycle', () => {
2873
+ const document: WorkflowDocument = {
2874
+ name: 'cycle with dependent',
2875
+ jobs: {
2876
+ build: {
2877
+ needs: 'test',
2878
+ steps: [{run: 'npm run build'}],
2879
+ },
2880
+ test: {
2881
+ needs: 'build',
2882
+ steps: [{run: 'npm test'}],
2883
+ },
2884
+ deploy: {
2885
+ needs: 'build',
2886
+ steps: [{run: 'npm run deploy'}],
2887
+ },
2888
+ },
2889
+ };
2890
+
2891
+ const error = expectInvalid(document);
2892
+
2893
+ expect(error.issues).toEqual([
2894
+ {
2895
+ code: 'job-dependency-cycle',
2896
+ message: 'Circular dependency detected among jobs: build, test.',
2897
+ path: ['jobs'],
2898
+ details: {cycleSourceNames: ['build', 'test'], cycleJobIds: ['build', 'test']},
2899
+ },
2900
+ ]);
2901
+ });
2902
+
2903
+ it('reports stable job id collisions', () => {
2904
+ const document: WorkflowDocument = {
2905
+ name: 'collision',
2906
+ jobs: {
2907
+ 'build app': {
2908
+ steps: [{run: 'npm run build'}],
2909
+ },
2910
+ 'build-app': {
2911
+ steps: [{run: 'npm run build'}],
2912
+ },
2913
+ },
2914
+ };
2915
+
2916
+ const error = expectInvalid(document);
2917
+
2918
+ expect(error.issues).toEqual([
2919
+ {
2920
+ code: 'duplicate-job-id',
2921
+ message: 'Job keys "build app" and "build-app" resolve to the same stable id "build-app".',
2922
+ path: ['jobs', 'build-app'],
2923
+ details: {id: 'build-app', sourceKeys: ['build app', 'build-app']},
2924
+ },
2925
+ ]);
2926
+ });
2927
+
2928
+ it('normalizes trimmed and symbolic job names into stable ids', () => {
2929
+ const document: WorkflowDocument = {
2930
+ name: 'stable ids',
2931
+ jobs: {
2932
+ ' Build App ': {
2933
+ steps: [{run: 'npm run build'}],
2934
+ },
2935
+ '!!!': {
2936
+ steps: [{run: 'npm test'}],
2937
+ },
2938
+ },
2939
+ };
2940
+
2941
+ const model = normalizeWorkflowDocument(document);
2942
+
2943
+ expect(model.jobs.map((job) => ({id: job.id, key: job.key}))).toEqual([
2944
+ {id: 'build-app', key: ' Build App '},
2945
+ {id: 'unnamed', key: '!!!'},
2946
+ ]);
2947
+ });
2948
+
2949
+ it('reports stable trigger id collisions', () => {
2950
+ const document: WorkflowDocument = {
2951
+ name: 'trigger collision',
2952
+ triggers: {
2953
+ main_push: {source: 'github', event: 'push'},
2954
+ 'main push': {source: 'github', event: 'push'},
2955
+ },
2956
+ jobs: {
2957
+ build: {
2958
+ steps: [{run: 'npm run build'}],
2959
+ },
2960
+ },
2961
+ };
2962
+
2963
+ const error = expectInvalid(document);
2964
+
2965
+ expect(error.issues).toEqual([
2966
+ {
2967
+ code: 'duplicate-trigger-id',
2968
+ message:
2969
+ 'Trigger keys "main_push" and "main push" resolve to the same stable id "main-push".',
2970
+ path: ['triggers', 'main push'],
2971
+ details: {id: 'main-push', sourceKeys: ['main_push', 'main push']},
2972
+ },
2973
+ ]);
2974
+ });
2975
+
2976
+ it('reports stable step id collisions inside a job', () => {
2977
+ const document: WorkflowDocument = {
2978
+ name: 'step collision',
2979
+ jobs: {
2980
+ build: {
2981
+ steps: [{run: 'npm install'}, {key: 'step 1', run: 'npm test'}],
2982
+ },
2983
+ },
2984
+ };
2985
+
2986
+ const error = expectInvalid(document);
2987
+
2988
+ expect(error.issues).toEqual([
2989
+ {
2990
+ code: 'duplicate-step-id',
2991
+ message: 'Steps 0 and 1 in job "build" resolve to the same stable id "build-step-1".',
2992
+ path: ['jobs', 'build', 'steps', 1],
2993
+ details: {id: 'build-step-1', indexes: [0, 1]},
2994
+ },
2995
+ ]);
2996
+ });
2997
+
2998
+ it('validates trigger filters while preserving their source strings', () => {
2999
+ const document: WorkflowDocument = {
3000
+ name: 'validated trigger filter',
3001
+ triggers: {
3002
+ main: {
3003
+ source: 'github',
3004
+ event: 'push',
3005
+ filter: 'event.ref == "refs/heads/main" && trigger.source == "github"',
3006
+ },
3007
+ },
3008
+ jobs: {
3009
+ build: {
3010
+ steps: [{run: 'npm run build'}],
3011
+ },
3012
+ },
3013
+ };
3014
+
3015
+ const model = normalizeWorkflowDocument(document);
3016
+
3017
+ expect(model.triggers).toEqual([
3018
+ {
3019
+ id: 'main',
3020
+ key: 'main',
3021
+ source: 'github',
3022
+ event: 'push',
3023
+ filter: 'event.ref == "refs/heads/main" && trigger.source == "github"',
3024
+ },
3025
+ ]);
3026
+ });
3027
+
3028
+ it.each([
3029
+ [
3030
+ 'jobs root',
3031
+ 'jobs.build.outputs.result == "ok"',
3032
+ 'context-unavailable-at-predicate-site',
3033
+ {
3034
+ field: 'trigger.filter',
3035
+ source: 'jobs.build.outputs.result == "ok"',
3036
+ contextRoots: ['jobs'],
3037
+ unavailableRoots: ['jobs'],
3038
+ site: 'ingest',
3039
+ },
3040
+ ],
3041
+ [
3042
+ 'run root',
3043
+ 'run.id == "run-1"',
3044
+ 'context-unavailable-at-predicate-site',
3045
+ {
3046
+ field: 'trigger.filter',
3047
+ source: 'run.id == "run-1"',
3048
+ contextRoots: ['run'],
3049
+ unavailableRoots: ['run'],
3050
+ site: 'ingest',
3051
+ },
3052
+ ],
3053
+ [
3054
+ 'runner root',
3055
+ 'runner.os == "linux"',
3056
+ 'runner-context-in-server-predicate',
3057
+ {
3058
+ field: 'trigger.filter',
3059
+ source: 'runner.os == "linux"',
3060
+ contextRoots: ['runner'],
3061
+ runnerRoots: ['runner'],
3062
+ site: 'ingest',
3063
+ },
3064
+ ],
3065
+ [
3066
+ 'vars root',
3067
+ 'vars.environment == "prod"',
3068
+ 'vars-context-in-server-predicate',
3069
+ {
3070
+ field: 'trigger.filter',
3071
+ source: 'vars.environment == "prod"',
3072
+ contextRoots: ['vars'],
3073
+ rejectedRoots: ['vars'],
3074
+ site: 'ingest',
3075
+ },
3076
+ ],
3077
+ [
3078
+ 'non-boolean shape',
3079
+ 'event.ref',
3080
+ 'invalid-trigger-filter',
3081
+ {
3082
+ field: 'trigger.filter',
3083
+ source: 'event.ref',
3084
+ contextRoots: ['event'],
3085
+ reason: 'Predicate source must be boolean-shaped.',
3086
+ },
3087
+ ],
3088
+ ] as const)('rejects trigger filters with %s', (_label, filter, code, details) => {
3089
+ const document: WorkflowDocument = {
3090
+ name: 'invalid trigger filter',
3091
+ triggers: {
3092
+ main: {
3093
+ source: 'github',
3094
+ event: 'push',
3095
+ filter,
3096
+ },
3097
+ },
3098
+ jobs: {
3099
+ build: {
3100
+ steps: [{run: 'npm run build'}],
3101
+ },
3102
+ },
3103
+ };
3104
+
3105
+ const error = expectInvalid(document);
3106
+
3107
+ expect(error.issues).toEqual([
3108
+ {
3109
+ code,
3110
+ message: expect.any(String),
3111
+ path: ['triggers', 'main', 'filter'],
3112
+ details,
3113
+ },
3114
+ ]);
3115
+ });
3116
+
3117
+ it.each(['manual', 'cron'] as const)('rejects filters on %s triggers', (source) => {
3118
+ const document: WorkflowDocument = {
3119
+ name: 'unsupported trigger filter',
3120
+ triggers: {
3121
+ main: {
3122
+ source,
3123
+ event: source === 'cron' ? 'tick' : 'fire',
3124
+ filter: 'event.ref == "refs/heads/main"',
3125
+ ...(source === 'cron' ? {config: {schedule: '0 2 * * *'}} : {}),
3126
+ },
3127
+ },
3128
+ jobs: {
3129
+ build: {
3130
+ steps: [{run: 'npm run build'}],
3131
+ },
3132
+ },
3133
+ };
3134
+
3135
+ const error = expectInvalid(document);
3136
+
3137
+ expect(error.issues).toEqual([
3138
+ {
3139
+ code: 'invalid-trigger-filter',
3140
+ message: `A ${source} trigger cannot define a filter because it does not receive an event payload.`,
3141
+ path: ['triggers', 'main', 'filter'],
3142
+ details: {source: 'event.ref == "refs/heads/main"', triggerSource: source},
3143
+ },
3144
+ ]);
3145
+ });
3146
+
3147
+ it('maps trigger with values to model inputs', () => {
3148
+ const document: WorkflowDocument = {
3149
+ name: 'inputs',
3150
+ triggers: {
3151
+ dispatch: {
3152
+ source: 'github',
3153
+ event: 'workflow_dispatch',
3154
+ with: {environment: 'production'},
3155
+ },
3156
+ },
3157
+ jobs: {
3158
+ build: {
3159
+ steps: [{run: 'npm run build'}],
3160
+ },
3161
+ },
3162
+ };
3163
+
3164
+ const model = normalizeWorkflowDocument(document);
3165
+
3166
+ expect(model.triggers).toEqual([
3167
+ {
3168
+ id: 'dispatch',
3169
+ key: 'dispatch',
3170
+ source: 'github',
3171
+ event: 'workflow_dispatch',
3172
+ inputs: {environment: 'production'},
3173
+ },
3174
+ ]);
3175
+ });
3176
+
3177
+ it('normalizes a valid cron trigger with the default timezone', () => {
3178
+ const document: WorkflowDocument = {
3179
+ name: 'nightly trigger',
3180
+ triggers: {
3181
+ nightly: {
3182
+ source: 'cron',
3183
+ event: 'tick',
3184
+ config: {schedule: '0 2 * * *'},
3185
+ },
3186
+ },
3187
+ jobs: {
3188
+ build: {
3189
+ steps: [{run: 'npm run build'}],
3190
+ },
3191
+ },
3192
+ };
3193
+
3194
+ const model = normalizeWorkflowDocument(document);
3195
+
3196
+ expect(model.triggers).toEqual([
3197
+ {
3198
+ id: 'nightly',
3199
+ key: 'nightly',
3200
+ source: 'cron',
3201
+ event: 'tick',
3202
+ config: {
3203
+ schedule: '0 2 * * *',
3204
+ timezone: 'UTC',
3205
+ },
3206
+ },
3207
+ ]);
3208
+ });
3209
+
3210
+ it('normalizes a valid cron trigger with an explicit timezone', () => {
3211
+ const document: WorkflowDocument = {
3212
+ name: 'nightly trigger',
3213
+ triggers: {
3214
+ nightly: {
3215
+ source: 'cron',
3216
+ event: 'tick',
3217
+ config: {
3218
+ schedule: '0 2 * * *',
3219
+ timezone: 'Europe/Paris',
3220
+ },
3221
+ },
3222
+ },
3223
+ jobs: {
3224
+ build: {
3225
+ steps: [{run: 'npm run build'}],
3226
+ },
3227
+ },
3228
+ };
3229
+
3230
+ const model = normalizeWorkflowDocument(document);
3231
+
3232
+ expect(model.triggers).toEqual([
3233
+ {
3234
+ id: 'nightly',
3235
+ key: 'nightly',
3236
+ source: 'cron',
3237
+ event: 'tick',
3238
+ config: {
3239
+ schedule: '0 2 * * *',
3240
+ timezone: 'Europe/Paris',
3241
+ },
3242
+ },
3243
+ ]);
3244
+ });
3245
+
3246
+ it('reports a cron trigger with a non-tick event', () => {
3247
+ const document: WorkflowDocument = {
3248
+ name: 'nightly trigger',
3249
+ triggers: {
3250
+ nightly: {
3251
+ source: 'cron',
3252
+ event: 'push',
3253
+ config: {schedule: '0 2 * * *'},
3254
+ },
3255
+ },
3256
+ jobs: {
3257
+ build: {
3258
+ steps: [{run: 'npm run build'}],
3259
+ },
3260
+ },
3261
+ };
3262
+
3263
+ const error = expectInvalid(document);
3264
+
3265
+ expect(error.issues).toEqual([
3266
+ {
3267
+ code: 'invalid-cron-event',
3268
+ message: 'A cron trigger must use event "tick"; found "push".',
3269
+ path: ['triggers', 'nightly', 'event'],
3270
+ details: {event: 'push'},
3271
+ },
3272
+ ]);
3273
+ });
3274
+
3275
+ it('reports a cron trigger without a schedule', () => {
3276
+ const document: WorkflowDocument = {
3277
+ name: 'nightly trigger',
3278
+ triggers: {
3279
+ nightly: {
3280
+ source: 'cron',
3281
+ event: 'tick',
3282
+ },
3283
+ },
3284
+ jobs: {
3285
+ build: {
3286
+ steps: [{run: 'npm run build'}],
3287
+ },
3288
+ },
3289
+ };
3290
+
3291
+ const error = expectInvalid(document);
3292
+
3293
+ expect(error.issues).toEqual([
3294
+ {
3295
+ code: 'missing-cron-schedule',
3296
+ message: 'A cron trigger requires a schedule.',
3297
+ path: ['triggers', 'nightly', 'config', 'schedule'],
3298
+ },
3299
+ ]);
3300
+ });
3301
+
3302
+ it.each([
3303
+ ['malformed', 'not a cron'],
3304
+ ['6-field', '0 0 2 * * *'],
3305
+ ['preset', '@daily'],
3306
+ ])('reports an invalid %s cron schedule', (_label, schedule) => {
3307
+ const document: WorkflowDocument = {
3308
+ name: 'nightly trigger',
3309
+ triggers: {
3310
+ nightly: {
3311
+ source: 'cron',
3312
+ event: 'tick',
3313
+ config: {schedule},
3314
+ },
3315
+ },
3316
+ jobs: {
3317
+ build: {
3318
+ steps: [{run: 'npm run build'}],
3319
+ },
3320
+ },
3321
+ };
3322
+
3323
+ const error = expectInvalid(document);
3324
+
3325
+ expect(error.issues).toEqual([
3326
+ {
3327
+ code: 'invalid-cron-schedule',
3328
+ message: 'Cron trigger schedule must be a valid 5-field cron expression.',
3329
+ path: ['triggers', 'nightly', 'config', 'schedule'],
3330
+ details: {schedule},
3331
+ },
3332
+ ]);
3333
+ });
3334
+
3335
+ it('reports an invalid cron timezone', () => {
3336
+ const document: WorkflowDocument = {
3337
+ name: 'nightly trigger',
3338
+ triggers: {
3339
+ nightly: {
3340
+ source: 'cron',
3341
+ event: 'tick',
3342
+ config: {
3343
+ schedule: '0 2 * * *',
3344
+ timezone: 'Not/A/Zone',
3345
+ },
3346
+ },
3347
+ },
3348
+ jobs: {
3349
+ build: {
3350
+ steps: [{run: 'npm run build'}],
3351
+ },
3352
+ },
3353
+ };
3354
+
3355
+ const error = expectInvalid(document);
3356
+
3357
+ expect(error.issues).toEqual([
3358
+ {
3359
+ code: 'invalid-cron-timezone',
3360
+ message: 'Cron trigger timezone must be a valid IANA time zone.',
3361
+ path: ['triggers', 'nightly', 'config', 'timezone'],
3362
+ details: {timezone: 'Not/A/Zone'},
3363
+ },
3364
+ ]);
3365
+ });
3366
+
3367
+ it('allows multiple cron triggers', () => {
3368
+ const document: WorkflowDocument = {
3369
+ name: 'cron triggers',
3370
+ triggers: {
3371
+ hourly: {
3372
+ source: 'cron',
3373
+ event: 'tick',
3374
+ config: {schedule: '0 * * * *'},
3375
+ },
3376
+ nightly: {
3377
+ source: 'cron',
3378
+ event: 'tick',
3379
+ config: {schedule: '0 2 * * *'},
3380
+ },
3381
+ },
3382
+ jobs: {
3383
+ build: {
3384
+ steps: [{run: 'npm run build'}],
3385
+ },
3386
+ },
3387
+ };
3388
+
3389
+ const model = normalizeWorkflowDocument(document);
3390
+
3391
+ expect(model.triggers.map((trigger) => trigger.key)).toEqual(['hourly', 'nightly']);
3392
+ });
3393
+
3394
+ it('allows a single manual trigger', () => {
3395
+ const document: WorkflowDocument = {
3396
+ name: 'manual trigger',
3397
+ triggers: {
3398
+ manual: {source: 'manual', event: 'fire'},
3399
+ },
3400
+ jobs: {
3401
+ build: {
3402
+ steps: [{run: 'npm run build'}],
3403
+ },
3404
+ },
3405
+ };
3406
+
3407
+ const model = normalizeWorkflowDocument(document);
3408
+
3409
+ expect(model.triggers).toMatchObject([{id: 'manual', source: 'manual', event: 'fire'}]);
3410
+ });
3411
+
3412
+ it('reports multiple manual triggers as a semantic rule', () => {
3413
+ const document: WorkflowDocument = {
3414
+ name: 'manual triggers',
3415
+ triggers: {
3416
+ one: {source: 'manual', event: 'fire'},
3417
+ two: {source: 'manual', event: 'fire'},
3418
+ },
3419
+ jobs: {
3420
+ build: {
3421
+ steps: [{run: 'npm run build'}],
3422
+ },
3423
+ },
3424
+ };
3425
+
3426
+ const error = expectInvalid(document);
3427
+
3428
+ expect(error.issues).toEqual([
3429
+ {
3430
+ code: 'multiple-manual-triggers',
3431
+ message: 'A workflow may declare at most one manual trigger; found 2: one, two.',
3432
+ path: ['triggers'],
3433
+ details: {manualTriggerKeys: ['one', 'two']},
3434
+ },
3435
+ ]);
3436
+ });
3437
+
3438
+ it('accumulates independent semantic issues in one pass', () => {
3439
+ const document: WorkflowDocument = {
3440
+ name: 'many issues',
3441
+ jobs: {
3442
+ 'test app': {
3443
+ needs: 'missing',
3444
+ steps: [{run: 'npm test'}],
3445
+ },
3446
+ 'test-app': {
3447
+ needs: 'test-app',
3448
+ steps: [{run: 'npm test'}],
3449
+ },
3450
+ lint: {
3451
+ needs: 'lint',
3452
+ steps: [{run: 'npm run lint'}],
3453
+ },
3454
+ },
3455
+ };
3456
+
3457
+ const error = expectInvalid(document);
3458
+
3459
+ expect(error.issues.map((issue) => issue.code)).toEqual([
3460
+ 'duplicate-job-id',
3461
+ 'unknown-job-dependency',
3462
+ 'self-job-dependency',
3463
+ ]);
3464
+ });
3465
+
3466
+ it('reports dependency-ordered normalization issues in document order', () => {
3467
+ const document: WorkflowDocument = {
3468
+ name: 'ordered issues',
3469
+ jobs: {
3470
+ deploy: {
3471
+ needs: 'build',
3472
+ runner: 'bad label',
3473
+ steps: [{run: 'npm run deploy'}],
3474
+ },
3475
+ build: {
3476
+ runner: 'also bad',
3477
+ steps: [{run: 'npm run build'}],
3478
+ },
3479
+ },
3480
+ };
3481
+
3482
+ const error = expectInvalid(document);
3483
+
3484
+ expect(error.issues.map((issue) => issue.path)).toEqual([
3485
+ ['jobs', 'deploy', 'runner'],
3486
+ ['jobs', 'build', 'runner'],
3487
+ ]);
3488
+ });
3489
+
3490
+ describe('definition-time interpolation', () => {
3491
+ const interpolation = (source: string) => '$'.concat('{{ ', source, ' }}');
3492
+ const listening = () => ({
3493
+ on: [{source: 'github', event: 'pull_request_review'}],
3494
+ max_executions: 1,
3495
+ });
3496
+
3497
+ it('stores parsed templates for run, env, prompt, and step name fields', () => {
3498
+ const document: WorkflowDocument = {
3499
+ name: 'templated workflow',
3500
+ env: {RUN_ID: interpolation('run.id'), PORT: 3000},
3501
+ jobs: {
3502
+ build: {
3503
+ runner: ['linux', interpolation('execution.events[0].data.runner')],
3504
+ env: {JOB_NAME: interpolation('job.key')},
3505
+ steps: [
3506
+ {
3507
+ name: `deploy ${interpolation('event.action')}`,
3508
+ run: `deploy ${interpolation('run.id')}`,
3509
+ env: {PR_TITLE: interpolation('event.pull_request.title'), DEBUG: false},
3510
+ },
3511
+ {
3512
+ name: `review ${interpolation('inputs.topic')}`,
3513
+ provider: 'openai',
3514
+ prompt: `Review ${interpolation('event.pull_request.title')}`,
3515
+ },
3516
+ ],
3517
+ },
3518
+ },
3519
+ };
3520
+
3521
+ const model = normalizeWorkflowDocument(document);
3522
+
3523
+ expect(model.templates?.env?.RUN_ID).toEqual([
3524
+ {
3525
+ kind: 'deferred',
3526
+ expression: {language: 'cel', source: 'run.id', check: 'typed', resultType: 'string'},
3527
+ roots: ['run'],
3528
+ fillTarget: 'run-creation',
3529
+ },
3530
+ ]);
3531
+ expect(model.templates?.env).not.toHaveProperty('PORT');
3532
+ expect(model.jobs[0]?.runner).toEqual(['linux']);
3533
+ expect(model.jobs[0]?.runnerTemplates).toEqual([
3534
+ [
3535
+ {
3536
+ kind: 'deferred',
3537
+ expression: {
3538
+ language: 'cel',
3539
+ source: 'execution.events[0].data.runner',
3540
+ check: 'typed',
3541
+ resultType: 'string',
3542
+ },
3543
+ roots: ['execution'],
3544
+ fillTarget: 'execution-creation',
3545
+ },
3546
+ ],
3547
+ ]);
3548
+ expect(model.jobs[0]?.templates?.env?.JOB_NAME).toEqual([
3549
+ {
3550
+ kind: 'deferred',
3551
+ expression: {language: 'cel', source: 'job.key', check: 'typed', resultType: 'string'},
3552
+ roots: ['job'],
3553
+ fillTarget: 'run-creation',
3554
+ },
3555
+ ]);
3556
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
3557
+ kind: 'run',
3558
+ command: {value: `deploy ${interpolation('run.id')}`},
3559
+ templates: {
3560
+ command: [
3561
+ {kind: 'literal', value: 'deploy '},
3562
+ {
3563
+ kind: 'deferred',
3564
+ expression: {language: 'cel', source: 'run.id', check: 'typed'},
3565
+ roots: ['run'],
3566
+ },
3567
+ ],
3568
+ name: [
3569
+ {kind: 'literal', value: 'deploy '},
3570
+ {
3571
+ kind: 'deferred',
3572
+ expression: {language: 'cel', source: 'event.action', check: 'syntax'},
3573
+ roots: ['event'],
3574
+ },
3575
+ ],
3576
+ env: {
3577
+ PR_TITLE: [
3578
+ {
3579
+ kind: 'deferred',
3580
+ expression: {
3581
+ language: 'cel',
3582
+ source: 'event.pull_request.title',
3583
+ check: 'syntax',
3584
+ },
3585
+ roots: ['event'],
3586
+ },
3587
+ ],
3588
+ },
3589
+ },
3590
+ });
3591
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
3592
+ kind: 'agent',
3593
+ templates: {
3594
+ prompt: [
3595
+ {kind: 'literal', value: 'Review '},
3596
+ {
3597
+ kind: 'deferred',
3598
+ expression: {
3599
+ language: 'cel',
3600
+ source: 'event.pull_request.title',
3601
+ check: 'syntax',
3602
+ },
3603
+ roots: ['event'],
3604
+ },
3605
+ ],
3606
+ name: [
3607
+ {kind: 'literal', value: 'review '},
3608
+ {
3609
+ kind: 'deferred',
3610
+ expression: {language: 'cel', source: 'inputs.topic', check: 'syntax'},
3611
+ roots: ['inputs'],
3612
+ },
3613
+ ],
3614
+ },
3615
+ });
3616
+ });
3617
+
3618
+ it('omits templates for pure literal and escaped interpolation text', () => {
3619
+ const document: WorkflowDocument = {
3620
+ name: 'literal workflow',
3621
+ env: {VALUE: '$${{ event.ref }}'},
3622
+ jobs: {
3623
+ build: {
3624
+ name: 'Build app',
3625
+ steps: [{name: 'literal step', run: 'echo $${{ event.ref }}'}],
3626
+ },
3627
+ },
3628
+ };
3629
+
3630
+ const model = normalizeWorkflowDocument(document);
3631
+
3632
+ expect(model).not.toHaveProperty('templates');
3633
+ expect(model.jobs[0]?.steps[0]).not.toHaveProperty('templates');
3634
+ expect(model.env).toEqual({VALUE: '$${{ event.ref }}'});
3635
+ expect(model.jobs[0]?.name).toEqual([{kind: 'literal', value: 'Build app'}]);
3636
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
3637
+ kind: 'run',
3638
+ command: {value: 'echo $${{ event.ref }}'},
3639
+ });
3640
+ });
3641
+
3642
+ it('preserves env keys that look like object prototype properties', () => {
3643
+ const document: WorkflowDocument = {
3644
+ name: 'prototype env',
3645
+ env: {['__proto__']: interpolation('event.name')},
3646
+ jobs: {
3647
+ build: {
3648
+ env: {['__proto__']: 'job-value'},
3649
+ steps: [
3650
+ {
3651
+ run: 'echo ok',
3652
+ env: {['__proto__']: interpolation('inputs.value')},
3653
+ },
3654
+ ],
3655
+ },
3656
+ },
3657
+ };
3658
+
3659
+ const model = normalizeWorkflowDocument(document);
3660
+ const workflowEnv = model.env ?? {};
3661
+ const jobEnv = model.jobs[0]?.env ?? {};
3662
+ const step = model.jobs[0]?.steps[0];
3663
+ if (step?.kind !== 'run') expect.fail('Expected a run step');
3664
+ const stepEnv = step.env ?? {};
3665
+ const workflowTemplates = model.templates?.env ?? {};
3666
+ const stepTemplates = step.templates?.env ?? {};
3667
+
3668
+ expect(Object.hasOwn(workflowEnv, '__proto__')).toBe(true);
3669
+ expect(Object.hasOwn(jobEnv, '__proto__')).toBe(true);
3670
+ expect(Object.hasOwn(stepEnv, '__proto__')).toBe(true);
3671
+ expect(Object.getOwnPropertyDescriptor(workflowEnv, '__proto__')?.value).toBe(
3672
+ interpolation('event.name'),
3673
+ );
3674
+ expect(Object.getOwnPropertyDescriptor(jobEnv, '__proto__')?.value).toBe('job-value');
3675
+ expect(Object.getOwnPropertyDescriptor(stepEnv, '__proto__')?.value).toBe(
3676
+ interpolation('inputs.value'),
3677
+ );
3678
+ expect(
3679
+ Object.getOwnPropertyDescriptor(workflowTemplates, '__proto__')?.value?.[0],
3680
+ ).toMatchObject({
3681
+ kind: 'deferred',
3682
+ roots: ['event'],
3683
+ });
3684
+ expect(Object.getOwnPropertyDescriptor(stepTemplates, '__proto__')?.value?.[0]).toMatchObject(
3685
+ {
3686
+ kind: 'deferred',
3687
+ roots: ['inputs'],
3688
+ },
3689
+ );
3690
+ });
3691
+
3692
+ it.each([
3693
+ ['event payload', 'event.pull_request.title', ['event']],
3694
+ ['job outputs', 'jobs.build.outputs.sha', ['jobs']],
3695
+ ] as const)('rejects untrusted %s context in run commands with the env fix-it message', (_label, source, rejectedRoots) => {
3696
+ const document: WorkflowDocument = {
3697
+ name: 'unsafe run',
3698
+ jobs: {
3699
+ build: {
3700
+ steps: [{run: `echo ${interpolation(source)}`}],
3701
+ },
3702
+ },
3703
+ };
3704
+
3705
+ const error = expectInvalid(document);
3706
+
3707
+ expect(error.issues).toEqual([
3708
+ expect.objectContaining({
3709
+ code: 'untrusted-context-in-field',
3710
+ path: ['jobs', 'build', 'steps', 0, 'run'],
3711
+ message: expect.stringContaining('Bind untrusted values to env'),
3712
+ details: expect.objectContaining({
3713
+ field: 'run',
3714
+ rejectedRoots,
3715
+ }),
3716
+ }),
3717
+ ]);
3718
+ });
3719
+
3720
+ it('rejects secrets in agent fields', () => {
3721
+ const document: WorkflowDocument = {
3722
+ name: 'agent secret',
3723
+ jobs: {
3724
+ build: {
3725
+ steps: [{prompt: interpolation('secrets.OPENAI_API_KEY')}],
3726
+ },
3727
+ },
3728
+ };
3729
+
3730
+ const error = expectInvalid(document);
3731
+
3732
+ expect(error.issues).toEqual([
3733
+ expect.objectContaining({
3734
+ code: 'runner-context-in-field',
3735
+ details: expect.objectContaining({rejectedRoots: ['secrets']}),
3736
+ }),
3737
+ ]);
3738
+ });
3739
+
3740
+ it('rejects computed vars keys', () => {
3741
+ const document: WorkflowDocument = {
3742
+ name: 'computed vars',
3743
+ jobs: {
3744
+ build: {
3745
+ steps: [{run: 'echo ok', env: {REGION: interpolation('vars[event.region]')}}],
3746
+ },
3747
+ },
3748
+ };
3749
+
3750
+ const error = expectInvalid(document);
3751
+
3752
+ expect(error.issues).toEqual(
3753
+ expect.arrayContaining([
3754
+ expect.objectContaining({
3755
+ code: 'computed-context-key',
3756
+ details: expect.objectContaining({root: 'vars'}),
3757
+ }),
3758
+ ]),
3759
+ );
3760
+ expect(error.issues.map((issue) => issue.code)).toEqual([
3761
+ 'computed-context-key',
3762
+ 'computed-context-key',
3763
+ ]);
3764
+ });
3765
+
3766
+ it('rejects unknown secret stores', () => {
3767
+ const document: WorkflowDocument = {
3768
+ name: 'unknown secret store',
3769
+ jobs: {
3770
+ build: {
3771
+ steps: [{run: 'echo ok', env: {TOKEN: interpolation('secrets.vault.TOKEN')}}],
3772
+ },
3773
+ },
3774
+ };
3775
+
3776
+ const error = expectInvalid(document);
3777
+
3778
+ expect(error.issues).toEqual([
3779
+ expect.objectContaining({
3780
+ code: 'unknown-secret-store',
3781
+ details: expect.objectContaining({store: 'vault'}),
3782
+ }),
3783
+ ]);
3784
+ });
3785
+
3786
+ it.each([
3787
+ 'execution.events[0].data.body',
3788
+ 'execution["events"][0].data.body',
3789
+ 'execution[x]',
3790
+ ])('rejects untrusted execution sub-paths in run commands: %s', (source) => {
3791
+ const document: WorkflowDocument = {
3792
+ name: 'unsafe execution run',
3793
+ jobs: {
3794
+ build: {
3795
+ steps: [{run: `echo ${interpolation(source)}`}],
3796
+ },
3797
+ },
3798
+ };
3799
+
3800
+ const error = expectInvalid(document);
3801
+
3802
+ expect(error.issues).toEqual([
3803
+ expect.objectContaining({
3804
+ code: 'untrusted-context-in-field',
3805
+ path: ['jobs', 'build', 'steps', 0, 'run'],
3806
+ details: expect.objectContaining({
3807
+ field: 'run',
3808
+ rejectedRoots: ['execution'],
3809
+ }),
3810
+ }),
3811
+ ]);
3812
+ });
3813
+
3814
+ it('rejects execution event access through CEL comprehension bindings in run commands', () => {
3815
+ const document: WorkflowDocument = {
3816
+ name: 'unsafe execution map run',
3817
+ jobs: {
3818
+ build: {
3819
+ steps: [{run: `echo ${interpolation('executions.map(e, e.events[0].data.body)')}`}],
3820
+ },
3821
+ },
3822
+ };
3823
+
3824
+ const error = expectInvalid(document);
3825
+
3826
+ expect(error.issues).toEqual([
3827
+ expect.objectContaining({
3828
+ code: 'untrusted-context-in-field',
3829
+ path: ['jobs', 'build', 'steps', 0, 'run'],
3830
+ details: expect.objectContaining({
3831
+ field: 'run',
3832
+ rejectedRoots: ['executions'],
3833
+ }),
3834
+ }),
3835
+ ]);
3836
+ });
3837
+
3838
+ it.each([
3839
+ ['run', {run: `echo ${interpolation('execution.index')}`}],
3840
+ [
3841
+ 'step-level env',
3842
+ {run: 'echo ok', env: {EXECUTION_INDEX: interpolation('execution.index')}},
3843
+ ],
3844
+ ['step name', {name: interpolation('execution.index'), run: 'echo ok'}],
3845
+ ] as const)('allows one-shot %s interpolation when execution context is available by dispatch', (_field, step) => {
3846
+ const document: WorkflowDocument = {
3847
+ name: 'dispatch execution context',
3848
+ jobs: {
3849
+ build: {
3850
+ steps: [step],
3851
+ },
3852
+ },
3853
+ };
3854
+
3855
+ const model = normalizeWorkflowDocument(document);
3856
+
3857
+ expect(model.jobs[0]?.steps).toHaveLength(1);
3858
+ });
3859
+
3860
+ it.each([
3861
+ ['run', {run: `echo ${interpolation('execution.index')}`}],
3862
+ [
3863
+ 'step-level env',
3864
+ {run: 'echo ok', env: {EXECUTION_INDEX: interpolation('execution.index')}},
3865
+ ],
3866
+ ['step name', {name: interpolation('execution.index'), run: 'echo ok'}],
3867
+ ] as const)('allows listening job %s interpolation when execution context is available', (_field, step) => {
3868
+ const document: WorkflowDocument = {
3869
+ name: 'execution context',
3870
+ jobs: {
3871
+ build: {
3872
+ listening: listening(),
3873
+ steps: [step],
3874
+ },
3875
+ },
3876
+ };
3877
+
3878
+ const model = normalizeWorkflowDocument(document);
3879
+
3880
+ expect(model.jobs[0]?.mode).toBe('listening');
3881
+ });
3882
+
3883
+ it.each([
3884
+ ['prompt', {prompt: interpolation('execution.index')}],
3885
+ ['model', {model: interpolation('execution.name'), prompt: 'Fix it.'}],
3886
+ ['provider', {provider: interpolation('execution.name'), prompt: 'Fix it.'}],
3887
+ ] as const)('allows one-shot agent %s interpolation when execution context is available by dispatch', (_field, step) => {
3888
+ const document: WorkflowDocument = {
3889
+ name: 'dispatch agent context',
3890
+ jobs: {
3891
+ fix: {
3892
+ steps: [step],
3893
+ },
3894
+ },
3895
+ };
3896
+
3897
+ const model = normalizeWorkflowDocument(document);
3898
+
3899
+ expect(model.jobs[0]?.steps).toHaveLength(1);
3900
+ });
3901
+
3902
+ it.each([
3903
+ ['prompt', {prompt: interpolation('execution.index')}],
3904
+ ['model', {model: interpolation('execution.name'), prompt: 'Fix it.'}],
3905
+ ['provider', {provider: interpolation('execution.name'), prompt: 'Fix it.'}],
3906
+ ] as const)('allows listening job agent %s interpolation when execution context is available', (_field, step) => {
3907
+ const document: WorkflowDocument = {
3908
+ name: 'agent execution context',
3909
+ jobs: {
3910
+ fix: {
3911
+ listening: listening(),
3912
+ steps: [step],
3913
+ },
3914
+ },
3915
+ };
3916
+
3917
+ const model = normalizeWorkflowDocument(document);
3918
+
3919
+ expect(model.jobs[0]?.mode).toBe('listening');
3920
+ });
3921
+
3922
+ it('allows step self-root at step dispatch', () => {
3923
+ const document: WorkflowDocument = {
3924
+ name: 'dispatch step context',
3925
+ jobs: {
3926
+ build: {
3927
+ listening: listening(),
3928
+ steps: [{run: `echo ${interpolation('step.status')}`}],
3929
+ },
3930
+ },
3931
+ };
3932
+
3933
+ const model = normalizeWorkflowDocument(document);
3934
+
3935
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
3936
+ kind: 'run',
3937
+ templates: {
3938
+ command: [
3939
+ {kind: 'literal', value: 'echo '},
3940
+ {
3941
+ kind: 'deferred',
3942
+ expression: {language: 'cel', source: 'step.status', check: 'typed'},
3943
+ roots: ['step'],
3944
+ fillTarget: 'step-dispatch',
3945
+ },
3946
+ ],
3947
+ },
3948
+ });
3949
+ });
3950
+
3951
+ it('does not apply availability checks to job names', () => {
3952
+ const document: WorkflowDocument = {
3953
+ name: 'job display context',
3954
+ jobs: {
3955
+ build: {
3956
+ name: interpolation('execution.index'),
3957
+ steps: [{run: 'echo ok'}],
3958
+ },
3959
+ review: {
3960
+ name: interpolation('execution.index'),
3961
+ listening: listening(),
3962
+ steps: [{run: 'echo ok'}],
3963
+ },
3964
+ },
3965
+ };
3966
+
3967
+ const model = normalizeWorkflowDocument(document);
3968
+
3969
+ expect(model.jobs[0]?.name?.[0]).toMatchObject({
3970
+ kind: 'deferred',
3971
+ roots: ['execution'],
3972
+ });
3973
+ expect(model.jobs[1]?.name?.[0]).toMatchObject({
3974
+ kind: 'deferred',
3975
+ roots: ['execution'],
3976
+ });
3977
+ });
3978
+
3979
+ it('does not apply availability checks to workflow-level or job-level env', () => {
3980
+ const document: WorkflowDocument = {
3981
+ name: 'shared env context',
3982
+ env: {WORKFLOW_EXECUTION: interpolation('execution.index')},
3983
+ jobs: {
3984
+ build: {
3985
+ env: {JOB_EXECUTION: interpolation('execution.index')},
3986
+ steps: [{run: 'echo ok'}],
3987
+ },
3988
+ },
3989
+ };
3990
+
3991
+ const model = normalizeWorkflowDocument(document);
3992
+
3993
+ expect(model.templates?.env?.WORKFLOW_EXECUTION?.[0]).toMatchObject({
3994
+ kind: 'deferred',
3995
+ roots: ['execution'],
3996
+ });
3997
+ expect(model.jobs[0]?.templates?.env?.JOB_EXECUTION?.[0]).toMatchObject({
3998
+ kind: 'deferred',
3999
+ roots: ['execution'],
4000
+ });
4001
+ });
4002
+
4003
+ it('allows multi-root step fields when all roots are available by dispatch', () => {
4004
+ const document: WorkflowDocument = {
4005
+ name: 'mixed availability',
4006
+ jobs: {
4007
+ build: {
4008
+ steps: [
4009
+ {
4010
+ run: 'echo ok',
4011
+ env: {MIXED: interpolation('run.id + execution.name')},
4012
+ },
4013
+ ],
4014
+ },
4015
+ },
4016
+ };
4017
+
4018
+ const model = normalizeWorkflowDocument(document);
4019
+
4020
+ expect(model.jobs[0]?.steps).toHaveLength(1);
4021
+ });
4022
+
4023
+ it('allows multi-root step fields when all roots including step are available by dispatch', () => {
4024
+ const document: WorkflowDocument = {
4025
+ name: 'mixed step availability',
4026
+ jobs: {
4027
+ build: {
4028
+ steps: [{run: `echo ${interpolation('string(execution.index) + step.status')}`}],
4029
+ },
4030
+ },
4031
+ };
4032
+
4033
+ const model = normalizeWorkflowDocument(document);
4034
+
4035
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4036
+ kind: 'run',
4037
+ templates: {
4038
+ command: [
4039
+ {kind: 'literal', value: 'echo '},
4040
+ {
4041
+ kind: 'deferred',
4042
+ roots: ['execution', 'step'],
4043
+ fillTarget: 'step-dispatch',
4044
+ },
4045
+ ],
4046
+ },
4047
+ });
4048
+ });
4049
+
4050
+ it('keeps one-shot fields valid when they reference run-scoped contexts', () => {
4051
+ const document: WorkflowDocument = {
4052
+ name: 'run context',
4053
+ jobs: {
4054
+ build: {
4055
+ steps: [
4056
+ {
4057
+ name: interpolation('job.key'),
4058
+ run: `echo ${interpolation('run.id + trigger.source')}`,
4059
+ env: {INPUT: interpolation('inputs.value')},
4060
+ },
4061
+ ],
4062
+ },
4063
+ },
4064
+ };
4065
+
4066
+ const model = normalizeWorkflowDocument(document);
4067
+
4068
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4069
+ kind: 'run',
4070
+ templates: {
4071
+ command: [{kind: 'literal'}, {kind: 'deferred', roots: ['run', 'trigger']}],
4072
+ name: [{kind: 'deferred', roots: ['job']}],
4073
+ env: {INPUT: [{kind: 'deferred', roots: ['inputs']}]},
4074
+ },
4075
+ });
4076
+ });
4077
+
4078
+ it('allows trusted execution metadata in run commands', () => {
4079
+ const document: WorkflowDocument = {
4080
+ name: 'execution metadata',
4081
+ jobs: {
4082
+ build: {
4083
+ listening: listening(),
4084
+ steps: [{run: `echo ${interpolation('executions[0].name')}`}],
4085
+ },
4086
+ },
4087
+ };
4088
+
4089
+ const model = normalizeWorkflowDocument(document);
4090
+
4091
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4092
+ kind: 'run',
4093
+ templates: {
4094
+ command: [
4095
+ {kind: 'literal', value: 'echo '},
4096
+ {
4097
+ kind: 'deferred',
4098
+ expression: {language: 'cel', source: 'executions[0].name', check: 'typed'},
4099
+ roots: ['executions'],
4100
+ },
4101
+ ],
4102
+ },
4103
+ });
4104
+ });
4105
+
4106
+ it('allows execution events in untrusted-capable fields', () => {
4107
+ const document: WorkflowDocument = {
4108
+ name: 'execution events allowed',
4109
+ jobs: {
4110
+ build: {
4111
+ name: `batch ${interpolation('execution.events[0].data.title')}`,
4112
+ listening: listening(),
4113
+ steps: [{provider: 'openai', prompt: interpolation('execution.events[0].data.body')}],
4114
+ },
4115
+ },
4116
+ };
4117
+
4118
+ const model = normalizeWorkflowDocument(document);
4119
+
4120
+ expect(model.jobs[0]?.name?.[1]).toMatchObject({
4121
+ kind: 'deferred',
4122
+ expression: {check: 'typed'},
4123
+ roots: ['execution'],
4124
+ });
4125
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4126
+ kind: 'agent',
4127
+ templates: {
4128
+ prompt: [
4129
+ {
4130
+ kind: 'deferred',
4131
+ expression: {check: 'typed'},
4132
+ roots: ['execution'],
4133
+ },
4134
+ ],
4135
+ },
4136
+ });
4137
+ });
4138
+
4139
+ it('allows untrusted context in env, prompt, and step names', () => {
4140
+ const document: WorkflowDocument = {
4141
+ name: 'untrusted allowed',
4142
+ env: {EVENT_NAME: interpolation('event.name')},
4143
+ jobs: {
4144
+ build: {
4145
+ steps: [
4146
+ {name: interpolation('event.action'), run: 'echo ok'},
4147
+ {provider: 'openai', prompt: interpolation('inputs.prompt')},
4148
+ ],
4149
+ },
4150
+ },
4151
+ };
4152
+
4153
+ const model = normalizeWorkflowDocument(document);
4154
+
4155
+ expect(model.templates?.env?.EVENT_NAME?.[0]).toMatchObject({
4156
+ kind: 'deferred',
4157
+ expression: {check: 'syntax'},
4158
+ roots: ['event'],
4159
+ });
4160
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4161
+ kind: 'run',
4162
+ templates: {name: [{kind: 'deferred', roots: ['event']}]},
4163
+ });
4164
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
4165
+ kind: 'agent',
4166
+ templates: {prompt: [{kind: 'deferred', roots: ['inputs']}]},
4167
+ });
4168
+ });
4169
+
4170
+ it.each([
4171
+ ['model', {model: interpolation('event.model'), prompt: 'Fix it.'}, 'event'],
4172
+ ['provider', {provider: interpolation('inputs.provider'), prompt: 'Fix it.'}, 'inputs'],
4173
+ ] as const)('rejects untrusted agent %s interpolation', (_field, step, root) => {
4174
+ const document: WorkflowDocument = {
4175
+ name: 'unsafe agent field',
4176
+ jobs: {
4177
+ fix: {
4178
+ steps: [step],
4179
+ },
4180
+ },
4181
+ };
4182
+
4183
+ const error = expectInvalid(document);
4184
+
4185
+ expect(error.issues).toEqual([
4186
+ expect.objectContaining({
4187
+ code: 'untrusted-context-in-field',
4188
+ details: expect.objectContaining({rejectedRoots: [root]}),
4189
+ }),
4190
+ ]);
4191
+ });
4192
+
4193
+ it.each([
4194
+ ['model', {model: interpolation('foo.bar'), prompt: 'Fix it.'}],
4195
+ ['provider', {provider: interpolation('foo.bar'), prompt: 'Fix it.'}],
4196
+ ] as const)('rejects unknown context roots in agent %s interpolation', (_field, step) => {
4197
+ const document: WorkflowDocument = {
4198
+ name: 'unknown agent context',
4199
+ jobs: {
4200
+ fix: {
4201
+ steps: [step],
4202
+ },
4203
+ },
4204
+ };
4205
+
4206
+ const error = expectInvalid(document);
4207
+
4208
+ expect(error.issues).toEqual([
4209
+ expect.objectContaining({
4210
+ code: 'unknown-interpolation-context',
4211
+ details: expect.objectContaining({unknownRoots: ['foo']}),
4212
+ }),
4213
+ ]);
4214
+ });
4215
+
4216
+ it.each([
4217
+ ['model', {model: interpolation('run.name'), prompt: 'Fix it.'}],
4218
+ ['provider', {provider: interpolation('run.name'), prompt: 'Fix it.'}],
4219
+ ] as const)('stores trusted agent %s interpolation templates', (field, step) => {
4220
+ const document: WorkflowDocument = {
4221
+ name: 'supported agent field',
4222
+ jobs: {
4223
+ fix: {
4224
+ steps: [step],
4225
+ },
4226
+ },
4227
+ };
4228
+
4229
+ const model = normalizeWorkflowDocument(document);
4230
+
4231
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4232
+ kind: 'agent',
4233
+ templates: {[field]: [{kind: 'deferred', roots: ['run']}]},
4234
+ });
4235
+ });
4236
+
4237
+ it('skips static provider catalog validation when provider is interpolated', () => {
4238
+ const document: WorkflowDocument = {
4239
+ name: 'templated provider',
4240
+ jobs: {
4241
+ fix: {
4242
+ steps: [{provider: interpolation('run.name'), prompt: 'Fix it.'}],
4243
+ },
4244
+ },
4245
+ };
4246
+
4247
+ const model = normalizeWorkflowDocument(document);
4248
+
4249
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4250
+ kind: 'agent',
4251
+ templates: {provider: [{kind: 'deferred', roots: ['run']}]},
4252
+ });
4253
+ });
4254
+
4255
+ it('still validates literal providers through the catalog', () => {
4256
+ const document: WorkflowDocument = {
4257
+ name: 'literal provider',
4258
+ jobs: {
4259
+ fix: {
4260
+ steps: [{provider: 'github-copilot', prompt: 'Fix it.'}],
4261
+ },
4262
+ },
4263
+ };
4264
+
4265
+ const error = expectInvalid(document);
4266
+
4267
+ expect(error.issues).toEqual([
4268
+ expect.objectContaining({
4269
+ code: 'invalid-provider',
4270
+ path: ['jobs', 'fix', 'steps', 0, 'provider'],
4271
+ }),
4272
+ ]);
4273
+ });
4274
+
4275
+ it('reports typed interpolation expression errors for trusted known contexts', () => {
4276
+ const document: WorkflowDocument = {
4277
+ name: 'bad trusted path',
4278
+ env: {BAD: interpolation('run.nope')},
4279
+ jobs: {
4280
+ build: {
4281
+ steps: [{run: 'echo ok'}],
4282
+ },
4283
+ },
4284
+ };
4285
+
4286
+ const error = expectInvalid(document);
4287
+
4288
+ expect(error.issues).toEqual([
4289
+ expect.objectContaining({
4290
+ code: 'invalid-interpolation-expression',
4291
+ path: ['env', 'BAD'],
4292
+ details: expect.objectContaining({
4293
+ field: 'env.value',
4294
+ expression: 'run.nope',
4295
+ contextRoots: ['run'],
4296
+ }),
4297
+ }),
4298
+ ]);
4299
+ });
4300
+
4301
+ it('reports malformed interpolation templates before expression validation', () => {
4302
+ const document: WorkflowDocument = {
4303
+ name: 'bad template',
4304
+ env: {BAD: 'deploy ${{ event.ref'},
4305
+ jobs: {
4306
+ build: {
4307
+ steps: [{run: 'echo ok'}],
4308
+ },
4309
+ },
4310
+ };
4311
+
4312
+ const error = expectInvalid(document);
4313
+
4314
+ expect(error.issues).toEqual([
4315
+ expect.objectContaining({
4316
+ code: 'invalid-interpolation-template',
4317
+ path: ['env', 'BAD'],
4318
+ details: expect.objectContaining({field: 'env.value'}),
4319
+ }),
4320
+ ]);
4321
+ });
4322
+
4323
+ it('reports unknown interpolation context roots', () => {
4324
+ const document: WorkflowDocument = {
4325
+ name: 'unknown context',
4326
+ env: {BAD: interpolation('foo.bar')},
4327
+ jobs: {
4328
+ build: {
4329
+ steps: [{run: 'echo ok'}],
4330
+ },
4331
+ },
4332
+ };
4333
+
4334
+ const error = expectInvalid(document);
4335
+
4336
+ expect(error.issues).toEqual([
4337
+ expect.objectContaining({
4338
+ code: 'unknown-interpolation-context',
4339
+ path: ['env', 'BAD'],
4340
+ details: expect.objectContaining({
4341
+ contextRoots: ['foo'],
4342
+ unknownRoots: ['foo'],
4343
+ }),
4344
+ }),
4345
+ ]);
4346
+ });
4347
+
4348
+ it('type-checks merged trusted contexts and reports bad fields from either root', () => {
4349
+ const validDocument: WorkflowDocument = {
4350
+ name: 'merged contexts',
4351
+ env: {VALID: interpolation('run.name + trigger.source')},
4352
+ jobs: {
4353
+ build: {
4354
+ steps: [{run: 'echo ok'}],
4355
+ },
4356
+ },
4357
+ };
4358
+ const invalidDocument: WorkflowDocument = {
4359
+ name: 'bad merged contexts',
4360
+ env: {BAD: interpolation('run.name + trigger.nope')},
4361
+ jobs: {
4362
+ build: {
4363
+ steps: [{run: 'echo ok'}],
4364
+ },
4365
+ },
4366
+ };
4367
+
4368
+ const model = normalizeWorkflowDocument(validDocument);
4369
+ const error = expectInvalid(invalidDocument);
4370
+
4371
+ expect(model.templates?.env?.VALID?.[0]).toMatchObject({
4372
+ kind: 'deferred',
4373
+ expression: {check: 'typed'},
4374
+ roots: ['run', 'trigger'],
4375
+ });
4376
+ expect(error.issues).toEqual([
4377
+ expect.objectContaining({
4378
+ code: 'invalid-interpolation-expression',
4379
+ path: ['env', 'BAD'],
4380
+ }),
4381
+ ]);
4382
+ });
4383
+
4384
+ it('uses syntax mode for mixed open contexts but still enforces minimum trust', () => {
4385
+ const envDocument: WorkflowDocument = {
4386
+ name: 'mixed env',
4387
+ env: {MIXED: interpolation('run.nope + event.x')},
4388
+ jobs: {
4389
+ build: {
4390
+ steps: [{run: 'echo ok'}],
4391
+ },
4392
+ },
4393
+ };
4394
+ const runDocument: WorkflowDocument = {
4395
+ name: 'mixed run',
4396
+ jobs: {
4397
+ build: {
4398
+ steps: [{run: `echo ${interpolation('run.id + event.x')}`}],
4399
+ },
4400
+ },
4401
+ };
4402
+
4403
+ const model = normalizeWorkflowDocument(envDocument);
4404
+ const error = expectInvalid(runDocument);
4405
+
4406
+ expect(model.templates?.env?.MIXED?.[0]).toMatchObject({
4407
+ kind: 'deferred',
4408
+ expression: {check: 'syntax'},
4409
+ roots: expect.arrayContaining(['run', 'event']),
4410
+ });
4411
+ expect(error.issues).toEqual([
4412
+ expect.objectContaining({
4413
+ code: 'untrusted-context-in-field',
4414
+ path: ['jobs', 'build', 'steps', 0, 'run'],
4415
+ }),
4416
+ ]);
4417
+ });
4418
+
4419
+ it('reports one trust issue for a multi-segment run field with one untrusted segment', () => {
4420
+ const document: WorkflowDocument = {
4421
+ name: 'multi segment run',
4422
+ jobs: {
4423
+ build: {
4424
+ steps: [{run: `${interpolation('run.id')}-${interpolation('event.x')}`}],
4425
+ },
4426
+ },
4427
+ };
4428
+
4429
+ const error = expectInvalid(document);
4430
+
4431
+ expect(error.issues).toEqual([
4432
+ expect.objectContaining({
4433
+ code: 'untrusted-context-in-field',
4434
+ details: expect.objectContaining({rejectedRoots: ['event']}),
4435
+ }),
4436
+ ]);
4437
+ });
4438
+
4439
+ it('does not parse templates in non-string env values', () => {
4440
+ const document: WorkflowDocument = {
4441
+ name: 'non-string env',
4442
+ env: {COUNT: 1, ENABLED: true},
4443
+ jobs: {
4444
+ build: {
4445
+ env: {LIMIT: 10},
4446
+ steps: [{run: 'echo ok', env: {DEBUG: false}}],
4447
+ },
4448
+ },
4449
+ };
4450
+
4451
+ const model = normalizeWorkflowDocument(document);
4452
+
4453
+ expect(model.env).toEqual({COUNT: '1', ENABLED: 'true'});
4454
+ expect(model).not.toHaveProperty('templates');
4455
+ expect(model.jobs[0]).not.toHaveProperty('templates');
4456
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4457
+ kind: 'run',
4458
+ env: {DEBUG: 'false'},
4459
+ });
4460
+ expect(model.jobs[0]?.steps[0]).not.toHaveProperty('templates');
4461
+ });
4462
+ });
4463
+ });