@shipfox/client-workflows 19.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 (50) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -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/workflow-run-row.d.ts.map +1 -1
  8. package/dist/components/workflow-run-list/workflow-run-row.js +9 -6
  9. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  10. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  11. package/dist/components/workflow-run-summary/workflow-run-summary.js +7 -34
  12. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  13. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  14. package/dist/components/workflow-status/status-visuals.js +0 -9
  15. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  16. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  17. package/dist/components/workflow-status/workflow-status-icon.js +0 -11
  18. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  19. package/dist/core/entities/workflow-run.d.ts +10 -55
  20. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  21. package/dist/core/entities/workflow-run.js +1 -94
  22. package/dist/core/entities/workflow-run.js.map +1 -1
  23. package/dist/core/workflow-run.d.ts +2 -2
  24. package/dist/core/workflow-run.d.ts.map +1 -1
  25. package/dist/core/workflow-run.js +1 -1
  26. package/dist/core/workflow-run.js.map +1 -1
  27. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  28. package/dist/hooks/api/workflow-run-mapper.js +3 -1
  29. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  30. package/dist/hooks/api/workflow-runs.js +1 -0
  31. package/dist/hooks/api/workflow-runs.js.map +1 -1
  32. package/dist/tsconfig.test.tsbuildinfo +1 -1
  33. package/package.json +2 -2
  34. package/src/components/workflow-run-duration-label.tsx +21 -44
  35. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +48 -0
  36. package/src/components/workflow-run-list/workflow-run-row.tsx +7 -6
  37. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +0 -38
  38. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +73 -21
  39. package/src/components/workflow-run-summary/workflow-run-summary.tsx +6 -27
  40. package/src/components/workflow-status/status-visuals.ts +0 -4
  41. package/src/components/workflow-status/workflow-status-icon.stories.tsx +0 -1
  42. package/src/components/workflow-status/workflow-status-icon.tsx +0 -18
  43. package/src/core/entities/workflow-run.ts +9 -136
  44. package/src/core/workflow-run.test.ts +2 -0
  45. package/src/core/workflow-run.ts +0 -8
  46. package/src/hooks/api/workflow-run-mapper.ts +2 -0
  47. package/src/hooks/api/workflow-runs.ts +1 -1
  48. package/test/fixtures/workflow-run.ts +30 -3
  49. package/tsconfig.build.tsbuildinfo +1 -1
  50. package/src/core/entities/workflow-run-display.test.ts +0 -187
@@ -6,21 +6,17 @@ import {
6
6
  type ListenerStatus,
7
7
  WORKFLOW_JOB_STATUSES,
8
8
  } from './job.js';
9
- import {
10
- elapsedTimeFromTimestamps,
11
- type JobExecutionDisplayDuration,
12
- type JobExecutionStatus,
13
- } from './job-execution.js';
9
+ import type {JobExecutionStatus} from './job-execution.js';
14
10
  import type {WorkflowRunAttempt, WorkflowRunAttemptSummary} from './workflow-run-attempt.js';
15
11
 
16
12
  export type WorkflowRunStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
17
13
  export type WorkflowRunRerunMode = 'all' | 'failed';
18
14
  export type WorkflowStatus = WorkflowRunStatus | (typeof WORKFLOW_JOB_STATUSES)[number];
19
15
  /**
20
- * `listening` and `queued` are display-only: the API never returns them, and both are derived
21
- * 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.
22
18
  */
23
- export type WorkflowDisplayStatus = WorkflowStatus | 'listening' | 'queued';
19
+ export type WorkflowDisplayStatus = WorkflowStatus | 'listening';
24
20
 
