@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
@@ -0,0 +1,237 @@
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 {isInterModuleKnownError} from '@shipfox/inter-module';
10
+ import {logger} from '@shipfox/node-opentelemetry';
11
+ import {
12
+ getJobExecutionFailureOrigin,
13
+ getJobScope,
14
+ getStepAttemptDetail,
15
+ getWorkflowRunAttemptById,
16
+ } from '#db/index.js';
17
+ import {recordWorkflowFailureAnnotationFailed} from '#metrics/instance.js';
18
+
19
+ const JOB_FAILURE_ANNOTATION_REASONS = new Set(['timed_out', 'runner_lost', 'condition_errored']);
20
+
21
+ export function onStepAttemptTerminatedFailureAnnotation(
22
+ annotations: AnnotationsInterModuleClient,
23
+ ) {
24
+ return async (payload: WorkflowsStepAttemptTerminatedEventDto): Promise<void> => {
25
+ // A first successful/cancelled attempt cannot have a stale failure annotation.
26
+ // Keep later terminal attempts on the lookup path so recovery removes the
27
+ // annotation created by an earlier failed attempt.
28
+ if (payload.status !== undefined && payload.status !== 'failed' && payload.attempt === 1) {
29
+ return;
30
+ }
31
+
32
+ try {
33
+ const initialDetail = await getStepAttemptDetail({
34
+ stepId: payload.stepId,
35
+ attempt: payload.attempt,
36
+ });
37
+ if (!initialDetail) return;
38
+
39
+ // The detail query joins the requested attempt to the step's current projection. A
40
+ // delayed event can therefore return an old attempt alongside a newer step status. Read
41
+ // the canonical current attempt before deciding whether to replace or remove the card.
42
+ const detail =
43
+ initialDetail.attempt.attempt === initialDetail.step.currentAttempt
44
+ ? initialDetail
45
+ : await getStepAttemptDetail({
46
+ stepId: initialDetail.step.id,
47
+ attempt: initialDetail.step.currentAttempt,
48
+ });
49
+ if (!detail || detail.attempt.attempt !== detail.step.currentAttempt) return;
50
+
51
+ const runAttempt = await getWorkflowRunAttemptById(payload.workflowRunAttemptId);
52
+ if (!runAttempt) return;
53
+
54
+ await writeFailureAnnotation({
55
+ annotations,
56
+ target: {
57
+ workspaceId: payload.workspaceId,
58
+ projectId: payload.projectId,
59
+ workflowRunId: payload.workflowRunId,
60
+ workflowRunAttempt: runAttempt.attempt,
61
+ workflowRunAttemptId: payload.workflowRunAttemptId,
62
+ jobId: payload.jobId,
63
+ jobExecutionId: detail.step.jobExecutionId,
64
+ originStepId: detail.step.id,
65
+ originStepAttempt: detail.attempt.attempt,
66
+ },
67
+ context: failureContext('step', detail.step.id),
68
+ failed:
69
+ detail.step.status === 'failed' ||
70
+ (detail.attempt.status === 'failed' &&
71
+ detail.step.status !== 'succeeded' &&
72
+ detail.step.status !== 'cancelled'),
73
+ body: stepFailureBody(
74
+ detail.step.name,
75
+ detail.attempt.error ?? detail.step.error,
76
+ detail.attempt.exitCode,
77
+ ),
78
+ });
79
+ } catch (error) {
80
+ recordFailureAnnotationFailure(error, 'lookup', {
81
+ stepId: payload.stepId,
82
+ jobId: payload.jobId,
83
+ });
84
+ }
85
+ };
86
+ }
87
+
88
+ export function onJobTerminatedFailureAnnotation(annotations: AnnotationsInterModuleClient) {
89
+ return async (payload: WorkflowsJobTerminatedEventDto): Promise<void> => {
90
+ // Step failures already have a step-scoped annotation. Job-scoped annotations
91
+ // are reserved for terminal causes where no step-level failure card exists.
92
+ const isConditionEvaluationFailure =
93
+ payload.status === 'skipped' && payload.statusReason === 'condition_errored';
94
+ if (
95
+ (payload.status !== 'failed' && !isConditionEvaluationFailure) ||
96
+ !JOB_FAILURE_ANNOTATION_REASONS.has(payload.statusReason ?? '')
97
+ ) {
98
+ return;
99
+ }
100
+
101
+ try {
102
+ const [scope, runAttempt] = await Promise.all([
103
+ getJobScope(payload.jobId),
104
+ getWorkflowRunAttemptById(payload.workflowRunAttemptId),
105
+ ]);
106
+ if (!scope || !runAttempt || !payload.jobExecutionId) return;
107
+
108
+ const origin = await getJobExecutionFailureOrigin(payload.jobExecutionId);
109
+ if (!origin) return;
110
+
111
+ await writeFailureAnnotation({
112
+ annotations,
113
+ target: {
114
+ workspaceId: scope.workspaceId,
115
+ projectId: scope.projectId,
116
+ workflowRunId: payload.workflowRunId,
117
+ workflowRunAttempt: runAttempt.attempt,
118
+ workflowRunAttemptId: payload.workflowRunAttemptId,
119
+ jobId: payload.jobId,
120
+ jobExecutionId: origin.jobExecutionId,
121
+ originStepId: origin.stepId,
122
+ originStepAttempt: origin.stepAttempt,
123
+ },
124
+ context: failureContext('job', payload.jobId),
125
+ failed: true,
126
+ body: jobFailureBody(payload.statusReason, origin),
127
+ });
128
+ } catch (error) {
129
+ recordFailureAnnotationFailure(error, 'lookup', {jobId: payload.jobId});
130
+ }
131
+ };
132
+ }
133
+
134
+ type FailureAnnotationTarget = {
135
+ workspaceId: string;
136
+ projectId: string;
137
+ workflowRunId: string;
138
+ workflowRunAttempt: number;
139
+ workflowRunAttemptId: string;
140
+ jobId: string;
141
+ jobExecutionId: string;
142
+ originStepId: string;
143
+ originStepAttempt: number;
144
+ };
145
+
146
+ /**
147
+ * Failure annotations are a best-effort projection. The workflow terminal fact is authoritative;
148
+ * projection lookup and writes are swallowed so they cannot change the workflow outcome. Every
149
+ * swallowed error emits a reason-labelled metric and a structured warning for operations.
150
+ */
151
+ async function writeFailureAnnotation(params: {
152
+ annotations: AnnotationsInterModuleClient;
153
+ target: FailureAnnotationTarget;
154
+ context: string;
155
+ failed: boolean;
156
+ body: string;
157
+ }): Promise<void> {
158
+ try {
159
+ await params.annotations.replaceOrRemoveAnnotation({
160
+ ...params.target,
161
+ context: params.context,
162
+ annotation: params.failed
163
+ ? {op: 'replace', style: 'error', body: params.body}
164
+ : {op: 'remove'},
165
+ });
166
+ } catch (error) {
167
+ const reason = isInterModuleKnownError(
168
+ annotationsInterModuleContract.methods.replaceOrRemoveAnnotation,
169
+ error,
170
+ )
171
+ ? 'budget'
172
+ : 'write';
173
+ recordFailureAnnotationFailure(error, reason, params.target);
174
+ }
175
+ }
176
+
177
+ function failureContext(kind: 'job' | 'step', id: string): string {
178
+ return `failure:${kind}:${id}`;
179
+ }
180
+
181
+ function stepFailureBody(
182
+ name: string,
183
+ error: Record<string, unknown> | null,
184
+ exitCode: number | null,
185
+ ): string {
186
+ const reason = typeof error?.reason === 'string' ? error.reason : 'unknown';
187
+ const message =
188
+ typeof error?.message === 'string' && error.message.trim()
189
+ ? error.message
190
+ : 'No failure message was recorded.';
191
+ return [
192
+ `**${name} failed**`,
193
+ '',
194
+ `Reason: \`${reason}\``,
195
+ `Exit code: \`${exitCode ?? 'none'}\``,
196
+ '',
197
+ message,
198
+ ].join('\n');
199
+ }
200
+
201
+ function jobFailureBody(
202
+ reason: string | null,
203
+ origin: {
204
+ stepName: string;
205
+ attemptStatus: string | null;
206
+ stepError: Record<string, unknown> | null;
207
+ attemptError: Record<string, unknown> | null;
208
+ attemptExitCode: number | null;
209
+ },
210
+ ): string {
211
+ const progress = origin.attemptStatus
212
+ ? `The job stopped while processing **${origin.stepName}**.`
213
+ : `The job stopped before **${origin.stepName}** started.`;
214
+ const error = origin.attemptError ?? origin.stepError;
215
+ const message = typeof error?.message === 'string' && error.message.trim() ? error.message : null;
216
+ const exitCode =
217
+ origin.attemptExitCode === null ? null : `Exit code: \`${origin.attemptExitCode}\``;
218
+ return [
219
+ `**Job failed before completion**`,
220
+ '',
221
+ progress,
222
+ `Reason: \`${reason ?? 'unknown'}\``,
223
+ message ? `Failure: ${message}` : null,
224
+ exitCode,
225
+ ]
226
+ .filter(Boolean)
227
+ .join('\n');
228
+ }
229
+
230
+ function recordFailureAnnotationFailure(
231
+ error: unknown,
232
+ reason: 'lookup' | 'budget' | 'write',
233
+ context: Record<string, string | number>,
234
+ ): void {
235
+ recordWorkflowFailureAnnotationFailed(reason);
236
+ logger().warn({error, reason, ...context}, 'Failed to project workflow failure annotation');
237
+ }