@shipfox/client-workflows 18.0.0 → 20.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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/dist/components/workflow-run-duration-label.d.ts +8 -7
  4. package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
  5. package/dist/components/workflow-run-duration-label.js +15 -31
  6. package/dist/components/workflow-run-duration-label.js.map +1 -1
  7. package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -1
  8. package/dist/components/workflow-run-list/job-status-strip.js +20 -7
  9. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
  10. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  11. package/dist/components/workflow-run-list/workflow-run-row.js +9 -6
  12. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  13. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  14. package/dist/components/workflow-run-summary/workflow-run-summary.js +7 -34
  15. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  16. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  17. package/dist/components/workflow-status/status-visuals.js +0 -9
  18. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  19. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  20. package/dist/components/workflow-status/workflow-status-icon.js +0 -11
  21. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  22. package/dist/core/entities/job.d.ts +5 -2
  23. package/dist/core/entities/job.d.ts.map +1 -1
  24. package/dist/core/entities/job.js +8 -1
  25. package/dist/core/entities/job.js.map +1 -1
  26. package/dist/core/entities/workflow-run.d.ts +15 -57
  27. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  28. package/dist/core/entities/workflow-run.js +1 -94
  29. package/dist/core/entities/workflow-run.js.map +1 -1
  30. package/dist/core/workflow-run.d.ts +2 -2
  31. package/dist/core/workflow-run.d.ts.map +1 -1
  32. package/dist/core/workflow-run.js +1 -1
  33. package/dist/core/workflow-run.js.map +1 -1
  34. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  35. package/dist/hooks/api/workflow-run-mapper.js +14 -2
  36. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  37. package/dist/hooks/api/workflow-runs.js +1 -0
  38. package/dist/hooks/api/workflow-runs.js.map +1 -1
  39. package/dist/tsconfig.test.tsbuildinfo +1 -1
  40. package/package.json +2 -2
  41. package/src/components/job-graph/job-node.test.tsx +2 -2
  42. package/src/components/workflow-run-duration-label.tsx +21 -44
  43. package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
  44. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +121 -0
  45. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +31 -0
  46. package/src/components/workflow-run-list/workflow-run-row.tsx +7 -6
  47. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +0 -38
  48. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +73 -21
  49. package/src/components/workflow-run-summary/workflow-run-summary.tsx +6 -27
  50. package/src/components/workflow-status/status-visuals.ts +0 -4
  51. package/src/components/workflow-status/workflow-status-icon.stories.tsx +0 -1
  52. package/src/components/workflow-status/workflow-status-icon.tsx +0 -18
  53. package/src/core/entities/job-status.test.ts +47 -1
  54. package/src/core/entities/job.ts +15 -3
  55. package/src/core/entities/workflow-run.ts +21 -134
  56. package/src/core/workflow-run.test.ts +15 -0
  57. package/src/core/workflow-run.ts +0 -8
  58. package/src/hooks/api/workflow-run-mapper.ts +20 -1
  59. package/src/hooks/api/workflow-runs.ts +1 -1
  60. package/test/fixtures/workflow-run.ts +60 -13
  61. package/tsconfig.build.tsbuildinfo +1 -1
  62. package/src/core/entities/workflow-run-display.test.ts +0 -159
