@shipfox/api-definitions 11.0.0 → 12.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 (148) hide show
  1. package/.turbo/turbo-build.log +13 -12
  2. package/CHANGELOG.md +81 -0
  3. package/dist/core/entities/index.d.ts +1 -0
  4. package/dist/core/entities/index.d.ts.map +1 -1
  5. package/dist/core/entities/index.js +1 -0
  6. package/dist/core/entities/index.js.map +1 -1
  7. package/dist/core/entities/sync-state.d.ts +4 -0
  8. package/dist/core/entities/sync-state.d.ts.map +1 -1
  9. package/dist/core/entities/sync-state.js +10 -0
  10. package/dist/core/entities/sync-state.js.map +1 -1
  11. package/dist/core/entities/validation-warning.d.ts +6 -0
  12. package/dist/core/entities/validation-warning.d.ts.map +1 -0
  13. package/dist/core/entities/validation-warning.js +3 -0
  14. package/dist/core/entities/validation-warning.js.map +1 -0
  15. package/dist/core/parse-definition.d.ts +9 -7
  16. package/dist/core/parse-definition.d.ts.map +1 -1
  17. package/dist/core/parse-definition.js +14 -2
  18. package/dist/core/parse-definition.js.map +1 -1
  19. package/dist/core/sync-definitions.d.ts +2 -0
  20. package/dist/core/sync-definitions.d.ts.map +1 -1
  21. package/dist/core/sync-definitions.js +6 -5
  22. package/dist/core/sync-definitions.js.map +1 -1
  23. package/dist/core/validate-definition.d.ts +9 -5
  24. package/dist/core/validate-definition.d.ts.map +1 -1
  25. package/dist/core/validate-definition.js +23 -2
  26. package/dist/core/validate-definition.js.map +1 -1
  27. package/dist/core/workflow-model/index.d.ts +1 -1
  28. package/dist/core/workflow-model/index.d.ts.map +1 -1
  29. package/dist/core/workflow-model/index.js.map +1 -1
  30. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +3 -1
  31. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -1
  32. package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -1
  33. package/dist/core/workflow-model/normalize-if-condition.d.ts +0 -1
  34. package/dist/core/workflow-model/normalize-if-condition.d.ts.map +1 -1
  35. package/dist/core/workflow-model/normalize-if-condition.js +0 -1
  36. package/dist/core/workflow-model/normalize-if-condition.js.map +1 -1
  37. package/dist/core/workflow-model/normalize-job-checkout.d.ts +15 -3
  38. package/dist/core/workflow-model/normalize-job-checkout.d.ts.map +1 -1
  39. package/dist/core/workflow-model/normalize-job-checkout.js +83 -4
  40. package/dist/core/workflow-model/normalize-job-checkout.js.map +1 -1
  41. package/dist/core/workflow-model/normalize-job-listening.js +0 -1
  42. package/dist/core/workflow-model/normalize-job-listening.js.map +1 -1
  43. package/dist/core/workflow-model/normalize-job-success.d.ts.map +1 -1
  44. package/dist/core/workflow-model/normalize-job-success.js +0 -1
  45. package/dist/core/workflow-model/normalize-job-success.js.map +1 -1
  46. package/dist/core/workflow-model/normalize-jobs.d.ts.map +1 -1
  47. package/dist/core/workflow-model/normalize-jobs.js +310 -45
  48. package/dist/core/workflow-model/normalize-jobs.js.map +1 -1
  49. package/dist/core/workflow-model/normalize-step-gate.js +0 -1
  50. package/dist/core/workflow-model/normalize-step-gate.js.map +1 -1
  51. package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -1
  52. package/dist/core/workflow-model/normalize-triggers.js +0 -1
  53. package/dist/core/workflow-model/normalize-triggers.js.map +1 -1
  54. package/dist/core/workflow-model/normalize-workflow-document.d.ts +3 -0
  55. package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -1
  56. package/dist/core/workflow-model/normalize-workflow-document.js +34 -4
  57. package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -1
  58. package/dist/core/workflow-model/parse-interpolation-field.d.ts +1 -1
  59. package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -1
  60. package/dist/core/workflow-model/parse-interpolation-field.js +27 -57
  61. package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -1
  62. package/dist/core/workflow-model/validate-literal-name.d.ts +12 -0
  63. package/dist/core/workflow-model/validate-literal-name.d.ts.map +1 -0
  64. package/dist/core/workflow-model/validate-literal-name.js +36 -0
  65. package/dist/core/workflow-model/validate-literal-name.js.map +1 -0
  66. package/dist/core/workflow-model/validate-predicate-expression.d.ts +1 -2
  67. package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -1
  68. package/dist/core/workflow-model/validate-predicate-expression.js +31 -90
  69. package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -1
  70. package/dist/core/workflow-model/validation-issue.d.ts +2 -1
  71. package/dist/core/workflow-model/validation-issue.d.ts.map +1 -1
  72. package/dist/core/workflow-model/validation-issue.js +8 -2
  73. package/dist/core/workflow-model/validation-issue.js.map +1 -1
  74. package/dist/core/workflow-model/workflow-field-label.d.ts.map +1 -1
  75. package/dist/core/workflow-model/workflow-field-label.js +16 -2
  76. package/dist/core/workflow-model/workflow-field-label.js.map +1 -1
  77. package/dist/db/db.d.ts +38 -0
  78. package/dist/db/db.d.ts.map +1 -1
  79. package/dist/db/schema/sync-states.d.ts +19 -0
  80. package/dist/db/schema/sync-states.d.ts.map +1 -1
  81. package/dist/db/schema/sync-states.js +3 -1
  82. package/dist/db/schema/sync-states.js.map +1 -1
  83. package/dist/db/sync-states.d.ts +2 -1
  84. package/dist/db/sync-states.d.ts.map +1 -1
  85. package/dist/db/sync-states.js +4 -0
  86. package/dist/db/sync-states.js.map +1 -1
  87. package/dist/presentation/dto/definition.d.ts.map +1 -1
  88. package/dist/presentation/dto/definition.js +2 -1
  89. package/dist/presentation/dto/definition.js.map +1 -1
  90. package/dist/presentation/routes/project-access.d.ts +1 -0
  91. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  92. package/dist/presentation/routes/validate-definition.d.ts.map +1 -1
  93. package/dist/presentation/routes/validate-definition.js +5 -3
  94. package/dist/presentation/routes/validate-definition.js.map +1 -1
  95. package/dist/temporal/activities/sync-activities.d.ts +6 -2
  96. package/dist/temporal/activities/sync-activities.d.ts.map +1 -1
  97. package/dist/temporal/activities/sync-activities.js +8 -1
  98. package/dist/temporal/activities/sync-activities.js.map +1 -1
  99. package/dist/temporal/workflows/definition-sync.js +4 -1
  100. package/dist/temporal/workflows/definition-sync.js.map +1 -1
  101. package/dist/temporal/workflows/index.bundle.js +9029 -3
  102. package/dist/tsconfig.test.tsbuildinfo +1 -1
  103. package/drizzle/0002_lumpy_ben_parker.sql +1 -0
  104. package/drizzle/meta/0002_snapshot.json +534 -0
  105. package/drizzle/meta/_journal.json +7 -0
  106. package/package.json +14 -14
  107. package/src/core/entities/index.ts +1 -0
  108. package/src/core/entities/sync-state.ts +23 -0
  109. package/src/core/entities/validation-warning.ts +5 -0
  110. package/src/core/parse-definition-warnings.test.ts +33 -0
  111. package/src/core/parse-definition.test.ts +7 -1
  112. package/src/core/parse-definition.ts +28 -10
  113. package/src/core/sync-definitions.test.ts +39 -0
  114. package/src/core/sync-definitions.ts +14 -4
  115. package/src/core/validate-definition.test.ts +128 -0
  116. package/src/core/validate-definition.ts +39 -8
  117. package/src/core/workflow-model/README.md +2 -2
  118. package/src/core/workflow-model/index.ts +1 -0
  119. package/src/core/workflow-model/invalid-workflow-model-error.ts +8 -2
  120. package/src/core/workflow-model/normalize-if-condition.ts +0 -2
  121. package/src/core/workflow-model/normalize-job-checkout.ts +105 -8
  122. package/src/core/workflow-model/normalize-job-listening.ts +0 -1
  123. package/src/core/workflow-model/normalize-job-success.ts +0 -1
  124. package/src/core/workflow-model/normalize-jobs.ts +330 -49
  125. package/src/core/workflow-model/normalize-step-gate.ts +0 -1
  126. package/src/core/workflow-model/normalize-triggers.ts +0 -1
  127. package/src/core/workflow-model/normalize-workflow-document.test.ts +732 -185
  128. package/src/core/workflow-model/normalize-workflow-document.ts +29 -4
  129. package/src/core/workflow-model/parse-interpolation-field.ts +50 -79
  130. package/src/core/workflow-model/validate-literal-name.ts +64 -0
  131. package/src/core/workflow-model/validate-predicate-expression.test.ts +153 -15
  132. package/src/core/workflow-model/validate-predicate-expression.ts +52 -120
  133. package/src/core/workflow-model/validation-issue.ts +4 -0
  134. package/src/core/workflow-model/workflow-field-label.ts +16 -2
  135. package/src/core/workflow-yaml/README.md +2 -2
  136. package/src/db/schema/sync-states.ts +4 -1
  137. package/src/db/sync-states.test.ts +57 -0
  138. package/src/db/sync-states.ts +6 -0
  139. package/src/presentation/dto/definition.ts +1 -0
  140. package/src/presentation/routes/list-definitions.test.ts +1 -0
  141. package/src/presentation/routes/validate-definition.test.ts +1 -0
  142. package/src/presentation/routes/validate-definition.ts +7 -1
  143. package/src/temporal/activities/sync-activities.test.ts +16 -0
  144. package/src/temporal/activities/sync-activities.ts +18 -3
  145. package/src/temporal/workflows/definition-sync.ts +1 -1
  146. package/test/agent-validation-catalog.ts +17 -0
  147. package/test/fixtures/valid-listening-job.yml +2 -1
  148. package/tsconfig.build.tsbuildinfo +1 -1
