@shipfox/api-workflows 12.1.0 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +20 -0
  4. package/dist/config.d.ts +11 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +56 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/core/entities/job.d.ts +1 -1
  9. package/dist/core/entities/job.d.ts.map +1 -1
  10. package/dist/core/entities/step.d.ts +2 -2
  11. package/dist/core/entities/step.d.ts.map +1 -1
  12. package/dist/core/errors.d.ts +18 -1
  13. package/dist/core/errors.d.ts.map +1 -1
  14. package/dist/core/errors.js +21 -2
  15. package/dist/core/errors.js.map +1 -1
  16. package/dist/core/step-config/assemble-run-context.d.ts +15 -5
  17. package/dist/core/step-config/assemble-run-context.d.ts.map +1 -1
  18. package/dist/core/step-config/assemble-run-context.js +77 -27
  19. package/dist/core/step-config/assemble-run-context.js.map +1 -1
  20. package/dist/core/step-config/fields.d.ts +5 -0
  21. package/dist/core/step-config/fields.d.ts.map +1 -1
  22. package/dist/core/step-config/fields.js +21 -3
  23. package/dist/core/step-config/fields.js.map +1 -1
  24. package/dist/core/step-config/index.d.ts +1 -1
  25. package/dist/core/step-config/index.d.ts.map +1 -1
  26. package/dist/core/step-config/index.js +1 -1
  27. package/dist/core/step-config/index.js.map +1 -1
  28. package/dist/core/step-config/job-output-limits.d.ts +11 -0
  29. package/dist/core/step-config/job-output-limits.d.ts.map +1 -0
  30. package/dist/core/step-config/job-output-limits.js +88 -0
  31. package/dist/core/step-config/job-output-limits.js.map +1 -0
  32. package/dist/core/step-config/materialize-workflow-model.d.ts +1 -1
  33. package/dist/core/step-config/materialize-workflow-model.d.ts.map +1 -1
  34. package/dist/core/step-config/materialize-workflow-model.js +47 -18
  35. package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
  36. package/dist/db/db.d.ts +50 -50
  37. package/dist/db/index.d.ts +2 -2
  38. package/dist/db/index.d.ts.map +1 -1
  39. package/dist/db/index.js +1 -1
  40. package/dist/db/index.js.map +1 -1
  41. package/dist/db/job-listeners.d.ts.map +1 -1
  42. package/dist/db/job-listeners.js +6 -5
  43. package/dist/db/job-listeners.js.map +1 -1
  44. package/dist/db/schema/job-executions.d.ts +3 -3
  45. package/dist/db/schema/job-listener-events.d.ts +1 -1
  46. package/dist/db/schema/jobs.d.ts +7 -7
  47. package/dist/db/schema/outbox.d.ts +1 -1
  48. package/dist/db/schema/step-attempts.d.ts +2 -2
  49. package/dist/db/schema/steps.d.ts +5 -5
  50. package/dist/db/schema/workflow-run-attempts.d.ts +3 -3
  51. package/dist/db/schema/workflow-run-counters.d.ts +1 -1
  52. package/dist/db/schema/workflow-runs.d.ts +2 -2
  53. package/dist/db/workflow-runs/job-executions.d.ts.map +1 -1
  54. package/dist/db/workflow-runs/job-executions.js +4 -2
  55. package/dist/db/workflow-runs/job-executions.js.map +1 -1
  56. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  57. package/dist/db/workflow-runs/jobs.js +33 -3
  58. package/dist/db/workflow-runs/jobs.js.map +1 -1
  59. package/dist/db/workflow-runs/outbox.d.ts +1 -0
  60. package/dist/db/workflow-runs/outbox.d.ts.map +1 -1
  61. package/dist/db/workflow-runs/outbox.js +1 -0
  62. package/dist/db/workflow-runs/outbox.js.map +1 -1
  63. package/dist/db/workflow-runs/steps.d.ts +28 -0
  64. package/dist/db/workflow-runs/steps.d.ts.map +1 -1
  65. package/dist/db/workflow-runs/steps.js +49 -1
  66. package/dist/db/workflow-runs/steps.js.map +1 -1
  67. package/dist/db/workflow-runs.d.ts +2 -2
  68. package/dist/db/workflow-runs.d.ts.map +1 -1
  69. package/dist/db/workflow-runs.js +1 -1
  70. package/dist/db/workflow-runs.js.map +1 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +4 -2
  73. package/dist/index.js.map +1 -1
  74. package/dist/metrics/instance.d.ts +1 -0
  75. package/dist/metrics/instance.d.ts.map +1 -1
  76. package/dist/metrics/instance.js +8 -0
  77. package/dist/metrics/instance.js.map +1 -1
  78. package/dist/presentation/dto/evaluation-trace.d.ts +4 -0
  79. package/dist/presentation/dto/evaluation-trace.d.ts.map +1 -0
  80. package/dist/presentation/dto/evaluation-trace.js +39 -0
  81. package/dist/presentation/dto/evaluation-trace.js.map +1 -0
  82. package/dist/presentation/dto/index.d.ts +2 -1
  83. package/dist/presentation/dto/index.d.ts.map +1 -1
  84. package/dist/presentation/dto/index.js +2 -1
  85. package/dist/presentation/dto/index.js.map +1 -1
  86. package/dist/presentation/dto/job.d.ts.map +1 -1
  87. package/dist/presentation/dto/job.js +6 -0
  88. package/dist/presentation/dto/job.js.map +1 -1
  89. package/dist/presentation/dto/step.d.ts +2 -1
  90. package/dist/presentation/dto/step.d.ts.map +1 -1
  91. package/dist/presentation/dto/step.js +12 -0
  92. package/dist/presentation/dto/step.js.map +1 -1
  93. package/dist/presentation/index.d.ts +1 -1
  94. package/dist/presentation/index.d.ts.map +1 -1
  95. package/dist/presentation/index.js +1 -1
  96. package/dist/presentation/index.js.map +1 -1
  97. package/dist/presentation/routes/get-step-attempt-detail.d.ts +3 -0
  98. package/dist/presentation/routes/get-step-attempt-detail.d.ts.map +1 -0
  99. package/dist/presentation/routes/get-step-attempt-detail.js +38 -0
  100. package/dist/presentation/routes/get-step-attempt-detail.js.map +1 -0
  101. package/dist/presentation/routes/index.d.ts.map +1 -1
  102. package/dist/presentation/routes/index.js +2 -0
  103. package/dist/presentation/routes/index.js.map +1 -1
  104. package/dist/presentation/routes/project-access.d.ts +1 -1
  105. package/dist/presentation/subscribers/index.d.ts +1 -0
  106. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  107. package/dist/presentation/subscribers/index.js +1 -0
  108. package/dist/presentation/subscribers/index.js.map +1 -1
  109. package/dist/presentation/subscribers/on-failure-annotations.d.ts +5 -0
  110. package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -0
  111. package/dist/presentation/subscribers/on-failure-annotations.js +160 -0
  112. package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -0
  113. package/dist/temporal/activities/index.d.ts +1 -1
  114. package/dist/temporal/activities/index.d.ts.map +1 -1
  115. package/dist/temporal/activities/orchestration-activities.d.ts +1 -1
  116. package/dist/temporal/activities/orchestration-activities.d.ts.map +1 -1
  117. package/dist/temporal/workflows/test-env.d.ts +2 -2
  118. package/dist/tsconfig.test.tsbuildinfo +1 -1
  119. package/package.json +18 -16
  120. package/src/config.test.ts +85 -0
  121. package/src/config.ts +71 -0
  122. package/src/core/errors.ts +44 -2
  123. package/src/core/job-execution.test.ts +1 -0
  124. package/src/core/step-config/assemble-run-context.test.ts +232 -20
  125. package/src/core/step-config/assemble-run-context.ts +126 -28
  126. package/src/core/step-config/fields.ts +42 -3
  127. package/src/core/step-config/index.ts +2 -0
  128. package/src/core/step-config/job-output-limits.ts +130 -0
  129. package/src/core/step-config/materialize-workflow-model.catalog.test.ts +117 -0
  130. package/src/core/step-config/materialize-workflow-model.test.ts +306 -1
  131. package/src/core/step-config/materialize-workflow-model.ts +65 -18
  132. package/src/db/index.ts +4 -0
  133. package/src/db/job-listeners.ts +14 -3
  134. package/src/db/workflow-runs/job-executions.test.ts +207 -1
  135. package/src/db/workflow-runs/job-executions.ts +15 -2
  136. package/src/db/workflow-runs/job-status.test.ts +10 -3
  137. package/src/db/workflow-runs/jobs.test.ts +49 -0
  138. package/src/db/workflow-runs/jobs.ts +53 -6
  139. package/src/db/workflow-runs/outbox.ts +7 -1
  140. package/src/db/workflow-runs/run-create.test.ts +56 -0
  141. package/src/db/workflow-runs/steps.test.ts +148 -0
  142. package/src/db/workflow-runs/steps.ts +105 -1
  143. package/src/db/workflow-runs.ts +4 -0
  144. package/src/index.ts +9 -0
  145. package/src/metrics/instance.ts +10 -0
  146. package/src/presentation/dto/evaluation-trace.ts +27 -0
  147. package/src/presentation/dto/index.ts +2 -1
  148. package/src/presentation/dto/job.ts +6 -0
  149. package/src/presentation/dto/step.test.ts +96 -1
  150. package/src/presentation/dto/step.ts +17 -0
  151. package/src/presentation/index.ts +2 -0
  152. package/src/presentation/routes/get-step-attempt-detail.test.ts +159 -0
  153. package/src/presentation/routes/get-step-attempt-detail.ts +33 -0
  154. package/src/presentation/routes/index.ts +2 -0
  155. package/src/presentation/subscribers/index.ts +4 -0
  156. package/src/presentation/subscribers/on-failure-annotations.test.ts +510 -0
  157. package/src/presentation/subscribers/on-failure-annotations.ts +237 -0
  158. package/test/factories/workflow-model.ts +2 -0
  159. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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
  }
@@ -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';