@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.
Files changed (104) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +24 -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 +11 -9
  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
@@ -0,0 +1,510 @@
1
+ import {
2
+ type AnnotationsInterModuleClient,
3
+ annotationsInterModuleContract,
4
+ } from '@shipfox/annotations-dto/inter-module';
5
+ import type {
6
+ WorkflowsJobTerminatedEventDto,
7
+ WorkflowsStepAttemptTerminatedEventDto,
8
+ } from '@shipfox/api-workflows-dto';
9
+ import {createInterModuleKnownError} from '@shipfox/inter-module';
10
+ import type {Step, StepAttempt} from '#core/entities/step.js';
11
+ import {
12
+ onJobTerminatedFailureAnnotation,
13
+ onStepAttemptTerminatedFailureAnnotation,
14
+ } from './on-failure-annotations.js';
15
+
16
+ const dbMocks = vi.hoisted(() => ({
17
+ getJobExecutionFailureOrigin: vi.fn(),
18
+ getJobScope: vi.fn(),
19
+ getStepAttemptDetail: vi.fn(),
20
+ getWorkflowRunAttemptById: vi.fn(),
21
+ }));
22
+
23
+ const metricMocks = vi.hoisted(() => ({recordWorkflowFailureAnnotationFailed: vi.fn()}));
24
+ const loggerWarn = vi.hoisted(() => vi.fn());
25
+
26
+ vi.mock('#db/index.js', () => dbMocks);
27
+ vi.mock('#metrics/instance.js', () => metricMocks);
28
+ vi.mock('@shipfox/node-opentelemetry', () => ({logger: () => ({warn: loggerWarn})}));
29
+
30
+ const replaceOrRemoveAnnotation = vi.fn(async () => ({}));
31
+ const annotations = {replaceOrRemoveAnnotation} as unknown as AnnotationsInterModuleClient;
32
+ const JOB_EXECUTION_ID = '66666666-6666-4666-8666-666666666666';
33
+
34
+ describe('failure annotations', () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ it('projects a failed step attempt into an error annotation', async () => {
40
+ const payload = stepAttemptTerminatedPayload();
41
+ const step = stepEntity({
42
+ id: payload.stepId,
43
+ jobExecutionId: JOB_EXECUTION_ID,
44
+ status: 'failed',
45
+ error: {reason: 'agent_invocation_failed', message: 'Provider returned 500'},
46
+ });
47
+ const attempt = stepAttemptEntity({
48
+ stepId: step.id,
49
+ status: 'failed',
50
+ exitCode: 1,
51
+ });
52
+ dbMocks.getStepAttemptDetail.mockResolvedValue({
53
+ workflowRunId: payload.workflowRunId,
54
+ workflowRunAttemptId: payload.workflowRunAttemptId,
55
+ step,
56
+ attempt,
57
+ });
58
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 2});
59
+
60
+ await onStepAttemptTerminatedFailureAnnotation(annotations)(payload);
61
+
62
+ expect(replaceOrRemoveAnnotation).toHaveBeenCalledWith(
63
+ expect.objectContaining({
64
+ workspaceId: payload.workspaceId,
65
+ projectId: payload.projectId,
66
+ workflowRunId: payload.workflowRunId,
67
+ workflowRunAttempt: 2,
68
+ workflowRunAttemptId: payload.workflowRunAttemptId,
69
+ jobId: payload.jobId,
70
+ jobExecutionId: JOB_EXECUTION_ID,
71
+ originStepId: payload.stepId,
72
+ originStepAttempt: payload.attempt,
73
+ context: `failure:step:${payload.stepId}`,
74
+ annotation: expect.objectContaining({op: 'replace', style: 'error'}),
75
+ }),
76
+ );
77
+ });
78
+
79
+ it('removes a stale step failure annotation after a successful terminal event', async () => {
80
+ const payload = stepAttemptTerminatedPayload({attempt: 2, status: 'succeeded'});
81
+ const step = stepEntity({
82
+ id: payload.stepId,
83
+ jobExecutionId: JOB_EXECUTION_ID,
84
+ status: 'succeeded',
85
+ currentAttempt: 2,
86
+ error: null,
87
+ });
88
+ const attempt = stepAttemptEntity({
89
+ stepId: step.id,
90
+ attempt: 2,
91
+ status: 'succeeded',
92
+ exitCode: 0,
93
+ });
94
+ dbMocks.getStepAttemptDetail.mockResolvedValue({
95
+ workflowRunId: payload.workflowRunId,
96
+ workflowRunAttemptId: payload.workflowRunAttemptId,
97
+ step,
98
+ attempt,
99
+ });
100
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 1});
101
+
102
+ await onStepAttemptTerminatedFailureAnnotation(annotations)(payload);
103
+
104
+ expect(replaceOrRemoveAnnotation).toHaveBeenCalledWith(
105
+ expect.objectContaining({
106
+ context: `failure:step:${payload.stepId}`,
107
+ annotation: {op: 'remove'},
108
+ }),
109
+ );
110
+ });
111
+
112
+ it('does not resurrect a failure when an older failed event arrives after recovery', async () => {
113
+ const payload = stepAttemptTerminatedPayload();
114
+ const step = stepEntity({
115
+ id: payload.stepId,
116
+ jobExecutionId: JOB_EXECUTION_ID,
117
+ status: 'succeeded',
118
+ currentAttempt: 2,
119
+ error: null,
120
+ });
121
+ const attempt = stepAttemptEntity({
122
+ stepId: step.id,
123
+ attempt: 1,
124
+ status: 'failed',
125
+ exitCode: 1,
126
+ error: {reason: 'agent_invocation_failed', message: 'old failure'},
127
+ });
128
+ const recoveredStep = stepEntity({
129
+ id: payload.stepId,
130
+ jobExecutionId: JOB_EXECUTION_ID,
131
+ status: 'succeeded',
132
+ currentAttempt: 2,
133
+ error: null,
134
+ });
135
+ const recoveredAttempt = stepAttemptEntity({
136
+ stepId: recoveredStep.id,
137
+ attempt: 2,
138
+ status: 'succeeded',
139
+ exitCode: 0,
140
+ error: null,
141
+ });
142
+ dbMocks.getStepAttemptDetail.mockResolvedValueOnce({
143
+ workflowRunId: payload.workflowRunId,
144
+ workflowRunAttemptId: payload.workflowRunAttemptId,
145
+ step,
146
+ attempt,
147
+ });
148
+ dbMocks.getStepAttemptDetail.mockResolvedValueOnce({
149
+ workflowRunId: payload.workflowRunId,
150
+ workflowRunAttemptId: payload.workflowRunAttemptId,
151
+ step: recoveredStep,
152
+ attempt: recoveredAttempt,
153
+ });
154
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 1});
155
+
156
+ await onStepAttemptTerminatedFailureAnnotation(annotations)(payload);
157
+
158
+ expect(replaceOrRemoveAnnotation).toHaveBeenCalledWith(
159
+ expect.objectContaining({
160
+ context: `failure:step:${payload.stepId}`,
161
+ annotation: {op: 'remove'},
162
+ }),
163
+ );
164
+ });
165
+
166
+ it('projects the canonical current attempt when an older failure event arrives late', async () => {
167
+ const payload = stepAttemptTerminatedPayload();
168
+ const oldStep = stepEntity({
169
+ id: payload.stepId,
170
+ jobExecutionId: JOB_EXECUTION_ID,
171
+ status: 'failed',
172
+ currentAttempt: 2,
173
+ error: {reason: 'agent_invocation_failed', message: 'old step error'},
174
+ });
175
+ const oldAttempt = stepAttemptEntity({
176
+ stepId: oldStep.id,
177
+ attempt: 1,
178
+ status: 'failed',
179
+ error: {reason: 'agent_invocation_failed', message: 'old failure'},
180
+ });
181
+ const currentStep = stepEntity({
182
+ id: payload.stepId,
183
+ jobExecutionId: JOB_EXECUTION_ID,
184
+ status: 'failed',
185
+ currentAttempt: 2,
186
+ error: {reason: 'agent_invocation_failed', message: 'current step error'},
187
+ });
188
+ const currentAttempt = stepAttemptEntity({
189
+ stepId: currentStep.id,
190
+ attempt: 2,
191
+ status: 'failed',
192
+ error: {reason: 'agent_invocation_failed', message: 'current failure'},
193
+ exitCode: 2,
194
+ });
195
+ dbMocks.getStepAttemptDetail.mockResolvedValueOnce({
196
+ workflowRunId: payload.workflowRunId,
197
+ workflowRunAttemptId: payload.workflowRunAttemptId,
198
+ step: oldStep,
199
+ attempt: oldAttempt,
200
+ });
201
+ dbMocks.getStepAttemptDetail.mockResolvedValueOnce({
202
+ workflowRunId: payload.workflowRunId,
203
+ workflowRunAttemptId: payload.workflowRunAttemptId,
204
+ step: currentStep,
205
+ attempt: currentAttempt,
206
+ });
207
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 1});
208
+
209
+ await onStepAttemptTerminatedFailureAnnotation(annotations)(payload);
210
+
211
+ expect(replaceOrRemoveAnnotation).toHaveBeenCalledWith(
212
+ expect.objectContaining({
213
+ originStepAttempt: 2,
214
+ annotation: expect.objectContaining({
215
+ op: 'replace',
216
+ body: expect.stringContaining('current failure'),
217
+ }),
218
+ }),
219
+ );
220
+ });
221
+
222
+ it('skips first successful attempts without reading projection history', async () => {
223
+ const payload = stepAttemptTerminatedPayload({status: 'succeeded'});
224
+
225
+ await onStepAttemptTerminatedFailureAnnotation(annotations)(payload);
226
+
227
+ expect(dbMocks.getStepAttemptDetail).not.toHaveBeenCalled();
228
+ expect(dbMocks.getWorkflowRunAttemptById).not.toHaveBeenCalled();
229
+ expect(replaceOrRemoveAnnotation).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it('projects a job failure from the current execution origin', async () => {
233
+ const payload = jobTerminatedPayload({status: 'failed'});
234
+ dbMocks.getJobScope.mockResolvedValue({
235
+ workspaceId: '44444444-4444-4444-8444-444444444444',
236
+ projectId: '55555555-5555-4555-8555-555555555555',
237
+ triggerReference: null,
238
+ });
239
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 3});
240
+ dbMocks.getJobExecutionFailureOrigin.mockResolvedValue({
241
+ jobExecutionId: payload.jobExecutionId,
242
+ stepId: '77777777-7777-4777-8777-777777777777',
243
+ stepName: 'Run tests',
244
+ stepStatus: 'failed',
245
+ stepAttempt: 2,
246
+ stepError: {reason: 'agent_invocation_failed', message: 'Provider returned 500'},
247
+ attemptStatus: 'failed',
248
+ attemptError: {reason: 'agent_invocation_failed', message: 'Provider returned 500'},
249
+ attemptExitCode: 1,
250
+ });
251
+
252
+ await onJobTerminatedFailureAnnotation(annotations)(payload);
253
+
254
+ expect(dbMocks.getJobExecutionFailureOrigin).toHaveBeenCalledWith(payload.jobExecutionId);
255
+ expect(replaceOrRemoveAnnotation).toHaveBeenCalledWith(
256
+ expect.objectContaining({
257
+ jobExecutionId: payload.jobExecutionId,
258
+ originStepId: '77777777-7777-4777-8777-777777777777',
259
+ originStepAttempt: 2,
260
+ context: `failure:job:${payload.jobId}`,
261
+ annotation: expect.objectContaining({
262
+ op: 'replace',
263
+ body: expect.stringContaining('Run tests'),
264
+ }),
265
+ }),
266
+ );
267
+ });
268
+
269
+ it('projects a condition evaluation error from a skipped job', async () => {
270
+ const payload = jobTerminatedPayload({status: 'skipped', statusReason: 'condition_errored'});
271
+ dbMocks.getJobScope.mockResolvedValue({
272
+ workspaceId: '44444444-4444-4444-8444-444444444444',
273
+ projectId: '55555555-5555-4555-8555-555555555555',
274
+ triggerReference: null,
275
+ });
276
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 3});
277
+ dbMocks.getJobExecutionFailureOrigin.mockResolvedValue({
278
+ jobExecutionId: payload.jobExecutionId,
279
+ stepId: '77777777-7777-4777-8777-777777777777',
280
+ stepName: 'Run tests',
281
+ stepStatus: 'skipped',
282
+ stepAttempt: 1,
283
+ stepError: null,
284
+ attemptStatus: null,
285
+ attemptError: null,
286
+ attemptExitCode: null,
287
+ });
288
+
289
+ await onJobTerminatedFailureAnnotation(annotations)(payload);
290
+
291
+ expect(replaceOrRemoveAnnotation).toHaveBeenCalledWith(
292
+ expect.objectContaining({
293
+ context: `failure:job:${payload.jobId}`,
294
+ annotation: expect.objectContaining({op: 'replace', style: 'error'}),
295
+ }),
296
+ );
297
+ });
298
+
299
+ it('uses the first step as the origin when a job fails before any attempt starts', async () => {
300
+ const payload = jobTerminatedPayload({status: 'failed'});
301
+ dbMocks.getJobScope.mockResolvedValue({
302
+ workspaceId: '44444444-4444-4444-8444-444444444444',
303
+ projectId: '55555555-5555-4555-8555-555555555555',
304
+ triggerReference: null,
305
+ });
306
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 1});
307
+ dbMocks.getJobExecutionFailureOrigin.mockResolvedValue({
308
+ jobExecutionId: payload.jobExecutionId,
309
+ stepId: '77777777-7777-4777-8777-777777777777',
310
+ stepName: 'Checkout',
311
+ stepStatus: 'pending',
312
+ stepAttempt: 1,
313
+ stepError: null,
314
+ attemptStatus: null,
315
+ attemptError: null,
316
+ attemptExitCode: null,
317
+ });
318
+
319
+ await onJobTerminatedFailureAnnotation(annotations)(payload);
320
+
321
+ expect(replaceOrRemoveAnnotation).toHaveBeenCalledWith(
322
+ expect.objectContaining({
323
+ originStepId: '77777777-7777-4777-8777-777777777777',
324
+ originStepAttempt: 1,
325
+ annotation: expect.objectContaining({
326
+ body: expect.stringContaining('before **Checkout** started'),
327
+ }),
328
+ }),
329
+ );
330
+ });
331
+
332
+ it('does not guess an execution for legacy terminal events without an execution id', async () => {
333
+ const payload = jobTerminatedPayload({jobExecutionId: undefined});
334
+
335
+ await onJobTerminatedFailureAnnotation(annotations)(payload);
336
+
337
+ expect(dbMocks.getJobExecutionFailureOrigin).not.toHaveBeenCalled();
338
+ expect(replaceOrRemoveAnnotation).not.toHaveBeenCalled();
339
+ });
340
+
341
+ it('does not project a duplicate job card for a step failure', async () => {
342
+ const payload = jobTerminatedPayload({statusReason: 'step_failed'});
343
+
344
+ await onJobTerminatedFailureAnnotation(annotations)(payload);
345
+
346
+ expect(dbMocks.getJobScope).not.toHaveBeenCalled();
347
+ expect(dbMocks.getWorkflowRunAttemptById).not.toHaveBeenCalled();
348
+ expect(dbMocks.getJobExecutionFailureOrigin).not.toHaveBeenCalled();
349
+ expect(replaceOrRemoveAnnotation).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it('skips successful jobs without reading projection history', async () => {
353
+ const payload = jobTerminatedPayload({status: 'succeeded', statusReason: null});
354
+
355
+ await onJobTerminatedFailureAnnotation(annotations)(payload);
356
+
357
+ expect(dbMocks.getJobScope).not.toHaveBeenCalled();
358
+ expect(dbMocks.getWorkflowRunAttemptById).not.toHaveBeenCalled();
359
+ expect(dbMocks.getJobExecutionFailureOrigin).not.toHaveBeenCalled();
360
+ expect(replaceOrRemoveAnnotation).not.toHaveBeenCalled();
361
+ });
362
+
363
+ it('records and logs lookup failures without changing the terminal outcome', async () => {
364
+ const error = new Error('database unavailable');
365
+ dbMocks.getStepAttemptDetail.mockRejectedValueOnce(error);
366
+
367
+ await expect(
368
+ onStepAttemptTerminatedFailureAnnotation(annotations)(stepAttemptTerminatedPayload()),
369
+ ).resolves.toBeUndefined();
370
+
371
+ expect(metricMocks.recordWorkflowFailureAnnotationFailed).toHaveBeenCalledWith('lookup');
372
+ expect(loggerWarn).toHaveBeenCalledWith(
373
+ expect.objectContaining({error, reason: 'lookup'}),
374
+ 'Failed to project workflow failure annotation',
375
+ );
376
+ });
377
+
378
+ it('records and logs annotation write failures without throwing', async () => {
379
+ const payload = stepAttemptTerminatedPayload();
380
+ const step = stepEntity({id: payload.stepId, jobExecutionId: JOB_EXECUTION_ID});
381
+ const attempt = stepAttemptEntity({stepId: step.id});
382
+ dbMocks.getStepAttemptDetail.mockResolvedValue({
383
+ workflowRunId: payload.workflowRunId,
384
+ workflowRunAttemptId: payload.workflowRunAttemptId,
385
+ step,
386
+ attempt,
387
+ });
388
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 1});
389
+ const error = new Error('annotation service unavailable');
390
+ replaceOrRemoveAnnotation.mockRejectedValueOnce(error);
391
+
392
+ await expect(
393
+ onStepAttemptTerminatedFailureAnnotation(annotations)(payload),
394
+ ).resolves.toBeUndefined();
395
+
396
+ expect(metricMocks.recordWorkflowFailureAnnotationFailed).toHaveBeenCalledWith('write');
397
+ expect(loggerWarn).toHaveBeenCalledWith(
398
+ expect.objectContaining({error, reason: 'write'}),
399
+ 'Failed to project workflow failure annotation',
400
+ );
401
+ });
402
+
403
+ it('classifies published annotation budget failures separately from write failures', async () => {
404
+ const payload = stepAttemptTerminatedPayload();
405
+ const step = stepEntity({id: payload.stepId, jobExecutionId: JOB_EXECUTION_ID});
406
+ const attempt = stepAttemptEntity({stepId: step.id});
407
+ dbMocks.getStepAttemptDetail.mockResolvedValue({
408
+ workflowRunId: payload.workflowRunId,
409
+ workflowRunAttemptId: payload.workflowRunAttemptId,
410
+ step,
411
+ attempt,
412
+ });
413
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 1});
414
+ const error = createInterModuleKnownError(
415
+ annotationsInterModuleContract.methods.replaceOrRemoveAnnotation,
416
+ 'annotation-count-limit-exceeded',
417
+ {maxAnnotations: 10},
418
+ );
419
+ replaceOrRemoveAnnotation.mockRejectedValueOnce(error);
420
+
421
+ await expect(
422
+ onStepAttemptTerminatedFailureAnnotation(annotations)(payload),
423
+ ).resolves.toBeUndefined();
424
+
425
+ expect(metricMocks.recordWorkflowFailureAnnotationFailed).toHaveBeenCalledWith('budget');
426
+ expect(loggerWarn).toHaveBeenCalledWith(
427
+ expect.objectContaining({error, reason: 'budget'}),
428
+ 'Failed to project workflow failure annotation',
429
+ );
430
+ });
431
+ });
432
+
433
+ function stepAttemptTerminatedPayload(
434
+ overrides: Partial<WorkflowsStepAttemptTerminatedEventDto> = {},
435
+ ): WorkflowsStepAttemptTerminatedEventDto {
436
+ return {
437
+ jobId: '11111111-1111-4111-8111-111111111111',
438
+ workflowRunId: '22222222-2222-4222-8222-222222222222',
439
+ workflowRunAttemptId: '33333333-3333-4333-8333-333333333333',
440
+ workspaceId: '44444444-4444-4444-8444-444444444444',
441
+ projectId: '55555555-5555-4555-8555-555555555555',
442
+ stepId: '77777777-7777-4777-8777-777777777777',
443
+ attempt: 1,
444
+ status: 'failed',
445
+ logOutcome: 'drained',
446
+ ...overrides,
447
+ };
448
+ }
449
+
450
+ function jobTerminatedPayload(
451
+ overrides: Partial<WorkflowsJobTerminatedEventDto> = {},
452
+ ): WorkflowsJobTerminatedEventDto {
453
+ return {
454
+ jobId: '11111111-1111-4111-8111-111111111111',
455
+ jobExecutionId: JOB_EXECUTION_ID,
456
+ workflowRunId: '22222222-2222-4222-8222-222222222222',
457
+ workflowRunAttemptId: '33333333-3333-4333-8333-333333333333',
458
+ status: 'failed',
459
+ statusReason: 'timed_out',
460
+ ...overrides,
461
+ };
462
+ }
463
+
464
+ function stepEntity(overrides: Partial<Step> = {}): Step {
465
+ return {
466
+ id: '77777777-7777-4777-8777-777777777777',
467
+ jobExecutionId: JOB_EXECUTION_ID,
468
+ key: 'run',
469
+ name: 'Run tests',
470
+ sourceLocation: null,
471
+ status: 'failed',
472
+ statusReason: null,
473
+ evaluationTrace: null,
474
+ type: 'run',
475
+ config: {run: 'pnpm test'},
476
+ condition: null,
477
+ configPlan: null,
478
+ authoredConfig: {run: 'pnpm test'},
479
+ error: {reason: 'agent_invocation_failed', message: 'Provider returned 500'},
480
+ position: 1,
481
+ version: 1,
482
+ currentAttempt: 1,
483
+ createdAt: new Date('2026-08-05T12:00:00.000Z'),
484
+ updatedAt: new Date('2026-08-05T12:00:00.000Z'),
485
+ ...overrides,
486
+ };
487
+ }
488
+
489
+ function stepAttemptEntity(overrides: Partial<StepAttempt> = {}): StepAttempt {
490
+ return {
491
+ id: '88888888-8888-4888-8888-888888888888',
492
+ stepId: '77777777-7777-4777-8777-777777777777',
493
+ attempt: 1,
494
+ executionOrder: 1,
495
+ status: 'failed',
496
+ config: {run: 'pnpm test'},
497
+ evaluationTrace: null,
498
+ output: null,
499
+ response: null,
500
+ error: null,
501
+ exitCode: 1,
502
+ gateResult: null,
503
+ restartFeedback: null,
504
+ logOutcome: 'drained',
505
+ startedAt: new Date('2026-08-05T12:00:00.000Z'),
506
+ finishedAt: new Date('2026-08-05T12:01:00.000Z'),
507
+ createdAt: new Date('2026-08-05T12:00:00.000Z'),
508
+ ...overrides,
509
+ };
510
+ }