@shipfox/api-workflows 12.1.0 → 12.3.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 (159) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +20 -0
  4. package/dist/config.d.ts +11 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +56 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/core/entities/job.d.ts +1 -1
  9. package/dist/core/entities/job.d.ts.map +1 -1
  10. package/dist/core/entities/step.d.ts +2 -2
  11. package/dist/core/entities/step.d.ts.map +1 -1
  12. package/dist/core/errors.d.ts +18 -1
  13. package/dist/core/errors.d.ts.map +1 -1
  14. package/dist/core/errors.js +21 -2
  15. package/dist/core/errors.js.map +1 -1
  16. package/dist/core/step-config/assemble-run-context.d.ts +15 -5
  17. package/dist/core/step-config/assemble-run-context.d.ts.map +1 -1
  18. package/dist/core/step-config/assemble-run-context.js +77 -27
  19. package/dist/core/step-config/assemble-run-context.js.map +1 -1
  20. package/dist/core/step-config/fields.d.ts +5 -0
  21. package/dist/core/step-config/fields.d.ts.map +1 -1
  22. package/dist/core/step-config/fields.js +21 -3
  23. package/dist/core/step-config/fields.js.map +1 -1
  24. package/dist/core/step-config/index.d.ts +1 -1
  25. package/dist/core/step-config/index.d.ts.map +1 -1
  26. package/dist/core/step-config/index.js +1 -1
  27. package/dist/core/step-config/index.js.map +1 -1
  28. package/dist/core/step-config/job-output-limits.d.ts +11 -0
  29. package/dist/core/step-config/job-output-limits.d.ts.map +1 -0
  30. package/dist/core/step-config/job-output-limits.js +88 -0
  31. package/dist/core/step-config/job-output-limits.js.map +1 -0
  32. package/dist/core/step-config/materialize-workflow-model.d.ts +1 -1
  33. package/dist/core/step-config/materialize-workflow-model.d.ts.map +1 -1
  34. package/dist/core/step-config/materialize-workflow-model.js +47 -18
  35. package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
  36. package/dist/db/db.d.ts +50 -50
  37. package/dist/db/index.d.ts +2 -2
  38. package/dist/db/index.d.ts.map +1 -1
  39. package/dist/db/index.js +1 -1
  40. package/dist/db/index.js.map +1 -1
  41. package/dist/db/job-listeners.d.ts.map +1 -1
  42. package/dist/db/job-listeners.js +6 -5
  43. package/dist/db/job-listeners.js.map +1 -1
  44. package/dist/db/schema/job-executions.d.ts +3 -3
  45. package/dist/db/schema/job-listener-events.d.ts +1 -1
  46. package/dist/db/schema/jobs.d.ts +7 -7
  47. package/dist/db/schema/outbox.d.ts +1 -1
  48. package/dist/db/schema/step-attempts.d.ts +2 -2
  49. package/dist/db/schema/steps.d.ts +5 -5
  50. package/dist/db/schema/workflow-run-attempts.d.ts +3 -3
  51. package/dist/db/schema/workflow-run-counters.d.ts +1 -1
  52. package/dist/db/schema/workflow-runs.d.ts +2 -2
  53. package/dist/db/workflow-runs/job-executions.d.ts.map +1 -1
  54. package/dist/db/workflow-runs/job-executions.js +4 -2
  55. package/dist/db/workflow-runs/job-executions.js.map +1 -1
  56. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  57. package/dist/db/workflow-runs/jobs.js +33 -3
  58. package/dist/db/workflow-runs/jobs.js.map +1 -1
  59. package/dist/db/workflow-runs/outbox.d.ts +1 -0
  60. package/dist/db/workflow-runs/outbox.d.ts.map +1 -1
  61. package/dist/db/workflow-runs/outbox.js +1 -0
  62. package/dist/db/workflow-runs/outbox.js.map +1 -1
  63. package/dist/db/workflow-runs/steps.d.ts +28 -0
  64. package/dist/db/workflow-runs/steps.d.ts.map +1 -1
  65. package/dist/db/workflow-runs/steps.js +49 -1
  66. package/dist/db/workflow-runs/steps.js.map +1 -1
  67. package/dist/db/workflow-runs.d.ts +2 -2
  68. package/dist/db/workflow-runs.d.ts.map +1 -1
  69. package/dist/db/workflow-runs.js +1 -1
  70. package/dist/db/workflow-runs.js.map +1 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +4 -2
  73. package/dist/index.js.map +1 -1
  74. package/dist/metrics/instance.d.ts +1 -0
  75. package/dist/metrics/instance.d.ts.map +1 -1
  76. package/dist/metrics/instance.js +8 -0
  77. package/dist/metrics/instance.js.map +1 -1
  78. package/dist/presentation/dto/evaluation-trace.d.ts +4 -0
  79. package/dist/presentation/dto/evaluation-trace.d.ts.map +1 -0
  80. package/dist/presentation/dto/evaluation-trace.js +39 -0
  81. package/dist/presentation/dto/evaluation-trace.js.map +1 -0
  82. package/dist/presentation/dto/index.d.ts +2 -1
  83. package/dist/presentation/dto/index.d.ts.map +1 -1
  84. package/dist/presentation/dto/index.js +2 -1
  85. package/dist/presentation/dto/index.js.map +1 -1
  86. package/dist/presentation/dto/job.d.ts.map +1 -1
  87. package/dist/presentation/dto/job.js +6 -0
  88. package/dist/presentation/dto/job.js.map +1 -1
  89. package/dist/presentation/dto/step.d.ts +2 -1
  90. package/dist/presentation/dto/step.d.ts.map +1 -1
  91. package/dist/presentation/dto/step.js +12 -0
  92. package/dist/presentation/dto/step.js.map +1 -1
  93. package/dist/presentation/index.d.ts +1 -1
  94. package/dist/presentation/index.d.ts.map +1 -1
  95. package/dist/presentation/index.js +1 -1
  96. package/dist/presentation/index.js.map +1 -1
  97. package/dist/presentation/routes/get-step-attempt-detail.d.ts +3 -0
  98. package/dist/presentation/routes/get-step-attempt-detail.d.ts.map +1 -0
  99. package/dist/presentation/routes/get-step-attempt-detail.js +38 -0
  100. package/dist/presentation/routes/get-step-attempt-detail.js.map +1 -0
  101. package/dist/presentation/routes/index.d.ts.map +1 -1
  102. package/dist/presentation/routes/index.js +2 -0
  103. package/dist/presentation/routes/index.js.map +1 -1
  104. package/dist/presentation/routes/project-access.d.ts +1 -1
  105. package/dist/presentation/subscribers/index.d.ts +1 -0
  106. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  107. package/dist/presentation/subscribers/index.js +1 -0
  108. package/dist/presentation/subscribers/index.js.map +1 -1
  109. package/dist/presentation/subscribers/on-failure-annotations.d.ts +5 -0
  110. package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -0
  111. package/dist/presentation/subscribers/on-failure-annotations.js +160 -0
  112. package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -0
  113. package/dist/temporal/activities/index.d.ts +1 -1
  114. package/dist/temporal/activities/index.d.ts.map +1 -1
  115. package/dist/temporal/activities/orchestration-activities.d.ts +1 -1
  116. package/dist/temporal/activities/orchestration-activities.d.ts.map +1 -1
  117. package/dist/temporal/workflows/test-env.d.ts +2 -2
  118. package/dist/tsconfig.test.tsbuildinfo +1 -1
  119. package/package.json +18 -16
  120. package/src/config.test.ts +85 -0
  121. package/src/config.ts +71 -0
  122. package/src/core/errors.ts +44 -2
  123. package/src/core/job-execution.test.ts +1 -0
  124. package/src/core/step-config/assemble-run-context.test.ts +232 -20
  125. package/src/core/step-config/assemble-run-context.ts +126 -28
  126. package/src/core/step-config/fields.ts +42 -3
  127. package/src/core/step-config/index.ts +2 -0
  128. package/src/core/step-config/job-output-limits.ts +130 -0
  129. package/src/core/step-config/materialize-workflow-model.catalog.test.ts +117 -0
  130. package/src/core/step-config/materialize-workflow-model.test.ts +306 -1
  131. package/src/core/step-config/materialize-workflow-model.ts +65 -18
  132. package/src/db/index.ts +4 -0
  133. package/src/db/job-listeners.ts +14 -3
  134. package/src/db/workflow-runs/job-executions.test.ts +207 -1
  135. package/src/db/workflow-runs/job-executions.ts +15 -2
  136. package/src/db/workflow-runs/job-status.test.ts +10 -3
  137. package/src/db/workflow-runs/jobs.test.ts +49 -0
  138. package/src/db/workflow-runs/jobs.ts +53 -6
  139. package/src/db/workflow-runs/outbox.ts +7 -1
  140. package/src/db/workflow-runs/run-create.test.ts +56 -0
  141. package/src/db/workflow-runs/steps.test.ts +148 -0
  142. package/src/db/workflow-runs/steps.ts +105 -1
  143. package/src/db/workflow-runs.ts +4 -0
  144. package/src/index.ts +9 -0
  145. package/src/metrics/instance.ts +10 -0
  146. package/src/presentation/dto/evaluation-trace.ts +27 -0
  147. package/src/presentation/dto/index.ts +2 -1
  148. package/src/presentation/dto/job.ts +6 -0
  149. package/src/presentation/dto/step.test.ts +96 -1
  150. package/src/presentation/dto/step.ts +17 -0
  151. package/src/presentation/index.ts +2 -0
  152. package/src/presentation/routes/get-step-attempt-detail.test.ts +159 -0
  153. package/src/presentation/routes/get-step-attempt-detail.ts +33 -0
  154. package/src/presentation/routes/index.ts +2 -0
  155. package/src/presentation/subscribers/index.ts +4 -0
  156. package/src/presentation/subscribers/on-failure-annotations.test.ts +510 -0
  157. package/src/presentation/subscribers/on-failure-annotations.ts +237 -0
  158. package/test/factories/workflow-model.ts +2 -0
  159. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,14 +1,21 @@
