@shipfox/client-workflows 18.0.0 → 19.0.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 (29) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +11 -0
  3. package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -1
  4. package/dist/components/workflow-run-list/job-status-strip.js +20 -7
  5. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
  6. package/dist/core/entities/job.d.ts +5 -2
  7. package/dist/core/entities/job.d.ts.map +1 -1
  8. package/dist/core/entities/job.js +8 -1
  9. package/dist/core/entities/job.js.map +1 -1
  10. package/dist/core/entities/workflow-run.d.ts +7 -4
  11. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  12. package/dist/core/entities/workflow-run.js.map +1 -1
  13. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  14. package/dist/hooks/api/workflow-run-mapper.js +11 -1
  15. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  16. package/dist/tsconfig.test.tsbuildinfo +1 -1
  17. package/package.json +2 -2
  18. package/src/components/job-graph/job-node.test.tsx +2 -2
  19. package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
  20. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +73 -0
  21. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +31 -0
  22. package/src/core/entities/job-status.test.ts +47 -1
  23. package/src/core/entities/job.ts +15 -3
  24. package/src/core/entities/workflow-run-display.test.ts +28 -0
  25. package/src/core/entities/workflow-run.ts +18 -4
  26. package/src/core/workflow-run.test.ts +13 -0
  27. package/src/hooks/api/workflow-run-mapper.ts +18 -1
  28. package/test/fixtures/workflow-run.ts +32 -12
  29. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-workflows",
3
3
  "license": "MIT",
4
- "version": "18.0.0",
4
+ "version": "19.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -29,7 +29,7 @@
29
29
  "date-fns": "^4.1.0",
30
30
  "@shipfox/annotations-dto": "12.3.0",
31
31
  "@shipfox/api-definitions-dto": "12.3.0",
32
- "@shipfox/api-workflows-dto": "12.5.0",
32
+ "@shipfox/api-workflows-dto": "12.6.0",
33
33
  "@shipfox/client-api": "6.0.1",
34
34
  "@shipfox/client-logs": "17.0.0",
35
35
  "@shipfox/client-projects": "17.0.0",
@@ -332,7 +332,7 @@ describe('JobNode status indicator', () => {
332
332
  expect(screen.getByRole('button', {name: 'deploy-prod, Succeeded'})).toBeInTheDocument();
333
333
  });
334
334
 
