@shipfox/api-definitions 11.0.0 → 12.2.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 (174) hide show
  1. package/.turbo/turbo-build.log +13 -12
  2. package/CHANGELOG.md +106 -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 +5 -1
  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/errors.d.ts +2 -2
  16. package/dist/core/errors.d.ts.map +1 -1
  17. package/dist/core/integrations.d.ts +1 -1
  18. package/dist/core/parse-definition.d.ts +9 -7
  19. package/dist/core/parse-definition.d.ts.map +1 -1
  20. package/dist/core/parse-definition.js +14 -2
  21. package/dist/core/parse-definition.js.map +1 -1
  22. package/dist/core/sync-definitions.d.ts +2 -0
  23. package/dist/core/sync-definitions.d.ts.map +1 -1
  24. package/dist/core/sync-definitions.js +6 -5
  25. package/dist/core/sync-definitions.js.map +1 -1
  26. package/dist/core/validate-definition.d.ts +9 -5
  27. package/dist/core/validate-definition.d.ts.map +1 -1
  28. package/dist/core/validate-definition.js +23 -2
  29. package/dist/core/validate-definition.js.map +1 -1
  30. package/dist/core/workflow-model/index.d.ts +1 -1
  31. package/dist/core/workflow-model/index.d.ts.map +1 -1
  32. package/dist/core/workflow-model/index.js.map +1 -1
  33. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +3 -1
  34. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -1
  35. package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -1
  36. package/dist/core/workflow-model/normalize-if-condition.d.ts +0 -1
  37. package/dist/core/workflow-model/normalize-if-condition.d.ts.map +1 -1
  38. package/dist/core/workflow-model/normalize-if-condition.js +0 -1
  39. package/dist/core/workflow-model/normalize-if-condition.js.map +1 -1
  40. package/dist/core/workflow-model/normalize-job-checkout.d.ts +15 -3
  41. package/dist/core/workflow-model/normalize-job-checkout.d.ts.map +1 -1
  42. package/dist/core/workflow-model/normalize-job-checkout.js +83 -4
  43. package/dist/core/workflow-model/normalize-job-checkout.js.map +1 -1
  44. package/dist/core/workflow-model/normalize-job-listening.js +0 -1
  45. package/dist/core/workflow-model/normalize-job-listening.js.map +1 -1
  46. package/dist/core/workflow-model/normalize-job-success.d.ts.map +1 -1
  47. package/dist/core/workflow-model/normalize-job-success.js +0 -1
  48. package/dist/core/workflow-model/normalize-job-success.js.map +1 -1
  49. package/dist/core/workflow-model/normalize-jobs.d.ts.map +1 -1
  50. package/dist/core/workflow-model/normalize-jobs.js +315 -74
  51. package/dist/core/workflow-model/normalize-jobs.js.map +1 -1
  52. package/dist/core/workflow-model/normalize-step-gate.js +0 -1
  53. package/dist/core/workflow-model/normalize-step-gate.js.map +1 -1
  54. package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -1
  55. package/dist/core/workflow-model/normalize-triggers.js +0 -1
  56. package/dist/core/workflow-model/normalize-triggers.js.map +1 -1
  57. package/dist/core/workflow-model/normalize-workflow-document.d.ts +3 -0
  58. package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -1
  59. package/dist/core/workflow-model/normalize-workflow-document.js +34 -4
  60. package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -1
  61. package/dist/core/workflow-model/parse-interpolation-field.d.ts +1 -1
  62. package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -1
  63. package/dist/core/workflow-model/parse-interpolation-field.js +27 -57
  64. package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -1
  65. package/dist/core/workflow-model/validate-literal-name.d.ts +12 -0
  66. package/dist/core/workflow-model/validate-literal-name.d.ts.map +1 -0
  67. package/dist/core/workflow-model/validate-literal-name.js +36 -0
  68. package/dist/core/workflow-model/validate-literal-name.js.map +1 -0
  69. package/dist/core/workflow-model/validate-predicate-expression.d.ts +1 -2
  70. package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -1
  71. package/dist/core/workflow-model/validate-predicate-expression.js +31 -90
  72. package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -1
  73. package/dist/core/workflow-model/validation-issue.d.ts +2 -1
  74. package/dist/core/workflow-model/validation-issue.d.ts.map +1 -1
  75. package/dist/core/workflow-model/validation-issue.js +8 -2
  76. package/dist/core/workflow-model/validation-issue.js.map +1 -1
  77. package/dist/core/workflow-model/workflow-field-label.d.ts.map +1 -1
  78. package/dist/core/workflow-model/workflow-field-label.js +16 -2
  79. package/dist/core/workflow-model/workflow-field-label.js.map +1 -1
  80. package/dist/core/workflow-yaml/invalid-workflow-yaml-error.d.ts.map +1 -1
  81. package/dist/db/db.d.ts +48 -10
  82. package/dist/db/db.d.ts.map +1 -1
  83. package/dist/db/schema/definitions.d.ts +1 -1
  84. package/dist/db/schema/outbox.d.ts +1 -1
  85. package/dist/db/schema/sync-states.d.ts +22 -3
  86. package/dist/db/schema/sync-states.d.ts.map +1 -1
  87. package/dist/db/schema/sync-states.js +3 -1
  88. package/dist/db/schema/sync-states.js.map +1 -1
  89. package/dist/db/sync-states.d.ts +2 -1
  90. package/dist/db/sync-states.d.ts.map +1 -1
  91. package/dist/db/sync-states.js +4 -0
  92. package/dist/db/sync-states.js.map +1 -1
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +3 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/presentation/dto/definition.d.ts.map +1 -1
  97. package/dist/presentation/dto/definition.js +2 -1
  98. package/dist/presentation/dto/definition.js.map +1 -1
  99. package/dist/presentation/routes/project-access.d.ts +1 -0
  100. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  101. package/dist/presentation/routes/validate-definition.d.ts.map +1 -1
  102. package/dist/presentation/routes/validate-definition.js +5 -3
  103. package/dist/presentation/routes/validate-definition.js.map +1 -1
  104. package/dist/presentation/subscribers/index.d.ts +1 -0
  105. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  106. package/dist/presentation/subscribers/index.js +1 -0
  107. package/dist/presentation/subscribers/index.js.map +1 -1
  108. package/dist/presentation/subscribers/on-integration-connection-available.d.ts +5 -0
  109. package/dist/presentation/subscribers/on-integration-connection-available.d.ts.map +1 -0
  110. package/dist/presentation/subscribers/on-integration-connection-available.js +32 -0
  111. package/dist/presentation/subscribers/on-integration-connection-available.js.map +1 -0
  112. package/dist/presentation/subscribers/start-definition-sync.d.ts +1 -0
  113. package/dist/presentation/subscribers/start-definition-sync.d.ts.map +1 -1
  114. package/dist/presentation/subscribers/start-definition-sync.js +30 -17
  115. package/dist/presentation/subscribers/start-definition-sync.js.map +1 -1
  116. package/dist/temporal/activities/sync-activities.d.ts +6 -2
  117. package/dist/temporal/activities/sync-activities.d.ts.map +1 -1
  118. package/dist/temporal/activities/sync-activities.js +8 -1
  119. package/dist/temporal/activities/sync-activities.js.map +1 -1
  120. package/dist/temporal/workflows/definition-sync.js +4 -1
  121. package/dist/temporal/workflows/definition-sync.js.map +1 -1
  122. package/dist/temporal/workflows/index.bundle.js +9029 -3
  123. package/dist/tsconfig.test.tsbuildinfo +1 -1
  124. package/drizzle/0002_lumpy_ben_parker.sql +1 -0
  125. package/drizzle/meta/0002_snapshot.json +534 -0
  126. package/drizzle/meta/_journal.json +7 -0
  127. package/package.json +17 -17
  128. package/src/core/entities/index.ts +1 -0
  129. package/src/core/entities/sync-state.ts +23 -0
  130. package/src/core/entities/validation-warning.ts +5 -0
  131. package/src/core/parse-definition-warnings.test.ts +33 -0
  132. package/src/core/parse-definition.test.ts +7 -1
  133. package/src/core/parse-definition.ts +28 -10
  134. package/src/core/sync-definitions.test.ts +39 -0
  135. package/src/core/sync-definitions.ts +14 -4
  136. package/src/core/validate-definition.test.ts +128 -0
  137. package/src/core/validate-definition.ts +39 -8
  138. package/src/core/workflow-model/README.md +2 -2
  139. package/src/core/workflow-model/index.ts +1 -0
  140. package/src/core/workflow-model/invalid-workflow-model-error.ts +8 -3
  141. package/src/core/workflow-model/normalize-if-condition.ts +0 -2
  142. package/src/core/workflow-model/normalize-job-checkout.ts +105 -8
  143. package/src/core/workflow-model/normalize-job-listening.ts +0 -1
  144. package/src/core/workflow-model/normalize-job-success.ts +0 -1
  145. package/src/core/workflow-model/normalize-jobs.ts +335 -90
  146. package/src/core/workflow-model/normalize-step-gate.ts +0 -1
  147. package/src/core/workflow-model/normalize-triggers.ts +0 -1
  148. package/src/core/workflow-model/normalize-workflow-document.test.ts +876 -202
  149. package/src/core/workflow-model/normalize-workflow-document.ts +29 -4
  150. package/src/core/workflow-model/parse-interpolation-field.ts +50 -79
  151. package/src/core/workflow-model/validate-literal-name.ts +64 -0
  152. package/src/core/workflow-model/validate-predicate-expression.test.ts +153 -15
  153. package/src/core/workflow-model/validate-predicate-expression.ts +52 -120
  154. package/src/core/workflow-model/validation-issue.ts +4 -0
  155. package/src/core/workflow-model/workflow-field-label.ts +16 -2
  156. package/src/core/workflow-yaml/README.md +2 -2
  157. package/src/db/schema/sync-states.ts +4 -1
  158. package/src/db/sync-states.test.ts +57 -0
  159. package/src/db/sync-states.ts +6 -0
  160. package/src/index.ts +12 -1
  161. package/src/presentation/dto/definition.ts +1 -0
  162. package/src/presentation/routes/list-definitions.test.ts +1 -0
  163. package/src/presentation/routes/validate-definition.test.ts +1 -0
  164. package/src/presentation/routes/validate-definition.ts +7 -1
  165. package/src/presentation/subscribers/index.ts +1 -0
  166. package/src/presentation/subscribers/on-integration-connection-available.test.ts +132 -0
  167. package/src/presentation/subscribers/on-integration-connection-available.ts +44 -0
  168. package/src/presentation/subscribers/start-definition-sync.ts +35 -19
  169. package/src/temporal/activities/sync-activities.test.ts +16 -0
  170. package/src/temporal/activities/sync-activities.ts +18 -3
  171. package/src/temporal/workflows/definition-sync.ts +1 -1
  172. package/test/agent-validation-catalog.ts +17 -0
  173. package/test/fixtures/valid-listening-job.yml +2 -1
  174. package/tsconfig.build.tsbuildinfo +1 -1