1
1
  import {WORKFLOWS_JOB_EXECUTION_TIMED_OUT} from '@shipfox/api-workflows-dto';
2
2
  import {and, eq, sql} from 'drizzle-orm';
3
- import {buildModel, workflowRunAttemptId} from '#test/helpers/workflow-runs.js';
3
+ import {
4
+ MAX_JOB_OUTPUT_ENTRIES,
5
+ MAX_JOB_OUTPUT_VALUE_BYTES,
6
+ } from '#core/step-config/job-output-limits.js';
7
+ import {buildModel, template, workflowRunAttemptId} from '#test/helpers/workflow-runs.js';
4
8
  import {db} from '../db.js';
5
9
  import {workflowsOutbox} from '../schema/outbox.js';
6
10
  import {
11
+ applyStepResult,
7
12
  createWorkflowRun,
8
13
  failJobExecutionAsTimedOut,
14
+ finishStepAttempt,
9
15
  getFirstJobExecutionByJobId,
10
16
  getJobsByWorkflowRunId,
11
17
  getStepsByJobId,
18
+ markStepRunning,
12
19
  resolveJobExecutionAfterLeaseExpiry,
13
20
  updateJobExecutionStatus,
14
21
  } from '../workflow-runs.js';
@@ -96,4 +103,203 @@ describe('workflow run job executions', () => {
96
103
  const jobSteps = await getStepsByJobId(job.id);
97
104
  expect(jobSteps.every((step) => step.status === 'pending')).toBe(true);
98
105
  });
