@shipfox/api-workflows 12.4.0 → 12.6.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 (84) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +24 -0
  3. package/dist/core/entities/job-execution.d.ts +1 -0
  4. package/dist/core/entities/job-execution.d.ts.map +1 -1
  5. package/dist/core/entities/job-execution.js.map +1 -1
  6. package/dist/core/entities/job.d.ts +1 -1
  7. package/dist/core/entities/job.d.ts.map +1 -1
  8. package/dist/core/entities/job.js +3 -1
  9. package/dist/core/entities/job.js.map +1 -1
  10. package/dist/core/errors.d.ts +3 -1
  11. package/dist/core/errors.d.ts.map +1 -1
  12. package/dist/core/errors.js +4 -2
  13. package/dist/core/errors.js.map +1 -1
  14. package/dist/core/step-config/job-output-limits.d.ts.map +1 -1
  15. package/dist/core/step-config/job-output-limits.js +2 -2
  16. package/dist/core/step-config/job-output-limits.js.map +1 -1
  17. package/dist/core/step-config/materialize-workflow-model.js +2 -2
  18. package/dist/core/step-config/materialize-workflow-model.js.map +1 -1
  19. package/dist/db/db.d.ts +42 -8
  20. package/dist/db/db.d.ts.map +1 -1
  21. package/dist/db/index.d.ts +1 -1
  22. package/dist/db/index.d.ts.map +1 -1
  23. package/dist/db/index.js.map +1 -1
  24. package/dist/db/schema/job-executions.d.ts +19 -2
  25. package/dist/db/schema/job-executions.d.ts.map +1 -1
  26. package/dist/db/schema/job-executions.js +2 -0
  27. package/dist/db/schema/job-executions.js.map +1 -1
  28. package/dist/db/schema/jobs.d.ts +3 -3
  29. package/dist/db/schema/jobs.d.ts.map +1 -1
  30. package/dist/db/schema/jobs.js +3 -0
  31. package/dist/db/schema/jobs.js.map +1 -1
  32. package/dist/db/workflow-runs/job-executions.d.ts +7 -0
  33. package/dist/db/workflow-runs/job-executions.d.ts.map +1 -1
  34. package/dist/db/workflow-runs/job-executions.js +26 -4
  35. package/dist/db/workflow-runs/job-executions.js.map +1 -1
  36. package/dist/db/workflow-runs/jobs.d.ts.map +1 -1
  37. package/dist/db/workflow-runs/jobs.js +4 -2
  38. package/dist/db/workflow-runs/jobs.js.map +1 -1
  39. package/dist/db/workflow-runs/queries.d.ts +14 -2
  40. package/dist/db/workflow-runs/queries.d.ts.map +1 -1
  41. package/dist/db/workflow-runs/queries.js +52 -13
  42. package/dist/db/workflow-runs/queries.js.map +1 -1
  43. package/dist/db/workflow-runs.d.ts +1 -1
  44. package/dist/db/workflow-runs.d.ts.map +1 -1
  45. package/dist/db/workflow-runs.js.map +1 -1
  46. package/dist/presentation/dto/job.d.ts.map +1 -1
  47. package/dist/presentation/dto/job.js +1 -0
  48. package/dist/presentation/dto/job.js.map +1 -1
  49. package/dist/presentation/dto/workflow-run.d.ts.map +1 -1
  50. package/dist/presentation/dto/workflow-run.js +10 -2
  51. package/dist/presentation/dto/workflow-run.js.map +1 -1
  52. package/dist/presentation/subscribers/on-failure-annotations.d.ts.map +1 -1
  53. package/dist/presentation/subscribers/on-failure-annotations.js +6 -3
  54. package/dist/presentation/subscribers/on-failure-annotations.js.map +1 -1
  55. package/dist/tsconfig.test.tsbuildinfo +1 -1
  56. package/drizzle/0002_output_failure_details.sql +2 -0
  57. package/drizzle/0003_output_invalid_reason.sql +1 -0
  58. package/drizzle/meta/0002_snapshot.json +1718 -0
  59. package/drizzle/meta/0003_snapshot.json +1719 -0
  60. package/drizzle/meta/_journal.json +14 -0
  61. package/package.json +2 -2
  62. package/src/core/entities/job-execution.ts +1 -0
  63. package/src/core/entities/job.ts +2 -0
  64. package/src/core/errors.test.ts +18 -0
  65. package/src/core/errors.ts +9 -2
  66. package/src/core/step-config/job-output-limits.ts +2 -2
  67. package/src/core/step-config/materialize-workflow-model.test.ts +67 -3
  68. package/src/core/step-config/materialize-workflow-model.ts +2 -2
  69. package/src/db/index.ts +1 -0
  70. package/src/db/schema/job-executions.ts +2 -0
  71. package/src/db/schema/jobs.ts +3 -0
  72. package/src/db/workflow-runs/job-executions.test.ts +149 -4
  73. package/src/db/workflow-runs/job-executions.ts +44 -9
  74. package/src/db/workflow-runs/job-status.test.ts +30 -0
  75. package/src/db/workflow-runs/jobs.ts +5 -1
  76. package/src/db/workflow-runs/queries.test.ts +169 -2
  77. package/src/db/workflow-runs/queries.ts +86 -11
  78. package/src/db/workflow-runs.ts +1 -0
  79. package/src/presentation/dto/job.ts +1 -0
  80. package/src/presentation/dto/workflow-run.ts +6 -2
  81. package/src/presentation/subscribers/on-failure-annotations.test.ts +47 -1
  82. package/src/presentation/subscribers/on-failure-annotations.ts +10 -2
  83. package/test/helpers/workflow-runs.ts +1 -0
  84. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,5 +1,8 @@