335
- test('shows a running lifecycle without a running step as pending', () => {
335
+ test('shows a running lifecycle without a running step as running', () => {
336
336
  const node = makeNode({
337
337
  name: 'deploy',
338
338
  status: 'pending',
@@ -341,7 +341,7 @@ describe('JobNode status indicator', () => {
341
341
 
342
342
  renderNode(node);
343
343
 
344
- expect(screen.getByRole('button', {name: 'deploy, Pending'})).toBeInTheDocument();
344
+ expect(screen.getByRole('button', {name: 'deploy, Running'})).toBeInTheDocument();
345
345
  });
346
346
 
347
347
  test('shows a pending lifecycle as running while a step is active', () => {
@@ -3,7 +3,8 @@ import {Code, Text} from '@shipfox/react-ui/typography';
3
3
  import {cn} from '@shipfox/react-ui/utils';
4
4
  import {getWorkflowStatusVisual} from '#components/workflow-status/status-visuals.js';
5
5
  import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
6
- import type {JobStatus, WorkflowRunJobSummary, WorkflowRunJobs} from '#core/workflow-run.js';
6
+ import {deriveJobDisplayStatus, type JobDisplayStatus} from '#core/entities/job.js';
7
+ import type {WorkflowRunJobSummary, WorkflowRunJobs} from '#core/workflow-run.js';
7
8
 
8
9
  /**
9
10
  * How many glyphs fit the strip's column before it starts costing the run name width.
@@ -33,16 +34,17 @@ const COMPACT_COUNT_FORMAT = new Intl.NumberFormat('en', {
33
34
 
34
35
  // Worst-first, so the overflow glyph reports the most alarming thing it is standing in for.
35
36
  // A strip that hides a failure behind eight green discs would be worse than no strip at all.
36
- const STATUS_SEVERITY: Record<JobStatus, number> = {
37
+ const STATUS_SEVERITY: Record<JobDisplayStatus, number> = {
37
38
  failed: 5,
38
39
  running: 4,
40
+ listening: 4,
39
41
  pending: 3,
40
42
  cancelled: 2,
41
43
  skipped: 1,
42
44
  succeeded: 0,
43
45
  };
44
46
 
45
- const NOTABLE_STATUSES: readonly JobStatus[] = ['failed', 'running'];
47
+ const NOTABLE_STATUSES: readonly JobDisplayStatus[] = ['failed', 'running', 'listening'];
46
48
 
47
49
  export interface JobStatusStripProps {
48
50
  jobs: WorkflowRunJobs;
@@ -80,7 +82,7 @@ export function JobStatusStrip({jobs, className}: JobStatusStripProps) {
80
82
  {visible.map((job) => (
81
83
  <WorkflowStatusIcon
82
84
  key={job.id}
83
- status={job.status}
85
+ status={displayStatus(job)}
84
86
  size={GLYPH_SIZE}
85
87
  // One ripple per running job, on every row, would turn a calm list into a
86
88
  // field of pulses. The run's own glyph already carries the live edge.
@@ -121,7 +123,7 @@ function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary:
121
123
  // can be named, but the count of those left over is read off the totals, so a failure past
122
124
  // the preview is still accounted for rather than silently missing.
123
125
  const named = jobs.preview
124
- .filter((job) => NOTABLE_STATUSES.includes(job.status))
126
+ .filter((job) => NOTABLE_STATUSES.includes(displayStatus(job)))
125
127
  .slice(0, MAX_TOOLTIP_JOB_NAMES);
126
128
  const notableTotal = NOTABLE_STATUSES.reduce((total, status) => total + countOf(jobs, status), 0);
127
129
  const remaining = notableTotal - named.length;
@@ -133,7 +135,7 @@ function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary:
133
135
  </Text>
134
136
  {named.map((job) => (
135
137
  <Code as="span" variant="label" key={job.id} className="block truncate">
136
- {getWorkflowStatusVisual(job.status).label.toLowerCase()} · {job.name ?? job.key}
138
+ {getWorkflowStatusVisual(displayStatus(job)).label.toLowerCase()} · {job.name ?? job.key}
137
139
  </Code>
138
140
  ))}
139
141
  {remaining > 0 ? (
@@ -162,15 +164,16 @@ export function jobStatusSummary(jobs: WorkflowRunJobs): string {
162
164
  function worstHiddenStatus(
163
165
  jobs: WorkflowRunJobs,
164
166
  visible: readonly WorkflowRunJobSummary[],
165
- ): JobStatus | null {
167
+ ): JobDisplayStatus | null {
166
168
  const hidden = new Map(jobs.statusCounts.map(({status, count}) => [status, count]));
167
169
  for (const job of visible) {
168
- const remaining = (hidden.get(job.status) ?? 0) - 1;
169
- if (remaining > 0) hidden.set(job.status, remaining);
170
- else hidden.delete(job.status);
170
+ const status = displayStatus(job);
171
+ const remaining = (hidden.get(status) ?? 0) - 1;
172
+ if (remaining > 0) hidden.set(status, remaining);
173
+ else hidden.delete(status);
171
174
  }
172
175
 
173
- let worst: JobStatus | null = null;
176
+ let worst: JobDisplayStatus | null = null;
174
177
  for (const [status, count] of hidden) {
175
178
  if (count <= 0) continue;
176
179
  if (worst === null || STATUS_SEVERITY[status] > STATUS_SEVERITY[worst]) worst = status;
@@ -184,6 +187,16 @@ export function overflowCountLabel(hiddenCount: number): string {
184
187
  : COMPACT_COUNT_FORMAT.format(hiddenCount);
185
188
  }
186
189
 
187
- function countOf(jobs: WorkflowRunJobs, status: JobStatus): number {
190
+ function countOf(jobs: WorkflowRunJobs, status: JobDisplayStatus): number {
188
191
  return jobs.statusCounts.find((entry) => entry.status === status)?.count ?? 0;
189
192
  }
193
+
194
+ function displayStatus(job: WorkflowRunJobSummary): JobDisplayStatus {
195
+ return deriveJobDisplayStatus({
196
+ mode: job.mode,
197
+ status: job.status,
198
+ listenerStatus: job.listenerStatus,
199
+ executionStatus: job.executionStatus,
200
+ jobExecutions: [],
201
+ });
202
+ }
@@ -307,6 +307,53 @@ describe('WorkflowRunListView', () => {
307
307
  ).toBeInTheDocument();
308
308
  });
309
309
 
310
+ test('shows an executing one-shot job instead of its pending verdict', async () => {
311
+ const {jobs} = workflowRunJobsFixture(['pending']);
312
+ renderListView([
313
+ run('running', 'deploy-web', 'run-1', {
314
+ jobs: jobs.map((job) => ({...job, execution_status: 'running'})),
315
+ job_status_counts: [{status: 'pending', count: 1}],
316
+ job_display_status_counts: [{status: 'running', count: 1}],
317
+ }),
318
+ ]);
319
+
320
+ const strip = await screen.findByRole('img', {name: '1 job: 1 running'});
321
+ expect(within(strip).getByLabelText('Running')).toBeInTheDocument();
322
+ });
323
+
324
+ test('shows an active listener as listening in the job strip', async () => {
325
+ const {jobs} = workflowRunJobsFixture(['running']);
326
+ renderListView([
327
+ run('running', 'event-driven', 'run-1', {
328
+ jobs: jobs.map((job) => ({
329
+ ...job,
330
+ mode: 'listening',
331
+ listener_status: 'listening',
332
+ execution_status: null,
333
+ })),
334
+ job_status_counts: [{status: 'running', count: 1}],
335
+ job_display_status_counts: [{status: 'listening', count: 1}],
336
+ }),
337
+ ]);
338
+
339
+ const strip = await screen.findByRole('img', {name: '1 job: 1 listening'});
340
+ expect(within(strip).getByLabelText('Listening')).toBeInTheDocument();
341
+ });
342
+
343
+ test('shows a failed execution when the job verdict is still pending', async () => {
344
+ const {jobs} = workflowRunJobsFixture(['pending']);
345
+ renderListView([
346
+ run('running', 'deploy-web', 'run-1', {
347
+ jobs: jobs.map((job) => ({...job, execution_status: 'failed'})),
348
+ job_status_counts: [{status: 'pending', count: 1}],
349
+ job_display_status_counts: [{status: 'failed', count: 1}],
350
+ }),
351
+ ]);
352
+
353
+ const strip = await screen.findByRole('img', {name: '1 job: 1 failed'});
354
+ expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
355
+ });
356
+
310
357
  // The link's aria-label replaces its contents, so the strip's own label is not spoken
311
358
  // when the row is announced as a link. Where a run failed has to survive that.
312
359
  test('carries the job breakdown into the row link name', async () => {
@@ -355,6 +402,32 @@ describe('WorkflowRunListView', () => {
355
402
  expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
356
403
  });
357
404
 
405
+ test('reports listening jobs in the overflow glyph and tooltip', async () => {
406
+ const {jobs} = workflowRunJobsFixture(
407
+ Array.from({length: 16}, () => 'pending') as JobStatusDto[],
408
+ );
409
+ renderListView([
410
+ run('running', 'event-driven', 'run-listener', {
411
+ jobs: jobs.map((job) => ({
412
+ ...job,
413
+ mode: 'listening',
414
+ listener_status: 'listening',
415
+ execution_status: null,
416
+ })),
417
+ job_status_counts: [{status: 'pending', count: 20}],
418
+ job_display_status_counts: [{status: 'listening', count: 20}],
419
+ }),
420
+ ]);
421
+
422
+ const strip = await screen.findByRole('img', {name: '20 jobs: 20 listening'});
423
+ expect(screen.getByText('+13')).toBeInTheDocument();
424
+ expect(within(strip).getAllByLabelText('Listening')).toHaveLength(8);
425
+
426
+ const user = userEvent.setup();
427
+ await user.hover(strip);
428
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('and 14 more');
429
+ });
430
+
358
431
  // Nothing caps a workflow's job count, so an exact overflow count is unbounded in width
359
432
  // and would eventually paint over the duration column beside it.
360
433
  test('abbreviates an overflow count too wide to print exactly', async () => {
@@ -101,6 +101,12 @@ type Story = StoryObj<typeof meta>;
101
101
 
102
102
  export const Playground: Story = {};
103
103
 
104
+ export const ExecutionStates: Story = {
105
+ args: {
106
+ runs: [makeExecutionStateRun(), makeListeningStateRun()],
107
+ },
108
+ };
109
+
104
110
  /**
105
111
  * The row under its one-line threshold, at the 720px a 768px viewport leaves the column.
106
112
  *
@@ -229,3 +235,28 @@ function StateExample({label, children}: {label: string; children: ReactNode}) {
229
235
  </div>
230
236
  );
231
237
  }
238
+
239
+ function makeExecutionStateRun(): WorkflowRunListItem {
240
+ const fixture = workflowRunJobsFixture(['pending']);
241
+ return sequencedWorkflowRunListItem('running', 'one-shot-executing', 1, {
242
+ ...fixture,
243
+ jobs: fixture.jobs.map((job) => ({...job, execution_status: 'running'})),
244
+ job_status_counts: [{status: 'pending', count: 1}],
245
+ job_display_status_counts: [{status: 'running', count: 1}],
246
+ });
247
+ }
248
+
249
+ function makeListeningStateRun(): WorkflowRunListItem {
250
+ const fixture = workflowRunJobsFixture(['pending']);
251
+ return sequencedWorkflowRunListItem('running', 'event-driven-listener', 3, {
252
+ ...fixture,
253
+ jobs: fixture.jobs.map((job) => ({
254
+ ...job,
255
+ mode: 'listening',
256
+ listener_status: 'listening',
257
+ execution_status: null,
258
+ })),
259
+ job_status_counts: [{status: 'pending', count: 1}],
260
+ job_display_status_counts: [{status: 'listening', count: 1}],
261
+ });
262
+ }
@@ -42,7 +42,7 @@ describe('deriveJobDisplayStatus', () => {
42
42
  const statuses: Array<[JobExecutionStatus, string[], JobDisplayStatus]> = [
43
43
  ['pending', [], 'pending'],
44
44
  ['pending', ['running'], 'running'],
45
- ['running', [], 'pending'],
45
+ ['running', [], 'running'],
46
46
  ['running', ['running'], 'running'],
47
47
  ];
48
48
 
@@ -91,6 +91,52 @@ describe('deriveJobDisplayStatus', () => {
91
91
  ).toBe('listening');
92
92
  });
93
93
 
94
+ test.each([
95
+ ['pending', 'pending'],
96
+ ['failed', 'failed'],
97
+ [null, 'pending'],
98
+ ] as const)('uses list execution evidence for %s', (executionStatus, expected) => {
99
+ expect(
100
+ deriveJobDisplayStatus({
101
+ mode: 'one_shot',
102
+ status: 'pending',
103
+ listenerStatus: 'inactive',
104
+ executionStatus,
105
+ jobExecutions: [],
106
+ }),
107
+ ).toBe(expected);
108
+ });
109
+
110
+ test('does not infer execution from a running verdict without execution evidence', () => {
111
+ expect(
112
+ deriveJobDisplayStatus({
113
+ mode: 'one_shot',
114
+ status: 'running',
115
+ listenerStatus: 'inactive',
116
+ executionStatus: null,
117
+ jobExecutions: [],
118
+ }),
119
+ ).toBe('pending');
120
+ });
121
+
122
+ test('uses the same running execution evidence with and without a step tree', () => {
123
+ const detailStatus = deriveJobDisplayStatus({
124
+ mode: 'one_shot',
125
+ status: 'pending',
126
+ listenerStatus: 'inactive',
127
+ jobExecutions: [{status: 'running', steps: [], sequence: 1}] as never,
128
+ });
129
+ const listStatus = deriveJobDisplayStatus({
130
+ mode: 'one_shot',
131
+ status: 'pending',
132
+ listenerStatus: 'inactive',
133
+ executionStatus: 'running',
134
+ jobExecutions: [],
135
+ });
136
+
137
+ expect(listStatus).toBe(detailStatus);
138
+ });
139
+
94
140
  test('uses the terminal job status when an active listener has resolved', () => {
95
141
  expect(
96
142
  deriveJobDisplayStatus({
@@ -2,6 +2,7 @@ import {
2
2
  deriveJobExecutionDisplayStatus,
3
3
  type JobExecution,
4
4
  type JobExecutionDisplayDuration,
5
+ type JobExecutionStatus,
5
6
  } from './job-execution.js';
6
7
  import type {EvaluationTraceEntry} from './step-attempt.js';
7
8
 
@@ -140,12 +141,23 @@ export function isTerminalJobStatus(status: JobStatus): boolean {
140
141
  }
141
142
 
142
143
  export function deriveJobDisplayStatus(
143
- job: Pick<Job, 'mode' | 'status' | 'listenerStatus' | 'jobExecutions'>,
144
+ job: Pick<Job, 'mode' | 'status' | 'listenerStatus' | 'jobExecutions'> & {
145
+ /** List previews carry the selected execution state without its step tree. */
146
+ executionStatus?: JobExecutionStatus | null | undefined;
147
+ },
144
148
  ): JobDisplayStatus {
145
149
  if (isTerminalJobStatus(job.status)) return job.status;
146
150
  if (job.mode === 'listening' && job.listenerStatus === 'listening') return 'listening';
147
-
148
- const execution = defaultJobExecution(job);
151
+ const execution =
152
+ job.executionStatus === undefined
153
+ ? defaultJobExecution(job)
154
+ : job.executionStatus === null
155
+ ? undefined
156
+ : {status: job.executionStatus, steps: []};
157
+ // A running execution is the shared display rule for both list previews and detail jobs.
158
+ // The list has no step tree, while the detail path can still use the execution's steps for
159
+ // other non-running states.
160
+ if (execution?.status === 'running') return 'running';
149
161
  return execution ? deriveJobExecutionDisplayStatus(execution) : 'pending';
150
162
  }
151
163
 
@@ -100,6 +100,34 @@ describe('workflowRunListItemDisplay', () => {
100
100
  expect(display.status).toBe('running');
101
101
  expect(display.duration).toMatchObject({kind: 'run'});
102
102
  });
103
+
104
+ test('uses execution-derived running counts for the row headline', () => {
105
+ const run = workflowRunListItem({
106
+ status: 'running',
107
+ ...workflowRunJobsFixture(['pending']),
108
+ job_display_status_counts: [{status: 'running', count: 1}],
109
+ started_at: ATTEMPT_STARTED_AT,
110
+ });
111
+
112
+ const display = workflowRunListItemDisplay(run);
113
+
114
+ expect(display.status).toBe('running');
115
+ expect(display.duration).toMatchObject({kind: 'run'});
116
+ });
117
+
118
+ test('uses execution-derived listening counts as started work', () => {
119
+ const run = workflowRunListItem({
120
+ status: 'running',
121
+ ...workflowRunJobsFixture(['pending']),
122
+ job_display_status_counts: [{status: 'listening', count: 1}],
123
+ started_at: ATTEMPT_STARTED_AT,
124
+ });
125
+
126
+ const display = workflowRunListItemDisplay(run);
127
+
128
+ expect(display.status).toBe('running');
129
+ expect(display.duration).toMatchObject({kind: 'run'});
130
+ });
103
131
  });
104
132
 
105
133
  describe('workflowRunBlockingJob', () => {
@@ -1,5 +1,16 @@
1
- import {type Job, type JobStatus, WORKFLOW_JOB_STATUSES} from './job.js';
2
- import {elapsedTimeFromTimestamps, type JobExecutionDisplayDuration} from './job-execution.js';
1
+ import {
2
+ type Job,
3
+ type JobDisplayStatus,
4
+ type JobMode,
5
+ type JobStatus,
6
+ type ListenerStatus,
7
+ WORKFLOW_JOB_STATUSES,
8
+ } from './job.js';
9
+ import {
10
+ elapsedTimeFromTimestamps,
11
+ type JobExecutionDisplayDuration,
12
+ type JobExecutionStatus,
13
+ } from './job-execution.js';
3
14
  import type {WorkflowRunAttempt, WorkflowRunAttemptSummary} from './workflow-run-attempt.js';
4
15
 
5
16
  export type WorkflowRunStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
@@ -60,11 +71,14 @@ export interface WorkflowRunJobSummary {
60
71
  key: string;
61
72
  name: string | null;
62
73
  status: JobStatus;
74
+ mode: JobMode;
75
+ listenerStatus: ListenerStatus;
76
+ executionStatus: JobExecutionStatus | null;
63
77
  position: number;
64
78
  }
65
79
 
66
80
  export interface WorkflowRunJobStatusCount {
67
- status: JobStatus;
81
+ status: JobDisplayStatus;
68
82
  count: number;
69
83
  }
70
84
 
@@ -209,7 +223,7 @@ export interface WorkflowRunProgress {
209
223
  runStatus: WorkflowRunStatus;
210
224
  startedAt: string | null;
211
225
  finishedAt: string | null;
212
- jobStatuses: JobStatus[];
226
+ jobStatuses: JobDisplayStatus[];
213
227
  firstStartedAt?: string | null | undefined;
214
228
  }
215
229
 
@@ -12,6 +12,7 @@ import {
12
12
  workflowRunAttemptDto,
13
13
  workflowRunDetailDto,
14
14
  workflowRunDto,
15
+ workflowRunJobSummaryDto,
15
16
  workflowRunListResponseDto,
16
17
  workflowStepAttemptDto,
17
18
  workflowStepDto,
@@ -127,6 +128,18 @@ describe('workflow run model mapping', () => {
127
128
  });
128
129
  });
129
130
 
131
+ test('keeps legacy raw-status counts aligned with preview glyphs', () => {
132
+ const {job_display_status_counts: _displayCounts, ...legacyDto} = workflowRunDto({
133
+ jobs: [workflowRunJobSummaryDto({status: 'running', execution_status: null})],
134
+ job_status_counts: [{status: 'running', count: 1}],
135
+ });
136
+
137
+ const run = toWorkflowRunListItem(legacyDto);
138
+
139
+ expect(run.jobs.preview[0]?.executionStatus).toBe('running');
140
+ expect(run.jobs.statusCounts).toEqual([{status: 'running', count: 1}]);
141
+ });
142
+
130
143
  test('maps run list pagination fields', () => {
131
144
  const dto = workflowRunListResponseDto({
132
145
  runs: [
@@ -96,7 +96,10 @@ export function toWorkflowRunRecord(dto: WorkflowRunResponseDto): WorkflowRunRec
96
96
  }
97
97
 
98
98
  export function toWorkflowRunListItem(dto: WorkflowRunListItemDto): WorkflowRunListItem {
99
- const statusCounts = dto.job_status_counts.map(({status, count}) => ({status, count}));
99
+ const hasDisplayStatusCounts = dto.job_display_status_counts !== undefined;
100
+ const statusCounts = (dto.job_display_status_counts ?? dto.job_status_counts).map(
101
+ ({status, count}) => ({status, count}),
102
+ );
100
103
  return {
101
104
  ...toWorkflowRunRecord(dto),
102
105
  jobs: {
@@ -105,6 +108,14 @@ export function toWorkflowRunListItem(dto: WorkflowRunListItemDto): WorkflowRunL
105
108
  key: job.key,
106
109
  name: job.name,
107
110
  status: job.status,
111
+ mode: job.mode ?? 'one_shot',
112
+ listenerStatus: job.listener_status ?? 'inactive',
113
+ // The optional display-count field is the rollout capability signal. Pre-display API
114
+ // responses only carry raw verdict counts, so mirror their non-terminal verdict into
115
+ // execution evidence to keep each legacy glyph aligned with those fallback counts.
116
+ executionStatus: hasDisplayStatusCounts
117
+ ? (job.execution_status ?? null)
118
+ : legacyExecutionStatus(job.status),
108
119
  position: job.position,
109
120
  })),
110
121
  statusCounts,
@@ -115,6 +126,12 @@ export function toWorkflowRunListItem(dto: WorkflowRunListItemDto): WorkflowRunL
115
126
  };
116
127
  }
117
128
 
129
+ function legacyExecutionStatus(
130
+ status: WorkflowRunListItemDto['jobs'][number]['status'],
131
+ ): 'pending' | 'running' | null {
132
+ return status === 'pending' || status === 'running' ? status : null;
133
+ }
134
+
118
135
  export function toWorkflowRunListPage(dto: WorkflowRunListResponseDto): WorkflowRunListPage {
119
136
  return {
120
137
  runs: dto.runs.map(toWorkflowRunListItem),
@@ -81,6 +81,7 @@ export function workflowRunDto(
81
81
  finished_at: null,
82
82
  jobs: [],
83
83
  job_status_counts: [],
84
+ job_display_status_counts: [],
84
85
  ...overrides,
85
86
  };
86
87
  }
@@ -89,11 +90,15 @@ export function workflowRunJobSummaryDto(
89
90
  overrides: Partial<WorkflowRunJobSummaryDto> = {},
90
91
  ): WorkflowRunJobSummaryDto {
91
92
  jobSummarySequence += 1;
93
+ const status = overrides.status ?? 'succeeded';
92
94
  return {
93
95
  id: `${JOB_SUMMARY_ID_PREFIX}${String(jobSummarySequence).padStart(11, '0')}`,
94
96
  key: `job-${jobSummarySequence}`,
95
97
  name: null,
96
- status: 'succeeded',
98
+ status,
99
+ mode: 'one_shot',
100
+ listener_status: 'inactive',
101
+ execution_status: executionStatusForFixtureStatus(status),
97
102
  position: jobSummarySequence - 1,
98
103
  ...overrides,
99
104
  };
@@ -104,13 +109,25 @@ export function workflowRunJobSummaryDtos(
104
109
  count: number,
105
110
  statuses: readonly JobStatusDto[] = [],
106
111
  ): WorkflowRunJobSummaryDto[] {
107
- return Array.from({length: count}, (_, index) =>
108
- workflowRunJobSummaryDto({
112
+ return Array.from({length: count}, (_, index) => {
113
+ const status = statuses[index];
114
+ return workflowRunJobSummaryDto({
109
115
  key: `job-${index + 1}`,
110
116
  position: index,
111
- ...(statuses[index] ? {status: statuses[index]} : {}),
112
- }),
113
- );
117
+ ...(status
118
+ ? {
119
+ status,
120
+ execution_status: executionStatusForFixtureStatus(status),
121
+ }
122
+ : {}),
123
+ });
124
+ });
125
+ }
126
+
127
+ function executionStatusForFixtureStatus(
128
+ status: JobStatusDto,
129
+ ): WorkflowRunJobSummaryDto['execution_status'] {
130
+ return status === 'pending' || status === 'running' ? status : null;
114
131
  }
115
132
 
116
133
  /**
@@ -122,16 +139,19 @@ export function workflowRunJobSummaryDtos(
122
139
  */
123
140
  export function workflowRunJobsFixture(
124
141
  statuses: readonly JobStatusDto[],
125
- ): Pick<WorkflowRunListItemDto, 'jobs' | 'job_status_counts'> {
142
+ ): Pick<WorkflowRunListItemDto, 'jobs' | 'job_status_counts' | 'job_display_status_counts'> {
126
143
  const counts = new Map<JobStatusDto, number>();
127
144
  for (const status of statuses) counts.set(status, (counts.get(status) ?? 0) + 1);
128
145
 
146
+ const preview = workflowRunJobSummaryDtos(
147
+ Math.min(statuses.length, WORKFLOW_RUN_JOB_PREVIEW_LIMIT),
148
+ statuses,
149
+ );
150
+
129
151
  return {
130
- jobs: workflowRunJobSummaryDtos(
131
- Math.min(statuses.length, WORKFLOW_RUN_JOB_PREVIEW_LIMIT),
132
- statuses,
133
- ),
152
+ jobs: preview,
134
153
  job_status_counts: [...counts.entries()].map(([status, count]) => ({status, count})),
154
+ job_display_status_counts: [...counts.entries()].map(([status, count]) => ({status, count})),
135
155
  };
136
156
  }
137
157
 
@@ -139,7 +159,7 @@ export function workflowRunJobsFixture(
139
159
  export function workflowRunJobsOfStatus(
140
160
  count: number,
141
161
  status: JobStatusDto = 'succeeded',
142
- ): Pick<WorkflowRunListItemDto, 'jobs' | 'job_status_counts'> {
162
+ ): Pick<WorkflowRunListItemDto, 'jobs' | 'job_status_counts' | 'job_display_status_counts'> {
143
163
  return workflowRunJobsFixture(Array.from({length: count}, () => status));
144
164
  }
145
165