106
+
107
+ test('persists a structured job output when execution succeeds', async () => {
108
+ const run = await createWorkflowRun({
109
+ workspaceId,
110
+ projectId,
111
+ definitionId,
112
+ model: buildModel({
113
+ jobs: {
114
+ build: {
115
+ steps: [{key: 'collect', run: 'echo build'}],
116
+ outputs: {findings: template('steps.collect.outputs.findings')},
117
+ outputTypes: {
118
+ findings: {
119
+ kind: 'list',
120
+ element: {kind: 'object', fields: {severity: 'string'}},
121
+ },
122
+ },
123
+ },
124
+ },
125
+ }),
126
+ triggerPayload: {
127
+ source: 'manual',
128
+ event: 'fire',
129
+ subscriptionId: crypto.randomUUID(),
130
+ userId: crypto.randomUUID(),
131
+ },
132
+ });
133
+ const [job] = await getJobsByWorkflowRunId(run.id);
134
+ if (!job) throw new Error('Expected workflow job');
135
+ const execution = await getFirstJobExecutionByJobId(job.id);
136
+ if (!execution) throw new Error('Expected job execution');
137
+ await finishCollectedStep(job.id, {findings: [{severity: 'high'}]});
138
+
139
+ const resolved = await updateJobExecutionStatus({
140
+ jobExecutionId: execution.id,
141
+ status: 'succeeded',
142
+ expectedVersion: execution.version,
143
+ });
144
+
145
+ expect(resolved.outputs).toEqual({findings: [{severity: 'high'}]});
146
+ expect(Array.isArray(resolved.outputs?.findings)).toBe(true);
147
+ const persisted = await getFirstJobExecutionByJobId(job.id);
148
+ expect(persisted?.outputs).toEqual({findings: [{severity: 'high'}]});
149
+ });
150
+
151
+ test('persists JSON-safe typed integer and timestamp outputs when execution succeeds', async () => {
152
+ const run = await createWorkflowRun({
153
+ workspaceId,
154
+ projectId,
155
+ definitionId,
156
+ model: buildModel({
157
+ jobs: {
158
+ build: {
159
+ steps: [{key: 'collect', run: 'echo build'}],
160
+ outputs: {
161
+ count: template('steps.collect.outputs.count'),
162
+ createdAt: template('run.created_at'),
163
+ },
164
+ outputTypes: {count: 'int', createdAt: 'timestamp'},
165
+ },
166
+ },
167
+ }),
168
+ triggerPayload: {
169
+ source: 'manual',
170
+ event: 'fire',
171
+ subscriptionId: crypto.randomUUID(),
172
+ userId: crypto.randomUUID(),
173
+ },
174
+ });
175
+ const [job] = await getJobsByWorkflowRunId(run.id);
176
+ if (!job) throw new Error('Expected workflow job');
177
+ const execution = await getFirstJobExecutionByJobId(job.id);
178
+ if (!execution) throw new Error('Expected job execution');
179
+ await finishCollectedStep(job.id, {count: 42});
180
+
181
+ const resolved = await updateJobExecutionStatus({
182
+ jobExecutionId: execution.id,
183
+ status: 'succeeded',
184
+ expectedVersion: execution.version,
185
+ });
186
+
187
+ expect(resolved.outputs).toEqual({count: 42, createdAt: run.createdAt.toISOString()});
188
+ const persisted = await getFirstJobExecutionByJobId(job.id);
189
+ expect(persisted?.outputs).toEqual({count: 42, createdAt: run.createdAt.toISOString()});
190
+ });
191
+
192
+ test('fails a successful execution when a materialized job output is too large', async () => {
193
+ const run = await createWorkflowRun({
194
+ workspaceId,
195
+ projectId,
196
+ definitionId,
197
+ model: buildModel({
198
+ jobs: {
199
+ build: {
200
+ steps: [{key: 'collect', run: 'echo build'}],
201
+ outputs: {payload: template('steps.collect.outputs.payload')},
202
+ outputTypes: {payload: {kind: 'list', element: 'string'}},
203
+ },
204
+ },
205
+ }),
206
+ triggerPayload: {
207
+ source: 'manual',
208
+ event: 'fire',
209
+ subscriptionId: crypto.randomUUID(),
210
+ userId: crypto.randomUUID(),
211
+ },
212
+ });
213
+ const [job] = await getJobsByWorkflowRunId(run.id);
214
+ if (!job) throw new Error('Expected workflow job');
215
+ const execution = await getFirstJobExecutionByJobId(job.id);
216
+ if (!execution) throw new Error('Expected job execution');
217
+ await finishCollectedStep(job.id, {
218
+ payload: ['x'.repeat(MAX_JOB_OUTPUT_VALUE_BYTES - 1)],
219
+ });
220
+
221
+ const resolved = await updateJobExecutionStatus({
222
+ jobExecutionId: execution.id,
223
+ status: 'succeeded',
224
+ expectedVersion: execution.version,
225
+ });
226
+
227
+ expect(resolved).toMatchObject({status: 'failed', statusReason: 'unknown', outputs: null});
228
+ });
229
+
230
+ test('fails a successful execution when the persisted model has too many job outputs', async () => {
231
+ const outputs = Object.fromEntries(
232
+ Array.from({length: MAX_JOB_OUTPUT_ENTRIES + 1}, (_, index) => {
233
+ const key = `output${index}`;
234
+ return [key, template(`steps.collect.outputs.${key}`)];
235
+ }),
236
+ );
237
+ const run = await createWorkflowRun({
238
+ workspaceId,
239
+ projectId,
240
+ definitionId,
241
+ model: buildModel({
242
+ jobs: {
243
+ build: {
244
+ steps: [{key: 'collect', run: 'echo build'}],
245
+ outputs,
246
+ },
247
+ },
248
+ }),
249
+ triggerPayload: {
250
+ source: 'manual',
251
+ event: 'fire',
252
+ subscriptionId: crypto.randomUUID(),
253
+ userId: crypto.randomUUID(),
254
+ },
255
+ });
256
+ const [job] = await getJobsByWorkflowRunId(run.id);
257
+ if (!job) throw new Error('Expected workflow job');
258
+ const execution = await getFirstJobExecutionByJobId(job.id);
259
+ if (!execution) throw new Error('Expected job execution');
260
+ await finishCollectedStep(job.id, {});
261
+
262
+ const resolved = await updateJobExecutionStatus({
263
+ jobExecutionId: execution.id,
264
+ status: 'succeeded',
265
+ expectedVersion: execution.version,
266
+ });
267
+
268
+ expect(resolved).toMatchObject({status: 'failed', statusReason: 'unknown', outputs: null});
269
+ });
99
270
  });