@@ -69,16 +69,219 @@ const integrationValidationContext = {
69
69
  ],
70
70
  },
71
71
  ],
72
+ [
73
+ 'jira',
74
+ {
75
+ selectors: [
76
+ {token: 'get_issue', kind: 'standalone', sensitivity: 'read', sensitive: false},
77
+ {token: 'add_comment', kind: 'standalone', sensitivity: 'write', sensitive: false},
78
+ ],
79
+ },
80
+ ],
72
81
  ]),
73
82
  workspaceConnectionSnapshot: new Map([
74
83
  ['github-main', {id: 'conn_1', provider: 'github', capabilities: ['agent_tools']}],
75
84
  ['sentry-main', {id: 'conn_2', provider: 'sentry', capabilities: []}],
76
85
  ['linear-main', {id: 'conn_3', provider: 'linear', capabilities: ['agent_tools']}],
86
+ ['jira-main', {id: 'conn_4', provider: 'jira', capabilities: ['agent_tools']}],
77
87
  ]),
78
88
  defaultConnectionSlug: 'github-main',
79
89
  } satisfies IntegrationValidationContext;
80
90
 
81
91
  describe('normalizeWorkflowDocument', () => {
92
+ it('rejects interpolated workflow names in favor of run_name', () => {
93
+ const error = expectInvalid({
94
+ name: interpolation('inputs.environment'),
95
+ jobs: {build: {steps: [{run: 'build'}]}},
96
+ });
97
+
98
+ expect(error.issues).toEqual([
99
+ expect.objectContaining({
100
+ code: 'invalid-interpolation-template',
101
+ message: 'Workflow name must be literal. Move runtime interpolation to run_name.',
102
+ path: ['name'],
103
+ }),
104
+ ]);
105
+ });
106
+
107
+ it('rejects unterminated workflow-name templates with the migration guidance', () => {
108
+ const error = expectInvalid({
109
+ name: '$' + '{{ unterminated',
110
+ jobs: {build: {steps: [{run: 'build'}]}},
111
+ });
112
+
113
+ expect(error.issues).toEqual([
114
+ expect.objectContaining({
115
+ code: 'invalid-interpolation-template',
116
+ message: 'Workflow name must be literal. Move runtime interpolation to run_name.',
117
+ path: ['name'],
118
+ details: expect.objectContaining({reason: 'unterminated opener'}),
119
+ }),
120
+ ]);
121
+ });
122
+
123
+ it('rejects unterminated job-name templates with the migration guidance', () => {
124
+ const error = expectInvalid({
125
+ name: 'Deploy',
126
+ jobs: {
127
+ build: {name: '$' + '{{ unterminated', steps: [{run: 'build'}]},
128
+ },
129
+ });
130
+
131
+ expect(error.issues).toEqual([
132
+ expect.objectContaining({
133
+ code: 'invalid-interpolation-template',
134
+ message: 'Job name must be literal. Move runtime interpolation to execution_name.',
135
+ path: ['jobs', 'build', 'name'],
136
+ details: expect.objectContaining({reason: 'unterminated opener'}),
137
+ }),
138
+ ]);
139
+ });
140
+
141
+ it('normalizes workflow run and job execution name templates', () => {
142
+ const model = normalizeWorkflowDocument({
143
+ name: 'Deploy application',
144
+ run_name: `Deploy ${interpolation('inputs.environment')}`,
145
+ jobs: {
146
+ deploy: {
147
+ name: 'Deploy',
148
+ execution_name: `Deploy ${interpolation('event.environment')}`,
149
+ steps: [{run: 'deploy'}],
150
+ },
151
+ },
152
+ });
153
+
154
+ expect(model.runName).toMatchObject([
155
+ {kind: 'literal', value: 'Deploy '},
156
+ {kind: 'deferred', roots: ['inputs'], fillTarget: 'run-creation'},
157
+ ]);
158
+ expect(model.jobs[0]?.executionName).toMatchObject([
159
+ {kind: 'literal', value: 'Deploy '},
160
+ {kind: 'deferred', roots: ['event'], fillTarget: 'execution-creation'},
161
+ ]);
162
+ });
163
+
164
+ it('preserves literal workflow run and job execution names', () => {
165
+ const model = normalizeWorkflowDocument({
166
+ name: 'Workflow',
167
+ run_name: 'Static run name',
168
+ jobs: {
169
+ build: {
170
+ execution_name: 'Static execution name',
171
+ steps: [{run: 'build'}],
172
+ },
173
+ },
174
+ });
175
+
176
+ expect(model.runName).toEqual([{kind: 'literal', value: 'Static run name'}]);
177
+ expect(model.jobs[0]?.executionName).toEqual([
178
+ {kind: 'literal', value: 'Static execution name'},
179
+ ]);
180
+ });
181
+
182
+ it('unescapes literal workflow and job names', () => {
183
+ const escapedName = `$${interpolation('inputs.environment')}`;
184
+ const model = normalizeWorkflowDocument({
185
+ name: escapedName,
186
+ jobs: {
187
+ build: {name: escapedName, steps: [{run: 'build'}]},
188
+ },
189
+ });
190
+
191
+ expect(model.name).toBe(interpolation('inputs.environment'));
192
+ expect(model.jobs[0]?.name).toBe(interpolation('inputs.environment'));
193
+ });
194
+
195
+ it.each([
196
+ [
197
+ 'run_name',
198
+ {
199
+ name: 'Workflow',
200
+ run_name: interpolation('steps.build.outputs.name'),
201
+ jobs: {build: {steps: [{run: 'build'}]}},
202
+ },
203
+ ],
204
+ [
205
+ 'execution_name',
206
+ {
207
+ name: 'Workflow',
208
+ jobs: {
209
+ build: {
210
+ execution_name: interpolation('steps.build.outputs.name'),
211
+ steps: [{run: 'build'}],
212
+ },
213
+ },
214
+ },
215
+ ],
216
+ ] as const)('rejects unavailable context in %s', (_field, document) => {
217
+ const error = expectInvalid(document as unknown as WorkflowDocument);
218
+ expect(error.issues).toEqual([
219
+ expect.objectContaining({code: 'context-unavailable-at-fill-site'}),
220
+ ]);
221
+ });
222
+
223
+ it('allows computed access to unrelated dynamic-name context fields', () => {
224
+ const model = normalizeWorkflowDocument({
225
+ name: 'Workflow',
226
+ run_name: interpolation('run["id"]'),
227
+ jobs: {
228
+ build: {
229
+ execution_name: interpolation('execution["status"]'),
230
+ steps: [{run: 'build'}],
231
+ },
232
+ },
233
+ });
234
+
235
+ expect(model.runName).toMatchObject([{kind: 'deferred', roots: ['run']}]);
236
+ expect(model.jobs[0]?.executionName).toMatchObject([{kind: 'deferred', roots: ['execution']}]);
237
+ });
238
+
239
+ it.each([
240
+ [
241
+ 'run_name',
242
+ {
243
+ name: 'Workflow',
244
+ run_name: interpolation('run.name'),
245
+ jobs: {build: {steps: [{run: 'build'}]}},
246
+ },
247
+ ],
248
+ [
249
+ 'run_name bracket access',
250
+ {
251
+ name: 'Workflow',
252
+ run_name: interpolation('run["name"]'),
253
+ jobs: {build: {steps: [{run: 'build'}]}},
254
+ },
255
+ ],
256
+ [
257
+ 'execution_name',
258
+ {
259
+ name: 'Workflow',
260
+ jobs: {
261
+ build: {
262
+ execution_name: interpolation('execution.name'),
263
+ steps: [{run: 'build'}],
264
+ },
265
+ },
266
+ },
267
+ ],
268
+ [
269
+ 'execution_name bracket access',
270
+ {
271
+ name: 'Workflow',
272
+ jobs: {
273
+ build: {
274
+ execution_name: interpolation('execution["name"]'),
275
+ steps: [{run: 'build'}],
276
+ },
277
+ },
278
+ },
279
+ ],
280
+ ] as const)('rejects dynamic-name self-reference in %s', (_field, document) => {
281
+ const error = expectInvalid(document as unknown as WorkflowDocument);
282
+ expect(error.issues).toEqual([expect.objectContaining({code: 'dynamic-name-self-reference'})]);
283
+ });
284
+
82
285
  it('normalizes a workflow document into a WorkflowModel', () => {
83
286
  const document: WorkflowDocument = {
84
287
  name: 'simple build',
@@ -154,7 +357,7 @@ describe('normalizeWorkflowDocument', () => {
154
357
  {
155
358
  key: 'review',
156
359
  harness: 'claude',
157
- model: 'gpt-5.5-pro',
360
+ model: 'claude-opus-4-8',
158
361
  provider: 'anthropic',
159
362
  prompt: 'Review the fix.',
160
363
  thinking: 'low',
@@ -454,6 +657,56 @@ describe('normalizeWorkflowDocument', () => {
454
657
  });
455
658
  });
456
659
 
660
+ it('requires allow_write for Jira write tools', () => {
661
+ const document: WorkflowDocument = {
662
+ name: 'jira write integrations',
663
+ jobs: {
664
+ fix: {
665
+ steps: [
666
+ {
667
+ prompt: 'Comment on the issue.',
668
+ integrations: [{connection: 'jira-main', include: ['add_comment']}],
669
+ },
670
+ ],
671
+ },
672
+ },
673
+ };
674
+
675
+ const error = expectInvalid(document, {integrationValidationContext});
676
+
677
+ expect(error.issues).toMatchObject([
678
+ {
679
+ code: 'integration-write-not-allowed',
680
+ details: {tokens: ['add_comment']},
681
+ },
682
+ ]);
683
+ });
684
+
685
+ it('accepts Jira write tools when allow_write is true', () => {
686
+ const document: WorkflowDocument = {
687
+ name: 'jira write integrations',
688
+ jobs: {
689
+ fix: {
690
+ steps: [
691
+ {
692
+ prompt: 'Comment on the issue.',
693
+ integrations: [
694
+ {connection: 'jira-main', include: ['add_comment'], allow_write: true},
695
+ ],
696
+ },
697
+ ],
698
+ },
699
+ },
700
+ };
701
+
702
+ const model = normalizeWorkflowDocument(document, {integrationValidationContext});
703
+
704
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
705
+ kind: 'agent',
706
+ integrations: [{connection: 'jira-main', include: ['add_comment'], allowWrite: true}],
707
+ });
708
+ });
709
+
457
710
  it('reports connection failures before token checks', () => {
458
711
  const document: WorkflowDocument = {
459
712
  name: 'missing connection',
@@ -538,6 +791,7 @@ describe('normalizeWorkflowDocument', () => {
538
791
  {
539
792
  harness: 'pi',
540
793
  provider: 'workspace-openai-compatible',
794
+ model: 'workspace-model',
541
795
  prompt: 'Fix it.',
542
796
  },
543
797
  ],
@@ -551,6 +805,80 @@ describe('normalizeWorkflowDocument', () => {
551
805
  kind: 'agent',
552
806
  harness: 'pi',
553
807
  provider: 'workspace-openai-compatible',
808
+ model: 'workspace-model',
809
+ });
810
+ });
811
+
812
+ it('reports unsupported literal models', () => {
813
+ const document: WorkflowDocument = {
814
+ name: 'agent build',
815
+ jobs: {
816
+ fix: {
817
+ steps: [{harness: 'pi', provider: 'anthropic', model: 'not-a-model', prompt: 'Fix it.'}],
818
+ },
819
+ },
820
+ };
821
+
822
+ const error = expectInvalid(document);
823
+
824
+ expect(error.issues).toEqual([
825
+ {
826
+ code: 'invalid-model',
827
+ message:
828
+ 'Agent model "not-a-model" is not available for harness "pi" and provider "anthropic".',
829
+ path: ['jobs', 'fix', 'steps', 0, 'model'],
830
+ details: {harness: 'pi', provider: 'anthropic', model: 'not-a-model'},
831
+ },
832
+ ]);
833
+ });
834
+
835
+ it('allows custom providers without an explicit harness', () => {
836
+ const document: WorkflowDocument = {
837
+ name: 'agent build',
838
+ jobs: {
839
+ fix: {
840
+ steps: [
841
+ {
842
+ provider: 'workspace-openai-compatible',
843
+ model: 'workspace-model',
844
+ prompt: 'Fix it.',
845
+ },
846
+ ],
847
+ },
848
+ },
849
+ };
850
+
851
+ const model = normalizeWorkflowDocument(document);
852
+
853
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
854
+ kind: 'agent',
855
+ provider: 'workspace-openai-compatible',
856
+ model: 'workspace-model',
857
+ });
858
+ });
859
+
860
+ it('defers harness-specific validation when harness is omitted', () => {
861
+ const document: WorkflowDocument = {
862
+ name: 'workspace-default harness',
863
+ jobs: {
864
+ fix: {
865
+ steps: [
866
+ {
867
+ provider: 'anthropic',
868
+ model: 'claude-sonnet-4-6',
869
+ prompt: 'Fix it.',
870
+ },
871
+ ],
872
+ },
873
+ },
874
+ };
875
+
876
+ const model = normalizeWorkflowDocument(document);
877
+
878
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
879
+ kind: 'agent',
880
+ provider: 'anthropic',
881
+ model: 'claude-sonnet-4-6',
554
882
  });
555
883
  });
556
884
 
@@ -743,6 +1071,29 @@ describe('normalizeWorkflowDocument', () => {
743
1071
  });
744
1072
  });