@@ -79,6 +79,199 @@ const integrationValidationContext = {
79
79
  } satisfies IntegrationValidationContext;
80
80
 
81
81
  describe('normalizeWorkflowDocument', () => {
82
+ it('rejects interpolated workflow names in favor of run_name', () => {
83
+ const error = expectInvalid({
84
+ name: interpolation('inputs.environment'),
85
+ jobs: {build: {steps: [{run: 'build'}]}},
86
+ });
87
+
88
+ expect(error.issues).toEqual([
89
+ expect.objectContaining({
90
+ code: 'invalid-interpolation-template',
91
+ message: 'Workflow name must be literal. Move runtime interpolation to run_name.',
92
+ path: ['name'],
93
+ }),
94
+ ]);
95
+ });
96
+
97
+ it('rejects unterminated workflow-name templates with the migration guidance', () => {
98
+ const error = expectInvalid({
99
+ name: '$' + '{{ unterminated',
100
+ jobs: {build: {steps: [{run: 'build'}]}},
101
+ });
102
+
103
+ expect(error.issues).toEqual([
104
+ expect.objectContaining({
105
+ code: 'invalid-interpolation-template',
106
+ message: 'Workflow name must be literal. Move runtime interpolation to run_name.',
107
+ path: ['name'],
108
+ details: expect.objectContaining({reason: 'unterminated opener'}),
109
+ }),
110
+ ]);
111
+ });
112
+
113
+ it('rejects unterminated job-name templates with the migration guidance', () => {
114
+ const error = expectInvalid({
115
+ name: 'Deploy',
116
+ jobs: {
117
+ build: {name: '$' + '{{ unterminated', steps: [{run: 'build'}]},
118
+ },
119
+ });
120
+
121
+ expect(error.issues).toEqual([
122
+ expect.objectContaining({
123
+ code: 'invalid-interpolation-template',
124
+ message: 'Job name must be literal. Move runtime interpolation to execution_name.',
125
+ path: ['jobs', 'build', 'name'],
126
+ details: expect.objectContaining({reason: 'unterminated opener'}),
127
+ }),
128
+ ]);
129
+ });
130
+
131
+ it('normalizes workflow run and job execution name templates', () => {
132
+ const model = normalizeWorkflowDocument({
133
+ name: 'Deploy application',
134
+ run_name: `Deploy ${interpolation('inputs.environment')}`,
135
+ jobs: {
136
+ deploy: {
137
+ name: 'Deploy',
138
+ execution_name: `Deploy ${interpolation('event.environment')}`,
139
+ steps: [{run: 'deploy'}],
140
+ },
141
+ },
142
+ });
143
+
144
+ expect(model.runName).toMatchObject([
145
+ {kind: 'literal', value: 'Deploy '},
146
+ {kind: 'deferred', roots: ['inputs'], fillTarget: 'run-creation'},
147
+ ]);
148
+ expect(model.jobs[0]?.executionName).toMatchObject([
149
+ {kind: 'literal', value: 'Deploy '},
150
+ {kind: 'deferred', roots: ['event'], fillTarget: 'execution-creation'},
151
+ ]);
152
+ });
153
+
154
+ it('preserves literal workflow run and job execution names', () => {
155
+ const model = normalizeWorkflowDocument({
156
+ name: 'Workflow',
157
+ run_name: 'Static run name',
158
+ jobs: {
159
+ build: {
160
+ execution_name: 'Static execution name',
161
+ steps: [{run: 'build'}],
162
+ },
163
+ },
164
+ });
165
+
166
+ expect(model.runName).toEqual([{kind: 'literal', value: 'Static run name'}]);
167
+ expect(model.jobs[0]?.executionName).toEqual([
168
+ {kind: 'literal', value: 'Static execution name'},
169
+ ]);
170
+ });
171
+
172
+ it('unescapes literal workflow and job names', () => {
173
+ const escapedName = `$${interpolation('inputs.environment')}`;
174
+ const model = normalizeWorkflowDocument({
175
+ name: escapedName,
176
+ jobs: {
177
+ build: {name: escapedName, steps: [{run: 'build'}]},
178
+ },
179
+ });
180
+
181
+ expect(model.name).toBe(interpolation('inputs.environment'));
182
+ expect(model.jobs[0]?.name).toBe(interpolation('inputs.environment'));
183
+ });
184
+
185
+ it.each([
186
+ [
187
+ 'run_name',
188
+ {
189
+ name: 'Workflow',
190
+ run_name: interpolation('steps.build.outputs.name'),
191
+ jobs: {build: {steps: [{run: 'build'}]}},
192
+ },
193
+ ],
194
+ [
195
+ 'execution_name',
196
+ {
197
+ name: 'Workflow',
198
+ jobs: {
199
+ build: {
200
+ execution_name: interpolation('steps.build.outputs.name'),
201
+ steps: [{run: 'build'}],
202
+ },
203
+ },
204
+ },
205
+ ],
206
+ ] as const)('rejects unavailable context in %s', (_field, document) => {
207
+ const error = expectInvalid(document as unknown as WorkflowDocument);
208
+ expect(error.issues).toEqual([
209
+ expect.objectContaining({code: 'context-unavailable-at-fill-site'}),
210
+ ]);
211
+ });
212
+
213
+ it('allows computed access to unrelated dynamic-name context fields', () => {
214
+ const model = normalizeWorkflowDocument({
215
+ name: 'Workflow',
216
+ run_name: interpolation('run["id"]'),
217
+ jobs: {
218
+ build: {
219
+ execution_name: interpolation('execution["status"]'),
220
+ steps: [{run: 'build'}],
221
+ },
222
+ },
223
+ });
224
+
225
+ expect(model.runName).toMatchObject([{kind: 'deferred', roots: ['run']}]);
226
+ expect(model.jobs[0]?.executionName).toMatchObject([{kind: 'deferred', roots: ['execution']}]);
227
+ });
228
+
229
+ it.each([
230
+ [
231
+ 'run_name',
232
+ {
233
+ name: 'Workflow',
234
+ run_name: interpolation('run.name'),
235
+ jobs: {build: {steps: [{run: 'build'}]}},
236
+ },
237
+ ],
238
+ [
239
+ 'run_name bracket access',
240
+ {
241
+ name: 'Workflow',
242
+ run_name: interpolation('run["name"]'),
243
+ jobs: {build: {steps: [{run: 'build'}]}},
244
+ },
245
+ ],
246
+ [
247
+ 'execution_name',
248
+ {
249
+ name: 'Workflow',
250
+ jobs: {
251
+ build: {
252
+ execution_name: interpolation('execution.name'),
253
+ steps: [{run: 'build'}],
254
+ },
255
+ },
256
+ },
257
+ ],
258
+ [
259
+ 'execution_name bracket access',
260
+ {
261
+ name: 'Workflow',
262
+ jobs: {
263
+ build: {
264
+ execution_name: interpolation('execution["name"]'),
265
+ steps: [{run: 'build'}],
266
+ },
267
+ },
268
+ },
269
+ ],
270
+ ] as const)('rejects dynamic-name self-reference in %s', (_field, document) => {
271
+ const error = expectInvalid(document as unknown as WorkflowDocument);
272
+ expect(error.issues).toEqual([expect.objectContaining({code: 'dynamic-name-self-reference'})]);
273
+ });
274
+
82
275
  it('normalizes a workflow document into a WorkflowModel', () => {
83
276
  const document: WorkflowDocument = {
84
277
  name: 'simple build',
@@ -154,7 +347,7 @@ describe('normalizeWorkflowDocument', () => {
154
347
  {
155
348
  key: 'review',
156
349
  harness: 'claude',
157
- model: 'gpt-5.5-pro',
350
+ model: 'claude-opus-4-8',
158
351
  provider: 'anthropic',
159
352
  prompt: 'Review the fix.',
160
353
  thinking: 'low',
@@ -538,6 +731,7 @@ describe('normalizeWorkflowDocument', () => {
538
731
  {
539
732
  harness: 'pi',
540
733
  provider: 'workspace-openai-compatible',
734
+ model: 'workspace-model',
541
735
  prompt: 'Fix it.',
542
736
  },
543
737
  ],
@@ -551,6 +745,80 @@ describe('normalizeWorkflowDocument', () => {
551
745
  kind: 'agent',
552
746
  harness: 'pi',
553
747
  provider: 'workspace-openai-compatible',
748
+ model: 'workspace-model',
749
+ });
750
+ });
751
+
752
+ it('reports unsupported literal models', () => {
753
+ const document: WorkflowDocument = {
754
+ name: 'agent build',
755
+ jobs: {
756
+ fix: {
757
+ steps: [{harness: 'pi', provider: 'anthropic', model: 'not-a-model', prompt: 'Fix it.'}],
758
+ },
759
+ },
760
+ };
761
+
762
+ const error = expectInvalid(document);
763
+
764
+ expect(error.issues).toEqual([
765
+ {
766
+ code: 'invalid-model',
767
+ message:
768
+ 'Agent model "not-a-model" is not available for harness "pi" and provider "anthropic".',
769
+ path: ['jobs', 'fix', 'steps', 0, 'model'],
770
+ details: {harness: 'pi', provider: 'anthropic', model: 'not-a-model'},
771
+ },
772
+ ]);
773
+ });
774
+
775
+ it('allows custom providers without an explicit harness', () => {
776
+ const document: WorkflowDocument = {
777
+ name: 'agent build',
778
+ jobs: {
779
+ fix: {
780
+ steps: [
781
+ {
782
+ provider: 'workspace-openai-compatible',
783
+ model: 'workspace-model',
784
+ prompt: 'Fix it.',
785
+ },
786
+ ],
787
+ },
788
+ },
789
+ };
790
+
791
+ const model = normalizeWorkflowDocument(document);
792
+
793
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
794
+ kind: 'agent',
795
+ provider: 'workspace-openai-compatible',
796
+ model: 'workspace-model',
797
+ });
798
+ });
799
+
800
+ it('defers harness-specific validation when harness is omitted', () => {
801
+ const document: WorkflowDocument = {
802
+ name: 'workspace-default harness',
803
+ jobs: {
804
+ fix: {
805
+ steps: [
806
+ {
807
+ provider: 'anthropic',
808
+ model: 'claude-sonnet-4-6',
809
+ prompt: 'Fix it.',
810
+ },
811
+ ],
812
+ },
813
+ },
814
+ };
815
+
816
+ const model = normalizeWorkflowDocument(document);
817
+
818
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
819
+ kind: 'agent',
820
+ provider: 'anthropic',
821
+ model: 'claude-sonnet-4-6',
554
822
  });
555
823
  });
556
824
 
@@ -743,6 +1011,29 @@ describe('normalizeWorkflowDocument', () => {
743
1011
  });
744
1012
  });
745
1013
 
1014
+ it('defers an interpolated thinking level to dispatch', () => {
1015
+ const document: WorkflowDocument = {
1016
+ name: 'agent build',
1017
+ jobs: {
1018
+ fix: {
1019
+ steps: [
1020
+ {
1021
+ harness: 'claude',
1022
+ prompt: 'Fix it.',
1023
+ thinking: workflowInterpolation('vars.THINKING'),
1024
+ },
1025
+ ],
1026
+ },
1027
+ },
1028
+ };
1029
+
1030
+ const model = normalizeWorkflowDocument(document);
1031
+ const step = model.jobs[0]?.steps[0];
1032
+
1033
+ if (step?.kind !== 'agent') throw new Error('Expected an agent step');
1034
+ expect(step.templates?.thinking).toMatchObject([{kind: 'deferred', roots: ['vars']}]);
1035
+ });
1036
+
746
1037
  it('normalizes job success expressions and execution timeouts', () => {
747
1038
  const document: WorkflowDocument = {
748
1039
  name: 'job controls',
@@ -831,6 +1122,144 @@ describe('normalizeWorkflowDocument', () => {
831
1122
  expect(model.jobs[0]?.checkout).toEqual(DEFAULT_JOB_CHECKOUT);
832
1123
  });
833
1124
 
1125
+ it('preserves the job checkout opt-out in the model', () => {
1126
+ const model = normalizeWorkflowDocument({
1127
+ name: 'checkout disabled',
1128
+ jobs: {
1129
+ build: {
1130
+ checkout: false,
1131
+ steps: [{run: 'npm test'}],
1132
+ },
1133
+ },
1134
+ });
1135
+
1136
+ expect(model.jobs[0]?.checkout).toBe(false);
1137
+ });
1138
+
1139
+ it('normalizes checkout-step targets from earlier step outputs', () => {
1140
+ const model = normalizeWorkflowDocument({
1141
+ name: 'templated checkout step',
1142
+ jobs: {
1143
+ build: {
1144
+ steps: [
1145
+ {
1146
+ key: 'route',
1147
+ run: 'echo route',
1148
+ outputs: {
1149
+ connection: {type: 'string'},
1150
+ repository: {type: 'string'},
1151
+ ref: {type: 'string'},
1152
+ path: {type: 'string'},
1153
+ },
1154
+ },
1155
+ {
1156
+ key: 'checkout',
1157
+ checkout: {
1158
+ connection: interpolation('inputs.connection'),
1159
+ repository: interpolation('steps.route.outputs.repository'),
1160
+ ref: interpolation('steps.route.outputs.ref'),
1161
+ path: interpolation('steps.route.outputs.path'),
1162
+ },
1163
+ },
1164
+ ],
1165
+ },
1166
+ },
1167
+ });
1168
+
1169
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
1170
+ kind: 'checkout',
1171
+ checkout: {
1172
+ connection: interpolation('inputs.connection'),
1173
+ repository: interpolation('steps.route.outputs.repository'),
1174
+ ref: interpolation('steps.route.outputs.ref'),
1175
+ path: interpolation('steps.route.outputs.path'),
1176
+ fetchDepth: 1,
1177
+ permissions: {contents: 'read'},
1178
+ persistCredentials: true,
1179
+ templates: {
1180
+ connection: [{kind: 'deferred', roots: ['inputs'], fillTarget: 'run-creation'}],
1181
+ repository: [{kind: 'deferred', roots: ['steps'], fillTarget: 'step-dispatch'}],
1182
+ ref: [{kind: 'deferred', roots: ['steps'], fillTarget: 'step-dispatch'}],
1183
+ path: [{kind: 'deferred', roots: ['steps'], fillTarget: 'step-dispatch'}],
1184
+ },
1185
+ },
1186
+ });
1187
+ });
1188
+
1189
+ it('reports invalid checkout target combinations from model normalization', () => {
1190
+ const error = expectInvalid({
1191
+ name: 'invalid checkout target',
1192
+ jobs: {
1193
+ build: {
1194
+ steps: [{checkout: {project: 'project-id', repository: 'acme/api'}}],
1195
+ },
1196
+ },
1197
+ });
1198
+
1199
+ expect(error.issues).toContainEqual(
1200
+ expect.objectContaining({
1201
+ code: 'checkout-target-invalid',
1202
+ path: ['jobs', 'build', 'steps', 0, 'checkout', 'repository'],
1203
+ }),
1204
+ );
1205
+
1206
+ const missingRepository = expectInvalid({
1207
+ name: 'missing checkout repository',
1208
+ jobs: {
1209
+ build: {
1210
+ steps: [{checkout: {connection: 'github'}}],
1211
+ },
1212
+ },
1213
+ });
1214
+
1215
+ expect(missingRepository.issues).toContainEqual(
1216
+ expect.objectContaining({
1217
+ code: 'checkout-target-invalid',
1218
+ path: ['jobs', 'build', 'steps', 0, 'checkout', 'repository'],
1219
+ details: {fields: ['connection', 'repository']},
1220
+ }),
1221
+ );
1222
+ });
1223
+
1224
+ it('normalizes checkout steps and their static defaults', () => {
1225
+ const model = normalizeWorkflowDocument({
1226
+ name: 'checkout step',
1227
+ jobs: {
1228
+ build: {
1229
+ steps: [
1230
+ {
1231
+ key: 'fetch-target',
1232
+ checkout: {
1233
+ repository: 'acme/api',
1234
+ ref: 'refs/heads/main',
1235
+ 'fetch-depth': 0,
1236
+ path: 'target',
1237
+ permissions: {contents: 'write'},
1238
+ 'persist-credentials': false,
1239
+ force: true,
1240
+ },
1241
+ },
1242
+ ],
1243
+ },
1244
+ },
1245
+ });
1246
+
1247
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
1248
+ id: expect.stringContaining('fetch-target'),
1249
+ key: 'fetch-target',
1250
+ kind: 'checkout',
1251
+ checkout: {
1252
+ repository: 'acme/api',
1253
+ ref: 'refs/heads/main',
1254
+ fetchDepth: 0,
1255
+ path: 'target',
1256
+ permissions: {contents: 'write'},
1257
+ persistCredentials: false,
1258
+ force: true,
1259
+ },
1260
+ });
1261
+ });
1262
+
834
1263
  it('normalizes checkout contents write and defaults persisted credentials', () => {
835
1264
  const document: WorkflowDocument = {
836
1265
  name: 'write checkout',
@@ -880,7 +1309,7 @@ describe('normalizeWorkflowDocument', () => {
880
1309
  name: 'listen for reviews',
881
1310
  jobs: {
882
1311
  review: {
883
- name: displayName,
1312
+ execution_name: displayName,
884
1313
  listening: {
885
1314
  on: [
886
1315
  {
@@ -915,7 +1344,7 @@ describe('normalizeWorkflowDocument', () => {
915
1344
  onResolve: 'cancel',
916
1345
  },
917
1346
  });
918
- expect(model.jobs[0]?.name?.[1]).toMatchObject({
1347
+ expect(model.jobs[0]?.executionName?.[1]).toMatchObject({
919
1348
  kind: 'deferred',
920
1349
  expression: {source: 'execution.index', check: 'typed'},
921
1350
  });
@@ -968,7 +1397,7 @@ describe('normalizeWorkflowDocument', () => {
968
1397
  ]);
969
1398
  });
970
1399
 
971
- it('validates listener filters against job-activation roots', () => {
1400
+ it('validates listener filters against their persisted snapshot roots', () => {
972
1401
  const document: WorkflowDocument = {
973
1402
  name: 'listener filter roots',
974
1403
  jobs: {
@@ -983,7 +1412,7 @@ describe('normalizeWorkflowDocument', () => {
983
1412
  source: 'github',
984
1413
  event: 'pull_request_review',
985
1414
  filter:
986
- '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',
1415
+ 'run.id != "" && trigger.event == "pull_request_review" && inputs.target == event.issue.number && vars.ENABLED == "true" && job.key == "review" && jobs.build.outputs.pr_number == event.issue.number',
987
1416
  },
988
1417
  ],
989
1418
  until: [
@@ -1002,7 +1431,7 @@ describe('normalizeWorkflowDocument', () => {
1002
1431
  const model = normalizeWorkflowDocument(document);
1003
1432
 
1004
1433
  expect(model.jobs[1]?.listening?.on[0]?.filter).toBe(
1005
- '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',
1434
+ 'run.id != "" && trigger.event == "pull_request_review" && inputs.target == event.issue.number && vars.ENABLED == "true" && job.key == "review" && jobs.build.outputs.pr_number == event.issue.number',
1006
1435
  );
1007
1436
  expect(model.jobs[1]?.listening?.until?.[0]?.filter).toBe('event.action == "closed"');
1008
1437
  });
@@ -1010,6 +1439,8 @@ describe('normalizeWorkflowDocument', () => {
1010
1439
  it.each([
1011
1440
  ['step root', 'step.status == "succeeded"', 'context-unavailable-at-predicate-site'],
1012
1441
  ['steps root', 'steps.build.outputs.sha == "abc"', 'context-unavailable-at-predicate-site'],
1442
+ ['executions root', 'executions.size() > 0', 'context-unavailable-at-predicate-site'],
1443
+ ['matrix root', 'matrix.os == "linux"', 'context-unavailable-at-predicate-site'],
1013
1444
  ['runner root', 'runner.os == "linux"', 'runner-context-in-server-predicate'],
1014
1445
  ['non-boolean source', 'event.action', 'invalid-listener-filter'],
1015
1446
  ] as const)('reports invalid listener on filters for %s', (_label, filter, code) => {
@@ -1114,12 +1545,12 @@ describe('normalizeWorkflowDocument', () => {
1114
1545
  ]);
1115
1546
  });
1116
1547
 
1117
- it('preserves explicit model ids even when the seed catalog only knows provider defaults', () => {
1548
+ it('accepts explicit model ids present in the harness catalog', () => {
1118
1549
  const document: WorkflowDocument = {
1119
1550
  name: 'agent build',
1120
1551
  jobs: {
1121
1552
  fix: {
1122
- steps: [{provider: 'openai', model: 'gpt-4.1', prompt: 'Fix it.'}],
1553
+ steps: [{harness: 'pi', provider: 'openai', model: 'gpt-4.1', prompt: 'Fix it.'}],
1123
1554
  },
1124
1555
  },
1125
1556
  };
@@ -1602,7 +2033,7 @@ describe('normalizeWorkflowDocument', () => {
1602
2033
  });
1603
2034
  });
1604
2035
 
1605
- it('accepts server roots available at step reporting in gate success', () => {
2036
+ it('rejects server roots omitted from the gate success context', () => {
1606
2037
  const document: WorkflowDocument = {
1607
2038
  name: 'server-context gate',
1608
2039
  jobs: {
@@ -1610,14 +2041,21 @@ describe('normalizeWorkflowDocument', () => {
1610
2041
  },
1611
2042
  };
1612
2043
 
1613
- const model = normalizeWorkflowDocument(document);
2044
+ const error = expectInvalid(document);
1614
2045
 
1615
- expect(model.jobs[0]?.steps[0]?.gate?.success).toEqual({
1616
- language: 'cel',
1617
- source: 'run.id != ""',
1618
- check: 'typed',
1619
- resultType: 'bool',
1620
- });
2046
+ expect(error.issues).toEqual([
2047
+ expect.objectContaining({
2048
+ code: 'context-unavailable-at-predicate-site',
2049
+ message: expect.stringContaining('Step gate success references context "run"'),
2050
+ path: ['jobs', 'build', 'steps', 0, 'gate', 'success'],
2051
+ details: expect.objectContaining({
2052
+ field: 'step.success',
2053
+ source: 'run.id != ""',
2054
+ unavailableRoots: ['run'],
2055
+ site: 'step-report',
2056
+ }),
2057
+ }),
2058
+ ]);
1621
2059
  });
1622
2060
 
1623
2061
  it('rejects runner-host roots in gate success with a server-predicate issue', () => {
@@ -1647,7 +2085,7 @@ describe('normalizeWorkflowDocument', () => {
1647
2085
  ]);
1648
2086
  });
1649
2087
 
1650
- it('rejects vars in gate success with a server-predicate issue', () => {
2088
+ it('accepts vars in gate success', () => {
1651
2089
  const document: WorkflowDocument = {
1652
2090
  name: 'vars-context gate',
1653
2091
  jobs: {
@@ -1657,24 +2095,15 @@ describe('normalizeWorkflowDocument', () => {
1657
2095
  },
1658
2096
  };
1659
2097
 
1660
- const error = expectInvalid(document);
2098
+ const model = normalizeWorkflowDocument(document);
1661
2099
 
1662
- expect(error.issues).toEqual([
1663
- expect.objectContaining({
1664
- code: 'vars-context-in-server-predicate',
1665
- message: expect.stringContaining('cannot reference vars'),
1666
- path: ['jobs', 'build', 'steps', 0, 'gate', 'success'],
1667
- details: expect.objectContaining({
1668
- field: 'step.success',
1669
- source: 'vars.REQUIRED == "true"',
1670
- rejectedRoots: ['vars'],
1671
- site: 'step-report',
1672
- }),
1673
- }),
1674
- ]);
2100
+ expect(model.jobs[0]?.steps[0]?.gate?.success).toMatchObject({
2101
+ source: 'vars.REQUIRED == "true"',
2102
+ check: 'syntax',
2103
+ });
1675
2104
  });
1676
2105
 
1677
- it('accepts jobs root references at the gate predicate site', () => {
2106
+ it('rejects jobs root references omitted from the gate success context', () => {
1678
2107
  const document: WorkflowDocument = {
1679
2108
  name: 'jobs-context gate',
1680
2109
  jobs: {
@@ -1688,13 +2117,21 @@ describe('normalizeWorkflowDocument', () => {
1688
2117
  },
1689
2118
  };
1690
2119
 
1691
- const model = normalizeWorkflowDocument(document);
2120
+ const error = expectInvalid(document);
1692
2121
 
1693
- expect(model.jobs[1]?.steps[0]?.gate?.success).toEqual({
1694
- language: 'cel',
1695
- source: 'jobs.build.status == "succeeded"',
1696
- check: 'syntax',
1697
- });
2122
+ expect(error.issues).toEqual([
2123
+ expect.objectContaining({
2124
+ code: 'context-unavailable-at-predicate-site',
2125
+ message: expect.stringContaining('Step gate success references context "jobs"'),
2126
+ path: ['jobs', 'deploy', 'steps', 0, 'gate', 'success'],
2127
+ details: expect.objectContaining({
2128
+ field: 'step.success',
2129
+ source: 'jobs.build.status == "succeeded"',
2130
+ unavailableRoots: ['jobs'],
2131
+ site: 'step-report',
2132
+ }),
2133
+ }),
2134
+ ]);
1698
2135
  });
1699
2136
 
1700
2137
  it('parses job output mappings at execution resolution', () => {
@@ -1981,7 +2418,7 @@ describe('normalizeWorkflowDocument', () => {
1981
2418
  });
1982
2419
  });
1983
2420
 
1984
- it('rejects untrusted step outputs in trusted-only run fields', () => {
2421
+ it('allows step outputs in run fields', () => {
1985
2422
  const document: WorkflowDocument = {
1986
2423
  name: 'untrusted step output',
1987
2424
  jobs: {
@@ -1994,15 +2431,17 @@ describe('normalizeWorkflowDocument', () => {
1994
2431
  },
1995
2432
  };
1996
2433
 
1997
- const error = expectInvalid(document);
2434
+ const model = normalizeWorkflowDocument(document);
1998
2435
 
1999
- expect(error.issues).toEqual([
2000
- expect.objectContaining({
2001
- code: 'untrusted-context-in-field',
2002
- path: ['jobs', 'build', 'steps', 1, 'run'],
2003
- details: expect.objectContaining({field: 'run', rejectedRoots: ['steps']}),
2004
- }),
2005
- ]);
2436
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
2437
+ kind: 'run',
2438
+ templates: {
2439
+ command: [
2440
+ {kind: 'literal', value: 'echo '},
2441
+ {kind: 'deferred', roots: ['steps']},
2442
+ ],
2443
+ },
2444
+ });
2006
2445
  });
2007
2446
 
2008
2447
  it('infers typed job output scalars for downstream job overlays', () => {
@@ -2318,29 +2757,29 @@ describe('normalizeWorkflowDocument', () => {
2318
2757
  );
2319
2758
  });
2320
2759
 
2321
- it('rejects vars roots in if predicates', () => {
2760
+ it('accepts vars in job and step if predicates', () => {
2322
2761
  const document: WorkflowDocument = {
2323
2762
  name: 'vars condition',
2324
2763
  jobs: {
2325
2764
  build: {
2326
2765
  if: interpolation('vars.REQUIRED == "true"'),
2327
- steps: [{run: 'npm run build'}],
2766
+ steps: [{if: interpolation('vars.REQUIRED == "true"'), run: 'npm run build'}],
2328
2767
  },
2329
2768
  },
2330
2769
  };
2331
2770
 
2332
- const error = expectInvalid(document);
2771
+ const model = normalizeWorkflowDocument(document);
2333
2772
 
2334
- expect(error.issues).toEqual([
2335
- expect.objectContaining({
2336
- code: 'vars-context-in-server-predicate',
2337
- path: ['jobs', 'build', 'if'],
2338
- details: expect.objectContaining({
2339
- field: 'job.if',
2340
- rejectedRoots: ['vars'],
2341
- }),
2342
- }),
2343
- ]);
2773
+ expect(model.jobs[0]?.if).toMatchObject({
2774
+ source: 'vars.REQUIRED == "true"',
2775
+ check: 'typed',
2776
+ resultType: 'bool',
2777
+ });
2778
+ expect(model.jobs[0]?.steps[0]?.if).toMatchObject({
2779
+ source: 'vars.REQUIRED == "true"',
2780
+ check: 'typed',
2781
+ resultType: 'bool',
2782
+ });
2344
2783
  });
2345
2784
 
2346
2785
  it('rejects if job references without a direct needs edge', () => {
@@ -2442,7 +2881,7 @@ describe('normalizeWorkflowDocument', () => {
2442
2881
  path: ['jobs', 'build', 'if'],
2443
2882
  details: expect.objectContaining({
2444
2883
  field: 'job.if',
2445
- unavailableRoots: ['execution.failed'],
2884
+ unavailableRoots: ['execution'],
2446
2885
  }),
2447
2886
  }),
2448
2887
  ]);
@@ -2573,7 +3012,7 @@ describe('normalizeWorkflowDocument', () => {
2573
3012
  ]);
2574
3013
  });
2575
3014
 
2576
- it('rejects vars in job success with a server-predicate issue', () => {
3015
+ it('accepts vars in job success', () => {
2577
3016
  const document: WorkflowDocument = {
2578
3017
  name: 'vars-context job success',
2579
3018
  jobs: {
@@ -2584,21 +3023,9 @@ describe('normalizeWorkflowDocument', () => {
2584
3023
  },
2585
3024
  };
2586
3025
 
2587
- const error = expectInvalid(document);
3026
+ const model = normalizeWorkflowDocument(document);
2588
3027
 
2589
- expect(error.issues).toEqual([
2590
- expect.objectContaining({
2591
- code: 'vars-context-in-server-predicate',
2592
- message: expect.stringContaining('cannot reference vars'),
2593
- path: ['jobs', 'build', 'success'],
2594
- details: expect.objectContaining({
2595
- field: 'job.success',
2596
- source: 'vars.ENVIRONMENT == "prod"',
2597
- rejectedRoots: ['vars'],
2598
- site: 'job-resolution',
2599
- }),
2600
- }),
2601
- ]);
3028
+ expect(model.jobs[0]?.success).toBe('vars.ENVIRONMENT == "prod"');
2602
3029
  });
2603
3030
 
2604
3031
  it('reports malformed job execution timeouts', () => {
@@ -3111,12 +3538,12 @@ describe('normalizeWorkflowDocument', () => {
3111
3538
  [
3112
3539
  'vars root',
3113
3540
  'vars.environment == "prod"',
3114
- 'vars-context-in-server-predicate',
3541
+ 'context-unavailable-at-predicate-site',
3115
3542
  {
3116
3543
  field: 'trigger.filter',
3117
3544
  source: 'vars.environment == "prod"',
3118
3545
  contextRoots: ['vars'],
3119
- rejectedRoots: ['vars'],
3546
+ unavailableRoots: ['vars'],
3120
3547
  site: 'ingest',
3121
3548
  },
3122
3549
  ],
@@ -3551,11 +3978,13 @@ describe('normalizeWorkflowDocument', () => {
3551
3978
  steps: [
3552
3979
  {
3553
3980
  name: `deploy ${interpolation('event.action')}`,
3981
+ working_directory: 'packages/api',
3554
3982
  run: `deploy ${interpolation('run.id')}`,
3555
3983
  env: {PR_TITLE: interpolation('event.pull_request.title'), DEBUG: false},
3556
3984
  },
3557
3985
  {
3558
3986
  name: `review ${interpolation('inputs.topic')}`,
3987
+ working_directory: `packages/${interpolation('inputs.topic')}`,
3559
3988
  provider: 'openai',
3560
3989
  prompt: `Review ${interpolation('event.pull_request.title')}`,
3561
3990
  },
@@ -3602,6 +4031,7 @@ describe('normalizeWorkflowDocument', () => {
3602
4031
  expect(model.jobs[0]?.steps[0]).toMatchObject({
3603
4032
  kind: 'run',
3604
4033
  command: {value: `deploy ${interpolation('run.id')}`},
4034
+ workingDirectory: 'packages/api',
3605
4035
  templates: {
3606
4036
  command: [
3607
4037
  {kind: 'literal', value: 'deploy '},
@@ -3636,7 +4066,16 @@ describe('normalizeWorkflowDocument', () => {
3636
4066
  });
3637
4067
  expect(model.jobs[0]?.steps[1]).toMatchObject({
3638
4068
  kind: 'agent',
4069
+ workingDirectory: `packages/${interpolation('inputs.topic')}`,
3639
4070
  templates: {
4071
+ workingDirectory: [
4072
+ {kind: 'literal', value: 'packages/'},
4073
+ {
4074
+ kind: 'deferred',
4075
+ expression: {source: 'inputs.topic'},
4076
+ roots: ['inputs'],
4077
+ },
4078
+ ],
3640
4079
  prompt: [
3641
4080
  {kind: 'literal', value: 'Review '},
3642
4081
  {
@@ -3678,7 +4117,7 @@ describe('normalizeWorkflowDocument', () => {
3678
4117
  expect(model).not.toHaveProperty('templates');
3679
4118
  expect(model.jobs[0]?.steps[0]).not.toHaveProperty('templates');
3680
4119
  expect(model.env).toEqual({VALUE: '$${{ event.ref }}'});
3681
- expect(model.jobs[0]?.name).toEqual([{kind: 'literal', value: 'Build app'}]);
4120
+ expect(model.jobs[0]?.name).toBe('Build app');
3682
4121
  expect(model.jobs[0]?.steps[0]).toMatchObject({
3683
4122
  kind: 'run',
3684
4123
  command: {value: 'echo $${{ event.ref }}'},
@@ -3737,30 +4176,32 @@ describe('normalizeWorkflowDocument', () => {
3737
4176
 
3738
4177
  it.each([
3739
4178
  ['event payload', 'event.pull_request.title', ['event']],
3740
- ['job outputs', 'jobs.build.outputs.sha', ['jobs']],
3741
- ] as const)('rejects untrusted %s context in run commands with the env fix-it message', (_label, source, rejectedRoots) => {
4179
+ ['job status', 'jobs.build.status', ['jobs']],
4180
+ ] as const)('allows %s context in run commands', (_label, source, roots) => {
3742
4181
  const document: WorkflowDocument = {
3743
4182
  name: 'unsafe run',
3744
4183
  jobs: {
3745
4184
  build: {
4185
+ steps: [{run: 'echo build'}],
4186
+ },
4187
+ deploy: {
4188
+ needs: 'build',
3746
4189
  steps: [{run: `echo ${interpolation(source)}`}],
3747
4190
  },
3748
4191
  },
3749
4192
  };
3750
4193
 
3751
- const error = expectInvalid(document);
4194
+ const model = normalizeWorkflowDocument(document);
3752
4195
 
3753
- expect(error.issues).toEqual([
3754
- expect.objectContaining({
3755
- code: 'untrusted-context-in-field',
3756
- path: ['jobs', 'build', 'steps', 0, 'run'],
3757
- message: expect.stringContaining('Bind untrusted values to env'),
3758
- details: expect.objectContaining({
3759
- field: 'run',
3760
- rejectedRoots,
3761
- }),
3762
- }),
3763
- ]);
4196
+ expect(model.jobs[1]?.steps[0]).toMatchObject({
4197
+ kind: 'run',
4198
+ templates: {
4199
+ command: [
4200
+ {kind: 'literal', value: 'echo '},
4201
+ {kind: 'deferred', roots},
4202
+ ],
4203
+ },
4204
+ });
3764
4205
  });
3765
4206
 
3766
4207
  it('rejects secrets in agent fields', () => {
@@ -3832,8 +4273,7 @@ describe('normalizeWorkflowDocument', () => {
3832
4273
  it.each([
3833
4274
  'execution.events[0].data.body',
3834
4275
  'execution["events"][0].data.body',
3835
- 'execution[x]',
3836
- ])('rejects untrusted execution sub-paths in run commands: %s', (source) => {
4276
+ ])('allows execution event sub-paths in run commands: %s', (source) => {
3837
4277
  const document: WorkflowDocument = {
3838
4278
  name: 'unsafe execution run',
3839
4279
  jobs: {
@@ -3843,42 +4283,17 @@ describe('normalizeWorkflowDocument', () => {
3843
4283
  },
3844
4284
  };
3845
4285
 
3846
- const error = expectInvalid(document);
3847
-
3848
- expect(error.issues).toEqual([
3849
- expect.objectContaining({
3850
- code: 'untrusted-context-in-field',
3851
- path: ['jobs', 'build', 'steps', 0, 'run'],
3852
- details: expect.objectContaining({
3853
- field: 'run',
3854
- rejectedRoots: ['execution'],
3855
- }),
3856
- }),
3857
- ]);
3858
- });
4286
+ const model = normalizeWorkflowDocument(document);
3859
4287
 
3860
- it('rejects execution event access through CEL comprehension bindings in run commands', () => {
3861
- const document: WorkflowDocument = {
3862
- name: 'unsafe execution map run',
3863
- jobs: {
3864
- build: {
3865
- steps: [{run: `echo ${interpolation('executions.map(e, e.events[0].data.body)')}`}],
3866
- },
4288
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4289
+ kind: 'run',
4290
+ templates: {
4291
+ command: [
4292
+ {kind: 'literal', value: 'echo '},
4293
+ {kind: 'deferred', roots: ['execution']},
4294
+ ],
3867
4295
  },
3868
- };
3869
-
3870
- const error = expectInvalid(document);
3871
-
3872
- expect(error.issues).toEqual([
3873
- expect.objectContaining({
3874
- code: 'untrusted-context-in-field',
3875
- path: ['jobs', 'build', 'steps', 0, 'run'],
3876
- details: expect.objectContaining({
3877
- field: 'run',
3878
- rejectedRoots: ['executions'],
3879
- }),
3880
- }),
3881
- ]);
4296
+ });
3882
4297
  });
3883
4298
 
3884
4299
  it.each([
@@ -3994,7 +4409,7 @@ describe('normalizeWorkflowDocument', () => {
3994
4409
  });
3995
4410
  });
3996
4411
 
3997
- it('does not apply availability checks to job names', () => {
4412
+ it('rejects dynamic job names in favor of execution_name', () => {
3998
4413
  const document: WorkflowDocument = {
3999
4414
  name: 'job display context',
4000
4415
  jobs: {
@@ -4010,16 +4425,19 @@ describe('normalizeWorkflowDocument', () => {
4010
4425
  },
4011
4426
  };
4012
4427
 
4013
- const model = normalizeWorkflowDocument(document);
4014
-
4015
- expect(model.jobs[0]?.name?.[0]).toMatchObject({
4016
- kind: 'deferred',
4017
- roots: ['execution'],
4018
- });
4019
- expect(model.jobs[1]?.name?.[0]).toMatchObject({
4020
- kind: 'deferred',
4021
- roots: ['execution'],
4022
- });
4428
+ const error = expectInvalid(document);
4429
+ expect(error.issues).toEqual([
4430
+ expect.objectContaining({
4431
+ code: 'invalid-interpolation-template',
4432
+ message: 'Job name must be literal. Move runtime interpolation to execution_name.',
4433
+ path: ['jobs', 'build', 'name'],
4434
+ }),
4435
+ expect.objectContaining({
4436
+ code: 'invalid-interpolation-template',
4437
+ message: 'Job name must be literal. Move runtime interpolation to execution_name.',
4438
+ path: ['jobs', 'review', 'name'],
4439
+ }),
4440
+ ]);
4023
4441
  });
4024
4442
 
4025
4443
  it('does not apply availability checks to workflow-level or job-level env', () => {
@@ -4149,7 +4567,7 @@ describe('normalizeWorkflowDocument', () => {
4149
4567
  });
4150
4568
  });
4151
4569
 
4152
- it('allows execution events in untrusted-capable fields', () => {
4570
+ it('rejects execution events in static job names', () => {
4153
4571
  const document: WorkflowDocument = {
4154
4572
  name: 'execution events allowed',
4155
4573
  jobs: {
@@ -4161,25 +4579,13 @@ describe('normalizeWorkflowDocument', () => {
4161
4579
  },
4162
4580
  };
4163
4581
 
4164
- const model = normalizeWorkflowDocument(document);
4165
-
4166
- expect(model.jobs[0]?.name?.[1]).toMatchObject({
4167
- kind: 'deferred',
4168
- expression: {check: 'typed'},
4169
- roots: ['execution'],
4170
- });
4171
- expect(model.jobs[0]?.steps[0]).toMatchObject({
4172
- kind: 'agent',
4173
- templates: {
4174
- prompt: [
4175
- {
4176
- kind: 'deferred',
4177
- expression: {check: 'typed'},
4178
- roots: ['execution'],
4179
- },
4180
- ],
4181
- },
4182
- });
4582
+ const error = expectInvalid(document);
4583
+ expect(error.issues).toEqual([
4584
+ expect.objectContaining({
4585
+ code: 'invalid-interpolation-template',
4586
+ path: ['jobs', 'build', 'name'],
4587
+ }),
4588
+ ]);
4183
4589
  });
4184
4590
 
4185
4591
  it('allows untrusted context in env, prompt, and step names', () => {
@@ -4216,9 +4622,9 @@ describe('normalizeWorkflowDocument', () => {
4216
4622
  it.each([
4217
4623
  ['model', {model: interpolation('event.model'), prompt: 'Fix it.'}, 'event'],
4218
4624
  ['provider', {provider: interpolation('inputs.provider'), prompt: 'Fix it.'}, 'inputs'],
4219
- ] as const)('rejects untrusted agent %s interpolation', (_field, step, root) => {
4625
+ ] as const)('allows external context in agent %s interpolation', (field, step, root) => {
4220
4626
  const document: WorkflowDocument = {
4221
- name: 'unsafe agent field',
4627
+ name: 'external agent field',
4222
4628
  jobs: {
4223
4629
  fix: {
4224
4630
  steps: [step],
@@ -4226,14 +4632,77 @@ describe('normalizeWorkflowDocument', () => {
4226
4632
  },
4227
4633
  };
4228
4634
 
4229
- const error = expectInvalid(document);
4635
+ const model = normalizeWorkflowDocument(document);
4230
4636
 
4231
- expect(error.issues).toEqual([
4232
- expect.objectContaining({
4233
- code: 'untrusted-context-in-field',
4234
- details: expect.objectContaining({rejectedRoots: [root]}),
4235
- }),
4236
- ]);
4637
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4638
+ kind: 'agent',
4639
+ templates: {
4640
+ [field]: [{kind: 'deferred', roots: [root]}],
4641
+ },
4642
+ });
4643
+ });
4644
+
4645
+ it.each([
4646
+ ['model', {model: interpolation('steps.collect.outputs.value'), prompt: 'Fix it.'}, 'steps'],
4647
+ [
4648
+ 'provider',
4649
+ {provider: interpolation('steps.collect.outputs.value'), prompt: 'Fix it.'},
4650
+ 'steps',
4651
+ ],
4652
+ [
4653
+ 'model',
4654
+ {
4655
+ model: interpolation('steps.filter(s, true).map(s, s.outputs.value)[0]'),
4656
+ prompt: 'Fix it.',
4657
+ },
4658
+ 'steps',
4659
+ ],
4660
+ [
4661
+ 'provider',
4662
+ {
4663
+ provider: interpolation('steps.filter(s, true).map(s, s.outputs.value)[0]'),
4664
+ prompt: 'Fix it.',
4665
+ },
4666
+ 'steps',
4667
+ ],
4668
+ [
4669
+ 'model',
4670
+ {model: interpolation('execution.events[0].data.body'), prompt: 'Fix it.'},
4671
+ 'execution',
4672
+ ],
4673
+ [
4674
+ 'provider',
4675
+ {provider: interpolation('execution.events[0].data.body'), prompt: 'Fix it.'},
4676
+ 'execution',
4677
+ ],
4678
+ [
4679
+ 'model',
4680
+ {model: interpolation('execution["events"][0].data.body'), prompt: 'Fix it.'},
4681
+ 'execution',
4682
+ ],
4683
+ [
4684
+ 'provider',
4685
+ {provider: interpolation('execution["events"][0].data.body'), prompt: 'Fix it.'},
4686
+ 'execution',
4687
+ ],
4688
+ ] as const)('allows path-specific external context in agent %s interpolation', (field, step, root) => {
4689
+ const document: WorkflowDocument = {
4690
+ name: 'path-specific external agent field',
4691
+ jobs: {
4692
+ fix: {
4693
+ steps: [{key: 'collect', run: 'collect'}, step],
4694
+ },
4695
+ },
4696
+ };
4697
+
4698
+ const model = normalizeWorkflowDocument(document);
4699
+
4700
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
4701
+ kind: 'agent',
4702
+ templates: {
4703
+ [field]: [{kind: 'deferred', roots: [root]}],
4704
+ },
4705
+ });
4237
4706
  });
4238
4707
 
4239
4708
  it.each([
@@ -4298,6 +4767,77 @@ describe('normalizeWorkflowDocument', () => {
4298
4767
  });
4299
4768
  });
4300
4769
 
4770
+ it('does not add provider catalog issues for invalid provider interpolation', () => {
4771
+ const document: WorkflowDocument = {
4772
+ name: 'invalid provider interpolation',
4773
+ jobs: {
4774
+ fix: {
4775
+ steps: [{harness: 'claude', provider: interpolation('foo.bar'), prompt: 'Fix it.'}],
4776
+ },
4777
+ },
4778
+ };
4779
+
4780
+ const error = expectInvalid(document);
4781
+
4782
+ expect(error.issues).toEqual([
4783
+ expect.objectContaining({
4784
+ code: 'unknown-interpolation-context',
4785
+ path: ['jobs', 'fix', 'steps', 0, 'provider'],
4786
+ }),
4787
+ ]);
4788
+ });
4789
+
4790
+ it('skips static model catalog validation when model is interpolated', () => {
4791
+ const document: WorkflowDocument = {
4792
+ name: 'templated model',
4793
+ jobs: {
4794
+ fix: {
4795
+ steps: [
4796
+ {
4797
+ provider: 'anthropic',
4798
+ model: interpolation('run.name'),
4799
+ prompt: 'Fix it.',
4800
+ },
4801
+ ],
4802
+ },
4803
+ },
4804
+ };
4805
+
4806
+ const model = normalizeWorkflowDocument(document);
4807
+
4808
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4809
+ kind: 'agent',
4810
+ templates: {model: [{kind: 'deferred', roots: ['run']}]},
4811
+ });
4812
+ });
4813
+
4814
+ it('does not add model catalog issues for invalid model interpolation', () => {
4815
+ const document: WorkflowDocument = {
4816
+ name: 'invalid model interpolation',
4817
+ jobs: {
4818
+ fix: {
4819
+ steps: [
4820
+ {
4821
+ harness: 'pi',
4822
+ provider: 'anthropic',
4823
+ model: interpolation('foo.bar'),
4824
+ prompt: 'Fix it.',
4825
+ },
4826
+ ],
4827
+ },
4828
+ },
4829
+ };
4830
+
4831
+ const error = expectInvalid(document);
4832
+
4833
+ expect(error.issues).toEqual([
4834
+ expect.objectContaining({
4835
+ code: 'unknown-interpolation-context',
4836
+ path: ['jobs', 'fix', 'steps', 0, 'model'],
4837
+ }),
4838
+ ]);
4839
+ });
4840
+
4301
4841
  it('still validates literal providers through the catalog', () => {
4302
4842
  const document: WorkflowDocument = {
4303
4843
  name: 'literal provider',
@@ -4427,7 +4967,7 @@ describe('normalizeWorkflowDocument', () => {
4427
4967
  ]);
4428
4968
  });
4429
4969
 
4430
- it('uses syntax mode for mixed open contexts but still enforces minimum trust', () => {
4970
+ it('uses syntax mode for mixed open contexts without a trust restriction', () => {
4431
4971
  const envDocument: WorkflowDocument = {
4432
4972
  name: 'mixed env',
4433
4973
  env: {MIXED: interpolation('run.nope + event.x')},
@@ -4447,22 +4987,25 @@ describe('normalizeWorkflowDocument', () => {
4447
4987
  };
4448
4988
 
4449
4989
  const model = normalizeWorkflowDocument(envDocument);
4450
- const error = expectInvalid(runDocument);
4990
+ const runModel = normalizeWorkflowDocument(runDocument);
4451
4991
 
4452
4992
  expect(model.templates?.env?.MIXED?.[0]).toMatchObject({
4453
4993
  kind: 'deferred',
4454
4994
  expression: {check: 'syntax'},
4455
4995
  roots: expect.arrayContaining(['run', 'event']),
4456
4996
  });
4457
- expect(error.issues).toEqual([
4458
- expect.objectContaining({
4459
- code: 'untrusted-context-in-field',
4460
- path: ['jobs', 'build', 'steps', 0, 'run'],
4461
- }),
4462
- ]);
4997
+ expect(runModel.jobs[0]?.steps[0]).toMatchObject({
4998
+ kind: 'run',
4999
+ templates: {
5000
+ command: [
5001
+ {kind: 'literal', value: 'echo '},
5002
+ {kind: 'deferred', roots: expect.arrayContaining(['run', 'event'])},
5003
+ ],
5004
+ },
5005
+ });
4463
5006
  });
4464
5007
 
4465
- it('reports one trust issue for a multi-segment run field with one untrusted segment', () => {
5008
+ it('allows multi-segment run fields with external context', () => {
4466
5009
  const document: WorkflowDocument = {
4467
5010
  name: 'multi segment run',
4468
5011
  jobs: {
@@ -4472,14 +5015,18 @@ describe('normalizeWorkflowDocument', () => {
4472
5015
  },
4473
5016
  };
4474
5017
 
4475
- const error = expectInvalid(document);
5018
+ const model = normalizeWorkflowDocument(document);
4476
5019
 
4477
- expect(error.issues).toEqual([
4478
- expect.objectContaining({
4479
- code: 'untrusted-context-in-field',
4480
- details: expect.objectContaining({rejectedRoots: ['event']}),
4481
- }),
4482
- ]);
5020
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
5021
+ kind: 'run',
5022
+ templates: {
5023
+ command: [
5024
+ {kind: 'deferred', roots: ['run']},
5025
+ {kind: 'literal', value: '-'},
5026
+ {kind: 'deferred', roots: ['event']},
5027
+ ],
5028
+ },
5029
+ });
4483
5030
  });
4484
5031
 
4485
5032
  it('does not parse templates in non-string env values', () => {