271
+
272
+ async function finishCollectedStep(jobId: string, output: Record<string, unknown>): Promise<void> {
273
+ const steps = await getStepsByJobId(jobId);
274
+ const step = steps.find((candidate) => candidate.key === 'collect');
275
+ if (!step) throw new Error('Expected collect step');
276
+
277
+ await db().transaction(async (tx) => {
278
+ const running = await markStepRunning(
279
+ {jobExecutionId: step.jobExecutionId, stepId: step.id},
280
+ tx,
281
+ );
282
+ if (!running) throw new Error('Expected pending collect step');
283
+ await finishStepAttempt(
284
+ {
285
+ stepId: step.id,
286
+ attempt: running.currentAttempt,
287
+ status: 'succeeded',
288
+ output,
289
+ response: null,
290
+ exitCode: 0,
291
+ logOutcome: 'drained',
292
+ },
293
+ tx,
294
+ );
295
+ await applyStepResult(
296
+ {
297
+ jobExecutionId: step.jobExecutionId,
298
+ stepId: step.id,
299
+ status: 'succeeded',
300
+ error: null,
301
+ },
302
+ tx,
303
+ );
304
+ });
305
+ }
@@ -4,7 +4,13 @@ import {WORKFLOWS_JOB_EXECUTION_TIMED_OUT} from '@shipfox/api-workflows-dto';
4
4
  import {and, asc, desc, eq, isNull, notInArray, sql} from 'drizzle-orm';