745
1073
 
1074
+ it('defers an interpolated thinking level to dispatch', () => {
1075
+ const document: WorkflowDocument = {
1076
+ name: 'agent build',
1077
+ jobs: {
1078
+ fix: {
1079
+ steps: [
1080
+ {
1081
+ harness: 'claude',
1082
+ prompt: 'Fix it.',
1083
+ thinking: workflowInterpolation('vars.THINKING'),
1084
+ },
1085
+ ],
1086
+ },
1087
+ },
1088
+ };
1089
+
1090
+ const model = normalizeWorkflowDocument(document);
1091
+ const step = model.jobs[0]?.steps[0];
1092
+
1093
+ if (step?.kind !== 'agent') throw new Error('Expected an agent step');
1094
+ expect(step.templates?.thinking).toMatchObject([{kind: 'deferred', roots: ['vars']}]);
1095
+ });
1096
+
746
1097
  it('normalizes job success expressions and execution timeouts', () => {
747
1098
  const document: WorkflowDocument = {
748
1099
  name: 'job controls',
@@ -831,6 +1182,144 @@ describe('normalizeWorkflowDocument', () => {
831
1182
  expect(model.jobs[0]?.checkout).toEqual(DEFAULT_JOB_CHECKOUT);
832
1183
  });
833
1184
 
1185
+ it('preserves the job checkout opt-out in the model', () => {
1186
+ const model = normalizeWorkflowDocument({
1187
+ name: 'checkout disabled',
1188
+ jobs: {
1189
+ build: {
1190
+ checkout: false,
1191
+ steps: [{run: 'npm test'}],
1192
+ },
1193
+ },
1194
+ });
1195
+
1196
+ expect(model.jobs[0]?.checkout).toBe(false);
1197
+ });
1198
+
1199
+ it('normalizes checkout-step targets from earlier step outputs', () => {
1200
+ const model = normalizeWorkflowDocument({
1201
+ name: 'templated checkout step',
1202
+ jobs: {
1203
+ build: {
1204
+ steps: [
1205
+ {
1206
+ key: 'route',
1207
+ run: 'echo route',
1208
+ outputs: {
1209
+ connection: {type: 'string'},
1210
+ repository: {type: 'string'},
1211
+ ref: {type: 'string'},
1212
+ path: {type: 'string'},
1213
+ },
1214
+ },
1215
+ {
1216
+ key: 'checkout',
1217
+ checkout: {
1218
+ connection: interpolation('inputs.connection'),
1219
+ repository: interpolation('steps.route.outputs.repository'),
1220
+ ref: interpolation('steps.route.outputs.ref'),
1221
+ path: interpolation('steps.route.outputs.path'),
1222
+ },
1223
+ },
1224
+ ],
1225
+ },
1226
+ },
1227
+ });
1228
+
1229
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
1230
+ kind: 'checkout',
1231
+ checkout: {
1232
+ connection: interpolation('inputs.connection'),
1233
+ repository: interpolation('steps.route.outputs.repository'),
1234
+ ref: interpolation('steps.route.outputs.ref'),
1235
+ path: interpolation('steps.route.outputs.path'),
1236
+ fetchDepth: 1,
1237
+ permissions: {contents: 'read'},
1238
+ persistCredentials: true,
1239
+ templates: {
1240
+ connection: [{kind: 'deferred', roots: ['inputs'], fillTarget: 'run-creation'}],
1241
+ repository: [{kind: 'deferred', roots: ['steps'], fillTarget: 'step-dispatch'}],
1242
+ ref: [{kind: 'deferred', roots: ['steps'], fillTarget: 'step-dispatch'}],
1243
+ path: [{kind: 'deferred', roots: ['steps'], fillTarget: 'step-dispatch'}],
1244
+ },
1245
+ },
1246
+ });
1247
+ });
1248
+
1249
+ it('reports invalid checkout target combinations from model normalization', () => {
1250
+ const error = expectInvalid({
1251
+ name: 'invalid checkout target',
1252
+ jobs: {
1253
+ build: {
1254
+ steps: [{checkout: {project: 'project-id', repository: 'acme/api'}}],
1255
+ },
1256
+ },
1257
+ });
1258
+
1259
+ expect(error.issues).toContainEqual(
1260
+ expect.objectContaining({
1261
+ code: 'checkout-target-invalid',
1262
+ path: ['jobs', 'build', 'steps', 0, 'checkout', 'repository'],
1263
+ }),
1264
+ );
1265
+
1266
+ const missingRepository = expectInvalid({
1267
+ name: 'missing checkout repository',
1268
+ jobs: {
1269
+ build: {
1270
+ steps: [{checkout: {connection: 'github'}}],
1271
+ },
1272
+ },
1273
+ });
1274
+
1275
+ expect(missingRepository.issues).toContainEqual(
1276
+ expect.objectContaining({
1277
+ code: 'checkout-target-invalid',
1278
+ path: ['jobs', 'build', 'steps', 0, 'checkout', 'repository'],
1279
+ details: {fields: ['connection', 'repository']},
1280
+ }),
1281
+ );
1282
+ });
1283
+
1284
+ it('normalizes checkout steps and their static defaults', () => {
1285
+ const model = normalizeWorkflowDocument({
1286
+ name: 'checkout step',
1287
+ jobs: {
1288
+ build: {
1289
+ steps: [
1290
+ {
1291
+ key: 'fetch-target',
1292
+ checkout: {
1293
+ repository: 'acme/api',
1294
+ ref: 'refs/heads/main',
1295
+ 'fetch-depth': 0,
1296
+ path: 'target',
1297
+ permissions: {contents: 'write'},
1298
+ 'persist-credentials': false,
1299
+ force: true,
1300
+ },
1301
+ },
1302
+ ],
1303
+ },
1304
+ },
1305
+ });
1306
+
1307
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
1308
+ id: expect.stringContaining('fetch-target'),
1309
+ key: 'fetch-target',
1310
+ kind: 'checkout',
1311
+ checkout: {
1312
+ repository: 'acme/api',
1313
+ ref: 'refs/heads/main',
1314
+ fetchDepth: 0,
1315
+ path: 'target',
1316
+ permissions: {contents: 'write'},
1317
+ persistCredentials: false,
1318
+ force: true,
1319
+ },
1320
+ });
1321
+ });
1322
+
834
1323
  it('normalizes checkout contents write and defaults persisted credentials', () => {
835
1324
  const document: WorkflowDocument = {
836
1325
  name: 'write checkout',
@@ -880,7 +1369,7 @@ describe('normalizeWorkflowDocument', () => {
880
1369
  name: 'listen for reviews',
881
1370
  jobs: {
882
1371
  review: {
883
- name: displayName,
1372
+ execution_name: displayName,
884
1373
  listening: {
885
1374
  on: [
886
1375
  {
@@ -915,7 +1404,7 @@ describe('normalizeWorkflowDocument', () => {
915
1404
  onResolve: 'cancel',
916
1405
  },
917
1406
  });
918
- expect(model.jobs[0]?.name?.[1]).toMatchObject({
1407
+ expect(model.jobs[0]?.executionName?.[1]).toMatchObject({
919
1408
  kind: 'deferred',
920
1409
  expression: {source: 'execution.index', check: 'typed'},
921
1410
  });
@@ -968,7 +1457,7 @@ describe('normalizeWorkflowDocument', () => {
968
1457
  ]);
969
1458
  });
970
1459
 
971
- it('validates listener filters against job-activation roots', () => {
1460
+ it('validates listener filters against their persisted snapshot roots', () => {
972
1461
  const document: WorkflowDocument = {
973
1462
  name: 'listener filter roots',
974
1463
  jobs: {
@@ -983,7 +1472,7 @@ describe('normalizeWorkflowDocument', () => {
983
1472
  source: 'github',
984
1473
  event: 'pull_request_review',
985
1474
  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',
1475
+ '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
1476
  },
988
1477
  ],
989
1478
  until: [
@@ -1002,7 +1491,7 @@ describe('normalizeWorkflowDocument', () => {
1002
1491
  const model = normalizeWorkflowDocument(document);
1003
1492
 
1004
1493
  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',
1494
+ '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
1495
  );
1007
1496
  expect(model.jobs[1]?.listening?.until?.[0]?.filter).toBe('event.action == "closed"');
1008
1497
  });
@@ -1010,6 +1499,8 @@ describe('normalizeWorkflowDocument', () => {
1010
1499
  it.each([
1011
1500
  ['step root', 'step.status == "succeeded"', 'context-unavailable-at-predicate-site'],
1012
1501
  ['steps root', 'steps.build.outputs.sha == "abc"', 'context-unavailable-at-predicate-site'],
1502
+ ['executions root', 'executions.size() > 0', 'context-unavailable-at-predicate-site'],
1503
+ ['matrix root', 'matrix.os == "linux"', 'context-unavailable-at-predicate-site'],
1013
1504
  ['runner root', 'runner.os == "linux"', 'runner-context-in-server-predicate'],
1014
1505
  ['non-boolean source', 'event.action', 'invalid-listener-filter'],
1015
1506
  ] as const)('reports invalid listener on filters for %s', (_label, filter, code) => {
@@ -1114,12 +1605,12 @@ describe('normalizeWorkflowDocument', () => {
1114
1605
  ]);
1115
1606
  });
1116
1607
 
1117
- it('preserves explicit model ids even when the seed catalog only knows provider defaults', () => {
1608
+ it('accepts explicit model ids present in the harness catalog', () => {
1118
1609
  const document: WorkflowDocument = {
1119
1610
  name: 'agent build',
1120
1611
  jobs: {
1121
1612
  fix: {
1122
- steps: [{provider: 'openai', model: 'gpt-4.1', prompt: 'Fix it.'}],
1613
+ steps: [{harness: 'pi', provider: 'openai', model: 'gpt-4.1', prompt: 'Fix it.'}],
1123
1614
  },
1124
1615
  },
1125
1616
  };
@@ -1602,7 +2093,7 @@ describe('normalizeWorkflowDocument', () => {
1602
2093
  });
1603
2094
  });
1604
2095
 
1605
- it('accepts server roots available at step reporting in gate success', () => {
2096
+ it('rejects server roots omitted from the gate success context', () => {
1606
2097
  const document: WorkflowDocument = {
1607
2098
  name: 'server-context gate',
1608
2099
  jobs: {
@@ -1610,14 +2101,21 @@ describe('normalizeWorkflowDocument', () => {
1610
2101
  },
1611
2102
  };
1612
2103
 
1613
- const model = normalizeWorkflowDocument(document);
2104
+ const error = expectInvalid(document);
1614
2105
 
1615
- expect(model.jobs[0]?.steps[0]?.gate?.success).toEqual({
1616
- language: 'cel',
1617
- source: 'run.id != ""',
1618
- check: 'typed',
1619
- resultType: 'bool',
1620
- });
2106
+ expect(error.issues).toEqual([
2107
+ expect.objectContaining({
2108
+ code: 'context-unavailable-at-predicate-site',
2109
+ message: expect.stringContaining('Step gate success references context "run"'),
2110
+ path: ['jobs', 'build', 'steps', 0, 'gate', 'success'],
2111
+ details: expect.objectContaining({
2112
+ field: 'step.success',
2113
+ source: 'run.id != ""',
2114
+ unavailableRoots: ['run'],
2115
+ site: 'step-report',
2116
+ }),
2117
+ }),
2118
+ ]);
1621
2119
  });
1622
2120
 
1623
2121
  it('rejects runner-host roots in gate success with a server-predicate issue', () => {
@@ -1647,7 +2145,7 @@ describe('normalizeWorkflowDocument', () => {
1647
2145
  ]);
1648
2146
  });
1649
2147
 
1650
- it('rejects vars in gate success with a server-predicate issue', () => {
2148
+ it('accepts vars in gate success', () => {
1651
2149
  const document: WorkflowDocument = {
1652
2150
  name: 'vars-context gate',
1653
2151
  jobs: {
@@ -1657,24 +2155,15 @@ describe('normalizeWorkflowDocument', () => {
1657
2155
  },
1658
2156
  };
1659
2157
 
1660
- const error = expectInvalid(document);
2158
+ const model = normalizeWorkflowDocument(document);
1661
2159
 
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
- ]);
2160
+ expect(model.jobs[0]?.steps[0]?.gate?.success).toMatchObject({
2161
+ source: 'vars.REQUIRED == "true"',
2162
+ check: 'syntax',
2163
+ });
1675
2164
  });
1676
2165
 
1677
- it('accepts jobs root references at the gate predicate site', () => {
2166
+ it('rejects jobs root references omitted from the gate success context', () => {
1678
2167
  const document: WorkflowDocument = {
1679
2168
  name: 'jobs-context gate',
1680
2169
  jobs: {
@@ -1688,13 +2177,21 @@ describe('normalizeWorkflowDocument', () => {
1688
2177
  },
1689
2178
  };
1690
2179
 
1691
- const model = normalizeWorkflowDocument(document);
2180
+ const error = expectInvalid(document);
1692
2181
 
1693
- expect(model.jobs[1]?.steps[0]?.gate?.success).toEqual({
1694
- language: 'cel',
1695
- source: 'jobs.build.status == "succeeded"',
1696
- check: 'syntax',
1697
- });
2182
+ expect(error.issues).toEqual([
2183
+ expect.objectContaining({
2184
+ code: 'context-unavailable-at-predicate-site',
2185
+ message: expect.stringContaining('Step gate success references context "jobs"'),
2186
+ path: ['jobs', 'deploy', 'steps', 0, 'gate', 'success'],
2187
+ details: expect.objectContaining({
2188
+ field: 'step.success',
2189
+ source: 'jobs.build.status == "succeeded"',
2190
+ unavailableRoots: ['jobs'],
2191
+ site: 'step-report',
2192
+ }),
2193
+ }),
2194
+ ]);
1698
2195
  });
1699
2196
 
1700
2197
  it('parses job output mappings at execution resolution', () => {
@@ -1981,7 +2478,7 @@ describe('normalizeWorkflowDocument', () => {
1981
2478
  });
1982
2479
  });
1983
2480
 
1984
- it('rejects untrusted step outputs in trusted-only run fields', () => {
2481
+ it('allows step outputs in run fields', () => {
1985
2482
  const document: WorkflowDocument = {
1986
2483
  name: 'untrusted step output',
1987
2484
  jobs: {
@@ -1994,15 +2491,17 @@ describe('normalizeWorkflowDocument', () => {
1994
2491
  },
1995
2492
  };
1996
2493
 
1997
- const error = expectInvalid(document);
2494
+ const model = normalizeWorkflowDocument(document);
1998
2495
 
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
- ]);
2496
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
2497
+ kind: 'run',
2498
+ templates: {
2499
+ command: [
2500
+ {kind: 'literal', value: 'echo '},
2501
+ {kind: 'deferred', roots: ['steps']},
2502
+ ],
2503
+ },
2504
+ });
2006
2505
  });
2007
2506
 
2008
2507
  it('infers typed job output scalars for downstream job overlays', () => {
@@ -2119,9 +2618,9 @@ describe('normalizeWorkflowDocument', () => {
2119
2618
  });
2120
2619
  });
2121
2620
 
2122
- it('reports non-scalar typed job output mappings', () => {
2621
+ it('infers structured typed job output mappings', () => {
2123
2622
  const document: WorkflowDocument = {
2124
- name: 'bad job output type',
2623
+ name: 'structured job output type',
2125
2624
  jobs: {
2126
2625
  build: {
2127
2626
  steps: [
@@ -2132,10 +2631,13 @@ describe('normalizeWorkflowDocument', () => {
2132
2631
  metadata: {
2133
2632
  type: 'json',
2134
2633
  schema: {
2135
- type: 'object',
2136
- additionalProperties: false,
2137
- required: ['digest'],
2138
- properties: {digest: {type: 'string'}},
2634
+ type: 'array',
2635
+ items: {
2636
+ type: 'object',
2637
+ additionalProperties: false,
2638
+ required: ['severity'],
2639
+ properties: {severity: {type: 'string'}},
2640
+ },
2139
2641
  },
2140
2642
  },
2141
2643
  },
@@ -2144,18 +2646,82 @@ describe('normalizeWorkflowDocument', () => {
2144
2646
  outputs: {
2145
2647
  metadata: interpolation('steps.collect.outputs.metadata'),
2146
2648
  },
2147
- },
2649
+ },
2650
+ },
2651
+ };
2652
+
2653
+ const model = normalizeWorkflowDocument(document);
2654
+
2655
+ expect(model.jobs[0]?.outputTypes).toEqual({
2656
+ metadata: {
2657
+ kind: 'list',
2658
+ element: {
2659
+ kind: 'object',
2660
+ fields: {severity: 'string'},
2661
+ },
2662
+ },
2663
+ });
2664
+ });
2665
+
2666
+ it('authorizes indexed reads from structured job outputs', () => {
2667
+ const document: WorkflowDocument = {
2668
+ name: 'indexed structured job output',
2669
+ jobs: {
2670
+ review: {
2671
+ steps: [
2672
+ {
2673
+ key: 'inspect',
2674
+ run: 'npm run inspect',
2675
+ outputs: {
2676
+ findings: {
2677
+ type: 'json',
2678
+ schema: {
2679
+ type: 'array',
2680
+ items: {
2681
+ type: 'object',
2682
+ additionalProperties: false,
2683
+ required: ['severity'],
2684
+ properties: {severity: {type: 'string'}},
2685
+ },
2686
+ },
2687
+ },
2688
+ },
2689
+ },
2690
+ ],
2691
+ outputs: {
2692
+ findings: interpolation('steps.inspect.outputs.findings'),
2693
+ },
2694
+ },
2695
+ summarize: {
2696
+ needs: ['review'],
2697
+ steps: [
2698
+ {
2699
+ run: `printf '%s\\n' ${interpolation('jobs.review.outputs.findings[0].severity')}`,
2700
+ },
2701
+ ],
2702
+ },
2703
+ },
2704
+ };
2705
+
2706
+ const model = normalizeWorkflowDocument(document);
2707
+ const summarize = model.jobs.find((job) => job.key === 'summarize');
2708
+
2709
+ expect(summarize?.steps[0]).toMatchObject({
2710
+ kind: 'run',
2711
+ templates: {
2712
+ command: [
2713
+ {kind: 'literal', value: "printf '%s\\n' "},
2714
+ {
2715
+ kind: 'deferred',
2716
+ expression: {
2717
+ source: 'jobs.review.outputs.findings[0].severity',
2718
+ check: 'typed',
2719
+ },
2720
+ roots: ['jobs'],
2721
+ },
2722
+ ],
2148
2723
  },
2149
- };
2150
-
2151
- const error = expectInvalid(document);
2152
-
2153
- expect(error.issues).toEqual([
2154
- expect.objectContaining({
2155
- code: 'invalid-job-output',
2156
- path: ['jobs', 'build', 'outputs', 'metadata'],
2157
- }),
2158
- ]);
2724
+ });
2159
2725
  });
2160
2726
 
2161
2727
  it('rejects job output references without a direct needs edge', () => {
@@ -2318,29 +2884,29 @@ describe('normalizeWorkflowDocument', () => {
2318
2884
  );
2319
2885
  });
2320
2886
 
2321
- it('rejects vars roots in if predicates', () => {
2887
+ it('accepts vars in job and step if predicates', () => {
2322
2888
  const document: WorkflowDocument = {
2323
2889
  name: 'vars condition',
2324
2890
  jobs: {
2325
2891
  build: {
2326
2892
  if: interpolation('vars.REQUIRED == "true"'),
2327
- steps: [{run: 'npm run build'}],
2893
+ steps: [{if: interpolation('vars.REQUIRED == "true"'), run: 'npm run build'}],
2328
2894
  },
2329
2895
  },
2330
2896
  };
2331
2897
 
2332
- const error = expectInvalid(document);
2898
+ const model = normalizeWorkflowDocument(document);
2333
2899
 
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
- ]);
2900
+ expect(model.jobs[0]?.if).toMatchObject({
2901
+ source: 'vars.REQUIRED == "true"',
2902
+ check: 'typed',
2903
+ resultType: 'bool',
2904
+ });
2905
+ expect(model.jobs[0]?.steps[0]?.if).toMatchObject({
2906
+ source: 'vars.REQUIRED == "true"',
2907
+ check: 'typed',
2908
+ resultType: 'bool',
2909
+ });
2344
2910
  });
2345
2911
 
2346
2912
  it('rejects if job references without a direct needs edge', () => {
@@ -2442,7 +3008,7 @@ describe('normalizeWorkflowDocument', () => {
2442
3008
  path: ['jobs', 'build', 'if'],
2443
3009
  details: expect.objectContaining({
2444
3010
  field: 'job.if',
2445
- unavailableRoots: ['execution.failed'],
3011
+ unavailableRoots: ['execution'],
2446
3012
  }),
2447
3013
  }),
2448
3014
  ]);
@@ -2573,7 +3139,7 @@ describe('normalizeWorkflowDocument', () => {
2573
3139
  ]);
2574
3140
  });
2575
3141
 
2576
- it('rejects vars in job success with a server-predicate issue', () => {
3142
+ it('accepts vars in job success', () => {
2577
3143
  const document: WorkflowDocument = {
2578
3144
  name: 'vars-context job success',
2579
3145
  jobs: {
@@ -2584,21 +3150,9 @@ describe('normalizeWorkflowDocument', () => {
2584
3150
  },
2585
3151
  };
2586
3152
 
2587
- const error = expectInvalid(document);
3153
+ const model = normalizeWorkflowDocument(document);
2588
3154
 
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
- ]);
3155
+ expect(model.jobs[0]?.success).toBe('vars.ENVIRONMENT == "prod"');
2602
3156
  });
2603
3157
 
2604
3158
  it('reports malformed job execution timeouts', () => {
@@ -3111,12 +3665,12 @@ describe('normalizeWorkflowDocument', () => {
3111
3665
  [
3112
3666
  'vars root',
3113
3667
  'vars.environment == "prod"',
3114
- 'vars-context-in-server-predicate',
3668
+ 'context-unavailable-at-predicate-site',
3115
3669
  {
3116
3670
  field: 'trigger.filter',
3117
3671
  source: 'vars.environment == "prod"',
3118
3672
  contextRoots: ['vars'],
3119
- rejectedRoots: ['vars'],
3673
+ unavailableRoots: ['vars'],
3120
3674
  site: 'ingest',
3121
3675
  },
3122
3676
  ],
@@ -3551,11 +4105,13 @@ describe('normalizeWorkflowDocument', () => {
3551
4105
  steps: [
3552
4106
  {
3553
4107
  name: `deploy ${interpolation('event.action')}`,
4108
+ working_directory: 'packages/api',
3554
4109
  run: `deploy ${interpolation('run.id')}`,
3555
4110
  env: {PR_TITLE: interpolation('event.pull_request.title'), DEBUG: false},
3556
4111
  },
3557
4112
  {
3558
4113
  name: `review ${interpolation('inputs.topic')}`,
4114
+ working_directory: `packages/${interpolation('inputs.topic')}`,
3559
4115
  provider: 'openai',
3560
4116
  prompt: `Review ${interpolation('event.pull_request.title')}`,
3561
4117
  },
@@ -3602,6 +4158,7 @@ describe('normalizeWorkflowDocument', () => {
3602
4158
  expect(model.jobs[0]?.steps[0]).toMatchObject({
3603
4159
  kind: 'run',
3604
4160
  command: {value: `deploy ${interpolation('run.id')}`},
4161
+ workingDirectory: 'packages/api',
3605
4162
  templates: {
3606
4163
  command: [
3607
4164
  {kind: 'literal', value: 'deploy '},
@@ -3636,7 +4193,16 @@ describe('normalizeWorkflowDocument', () => {
3636
4193
  });
3637
4194
  expect(model.jobs[0]?.steps[1]).toMatchObject({
3638
4195
  kind: 'agent',
4196
+ workingDirectory: `packages/${interpolation('inputs.topic')}`,
3639
4197
  templates: {
4198
+ workingDirectory: [
4199
+ {kind: 'literal', value: 'packages/'},
4200
+ {
4201
+ kind: 'deferred',
4202
+ expression: {source: 'inputs.topic'},
4203
+ roots: ['inputs'],
4204
+ },
4205
+ ],
3640
4206
  prompt: [
3641
4207
  {kind: 'literal', value: 'Review '},
3642
4208
  {
@@ -3678,7 +4244,7 @@ describe('normalizeWorkflowDocument', () => {
3678
4244
  expect(model).not.toHaveProperty('templates');
3679
4245
  expect(model.jobs[0]?.steps[0]).not.toHaveProperty('templates');
3680
4246
  expect(model.env).toEqual({VALUE: '$${{ event.ref }}'});
3681
- expect(model.jobs[0]?.name).toEqual([{kind: 'literal', value: 'Build app'}]);
4247
+ expect(model.jobs[0]?.name).toBe('Build app');
3682
4248
  expect(model.jobs[0]?.steps[0]).toMatchObject({
3683
4249
  kind: 'run',
3684
4250
  command: {value: 'echo $${{ event.ref }}'},
@@ -3737,30 +4303,32 @@ describe('normalizeWorkflowDocument', () => {
3737
4303
 
3738
4304
  it.each([
3739
4305
  ['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) => {
4306
+ ['job status', 'jobs.build.status', ['jobs']],
4307
+ ] as const)('allows %s context in run commands', (_label, source, roots) => {
3742
4308
  const document: WorkflowDocument = {
3743
4309
  name: 'unsafe run',
3744
4310
  jobs: {
3745
4311
  build: {
4312
+ steps: [{run: 'echo build'}],
4313
+ },
4314
+ deploy: {
4315
+ needs: 'build',
3746
4316
  steps: [{run: `echo ${interpolation(source)}`}],
3747
4317
  },
3748
4318
  },
3749
4319
  };
3750
4320
 
3751
- const error = expectInvalid(document);
4321
+ const model = normalizeWorkflowDocument(document);
3752
4322
 
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
- ]);
4323
+ expect(model.jobs[1]?.steps[0]).toMatchObject({
4324
+ kind: 'run',
4325
+ templates: {
4326
+ command: [
4327
+ {kind: 'literal', value: 'echo '},
4328
+ {kind: 'deferred', roots},
4329
+ ],
4330
+ },
4331
+ });
3764
4332
  });
3765
4333
 
3766
4334
  it('rejects secrets in agent fields', () => {
@@ -3832,8 +4400,7 @@ describe('normalizeWorkflowDocument', () => {
3832
4400
  it.each([
3833
4401
  'execution.events[0].data.body',
3834
4402
  'execution["events"][0].data.body',
3835
- 'execution[x]',
3836
- ])('rejects untrusted execution sub-paths in run commands: %s', (source) => {
4403
+ ])('allows execution event sub-paths in run commands: %s', (source) => {
3837
4404
  const document: WorkflowDocument = {
3838
4405
  name: 'unsafe execution run',
3839
4406
  jobs: {
@@ -3843,42 +4410,17 @@ describe('normalizeWorkflowDocument', () => {
3843
4410
  },
3844
4411
  };
3845
4412
 
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
- });
4413
+ const model = normalizeWorkflowDocument(document);
3859
4414
 
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
- },
4415
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4416
+ kind: 'run',
4417
+ templates: {
4418
+ command: [
4419
+ {kind: 'literal', value: 'echo '},
4420
+ {kind: 'deferred', roots: ['execution']},
4421
+ ],
3867
4422
  },
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
- ]);
4423
+ });
3882
4424
  });
3883
4425
 
3884
4426
  it.each([
@@ -3994,7 +4536,7 @@ describe('normalizeWorkflowDocument', () => {
3994
4536
  });
3995
4537
  });
3996
4538
 
3997
- it('does not apply availability checks to job names', () => {
4539
+ it('rejects dynamic job names in favor of execution_name', () => {
3998
4540
  const document: WorkflowDocument = {
3999
4541
  name: 'job display context',
4000
4542
  jobs: {
@@ -4010,16 +4552,19 @@ describe('normalizeWorkflowDocument', () => {
4010
4552
  },
4011
4553
  };
4012
4554
 
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
- });
4555
+ const error = expectInvalid(document);
4556
+ expect(error.issues).toEqual([
4557
+ expect.objectContaining({
4558
+ code: 'invalid-interpolation-template',
4559
+ message: 'Job name must be literal. Move runtime interpolation to execution_name.',
4560
+ path: ['jobs', 'build', 'name'],
4561
+ }),
4562
+ expect.objectContaining({
4563
+ code: 'invalid-interpolation-template',
4564
+ message: 'Job name must be literal. Move runtime interpolation to execution_name.',
4565
+ path: ['jobs', 'review', 'name'],
4566
+ }),
4567
+ ]);
4023
4568
  });
4024
4569
 
4025
4570
  it('does not apply availability checks to workflow-level or job-level env', () => {
@@ -4149,7 +4694,7 @@ describe('normalizeWorkflowDocument', () => {
4149
4694
  });
4150
4695
  });
4151
4696
 
4152
- it('allows execution events in untrusted-capable fields', () => {
4697
+ it('rejects execution events in static job names', () => {
4153
4698
  const document: WorkflowDocument = {
4154
4699
  name: 'execution events allowed',
4155
4700
  jobs: {
@@ -4161,25 +4706,13 @@ describe('normalizeWorkflowDocument', () => {
4161
4706
  },
4162
4707
  };
4163
4708
 
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
- });
4709
+ const error = expectInvalid(document);
4710
+ expect(error.issues).toEqual([
4711
+ expect.objectContaining({
4712
+ code: 'invalid-interpolation-template',
4713
+ path: ['jobs', 'build', 'name'],
4714
+ }),
4715
+ ]);
4183
4716
  });
4184
4717
 
4185
4718
  it('allows untrusted context in env, prompt, and step names', () => {
@@ -4216,9 +4749,9 @@ describe('normalizeWorkflowDocument', () => {
4216
4749
  it.each([
4217
4750
  ['model', {model: interpolation('event.model'), prompt: 'Fix it.'}, 'event'],
4218
4751
  ['provider', {provider: interpolation('inputs.provider'), prompt: 'Fix it.'}, 'inputs'],
4219
- ] as const)('rejects untrusted agent %s interpolation', (_field, step, root) => {
4752
+ ] as const)('allows external context in agent %s interpolation', (field, step, root) => {
4220
4753
  const document: WorkflowDocument = {
4221
- name: 'unsafe agent field',
4754
+ name: 'external agent field',
4222
4755
  jobs: {
4223
4756
  fix: {
4224
4757
  steps: [step],
@@ -4226,14 +4759,77 @@ describe('normalizeWorkflowDocument', () => {
4226
4759
  },
4227
4760
  };
4228
4761
 
4229
- const error = expectInvalid(document);
4762
+ const model = normalizeWorkflowDocument(document);
4230
4763
 
4231
- expect(error.issues).toEqual([
4232
- expect.objectContaining({
4233
- code: 'untrusted-context-in-field',
4234
- details: expect.objectContaining({rejectedRoots: [root]}),
4235
- }),
4236
- ]);
4764
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4765
+ kind: 'agent',
4766
+ templates: {
4767
+ [field]: [{kind: 'deferred', roots: [root]}],
4768
+ },
4769
+ });
4770
+ });
4771
+
4772
+ it.each([
4773
+ ['model', {model: interpolation('steps.collect.outputs.value'), prompt: 'Fix it.'}, 'steps'],
4774
+ [
4775
+ 'provider',
4776
+ {provider: interpolation('steps.collect.outputs.value'), prompt: 'Fix it.'},
4777
+ 'steps',
4778
+ ],
4779
+ [
4780
+ 'model',
4781
+ {
4782
+ model: interpolation('steps.filter(s, true).map(s, s.outputs.value)[0]'),
4783
+ prompt: 'Fix it.',
4784
+ },
4785
+ 'steps',
4786
+ ],
4787
+ [
4788
+ 'provider',
4789
+ {
4790
+ provider: interpolation('steps.filter(s, true).map(s, s.outputs.value)[0]'),
4791
+ prompt: 'Fix it.',
4792
+ },
4793
+ 'steps',
4794
+ ],
4795
+ [
4796
+ 'model',
4797
+ {model: interpolation('execution.events[0].data.body'), prompt: 'Fix it.'},
4798
+ 'execution',
4799
+ ],
4800
+ [
4801
+ 'provider',
4802
+ {provider: interpolation('execution.events[0].data.body'), prompt: 'Fix it.'},
4803
+ 'execution',
4804
+ ],
4805
+ [
4806
+ 'model',
4807
+ {model: interpolation('execution["events"][0].data.body'), prompt: 'Fix it.'},
4808
+ 'execution',
4809
+ ],
4810
+ [
4811
+ 'provider',
4812
+ {provider: interpolation('execution["events"][0].data.body'), prompt: 'Fix it.'},
4813
+ 'execution',
4814
+ ],
4815
+ ] as const)('allows path-specific external context in agent %s interpolation', (field, step, root) => {
4816
+ const document: WorkflowDocument = {
4817
+ name: 'path-specific external agent field',
4818
+ jobs: {
4819
+ fix: {
4820
+ steps: [{key: 'collect', run: 'collect'}, step],
4821
+ },
4822
+ },
4823
+ };
4824
+
4825
+ const model = normalizeWorkflowDocument(document);
4826
+
4827
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
4828
+ kind: 'agent',
4829
+ templates: {
4830
+ [field]: [{kind: 'deferred', roots: [root]}],
4831
+ },
4832
+ });
4237
4833
  });
4238
4834
 
4239
4835
  it.each([
@@ -4298,6 +4894,77 @@ describe('normalizeWorkflowDocument', () => {
4298
4894
  });
4299
4895
  });
4300
4896
 
4897
+ it('does not add provider catalog issues for invalid provider interpolation', () => {
4898
+ const document: WorkflowDocument = {
4899
+ name: 'invalid provider interpolation',
4900
+ jobs: {
4901
+ fix: {
4902
+ steps: [{harness: 'claude', provider: interpolation('foo.bar'), prompt: 'Fix it.'}],
4903
+ },
4904
+ },
4905
+ };
4906
+
4907
+ const error = expectInvalid(document);
4908
+
4909
+ expect(error.issues).toEqual([
4910
+ expect.objectContaining({
4911
+ code: 'unknown-interpolation-context',
4912
+ path: ['jobs', 'fix', 'steps', 0, 'provider'],
4913
+ }),
4914
+ ]);
4915
+ });
4916
+
4917
+ it('skips static model catalog validation when model is interpolated', () => {
4918
+ const document: WorkflowDocument = {
4919
+ name: 'templated model',
4920
+ jobs: {
4921
+ fix: {
4922
+ steps: [
4923
+ {
4924
+ provider: 'anthropic',
4925
+ model: interpolation('run.name'),
4926
+ prompt: 'Fix it.',
4927
+ },
4928
+ ],
4929
+ },
4930
+ },
4931
+ };
4932
+
4933
+ const model = normalizeWorkflowDocument(document);
4934
+
4935
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4936
+ kind: 'agent',
4937
+ templates: {model: [{kind: 'deferred', roots: ['run']}]},
4938
+ });
4939
+ });
4940
+
4941
+ it('does not add model catalog issues for invalid model interpolation', () => {
4942
+ const document: WorkflowDocument = {
4943
+ name: 'invalid model interpolation',
4944
+ jobs: {
4945
+ fix: {
4946
+ steps: [
4947
+ {
4948
+ harness: 'pi',
4949
+ provider: 'anthropic',
4950
+ model: interpolation('foo.bar'),
4951
+ prompt: 'Fix it.',
4952
+ },
4953
+ ],
4954
+ },
4955
+ },
4956
+ };
4957
+
4958
+ const error = expectInvalid(document);
4959
+
4960
+ expect(error.issues).toEqual([
4961
+ expect.objectContaining({
4962
+ code: 'unknown-interpolation-context',
4963
+ path: ['jobs', 'fix', 'steps', 0, 'model'],
4964
+ }),
4965
+ ]);
4966
+ });
4967
+
4301
4968
  it('still validates literal providers through the catalog', () => {
4302
4969
  const document: WorkflowDocument = {
4303
4970
  name: 'literal provider',
@@ -4427,7 +5094,7 @@ describe('normalizeWorkflowDocument', () => {
4427
5094
  ]);
4428
5095
  });
4429
5096
 
4430
- it('uses syntax mode for mixed open contexts but still enforces minimum trust', () => {
5097
+ it('uses syntax mode for mixed open contexts without a trust restriction', () => {
4431
5098
  const envDocument: WorkflowDocument = {
4432
5099
  name: 'mixed env',
4433
5100
  env: {MIXED: interpolation('run.nope + event.x')},
@@ -4447,22 +5114,25 @@ describe('normalizeWorkflowDocument', () => {
4447
5114
  };
4448
5115
 
4449
5116
  const model = normalizeWorkflowDocument(envDocument);
4450
- const error = expectInvalid(runDocument);
5117
+ const runModel = normalizeWorkflowDocument(runDocument);
4451
5118
 
4452
5119
  expect(model.templates?.env?.MIXED?.[0]).toMatchObject({
4453
5120
  kind: 'deferred',
4454
5121
  expression: {check: 'syntax'},
4455
5122
  roots: expect.arrayContaining(['run', 'event']),
4456
5123
  });
4457
- expect(error.issues).toEqual([
4458
- expect.objectContaining({
4459
- code: 'untrusted-context-in-field',
4460
- path: ['jobs', 'build', 'steps', 0, 'run'],
4461
- }),
4462
- ]);
5124
+ expect(runModel.jobs[0]?.steps[0]).toMatchObject({
5125
+ kind: 'run',
5126
+ templates: {
5127
+ command: [
5128
+ {kind: 'literal', value: 'echo '},
5129
+ {kind: 'deferred', roots: expect.arrayContaining(['run', 'event'])},
5130
+ ],
5131
+ },
5132
+ });
4463
5133
  });
4464
5134
 
4465
- it('reports one trust issue for a multi-segment run field with one untrusted segment', () => {
5135
+ it('allows multi-segment run fields with external context', () => {
4466
5136
  const document: WorkflowDocument = {
4467
5137
  name: 'multi segment run',
4468
5138
  jobs: {
@@ -4472,14 +5142,18 @@ describe('normalizeWorkflowDocument', () => {
4472
5142
  },
4473
5143
  };
4474
5144
 
4475
- const error = expectInvalid(document);
5145
+ const model = normalizeWorkflowDocument(document);
4476
5146
 
4477
- expect(error.issues).toEqual([
4478
- expect.objectContaining({
4479
- code: 'untrusted-context-in-field',
4480
- details: expect.objectContaining({rejectedRoots: ['event']}),
4481
- }),
4482
- ]);
5147
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
5148
+ kind: 'run',
5149
+ templates: {
5150
+ command: [
5151
+ {kind: 'deferred', roots: ['run']},
5152
+ {kind: 'literal', value: '-'},
5153
+ {kind: 'deferred', roots: ['event']},
5154
+ ],
5155
+ },
5156
+ });
4483
5157
  });
4484
5158
 
4485
5159
  it('does not parse templates in non-string env values', () => {