1
1
  import {WORKFLOW_RUN_JOB_PREVIEW_LIMIT} from '@shipfox/api-workflows-dto';
2
+ import {eq} from 'drizzle-orm';
2
3
  import {buildModel, createTestRun} from '#test/helpers/workflow-runs.js';
4
+ import {db} from '../db.js';
5
+ import {jobs} from '../schema/jobs.js';
3
6
  import {
4
7
  createRerunWorkflowRun,
5
8
  createWorkflowRun,
@@ -272,6 +275,147 @@ describe('workflow run queries', () => {
272
275
  expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'pending', count: 2}]);
273
276
  });
274
277
 
278
+ test('returns execution evidence and counts its display status', async () => {
279
+ const run = await createWorkflowRun({
280
+ workspaceId,
281
+ projectId,
282
+ definitionId,
283
+ model: buildModel({jobs: {build: {steps: [{run: 'echo build'}]}}}),
284
+ triggerPayload: manualTrigger(),
285
+ });
286
+ const [job] = await getJobsByWorkflowRunId(run.id);
287
+ if (!job) throw new Error('expected the run to have a job');
288
+ const execution = await getFirstJobExecutionByJobId(job.id);
289
+ if (!execution) throw new Error('expected the job to have an execution');
290
+
291
+ await updateJobExecutionStatus({
292
+ jobExecutionId: execution.id,
293
+ status: 'running',
294
+ expectedVersion: execution.version,
295
+ });
296
+
297
+ const summary = await listWorkflowRunJobSummaries([
298
+ {id: run.id, currentAttempt: run.currentAttempt},
299
+ ]);
300
+
301
+ expect(summary.get(run.id)?.preview).toMatchObject([
302
+ {
303
+ status: 'pending',
304
+ mode: 'one_shot',
305
+ listenerStatus: 'inactive',
306
+ executionStatus: 'running',
307
+ },
308
+ ]);
309
+ expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'running', count: 1}]);
310
+ expect(summary.get(run.id)?.rawStatusCounts).toEqual([{status: 'pending', count: 1}]);
311
+ });
312
+
313
+ test('counts an active listener without an execution', async () => {
314
+ const run = await createWorkflowRun({
315
+ workspaceId,
316
+ projectId,
317
+ definitionId,
318
+ model: buildModel({
319
+ jobs: {
320
+ listen: {
321
+ listening: {
322
+ on: [{source: 'github', event: 'push'}],
323
+ onResolve: 'finish',
324
+ },
325
+ steps: [{run: 'echo listen'}],
326
+ },
327
+ },
328
+ }),
329
+ triggerPayload: manualTrigger(),
330
+ });
331
+ const [job] = await getJobsByWorkflowRunId(run.id);
332
+ if (!job) throw new Error('expected the run to have a listener job');
333
+
334
+ await db().update(jobs).set({listenerStatus: 'listening'}).where(eq(jobs.id, job.id));
335
+
336
+ const summary = await listWorkflowRunJobSummaries([
337
+ {id: run.id, currentAttempt: run.currentAttempt},
338
+ ]);
339
+
340
+ expect(summary.get(run.id)?.preview).toMatchObject([
341
+ {
342
+ mode: 'listening',
343
+ listenerStatus: 'listening',
344
+ executionStatus: null,
345
+ },
346
+ ]);
347
+ expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'listening', count: 1}]);
348
+ expect(summary.get(run.id)?.rawStatusCounts).toEqual([{status: 'pending', count: 1}]);
349
+ });
350
+
351
+ test('keeps a terminal verdict ahead of a running execution', async () => {
352
+ const run = await createWorkflowRun({
353
+ workspaceId,
354
+ projectId,
355
+ definitionId,
356
+ model: buildModel({jobs: {build: {steps: [{run: 'echo build'}]}}}),
357
+ triggerPayload: manualTrigger(),
358
+ });
359
+ const [job] = await getJobsByWorkflowRunId(run.id);
360
+ if (!job) throw new Error('expected the run to have a job');
361
+ const execution = await getFirstJobExecutionByJobId(job.id);
362
+ if (!execution) throw new Error('expected the job to have an execution');
363
+
364
+ await updateJobExecutionStatus({
365
+ jobExecutionId: execution.id,
366
+ status: 'running',
367
+ expectedVersion: execution.version,
368
+ });
369
+ await updateJobStatus({jobId: job.id, status: 'failed', expectedVersion: job.version});
370
+
371
+ const summary = await listWorkflowRunJobSummaries([
372
+ {id: run.id, currentAttempt: run.currentAttempt},
373
+ ]);
374
+
375
+ expect(summary.get(run.id)?.preview).toMatchObject([
376
+ {status: 'failed', executionStatus: 'running'},
377
+ ]);
378
+ expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'failed', count: 1}]);
379
+ expect(summary.get(run.id)?.rawStatusCounts).toEqual([{status: 'failed', count: 1}]);
380
+ });
381
+
382
+ test('counts a skipped zero-execution job from its terminal verdict', async () => {
383
+ const run = await createWorkflowRun({
384
+ workspaceId,
385
+ projectId,
386
+ definitionId,
387
+ model: buildModel({
388
+ jobs: {
389
+ listen: {
390
+ listening: {
391
+ on: [{source: 'github', event: 'push'}],
392
+ onResolve: 'finish',
393
+ },
394
+ steps: [{run: 'echo listen'}],
395
+ },
396
+ },
397
+ }),
398
+ triggerPayload: manualTrigger(),
399
+ });
400
+ const [job] = await getJobsByWorkflowRunId(run.id);
401
+ if (!job) throw new Error('expected the run to have a listener job');
402
+
403
+ await db()
404
+ .update(jobs)
405
+ .set({status: 'skipped', listenerStatus: 'resolved'})
406
+ .where(eq(jobs.id, job.id));
407
+
408
+ const summary = await listWorkflowRunJobSummaries([
409
+ {id: run.id, currentAttempt: run.currentAttempt},
410
+ ]);
411
+
412
+ expect(summary.get(run.id)?.preview).toMatchObject([
413
+ {status: 'skipped', executionStatus: null},
414
+ ]);
415
+ expect(summary.get(run.id)?.statusCounts).toEqual([{status: 'skipped', count: 1}]);
416
+ expect(summary.get(run.id)?.rawStatusCounts).toEqual([{status: 'skipped', count: 1}]);
417
+ });
418
+
275
419
  // Checks the invariant the snapshot exists to protect: for a run inside the preview