5
5
  import type {JobStatusReason} from '#core/entities/job.js';
6
6
  import type {JobExecution, JobExecutionStatus} from '#core/entities/job-execution.js';
7
- import {InterpolationUnresolvableError, JobNotFoundError} from '#core/errors.js';
7
+ import {
8
+ InterpolationUnresolvableError,
9
+ JobNotFoundError,
10
+ JobOutputNotJsonSafeError,
11
+ JobOutputTooLargeError,
12
+ JobOutputTooManyEntriesError,
13
+ } from '#core/errors.js';
8
14
  import {deriveJobExecutionOutputs} from '#core/job-transition/index.js';
9
15
  import {deriveCompletion, isTerminal} from '#core/step-transition/decide-step-transition.js';
10
16
  import type {RuntimeCompletionStatus} from '#core/workflow-scheduling/runtime-dag.js';
@@ -207,7 +213,14 @@ async function updateJobExecutionStatusAtVersion(
207
213
  secrets: params.secrets,
208
214
  });
209
215
  } catch (error) {
210
- if (!(error instanceof InterpolationUnresolvableError)) throw error;
216
+ if (
217
+ !(error instanceof InterpolationUnresolvableError) &&
218
+ !(error instanceof JobOutputNotJsonSafeError) &&
219
+ !(error instanceof JobOutputTooLargeError) &&
220
+ !(error instanceof JobOutputTooManyEntriesError)
221
+ ) {
222
+ throw error;
223
+ }
211
224
  status = 'failed';
212
225
  statusReason = 'unknown';
213
226
  outputs = null;
@@ -473,7 +473,8 @@ describe('workflow run queries', () => {
473
473
  async function seedPendingJob() {
474
474
  const run = await createTestRun({workspaceId, projectId, definitionId});
475
475
  const jobId = (await getJobsByWorkflowRunId(run.id))[0]?.id as string;
476
- return {run, jobId};
476
+ const execution = await getFirstJobExecutionByJobId(jobId);
477
+ return {run, jobId, jobExecutionId: execution?.id};
477
478
  }
478
479
 
479
480
  test.each([
@@ -482,13 +483,19 @@ describe('workflow run queries', () => {
482
483
  'cancelled',
483
484
  'skipped',
484
485
  ] as const)('writes one terminated event when a job becomes %s', async (status) => {
485
- const {run, jobId} = await seedPendingJob();
486
+ const {run, jobId, jobExecutionId} = await seedPendingJob();
486
487
 
487
488
  await updateJobStatus({jobId, status, expectedVersion: 1});
488
489
 
489
490
  const events = await jobTerminatedEvents(jobId);
490
491
  expect(events).toHaveLength(1);
491
- expect(events[0]).toMatchObject({jobId, workflowRunId: run.id, status, statusReason: null});
492
+ expect(events[0]).toMatchObject({
493
+ jobId,
494
+ jobExecutionId,
495
+ workflowRunId: run.id,
496
+ status,
497
+ statusReason: null,
498
+ });
492
499
  });
493
500
 
494
501
  test('writes status reason on the terminated event', async () => {
@@ -3,6 +3,7 @@ import {
3
3
  buildModel,
4
4
  conditionTrace,
5
5
  jobByKey,
6
+ template,
6
7
  workflowRunAttemptId,
7
8
  } from '#test/helpers/workflow-runs.js';
8
9
  import {db} from '../db.js';
@@ -148,6 +149,54 @@ describe('evaluateJobActivations', () => {
148
149
  expect(result).toEqual([{kind: 'start-job', jobId: notify.id}]);
149
150
  });
150
151
 
152
+ it('rehydrates typed dependency outputs before activation evaluation', async () => {
153
+ const run = await createWorkflowRun({
154
+ ...scope,
155
+ name: 'typed dependency outputs',
156
+ model: buildModel({
157
+ jobs: {
158
+ build: {
159
+ steps: [{key: 'collect', run: 'npm run build'}],
160
+ outputs: {
161
+ count: template('steps.collect.outputs.count'),
162
+ createdAt: template('run.created_at'),
163
+ },
164
+ outputTypes: {count: 'int', createdAt: 'timestamp'},
165
+ },
166
+ notify: {
167
+ needs: 'build',
168
+ if: 'jobs.build.outputs.count + 1 == 43 && jobs.build.outputs.createdAt < timestamp("2026-07-01T00:00:00Z")',
169
+ steps: [{run: './notify.sh'}],
170
+ },
171
+ },
172
+ }),
173
+ triggerPayload: {
174
+ source: 'manual',
175
+ event: 'fire',
176
+ subscriptionId: crypto.randomUUID(),
177
+ userId: crypto.randomUUID(),
178
+ },
179
+ });
180
+ const build = await jobByKey(run.id, 'build');
181
+ const notify = await jobByKey(run.id, 'notify');
182
+ await updateJobStatus({
183
+ jobId: build.id,
184
+ status: 'succeeded',
185
+ expectedVersion: build.version,
186
+ });
187
+ await db()
188
+ .update(jobs)
189
+ .set({outputs: {count: '42', createdAt: '2026-06-30T12:00:00.000Z'}})
190
+ .where(eq(jobs.id, build.id));
191
+
192
+ const result = await evaluateJobActivations({
193
+ runAttemptId: await workflowRunAttemptId(run.id),
194
+ jobs: [{jobId: notify.id, expectedVersion: notify.version}],
195
+ });
196
+
197
+ expect(result).toEqual([{kind: 'start-job', jobId: notify.id}]);
198
+ });
199
+
151
200
  it('records condition_errored when predicate evaluation fails closed', async () => {
152
201
  const run = await createWorkflowRun({
153
202
  ...scope,
@@ -1,5 +1,6 @@
1
1
  import {readPersistedWorkflowModel} from '@shipfox/api-definitions-dto';
2
2
  import {WORKFLOWS_JOB_TERMINATED} from '@shipfox/api-workflows-dto';
3
+ import type {ExpressionType} from '@shipfox/expression';
3
4
  import {and, asc, desc, eq, inArray, notInArray, sql} from 'drizzle-orm';
4
5
  import {isJobTerminal, type Job, type JobStatus, type JobStatusReason} from '#core/entities/job.js';
5
6
  import type {JobExecution} from '#core/entities/job-execution.js';
@@ -24,6 +25,16 @@ import {toWorkflowRun, workflowRuns} from '../schema/workflow-runs.js';
24
25
  import {getWorkflowRunById} from './queries.js';
25
26
  import {getWorkflowContextForJob, optimisticLockRetry, TERMINAL_JOB_STATUSES} from './shared.js';
26
27
 
28
+ function outputTypesByJobKey(
29
+ model: ReturnType<typeof readPersistedWorkflowModel> | null,
30
+ ): ReadonlyMap<string, Readonly<Record<string, ExpressionType>>> {
31
+ return new Map(
32
+ (model?.jobs ?? []).flatMap((job) =>
33
+ job.outputTypes === undefined ? [] : [[job.key, job.outputTypes] as const],
34
+ ),
35
+ );
36
+ }
37
+
27
38
  export async function getJobsByWorkflowRunAttemptId(workflowRunAttemptId: string): Promise<Job[]> {
28
39
  const rows = await db()
29
40
  .select()
@@ -81,9 +92,17 @@ export async function getDirectDependencyJobContexts(
81
92
  jobId: string,
82
93
  tx?: Tx,
83
94
  ): Promise<JobContextInput[]> {
84
- const targetRows = await (tx ?? db()).select().from(jobs).where(eq(jobs.id, jobId)).limit(1);
95
+ const targetRows = await (tx ?? db())
96
+ .select({job: jobs, attempt: workflowRunAttempts})
97
+ .from(jobs)
98
+ .innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
99
+ .where(eq(jobs.id, jobId))
100
+ .limit(1);
85
101
  const target = targetRows[0];
86
- if (!target || target.dependencies.length === 0) return [];
102
+ if (!target || target.job.dependencies.length === 0) return [];
103
+ const model =
104
+ target.attempt.model === null ? null : readPersistedWorkflowModel(target.attempt.model);
105
+ const outputTypes = outputTypesByJobKey(model);
87
106
 
88
107
  const rows = await (tx ?? db())
89
108
  .select({job: jobs, execution: jobExecutions})
@@ -91,8 +110,8 @@ export async function getDirectDependencyJobContexts(
91
110
  .leftJoin(jobExecutions, eq(jobExecutions.jobId, jobs.id))
92
111
  .where(
93
112
  and(
94
- eq(jobs.workflowRunAttemptId, target.workflowRunAttemptId),
95
- inArray(jobs.key, target.dependencies),
113
+ eq(jobs.workflowRunAttemptId, target.job.workflowRunAttemptId),
114
+ inArray(jobs.key, target.job.dependencies),
96
115
  ),
97
116
  )
98
117
  .orderBy(asc(jobs.position), asc(jobs.id), asc(jobExecutions.sequence), asc(jobExecutions.id));
@@ -101,7 +120,12 @@ export async function getDirectDependencyJobContexts(
101
120
  for (const row of rows) {
102
121
  let context = contextsByJobId.get(row.job.id);
103
122
  if (!context) {
104
- context = {job: toJob(row.job), executions: []};
123
+ const jobOutputTypes = outputTypes.get(row.job.key);
124
+ context = {
125
+ job: toJob(row.job),
126
+ ...(jobOutputTypes === undefined ? {} : {outputTypes: jobOutputTypes}),
127
+ executions: [],
128
+ };
105
129
  contextsByJobId.set(row.job.id, context);
106
130
  }
107
131
  if (row.execution)
@@ -243,6 +267,17 @@ async function directDependencyContextsByJobKey(
243
267
  const dependencyKeys = new Set(targetJobs.flatMap((job) => job.dependencies));
244
268
  if (dependencyKeys.size === 0) return new Map();
245
269
 
270
+ const [attempt] = await tx
271
+ .select({model: workflowRunAttempts.model})
272
+ .from(workflowRunAttempts)
273
+ .where(eq(workflowRunAttempts.id, runAttemptId))
274
+ .limit(1);
275
+ const model =
276
+ attempt === undefined || attempt.model === null
277
+ ? null
278
+ : readPersistedWorkflowModel(attempt.model);
279
+ const outputTypes = outputTypesByJobKey(model);
280
+
246
281
  const rows = await tx
247
282
  .select({job: jobs, execution: jobExecutions})
248
283
  .from(jobs)
@@ -254,7 +289,12 @@ async function directDependencyContextsByJobKey(
254
289
  for (const row of rows) {
255
290
  let context = contextsByJobKey.get(row.job.key);
256
291
  if (!context) {
257
- context = {job: toJob(row.job), executions: []};
292
+ const jobOutputTypes = outputTypes.get(row.job.key);
293
+ context = {
294
+ job: toJob(row.job),
295
+ ...(jobOutputTypes === undefined ? {} : {outputTypes: jobOutputTypes}),
296
+ executions: [],
297
+ };
258
298
  contextsByJobKey.set(row.job.key, context);
259
299
  }
260
300
  if (row.execution)
@@ -307,11 +347,18 @@ export async function updateJobStatusAtVersion(
307
347
  // version match lets a single caller win. Emitting here, in the same transaction,
308
348
  // makes the terminal fact fire exactly once across all paths.
309
349
  if (isJobTerminal(job.status)) {
350
+ const [currentExecution] = await tx
351
+ .select({id: jobExecutions.id})
352
+ .from(jobExecutions)
353
+ .where(eq(jobExecutions.jobId, job.id))
354
+ .orderBy(desc(jobExecutions.sequence), desc(jobExecutions.id))
355
+ .limit(1);
310
356
  const identity = await getWorkflowContextForJob(job.id, tx);
311
357
  await writeWorkflowsOutboxEvent(tx, {
312
358
  type: WORKFLOWS_JOB_TERMINATED,
313
359
  payload: {
314
360
  jobId: job.id,
361
+ jobExecutionId: currentExecution?.id ?? null,
315
362
  workflowRunId: identity.workflowRunId,
316
363
  workflowRunAttemptId: identity.workflowRunAttemptId,
317
364
  status: job.status,
@@ -35,7 +35,12 @@ export async function writeJobStepsSettledOutbox(
35
35
 
36
36
  export async function writeStepAttemptTerminatedOutbox(
37
37
  tx: Tx,
38
- params: {stepId: string; attempt: number; logOutcome: LogOutcomeDto},
38
+ params: {
39
+ stepId: string;
40
+ attempt: number;
41
+ status: 'succeeded' | 'failed' | 'cancelled';
42
+ logOutcome: LogOutcomeDto;
43
+ },
39
44
  ): Promise<void> {
40
45
  const identity = await getStepAttemptTerminatedOutboxIdentity(tx, params.stepId);
41
46
 
@@ -49,6 +54,7 @@ export async function writeStepAttemptTerminatedOutbox(
49
54
  projectId: identity.projectId,
50
55
  stepId: params.stepId,
51
56
  attempt: params.attempt,
57
+ status: params.status,
52
58
  logOutcome: params.logOutcome,
53
59
  },
54
60
  });
@@ -578,6 +578,62 @@ describe('workflow run queries', () => {
578
578
  });
579
579
  });
580
580
 
581
+ test('creates a run with indexed structured dependency output config', async () => {
582
+ const run = await createWorkflowRun({
583
+ workspaceId,
584
+ projectId,
585
+ definitionId,
586
+ model: buildModel({
587
+ jobs: {
588
+ review: {
589
+ steps: [{key: 'inspect', run: 'echo inspect'}],
590
+ outputs: {findings: template('steps.inspect.outputs.findings')},
591
+ outputTypes: {
592
+ findings: {
593
+ kind: 'list',
594
+ element: {kind: 'object', fields: {severity: 'string'}},
595
+ },
596
+ },
597
+ },
598
+ summarize: {
599
+ needs: 'review',
600
+ steps: [
601
+ {
602
+ key: 'consume',
603
+ run: `test "${template('jobs.review.outputs.findings[0].severity')}" = high\necho "structured severity=${template('jobs.review.outputs.findings[0].severity')}"`,
604
+ },
605
+ ],
606
+ },
607
+ },
608
+ }),
609
+ triggerPayload: {
610
+ source: 'manual',
611
+ event: 'fire',
612
+ subscriptionId: crypto.randomUUID(),
613
+ userId: crypto.randomUUID(),
614
+ },
615
+ resolveAgentDefaults: resolveTestAgentDefaults,
616
+ });
617
+
618
+ const summarize = (await getJobsByWorkflowRunId(run.id)).find(
619
+ (job) => job.key === 'summarize',
620
+ );
621
+ if (!summarize) throw new Error('Expected summarize job');
622
+ const [, consume] = await getStepsByJobId(summarize.id);
623
+ expect(consume?.configPlan).toMatchObject({
624
+ env: {
625
+ __sf_0: {
626
+ segments: [
627
+ {
628
+ kind: 'deferred',
629
+ expression: {source: 'jobs.review.outputs.findings[0].severity'},
630
+ },
631
+ ],
632
+ },
633
+ },
634
+ });
635
+ });
636
+
581
637
  test('persists the parsed model on the run attempt', async () => {
582
638
  const model = buildModel({
583
639
  env: {RUN_ID: template('run.id')},