25
21
  export const WORKFLOW_RUN_STATUSES = [
26
22
  'pending',
@@ -34,7 +30,6 @@ export const WORKFLOW_DISPLAY_STATUSES = [
34
30
  ...WORKFLOW_RUN_STATUSES,
35
31
  ...WORKFLOW_JOB_STATUSES,
36
32
  'listening',
37
- 'queued',
38
33
  ] as const satisfies readonly WorkflowDisplayStatus[];
39
34
 
40
35
  export const TERMINAL_WORKFLOW_RUN_STATUSES = [
@@ -87,11 +82,14 @@ export interface WorkflowRunJobStatusCount {
87
82
  * of them.
88
83
  *
89
84
  * `preview` is capped by the API, so `preview.length` is not the job count and the strip
90
- * 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.
91
88
  */
92
89
  export interface WorkflowRunJobs {
93
90
  preview: WorkflowRunJobSummary[];
94
91
  statusCounts: WorkflowRunJobStatusCount[];
92
+ hasStartedJobExecution: boolean;
95
93
  total: number;
96
94
  }
97
95
 
@@ -132,6 +130,7 @@ export interface WorkflowRunDetail extends WorkflowRun {
132
130
  latestAttempt: number;
133
131
  runAttempt: WorkflowRunAttempt;
134
132
  jobs: Job[];
133
+ hasStartedJobExecution: boolean;
135
134
  }
136
135
 
137
136
  export interface WorkflowRunListPage {
@@ -198,129 +197,3 @@ export function isWorkflowRunTerminal(status: WorkflowRunStatus): boolean {
198
197
  export function isWorkflowStatus(status: string): status is WorkflowStatus {
199
198
  return WORKFLOW_STATUSES.has(status as WorkflowStatus);
200
199
  }
201
-
202
- /**
203
- * A run's headline duration, split the way a job's already is. An attempt's `startedAt` marks
204
- * when the orchestrator picked the run up, not when work began, so a run whose jobs are all
205
- * still waiting for a runner reads as queue time rather than as run time.
206
- */
207
- export type WorkflowRunDisplayDuration = JobExecutionDisplayDuration;
208
-
209
- /** What every surface shows for a run, derived together so status and duration cannot disagree. */
210
- export interface WorkflowRunDisplay {
211
- status: WorkflowDisplayStatus;
212
- duration: WorkflowRunDisplayDuration | null;
213
- }
214
-
215
- /**
216
- * The evidence a surface can offer about a run's progress.
217
- *
218
- * `jobStatuses` is what every surface has: the list carries status counts, the detail carries
219
- * whole jobs. `firstStartedAt` is the detail's extra precision, and where it is absent the
220
- * attempt's own mark stands in.
221
- */
222
- export interface WorkflowRunProgress {
223
- runStatus: WorkflowRunStatus;
224
- startedAt: string | null;
225
- finishedAt: string | null;
226
- jobStatuses: JobDisplayStatus[];
227
- firstStartedAt?: string | null | undefined;
228
- }
229
-
230
- /** When the run's earliest execution began, or null while none of them has. */
231
- export function workflowRunFirstStartedAt(jobs: Job[]): string | null {
232
- let earliest: string | null = null;
233
- let earliestMs = Number.POSITIVE_INFINITY;
234
-
235
- for (const job of jobs) {
236
- for (const {startedAt} of job.jobExecutions) {
237
- if (startedAt === null) continue;
238
- const startedMs = new Date(startedAt).getTime();
239
- if (!Number.isFinite(startedMs) || startedMs >= earliestMs) continue;
240
- earliest = startedAt;
241
- earliestMs = startedMs;
242
- }
243
- }
244
-
245
- return earliest;
246
- }
247
-
248
- /**
249
- * Whether any work has begun. A run with no jobs on hand is a run whose jobs were not fetched,
250
- * which is no evidence that nothing started, so it counts as started rather than claiming a
251
- * queue this surface cannot see.
252
- */
253
- function workflowRunHasStarted({jobStatuses, firstStartedAt}: WorkflowRunProgress): boolean {
254
- if (firstStartedAt != null) return true;
255
- if (jobStatuses.length === 0) return true;
256
- return jobStatuses.some((status) => status !== 'pending' && status !== 'skipped');
257
- }
258
-
259
- /**
260
- * The single rule behind every run readout. An attempt's `startedAt` marks when the
261
- * orchestrator picked the run up, not when work began, so a run whose jobs are all still
262
- * waiting reads as `Queued` for queue time. Calling that "running for 2h" sends an operator to
263
- * debug a build that never began.
264
- */
265
- export function deriveWorkflowRunDisplay(progress: WorkflowRunProgress): WorkflowRunDisplay {
266
- const {runStatus, startedAt, finishedAt, firstStartedAt} = progress;
267
- const hasStarted = workflowRunHasStarted(progress);
268
- const status = runStatus === 'running' && !hasStarted ? 'queued' : runStatus;
269
-
270
- if (startedAt === null) return {status, duration: null};
271
-
272
- // A run cancelled before anything started keeps its queue reading rather than reporting a
273
- // run that never was.
274
- const time = elapsedTimeFromTimestamps({from: firstStartedAt ?? startedAt, to: finishedAt});
275
- if (time === null) return {status, duration: null};
276
-
277
- return {status, duration: {kind: hasStarted ? 'run' : 'queue', ...time}};
278
- }
279
-
280
- /** The run detail's reading: whole jobs, so queue and run time split on the first execution. */
281
- export function workflowRunDetailDisplay(run: {
282
- runAttempt: Pick<WorkflowRunAttemptSummary, 'status' | 'startedAt' | 'finishedAt'>;
283
- jobs: Job[];
284
- }): WorkflowRunDisplay {
285
- return deriveWorkflowRunDisplay({
286
- runStatus: run.runAttempt.status,
287
- startedAt: run.runAttempt.startedAt,
288
- finishedAt: run.runAttempt.finishedAt,
289
- jobStatuses: run.jobs.map((job) => job.status),
290
- firstStartedAt: workflowRunFirstStartedAt(run.jobs),
291
- });
292
- }
293
-
294
- /**
295
- * The run list's reading: status counts cover every job, not just the drawn preview, so a row
296
- * reaches the same verdict as the detail page without fetching a single timestamp more.
297
- */
298
- export function workflowRunListItemDisplay(run: WorkflowRunListItem): WorkflowRunDisplay {
299
- return deriveWorkflowRunDisplay({
300
- runStatus: run.status,
301
- startedAt: run.runAttempt.startedAt,
302
- finishedAt: run.runAttempt.finishedAt,
303
- jobStatuses: run.jobs.statusCounts.filter(({count}) => count > 0).map(({status}) => status),
304
- });
305
- }
306
-
307
- /**
308
- * The job the run is waiting on: the one queued longest without starting. Named only so a
309
- * queued run says what it waits for instead of showing a number with no subject.
310
- */
311
- export function workflowRunBlockingJob(jobs: Job[]): Job | null {
312
- let blocking: Job | null = null;
313
- let queuedMs = Number.POSITIVE_INFINITY;
314
-
315
- for (const job of jobs) {
316
- for (const {queuedAt, startedAt, finishedAt} of job.jobExecutions) {
317
- if (queuedAt === null || startedAt !== null || finishedAt !== null) continue;
318
- const candidateMs = new Date(queuedAt).getTime();
319
- if (!Number.isFinite(candidateMs) || candidateMs >= queuedMs) continue;
320
- blocking = job;
321
- queuedMs = candidateMs;
322
- }
323
- }
324
-
325
- return blocking;
326
- }
@@ -103,6 +103,7 @@ describe('workflow run model mapping', () => {
103
103
  latest_attempt: 4,
104
104
  started_at: '2026-05-07T01:01:10.000Z',
105
105
  finished_at: null,
106
+ has_started_job_execution: true,
106
107
  });
107
108
 
108
109
  const run = toWorkflowRunListItem(dto);
@@ -122,6 +123,7 @@ describe('workflow run model mapping', () => {
122
123
  expect(run).not.toHaveProperty('startedAt');
123
124
  expect(run).not.toHaveProperty('finishedAt');
124
125
  expect(run).not.toHaveProperty('displayDuration');
126
+ expect(run.jobs.hasStartedJobExecution).toBe(true);
125
127
  expect(run.runAttempt.displayDuration).toEqual({
126
128
  state: 'live',
127
129
  fromIso: '2026-05-07T01:01:10.000Z',
@@ -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';
@@ -119,6 +119,7 @@ export function toWorkflowRunListItem(dto: WorkflowRunListItemDto): WorkflowRunL
119
119
  position: job.position,
120
120
  })),
121
121
  statusCounts,
122
+ hasStartedJobExecution: dto.has_started_job_execution ?? true,
122
123
  // Derived rather than sent: the counts already cover every job, and a separate total
123
124
  // would be a second source of truth that could disagree with them.
124
125
  total: statusCounts.reduce((sum, entry) => sum + entry.count, 0),
@@ -146,6 +147,7 @@ export function toWorkflowRunDetail(dto: WorkflowRunDetailResponseDto): Workflow
146
147
  latestAttempt: dto.latest_attempt,
147
148
  runAttempt: toWorkflowRunAttempt(dto.run_attempt),
148
149
  jobs: dto.jobs.map(toJob),
150
+ hasStartedJobExecution: dto.has_started_job_execution ?? true,
149
151
  };
150
152
  }
151
153
 
@@ -358,7 +358,7 @@ function buildTempRun({
358
358
  updatedAt: createdAt,
359
359
  isTemporary: true,
360
360
  // The optimistic row genuinely has no jobs yet: the server has not planned the graph.
361
- jobs: {preview: [], statusCounts: [], total: 0},
361
+ jobs: {preview: [], statusCounts: [], hasStartedJobExecution: false, total: 0},
362
362
  runAttempt: new WorkflowRunAttemptSummary({
363
363
  workflowRunId: id,
364
364
  attempt: 1,
@@ -82,6 +82,7 @@ export function workflowRunDto(
82
82
  jobs: [],
83
83
  job_status_counts: [],
84
84
  job_display_status_counts: [],
85
+ has_started_job_execution: false,
85
86
  ...overrides,
86
87
  };
87
88
  }
@@ -136,10 +137,18 @@ function executionStatusForFixtureStatus(
136
137
  *
137
138
  * Built from one list so the two can never disagree. A fixture whose counts contradicted its
138
139
  * preview would hide exactly the bug the split exists to prevent.
140
+ *
141
+ * `has_started_job_execution` follows the statuses where they settle it: `succeeded` and `failed`
142
+ * cannot be reached without running. `pending`, `skipped`, and `cancelled` leave it open, which is
143
+ * the ambiguity the server flag exists to resolve, so those default to not started and a case that
144
+ * needs the other reading overrides it.
139
145
  */
140
146
  export function workflowRunJobsFixture(
141
147
  statuses: readonly JobStatusDto[],
142
- ): Pick<WorkflowRunListItemDto, 'jobs' | 'job_status_counts' | 'job_display_status_counts'> {
148
+ ): Pick<
149
+ WorkflowRunListItemDto,
150
+ 'jobs' | 'job_status_counts' | 'job_display_status_counts' | 'has_started_job_execution'
151
+ > {
143
152
  const counts = new Map<JobStatusDto, number>();
144
153
  for (const status of statuses) counts.set(status, (counts.get(status) ?? 0) + 1);
145
154
 
@@ -152,6 +161,9 @@ export function workflowRunJobsFixture(
152
161
  jobs: preview,
153
162
  job_status_counts: [...counts.entries()].map(([status, count]) => ({status, count})),
154
163
  job_display_status_counts: [...counts.entries()].map(([status, count]) => ({status, count})),
164
+ has_started_job_execution: statuses.some(
165
+ (status) => status === 'running' || status === 'succeeded' || status === 'failed',
166
+ ),
155
167
  };
156
168
  }
157
169
 
@@ -159,7 +171,10 @@ export function workflowRunJobsFixture(
159
171
  export function workflowRunJobsOfStatus(
160
172
  count: number,
161
173
  status: JobStatusDto = 'succeeded',
162
- ): Pick<WorkflowRunListItemDto, 'jobs' | 'job_status_counts' | 'job_display_status_counts'> {
174
+ ): Pick<
175
+ WorkflowRunListItemDto,
176
+ 'jobs' | 'job_status_counts' | 'job_display_status_counts' | 'has_started_job_execution'
177
+ > {
163
178
  return workflowRunJobsFixture(Array.from({length: count}, () => status));
164
179
  }
165
180
 
@@ -189,7 +204,12 @@ export function workflowRunListPage(
189
204
  export function workflowRunDetailDto(
190
205
  overrides: Partial<WorkflowRunDetailResponseDto> = {},
191
206
  ): WorkflowRunDetailResponseDto {
192
- const {jobs, run_attempt: runAttemptOverride, ...runOverrides} = overrides;
207
+ const {
208
+ jobs,
209
+ run_attempt: runAttemptOverride,
210
+ has_started_job_execution: hasStartedOverride,
211
+ ...runOverrides
212
+ } = overrides;
193
213
  const run = workflowRunDto(runOverrides);
194
214
 
195
215
  return {
@@ -205,6 +225,13 @@ export function workflowRunDetailDto(
205
225
  finished_at: run.finished_at,
206
226
  }),
207
227
  jobs: jobs ?? [],
228
+ // Follows the executions the case actually built, so the fixture cannot claim a run started
229
+ // while carrying no execution that did.
230
+ has_started_job_execution:
231
+ hasStartedOverride ??
232
+ (jobs ?? []).some((job) =>
233
+ job.job_executions.some((execution) => execution.started_at != null),
234
+ ),
208
235
  };
209
236
  }
210
237