@xemahq/dsl 0.1.1

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 (288) hide show
  1. package/LICENSE +201 -0
  2. package/dist/deliverable-spec/index.d.ts +3 -0
  3. package/dist/deliverable-spec/index.d.ts.map +1 -0
  4. package/dist/deliverable-spec/index.js +19 -0
  5. package/dist/deliverable-spec/index.js.map +1 -0
  6. package/dist/deliverable-spec/lib/schema.d.ts +151 -0
  7. package/dist/deliverable-spec/lib/schema.d.ts.map +1 -0
  8. package/dist/deliverable-spec/lib/schema.js +139 -0
  9. package/dist/deliverable-spec/lib/schema.js.map +1 -0
  10. package/dist/deliverable-spec/lib/types.d.ts +8 -0
  11. package/dist/deliverable-spec/lib/types.d.ts.map +1 -0
  12. package/dist/deliverable-spec/lib/types.js +3 -0
  13. package/dist/deliverable-spec/lib/types.js.map +1 -0
  14. package/dist/payload-codec/index.d.ts +8 -0
  15. package/dist/payload-codec/index.d.ts.map +1 -0
  16. package/dist/payload-codec/index.js +27 -0
  17. package/dist/payload-codec/index.js.map +1 -0
  18. package/dist/payload-codec/lib/blob-store.d.ts +37 -0
  19. package/dist/payload-codec/lib/blob-store.d.ts.map +1 -0
  20. package/dist/payload-codec/lib/blob-store.js +0 -0
  21. package/dist/payload-codec/lib/blob-store.js.map +1 -0
  22. package/dist/payload-codec/lib/codec-context.d.ts +6 -0
  23. package/dist/payload-codec/lib/codec-context.d.ts.map +1 -0
  24. package/dist/payload-codec/lib/codec-context.js +16 -0
  25. package/dist/payload-codec/lib/codec-context.js.map +1 -0
  26. package/dist/payload-codec/lib/codec.d.ts +51 -0
  27. package/dist/payload-codec/lib/codec.d.ts.map +1 -0
  28. package/dist/payload-codec/lib/codec.js +330 -0
  29. package/dist/payload-codec/lib/codec.js.map +1 -0
  30. package/dist/payload-codec/lib/enums.d.ts +18 -0
  31. package/dist/payload-codec/lib/enums.d.ts.map +1 -0
  32. package/dist/payload-codec/lib/enums.js +23 -0
  33. package/dist/payload-codec/lib/enums.js.map +1 -0
  34. package/dist/payload-codec/lib/errors.d.ts +18 -0
  35. package/dist/payload-codec/lib/errors.d.ts.map +1 -0
  36. package/dist/payload-codec/lib/errors.js +39 -0
  37. package/dist/payload-codec/lib/errors.js.map +1 -0
  38. package/dist/payload-codec/lib/http-blob-store.d.ts +21 -0
  39. package/dist/payload-codec/lib/http-blob-store.d.ts.map +1 -0
  40. package/dist/payload-codec/lib/http-blob-store.js +139 -0
  41. package/dist/payload-codec/lib/http-blob-store.js.map +1 -0
  42. package/dist/payload-codec/lib/lru-cache.d.ts +12 -0
  43. package/dist/payload-codec/lib/lru-cache.d.ts.map +1 -0
  44. package/dist/payload-codec/lib/lru-cache.js +59 -0
  45. package/dist/payload-codec/lib/lru-cache.js.map +1 -0
  46. package/dist/schema/action.schema.json +181 -0
  47. package/dist/schema/reusable-workflow.schema.json +46 -0
  48. package/dist/schema/workflow.schema.json +373 -0
  49. package/dist/workflow/index.d.ts +14 -0
  50. package/dist/workflow/index.d.ts.map +1 -0
  51. package/dist/workflow/index.js +49 -0
  52. package/dist/workflow/index.js.map +1 -0
  53. package/dist/workflow/lib/action-input-validator.d.ts +10 -0
  54. package/dist/workflow/lib/action-input-validator.d.ts.map +1 -0
  55. package/dist/workflow/lib/action-input-validator.js +69 -0
  56. package/dist/workflow/lib/action-input-validator.js.map +1 -0
  57. package/dist/workflow/lib/compiler/action-shape.d.ts +5 -0
  58. package/dist/workflow/lib/compiler/action-shape.d.ts.map +1 -0
  59. package/dist/workflow/lib/compiler/action-shape.js +43 -0
  60. package/dist/workflow/lib/compiler/action-shape.js.map +1 -0
  61. package/dist/workflow/lib/compiler/canonical-json.d.ts +3 -0
  62. package/dist/workflow/lib/compiler/canonical-json.d.ts.map +1 -0
  63. package/dist/workflow/lib/compiler/canonical-json.js +45 -0
  64. package/dist/workflow/lib/compiler/canonical-json.js.map +1 -0
  65. package/dist/workflow/lib/compiler/compile.d.ts +4 -0
  66. package/dist/workflow/lib/compiler/compile.d.ts.map +1 -0
  67. package/dist/workflow/lib/compiler/compile.js +794 -0
  68. package/dist/workflow/lib/compiler/compile.js.map +1 -0
  69. package/dist/workflow/lib/compiler/concurrency.d.ts +5 -0
  70. package/dist/workflow/lib/compiler/concurrency.d.ts.map +1 -0
  71. package/dist/workflow/lib/compiler/concurrency.js +104 -0
  72. package/dist/workflow/lib/compiler/concurrency.js.map +1 -0
  73. package/dist/workflow/lib/compiler/dag.d.ts +10 -0
  74. package/dist/workflow/lib/compiler/dag.d.ts.map +1 -0
  75. package/dist/workflow/lib/compiler/dag.js +74 -0
  76. package/dist/workflow/lib/compiler/dag.js.map +1 -0
  77. package/dist/workflow/lib/compiler/index.d.ts +6 -0
  78. package/dist/workflow/lib/compiler/index.d.ts.map +1 -0
  79. package/dist/workflow/lib/compiler/index.js +14 -0
  80. package/dist/workflow/lib/compiler/index.js.map +1 -0
  81. package/dist/workflow/lib/compiler/inputs.d.ts +4 -0
  82. package/dist/workflow/lib/compiler/inputs.d.ts.map +1 -0
  83. package/dist/workflow/lib/compiler/inputs.js +108 -0
  84. package/dist/workflow/lib/compiler/inputs.js.map +1 -0
  85. package/dist/workflow/lib/compiler/installation-resource-validator.d.ts +9 -0
  86. package/dist/workflow/lib/compiler/installation-resource-validator.d.ts.map +1 -0
  87. package/dist/workflow/lib/compiler/installation-resource-validator.js +76 -0
  88. package/dist/workflow/lib/compiler/installation-resource-validator.js.map +1 -0
  89. package/dist/workflow/lib/compiler/manifest-source.d.ts +4 -0
  90. package/dist/workflow/lib/compiler/manifest-source.d.ts.map +1 -0
  91. package/dist/workflow/lib/compiler/manifest-source.js +100 -0
  92. package/dist/workflow/lib/compiler/manifest-source.js.map +1 -0
  93. package/dist/workflow/lib/compiler/matrix.d.ts +4 -0
  94. package/dist/workflow/lib/compiler/matrix.d.ts.map +1 -0
  95. package/dist/workflow/lib/compiler/matrix.js +76 -0
  96. package/dist/workflow/lib/compiler/matrix.js.map +1 -0
  97. package/dist/workflow/lib/compiler/mount-plan.d.ts +4 -0
  98. package/dist/workflow/lib/compiler/mount-plan.d.ts.map +1 -0
  99. package/dist/workflow/lib/compiler/mount-plan.js +96 -0
  100. package/dist/workflow/lib/compiler/mount-plan.js.map +1 -0
  101. package/dist/workflow/lib/compiler/payload-reach-in.d.ts +14 -0
  102. package/dist/workflow/lib/compiler/payload-reach-in.d.ts.map +1 -0
  103. package/dist/workflow/lib/compiler/payload-reach-in.js +273 -0
  104. package/dist/workflow/lib/compiler/payload-reach-in.js.map +1 -0
  105. package/dist/workflow/lib/compiler/permissions.d.ts +6 -0
  106. package/dist/workflow/lib/compiler/permissions.d.ts.map +1 -0
  107. package/dist/workflow/lib/compiler/permissions.js +43 -0
  108. package/dist/workflow/lib/compiler/permissions.js.map +1 -0
  109. package/dist/workflow/lib/compiler/retry-timeout.d.ts +6 -0
  110. package/dist/workflow/lib/compiler/retry-timeout.d.ts.map +1 -0
  111. package/dist/workflow/lib/compiler/retry-timeout.js +64 -0
  112. package/dist/workflow/lib/compiler/retry-timeout.js.map +1 -0
  113. package/dist/workflow/lib/compiler/review-step.d.ts +18 -0
  114. package/dist/workflow/lib/compiler/review-step.d.ts.map +1 -0
  115. package/dist/workflow/lib/compiler/review-step.js +247 -0
  116. package/dist/workflow/lib/compiler/review-step.js.map +1 -0
  117. package/dist/workflow/lib/compiler/types.d.ts +42 -0
  118. package/dist/workflow/lib/compiler/types.d.ts.map +1 -0
  119. package/dist/workflow/lib/compiler/types.js +3 -0
  120. package/dist/workflow/lib/compiler/types.js.map +1 -0
  121. package/dist/workflow/lib/compiler/variable-requirements.d.ts +5 -0
  122. package/dist/workflow/lib/compiler/variable-requirements.d.ts.map +1 -0
  123. package/dist/workflow/lib/compiler/variable-requirements.js +119 -0
  124. package/dist/workflow/lib/compiler/variable-requirements.js.map +1 -0
  125. package/dist/workflow/lib/deliverable-spec-keys.d.ts +3 -0
  126. package/dist/workflow/lib/deliverable-spec-keys.d.ts.map +1 -0
  127. package/dist/workflow/lib/deliverable-spec-keys.js +90 -0
  128. package/dist/workflow/lib/deliverable-spec-keys.js.map +1 -0
  129. package/dist/workflow/lib/dispatch-inputs/index.d.ts +23 -0
  130. package/dist/workflow/lib/dispatch-inputs/index.d.ts.map +1 -0
  131. package/dist/workflow/lib/dispatch-inputs/index.js +106 -0
  132. package/dist/workflow/lib/dispatch-inputs/index.js.map +1 -0
  133. package/dist/workflow/lib/dispatch-inputs/to-json-schema.d.ts +3 -0
  134. package/dist/workflow/lib/dispatch-inputs/to-json-schema.d.ts.map +1 -0
  135. package/dist/workflow/lib/dispatch-inputs/to-json-schema.js +43 -0
  136. package/dist/workflow/lib/dispatch-inputs/to-json-schema.js.map +1 -0
  137. package/dist/workflow/lib/duration.d.ts +2 -0
  138. package/dist/workflow/lib/duration.d.ts.map +1 -0
  139. package/dist/workflow/lib/duration.js +26 -0
  140. package/dist/workflow/lib/duration.js.map +1 -0
  141. package/dist/workflow/lib/errors.d.ts +9 -0
  142. package/dist/workflow/lib/errors.d.ts.map +1 -0
  143. package/dist/workflow/lib/errors.js +28 -0
  144. package/dist/workflow/lib/errors.js.map +1 -0
  145. package/dist/workflow/lib/expression/ast.d.ts +61 -0
  146. package/dist/workflow/lib/expression/ast.d.ts.map +1 -0
  147. package/dist/workflow/lib/expression/ast.js +34 -0
  148. package/dist/workflow/lib/expression/ast.js.map +1 -0
  149. package/dist/workflow/lib/expression/context.d.ts +63 -0
  150. package/dist/workflow/lib/expression/context.d.ts.map +1 -0
  151. package/dist/workflow/lib/expression/context.js +32 -0
  152. package/dist/workflow/lib/expression/context.js.map +1 -0
  153. package/dist/workflow/lib/expression/evaluator.d.ts +5 -0
  154. package/dist/workflow/lib/expression/evaluator.d.ts.map +1 -0
  155. package/dist/workflow/lib/expression/evaluator.js +291 -0
  156. package/dist/workflow/lib/expression/evaluator.js.map +1 -0
  157. package/dist/workflow/lib/expression/index.d.ts +9 -0
  158. package/dist/workflow/lib/expression/index.d.ts.map +1 -0
  159. package/dist/workflow/lib/expression/index.js +26 -0
  160. package/dist/workflow/lib/expression/index.js.map +1 -0
  161. package/dist/workflow/lib/expression/interpolation.d.ts +9 -0
  162. package/dist/workflow/lib/expression/interpolation.d.ts.map +1 -0
  163. package/dist/workflow/lib/expression/interpolation.js +51 -0
  164. package/dist/workflow/lib/expression/interpolation.js.map +1 -0
  165. package/dist/workflow/lib/expression/parser.d.ts +4 -0
  166. package/dist/workflow/lib/expression/parser.d.ts.map +1 -0
  167. package/dist/workflow/lib/expression/parser.js +203 -0
  168. package/dist/workflow/lib/expression/parser.js.map +1 -0
  169. package/dist/workflow/lib/expression/template.d.ts +18 -0
  170. package/dist/workflow/lib/expression/template.d.ts.map +1 -0
  171. package/dist/workflow/lib/expression/template.js +63 -0
  172. package/dist/workflow/lib/expression/template.js.map +1 -0
  173. package/dist/workflow/lib/expression/tokenizer.d.ts +3 -0
  174. package/dist/workflow/lib/expression/tokenizer.d.ts.map +1 -0
  175. package/dist/workflow/lib/expression/tokenizer.js +153 -0
  176. package/dist/workflow/lib/expression/tokenizer.js.map +1 -0
  177. package/dist/workflow/lib/expression/tokens.d.ts +25 -0
  178. package/dist/workflow/lib/expression/tokens.d.ts.map +1 -0
  179. package/dist/workflow/lib/expression/tokens.js +24 -0
  180. package/dist/workflow/lib/expression/tokens.js.map +1 -0
  181. package/dist/workflow/lib/expression/walk-artifact-refs.d.ts +5 -0
  182. package/dist/workflow/lib/expression/walk-artifact-refs.d.ts.map +1 -0
  183. package/dist/workflow/lib/expression/walk-artifact-refs.js +138 -0
  184. package/dist/workflow/lib/expression/walk-artifact-refs.js.map +1 -0
  185. package/dist/workflow/lib/installation-resource-kind.d.ts +14 -0
  186. package/dist/workflow/lib/installation-resource-kind.d.ts.map +1 -0
  187. package/dist/workflow/lib/installation-resource-kind.js +59 -0
  188. package/dist/workflow/lib/installation-resource-kind.js.map +1 -0
  189. package/dist/workflow/lib/schemas-loader.d.ts +4 -0
  190. package/dist/workflow/lib/schemas-loader.d.ts.map +1 -0
  191. package/dist/workflow/lib/schemas-loader.js +36 -0
  192. package/dist/workflow/lib/schemas-loader.js.map +1 -0
  193. package/dist/workflow/lib/serializer.d.ts +3 -0
  194. package/dist/workflow/lib/serializer.d.ts.map +1 -0
  195. package/dist/workflow/lib/serializer.js +15 -0
  196. package/dist/workflow/lib/serializer.js.map +1 -0
  197. package/dist/workflow/lib/types.d.ts +179 -0
  198. package/dist/workflow/lib/types.d.ts.map +1 -0
  199. package/dist/workflow/lib/types.js +3 -0
  200. package/dist/workflow/lib/types.js.map +1 -0
  201. package/dist/workflow/lib/validate.d.ts +8 -0
  202. package/dist/workflow/lib/validate.d.ts.map +1 -0
  203. package/dist/workflow/lib/validate.js +119 -0
  204. package/dist/workflow/lib/validate.js.map +1 -0
  205. package/dist/workspace-manifest/index.d.ts +6 -0
  206. package/dist/workspace-manifest/index.d.ts.map +1 -0
  207. package/dist/workspace-manifest/index.js +22 -0
  208. package/dist/workspace-manifest/index.js.map +1 -0
  209. package/dist/workspace-manifest/lib/compile.d.ts +8 -0
  210. package/dist/workspace-manifest/lib/compile.d.ts.map +1 -0
  211. package/dist/workspace-manifest/lib/compile.js +439 -0
  212. package/dist/workspace-manifest/lib/compile.js.map +1 -0
  213. package/dist/workspace-manifest/lib/interpolate.d.ts +12 -0
  214. package/dist/workspace-manifest/lib/interpolate.d.ts.map +1 -0
  215. package/dist/workspace-manifest/lib/interpolate.js +81 -0
  216. package/dist/workspace-manifest/lib/interpolate.js.map +1 -0
  217. package/dist/workspace-manifest/lib/resolve-extends.d.ts +10 -0
  218. package/dist/workspace-manifest/lib/resolve-extends.d.ts.map +1 -0
  219. package/dist/workspace-manifest/lib/resolve-extends.js +108 -0
  220. package/dist/workspace-manifest/lib/resolve-extends.js.map +1 -0
  221. package/dist/workspace-manifest/lib/schema.d.ts +710 -0
  222. package/dist/workspace-manifest/lib/schema.d.ts.map +1 -0
  223. package/dist/workspace-manifest/lib/schema.js +355 -0
  224. package/dist/workspace-manifest/lib/schema.js.map +1 -0
  225. package/dist/workspace-manifest/lib/types.d.ts +153 -0
  226. package/dist/workspace-manifest/lib/types.d.ts.map +1 -0
  227. package/dist/workspace-manifest/lib/types.js +10 -0
  228. package/dist/workspace-manifest/lib/types.js.map +1 -0
  229. package/package.json +79 -0
  230. package/schema/action.schema.json +181 -0
  231. package/schema/reusable-workflow.schema.json +46 -0
  232. package/schema/workflow.schema.json +373 -0
  233. package/src/deliverable-spec/index.ts +19 -0
  234. package/src/deliverable-spec/lib/schema.ts +248 -0
  235. package/src/deliverable-spec/lib/types.ts +26 -0
  236. package/src/payload-codec/index.ts +40 -0
  237. package/src/payload-codec/lib/blob-store.ts +0 -0
  238. package/src/payload-codec/lib/codec-context.ts +38 -0
  239. package/src/payload-codec/lib/codec.ts +593 -0
  240. package/src/payload-codec/lib/enums.ts +58 -0
  241. package/src/payload-codec/lib/errors.ts +54 -0
  242. package/src/payload-codec/lib/http-blob-store.ts +257 -0
  243. package/src/payload-codec/lib/lru-cache.ts +81 -0
  244. package/src/workflow/index.ts +98 -0
  245. package/src/workflow/lib/action-input-validator.ts +160 -0
  246. package/src/workflow/lib/compiler/action-shape.ts +71 -0
  247. package/src/workflow/lib/compiler/canonical-json.ts +53 -0
  248. package/src/workflow/lib/compiler/compile.ts +1518 -0
  249. package/src/workflow/lib/compiler/concurrency.ts +223 -0
  250. package/src/workflow/lib/compiler/dag.ts +108 -0
  251. package/src/workflow/lib/compiler/index.ts +10 -0
  252. package/src/workflow/lib/compiler/inputs.ts +199 -0
  253. package/src/workflow/lib/compiler/installation-resource-validator.ts +114 -0
  254. package/src/workflow/lib/compiler/manifest-source.ts +176 -0
  255. package/src/workflow/lib/compiler/matrix.ts +135 -0
  256. package/src/workflow/lib/compiler/mount-plan.ts +202 -0
  257. package/src/workflow/lib/compiler/payload-reach-in.ts +497 -0
  258. package/src/workflow/lib/compiler/permissions.ts +64 -0
  259. package/src/workflow/lib/compiler/retry-timeout.ts +105 -0
  260. package/src/workflow/lib/compiler/review-step.ts +517 -0
  261. package/src/workflow/lib/compiler/types.ts +170 -0
  262. package/src/workflow/lib/compiler/variable-requirements.ts +208 -0
  263. package/src/workflow/lib/deliverable-spec-keys.ts +109 -0
  264. package/src/workflow/lib/dispatch-inputs/index.ts +160 -0
  265. package/src/workflow/lib/dispatch-inputs/to-json-schema.ts +60 -0
  266. package/src/workflow/lib/duration.ts +48 -0
  267. package/src/workflow/lib/errors.ts +37 -0
  268. package/src/workflow/lib/expression/ast.ts +108 -0
  269. package/src/workflow/lib/expression/context.ts +148 -0
  270. package/src/workflow/lib/expression/evaluator.ts +492 -0
  271. package/src/workflow/lib/expression/index.ts +28 -0
  272. package/src/workflow/lib/expression/interpolation.ts +84 -0
  273. package/src/workflow/lib/expression/parser.ts +264 -0
  274. package/src/workflow/lib/expression/template.ts +117 -0
  275. package/src/workflow/lib/expression/tokenizer.ts +200 -0
  276. package/src/workflow/lib/expression/tokens.ts +30 -0
  277. package/src/workflow/lib/expression/walk-artifact-refs.ts +232 -0
  278. package/src/workflow/lib/installation-resource-kind.ts +107 -0
  279. package/src/workflow/lib/schemas-loader.ts +64 -0
  280. package/src/workflow/lib/serializer.ts +30 -0
  281. package/src/workflow/lib/types.ts +361 -0
  282. package/src/workflow/lib/validate.ts +199 -0
  283. package/src/workspace-manifest/index.ts +27 -0
  284. package/src/workspace-manifest/lib/compile.ts +608 -0
  285. package/src/workspace-manifest/lib/interpolate.ts +140 -0
  286. package/src/workspace-manifest/lib/resolve-extends.ts +260 -0
  287. package/src/workspace-manifest/lib/schema.ts +612 -0
  288. package/src/workspace-manifest/lib/types.ts +392 -0
