@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +25 -0
- package/dist/components/workflow-run-duration-label.d.ts +8 -7
- package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
- package/dist/components/workflow-run-duration-label.js +15 -31
- package/dist/components/workflow-run-duration-label.js.map +1 -1
- package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -1
- package/dist/components/workflow-run-list/job-status-strip.js +20 -7
- package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.js +9 -6
- package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.js +7 -34
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
- package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
- package/dist/components/workflow-status/status-visuals.js +0 -9
- package/dist/components/workflow-status/status-visuals.js.map +1 -1
- package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
- package/dist/components/workflow-status/workflow-status-icon.js +0 -11
- package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
- package/dist/core/entities/job.d.ts +5 -2
- package/dist/core/entities/job.d.ts.map +1 -1
- package/dist/core/entities/job.js +8 -1
- package/dist/core/entities/job.js.map +1 -1
- package/dist/core/entities/workflow-run.d.ts +15 -57
- package/dist/core/entities/workflow-run.d.ts.map +1 -1
- package/dist/core/entities/workflow-run.js +1 -94
- package/dist/core/entities/workflow-run.js.map +1 -1
- package/dist/core/workflow-run.d.ts +2 -2
- package/dist/core/workflow-run.d.ts.map +1 -1
- package/dist/core/workflow-run.js +1 -1
- package/dist/core/workflow-run.js.map +1 -1
- package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
- package/dist/hooks/api/workflow-run-mapper.js +14 -2
- package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
- package/dist/hooks/api/workflow-runs.js +1 -0
- package/dist/hooks/api/workflow-runs.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/job-graph/job-node.test.tsx +2 -2
- package/src/components/workflow-run-duration-label.tsx +21 -44
- package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
- package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +121 -0
- package/src/components/workflow-run-list/workflow-run-list.stories.tsx +31 -0
- package/src/components/workflow-run-list/workflow-run-row.tsx +7 -6
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +0 -38
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +73 -21
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +6 -27
- package/src/components/workflow-status/status-visuals.ts +0 -4
- package/src/components/workflow-status/workflow-status-icon.stories.tsx +0 -1
- package/src/components/workflow-status/workflow-status-icon.tsx +0 -18
- package/src/core/entities/job-status.test.ts +47 -1
- package/src/core/entities/job.ts +15 -3
- package/src/core/entities/workflow-run.ts +21 -134
- package/src/core/workflow-run.test.ts +15 -0
- package/src/core/workflow-run.ts +0 -8
- package/src/hooks/api/workflow-run-mapper.ts +20 -1
- package/src/hooks/api/workflow-runs.ts +1 -1
- package/test/fixtures/workflow-run.ts +60 -13
- package/tsconfig.build.tsbuildinfo +1 -1
- 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
|
-
|
|
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('
|
|
191
|
-
expect(within(summary).queryByText('
|
|
192
|
-
expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', '
|
|
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('
|
|
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:
|
|
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).
|
|
215
|
-
expect(within(summary).getByText('
|
|
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('
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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:
|
|
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('
|
|
233
|
-
expect(within(summary).
|
|
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 {
|
|
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
|
-
...
|
|
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
|
|
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 =
|
|
72
|
-
|
|
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':
|
|
@@ -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', [], '
|
|
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({
|
package/src/core/entities/job.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
2
|
-
|
|
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`
|
|
10
|
-
*
|
|
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'
|
|
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:
|
|
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: [
|
package/src/core/workflow-run.ts
CHANGED
|
@@ -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';
|