276
420
  // bound, the statuses drawn and the statuses counted describe the same jobs and must
277
421
  // agree exactly. The read races a commit to give the anomaly a chance to appear, so this
@@ -445,13 +589,36 @@ function totalOf(summary: {statusCounts: Array<{count: number}>} | undefined): n
445
589
 
446
590
  /** Statuses the preview actually drew, counted. */
447
591
  function previewCounts(
448
- summary: {preview: Array<{status: string}>} | undefined,
592
+ summary:
593
+ | {
594
+ preview: Array<{
595
+ status: string;
596
+ mode: string;
597
+ listenerStatus: string;
598
+ executionStatus: string | null;
599
+ }>;
600
+ }
601
+ | undefined,
449
602
  ): Record<string, number> {
450
603
  const counts: Record<string, number> = {};
451
- for (const job of summary?.preview ?? []) counts[job.status] = (counts[job.status] ?? 0) + 1;
604
+ for (const job of summary?.preview ?? []) {
605
+ const status = previewDisplayStatus(job);
606
+ counts[status] = (counts[status] ?? 0) + 1;
607
+ }
452
608
  return counts;
453
609
  }
454
610
 
611
+ function previewDisplayStatus(job: {
612
+ status: string;
613
+ mode: string;
614
+ listenerStatus: string;
615
+ executionStatus: string | null;
616
+ }): string {
617
+ if (['succeeded', 'failed', 'cancelled', 'skipped'].includes(job.status)) return job.status;
618
+ if (job.mode === 'listening' && job.listenerStatus === 'listening') return 'listening';
619
+ return job.executionStatus ?? 'pending';
620
+ }
621
+
455
622
  /** The same shape read off the totals, so the two halves can be compared directly. */
