@shipfox/api-workflows 12.2.0 → 12.4.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 +24 -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/errors.d.ts +2 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +3 -2
- package/dist/core/errors.js.map +1 -1
- package/dist/core/step-config/materialize-workflow-model.d.ts.map +1 -1
- package/dist/core/step-config/materialize-workflow-model.js +6 -4
- package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
- 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/workflow-runs/jobs.d.ts.map +1 -1
- package/dist/db/workflow-runs/jobs.js +4 -0
- 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/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/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +11 -9
- package/src/config.test.ts +85 -0
- package/src/config.ts +71 -0
- package/src/core/errors.ts +9 -2
- package/src/core/job-execution.test.ts +1 -0
- package/src/core/step-config/materialize-workflow-model.catalog.test.ts +117 -0
- package/src/core/step-config/materialize-workflow-model.ts +5 -3
- package/src/db/index.ts +4 -0
- package/src/db/workflow-runs/job-status.test.ts +10 -3
- package/src/db/workflow-runs/jobs.ts +7 -0
- package/src/db/workflow-runs/outbox.ts +7 -1
- 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/tsconfig.build.tsbuildinfo +1 -1
|
@@ -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';
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {buildUserContext, setUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
|
|
3
|
+
import {ClientError} from '@shipfox/node-fastify';
|
|
4
|
+
import type {FastifyInstance} from 'fastify';
|
|
5
|
+
import Fastify from 'fastify';
|
|
6
|
+
import {serializerCompiler, validatorCompiler} from 'fastify-type-provider-zod';
|
|
7
|
+
import type {Step, StepAttempt} from '#core/entities/step.js';
|
|
8
|
+
import * as dbMocks from '#db/index.js';
|
|
9
|
+
import {getStepAttemptDetailRoute} from './get-step-attempt-detail.js';
|
|
10
|
+
|
|
11
|
+
vi.mock('#db/index.js', () => ({
|
|
12
|
+
getStepAttemptDetail: vi.fn(),
|
|
13
|
+
getWorkflowRunById: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const getProjectById = vi.fn();
|
|
17
|
+
const projects = {getProjectById} as unknown as ProjectsModuleClient;
|
|
18
|
+
const STEP_ID = crypto.randomUUID();
|
|
19
|
+
const getStepAttemptDetail = vi.mocked(dbMocks.getStepAttemptDetail);
|
|
20
|
+
const getWorkflowRunById = vi.mocked(dbMocks.getWorkflowRunById);
|
|
21
|
+
|
|
22
|
+
describe('GET /api/workflows/runs/steps/:stepId/attempts/:attempt', () => {
|
|
23
|
+
let app: FastifyInstance;
|
|
24
|
+
let workspaceId: string;
|
|
25
|
+
const workflowRunId = crypto.randomUUID();
|
|
26
|
+
const projectId = crypto.randomUUID();
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
app = Fastify();
|
|
30
|
+
app.setValidatorCompiler(validatorCompiler);
|
|
31
|
+
app.setSerializerCompiler(serializerCompiler);
|
|
32
|
+
app.addHook('onRequest', (request, _reply, done) => {
|
|
33
|
+
setUserContext(
|
|
34
|
+
request,
|
|
35
|
+
buildUserContext({
|
|
36
|
+
userId: crypto.randomUUID(),
|
|
37
|
+
email: 'user@example.com',
|
|
38
|
+
memberships: [{workspaceId, role: 'admin', workspaceStatus: 'active'}],
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
done();
|
|
42
|
+
});
|
|
43
|
+
app.get(
|
|
44
|
+
'/api/workflows/runs/steps/:stepId/attempts/:attempt',
|
|
45
|
+
getStepAttemptDetailRoute(projects),
|
|
46
|
+
);
|
|
47
|
+
await app.ready();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
workspaceId = crypto.randomUUID();
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
getProjectById.mockResolvedValue({
|
|
54
|
+
project: {id: projectId, workspaceId, name: 'Project'},
|
|
55
|
+
});
|
|
56
|
+
getWorkflowRunById.mockResolvedValue({projectId} as never);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns the requested attempt detail for an accessible run', async () => {
|
|
60
|
+
const step = stepEntity();
|
|
61
|
+
const attempt = stepAttemptEntity();
|
|
62
|
+
getStepAttemptDetail.mockResolvedValue({
|
|
63
|
+
workflowRunId,
|
|
64
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
65
|
+
step,
|
|
66
|
+
attempt,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const response = await app.inject({
|
|
70
|
+
method: 'GET',
|
|
71
|
+
url: `/api/workflows/runs/steps/${STEP_ID}/attempts/1`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(response.statusCode).toBe(200);
|
|
75
|
+
expect(response.json()).toMatchObject({
|
|
76
|
+
step_id: STEP_ID,
|
|
77
|
+
attempt: 1,
|
|
78
|
+
authored_config: {run: 'pnpm test'},
|
|
79
|
+
config: {run: 'pnpm test'},
|
|
80
|
+
});
|
|
81
|
+
expect(getProjectById).toHaveBeenCalledWith({projectId});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns 404 for a missing attempt', async () => {
|
|
85
|
+
getStepAttemptDetail.mockResolvedValue(undefined);
|
|
86
|
+
|
|
87
|
+
const response = await app.inject({
|
|
88
|
+
method: 'GET',
|
|
89
|
+
url: `/api/workflows/runs/steps/${STEP_ID}/attempts/1`,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(response.statusCode).toBe(404);
|
|
93
|
+
expect(getProjectById).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns 404 for an attempt in an inaccessible run', async () => {
|
|
97
|
+
getStepAttemptDetail.mockResolvedValue({
|
|
98
|
+
workflowRunId,
|
|
99
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
100
|
+
step: stepEntity(),
|
|
101
|
+
attempt: stepAttemptEntity(),
|
|
102
|
+
});
|
|
103
|
+
getProjectById.mockRejectedValueOnce(new ClientError('Forbidden', 'forbidden', {status: 403}));
|
|
104
|
+
|
|
105
|
+
const response = await app.inject({
|
|
106
|
+
method: 'GET',
|
|
107
|
+
url: `/api/workflows/runs/steps/${STEP_ID}/attempts/1`,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(response.statusCode).toBe(404);
|
|
111
|
+
expect(response.json().code).toBe('not-found');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
function stepEntity(): Step {
|
|
116
|
+
return {
|
|
117
|
+
id: STEP_ID,
|
|
118
|
+
jobExecutionId: crypto.randomUUID(),
|
|
119
|
+
key: 'test',
|
|
120
|
+
name: 'Run tests',
|
|
121
|
+
sourceLocation: null,
|
|
122
|
+
status: 'failed',
|
|
123
|
+
statusReason: null,
|
|
124
|
+
evaluationTrace: null,
|
|
125
|
+
type: 'run',
|
|
126
|
+
config: {run: 'pnpm test'},
|
|
127
|
+
condition: null,
|
|
128
|
+
configPlan: null,
|
|
129
|
+
authoredConfig: {run: 'pnpm test'},
|
|
130
|
+
error: {reason: 'command_failed', message: 'Command failed'},
|
|
131
|
+
position: 1,
|
|
132
|
+
version: 1,
|
|
133
|
+
currentAttempt: 1,
|
|
134
|
+
createdAt: new Date('2026-08-05T12:00:00.000Z'),
|
|
135
|
+
updatedAt: new Date('2026-08-05T12:01:00.000Z'),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stepAttemptEntity(): StepAttempt {
|
|
140
|
+
return {
|
|
141
|
+
id: crypto.randomUUID(),
|
|
142
|
+
stepId: STEP_ID,
|
|
143
|
+
attempt: 1,
|
|
144
|
+
executionOrder: 1,
|
|
145
|
+
status: 'failed',
|
|
146
|
+
config: {run: 'pnpm test'},
|
|
147
|
+
evaluationTrace: null,
|
|
148
|
+
output: {result: 'failed'},
|
|
149
|
+
response: null,
|
|
150
|
+
error: {reason: 'command_failed', message: 'Command failed'},
|
|
151
|
+
exitCode: 1,
|
|
152
|
+
gateResult: null,
|
|
153
|
+
restartFeedback: null,
|
|
154
|
+
logOutcome: 'drained',
|
|
155
|
+
startedAt: new Date('2026-08-05T12:00:00.000Z'),
|
|
156
|
+
finishedAt: new Date('2026-08-05T12:01:00.000Z'),
|
|
157
|
+
createdAt: new Date('2026-08-05T12:00:00.000Z'),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
|
|
2
|
+
import {stepAttemptDetailResponseSchema} from '@shipfox/api-workflows-dto';
|
|
3
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
|
+
import {z} from 'zod';
|
|
5
|
+
import {getStepAttemptDetail} from '#db/index.js';
|
|
6
|
+
import {toStepAttemptDetailResponseDto} from '#presentation/dto/step.js';
|
|
7
|
+
import {requireAccessibleRun} from './require-accessible-run.js';
|
|
8
|
+
|
|
9
|
+
export function getStepAttemptDetailRoute(projects: ProjectsModuleClient) {
|
|
10
|
+
return defineRoute({
|
|
11
|
+
method: 'GET',
|
|
12
|
+
path: '/steps/:stepId/attempts/:attempt',
|
|
13
|
+
description: 'Get troubleshooting details for one step attempt',
|
|
14
|
+
schema: {
|
|
15
|
+
params: z.object({
|
|
16
|
+
stepId: z.string().uuid(),
|
|
17
|
+
attempt: z.coerce.number().int().positive(),
|
|
18
|
+
}),
|
|
19
|
+
response: {
|
|
20
|
+
200: stepAttemptDetailResponseSchema,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
handler: async (request) => {
|
|
24
|
+
const detail = await getStepAttemptDetail(request.params);
|
|
25
|
+
if (!detail) {
|
|
26
|
+
throw new ClientError('Step attempt not found', 'not-found', {status: 404});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await requireAccessibleRun({request, id: detail.workflowRunId, projects});
|
|
30
|
+
return toStepAttemptDetailResponseDto(detail.step, detail.attempt);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -13,6 +13,7 @@ import {cancelRunRoute} from './cancel-run.js';
|
|
|
13
13
|
import {createCheckoutTokenRoute} from './checkout-token.js';
|
|
14
14
|
import {getRunRoute} from './get-run.js';
|
|
15
15
|
import {getRunAggregatesRoute} from './get-run-aggregates.js';
|
|
16
|
+
import {getStepAttemptDetailRoute} from './get-step-attempt-detail.js';
|
|
16
17
|
import {createGetStepSecretsRoute} from './get-step-secrets.js';
|
|
17
18
|
import {listRunAttemptsRoute} from './list-run-attempts.js';
|
|
18
19
|
import {listRunsRoute} from './list-runs.js';
|
|
@@ -62,6 +63,7 @@ export function createWorkflowRoutes(params: WorkflowRouteClients): RouteGroup[]
|
|
|
62
63
|
getRunAggregatesRoute(params.projects),
|
|
63
64
|
listRunAttemptsRoute(params.projects),
|
|
64
65
|
getRunRoute(params.projects),
|
|
66
|
+
getStepAttemptDetailRoute(params.projects),
|
|
65
67
|
cancelRunRoute(params.projects),
|
|
66
68
|
rerunRunRoute(params.projects, params.workspaces),
|
|
67
69
|
],
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export {
|
|
2
|
+
onJobTerminatedFailureAnnotation,
|
|
3
|
+
onStepAttemptTerminatedFailureAnnotation,
|
|
4
|
+
} from './on-failure-annotations.js';
|
|
1
5
|
export {onJobEventDelivered} from './on-job-event-delivered.js';
|
|
2
6
|
export {onJobStepsSettled} from './on-job-steps-settled.js';
|
|
3
7
|
export {onRunnerJobClaimed} from './on-runner-job-claimed.js';
|