@shipfox/api-workflows 12.2.0 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +20 -0
  4. package/dist/config.d.ts +11 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +56 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/core/errors.d.ts +2 -1
  9. package/dist/core/errors.d.ts.map +1 -1
  10. package/dist/core/errors.js +3 -2
  11. package/dist/core/errors.js.map +1 -1
  12. package/dist/core/step-config/materialize-workflow-model.d.ts.map +1 -1
  13. package/dist/core/step-config/materialize-workflow-model.js +6 -4
  14. package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
  15. package/dist/db/index.d.ts +2 -2
  16. package/dist/db/index.d.ts.map +1 -1
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/index.js.map +1 -1
  19. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  20. package/dist/db/workflow-runs/jobs.js +4 -0
  21. package/dist/db/workflow-runs/jobs.js.map +1 -1
  22. package/dist/db/workflow-runs/outbox.d.ts +1 -0
  23. package/dist/db/workflow-runs/outbox.d.ts.map +1 -1
  24. package/dist/db/workflow-runs/outbox.js +1 -0
  25. package/dist/db/workflow-runs/outbox.js.map +1 -1
  26. package/dist/db/workflow-runs/steps.d.ts +28 -0
  27. package/dist/db/workflow-runs/steps.d.ts.map +1 -1
  28. package/dist/db/workflow-runs/steps.js +49 -1
  29. package/dist/db/workflow-runs/steps.js.map +1 -1
  30. package/dist/db/workflow-runs.d.ts +2 -2
  31. package/dist/db/workflow-runs.d.ts.map +1 -1
  32. package/dist/db/workflow-runs.js +1 -1
  33. package/dist/db/workflow-runs.js.map +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +4 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/metrics/instance.d.ts +1 -0
  38. package/dist/metrics/instance.d.ts.map +1 -1
  39. package/dist/metrics/instance.js +8 -0
  40. package/dist/metrics/instance.js.map +1 -1
  41. package/dist/presentation/dto/evaluation-trace.d.ts +4 -0
  42. package/dist/presentation/dto/evaluation-trace.d.ts.map +1 -0
  43. package/dist/presentation/dto/evaluation-trace.js +39 -0
  44. package/dist/presentation/dto/evaluation-trace.js.map +1 -0
  45. package/dist/presentation/dto/index.d.ts +2 -1
  46. package/dist/presentation/dto/index.d.ts.map +1 -1
  47. package/dist/presentation/dto/index.js +2 -1
  48. package/dist/presentation/dto/index.js.map +1 -1
  49. package/dist/presentation/dto/job.d.ts.map +1 -1
  50. package/dist/presentation/dto/job.js +6 -0
  51. package/dist/presentation/dto/job.js.map +1 -1
  52. package/dist/presentation/dto/step.d.ts +2 -1
  53. package/dist/presentation/dto/step.d.ts.map +1 -1
  54. package/dist/presentation/dto/step.js +12 -0
  55. package/dist/presentation/dto/step.js.map +1 -1
  56. package/dist/presentation/index.d.ts +1 -1
  57. package/dist/presentation/index.d.ts.map +1 -1
  58. package/dist/presentation/index.js +1 -1
  59. package/dist/presentation/index.js.map +1 -1
  60. package/dist/presentation/routes/get-step-attempt-detail.d.ts +3 -0
  61. package/dist/presentation/routes/get-step-attempt-detail.d.ts.map +1 -0
  62. package/dist/presentation/routes/get-step-attempt-detail.js +38 -0
  63. package/dist/presentation/routes/get-step-attempt-detail.js.map +1 -0
  64. package/dist/presentation/routes/index.d.ts.map +1 -1
  65. package/dist/presentation/routes/index.js +2 -0
  66. package/dist/presentation/routes/index.js.map +1 -1
  67. package/dist/presentation/subscribers/index.d.ts +1 -0
  68. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  69. package/dist/presentation/subscribers/index.js +1 -0
  70. package/dist/presentation/subscribers/index.js.map +1 -1
  71. package/dist/presentation/subscribers/on-failure-annotations.d.ts +5 -0
  72. package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -0
  73. package/dist/presentation/subscribers/on-failure-annotations.js +160 -0
  74. package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -0
  75. package/dist/tsconfig.test.tsbuildinfo +1 -1
  76. package/package.json +10 -8
  77. package/src/config.test.ts +85 -0
  78. package/src/config.ts +71 -0
  79. package/src/core/errors.ts +9 -2
  80. package/src/core/job-execution.test.ts +1 -0
  81. package/src/core/step-config/materialize-workflow-model.catalog.test.ts +117 -0
  82. package/src/core/step-config/materialize-workflow-model.ts +5 -3
  83. package/src/db/index.ts +4 -0
  84. package/src/db/workflow-runs/job-status.test.ts +10 -3
  85. package/src/db/workflow-runs/jobs.ts +7 -0
  86. package/src/db/workflow-runs/outbox.ts +7 -1
  87. package/src/db/workflow-runs/steps.test.ts +148 -0
  88. package/src/db/workflow-runs/steps.ts +105 -1
  89. package/src/db/workflow-runs.ts +4 -0
  90. package/src/index.ts +9 -0
  91. package/src/metrics/instance.ts +10 -0
  92. package/src/presentation/dto/evaluation-trace.ts +27 -0
  93. package/src/presentation/dto/index.ts +2 -1
  94. package/src/presentation/dto/job.ts +6 -0
  95. package/src/presentation/dto/step.test.ts +96 -1
  96. package/src/presentation/dto/step.ts +17 -0
  97. package/src/presentation/index.ts +2 -0
  98. package/src/presentation/routes/get-step-attempt-detail.test.ts +159 -0
  99. package/src/presentation/routes/get-step-attempt-detail.ts +33 -0
  100. package/src/presentation/routes/index.ts +2 -0
  101. package/src/presentation/subscribers/index.ts +4 -0
  102. package/src/presentation/subscribers/on-failure-annotations.test.ts +510 -0
  103. package/src/presentation/subscribers/on-failure-annotations.ts +237 -0
  104. 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
  }
@@ -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),
@@ -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 {fromStepErrorDto, toStepAttemptDto, toStepDto} from './step.js';
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';