@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
|
@@ -11,7 +11,10 @@ import {stepAttempts as stepAttemptsTable} from '../schema/step-attempts.js';
|
|
|
11
11
|
import {steps as stepsTable} from '../schema/steps.js';
|
|
12
12
|
import {
|
|
13
13
|
createWorkflowRun,
|
|
14
|
+
getFirstJobExecutionByJobId,
|
|
15
|
+
getJobExecutionFailureOrigin,
|
|
14
16
|
getJobsByWorkflowRunId,
|
|
17
|
+
getStepAttemptDetail,
|
|
15
18
|
getStepAttempts,
|
|
16
19
|
getStepsByJobId,
|
|
17
20
|
} from '../workflow-runs.js';
|
|
@@ -60,6 +63,150 @@ describe('workflow run queries', () => {
|
|
|
60
63
|
});
|
|
61
64
|
});
|
|
62
65
|
|
|
66
|
+
describe('getStepAttemptDetail', () => {
|
|
67
|
+
test('joins a step attempt to its run for lazy troubleshooting reads', async () => {
|
|
68
|
+
const run = await createWorkflowRun({
|
|
69
|
+
workspaceId,
|
|
70
|
+
projectId,
|
|
71
|
+
definitionId,
|
|
72
|
+
model: buildModel({jobs: {build: {steps: [{run: 'echo hello'}]}}}),
|
|
73
|
+
triggerPayload: {
|
|
74
|
+
source: 'manual',
|
|
75
|
+
event: 'fire',
|
|
76
|
+
subscriptionId: crypto.randomUUID(),
|
|
77
|
+
userId: crypto.randomUUID(),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const [job] = await getJobsByWorkflowRunId(run.id);
|
|
81
|
+
if (!job) throw new Error('Expected a workflow job');
|
|
82
|
+
await stripSetupStep(job.id);
|
|
83
|
+
await nextStepForJob(job.id);
|
|
84
|
+
|
|
85
|
+
const [attempt] = await getStepAttempts(job.id);
|
|
86
|
+
if (!attempt) throw new Error('Expected a dispatched step attempt');
|
|
87
|
+
|
|
88
|
+
const detail = await getStepAttemptDetail({stepId: attempt.stepId, attempt: attempt.attempt});
|
|
89
|
+
|
|
90
|
+
expect(detail).toMatchObject({
|
|
91
|
+
workflowRunId: run.id,
|
|
92
|
+
workflowRunAttemptId: job.workflowRunAttemptId,
|
|
93
|
+
step: {id: attempt.stepId},
|
|
94
|
+
attempt: {id: attempt.id, attempt: attempt.attempt},
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('getJobExecutionFailureOrigin', () => {
|
|
100
|
+
test('selects the current failed attempt instead of loading attempt history', async () => {
|
|
101
|
+
const run = await createWorkflowRun({
|
|
102
|
+
workspaceId,
|
|
103
|
+
projectId,
|
|
104
|
+
definitionId,
|
|
105
|
+
model: buildModel({
|
|
106
|
+
jobs: {build: {steps: [{run: 'echo hello'}, {run: 'echo goodbye'}]}},
|
|
107
|
+
}),
|
|
108
|
+
triggerPayload: {
|
|
109
|
+
source: 'manual',
|
|
110
|
+
event: 'fire',
|
|
111
|
+
subscriptionId: crypto.randomUUID(),
|
|
112
|
+
userId: crypto.randomUUID(),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const [job] = await getJobsByWorkflowRunId(run.id);
|
|
116
|
+
if (!job) throw new Error('Expected a workflow job');
|
|
117
|
+
await stripSetupStep(job.id);
|
|
118
|
+
const execution = await getFirstJobExecutionByJobId(job.id);
|
|
119
|
+
if (!execution) throw new Error('Expected a job execution');
|
|
120
|
+
await nextStepForJob(job.id);
|
|
121
|
+
|
|
122
|
+
const [step] = await getStepsByJobId(job.id);
|
|
123
|
+
const [attempt] = await getStepAttempts(job.id);
|
|
124
|
+
if (!step || !attempt) throw new Error('Expected a current step attempt');
|
|
125
|
+
await db()
|
|
126
|
+
.update(stepsTable)
|
|
127
|
+
.set({status: 'failed', error: {reason: 'command_failed', message: 'current failure'}})
|
|
128
|
+
.where(eq(stepsTable.id, step.id));
|
|
129
|
+
await db()
|
|
130
|
+
.update(stepAttemptsTable)
|
|
131
|
+
.set({
|
|
132
|
+
status: 'failed',
|
|
133
|
+
error: {reason: 'command_failed', message: 'current failure'},
|
|
134
|
+
finishedAt: new Date(),
|
|
135
|
+
})
|
|
136
|
+
.where(eq(stepAttemptsTable.id, attempt.id));
|
|
137
|
+
|
|
138
|
+
await expect(getJobExecutionFailureOrigin(execution.id)).resolves.toMatchObject({
|
|
139
|
+
jobExecutionId: execution.id,
|
|
140
|
+
stepId: step.id,
|
|
141
|
+
stepAttempt: attempt.attempt,
|
|
142
|
+
attemptStatus: 'failed',
|
|
143
|
+
attemptError: {message: 'current failure'},
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('returns the first step when the execution fails before any attempt starts', async () => {
|
|
148
|
+
const run = await createWorkflowRun({
|
|
149
|
+
workspaceId,
|
|
150
|
+
projectId,
|
|
151
|
+
definitionId,
|
|
152
|
+
model: buildModel({jobs: {build: {steps: [{run: 'echo hello'}]}}}),
|
|
153
|
+
triggerPayload: {
|
|
154
|
+
source: 'manual',
|
|
155
|
+
event: 'fire',
|
|
156
|
+
subscriptionId: crypto.randomUUID(),
|
|
157
|
+
userId: crypto.randomUUID(),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
const [job] = await getJobsByWorkflowRunId(run.id);
|
|
161
|
+
if (!job) throw new Error('Expected a workflow job');
|
|
162
|
+
const execution = await getFirstJobExecutionByJobId(job.id);
|
|
163
|
+
if (!execution) throw new Error('Expected a job execution');
|
|
164
|
+
|
|
165
|
+
await expect(getJobExecutionFailureOrigin(execution.id)).resolves.toMatchObject({
|
|
166
|
+
jobExecutionId: execution.id,
|
|
167
|
+
stepAttempt: 1,
|
|
168
|
+
attemptStatus: null,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('selects a condition-errored step before an earlier pending step', async () => {
|
|
173
|
+
const run = await createWorkflowRun({
|
|
174
|
+
workspaceId,
|
|
175
|
+
projectId,
|
|
176
|
+
definitionId,
|
|
177
|
+
model: buildModel({
|
|
178
|
+
jobs: {build: {steps: [{run: 'echo first'}, {run: 'echo second'}]}},
|
|
179
|
+
}),
|
|
180
|
+
triggerPayload: {
|
|
181
|
+
source: 'manual',
|
|
182
|
+
event: 'fire',
|
|
183
|
+
subscriptionId: crypto.randomUUID(),
|
|
184
|
+
userId: crypto.randomUUID(),
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const [job] = await getJobsByWorkflowRunId(run.id);
|
|
188
|
+
if (!job) throw new Error('Expected a workflow job');
|
|
189
|
+
await stripSetupStep(job.id);
|
|
190
|
+
const execution = await getFirstJobExecutionByJobId(job.id);
|
|
191
|
+
if (!execution) throw new Error('Expected a job execution');
|
|
192
|
+
|
|
193
|
+
const steps = await getStepsByJobId(job.id);
|
|
194
|
+
const firstStep = steps[0];
|
|
195
|
+
const conditionErroredStep = steps[1];
|
|
196
|
+
if (!firstStep || !conditionErroredStep) throw new Error('Expected two workflow steps');
|
|
197
|
+
await db()
|
|
198
|
+
.update(stepsTable)
|
|
199
|
+
.set({status: 'skipped', statusReason: 'condition_errored'})
|
|
200
|
+
.where(eq(stepsTable.id, conditionErroredStep.id));
|
|
201
|
+
|
|
202
|
+
await expect(getJobExecutionFailureOrigin(execution.id)).resolves.toMatchObject({
|
|
203
|
+
stepId: conditionErroredStep.id,
|
|
204
|
+
stepStatus: 'skipped',
|
|
205
|
+
});
|
|
206
|
+
expect(firstStep.position).toBeLessThan(conditionErroredStep.position);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
63
210
|
describe('bulkUpdateStepStatuses', () => {
|
|
64
211
|
test('updates all steps for a job to the given sweep status', async () => {
|
|
65
212
|
const run = await createWorkflowRun({
|
|
@@ -183,6 +330,7 @@ describe('workflow run queries', () => {
|
|
|
183
330
|
projectId,
|
|
184
331
|
stepId: attempt?.stepId,
|
|
185
332
|
attempt: 1,
|
|
333
|
+
status: 'cancelled',
|
|
186
334
|
logOutcome: 'abandoned',
|
|
187
335
|
},
|
|
188
336
|
]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {LogOutcomeDto} from '@shipfox/api-workflows-dto';
|
|
2
|
-
import {and, asc, count, eq, gte, inArray, sql} from 'drizzle-orm';
|
|
2
|
+
import {and, asc, count, desc, eq, gte, inArray, sql} from 'drizzle-orm';
|
|
3
3
|
import type {
|
|
4
4
|
PersistedEvaluationTraceEntry,
|
|
5
5
|
Step,
|
|
@@ -11,8 +11,11 @@ import type {
|
|
|
11
11
|
import {deriveCompletion, isTerminal} from '#core/step-transition/decide-step-transition.js';
|
|
12
12
|
import {db, type Tx} from '../db.js';
|
|
13
13
|
import {jobExecutions} from '../schema/job-executions.js';
|
|
14
|
+
import {jobs} from '../schema/jobs.js';
|
|
14
15
|
import {stepAttempts, toStepAttempt} from '../schema/step-attempts.js';
|
|
15
16
|
import {steps, toStep} from '../schema/steps.js';
|
|
17
|
+
import {workflowRunAttempts} from '../schema/workflow-run-attempts.js';
|
|
18
|
+
import {workflowRuns} from '../schema/workflow-runs.js';
|
|
16
19
|
import {writeJobStepsSettledOutbox, writeStepAttemptTerminatedOutbox} from './outbox.js';
|
|
17
20
|
import {NON_TERMINAL_STEP_STATUS_FILTER} from './shared.js';
|
|
18
21
|
|
|
@@ -39,6 +42,105 @@ export async function getStepById(stepId: string): Promise<Step | undefined> {
|
|
|
39
42
|
return toStep(row);
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
export interface StepAttemptDetail {
|
|
46
|
+
workflowRunId: string;
|
|
47
|
+
workflowRunAttemptId: string;
|
|
48
|
+
step: Step;
|
|
49
|
+
attempt: StepAttempt;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface JobExecutionFailureOrigin {
|
|
53
|
+
jobExecutionId: string;
|
|
54
|
+
stepId: string;
|
|
55
|
+
stepName: string;
|
|
56
|
+
stepStatus: StepStatus;
|
|
57
|
+
stepAttempt: number;
|
|
58
|
+
stepError: Record<string, unknown> | null;
|
|
59
|
+
attemptStatus: StepAttemptStatus | null;
|
|
60
|
+
attemptError: Record<string, unknown> | null;
|
|
61
|
+
attemptExitCode: number | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read only the current attempt for the most relevant step. The terminal job event identifies
|
|
66
|
+
* the execution, so this intentionally avoids hydrating the execution's complete attempt
|
|
67
|
+
* history. If no attempt was dispatched, the first step still gives the projection a stable
|
|
68
|
+
* troubleshooting origin for failures that happened before step work started.
|
|
69
|
+
*/
|
|
70
|
+
export async function getJobExecutionFailureOrigin(
|
|
71
|
+
jobExecutionId: string,
|
|
72
|
+
): Promise<JobExecutionFailureOrigin | undefined> {
|
|
73
|
+
const rows = await db()
|
|
74
|
+
.select({
|
|
75
|
+
jobExecutionId: jobExecutions.id,
|
|
76
|
+
stepId: steps.id,
|
|
77
|
+
stepName: steps.name,
|
|
78
|
+
stepStatus: steps.status,
|
|
79
|
+
stepAttempt: steps.currentAttempt,
|
|
80
|
+
stepError: steps.error,
|
|
81
|
+
attemptStatus: stepAttempts.status,
|
|
82
|
+
attemptError: stepAttempts.error,
|
|
83
|
+
attemptExitCode: stepAttempts.exitCode,
|
|
84
|
+
})
|
|
85
|
+
.from(jobExecutions)
|
|
86
|
+
.innerJoin(steps, eq(steps.jobExecutionId, jobExecutions.id))
|
|
87
|
+
.leftJoin(
|
|
88
|
+
stepAttempts,
|
|
89
|
+
and(eq(stepAttempts.stepId, steps.id), eq(stepAttempts.attempt, steps.currentAttempt)),
|
|
90
|
+
)
|
|
91
|
+
.where(eq(jobExecutions.id, jobExecutionId))
|
|
92
|
+
.orderBy(
|
|
93
|
+
desc(
|
|
94
|
+
sql<number>`case when ${steps.status} = 'failed' or ${stepAttempts.status} = 'failed' or ${steps.statusReason} = 'condition_errored' then 1 else 0 end`,
|
|
95
|
+
),
|
|
96
|
+
asc(steps.position),
|
|
97
|
+
asc(steps.id),
|
|
98
|
+
)
|
|
99
|
+
.limit(1);
|
|
100
|
+
|
|
101
|
+
const row = rows[0];
|
|
102
|
+
if (!row) return undefined;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
...row,
|
|
106
|
+
stepError: (row.stepError as Record<string, unknown> | null) ?? null,
|
|
107
|
+
attemptError: (row.attemptError as Record<string, unknown> | null) ?? null,
|
|
108
|
+
attemptStatus: row.attemptStatus === null ? null : (row.attemptStatus as StepAttemptStatus),
|
|
109
|
+
attemptExitCode: row.attemptExitCode ?? null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function getStepAttemptDetail(params: {
|
|
114
|
+
stepId: string;
|
|
115
|
+
attempt: number;
|
|
116
|
+
}): Promise<StepAttemptDetail | undefined> {
|
|
117
|
+
const rows = await db()
|
|
118
|
+
.select({
|
|
119
|
+
workflowRunId: workflowRuns.id,
|
|
120
|
+
workflowRunAttemptId: workflowRunAttempts.id,
|
|
121
|
+
step: steps,
|
|
122
|
+
stepAttempt: stepAttempts,
|
|
123
|
+
})
|
|
124
|
+
.from(stepAttempts)
|
|
125
|
+
.innerJoin(steps, eq(stepAttempts.stepId, steps.id))
|
|
126
|
+
.innerJoin(jobExecutions, eq(steps.jobExecutionId, jobExecutions.id))
|
|
127
|
+
.innerJoin(jobs, eq(jobExecutions.jobId, jobs.id))
|
|
128
|
+
.innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
|
|
129
|
+
.innerJoin(workflowRuns, eq(workflowRunAttempts.workflowRunId, workflowRuns.id))
|
|
130
|
+
.where(and(eq(stepAttempts.stepId, params.stepId), eq(stepAttempts.attempt, params.attempt)))
|
|
131
|
+
.limit(1);
|
|
132
|
+
|
|
133
|
+
const row = rows[0];
|
|
134
|
+
if (!row) return undefined;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
workflowRunId: row.workflowRunId,
|
|
138
|
+
workflowRunAttemptId: row.workflowRunAttemptId,
|
|
139
|
+
step: toStep(row.step),
|
|
140
|
+
attempt: toStepAttempt(row.stepAttempt),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
42
144
|
export async function getStepsByJobId(jobId: string): Promise<Step[]> {
|
|
43
145
|
const rows = await db()
|
|
44
146
|
.select({step: steps})
|
|
@@ -105,6 +207,7 @@ export async function bulkUpdateStepStatuses(
|
|
|
105
207
|
await writeStepAttemptTerminatedOutbox(tx, {
|
|
106
208
|
stepId: attempt.stepId,
|
|
107
209
|
attempt: attempt.attempt,
|
|
210
|
+
status: params.status,
|
|
108
211
|
logOutcome: attempt.logOutcome ?? 'abandoned',
|
|
109
212
|
});
|
|
110
213
|
}
|
|
@@ -367,6 +470,7 @@ export async function finishStepAttempt(params: FinishStepAttemptParams, tx: Tx)
|
|
|
367
470
|
await writeStepAttemptTerminatedOutbox(tx, {
|
|
368
471
|
stepId: row.stepId,
|
|
369
472
|
attempt: row.attempt,
|
|
473
|
+
status: params.status,
|
|
370
474
|
logOutcome: row.logOutcome ?? params.logOutcome,
|
|
371
475
|
});
|
|
372
476
|
}
|
package/src/db/workflow-runs.ts
CHANGED
|
@@ -90,9 +90,11 @@ export type {
|
|
|
90
90
|
DispatchStepWithCompletedConfigParams,
|
|
91
91
|
FinishStepAttemptParams,
|
|
92
92
|
InsertRunningStepAttemptParams,
|
|
93
|
+
JobExecutionFailureOrigin,
|
|
93
94
|
MarkStepRunningParams,
|
|
94
95
|
MarkStepSkippedParams,
|
|
95
96
|
RewindStepsToPendingParams,
|
|
97
|
+
StepAttemptDetail,
|
|
96
98
|
} from './workflow-runs/steps.js';
|
|
97
99
|
export {
|
|
98
100
|
applyStepResult,
|
|
@@ -101,6 +103,8 @@ export {
|
|
|
101
103
|
countStepAttempts,
|
|
102
104
|
dispatchStepWithCompletedConfig,
|
|
103
105
|
finishStepAttempt,
|
|
106
|
+
getJobExecutionFailureOrigin,
|
|
107
|
+
getStepAttemptDetail,
|
|
104
108
|
getStepAttempts,
|
|
105
109
|
getStepAttemptsByJobExecutionId,
|
|
106
110
|
getStepAttemptsByJobIds,
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ import type {SecretsInterModuleClient} from '@shipfox/api-secrets-dto/inter-modu
|
|
|
17
17
|
import {
|
|
18
18
|
WORKFLOWS_JOB_EVENT_DELIVERED,
|
|
19
19
|
WORKFLOWS_JOB_STEPS_SETTLED,
|
|
20
|
+
WORKFLOWS_JOB_TERMINATED,
|
|
21
|
+
WORKFLOWS_STEP_ATTEMPT_TERMINATED,
|
|
20
22
|
WORKFLOWS_WORKFLOW_RUN_ATTEMPT_CREATED,
|
|
21
23
|
WORKFLOWS_WORKFLOW_RUN_CANCELLED,
|
|
22
24
|
type WorkflowsEventMapDto,
|
|
@@ -30,9 +32,11 @@ import {
|
|
|
30
32
|
createWorkflowRoutes,
|
|
31
33
|
onJobEventDelivered,
|
|
32
34
|
onJobStepsSettled,
|
|
35
|
+
onJobTerminatedFailureAnnotation,
|
|
33
36
|
onRunnerJobClaimed,
|
|
34
37
|
onRunnerJobLeaseExpired,
|
|
35
38
|
onRunnerJobQueued,
|
|
39
|
+
onStepAttemptTerminatedFailureAnnotation,
|
|
36
40
|
onWorkflowRunAttemptCreated,
|
|
37
41
|
onWorkflowRunCancelled,
|
|
38
42
|
} from '#presentation/index.js';
|
|
@@ -118,6 +122,11 @@ export function createWorkflowsModule({
|
|
|
118
122
|
subscriber(WORKFLOWS_WORKFLOW_RUN_CANCELLED, onWorkflowRunCancelled),
|
|
119
123
|
subscriber(WORKFLOWS_JOB_EVENT_DELIVERED, onJobEventDelivered),
|
|
120
124
|
subscriber(WORKFLOWS_JOB_STEPS_SETTLED, onJobStepsSettled),
|
|
125
|
+
subscriber(
|
|
126
|
+
WORKFLOWS_STEP_ATTEMPT_TERMINATED,
|
|
127
|
+
onStepAttemptTerminatedFailureAnnotation(annotations),
|
|
128
|
+
),
|
|
129
|
+
subscriber(WORKFLOWS_JOB_TERMINATED, onJobTerminatedFailureAnnotation(annotations)),
|
|
121
130
|
subscriber(RUNNER_JOB_LEASE_EXPIRED, onRunnerJobLeaseExpired),
|
|
122
131
|
subscriber(RUNNER_JOB_QUEUED, onRunnerJobQueued),
|
|
123
132
|
subscriber(RUNNER_JOB_CLAIMED, onRunnerJobClaimed),
|
package/src/metrics/instance.ts
CHANGED
|
@@ -91,6 +91,12 @@ const agentToolWarningFailedCount = meter.createCounter<{
|
|
|
91
91
|
description: 'Agent tool capability warning failures by bounded reason',
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
const failureAnnotationFailedCount = meter.createCounter<{
|
|
95
|
+
reason: 'lookup' | 'budget' | 'write';
|
|
96
|
+
}>('workflows_failure_annotation_failed', {
|
|
97
|
+
description: 'Failure annotation projection failures by bounded reason',
|
|
98
|
+
});
|
|
99
|
+
|
|
94
100
|
const listenerEventsCoalesced = meter.createHistogram<Record<string, never>>(
|
|
95
101
|
'workflows_listener_events_coalesced',
|
|
96
102
|
{
|
|
@@ -172,3 +178,7 @@ export function recordListenerEventsCoalesced(batchSize: number): void {
|
|
|
172
178
|
export function recordWorkflowAgentToolWarningFailed(reason: 'budget' | 'lookup' | 'write'): void {
|
|
173
179
|
agentToolWarningFailedCount.add(1, {reason});
|
|
174
180
|
}
|
|
181
|
+
|
|
182
|
+
export function recordWorkflowFailureAnnotationFailed(reason: 'lookup' | 'budget' | 'write'): void {
|
|
183
|
+
failureAnnotationFailedCount.add(1, {reason});
|
|
184
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {EvaluationTraceDto, EvaluationTraceEntryDto} from '@shipfox/api-workflows-dto';
|
|
2
|
+
import type {PersistedEvaluationTraceEntry} from '#core/entities/step.js';
|
|
3
|
+
|
|
4
|
+
export function toEvaluationTraceDto(
|
|
5
|
+
trace: readonly PersistedEvaluationTraceEntry[] | null | undefined,
|
|
6
|
+
): EvaluationTraceDto | null {
|
|
7
|
+
if (trace === null || trace === undefined) return null;
|
|
8
|
+
return trace.map(toEvaluationTraceEntryDto);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function toEvaluationTraceEntryDto(entry: PersistedEvaluationTraceEntry): EvaluationTraceEntryDto {
|
|
12
|
+
if ('dropped' in entry) return {truncated: true, dropped: entry.dropped};
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
expression: entry.expression,
|
|
16
|
+
roots: [...entry.roots],
|
|
17
|
+
fill_target: entry.fillTarget,
|
|
18
|
+
evaluated_at: entry.evaluatedAt,
|
|
19
|
+
field: entry.field,
|
|
20
|
+
...(entry.value === undefined ? {} : {value: entry.value}),
|
|
21
|
+
...(entry.truncated === undefined ? {} : {truncated: entry.truncated}),
|
|
22
|
+
...(entry.exprTruncated === undefined ? {} : {expr_truncated: entry.exprTruncated}),
|
|
23
|
+
...(entry.reference === undefined ? {} : {reference: entry.reference}),
|
|
24
|
+
...(entry.degraded === undefined ? {} : {degraded: entry.degraded}),
|
|
25
|
+
...(entry.envKey === undefined ? {} : {env_key: entry.envKey}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export {toEvaluationTraceDto} from './evaluation-trace.js';
|
|
1
2
|
export {toJobDto, toJobExecutionDto} from './job.js';
|
|
2
|
-
export {toStepAttemptDto, toStepDto} from './step.js';
|
|
3
|
+
export {toStepAttemptDetailResponseDto, toStepAttemptDto, toStepDto} from './step.js';
|
|
3
4
|
export {toRunAttemptDto, toRunDto, toRunListItemDto} from './workflow-run.js';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {JobDto, JobExecutionDto} from '@shipfox/api-workflows-dto';
|
|
2
2
|
import type {Job} from '#core/entities/job.js';
|
|
3
3
|
import type {JobExecution} from '#core/entities/job-execution.js';
|
|
4
|
+
import {toEvaluationTraceDto} from './evaluation-trace.js';
|
|
4
5
|
|
|
5
6
|
export function toJobDto(job: Job): JobDto {
|
|
6
7
|
return {
|
|
@@ -12,6 +13,9 @@ export function toJobDto(job: Job): JobDto {
|
|
|
12
13
|
status: job.status,
|
|
13
14
|
status_reason: job.statusReason,
|
|
14
15
|
carried_over: job.carriedOver,
|
|
16
|
+
success: job.success ?? null,
|
|
17
|
+
runner: job.runner,
|
|
18
|
+
evaluation_trace: toEvaluationTraceDto(job.evaluationTrace),
|
|
15
19
|
listening:
|
|
16
20
|
job.listeningOn === null
|
|
17
21
|
? null
|
|
@@ -52,8 +56,10 @@ export function toJobExecutionDto(jobExecution: JobExecution): JobExecutionDto {
|
|
|
52
56
|
name: jobExecution.name,
|
|
53
57
|
status: jobExecution.status,
|
|
54
58
|
status_reason: jobExecution.statusReason,
|
|
59
|
+
runner: jobExecution.runner,
|
|
55
60
|
trigger_events: jobExecution.triggerEvents,
|
|
56
61
|
outputs: jobExecution.outputs,
|
|
62
|
+
evaluation_trace: toEvaluationTraceDto(jobExecution.evaluationTrace),
|
|
57
63
|
queued_at: jobExecution.queuedAt?.toISOString() ?? null,
|
|
58
64
|
started_at: jobExecution.startedAt?.toISOString() ?? null,
|
|
59
65
|
finished_at: jobExecution.finishedAt?.toISOString() ?? null,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type {Step, StepAttempt} from '#core/entities/step.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
fromStepErrorDto,
|
|
4
|
+
toStepAttemptDetailResponseDto,
|
|
5
|
+
toStepAttemptDto,
|
|
6
|
+
toStepDto,
|
|
7
|
+
} from './step.js';
|
|
3
8
|
|
|
4
9
|
function step(overrides: Partial<Step> & {type: string}): Step {
|
|
5
10
|
return {
|
|
@@ -102,6 +107,37 @@ describe('fromStepErrorDto', () => {
|
|
|
102
107
|
});
|
|
103
108
|
|
|
104
109
|
describe('toStepDto error category', () => {
|
|
110
|
+
it('surfaces status reasons and evaluation traces', () => {
|
|
111
|
+
const dto = toStepDto(
|
|
112
|
+
step({
|
|
113
|
+
type: 'run',
|
|
114
|
+
statusReason: 'condition_errored',
|
|
115
|
+
evaluationTrace: [
|
|
116
|
+
{
|
|
117
|
+
expression: 'inputs.environment',
|
|
118
|
+
roots: ['inputs.environment'],
|
|
119
|
+
fillTarget: 'step-dispatch',
|
|
120
|
+
evaluatedAt: 'step-dispatch',
|
|
121
|
+
field: 'condition',
|
|
122
|
+
value: 'production',
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(dto.status_reason).toBe('condition_errored');
|
|
129
|
+
expect(dto.evaluation_trace).toEqual([
|
|
130
|
+
{
|
|
131
|
+
expression: 'inputs.environment',
|
|
132
|
+
roots: ['inputs.environment'],
|
|
133
|
+
fill_target: 'step-dispatch',
|
|
134
|
+
evaluated_at: 'step-dispatch',
|
|
135
|
+
field: 'condition',
|
|
136
|
+
value: 'production',
|
|
137
|
+
},
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
105
141
|
it("derives category 'setup' for a setup step error and surfaces the reason", () => {
|
|
106
142
|
const dto = toStepDto(
|
|
107
143
|
step({type: 'setup', error: {message: 'mkdir denied', reason: 'workspace_prep_failed'}}),
|
|
@@ -222,6 +258,24 @@ const baseAttempt: StepAttempt = {
|
|
|
222
258
|
};
|
|
223
259
|
|
|
224
260
|
describe('toStepAttemptDto', () => {
|
|
261
|
+
it('keeps evaluation traces on the lazy detail response instead of run polling', () => {
|
|
262
|
+
const result = toStepAttemptDto({
|
|
263
|
+
...baseAttempt,
|
|
264
|
+
evaluationTrace: [
|
|
265
|
+
{
|
|
266
|
+
expression: 'inputs.message',
|
|
267
|
+
roots: ['inputs.message'],
|
|
268
|
+
fillTarget: 'step-dispatch',
|
|
269
|
+
evaluatedAt: 'step-dispatch',
|
|
270
|
+
field: 'run',
|
|
271
|
+
value: 'hello',
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(result).not.toHaveProperty('evaluation_trace');
|
|
277
|
+
});
|
|
278
|
+
|
|
225
279
|
it('maps passed gate payloads to typed gate results', () => {
|
|
226
280
|
const attempt: StepAttempt = {
|
|
227
281
|
...baseAttempt,
|
|
@@ -337,3 +391,44 @@ describe('toStepAttemptDto', () => {
|
|
|
337
391
|
});
|
|
338
392
|
});
|
|
339
393
|
});
|
|
394
|
+
|
|
395
|
+
describe('toStepAttemptDetailResponseDto', () => {
|
|
396
|
+
it('returns authored config, resolved config, and attempt trace', () => {
|
|
397
|
+
const stepData = step({
|
|
398
|
+
type: 'run',
|
|
399
|
+
authoredConfig: {run: 'echo $' + '{{ inputs.message }}'},
|
|
400
|
+
config: {run: 'echo hello'},
|
|
401
|
+
});
|
|
402
|
+
const attempt: StepAttempt = {
|
|
403
|
+
...baseAttempt,
|
|
404
|
+
config: {run: 'echo hello'},
|
|
405
|
+
evaluationTrace: [
|
|
406
|
+
{
|
|
407
|
+
expression: 'inputs.message',
|
|
408
|
+
roots: ['inputs.message'],
|
|
409
|
+
fillTarget: 'step-dispatch',
|
|
410
|
+
evaluatedAt: 'step-dispatch',
|
|
411
|
+
field: 'run',
|
|
412
|
+
value: 'hello',
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
expect(toStepAttemptDetailResponseDto(stepData, attempt)).toEqual({
|
|
418
|
+
step_id: stepData.id,
|
|
419
|
+
attempt: 1,
|
|
420
|
+
authored_config: {run: 'echo $' + '{{ inputs.message }}'},
|
|
421
|
+
config: {run: 'echo hello'},
|
|
422
|
+
evaluation_trace: [
|
|
423
|
+
{
|
|
424
|
+
expression: 'inputs.message',
|
|
425
|
+
roots: ['inputs.message'],
|
|
426
|
+
fill_target: 'step-dispatch',
|
|
427
|
+
evaluated_at: 'step-dispatch',
|
|
428
|
+
field: 'run',
|
|
429
|
+
value: 'hello',
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
agentConfigIssueSchema,
|
|
3
|
+
type StepAttemptDetailResponseDto,
|
|
3
4
|
type StepAttemptDto,
|
|
4
5
|
type StepDto,
|
|
5
6
|
type StepErrorCategoryDto,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
} from '@shipfox/api-workflows-dto';
|
|
10
11
|
import type {Step, StepAttempt} from '#core/entities/step.js';
|
|
11
12
|
import {GATE_EVALUATION_ERROR_REASON} from '#core/step-transition/evaluate-gate.js';
|
|
13
|
+
import {toEvaluationTraceDto} from './evaluation-trace.js';
|
|
12
14
|
|
|
13
15
|
// Domain `error` is loosely typed (jsonb), so narrow it to the fixed runner
|
|
14
16
|
// contract rather than trusting whatever shape the row happens to hold. `category`
|
|
@@ -105,8 +107,10 @@ export function toStepDto(step: Step): StepDto {
|
|
|
105
107
|
name: step.name,
|
|
106
108
|
source_location: toStepSourceLocationDto(step.sourceLocation),
|
|
107
109
|
status: step.status,
|
|
110
|
+
status_reason: step.statusReason,
|
|
108
111
|
type: step.type,
|
|
109
112
|
config: step.config,
|
|
113
|
+
evaluation_trace: toEvaluationTraceDto(step.evaluationTrace),
|
|
110
114
|
error: toStepErrorDto(
|
|
111
115
|
step.error,
|
|
112
116
|
step.type === 'setup' || step.type === 'checkout' ? 'setup' : 'user',
|
|
@@ -146,3 +150,16 @@ export function toStepAttemptDto(attempt: StepAttempt): StepAttemptDto {
|
|
|
146
150
|
finished_at: attempt.finishedAt ? attempt.finishedAt.toISOString() : null,
|
|
147
151
|
};
|
|
148
152
|
}
|
|
153
|
+
|
|
154
|
+
export function toStepAttemptDetailResponseDto(
|
|
155
|
+
step: Step,
|
|
156
|
+
attempt: StepAttempt,
|
|
157
|
+
): StepAttemptDetailResponseDto {
|
|
158
|
+
return {
|
|
159
|
+
step_id: step.id,
|
|
160
|
+
attempt: attempt.attempt,
|
|
161
|
+
authored_config: step.authoredConfig,
|
|
162
|
+
config: attempt.config,
|
|
163
|
+
evaluation_trace: toEvaluationTraceDto(attempt.evaluationTrace),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -2,9 +2,11 @@ export {createWorkflowRoutes} from './routes/index.js';
|
|
|
2
2
|
export {
|
|
3
3
|
onJobEventDelivered,
|
|
4
4
|
onJobStepsSettled,
|
|
5
|
+
onJobTerminatedFailureAnnotation,
|
|
5
6
|
onRunnerJobClaimed,
|
|
6
7
|
onRunnerJobLeaseExpired,
|
|
7
8
|
onRunnerJobQueued,
|
|
9
|
+
onStepAttemptTerminatedFailureAnnotation,
|
|
8
10
|
onWorkflowRunAttemptCreated,
|
|
9
11
|
onWorkflowRunCancelled,
|
|
10
12
|
} from './subscribers/index.js';
|