@@ -0,0 +1,1518 @@
1
+ import {
2
+ ActionExecutionKind,
3
+ ActionKind,
4
+ MatrixStrategyKind,
5
+ WorkflowErrorCode,
6
+ type ActionRef,
7
+ type CompiledJob,
8
+ type CompiledRun,
9
+ type PermissionResource,
10
+ type PermissionScope,
11
+ } from '@xemahq/kernel-contracts/workflow';
12
+ import { WorkflowDslError } from '../errors';
13
+ import {
14
+ compileExpression,
15
+ ExpressionNodeKind,
16
+ extractInterpolations,
17
+ stripInterpolation,
18
+ type ExpressionNode,
19
+ } from '../expression';
20
+ import type { WorkflowDocument } from '../types';
21
+ import { canonicalJsonSha256 } from './canonical-json';
22
+ import {
23
+ compileConcurrency,
24
+ validateConcurrencyGroupTemplate,
25
+ } from './concurrency';
26
+ import { topologicalSort } from './dag';
27
+ import { bindTriggerInputs } from './inputs';
28
+ import { compileStrategy } from './matrix';
29
+ import { compileManifestSource } from './manifest-source';
30
+ import { compileMountPlan } from './mount-plan';
31
+ import { assertJobPermissionsFit, normalizePermissions } from './permissions';
32
+ import {
33
+ computeReviewNeedsExtensions,
34
+ rewriteReviewStepWith,
35
+ validateReviewSteps,
36
+ } from './review-step';
37
+ import {
38
+ resolveJobRetry,
39
+ resolveJobTimeout,
40
+ resolveWorkflowDefaults,
41
+ } from './retry-timeout';
42
+ import {
43
+ collectPayloadReachInsForJob,
44
+ validatePayloadReachIns,
45
+ } from './payload-reach-in';
46
+ import { validateInstallationResourceBindings } from './installation-resource-validator';
47
+ import {
48
+ resolveWalletRequirements,
49
+ validateVariableReferences,
50
+ } from './variable-requirements';
51
+ import type {
52
+ CompileInput,
53
+ ResolvedAgent,
54
+ ResolvedDeliverableSpec,
55
+ ResolvedRef,
56
+ } from './types';
57
+
58
+ /**
59
+ * Compile a validated workflow document into a CompiledRun. Deterministic:
60
+ * the same (workflow, trigger, resolvedRefs) always produces the same
61
+ * CompiledRun modulo `snapshotCreatedAt`. Pure function; no I/O.
62
+ *
63
+ * Responsibilities:
64
+ * 1. Bind trigger inputs against declared schemas (fail-fast).
65
+ * 2. Resolve concurrency group to a concrete lease key.
66
+ * 3. Resolve action/reusable-workflow refs using the pre-fetched map.
67
+ * 4. Pre-compile every authored expression (so runtime only deals with
68
+ * already-validated ASTs — not done yet in this pass, we validate
69
+ * without caching because ASTs aren't serializable into the CompiledRun).
70
+ * 5. Expand static matrices; record dynamic matrix metadata for runtime.
71
+ * 6. Topologically sort jobs; reject cycles + unknown needs.
72
+ * 7. Enforce job-level permissions are a subset of workflow permissions.
73
+ * 8. Emit CompiledRun with a deterministic sha256.
74
+ */
75
+ export function compileWorkflow(input: CompileInput): CompiledRun {
76
+ const { workflow, workflowRef, trigger, resolvedRefs, workflowDefinitionVersionSha256 } = input;
77
+
78
+ const inputs = bindTriggerInputs(workflow, trigger, input.previewMode ?? false);
79
+ const vars: Readonly<Record<string, unknown>> = workflow.vars ?? {};
80
+ const requiredVariables = resolveWalletRequirements(
81
+ workflow,
82
+ input.resolvedWallets,
83
+ );
84
+ const requiredWallets = Object.freeze([
85
+ ...(workflow.requires?.wallets ?? []),
86
+ ]) as readonly string[];
87
+ const permissions = normalizePermissions(workflow.permissions);
88
+ const concurrency = compileConcurrency(workflow.concurrency, trigger, inputs, vars);
89
+ const defaults = resolveWorkflowDefaults(workflow.defaults);
90
+
91
+ // Validate every expression everywhere at compile time. We throw away the
92
+ // compiled ASTs because CompiledRun is a serializable DTO; the worker will
93
+ // recompile from the same source strings — determinism is preserved
94
+ // because the source text itself is a function of the workflow document.
95
+ validateAllAuthoredExpressions(workflow);
96
+
97
+ // Cross-validate every `${{ vars.X }}` / `${{ secrets.X }}` reference
98
+ // against the union of wallet contents the engine pre-fetched (plus the
99
+ // YAML-static `vars:` block for `vars.*`). Authors get a typo /
100
+ // stale-name error at compile time instead of a runtime
101
+ // DSL_EXPRESSION_INVALID against an unknown property.
102
+ validateVariableReferences(
103
+ workflow,
104
+ requiredVariables,
105
+ input.resolvedWallets,
106
+ );
107
+
108
+ // Validate every literal cross-document reference (agent slugs, deliverable
109
+ // spec refs) against the resolved maps the engine pre-fetched. Expression-
110
+ // shaped values are skipped — they bind at dispatch and the activity's
111
+ // own preflight catches unknowns at runtime. Empty maps opt out (e.g.
112
+ // preview-raw cannot reach the registries).
113
+ validateAllLiteralReferences(
114
+ workflow,
115
+ input.resolvedAgents ?? {},
116
+ input.resolvedDeliverableSpecs ?? {},
117
+ );
118
+
119
+ // `xema/review@*` semantic checks: subject/redraft shape, redraft.step
120
+ // exists/is-agent/is-in-needs/is-not-matrix, redraft-without-subject
121
+ // forbidden. Run BEFORE expression validation so author errors surface
122
+ // with clear messages before less-helpful generic errors (e.g.
123
+ // "unknown step in needs"). The rewrite + needs extension happen
124
+ // alongside the per-job compile loop below.
125
+ validateReviewSteps(workflow);
126
+ const reviewNeedsExtensions = computeReviewNeedsExtensions(workflow);
127
+
128
+ // `matrixGather:` implicitly depends on the gathered jobKeys, so fold
129
+ // them into `needs` BEFORE the topological sort. This keeps authors from
130
+ // having to duplicate each key; the compiler validates referenced keys
131
+ // below (must exist + must be matrix jobs).
132
+ validateMatrixGather(workflow);
133
+
134
+ // Cross-job validation: every `needs.<X>.outputs.…` access must match
135
+ // upstream's strategy + keyBy. Catches `needs.matrix.outputs.deliverables`
136
+ // (silent-passing-then-runtime-error) and `byKey[…]` against
137
+ // un-`keyBy`-ed upstreams at compile time.
138
+ validateAllNeedsAccessShapes(workflow);
139
+
140
+ // Spec-aware field validation: every
141
+ // `${{ ...outputs.deliverable.content.value.<field> }}` access must
142
+ // reference a `<field>` that exists as a top-level key on the producing
143
+ // job's declared deliverable spec. Skips when the engine couldn't
144
+ // pre-fetch spec content (preview mode or specs without introspectable
145
+ // shape).
146
+ validateDeliverableValueExpressions(workflow, input.resolvedDeliverableSpecs ?? {});
147
+
148
+ // §C.1 payload reach-in validation: `needs.<X>.outputs.<name>.<field>`
149
+ // chains where `<field>` is not an ArtifactRef envelope field reach
150
+ // INTO the artifact's parsed payload. The validator asserts the
151
+ // upstream job has a schema-bearing `with.deliverableSpecRef` and
152
+ // that `<field>` is a top-level key on that spec — fail-fast with
153
+ // `DSL_FIELD_NOT_TYPED` / `DSL_FIELD_NOT_IN_SCHEMA` at compile time
154
+ // so authors don't see a generic "Unknown property" at runtime.
155
+ validatePayloadReachIns(
156
+ workflow,
157
+ input.resolvedDeliverableSpecs ?? {},
158
+ resolvedRefs,
159
+ );
160
+
161
+ const orderedJobs = topologicalSort(
162
+ Object.entries(workflow.jobs).map(([key, decl]) => {
163
+ const authoredNeeds = decl.needs ?? [];
164
+ const gather = decl.matrixGather ?? [];
165
+ const reviewExt = reviewNeedsExtensions[key] ?? [];
166
+ const merged =
167
+ gather.length > 0 || reviewExt.length > 0
168
+ ? Array.from(new Set([...authoredNeeds, ...gather, ...reviewExt]))
169
+ : authoredNeeds;
170
+ return { key, payload: decl, needs: merged };
171
+ }),
172
+ );
173
+
174
+ const compiledJobs: CompiledJob[] = orderedJobs.map(({ key, payload, needs }) => {
175
+ const resolved = lookupResolvedRef(payload.uses, resolvedRefs);
176
+ const actionRef: ActionRef = {
177
+ id: resolved.id,
178
+ version: resolved.version,
179
+ manifestSha256: resolved.manifestSha256,
180
+ executionKind: resolveJobExecutionKind(key, payload.with, resolved),
181
+ taskQueue: resolved.taskQueue,
182
+ actionKind: resolveActionKind(resolved),
183
+ isReusableWorkflow: resolved.isReusableWorkflow,
184
+ // Pin the manifest's `inputs:` schema into the compiled ref so the
185
+ // worker validates `with:` against the schema that was in effect at
186
+ // compile time — not whatever's currently published. Reusable
187
+ // workflows have their own `workflow_call.inputs`, validated by
188
+ // their own compile pass.
189
+ inputsSchema: resolved.isReusableWorkflow
190
+ ? null
191
+ : resolved.actionManifest?.spec.inputs ?? null,
192
+ };
193
+
194
+ const jobPermissions = normalizePermissions(payload.permissions);
195
+ assertJobPermissionsFit(jobPermissions, permissions, key);
196
+
197
+ const strategy = compileStrategy(payload.strategy, key);
198
+
199
+ // When the job uses a reusable workflow, mount planning is governed
200
+ // by the reusable workflow's own jobs, not by this job. So we emit an
201
+ // empty plan — the child workflow's compiler run will produce its own.
202
+ const mountPlan = resolved.isReusableWorkflow
203
+ ? { readOnly: {}, readWrite: {} }
204
+ : compileMountPlan(key, payload.with, resolved.actionManifest);
205
+
206
+ // For agent-shaped actions, pre-resolve the manifest source so the
207
+ // worker sees a single discriminated shape (`ref` / `inline` /
208
+ // `inline-deferred`) instead of branching on input fields at
209
+ // dispatch. Reusable-workflow jobs return null — their nested
210
+ // CompiledRun owns its own agent steps.
211
+ const manifestSource = resolved.isReusableWorkflow
212
+ ? null
213
+ : compileManifestSource(key, payload.with, resolved.actionManifest);
214
+
215
+ const retry = resolveJobRetry(
216
+ defaults.retry,
217
+ resolved.actionManifest?.spec.retryDefaults ?? null,
218
+ payload.retry,
219
+ );
220
+ const timeoutMs = resolveJobTimeout(
221
+ defaults.timeoutMs,
222
+ resolved.actionManifest?.spec.timeoutDefaults ?? null,
223
+ payload.timeout,
224
+ );
225
+
226
+ // Validate `if` expression shape now — runtime evaluator only fails on
227
+ // unknown bindings after this point. Plan §G shorthand: rewrite a
228
+ // bare `needs.<X>` reference to `needs.<X>.outcome == 'ok'` so the
229
+ // evaluator sees the explicit success check (an envelope object is
230
+ // truthy even on failure — silent always-true is the trap §G
231
+ // explicitly avoids).
232
+ const ifExpression = payload.if !== undefined
233
+ ? (() => {
234
+ const body = stripInterpolation(payload.if!);
235
+ const rewritten = rewriteBareNeedsInIf(body);
236
+ compileExpression(rewritten);
237
+ return rewritten;
238
+ })()
239
+ : null;
240
+
241
+ // For `xema/review@*` steps, rewrite `with:` from the author shape
242
+ // (`subject` + `redraft.step`) to the worker contract
243
+ // (`subjects` + embedded `redraft: { uses, with }`). Other steps
244
+ // pass through verbatim.
245
+ const compiledWith = rewriteReviewStepWith(key, payload, workflow);
246
+
247
+ // Compile-time installation-binding gate: when the engine threaded
248
+ // an installationScope through CompileInput, walk every literal
249
+ // `x-installation-resource` field in the `with:` block and confirm
250
+ // its value is bound to the calling installation. Rejects unbound
251
+ // walletIds (etc.) BEFORE the dispatch creates a run, instead of
252
+ // failing 3 activities later when integration-adapters refuses to
253
+ // mint credentials. Skips entirely for system / org-wide dispatches.
254
+ validateInstallationResourceBindings({
255
+ jobKey: key,
256
+ actionId: actionRef.id,
257
+ inputsSchema: actionRef.inputsSchema,
258
+ withValue: compiledWith,
259
+ scope: input.installationScope,
260
+ });
261
+
262
+ return Object.freeze({
263
+ jobKey: key,
264
+ title: payload.title ?? null,
265
+ needs,
266
+ matrixGather: Object.freeze([...(payload.matrixGather ?? [])]) as readonly string[],
267
+ ifExpression,
268
+ strategy,
269
+ action: actionRef,
270
+ mountPlan,
271
+ manifestSource,
272
+ with: Object.freeze({ ...compiledWith }) as Readonly<Record<string, unknown>>,
273
+ // Strip the `${{ ... }}` wrapper at compile time so the runtime
274
+ // evaluator receives expression bodies directly — same convention
275
+ // as `ifExpression`. `validateAllAuthoredExpressions` already
276
+ // compiled each body to surface invalid expressions as DSL errors.
277
+ outputs: compileOutputsMap(payload.outputs, `jobs.${key}.outputs`),
278
+ retry,
279
+ timeoutMs,
280
+ permissions: jobPermissions,
281
+ // The DSL emits null; the engine (which owns the reusable-workflow
282
+ // registry) post-processes each CompiledRun and attaches the nested
283
+ // compiled body for jobs whose action is a reusable workflow.
284
+ reusableCompiledRun: null,
285
+ payloadReachIns: Object.freeze(
286
+ collectPayloadReachInsForJob(
287
+ workflow,
288
+ key,
289
+ payload,
290
+ input.resolvedDeliverableSpecs ?? {},
291
+ resolvedRefs,
292
+ ),
293
+ ),
294
+ }) as CompiledJob;
295
+ });
296
+
297
+ const snapshotCreatedAt = input.trigger.triggeredAt;
298
+ const workflowCallOutputs = compileOutputsMap(
299
+ workflow.on.workflow_call?.outputs,
300
+ 'on.workflow_call.outputs',
301
+ );
302
+ const workflowOutputs = compileWorkflowOutputs(workflow);
303
+
304
+ const baseCompiled = {
305
+ compiledRunVersion: 1 as const,
306
+ workflowRef,
307
+ trigger,
308
+ inputs,
309
+ vars,
310
+ requiredWallets,
311
+ requiredVariables,
312
+ permissions,
313
+ concurrency,
314
+ defaults,
315
+ jobs: Object.freeze(compiledJobs) as readonly CompiledJob[],
316
+ snapshotCreatedAt,
317
+ workflowDefinitionVersionSha256,
318
+ workflowCallOutputs,
319
+ workflowOutputs,
320
+ // Spread only when present so a run dispatched without a briefcase
321
+ // produces a CompiledRun without the field — keeps snapshots from
322
+ // older runs hash-stable while letting new dispatches carry the
323
+ // briefcase into the run's permanent record.
324
+ ...(input.briefcase !== undefined && { briefcase: input.briefcase }),
325
+ };
326
+
327
+ const snapshotSha256 = canonicalJsonSha256(baseCompiled);
328
+
329
+ const compiled: CompiledRun = {
330
+ ...baseCompiled,
331
+ snapshotSha256,
332
+ };
333
+
334
+ // Final shape check: strategy presence vs static cardinality makes
335
+ // sense. Dynamic strategies cannot satisfy permission budgets at compile
336
+ // time — the runtime enforces against the declared maxEntries instead.
337
+ assertStrategyInvariants(compiled);
338
+
339
+ return compiled;
340
+ }
341
+
342
+ /**
343
+ * Resolve an action's semantic `ActionKind` from its manifest. Reusable
344
+ * workflows are always `DISPATCH` (their `uses:` resolves to another
345
+ * workflow). Otherwise read the manifest's `spec.actionKind`; any
346
+ * unknown string falls back to `GENERIC` so third-party manifests stay
347
+ * safe with this compiler's closed enum.
348
+ */
349
+ /**
350
+ * Plan §G shorthand: rewrite a bare `needs.<X>` reference at the top
351
+ * level of an `if:` expression to `needs.<X>.outcome == 'ok'`. Authors
352
+ * write `if: needs.draft` to mean "run if draft succeeded"; without
353
+ * this rewrite the evaluator sees the upstream's envelope object
354
+ * (always truthy) and the gate becomes a silent no-op.
355
+ *
356
+ * Only the BARE form is rewritten — anything compound (comparison,
357
+ * boolean op, function call, payload reach-in) is left alone so
358
+ * authors who want the raw value can still get it.
359
+ */
360
+ function rewriteBareNeedsInIf(body: string): string {
361
+ let ast: ExpressionNode;
362
+ try {
363
+ ast = compileExpression(body);
364
+ } catch {
365
+ // Defer to the caller's compileExpression — it'll re-throw with the
366
+ // proper DSL error envelope.
367
+ return body;
368
+ }
369
+ const upstreamKey = matchBareNeedsRef(ast);
370
+ if (upstreamKey === null) return body;
371
+ return `needs.${upstreamKey}.outcome == 'ok'`;
372
+ }
373
+
374
+ /**
375
+ * Match `needs.<X>` as a top-level expression (no descendant fields,
376
+ * no operators). Returns `<X>` on match, `null` otherwise.
377
+ */
378
+ function matchBareNeedsRef(node: ExpressionNode): string | null {
379
+ if (node.kind !== ExpressionNodeKind.MEMBER) return null;
380
+ const target = node.target;
381
+ if (target.kind !== ExpressionNodeKind.IDENTIFIER) return null;
382
+ if (target.name !== 'needs') return null;
383
+ return node.property;
384
+ }
385
+
386
+ /**
387
+ * Resolve a job's Temporal dispatch primitive.
388
+ *
389
+ * For most actions the execution kind is fixed by the manifest
390
+ * (`spec.executionKind`). An action MAY opt into per-job selection by
391
+ * declaring a top-level `executionKind` property in its `inputs:`
392
+ * schema — today only `xema/dispatch-workflow`, which serves both the
393
+ * fire-and-forget (`activity`) and await-completion (`child_workflow`)
394
+ * forms from a single manifest. When opted in, the job's
395
+ * `with.executionKind` overrides the manifest default.
396
+ *
397
+ * The value MUST be a literal — the dispatch primitive is chosen at
398
+ * compile time, so a `${{ }}` expression (which only resolves at
399
+ * dispatch) cannot drive it. Anything other than the two literals
400
+ * fails fast with `DSL_SEMANTIC_INVALID`.
401
+ */
402
+ function resolveJobExecutionKind(
403
+ jobKey: string,
404
+ withBlock: Readonly<Record<string, unknown>> | undefined,
405
+ resolved: ResolvedRef,
406
+ ): ActionExecutionKind {
407
+ const inputs = resolved.actionManifest?.spec.inputs;
408
+ const inputProps =
409
+ inputs && typeof inputs === 'object'
410
+ ? (inputs as { properties?: unknown }).properties
411
+ : undefined;
412
+ const declaresExecutionKindInput =
413
+ inputProps !== null &&
414
+ typeof inputProps === 'object' &&
415
+ 'executionKind' in (inputProps as Record<string, unknown>);
416
+ if (!declaresExecutionKindInput) {
417
+ return resolved.executionKind;
418
+ }
419
+ const raw = withBlock?.executionKind;
420
+ if (raw === undefined) {
421
+ return resolved.executionKind;
422
+ }
423
+ if (
424
+ raw !== ActionExecutionKind.ACTIVITY &&
425
+ raw !== ActionExecutionKind.CHILD_WORKFLOW
426
+ ) {
427
+ throw new WorkflowDslError(
428
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
429
+ `jobs.${jobKey}.with.executionKind must be the literal '${ActionExecutionKind.ACTIVITY}' or '${ActionExecutionKind.CHILD_WORKFLOW}' — the Temporal dispatch primitive is fixed at compile time, so expressions are not allowed.`,
430
+ { jobKey, executionKind: raw },
431
+ );
432
+ }
433
+ return raw;
434
+ }
435
+
436
+ function resolveActionKind(resolved: ResolvedRef): ActionKind {
437
+ if (resolved.isReusableWorkflow) return ActionKind.DISPATCH;
438
+ const declared = resolved.actionManifest?.spec.actionKind;
439
+ if (declared === undefined) return ActionKind.GENERIC;
440
+ const known: ReadonlySet<string> = new Set<string>(Object.values(ActionKind));
441
+ return known.has(declared) ? (declared as ActionKind) : ActionKind.GENERIC;
442
+ }
443
+
444
+ function lookupResolvedRef(
445
+ uses: string,
446
+ resolvedRefs: Readonly<Record<string, ResolvedRef>>,
447
+ ): ResolvedRef {
448
+ const ref = resolvedRefs[uses];
449
+ if (!ref) {
450
+ const isReusable = uses.startsWith('xema://workflow/');
451
+ throw new WorkflowDslError(
452
+ isReusable
453
+ ? WorkflowErrorCode.DSL_UNKNOWN_REUSABLE_WORKFLOW
454
+ : WorkflowErrorCode.DSL_UNKNOWN_ACTION,
455
+ `Reference '${uses}' is not present in resolvedRefs map passed to compiler.`,
456
+ { uses },
457
+ );
458
+ }
459
+ return ref;
460
+ }
461
+
462
+ function validateAllAuthoredExpressions(doc: WorkflowDocument): void {
463
+ // Walk every job's with/outputs/strategy.dynamic.from/if for `${{ ... }}`
464
+ // blocks and compile each. Uses extractInterpolations which enforces the
465
+ // "fully wrapped or pure literal" policy and rejects partial interpolation.
466
+ // The concurrency.group template is the one DSL field that opts in to
467
+ // partial interpolation; it has its own validator below.
468
+ for (const [key, job] of Object.entries(doc.jobs)) {
469
+ validateJobExpressions(key, job);
470
+ }
471
+
472
+ // Reusable workflows declare their externally-visible outputs under
473
+ // `on.workflow_call.outputs`. Each value is a `${{ needs.<job>.outputs.* }}`
474
+ // expression evaluated by the parent runtime once the child finishes.
475
+ const callOutputs = doc.on.workflow_call?.outputs;
476
+ if (callOutputs) {
477
+ for (const expr of Object.values(callOutputs)) {
478
+ const body = stripInterpolation(expr);
479
+ compileExpression(body);
480
+ }
481
+ }
482
+
483
+ validateConcurrencyGroupTemplate(doc.concurrency);
484
+ }
485
+
486
+ /**
487
+ * Walk every job's `with.agentSlug`, `with.reviewers[].agentSlug`, and
488
+ * `with.deliverableSpecRef` entries and assert each literal value
489
+ * resolves against the engine-supplied registry maps.
490
+ *
491
+ * Skips:
492
+ * - Expression-shaped values (`${{ … }}`). Those resolve at dispatch
493
+ * and the activity's preflight (`AGENT_NOT_REGISTERED`) catches
494
+ * unknowns at runtime — the compiler has no way to know what an
495
+ * unbound expression will evaluate to.
496
+ * - Empty maps. The engine signals "skip validation" by passing `{}`
497
+ * (e.g. for `preview-raw` where it cannot reach the registries).
498
+ *
499
+ * Errors are thrown one-at-a-time on the first miss, naming the exact
500
+ * job + field path + offending value so authors fix at the source.
501
+ */
502
+ function validateAllLiteralReferences(
503
+ doc: WorkflowDocument,
504
+ resolvedAgents: Readonly<Record<string, ResolvedAgent>>,
505
+ resolvedDeliverableSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
506
+ ): void {
507
+ const agentValidationEnabled = Object.keys(resolvedAgents).length > 0;
508
+ const specValidationEnabled =
509
+ Object.keys(resolvedDeliverableSpecs).length > 0;
510
+ if (!agentValidationEnabled && !specValidationEnabled) {
511
+ return;
512
+ }
513
+ for (const [jobKey, job] of Object.entries(doc.jobs)) {
514
+ validateJobLiteralReferences(
515
+ jobKey,
516
+ job,
517
+ agentValidationEnabled ? resolvedAgents : null,
518
+ specValidationEnabled ? resolvedDeliverableSpecs : null,
519
+ );
520
+ }
521
+ }
522
+
523
+ function validateJobLiteralReferences(
524
+ jobKey: string,
525
+ job: WorkflowDocument['jobs'][string],
526
+ resolvedAgents: Readonly<Record<string, ResolvedAgent>> | null,
527
+ resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>> | null,
528
+ ): void {
529
+ const withMap = job.with as Record<string, unknown> | undefined;
530
+ if (withMap) {
531
+ if (resolvedAgents) {
532
+ assertLiteralAgent(
533
+ jobKey,
534
+ 'with.agentSlug',
535
+ withMap['agentSlug'],
536
+ resolvedAgents,
537
+ );
538
+ validateReviewerAgents(jobKey, withMap['reviewers'], resolvedAgents);
539
+ }
540
+ if (resolvedSpecs) {
541
+ assertLiteralDeliverableSpec(
542
+ jobKey,
543
+ 'with.deliverableSpecRef',
544
+ withMap['deliverableSpecRef'],
545
+ resolvedSpecs,
546
+ );
547
+ }
548
+ assertVersioningMode(jobKey, withMap['versioning']);
549
+ }
550
+ }
551
+
552
+ function validateReviewerAgents(
553
+ jobKey: string,
554
+ reviewers: unknown,
555
+ resolvedAgents: Readonly<Record<string, ResolvedAgent>>,
556
+ ): void {
557
+ if (!Array.isArray(reviewers)) {
558
+ return;
559
+ }
560
+ for (let i = 0; i < reviewers.length; i++) {
561
+ const reviewer = reviewers[i];
562
+ if (
563
+ reviewer === null ||
564
+ typeof reviewer !== 'object' ||
565
+ Array.isArray(reviewer)
566
+ ) {
567
+ continue;
568
+ }
569
+ assertLiteralAgent(
570
+ jobKey,
571
+ `with.reviewers[${i}].agentSlug`,
572
+ (reviewer as Record<string, unknown>)['agentSlug'],
573
+ resolvedAgents,
574
+ );
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Match the literal/expression policy used by `validateAllAuthoredExpressions`:
580
+ * a value is "literal" when it is a string that does NOT contain `${{`. The
581
+ * full interpolation grammar is enforced separately by the expression pass —
582
+ * here we only care whether the compiler can reasonably know the bound value.
583
+ */
584
+ function isLiteralStringValue(value: unknown): value is string {
585
+ return typeof value === 'string' && !value.includes('${{');
586
+ }
587
+
588
+ function assertLiteralAgent(
589
+ jobKey: string,
590
+ fieldPath: string,
591
+ value: unknown,
592
+ resolvedAgents: Readonly<Record<string, ResolvedAgent>>,
593
+ ): void {
594
+ if (!isLiteralStringValue(value)) {
595
+ return;
596
+ }
597
+ if (resolvedAgents[value] !== undefined) {
598
+ return;
599
+ }
600
+ const knownPreview = formatKnownPreview(Object.keys(resolvedAgents));
601
+ throw new WorkflowDslError(
602
+ WorkflowErrorCode.DSL_UNKNOWN_AGENT,
603
+ `Job '${jobKey}' ${fieldPath} = '${value}' is not a registered agent. Known: [${knownPreview}].`,
604
+ { jobKey, fieldPath, value, knownCount: Object.keys(resolvedAgents).length },
605
+ );
606
+ }
607
+
608
+ function assertLiteralDeliverableSpec(
609
+ jobKey: string,
610
+ fieldPath: string,
611
+ value: unknown,
612
+ resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
613
+ ): void {
614
+ if (!isLiteralStringValue(value)) {
615
+ return;
616
+ }
617
+ if (resolvedSpecs[value] !== undefined) {
618
+ return;
619
+ }
620
+ const knownPreview = formatKnownPreview(Object.keys(resolvedSpecs));
621
+ throw new WorkflowDslError(
622
+ WorkflowErrorCode.DSL_UNKNOWN_DELIVERABLE_SPEC,
623
+ `Job '${jobKey}' ${fieldPath} = '${value}' is not a registered deliverable spec. Known: [${knownPreview}].`,
624
+ { jobKey, fieldPath, value, knownCount: Object.keys(resolvedSpecs).length },
625
+ );
626
+ }
627
+
628
+ /**
629
+ * Closed-set validation for `with.versioning`. Mirrors
630
+ * `ArtifactVersioningMode` in `@xemahq/platform-common` — duplicated
631
+ * as a string literal here because workflow-contracts (Layer-1) must
632
+ * not depend on platform-common (Layer-2). Drift between the two is
633
+ * caught by a unit test in the artifact-store-api suite.
634
+ */
635
+ const VALID_VERSIONING_MODES = ['append', 'new', 'replace'] as const;
636
+
637
+ function assertVersioningMode(jobKey: string, value: unknown): void {
638
+ if (value === undefined || value === null) return;
639
+ // Interpolated expressions land here as strings starting with `${{`;
640
+ // semantics are checked at runtime, so we only reject literal bad
641
+ // values at compile time.
642
+ if (typeof value === 'string' && value.startsWith('${{')) return;
643
+ if (
644
+ typeof value === 'string' &&
645
+ (VALID_VERSIONING_MODES as readonly string[]).includes(value)
646
+ ) {
647
+ return;
648
+ }
649
+ throw new WorkflowDslError(
650
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
651
+ `Job '${jobKey}' with.versioning = ${JSON.stringify(value)} is not a valid ArtifactVersioningMode. Allowed: [${VALID_VERSIONING_MODES.join(', ')}].`,
652
+ {
653
+ jobKey,
654
+ fieldPath: 'with.versioning',
655
+ value: typeof value === 'string' ? value : JSON.stringify(value),
656
+ allowed: [...VALID_VERSIONING_MODES],
657
+ },
658
+ );
659
+ }
660
+
661
+ /**
662
+ * Lexicographic, stable, capped preview of known keys for the
663
+ * "Known: [...]" hint in DSL_UNKNOWN_* error messages. Capped at 12
664
+ * names so a registry of hundreds of agents does not produce a
665
+ * thousand-character error string.
666
+ */
667
+ function formatKnownPreview(keys: readonly string[]): string {
668
+ if (keys.length === 0) {
669
+ return '(none)';
670
+ }
671
+ const sorted = [...keys].sort((a, b) => a.localeCompare(b));
672
+ if (sorted.length <= 12) {
673
+ return sorted.join(', ');
674
+ }
675
+ return `${sorted.slice(0, 12).join(', ')}, … (+${sorted.length - 12} more)`;
676
+ }
677
+
678
+ function validateJobExpressions(
679
+ key: string,
680
+ job: WorkflowDocument['jobs'][string],
681
+ ): void {
682
+ if (job.if !== undefined) {
683
+ const body = stripInterpolation(job.if);
684
+ compileExpression(body);
685
+ }
686
+ if (job.with) {
687
+ const extracted = extractInterpolations(job.with, [key, 'with']);
688
+ for (const ext of extracted) compileExpression(ext.source);
689
+ }
690
+ if (job.outputs) {
691
+ for (const expr of Object.values(job.outputs)) {
692
+ const body = stripInterpolation(expr);
693
+ compileExpression(body);
694
+ }
695
+ }
696
+ if (job.strategy && 'dynamic' in job.strategy) {
697
+ const body = stripInterpolation(job.strategy.dynamic.from);
698
+ compileExpression(body);
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Strip the `${{ ... }}` wrapper from each entry of an authored
704
+ * outputs map and return a frozen body→body map. The compiler's
705
+ * validation pass already compiled each expression, so this never sees
706
+ * a malformed body — but we still call `stripInterpolation` to
707
+ * normalize the storage format the runtime evaluator expects.
708
+ *
709
+ * Empty / missing input → frozen empty object so downstream code can
710
+ * treat the field as a non-nullable map.
711
+ */
712
+ function compileOutputsMap(
713
+ raw: Readonly<Record<string, string>> | undefined,
714
+ contextLabel: string,
715
+ ): Readonly<Record<string, string>> {
716
+ if (!raw || Object.keys(raw).length === 0) {
717
+ return Object.freeze({}) as Readonly<Record<string, string>>;
718
+ }
719
+ const stripped: Record<string, string> = {};
720
+ for (const [name, expr] of Object.entries(raw)) {
721
+ try {
722
+ stripped[name] = stripInterpolation(expr);
723
+ } catch (err) {
724
+ // Re-throw with the explicit field path so authors see exactly
725
+ // which output expression was malformed.
726
+ const message = err instanceof Error ? err.message : String(err);
727
+ throw new WorkflowDslError(
728
+ WorkflowErrorCode.DSL_EXPRESSION_INVALID,
729
+ `${contextLabel}.${name}: ${message}`,
730
+ { contextLabel, name, expr },
731
+ );
732
+ }
733
+ }
734
+ return Object.freeze(stripped) as Readonly<Record<string, string>>;
735
+ }
736
+
737
+ /**
738
+ * Compile + validate the workflow-level `outputs:` block. Each entry
739
+ * is cross-referenced against the workflow's `jobs[fromJob].outputs`
740
+ * map; missing references fail fast at compile time.
741
+ *
742
+ * The frozen result is consumed by biome-host-sdk (mcpWorkflowTools
743
+ * cross-validation), workflow-engine-api (RunResponseDto.deliverables
744
+ * projection), and the future workflow_call composition surfaces.
745
+ */
746
+ function compileWorkflowOutputs(
747
+ workflow: WorkflowDocument,
748
+ ): Readonly<Record<string, import('@xemahq/kernel-contracts/workflow').WorkflowOutputDescriptor>> {
749
+ const raw = workflow.outputs;
750
+ if (!raw || Object.keys(raw).length === 0) {
751
+ return Object.freeze({}) as Readonly<
752
+ Record<string, import('@xemahq/kernel-contracts/workflow').WorkflowOutputDescriptor>
753
+ >;
754
+ }
755
+ const seenSlugs = new Set<string>();
756
+ const compiled: Record<
757
+ string,
758
+ import('@xemahq/kernel-contracts/workflow').WorkflowOutputDescriptor
759
+ > = {};
760
+ for (const [name, decl] of Object.entries(raw)) {
761
+ const job = workflow.jobs[decl.fromJob];
762
+ if (!job) {
763
+ throw new WorkflowDslError(
764
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
765
+ `outputs.${name}: fromJob "${decl.fromJob}" is not a declared job`,
766
+ { outputName: name, fromJob: decl.fromJob },
767
+ );
768
+ }
769
+ if (!job.outputs || !(decl.fromOutput in job.outputs)) {
770
+ throw new WorkflowDslError(
771
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
772
+ `outputs.${name}: job "${decl.fromJob}" does not declare an output named "${decl.fromOutput}"`,
773
+ { outputName: name, fromJob: decl.fromJob, fromOutput: decl.fromOutput },
774
+ );
775
+ }
776
+ if (seenSlugs.has(decl.slug)) {
777
+ throw new WorkflowDslError(
778
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
779
+ `outputs.${name}: slug "${decl.slug}" is already used by another output`,
780
+ { outputName: name, slug: decl.slug },
781
+ );
782
+ }
783
+ seenSlugs.add(decl.slug);
784
+ if (decl.kind === 'deliverable') {
785
+ compiled[name] = {
786
+ kind: 'deliverable',
787
+ slug: decl.slug,
788
+ fromJob: decl.fromJob,
789
+ fromOutput: decl.fromOutput,
790
+ deliverableSpecRef: decl.deliverableSpecRef,
791
+ ...(decl.description ? { description: decl.description } : {}),
792
+ };
793
+ } else if (decl.kind === 'structured') {
794
+ compiled[name] = {
795
+ kind: 'structured',
796
+ slug: decl.slug,
797
+ fromJob: decl.fromJob,
798
+ fromOutput: decl.fromOutput,
799
+ ...(decl.schemaRef ? { schemaRef: decl.schemaRef } : {}),
800
+ ...(decl.description ? { description: decl.description } : {}),
801
+ };
802
+ } else {
803
+ compiled[name] = {
804
+ kind: 'text',
805
+ slug: decl.slug,
806
+ fromJob: decl.fromJob,
807
+ fromOutput: decl.fromOutput,
808
+ ...(decl.description ? { description: decl.description } : {}),
809
+ };
810
+ }
811
+ }
812
+ return Object.freeze(compiled) as Readonly<
813
+ Record<string, import('@xemahq/kernel-contracts/workflow').WorkflowOutputDescriptor>
814
+ >;
815
+ }
816
+
817
+ /**
818
+ * Enforce the `matrixGather:` invariants up-front, before the topological
819
+ * sort so errors carry the authored jobKey rather than a less useful
820
+ * cycle/unknown-need complaint. Each entry must name a declared job, the
821
+ * target job must itself use a matrix strategy, and self-references are
822
+ * rejected.
823
+ */
824
+ function validateMatrixGather(doc: WorkflowDocument): void {
825
+ for (const [jobKey, decl] of Object.entries(doc.jobs)) {
826
+ const gather = decl.matrixGather;
827
+ if (!gather || gather.length === 0) continue;
828
+ for (const target of gather) {
829
+ if (target === jobKey) {
830
+ throw new WorkflowDslError(
831
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
832
+ `Job '${jobKey}' matrixGather cannot reference itself.`,
833
+ { jobKey, target },
834
+ );
835
+ }
836
+ const targetDecl = doc.jobs[target];
837
+ if (!targetDecl) {
838
+ throw new WorkflowDslError(
839
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
840
+ `Job '${jobKey}' matrixGather references unknown job '${target}'.`,
841
+ { jobKey, target },
842
+ );
843
+ }
844
+ if (!targetDecl.strategy || (!('matrix' in targetDecl.strategy) && !('dynamic' in targetDecl.strategy))) {
845
+ throw new WorkflowDslError(
846
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
847
+ `Job '${jobKey}' matrixGather references '${target}' which has no matrix/dynamic strategy; gathering only makes sense across matrix entries.`,
848
+ { jobKey, target },
849
+ );
850
+ }
851
+ }
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Cross-job shape validator for `needs.<X>.outputs.…` accesses. Catches
857
+ * the entire class of "YAML reads like the intent but runtime sees the
858
+ * wrong shape" bugs at compile time.
859
+ *
860
+ * Rules per upstream `<X>`:
861
+ * • non-matrix → any access OK; skip.
862
+ * • matrix WITHOUT `keyBy` →
863
+ * - `outputs.byKey…` → DSL_MATRIX_KEY_NOT_DECLARED.
864
+ * - `outputs.<word>` where `<word>` is not `length` / `byKey` →
865
+ * DSL_INVALID_MATRIX_OUTPUT_ACCESS (with three-pronged fix-it).
866
+ * - `outputs[<numeric literal>]` → OK (positional access).
867
+ * - bare `outputs` (no further deref) → OK (consumer takes the
868
+ * indexed map as-is).
869
+ * • matrix WITH `keyBy` →
870
+ * - `outputs.byKey[<string-literal>]` → OK.
871
+ * - `outputs.byKey[matrix.<binding>.<keyBy-path>]` → OK iff
872
+ * `<binding>` matches THIS job's matrix binding AND
873
+ * `<keyBy-path>` matches upstream's declared `keyBy`.
874
+ * - any other `outputs.byKey[<expr>]` → DSL_INVALID_MATRIX_OUTPUT_ACCESS.
875
+ * - `outputs.<word>` other than `byKey` / `length` and not numeric
876
+ * index → DSL_INVALID_MATRIX_OUTPUT_ACCESS.
877
+ * - `if:` body cannot use `matrix.*` (binding doesn't exist there)
878
+ * — caught generically by the existing evaluator at runtime;
879
+ * this validator does not duplicate that check.
880
+ *
881
+ * `outputs` and `on.workflow_call.outputs` use `job.outputs.*` /
882
+ * `needs.<X>.outputs.*` against the *child* run, which is identical
883
+ * shape — so the same rules apply.
884
+ */
885
+ function validateAllNeedsAccessShapes(doc: WorkflowDocument): void {
886
+ for (const [jobKey, job] of Object.entries(doc.jobs)) {
887
+ const consumerBinding = extractConsumerBinding(job);
888
+ const expressions = collectJobExpressions(jobKey, job);
889
+ for (const { source, fieldPath } of expressions) {
890
+ const ast = compileExpression(source);
891
+ walkNeedsAccesses(ast, (access) => {
892
+ validateNeedsAccess(doc, jobKey, fieldPath, consumerBinding, access);
893
+ });
894
+ }
895
+ }
896
+
897
+ const callOutputs = doc.on.workflow_call?.outputs;
898
+ if (callOutputs) {
899
+ for (const [outputName, expr] of Object.entries(callOutputs)) {
900
+ const ast = compileExpression(stripInterpolation(expr));
901
+ walkNeedsAccesses(ast, (access) => {
902
+ // workflow_call.outputs has no consumer binding (it's a
903
+ // workflow-level output). Pass null and let the validator
904
+ // reject any matrix-binding indices.
905
+ validateNeedsAccess(
906
+ doc,
907
+ '<workflow_call.outputs>',
908
+ `on.workflow_call.outputs.${outputName}`,
909
+ null,
910
+ access,
911
+ );
912
+ });
913
+ }
914
+ }
915
+ }
916
+
917
+ interface CollectedExpression {
918
+ readonly source: string;
919
+ readonly fieldPath: string;
920
+ }
921
+
922
+ function collectJobExpressions(
923
+ jobKey: string,
924
+ job: WorkflowDocument['jobs'][string],
925
+ ): readonly CollectedExpression[] {
926
+ const out: CollectedExpression[] = [];
927
+ if (job.if !== undefined) {
928
+ out.push({ source: stripInterpolation(job.if), fieldPath: `${jobKey}.if` });
929
+ }
930
+ if (job.with) {
931
+ for (const ext of extractInterpolations(job.with, [jobKey, 'with'])) {
932
+ out.push({ source: ext.source, fieldPath: ext.path.join('.') });
933
+ }
934
+ }
935
+ if (job.outputs) {
936
+ for (const [name, expr] of Object.entries(job.outputs)) {
937
+ out.push({
938
+ source: stripInterpolation(expr),
939
+ fieldPath: `${jobKey}.outputs.${name}`,
940
+ });
941
+ }
942
+ }
943
+ if (job.strategy && 'dynamic' in job.strategy) {
944
+ out.push({
945
+ source: stripInterpolation(job.strategy.dynamic.from),
946
+ fieldPath: `${jobKey}.strategy.dynamic.from`,
947
+ });
948
+ }
949
+ return out;
950
+ }
951
+
952
+ /**
953
+ * The dynamic-matrix `as:` binding name for the current consumer job, or
954
+ * null when the consumer is not a dynamic matrix. Used by the validator
955
+ * to allow `byKey[matrix.<thisBinding>.<keyBy-path>]` indices.
956
+ *
957
+ * Static matrices bind axis names — those don't pair with upstream
958
+ * matrix-output keys (different cardinality semantics) so we don't
959
+ * surface them here.
960
+ */
961
+ function extractConsumerBinding(
962
+ job: WorkflowDocument['jobs'][string],
963
+ ): string | null {
964
+ if (job.strategy && 'dynamic' in job.strategy) {
965
+ return job.strategy.dynamic.as;
966
+ }
967
+ return null;
968
+ }
969
+
970
+ interface NeedsAccess {
971
+ readonly upstreamJobKey: string;
972
+ /**
973
+ * The chain of accesses *after* `needs.<X>.outputs`. Each step is
974
+ * either `{ kind: 'member', name: string }` (e.g. `.deliverables`,
975
+ * `.byKey`, `.length`) or `{ kind: 'index', node: ExpressionNode }`
976
+ * (e.g. `[0]`, `['cu-1']`, `[matrix.x.id]`). Empty chain means the
977
+ * expression is just `needs.<X>.outputs` (consumer takes the whole
978
+ * shape as-is).
979
+ */
980
+ readonly steps: readonly AccessStep[];
981
+ }
982
+
983
+ type AccessStep =
984
+ | { readonly kind: 'member'; readonly name: string }
985
+ | { readonly kind: 'index'; readonly node: ExpressionNode };
986
+
987
+ function walkNeedsAccesses(
988
+ node: ExpressionNode,
989
+ visit: (access: NeedsAccess) => void,
990
+ ): void {
991
+ // Try to interpret this node as a `needs.<X>.outputs.*` chain. If it
992
+ // is, emit the access and recurse into any index expressions inside
993
+ // it (those can also reference `needs.*`). Otherwise recurse into
994
+ // children generically.
995
+ const access = matchNeedsAccess(node);
996
+ if (access !== null) {
997
+ visit(access);
998
+ for (const step of access.steps) {
999
+ if (step.kind === 'index') {
1000
+ walkNeedsAccesses(step.node, visit);
1001
+ }
1002
+ }
1003
+ return;
1004
+ }
1005
+ // Generic recursion for non-needs subtrees.
1006
+ switch (node.kind) {
1007
+ case ExpressionNodeKind.MEMBER:
1008
+ walkNeedsAccesses(node.target, visit);
1009
+ return;
1010
+ case ExpressionNodeKind.INDEX:
1011
+ walkNeedsAccesses(node.target, visit);
1012
+ walkNeedsAccesses(node.index, visit);
1013
+ return;
1014
+ case ExpressionNodeKind.CALL:
1015
+ for (const arg of node.args) walkNeedsAccesses(arg, visit);
1016
+ return;
1017
+ case ExpressionNodeKind.UNARY_NOT:
1018
+ walkNeedsAccesses(node.operand, visit);
1019
+ return;
1020
+ case ExpressionNodeKind.BINARY_EQ:
1021
+ case ExpressionNodeKind.BINARY_NEQ:
1022
+ case ExpressionNodeKind.BINARY_AND:
1023
+ case ExpressionNodeKind.BINARY_OR:
1024
+ walkNeedsAccesses(node.left, visit);
1025
+ walkNeedsAccesses(node.right, visit);
1026
+ return;
1027
+ case ExpressionNodeKind.LITERAL:
1028
+ case ExpressionNodeKind.IDENTIFIER:
1029
+ return;
1030
+ }
1031
+ }
1032
+
1033
+ /**
1034
+ * Walk a node that might be an outermost `needs.<X>.outputs.<…>` chain.
1035
+ * Returns null if the chain root is not `needs.<X>.outputs`. The
1036
+ * returned chain is in author order (first access first), regardless
1037
+ * of how the parser built the AST.
1038
+ */
1039
+ function matchNeedsAccess(node: ExpressionNode): NeedsAccess | null {
1040
+ // Collect the chain by walking down from outermost to innermost,
1041
+ // pushing each step onto a stack we'll reverse at the end.
1042
+ const reverseSteps: AccessStep[] = [];
1043
+ let cursor: ExpressionNode = node;
1044
+ while (
1045
+ cursor.kind === ExpressionNodeKind.MEMBER ||
1046
+ cursor.kind === ExpressionNodeKind.INDEX
1047
+ ) {
1048
+ if (cursor.kind === ExpressionNodeKind.MEMBER) {
1049
+ reverseSteps.push({ kind: 'member', name: cursor.property });
1050
+ } else {
1051
+ reverseSteps.push({ kind: 'index', node: cursor.index });
1052
+ }
1053
+ cursor = cursor.target;
1054
+ }
1055
+ // Innermost must be `needs` IDENTIFIER, then the popped chain must
1056
+ // start with member('<jobKey>') member('outputs').
1057
+ if (
1058
+ cursor.kind !== ExpressionNodeKind.IDENTIFIER ||
1059
+ cursor.name !== 'needs'
1060
+ ) {
1061
+ return null;
1062
+ }
1063
+ const steps = reverseSteps.toReversed();
1064
+ if (steps.length < 2) return null;
1065
+ const first = steps[0]!;
1066
+ const second = steps[1]!;
1067
+ if (first.kind !== 'member') return null;
1068
+ if (second.kind !== 'member' || second.name !== 'outputs') return null;
1069
+ return {
1070
+ upstreamJobKey: first.name,
1071
+ steps: steps.slice(2),
1072
+ };
1073
+ }
1074
+
1075
+ function validateNeedsAccess(
1076
+ doc: WorkflowDocument,
1077
+ consumerJobKey: string,
1078
+ fieldPath: string,
1079
+ consumerBinding: string | null,
1080
+ access: NeedsAccess,
1081
+ ): void {
1082
+ const upstream = doc.jobs[access.upstreamJobKey];
1083
+ if (!upstream) {
1084
+ // Unknown upstream — the topo sort already fails this with a more
1085
+ // useful message. Don't double-report.
1086
+ return;
1087
+ }
1088
+ const isDynamicMatrix = !!upstream.strategy && 'dynamic' in upstream.strategy;
1089
+ const isStaticMatrix = !!upstream.strategy && 'matrix' in upstream.strategy;
1090
+ const isMatrix = isDynamicMatrix || isStaticMatrix;
1091
+ if (!isMatrix) {
1092
+ // Single (non-matrix) jobs accept any shape, EXCEPT `byKey[…]` —
1093
+ // single jobs don't have a keyed map. Catch the misuse at compile
1094
+ // time so authors don't get a confusing runtime "Unknown property
1095
+ // byKey" later.
1096
+ if (
1097
+ access.steps.length > 0 &&
1098
+ access.steps[0]!.kind === 'member' &&
1099
+ access.steps[0]!.name === 'byKey'
1100
+ ) {
1101
+ throw new WorkflowDslError(
1102
+ WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
1103
+ `Job '${consumerJobKey}' ${fieldPath}: cannot read 'byKey' on '${access.upstreamJobKey}' because it is not a matrix job. 'byKey' is only available on matrix jobs that declare 'keyBy:'.`,
1104
+ {
1105
+ consumerJobKey,
1106
+ upstreamJobKey: access.upstreamJobKey,
1107
+ fieldPath,
1108
+ },
1109
+ );
1110
+ }
1111
+ return;
1112
+ }
1113
+
1114
+ const consumerJob = doc.jobs[consumerJobKey];
1115
+ if (consumerJob?.matrixGather?.includes(access.upstreamJobKey)) {
1116
+ rejectByKeyOnGathered(access, consumerJobKey, fieldPath);
1117
+ return;
1118
+ }
1119
+
1120
+ const keyBy = isDynamicMatrix
1121
+ ? (upstream.strategy as { dynamic: { keyBy?: string } }).dynamic.keyBy ?? null
1122
+ : null;
1123
+
1124
+ // Bare `needs.X.outputs` is always OK — consumer chooses to receive
1125
+ // the indexed map (or the keyed map; runtime exposes both).
1126
+ if (access.steps.length === 0) return;
1127
+
1128
+ const head = access.steps[0]!;
1129
+
1130
+ if (head.kind === 'member' && head.name === 'byKey') {
1131
+ validateByKeyChain(access, keyBy, consumerJobKey, consumerBinding, fieldPath);
1132
+ return;
1133
+ }
1134
+
1135
+ if (head.kind === 'member') {
1136
+ // `outputs.length` is part of the indexed-map shape — allowed.
1137
+ if (head.name === 'length') return;
1138
+ // Anything else (`outputs.deliverables`, etc.) is the bug we want
1139
+ // to catch at compile time.
1140
+ throw new WorkflowDslError(
1141
+ WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
1142
+ `Job '${consumerJobKey}' ${fieldPath}: cannot read 'needs.${access.upstreamJobKey}.outputs.${head.name}' because '${access.upstreamJobKey}' is a matrix job — its outputs are indexed by entry, not by field. Choose one: (1) read positionally with 'outputs[<n>].${head.name}'; (2) flatten with 'matrixGather: [${access.upstreamJobKey}]' on '${consumerJobKey}' and drop the '.${head.name}' suffix; (3) add 'keyBy: <field>' to '${access.upstreamJobKey}' and use 'outputs.byKey[<key>].${head.name}'.`,
1143
+ {
1144
+ consumerJobKey,
1145
+ upstreamJobKey: access.upstreamJobKey,
1146
+ fieldPath,
1147
+ offendingProperty: head.name,
1148
+ },
1149
+ );
1150
+ }
1151
+
1152
+ // `head.kind === 'index'` — positional access. Numeric literals are
1153
+ // the canonical use; non-numeric string literals are nonsense on the
1154
+ // indexed map (no key '0' for a string), but a runtime evaluator
1155
+ // already errors clearly there. Don't over-validate.
1156
+ }
1157
+
1158
+ function rejectByKeyOnGathered(
1159
+ access: NeedsAccess,
1160
+ consumerJobKey: string,
1161
+ fieldPath: string,
1162
+ ): void {
1163
+ const head = access.steps[0];
1164
+ if (head?.kind === 'member' && head.name === 'byKey') {
1165
+ throw new WorkflowDslError(
1166
+ WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
1167
+ `Job '${consumerJobKey}' ${fieldPath}: cannot read 'byKey' on '${access.upstreamJobKey}' because the consumer applies 'matrixGather' which flattens the upstream into an array. Drop 'matrixGather' to enable keyed access, or read positional entries via [n].`,
1168
+ {
1169
+ consumerJobKey,
1170
+ upstreamJobKey: access.upstreamJobKey,
1171
+ fieldPath,
1172
+ },
1173
+ );
1174
+ }
1175
+ }
1176
+
1177
+ function validateByKeyChain(
1178
+ access: NeedsAccess,
1179
+ upstreamKeyBy: string | null,
1180
+ consumerJobKey: string,
1181
+ consumerBinding: string | null,
1182
+ fieldPath: string,
1183
+ ): void {
1184
+ if (upstreamKeyBy === null) {
1185
+ throw new WorkflowDslError(
1186
+ WorkflowErrorCode.DSL_MATRIX_KEY_NOT_DECLARED,
1187
+ `Job '${consumerJobKey}' ${fieldPath}: cannot read 'needs.${access.upstreamJobKey}.outputs.byKey' because '${access.upstreamJobKey}' has no 'keyBy:' declared on its matrix strategy. Add 'keyBy: <field>' to '${access.upstreamJobKey}.strategy.dynamic' to expose a keyed map.`,
1188
+ {
1189
+ consumerJobKey,
1190
+ upstreamJobKey: access.upstreamJobKey,
1191
+ fieldPath,
1192
+ },
1193
+ );
1194
+ }
1195
+ const indexStep = access.steps[1];
1196
+ if (indexStep?.kind !== 'index') {
1197
+ throw new WorkflowDslError(
1198
+ WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
1199
+ `Job '${consumerJobKey}' ${fieldPath}: 'needs.${access.upstreamJobKey}.outputs.byKey' must be followed by '[<key>]'. Use a string literal or 'matrix.<binding>.${upstreamKeyBy}'.`,
1200
+ {
1201
+ consumerJobKey,
1202
+ upstreamJobKey: access.upstreamJobKey,
1203
+ fieldPath,
1204
+ },
1205
+ );
1206
+ }
1207
+ assertByKeyIndexShape(
1208
+ indexStep.node,
1209
+ upstreamKeyBy,
1210
+ consumerJobKey,
1211
+ consumerBinding,
1212
+ access.upstreamJobKey,
1213
+ fieldPath,
1214
+ );
1215
+ }
1216
+
1217
+ function assertByKeyIndexShape(
1218
+ indexNode: ExpressionNode,
1219
+ upstreamKeyBy: string,
1220
+ consumerJobKey: string,
1221
+ consumerBinding: string | null,
1222
+ upstreamJobKey: string,
1223
+ fieldPath: string,
1224
+ ): void {
1225
+ // Acceptable forms:
1226
+ // • LITERAL string → OK
1227
+ // • IDENTIFIER('matrix') + MEMBER chain matching the consumer's
1228
+ // binding name and the upstream's keyBy path → OK
1229
+ // Anything else → DSL_INVALID_MATRIX_OUTPUT_ACCESS.
1230
+
1231
+ if (
1232
+ indexNode.kind === ExpressionNodeKind.LITERAL &&
1233
+ typeof indexNode.value === 'string'
1234
+ ) {
1235
+ return;
1236
+ }
1237
+
1238
+ if (
1239
+ indexNode.kind === ExpressionNodeKind.MEMBER ||
1240
+ indexNode.kind === ExpressionNodeKind.INDEX
1241
+ ) {
1242
+ const path = collectMatrixMemberPath(indexNode);
1243
+ if (path) {
1244
+ if (consumerBinding === null) {
1245
+ throw new WorkflowDslError(
1246
+ WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
1247
+ `Job '${consumerJobKey}' ${fieldPath}: 'matrix.*' indices into 'needs.${upstreamJobKey}.outputs.byKey' require the consumer job to declare its own 'strategy.dynamic'. Use a string literal key instead, or convert '${consumerJobKey}' into a dynamic matrix.`,
1248
+ { consumerJobKey, upstreamJobKey, fieldPath },
1249
+ );
1250
+ }
1251
+ if (path.binding !== consumerBinding) {
1252
+ throw new WorkflowDslError(
1253
+ WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
1254
+ `Job '${consumerJobKey}' ${fieldPath}: 'matrix.${path.binding}' is not bound here — '${consumerJobKey}' binds 'matrix.${consumerBinding}'.`,
1255
+ { consumerJobKey, upstreamJobKey, fieldPath },
1256
+ );
1257
+ }
1258
+ if (path.path !== upstreamKeyBy) {
1259
+ throw new WorkflowDslError(
1260
+ WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
1261
+ `Job '${consumerJobKey}' ${fieldPath}: 'byKey' index reads 'matrix.${path.binding}.${path.path}' but '${upstreamJobKey}' is keyed by '${upstreamKeyBy}'. Match the upstream's 'keyBy:' path or change the upstream's 'keyBy:' to '${path.path}'.`,
1262
+ { consumerJobKey, upstreamJobKey, fieldPath },
1263
+ );
1264
+ }
1265
+ return;
1266
+ }
1267
+ }
1268
+
1269
+ throw new WorkflowDslError(
1270
+ WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
1271
+ `Job '${consumerJobKey}' ${fieldPath}: 'needs.${upstreamJobKey}.outputs.byKey[…]' index must be a string literal or 'matrix.<binding>.${upstreamKeyBy}'.`,
1272
+ { consumerJobKey, upstreamJobKey, fieldPath },
1273
+ );
1274
+ }
1275
+
1276
+ /**
1277
+ * Collapse a chain like `matrix.changeUnit.metadata.id` into
1278
+ * `{ binding: 'changeUnit', path: 'metadata.id' }`. Returns null if the
1279
+ * chain root isn't `matrix.<binding>` or contains an INDEX step (we
1280
+ * only support pure dotted paths in `keyBy`).
1281
+ */
1282
+ function collectMatrixMemberPath(
1283
+ node: ExpressionNode,
1284
+ ): { readonly binding: string; readonly path: string } | null {
1285
+ const reverseProperties: string[] = [];
1286
+ let cursor: ExpressionNode = node;
1287
+ while (cursor.kind === ExpressionNodeKind.MEMBER) {
1288
+ reverseProperties.push(cursor.property);
1289
+ cursor = cursor.target;
1290
+ }
1291
+ if (
1292
+ cursor.kind !== ExpressionNodeKind.IDENTIFIER ||
1293
+ cursor.name !== 'matrix'
1294
+ ) {
1295
+ return null;
1296
+ }
1297
+ const props = reverseProperties.toReversed();
1298
+ if (props.length < 2) return null;
1299
+ const [binding, ...rest] = props;
1300
+ return { binding: binding!, path: rest.join('.') };
1301
+ }
1302
+
1303
+ function assertStrategyInvariants(compiled: CompiledRun): void {
1304
+ for (const job of compiled.jobs) {
1305
+ if (job.strategy.kind === MatrixStrategyKind.STATIC && job.strategy.entries.length === 0) {
1306
+ throw new WorkflowDslError(
1307
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
1308
+ `Job '${job.jobKey}' static strategy expansion produced zero entries.`,
1309
+ { jobKey: job.jobKey },
1310
+ );
1311
+ }
1312
+ }
1313
+
1314
+ // Permission escalation is already checked per-job. Cycle checked in DAG.
1315
+ // No-op placeholder kept explicit for the next author.
1316
+ const _permissionRef: Readonly<Record<PermissionResource, PermissionScope>> = compiled.permissions;
1317
+ void _permissionRef;
1318
+ }
1319
+
1320
+ /**
1321
+ * Walk every authored expression and field-check
1322
+ * `<root>.outputs.deliverable.content.value.<field>` accesses (the JSON
1323
+ * payload for `JSON_SCHEMA` / `ZOD_SCHEMA` / `STRUCTURED_JSON` kinds).
1324
+ * `<field>` must be one of the producing job's spec `topLevelKeys` when
1325
+ * the engine pre-fetched them. Other content paths (`pages`, `text`,
1326
+ * `files`, `payload`, `manifestPath`) are kind-discriminated and the
1327
+ * activity-side harvester enforces them at runtime.
1328
+ */
1329
+ function validateDeliverableValueExpressions(
1330
+ doc: WorkflowDocument,
1331
+ resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
1332
+ ): void {
1333
+ if (Object.keys(resolvedSpecs).length === 0) return;
1334
+
1335
+ for (const [jobKey, job] of Object.entries(doc.jobs)) {
1336
+ const expressions = collectJobExpressions(jobKey, job);
1337
+ for (const { source, fieldPath } of expressions) {
1338
+ const ast = compileExpression(source);
1339
+ walkDeliverableAccesses(ast, (access) => {
1340
+ validateDeliverableAccess(doc, jobKey, fieldPath, access, resolvedSpecs);
1341
+ });
1342
+ }
1343
+ }
1344
+
1345
+ const callOutputs = doc.on.workflow_call?.outputs;
1346
+ if (callOutputs) {
1347
+ for (const [outputName, expr] of Object.entries(callOutputs)) {
1348
+ const ast = compileExpression(stripInterpolation(expr));
1349
+ walkDeliverableAccesses(ast, (access) => {
1350
+ validateDeliverableAccess(
1351
+ doc,
1352
+ '<workflow_call.outputs>',
1353
+ `on.workflow_call.outputs.${outputName}`,
1354
+ access,
1355
+ resolvedSpecs,
1356
+ );
1357
+ });
1358
+ }
1359
+ }
1360
+ }
1361
+
1362
+ interface ValueFieldDeliverableAccess {
1363
+ readonly upstreamJobKey: string | null;
1364
+ readonly field: string;
1365
+ }
1366
+
1367
+ function walkDeliverableAccesses(
1368
+ node: ExpressionNode,
1369
+ visit: (access: ValueFieldDeliverableAccess) => void,
1370
+ ): void {
1371
+ const valueField = matchDeliverableValueFieldAccess(node);
1372
+ if (valueField !== null) {
1373
+ visit(valueField);
1374
+ return;
1375
+ }
1376
+ switch (node.kind) {
1377
+ case ExpressionNodeKind.MEMBER:
1378
+ walkDeliverableAccesses(node.target, visit);
1379
+ return;
1380
+ case ExpressionNodeKind.INDEX:
1381
+ walkDeliverableAccesses(node.target, visit);
1382
+ walkDeliverableAccesses(node.index, visit);
1383
+ return;
1384
+ case ExpressionNodeKind.CALL:
1385
+ for (const arg of node.args) walkDeliverableAccesses(arg, visit);
1386
+ return;
1387
+ case ExpressionNodeKind.UNARY_NOT:
1388
+ walkDeliverableAccesses(node.operand, visit);
1389
+ return;
1390
+ case ExpressionNodeKind.BINARY_EQ:
1391
+ case ExpressionNodeKind.BINARY_NEQ:
1392
+ case ExpressionNodeKind.BINARY_AND:
1393
+ case ExpressionNodeKind.BINARY_OR:
1394
+ walkDeliverableAccesses(node.left, visit);
1395
+ walkDeliverableAccesses(node.right, visit);
1396
+ return;
1397
+ case ExpressionNodeKind.LITERAL:
1398
+ case ExpressionNodeKind.IDENTIFIER:
1399
+ return;
1400
+ }
1401
+ }
1402
+
1403
+ /**
1404
+ * Match `<root>.outputs.deliverable.content.value.<field>`. `<root>` is
1405
+ * `job` (same-job projection), `needs.<jobKey>` (cross-job), or
1406
+ * `needs.<jobKey>.outputs.byKey[<...>]` (matrix-keyed cross-job — handled
1407
+ * structurally by walking through the byKey index).
1408
+ */
1409
+ function matchDeliverableValueFieldAccess(
1410
+ node: ExpressionNode,
1411
+ ): ValueFieldDeliverableAccess | null {
1412
+ if (node.kind !== ExpressionNodeKind.MEMBER) return null;
1413
+ const field = node.property;
1414
+
1415
+ const valueNode = node.target;
1416
+ if (valueNode.kind !== ExpressionNodeKind.MEMBER) return null;
1417
+ if (valueNode.property !== 'value') return null;
1418
+
1419
+ const contentNode = valueNode.target;
1420
+ if (contentNode.kind !== ExpressionNodeKind.MEMBER) return null;
1421
+ if (contentNode.property !== 'content') return null;
1422
+
1423
+ const deliverableNode = contentNode.target;
1424
+ if (deliverableNode.kind !== ExpressionNodeKind.MEMBER) return null;
1425
+ if (deliverableNode.property !== 'deliverable') return null;
1426
+
1427
+ const outputsNode = deliverableNode.target;
1428
+ if (outputsNode.kind !== ExpressionNodeKind.MEMBER) return null;
1429
+ if (outputsNode.property !== 'outputs') return null;
1430
+
1431
+ const rootNode = outputsNode.target;
1432
+ if (
1433
+ rootNode.kind === ExpressionNodeKind.IDENTIFIER &&
1434
+ rootNode.name === 'job'
1435
+ ) {
1436
+ return { upstreamJobKey: null, field };
1437
+ }
1438
+ if (rootNode.kind === ExpressionNodeKind.MEMBER) {
1439
+ const needsRoot = rootNode.target;
1440
+ if (
1441
+ needsRoot.kind === ExpressionNodeKind.IDENTIFIER &&
1442
+ needsRoot.name === 'needs'
1443
+ ) {
1444
+ return {
1445
+ upstreamJobKey: rootNode.property,
1446
+ field,
1447
+ };
1448
+ }
1449
+ // `needs.<X>.outputs.byKey[<...>].deliverable.content.value.<f>` —
1450
+ // walk through the index node.
1451
+ if (needsRoot.kind === ExpressionNodeKind.MEMBER) {
1452
+ const upstreamRoot = needsRoot.target;
1453
+ if (
1454
+ needsRoot.property === 'outputs' &&
1455
+ upstreamRoot.kind === ExpressionNodeKind.MEMBER
1456
+ ) {
1457
+ // outputs.byKey[<...>] — outputs.target is needs.<X>
1458
+ const needsParent = upstreamRoot.target;
1459
+ if (
1460
+ upstreamRoot.property === 'byKey' &&
1461
+ needsParent.kind === ExpressionNodeKind.IDENTIFIER &&
1462
+ needsParent.name === 'needs'
1463
+ ) {
1464
+ // outputsNode would actually be the index node; this branch
1465
+ // is only reachable via the INDEX path in walkDeliverableAccesses.
1466
+ return null;
1467
+ }
1468
+ }
1469
+ }
1470
+ }
1471
+ return null;
1472
+ }
1473
+
1474
+ function validateDeliverableAccess(
1475
+ doc: WorkflowDocument,
1476
+ consumerJobKey: string,
1477
+ fieldPath: string,
1478
+ access: ValueFieldDeliverableAccess,
1479
+ resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
1480
+ ): void {
1481
+ // value-field access: validate against producing job's spec when
1482
+ // introspectable.
1483
+ const producingJobKey = access.upstreamJobKey ?? consumerJobKey;
1484
+ const producingJob = doc.jobs[producingJobKey];
1485
+ if (!producingJob) return;
1486
+
1487
+ if (access.upstreamJobKey !== null) {
1488
+ // Explicit `outputs:` projection re-shapes the namespace; skip.
1489
+ if (producingJob.outputs && Object.keys(producingJob.outputs).length > 0) {
1490
+ return;
1491
+ }
1492
+ }
1493
+
1494
+ const withMap = producingJob.with as Record<string, unknown> | undefined;
1495
+ const specRef = withMap?.['deliverableSpecRef'];
1496
+ if (typeof specRef !== 'string' || specRef.length === 0) return;
1497
+ // Skip expression-shaped refs — the spec is unknown until dispatch.
1498
+ if (specRef.includes('${{')) return;
1499
+
1500
+ const spec = resolvedSpecs[specRef];
1501
+ if (!spec) return;
1502
+ if (!spec.topLevelKeys || spec.topLevelKeys.length === 0) return;
1503
+ if (spec.topLevelKeys.includes(access.field)) return;
1504
+
1505
+ const knownPreview = formatKnownPreview(spec.topLevelKeys);
1506
+ throw new WorkflowDslError(
1507
+ WorkflowErrorCode.DSL_UNKNOWN_DELIVERABLE_FIELD,
1508
+ `${fieldPath}: deliverable.content.value.${access.field} is not a top-level field on deliverable spec '${spec.ref}' (produced by job '${producingJobKey}'). Known fields: [${knownPreview}].`,
1509
+ {
1510
+ consumerJobKey,
1511
+ fieldPath,
1512
+ producingJobKey,
1513
+ specRef: spec.ref,
1514
+ field: access.field,
1515
+ knownFields: [...spec.topLevelKeys],
1516
+ },
1517
+ );
1518
+ }