@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +45 -0
- package/README.md +20 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +56 -0
- package/dist/config.js.map +1 -0
- package/dist/core/entities/job.d.ts +1 -1
- package/dist/core/entities/job.d.ts.map +1 -1
- package/dist/core/entities/step.d.ts +2 -2
- package/dist/core/entities/step.d.ts.map +1 -1
- package/dist/core/errors.d.ts +18 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +21 -2
- package/dist/core/errors.js.map +1 -1
- package/dist/core/step-config/assemble-run-context.d.ts +15 -5
- package/dist/core/step-config/assemble-run-context.d.ts.map +1 -1
- package/dist/core/step-config/assemble-run-context.js +77 -27
- package/dist/core/step-config/assemble-run-context.js.map +1 -1
- package/dist/core/step-config/fields.d.ts +5 -0
- package/dist/core/step-config/fields.d.ts.map +1 -1
- package/dist/core/step-config/fields.js +21 -3
- package/dist/core/step-config/fields.js.map +1 -1
- package/dist/core/step-config/index.d.ts +1 -1
- package/dist/core/step-config/index.d.ts.map +1 -1
- package/dist/core/step-config/index.js +1 -1
- package/dist/core/step-config/index.js.map +1 -1
- package/dist/core/step-config/job-output-limits.d.ts +11 -0
- package/dist/core/step-config/job-output-limits.d.ts.map +1 -0
- package/dist/core/step-config/job-output-limits.js +88 -0
- package/dist/core/step-config/job-output-limits.js.map +1 -0
- package/dist/core/step-config/materialize-workflow-model.d.ts +1 -1
- package/dist/core/step-config/materialize-workflow-model.d.ts.map +1 -1
- package/dist/core/step-config/materialize-workflow-model.js +47 -18
- package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
- package/dist/db/db.d.ts +50 -50
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/job-listeners.d.ts.map +1 -1
- package/dist/db/job-listeners.js +6 -5
- package/dist/db/job-listeners.js.map +1 -1
- package/dist/db/schema/job-executions.d.ts +3 -3
- package/dist/db/schema/job-listener-events.d.ts +1 -1
- package/dist/db/schema/jobs.d.ts +7 -7
- package/dist/db/schema/outbox.d.ts +1 -1
- package/dist/db/schema/step-attempts.d.ts +2 -2
- package/dist/db/schema/steps.d.ts +5 -5
- package/dist/db/schema/workflow-run-attempts.d.ts +3 -3
- package/dist/db/schema/workflow-run-counters.d.ts +1 -1
- package/dist/db/schema/workflow-runs.d.ts +2 -2
- package/dist/db/workflow-runs/job-executions.d.ts.map +1 -1
- package/dist/db/workflow-runs/job-executions.js +4 -2
- package/dist/db/workflow-runs/job-executions.js.map +1 -1
- package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
- package/dist/db/workflow-runs/jobs.js +33 -3
- package/dist/db/workflow-runs/jobs.js.map +1 -1
- package/dist/db/workflow-runs/outbox.d.ts +1 -0
- package/dist/db/workflow-runs/outbox.d.ts.map +1 -1
- package/dist/db/workflow-runs/outbox.js +1 -0
- package/dist/db/workflow-runs/outbox.js.map +1 -1
- package/dist/db/workflow-runs/steps.d.ts +28 -0
- package/dist/db/workflow-runs/steps.d.ts.map +1 -1
- package/dist/db/workflow-runs/steps.js +49 -1
- package/dist/db/workflow-runs/steps.js.map +1 -1
- package/dist/db/workflow-runs.d.ts +2 -2
- package/dist/db/workflow-runs.d.ts.map +1 -1
- package/dist/db/workflow-runs.js +1 -1
- package/dist/db/workflow-runs.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +1 -0
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +8 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/dto/evaluation-trace.d.ts +4 -0
- package/dist/presentation/dto/evaluation-trace.d.ts.map +1 -0
- package/dist/presentation/dto/evaluation-trace.js +39 -0
- package/dist/presentation/dto/evaluation-trace.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -1
- package/dist/presentation/dto/index.d.ts.map +1 -1
- package/dist/presentation/dto/index.js +2 -1
- package/dist/presentation/dto/index.js.map +1 -1
- package/dist/presentation/dto/job.d.ts.map +1 -1
- package/dist/presentation/dto/job.js +6 -0
- package/dist/presentation/dto/job.js.map +1 -1
- package/dist/presentation/dto/step.d.ts +2 -1
- package/dist/presentation/dto/step.d.ts.map +1 -1
- package/dist/presentation/dto/step.js +12 -0
- package/dist/presentation/dto/step.js.map +1 -1
- package/dist/presentation/index.d.ts +1 -1
- package/dist/presentation/index.d.ts.map +1 -1
- package/dist/presentation/index.js +1 -1
- package/dist/presentation/index.js.map +1 -1
- package/dist/presentation/routes/get-step-attempt-detail.d.ts +3 -0
- package/dist/presentation/routes/get-step-attempt-detail.d.ts.map +1 -0
- package/dist/presentation/routes/get-step-attempt-detail.js +38 -0
- package/dist/presentation/routes/get-step-attempt-detail.js.map +1 -0
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +2 -0
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/project-access.d.ts +1 -1
- package/dist/presentation/subscribers/index.d.ts +1 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -1
- package/dist/presentation/subscribers/index.js +1 -0
- package/dist/presentation/subscribers/index.js.map +1 -1
- package/dist/presentation/subscribers/on-failure-annotations.d.ts +5 -0
- package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-failure-annotations.js +160 -0
- package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +1 -1
- package/dist/temporal/activities/index.d.ts.map +1 -1
- package/dist/temporal/activities/orchestration-activities.d.ts +1 -1
- package/dist/temporal/activities/orchestration-activities.d.ts.map +1 -1
- package/dist/temporal/workflows/test-env.d.ts +2 -2
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +18 -16
- package/src/config.test.ts +85 -0
- package/src/config.ts +71 -0
- package/src/core/errors.ts +44 -2
- package/src/core/job-execution.test.ts +1 -0
- package/src/core/step-config/assemble-run-context.test.ts +232 -20
- package/src/core/step-config/assemble-run-context.ts +126 -28
- package/src/core/step-config/fields.ts +42 -3
- package/src/core/step-config/index.ts +2 -0
- package/src/core/step-config/job-output-limits.ts +130 -0
- package/src/core/step-config/materialize-workflow-model.catalog.test.ts +117 -0
- package/src/core/step-config/materialize-workflow-model.test.ts +306 -1
- package/src/core/step-config/materialize-workflow-model.ts +65 -18
- package/src/db/index.ts +4 -0
- package/src/db/job-listeners.ts +14 -3
- package/src/db/workflow-runs/job-executions.test.ts +207 -1
- package/src/db/workflow-runs/job-executions.ts +15 -2
- package/src/db/workflow-runs/job-status.test.ts +10 -3
- package/src/db/workflow-runs/jobs.test.ts +49 -0
- package/src/db/workflow-runs/jobs.ts +53 -6
- package/src/db/workflow-runs/outbox.ts +7 -1
- package/src/db/workflow-runs/run-create.test.ts +56 -0
- package/src/db/workflow-runs/steps.test.ts +148 -0
- package/src/db/workflow-runs/steps.ts +105 -1
- package/src/db/workflow-runs.ts +4 -0
- package/src/index.ts +9 -0
- package/src/metrics/instance.ts +10 -0
- package/src/presentation/dto/evaluation-trace.ts +27 -0
- package/src/presentation/dto/index.ts +2 -1
- package/src/presentation/dto/job.ts +6 -0
- package/src/presentation/dto/step.test.ts +96 -1
- package/src/presentation/dto/step.ts +17 -0
- package/src/presentation/index.ts +2 -0
- package/src/presentation/routes/get-step-attempt-detail.test.ts +159 -0
- package/src/presentation/routes/get-step-attempt-detail.ts +33 -0
- package/src/presentation/routes/index.ts +2 -0
- package/src/presentation/subscribers/index.ts +4 -0
- package/src/presentation/subscribers/on-failure-annotations.test.ts +510 -0
- package/src/presentation/subscribers/on-failure-annotations.ts +237 -0
- package/test/factories/workflow-model.ts +2 -0
- 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 {
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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({
|
|
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())
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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')},
|