@@ -159,6 +159,17 @@ describe('WorkflowRunSummary', () => {
159
159
  test('shows a live selected attempt duration for running runs', async () => {
160
160
  vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-05-07T01:02:14.000Z'));
161
161
  renderSummary({
162
+ jobs: [
163
+ workflowJobDto({
164
+ status: 'running',
165
+ job_executions: [
166
+ workflowJobExecutionDto({
167
+ status: 'running',
168
+ started_at: '2026-05-07T01:02:00.000Z',
169
+ }),
170
+ ],
171
+ }),
172
+ ],
162
173
  run_attempt: {
163
174
  id: '11111111-1111-4111-8111-000000000001',
164
175
  workflow_run_id: RUN_ID,
@@ -178,7 +189,9 @@ describe('WorkflowRunSummary', () => {
178
189
  expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
179
190
  });
180
191
 
181
- test('reads a running run whose jobs have not started as queued', async () => {
192
+ // The header reports the attempt the API returned. Whether a job has reached a runner yet is
193
+ // the job surfaces' business, so an attempt whose jobs are all pending still reads as running.
194
+ test('reads a running attempt as running whatever its jobs have reached', async () => {
182
195
  vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-05-07T01:02:14.000Z'));
183
196
  renderSummary({
184
197
  jobs: [workflowJobDto({status: 'pending'})],
@@ -187,50 +200,89 @@ describe('WorkflowRunSummary', () => {
187
200
 
188
201
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
189
202
 
190
- expect(within(summary).getAllByText('Queued')).not.toHaveLength(0);
191
- expect(within(summary).queryByText('Running')).not.toBeInTheDocument();
192
- expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'queued 2m 14s');
203
+ expect(within(summary).getAllByText('Running')).not.toHaveLength(0);
204
+ expect(within(summary).queryByText('Queued')).not.toBeInTheDocument();
205
+ expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'running 2m 14s');
193
206
  });
194
207
 
195
- test('names the job a queued run is waiting on when there is more than one', async () => {
208
+ test('uses a neutral verb when an attempt finished before any job execution started', async () => {
196
209
  renderSummary({
210
+ status: 'cancelled',
197
211
  jobs: [
198
212
  workflowJobDto({
199
- name: 'lint',
200
213
  status: 'pending',
201
214
  job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T01:00:00.000Z'})],
202
215
  }),
203
- workflowJobDto({
204
- name: 'build',
205
- status: 'pending',
206
- job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T00:59:00.000Z'})],
207
- }),
208
216
  ],
209
- run_attempt: runningAttemptDto(),
217
+ run_attempt: {
218
+ id: '11111111-1111-4111-8111-000000000001',
219
+ workflow_run_id: RUN_ID,
220
+ attempt: 1,
221
+ status: 'cancelled',
222
+ created_at: '2026-05-07T01:00:00.000Z',
223
+ started_at: '2026-05-07T01:00:00.000Z',
224
+ finished_at: '2026-05-07T01:02:14.000Z',
225
+ rerun_mode: null,
226
+ },
210
227
  });
211
228
 
212
229
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
213
230
 
214
- expect(within(summary).getByText('waiting on')).toBeInTheDocument();
215
- expect(within(summary).getByText('build')).toBeInTheDocument();
231
+ expect(within(summary).getAllByText('Cancelled')).not.toHaveLength(0);
232
+ expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'lasted 2m 14s');
216
233
  });
217
234
 
218
- test('leaves the waiting job unnamed when the run has a single job', async () => {
235
+ test('uses the run verb when a cancelled job execution started', async () => {
219
236
  renderSummary({
237
+ status: 'cancelled',
220
238
  jobs: [
221
239
  workflowJobDto({
222
- name: 'build',
223
- status: 'pending',
224
- job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T01:00:00.000Z'})],
240
+ status: 'cancelled',
241
+ job_executions: [
242
+ workflowJobExecutionDto({
243
+ status: 'cancelled',
244
+ started_at: '2026-05-07T01:00:05.000Z',
245
+ }),
246
+ ],
225
247
  }),
226
248
  ],
227
- run_attempt: runningAttemptDto(),
249
+ run_attempt: {
250
+ id: '11111111-1111-4111-8111-000000000001',
251
+ workflow_run_id: RUN_ID,
252
+ attempt: 1,
253
+ status: 'cancelled',
254
+ created_at: '2026-05-07T01:00:00.000Z',
255
+ started_at: '2026-05-07T01:00:00.000Z',
256
+ finished_at: '2026-05-07T01:02:14.000Z',
257
+ rerun_mode: null,
258
+ },
259
+ });
260
+
261
+ const summary = await screen.findByRole('region', {name: 'deploy-web'});
262
+
263
+ expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'ran 2m 14s');
264
+ });
265
+
266
+ test('uses a neutral verb when every job was skipped', async () => {
267
+ renderSummary({
268
+ status: 'succeeded',
269
+ jobs: [workflowJobDto({status: 'skipped'})],
270
+ run_attempt: {
271
+ id: '11111111-1111-4111-8111-000000000001',
272
+ workflow_run_id: RUN_ID,
273
+ attempt: 1,
274
+ status: 'succeeded',
275
+ created_at: '2026-05-07T01:00:00.000Z',
276
+ started_at: '2026-05-07T01:00:00.000Z',
277
+ finished_at: '2026-05-07T01:02:14.000Z',
278
+ rerun_mode: null,
279
+ },
228
280
  });
229
281
 
230
282
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
231
283
 
232
- expect(within(summary).getAllByText('Queued')).not.toHaveLength(0);
233
- expect(within(summary).queryByText('waiting on')).not.toBeInTheDocument();
284
+ expect(within(summary).getAllByText('Succeeded')).not.toHaveLength(0);
285
+ expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'lasted 2m 14s');
234
286
  });
235
287
 
236
288
  test('does not render a whole-run source control', async () => {
@@ -12,7 +12,7 @@ import {useIsTextTruncated} from '@shipfox/react-ui/hooks';
12
12
  import {RelativeTime} from '@shipfox/react-ui/relative-time';
13
13
  import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
14
14
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
15
- import {Code, Header, Text} from '@shipfox/react-ui/typography';
15
+ import {Header, Text} from '@shipfox/react-ui/typography';
16
16
  import {useId} from 'react';
17
17
  import {WorkflowRunNumberLabel} from '#components/workflow-run-number-label.js';
18
18
  import {
@@ -21,17 +21,13 @@ import {
21
21
  WORKFLOW_RUN_STATUSES,
22
22
  type WorkflowRunDetail,
23
23
  type WorkflowRunRerunMode,
24
- workflowRunBlockingJob,
25
- workflowRunDetailDisplay,
26
24
  } from '#core/workflow-run.js';
27
25
  import {WorkflowRunDurationLabel} from '../workflow-run-duration-label.js';
28
26
  import {getWorkflowStatusVisual} from '../workflow-status/status-visuals.js';
29
27
  import {WorkflowRunAttemptSwitcher} from './workflow-run-attempt-switcher.js';
30
28
 
31
29
  const STATUS_BADGE_LABEL_WIDTH_CH = Math.max(
32
- ...[...WORKFLOW_RUN_STATUSES, 'queued' as const].map(
33
- (status) => getWorkflowStatusVisual(status).label.length,
34
- ),
30
+ ...WORKFLOW_RUN_STATUSES.map((status) => getWorkflowStatusVisual(status).label.length),
35
31
  );
36
32
  const RERUN_BUTTON_SURFACE_CLASS_NAME =
37
33
  'bg-background-neutral-base hover:bg-background-neutral-hover active:bg-background-neutral-pressed';
@@ -60,19 +56,15 @@ export function WorkflowRunSummary({
60
56
  latestAttempt,
61
57
  }: WorkflowRunSummaryProps) {
62
58
  const headingId = useId();
63
- const display = workflowRunDetailDisplay(run);
64
- const status = getWorkflowStatusVisual(display.status);
59
+ const status = getWorkflowStatusVisual(run.runAttempt.status);
65
60
  const action = workflowRunActionForRun(run);
66
61
  const hasAction = canRenderWorkflowRunAction(action, onCancel, onRerun);
67
62
  const attemptSwitcher =
68
63
  latestAttempt && latestAttempt > 1 && workspaceSlug && projectSlug
69
64
  ? {workspaceSlug, projectSlug, latestAttempt}
70
65
  : null;
71
- const displayDuration = display.duration;
72
- // Named only while the run is waiting and has more than one job: with a single job the rail
73
- // and the graph already say which one it is.
74
- const blockingJob =
75
- display.status === 'queued' && run.jobs.length > 1 ? workflowRunBlockingJob(run.jobs) : null;
66
+ const displayDuration = run.runAttempt.displayDuration;
67
+ const hasStarted = run.hasStartedJobExecution;
76
68
  const {ref: headingTextRef, isTruncated: isHeadingTruncated} =
77
69
  useIsTextTruncated<HTMLSpanElement>(run.name);
78
70
 
@@ -184,24 +176,11 @@ export function WorkflowRunSummary({
184
176
  <MetadataSeparator />
185
177
  <WorkflowRunDurationLabel
186
178
  duration={displayDuration}
179
+ hasStarted={hasStarted}
187
180
  className="text-foreground-neutral-subtle"
188
181
  />
189
182
  </>
190
183
  ) : null}
191
-
192
- {blockingJob ? (
193
- <>
194
- <MetadataSeparator />
195
- <span className="flex min-w-0 items-center gap-tight">
196
- <Text as="span" size="xs" className="shrink-0">
197
- waiting on
198
- </Text>
199
- <Code as="span" variant="label" className="min-w-0 truncate">
200
- {blockingJob.displayName}
201
- </Code>
202
- </span>
203
- </>
204
- ) : null}
205
184
  </div>
206
185
  </div>
207
186
  </section>
@@ -18,10 +18,6 @@ export function getWorkflowStatusVisual(status: WorkflowDisplayStatus): Workflow
18
18
  switch (status) {
19
19
  case 'pending':
20
20
  return {kind: 'pending', label: 'Pending', dot: 'neutral', badge: 'neutral'};
21
- // Waiting on a runner is the awaiting-* state the warning tone was held for: the run is
22
- // live but nothing is executing, which is the operator's problem to see, not ours to hide.
23
- case 'queued':
24
- return {kind: 'queued', label: 'Queued', dot: 'warning', badge: 'warning'};
25
21
  case 'running':
26
22
  return {kind: 'running', label: 'Running', dot: 'info', badge: 'info'};
27
23
  case 'listening':
@@ -6,7 +6,6 @@ import {WorkflowStatusIcon} from './workflow-status-icon.js';
6
6
 
7
7
  const statuses: WorkflowDisplayStatus[] = [
8
8
  'pending',
9
- 'queued',
10
9
  'running',
11
10
  'listening',
12
11
  'succeeded',
@@ -89,24 +89,6 @@ export function WorkflowStatusIcon({
89
89
  <Icon name="pulseLine" size={Math.max(8, Math.round(size * 0.7))} className="text-white" />
90
90
  </span>
91
91
  );
92
- } else if (visual.kind === 'queued') {
93
- // An hourglass in the same filled disc the listening state uses, so waiting reads as its
94
- // own shape rather than as a pending ring that happens to be a different colour.
95
- glyph = (
96
- <span
97
- className={cn(
98
- 'inline-flex shrink-0 items-center justify-center rounded-full bg-current',
99
- box,
100
- toneByVariant.warning,
101
- )}
102
- >
103
- <Icon
104
- name="hourglassLine"
105
- size={Math.max(8, Math.round(size * 0.7))}
106
- className="text-white"
107
- />
108
- </span>
109
- );
110
92
  } else if (visual.kind === 'running') {
111
93
  glyph = <Dot variant="info" ripple={ripple} className={box} />;
112
94
  } else if (visual.kind === 'pending') {
@@ -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
 
@@ -1,15 +1,22 @@
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 type {JobExecutionStatus} from './job-execution.js';
3
10
  import type {WorkflowRunAttempt, WorkflowRunAttemptSummary} from './workflow-run-attempt.js';
4
11
 
5
12
  export type WorkflowRunStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
6
13
  export type WorkflowRunRerunMode = 'all' | 'failed';
7
14
  export type WorkflowStatus = WorkflowRunStatus | (typeof WORKFLOW_JOB_STATUSES)[number];
8
15
  /**
9
- * `listening` and `queued` are display-only: the API never returns them, and both are derived
10
- * from the jobs a run already carries.
16
+ * `listening` is display-only: the API never returns it, and it is derived from the listener
17
+ * state a job already carries.
11
18
  */
12
- export type WorkflowDisplayStatus = WorkflowStatus | 'listening' | 'queued';
19
+ export type WorkflowDisplayStatus = WorkflowStatus | 'listening';
13
20
 
14
21
  export const WORKFLOW_RUN_STATUSES = [
15
22
  'pending',
@@ -23,7 +30,6 @@ export const WORKFLOW_DISPLAY_STATUSES = [
23
30
  ...WORKFLOW_RUN_STATUSES,
24
31
  ...WORKFLOW_JOB_STATUSES,
25
32
  'listening',
26
- 'queued',
27
33
  ] as const satisfies readonly WorkflowDisplayStatus[];
28
34
 
29
35
  export const TERMINAL_WORKFLOW_RUN_STATUSES = [
@@ -60,11 +66,14 @@ export interface WorkflowRunJobSummary {
60
66
  key: string;
61
67
  name: string | null;
62
68
  status: JobStatus;
69
+ mode: JobMode;
70
+ listenerStatus: ListenerStatus;
71
+ executionStatus: JobExecutionStatus | null;
63
72
  position: number;
64
73
  }
65
74
 
66
75
  export interface WorkflowRunJobStatusCount {
67
- status: JobStatus;
76
+ status: JobDisplayStatus;
68
77
  count: number;
69
78
  }
70
79
 
@@ -73,11 +82,14 @@ export interface WorkflowRunJobStatusCount {
73
82
  * of them.
74
83
  *
75
84
  * `preview` is capped by the API, so `preview.length` is not the job count and the strip
76
- * reads `total` and `statusCounts` for anything it says rather than anything it draws.
85
+ * reads `total` and `statusCounts` for anything it says rather than anything it draws. The API
86
+ * also reports whether any job execution has started because a terminal `cancelled` job can have
87
+ * started work even though its job status does not preserve that distinction.
77
88
  */
78
89
  export interface WorkflowRunJobs {
79
90
  preview: WorkflowRunJobSummary[];
80
91
  statusCounts: WorkflowRunJobStatusCount[];
92
+ hasStartedJobExecution: boolean;
81
93
  total: number;
82
94
  }
83
95
 
@@ -118,6 +130,7 @@ export interface WorkflowRunDetail extends WorkflowRun {
118
130
  latestAttempt: number;
119
131
  runAttempt: WorkflowRunAttempt;
120
132
  jobs: Job[];
133
+ hasStartedJobExecution: boolean;
121
134
  }
122
135
 
123
136
  export interface WorkflowRunListPage {
@@ -184,129 +197,3 @@ export function isWorkflowRunTerminal(status: WorkflowRunStatus): boolean {
184
197
  export function isWorkflowStatus(status: string): status is WorkflowStatus {
185
198
  return WORKFLOW_STATUSES.has(status as WorkflowStatus);
186
199
  }
187
-
188
- /**
189
- * A run's headline duration, split the way a job's already is. An attempt's `startedAt` marks
190
- * when the orchestrator picked the run up, not when work began, so a run whose jobs are all
191
- * still waiting for a runner reads as queue time rather than as run time.
192
- */
193
- export type WorkflowRunDisplayDuration = JobExecutionDisplayDuration;
194
-
195
- /** What every surface shows for a run, derived together so status and duration cannot disagree. */
196
- export interface WorkflowRunDisplay {
197
- status: WorkflowDisplayStatus;
198
- duration: WorkflowRunDisplayDuration | null;
199
- }
200
-
201
- /**
202
- * The evidence a surface can offer about a run's progress.
203
- *
204
- * `jobStatuses` is what every surface has: the list carries status counts, the detail carries
205
- * whole jobs. `firstStartedAt` is the detail's extra precision, and where it is absent the
206
- * attempt's own mark stands in.
207
- */
208
- export interface WorkflowRunProgress {
209
- runStatus: WorkflowRunStatus;
210
- startedAt: string | null;
211
- finishedAt: string | null;
212
- jobStatuses: JobStatus[];
213
- firstStartedAt?: string | null | undefined;
214
- }
215
-
216
- /** When the run's earliest execution began, or null while none of them has. */
217
- export function workflowRunFirstStartedAt(jobs: Job[]): string | null {
218
- let earliest: string | null = null;
219
- let earliestMs = Number.POSITIVE_INFINITY;
220
-
221
- for (const job of jobs) {
222
- for (const {startedAt} of job.jobExecutions) {
223
- if (startedAt === null) continue;
224
- const startedMs = new Date(startedAt).getTime();
225
- if (!Number.isFinite(startedMs) || startedMs >= earliestMs) continue;
226
- earliest = startedAt;
227
- earliestMs = startedMs;
228
- }
229
- }
230
-
231
- return earliest;
232
- }
233
-
234
- /**
235
- * Whether any work has begun. A run with no jobs on hand is a run whose jobs were not fetched,
236
- * which is no evidence that nothing started, so it counts as started rather than claiming a
237
- * queue this surface cannot see.
238
- */
239
- function workflowRunHasStarted({jobStatuses, firstStartedAt}: WorkflowRunProgress): boolean {
240
- if (firstStartedAt != null) return true;
241
- if (jobStatuses.length === 0) return true;
242
- return jobStatuses.some((status) => status !== 'pending' && status !== 'skipped');
243
- }
244
-
245
- /**
246
- * The single rule behind every run readout. An attempt's `startedAt` marks when the
247
- * orchestrator picked the run up, not when work began, so a run whose jobs are all still
248
- * waiting reads as `Queued` for queue time. Calling that "running for 2h" sends an operator to
249
- * debug a build that never began.
250
- */
251
- export function deriveWorkflowRunDisplay(progress: WorkflowRunProgress): WorkflowRunDisplay {
252
- const {runStatus, startedAt, finishedAt, firstStartedAt} = progress;
253
- const hasStarted = workflowRunHasStarted(progress);
254
- const status = runStatus === 'running' && !hasStarted ? 'queued' : runStatus;
255
-
256
- if (startedAt === null) return {status, duration: null};
257
-
258
- // A run cancelled before anything started keeps its queue reading rather than reporting a
259
- // run that never was.
260
- const time = elapsedTimeFromTimestamps({from: firstStartedAt ?? startedAt, to: finishedAt});
261
- if (time === null) return {status, duration: null};
262
-
263
- return {status, duration: {kind: hasStarted ? 'run' : 'queue', ...time}};
264
- }
265
-
266
- /** The run detail's reading: whole jobs, so queue and run time split on the first execution. */
267
- export function workflowRunDetailDisplay(run: {
268
- runAttempt: Pick<WorkflowRunAttemptSummary, 'status' | 'startedAt' | 'finishedAt'>;
269
- jobs: Job[];
270
- }): WorkflowRunDisplay {
271
- return deriveWorkflowRunDisplay({
272
- runStatus: run.runAttempt.status,
273
- startedAt: run.runAttempt.startedAt,
274
- finishedAt: run.runAttempt.finishedAt,
275
- jobStatuses: run.jobs.map((job) => job.status),
276
- firstStartedAt: workflowRunFirstStartedAt(run.jobs),
277
- });
278
- }
279
-
280
- /**
281
- * The run list's reading: status counts cover every job, not just the drawn preview, so a row
282
- * reaches the same verdict as the detail page without fetching a single timestamp more.
283
- */
284
- export function workflowRunListItemDisplay(run: WorkflowRunListItem): WorkflowRunDisplay {
285
- return deriveWorkflowRunDisplay({
286
- runStatus: run.status,
287
- startedAt: run.runAttempt.startedAt,
288
- finishedAt: run.runAttempt.finishedAt,
289
- jobStatuses: run.jobs.statusCounts.filter(({count}) => count > 0).map(({status}) => status),
290
- });
291
- }
292
-
293
- /**
294
- * The job the run is waiting on: the one queued longest without starting. Named only so a
295
- * queued run says what it waits for instead of showing a number with no subject.
296
- */
297
- export function workflowRunBlockingJob(jobs: Job[]): Job | null {
298
- let blocking: Job | null = null;
299
- let queuedMs = Number.POSITIVE_INFINITY;
300
-
301
- for (const job of jobs) {
302
- for (const {queuedAt, startedAt, finishedAt} of job.jobExecutions) {
303
- if (queuedAt === null || startedAt !== null || finishedAt !== null) continue;
304
- const candidateMs = new Date(queuedAt).getTime();
305
- if (!Number.isFinite(candidateMs) || candidateMs >= queuedMs) continue;
306
- blocking = job;
307
- queuedMs = candidateMs;
308
- }
309
- }
310
-
311
- return blocking;
312
- }
@@ -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,
@@ -102,6 +103,7 @@ describe('workflow run model mapping', () => {
102
103
  latest_attempt: 4,
103
104
  started_at: '2026-05-07T01:01:10.000Z',
104
105
  finished_at: null,
106
+ has_started_job_execution: true,
105
107
  });
106
108
 
107
109
  const run = toWorkflowRunListItem(dto);
@@ -121,12 +123,25 @@ describe('workflow run model mapping', () => {
121
123
  expect(run).not.toHaveProperty('startedAt');
122
124
  expect(run).not.toHaveProperty('finishedAt');
123
125
  expect(run).not.toHaveProperty('displayDuration');
126
+ expect(run.jobs.hasStartedJobExecution).toBe(true);
124
127
  expect(run.runAttempt.displayDuration).toEqual({
125
128
  state: 'live',
126
129
  fromIso: '2026-05-07T01:01:10.000Z',
127
130
  });
128
131
  });
129
132
 
133
+ test('keeps legacy raw-status counts aligned with preview glyphs', () => {
134
+ const {job_display_status_counts: _displayCounts, ...legacyDto} = workflowRunDto({
135
+ jobs: [workflowRunJobSummaryDto({status: 'running', execution_status: null})],
136
+ job_status_counts: [{status: 'running', count: 1}],
137
+ });
138
+
139
+ const run = toWorkflowRunListItem(legacyDto);
140
+
141
+ expect(run.jobs.preview[0]?.executionStatus).toBe('running');
142
+ expect(run.jobs.statusCounts).toEqual([{status: 'running', count: 1}]);
143
+ });
144
+
130
145
  test('maps run list pagination fields', () => {
131
146
  const dto = workflowRunListResponseDto({
132
147
  runs: [
@@ -59,14 +59,11 @@ export type {
59
59
  WorkflowDisplayStatus,
60
60
  WorkflowRun,
61
61
  WorkflowRunDetail,
62
- WorkflowRunDisplay,
63
- WorkflowRunDisplayDuration,
64
62
  WorkflowRunJobStatusCount,
65
63
  WorkflowRunJobSummary,
66
64
  WorkflowRunJobs,
67
65
  WorkflowRunListItem,
68
66
  WorkflowRunListPage,
69
- WorkflowRunProgress,
70
67
  WorkflowRunRecord,
71
68
  WorkflowRunRerunMode,
72
69
  WorkflowRunStatus,
@@ -75,19 +72,14 @@ export type {
75
72
  WorkflowStatus,
76
73
  } from './entities/workflow-run.js';
77
74
  export {
78
- deriveWorkflowRunDisplay,
79
75
  isWorkflowRunTerminal,
80
76
  isWorkflowStatus,
81
77
  TERMINAL_WORKFLOW_RUN_STATUSES,
82
78
  WORKFLOW_DISPLAY_STATUSES,
83
79
  WORKFLOW_RUN_STATUSES,
84
80
  workflowRunActor,
85
- workflowRunBlockingJob,
86
81
  workflowRunBranchLabel,
87
82
  workflowRunCommitLabel,
88
- workflowRunDetailDisplay,
89
- workflowRunFirstStartedAt,
90
- workflowRunListItemDisplay,
91
83
  workflowRunTriggerDisplayLabel,
92
84
  workflowRunTriggerLabel,
93
85
  } from './entities/workflow-run.js';