456
623
  function statusCountMap(
457
624
  summary: {statusCounts: Array<{status: string; count: number}>} | undefined,
@@ -5,7 +5,8 @@ import {
5
5
  timestampIdCursorWhere,
6
6
  } from '@shipfox/node-drizzle';
7
7
  import {and, asc, count, desc, eq, gte, lte, type SQL, sql} from 'drizzle-orm';
8
- import type {JobStatus} from '#core/entities/job.js';
8
+ import type {JobMode, JobStatus, ListenerStatus} from '#core/entities/job.js';
9
+ import type {JobExecutionStatus} from '#core/entities/job-execution.js';
9
10
  import type {
10
11
  JobExecutionDetail,
11
12
  StepDetail,
@@ -52,6 +53,9 @@ export interface WorkflowRunJobSummary {
52
53
  key: string;
53
54
  name: string | null;
54
55
  status: JobStatus;
56
+ mode: JobMode;
57
+ listenerStatus: ListenerStatus;
58
+ executionStatus: JobExecutionStatus | null;
55
59
  position: number;
56
60
  }
57
61
 
@@ -212,14 +216,23 @@ export interface WorkflowRunJobSummaryTarget {
212
216
  * A page row's jobs: a bounded slice to draw, and totals describing all of them.
213
217
  *
214
218
  * The preview is what a row can show; `statusCounts` is what it can say. Keeping the counts
215
- * server-side is what lets a row report a failure that sits past the preview.
219
+ * server-side is what lets a row report a failure that sits past the preview. Counts use the
220
+ * display status derived from the job verdict, listener state, and selected execution state;
221
+ * the row keeps the verdict and evidence separate so the client can apply the same display
222
+ * rule as run detail.
216
223
  */
217
224
  export interface WorkflowRunJobsSummary {
218
225
  preview: WorkflowRunJobSummary[];
219
226
  statusCounts: WorkflowRunJobStatusCount[];
227
+ rawStatusCounts: WorkflowRunJobRawStatusCount[];
220
228
  }
221
229
 
222
230
  export interface WorkflowRunJobStatusCount {
231
+ status: JobStatus | 'listening';
232
+ count: number;
233
+ }
234
+
235
+ export interface WorkflowRunJobRawStatusCount {
223
236
  status: JobStatus;
224
237
  count: number;
225
238
  }
@@ -262,23 +275,64 @@ export async function listWorkflowRunJobSummaries(
262
275
 
263
276
  const {previewRows, countRows} = await db().transaction(
264
277
  async (tx) => {
278
+ // Pick the execution the detail view would display once per job before either list
279
+ // statement touches it. The existing (job_id, sequence) index supports the latest
280
+ // execution ordering, while the status-first expression preserves the rule that an
281
+ // active execution wins over a newer completed retry.
282
+ const selectedExecution = tx.$with('selected_execution').as(
283
+ tx
284
+ .selectDistinctOn([jobExecutions.jobId], {
285
+ jobId: jobExecutions.jobId,
286
+ executionStatus: sql<JobExecutionStatus>`${jobExecutions.status}`.as(
287
+ 'execution_status',
288
+ ),
289
+ })
290
+ .from(jobExecutions)
291
+ .innerJoin(jobs, eq(jobExecutions.jobId, jobs.id))
292
+ .innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
293
+ .where(attemptFilter)
294
+ .orderBy(
295
+ asc(jobExecutions.jobId),
296
+ sql`case when ${jobExecutions.status} = 'running' then 0 else 1 end`,
297
+ desc(jobExecutions.sequence),
298
+ desc(jobExecutions.id),
299
+ ),
300
+ );
301
+
265
302
  const ranked = tx
303
+ .with(selectedExecution)
266
304
  .select({
267
305
  workflowRunId: workflowRunAttempts.workflowRunId,
268
306
  id: jobs.id,
269
307
  key: jobs.key,
270
308
  name: jobs.name,
271
309
  status: jobs.status,
310
+ mode: jobs.mode,
311
+ listenerStatus: jobs.listenerStatus,
312
+ executionStatus: selectedExecution.executionStatus,
272
313
  position: jobs.position,
273
- rank: sql<number>`row_number() over (partition by ${workflowRunAttempts.workflowRunId} order by ${jobs.position} asc, ${jobs.id} asc)`.as(
274
- 'rank',
275
- ),
314
+ jobRank:
315
+ sql<number>`row_number() over (partition by ${workflowRunAttempts.workflowRunId} order by ${jobs.position} asc, ${jobs.id} asc)`.as(
316
+ 'job_rank',
317
+ ),
276
318
  })
277
319
  .from(jobs)
278
320
  .innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
321
+ .leftJoin(selectedExecution, eq(selectedExecution.jobId, jobs.id))
279
322
  .where(attemptFilter)
280
323
  .as('ranked');
281
324
 
325
+ const executionDisplayStatus = sql<JobExecutionStatus | null>`
326
+ ${selectedExecution.executionStatus}
327
+ `;
328
+ const displayStatus = sql<WorkflowRunJobStatusCount['status']>`
329
+ case
330
+ when ${jobs.status} in ('succeeded', 'failed', 'cancelled', 'skipped') then ${jobs.status}::text
331
+ when ${jobs.mode} = 'listening' and ${jobs.listenerStatus} = 'listening' then 'listening'
332
+ when ${executionDisplayStatus} is not null then ${executionDisplayStatus}::text
333
+ else 'pending'
334
+ end
335
+ `;
282
336
  return {
283
337
  previewRows: await tx
284
338
  .select({
@@ -287,21 +341,27 @@ export async function listWorkflowRunJobSummaries(
287
341
  key: ranked.key,
288
342
  name: ranked.name,
289
343
  status: ranked.status,
344
+ mode: ranked.mode,
345
+ listenerStatus: ranked.listenerStatus,
346
+ executionStatus: ranked.executionStatus,
290
347
  position: ranked.position,
291
348
  })
292
349
  .from(ranked)
293
- .where(lte(ranked.rank, WORKFLOW_RUN_JOB_PREVIEW_LIMIT))
350
+ .where(lte(ranked.jobRank, WORKFLOW_RUN_JOB_PREVIEW_LIMIT))
294
351
  .orderBy(asc(ranked.workflowRunId), asc(ranked.position), asc(ranked.id)),
295
352
  countRows: await tx
353
+ .with(selectedExecution)
296
354
  .select({
297
355
  workflowRunId: workflowRunAttempts.workflowRunId,
298
- status: jobs.status,
356
+ rawStatus: jobs.status,
357
+ status: displayStatus,
299
358
  count: count(),
300
359
  })
301
360
  .from(jobs)
302
361
  .innerJoin(workflowRunAttempts, eq(jobs.workflowRunAttemptId, workflowRunAttempts.id))
362
+ .leftJoin(selectedExecution, eq(selectedExecution.jobId, jobs.id))
303
363
  .where(attemptFilter)
304
- .groupBy(workflowRunAttempts.workflowRunId, jobs.status),
364
+ .groupBy(workflowRunAttempts.workflowRunId, jobs.status, displayStatus),
305
365
  };
306
366
  },
307
367
  {isolationLevel: 'repeatable read', accessMode: 'read only'},
@@ -310,8 +370,10 @@ export async function listWorkflowRunJobSummaries(
310
370
  for (const {workflowRunId, ...summary} of previewRows) {
311
371
  summaryFor(summaries, workflowRunId).preview.push(summary);
312
372
  }
313
- for (const {workflowRunId, status, count: statusCount} of countRows) {
314
- summaryFor(summaries, workflowRunId).statusCounts.push({status, count: statusCount});
373
+ for (const {workflowRunId, rawStatus, status, count: statusCount} of countRows) {
374
+ const summary = summaryFor(summaries, workflowRunId);
375
+ appendStatusCount(summary.rawStatusCounts, rawStatus, statusCount);
376
+ appendStatusCount(summary.statusCounts, status, statusCount);
315
377
  }
316
378
 
317
379
  return summaries;
@@ -323,11 +385,24 @@ function summaryFor(
323
385
  ): WorkflowRunJobsSummary {
324
386
  const existing = summaries.get(workflowRunId);
325
387
  if (existing) return existing;
326
- const created: WorkflowRunJobsSummary = {preview: [], statusCounts: []};
388
+ const created: WorkflowRunJobsSummary = {preview: [], statusCounts: [], rawStatusCounts: []};
327
389
  summaries.set(workflowRunId, created);
328
390
  return created;
329
391
  }
330
392
 
393
+ function appendStatusCount<T extends string>(
394
+ counts: Array<{status: T; count: number}>,
395
+ status: T,
396
+ count: number,
397
+ ): void {
398
+ const existing = counts.find((entry) => entry.status === status);
399
+ if (existing) {
400
+ existing.count += count;
401
+ } else {
402
+ counts.push({status, count});
403
+ }
404
+ }
405
+
331
406
  export async function listWorkflowRunsByProject(projectId: string): Promise<WorkflowRun[]> {
332
407
  const result = await listWorkflowRuns({projectId, limit: 100});
333
408
  return result.runs;
@@ -47,6 +47,7 @@ export type {
47
47
  WorkflowRunAggregates,
48
48
  WorkflowRunCursor,
49
49
  WorkflowRunFilters,
50
+ WorkflowRunJobRawStatusCount,
50
51
  WorkflowRunJobStatusCount,
51
52
  WorkflowRunJobSummary,
52
53
  WorkflowRunJobSummaryTarget,
@@ -56,6 +56,7 @@ export function toJobExecutionDto(jobExecution: JobExecution): JobExecutionDto {
56
56
  name: jobExecution.name,
57
57
  status: jobExecution.status,
58
58
  status_reason: jobExecution.statusReason,
59
+ status_reason_message: jobExecution.statusReasonMessage,
59
60
  runner: jobExecution.runner,
60
61
  trigger_events: jobExecution.triggerEvents,
61
62
  outputs: jobExecution.outputs,
@@ -33,7 +33,7 @@ export function toRunDto(run: WorkflowRun, latestAttempt = run.currentAttempt):
33
33
  };
34
34
  }
35
35
 
36
- const EMPTY_JOBS: WorkflowRunJobsSummary = {preview: [], statusCounts: []};
36
+ const EMPTY_JOBS: WorkflowRunJobsSummary = {preview: [], statusCounts: [], rawStatusCounts: []};
37
37
 
38
38
  export function toRunListItemDto(
39
39
  run: WorkflowRun,
@@ -46,9 +46,13 @@ export function toRunListItemDto(
46
46
  key: job.key,
47
47
  name: job.name,
48
48
  status: job.status,
49
+ mode: job.mode,
50
+ listener_status: job.listenerStatus,
51
+ execution_status: job.executionStatus,
49
52
  position: job.position,
50
53
  })),
51
- job_status_counts: jobs.statusCounts.map(({status, count}) => ({status, count})),
54
+ job_status_counts: jobs.rawStatusCounts.map(({status, count}) => ({status, count})),
55
+ job_display_status_counts: jobs.statusCounts.map(({status, count}) => ({status, count})),
52
56
  };
53
57
  }
54
58
 
@@ -230,7 +230,12 @@ describe('failure annotations', () => {
230
230
  });
231
231
 
232
232
  it('projects a job failure from the current execution origin', async () => {
233
- const payload = jobTerminatedPayload({status: 'failed'});
233
+ const payload = jobTerminatedPayload({
234
+ status: 'failed',
235
+ statusReason: 'output_too_large',
236
+ statusReasonMessage:
237
+ 'Job output "payload" exceeds the per-value size limit of 16384 bytes (measured 16385 bytes; overshoot 1 bytes).',
238
+ });
234
239
  dbMocks.getJobScope.mockResolvedValue({
235
240
  workspaceId: '44444444-4444-4444-8444-444444444444',
236
241
  projectId: '55555555-5555-4555-8555-555555555555',
@@ -264,6 +269,47 @@ describe('failure annotations', () => {
264
269
  }),
265
270
  }),
266
271
  );
272
+ expect(replaceOrRemoveAnnotation).toHaveBeenCalledWith(
273
+ expect.objectContaining({
274
+ annotation: expect.objectContaining({
275
+ body: expect.stringContaining('measured 16385 bytes; overshoot 1 bytes'),
276
+ }),
277
+ }),
278
+ );
279
+ });
280
+
281
+ it('projects an output-invalid job failure with its status message', async () => {
282
+ const payload = jobTerminatedPayload({
283
+ statusReason: 'output_invalid',
284
+ statusReasonMessage: 'Job output "payload" cannot be persisted as JSON: undefined.',
285
+ });
286
+ dbMocks.getJobScope.mockResolvedValue({
287
+ workspaceId: '44444444-4444-4444-8444-444444444444',
288
+ projectId: '55555555-5555-4555-8555-555555555555',
289
+ triggerReference: null,
290
+ });
291
+ dbMocks.getWorkflowRunAttemptById.mockResolvedValue({attempt: 3});
292
+ dbMocks.getJobExecutionFailureOrigin.mockResolvedValue({
293
+ jobExecutionId: payload.jobExecutionId,
294
+ stepId: '77777777-7777-4777-8777-777777777777',
295
+ stepName: 'Run tests',
296
+ stepStatus: 'succeeded',
297
+ stepAttempt: 1,
298
+ stepError: null,
299
+ attemptStatus: 'succeeded',
300
+ attemptError: null,
301
+ attemptExitCode: 0,
302
+ });
303
+
304
+ await onJobTerminatedFailureAnnotation(annotations)(payload);
305
+
306
+ expect(replaceOrRemoveAnnotation).toHaveBeenCalledWith(
307
+ expect.objectContaining({
308
+ annotation: expect.objectContaining({
309
+ body: expect.stringContaining('Job output "payload" cannot be persisted as JSON'),
310
+ }),
311
+ }),
312
+ );
267
313
  });
268
314
 
269
315
  it('projects a condition evaluation error from a skipped job', async () => {
@@ -16,7 +16,13 @@ import {
16
16
  } from '#db/index.js';
17
17
  import {recordWorkflowFailureAnnotationFailed} from '#metrics/instance.js';
18
18
 
19
- const JOB_FAILURE_ANNOTATION_REASONS = new Set(['timed_out', 'runner_lost', 'condition_errored']);
19
+ const JOB_FAILURE_ANNOTATION_REASONS = new Set([
20
+ 'timed_out',
21
+ 'runner_lost',
22
+ 'condition_errored',
23
+ 'output_too_large',
24
+ 'output_invalid',
25
+ ]);
20
26
 
21
27
  export function onStepAttemptTerminatedFailureAnnotation(
22
28
  annotations: AnnotationsInterModuleClient,
@@ -123,7 +129,7 @@ export function onJobTerminatedFailureAnnotation(annotations: AnnotationsInterMo
123
129
  },
124
130
  context: failureContext('job', payload.jobId),
125
131
  failed: true,
126
- body: jobFailureBody(payload.statusReason, origin),
132
+ body: jobFailureBody(payload.statusReason, payload.statusReasonMessage, origin),
127
133
  });
128
134
  } catch (error) {
129
135
  recordFailureAnnotationFailure(error, 'lookup', {jobId: payload.jobId});
@@ -200,6 +206,7 @@ function stepFailureBody(
200
206
 
201
207
  function jobFailureBody(
202
208
  reason: string | null,
209
+ statusReasonMessage: string | null | undefined,
203
210
  origin: {
204
211
  stepName: string;
205
212
  attemptStatus: string | null;
@@ -220,6 +227,7 @@ function jobFailureBody(
220
227
  '',
221
228
  progress,
222
229
  `Reason: \`${reason ?? 'unknown'}\``,
230
+ statusReasonMessage ? `Failure: ${statusReasonMessage}` : null,
223
231
  message ? `Failure: ${message}` : null,
224
232
  exitCode,
225
233
  ]
@@ -132,6 +132,7 @@ export async function jobTerminatedEvents(jobId: string) {
132
132
  workflowRunId: string;
133
133
  status: string;
134
134
  statusReason: string | null;
135
+ statusReasonMessage?: string | null;
135
136
  },
136
137
  );
137
138
  }