@shipfox/client-workflows 14.0.1 → 16.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 (272) hide show
  1. package/.storybook/preview.css +4 -0
  2. package/.storybook/preview.tsx +7 -2
  3. package/.turbo/turbo-build.log +1 -1
  4. package/CHANGELOG.md +42 -0
  5. package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -0
  6. package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.js +2 -2
  7. package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -0
  8. package/dist/components/job-detail/job-detail-header.d.ts +15 -0
  9. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -0
  10. package/dist/components/job-detail/job-detail-header.js +124 -0
  11. package/dist/components/job-detail/job-detail-header.js.map +1 -0
  12. package/dist/components/job-detail/job-detail-view.d.ts +16 -0
  13. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -0
  14. package/dist/components/job-detail/job-detail-view.js +420 -0
  15. package/dist/components/job-detail/job-detail-view.js.map +1 -0
  16. package/dist/components/job-detail/job-empty-states.d.ts +10 -0
  17. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -0
  18. package/dist/components/job-detail/job-empty-states.js +195 -0
  19. package/dist/components/job-detail/job-empty-states.js.map +1 -0
  20. package/dist/components/job-detail/job-execution-switcher.d.ts.map +1 -0
  21. package/dist/components/job-detail/job-execution-switcher.js.map +1 -0
  22. package/dist/components/job-detail/job-execution-time-text.d.ts.map +1 -0
  23. package/dist/components/job-detail/job-execution-time-text.js.map +1 -0
  24. package/dist/components/job-detail/job-selection.d.ts +24 -0
  25. package/dist/components/job-detail/job-selection.d.ts.map +1 -0
  26. package/dist/components/job-detail/job-selection.js +66 -0
  27. package/dist/components/job-detail/job-selection.js.map +1 -0
  28. package/dist/components/job-detail/step-attempt-log-panel.d.ts +15 -0
  29. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -0
  30. package/dist/components/{workflow-run-view → job-detail}/step-attempt-log-panel.js +61 -16
  31. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -0
  32. package/dist/components/job-graph/job-graph-content.js +1 -1
  33. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  34. package/dist/components/step-list/step-list-model.js +2 -2
  35. package/dist/components/step-list/step-list-model.js.map +1 -1
  36. package/dist/components/step-list/step-list.d.ts +1 -0
  37. package/dist/components/step-list/step-list.d.ts.map +1 -1
  38. package/dist/components/step-list/step-list.js +29 -4
  39. package/dist/components/step-list/step-list.js.map +1 -1
  40. package/dist/components/workflow-run-duration-label.d.ts +9 -4
  41. package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
  42. package/dist/components/workflow-run-duration-label.js +27 -7
  43. package/dist/components/workflow-run-duration-label.js.map +1 -1
  44. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  45. package/dist/components/workflow-run-list/workflow-run-row.js +6 -5
  46. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  47. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  48. package/dist/components/workflow-run-summary/workflow-run-summary.js +69 -64
  49. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  50. package/dist/components/workflow-run-tabs/index.d.ts +3 -3
  51. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  52. package/dist/components/workflow-run-tabs/index.js +3 -3
  53. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  54. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts +16 -0
  55. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts.map +1 -0
  56. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js +50 -0
  57. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js.map +1 -0
  58. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts +20 -0
  59. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts.map +1 -0
  60. package/dist/components/workflow-run-tabs/run-annotation-item.js +102 -0
  61. package/dist/components/workflow-run-tabs/run-annotation-item.js.map +1 -0
  62. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts +32 -0
  63. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts.map +1 -0
  64. package/dist/components/workflow-run-tabs/run-annotation-list.js +202 -0
  65. package/dist/components/workflow-run-tabs/run-annotation-list.js.map +1 -0
  66. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +1 -1
  67. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -1
  68. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +87 -22
  69. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -1
  70. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +3 -1
  71. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -1
  72. package/dist/components/workflow-run-tabs/run-annotations-empty.js +2 -2
  73. package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -1
  74. package/dist/components/workflow-run-tabs/severity-visuals.d.ts +20 -0
  75. package/dist/components/workflow-run-tabs/severity-visuals.d.ts.map +1 -0
  76. package/dist/components/workflow-run-tabs/severity-visuals.js +31 -0
  77. package/dist/components/workflow-run-tabs/severity-visuals.js.map +1 -0
  78. package/dist/components/workflow-run-view/index.d.ts +0 -1
  79. package/dist/components/workflow-run-view/index.d.ts.map +1 -1
  80. package/dist/components/workflow-run-view/index.js +0 -1
  81. package/dist/components/workflow-run-view/index.js.map +1 -1
  82. package/dist/components/workflow-run-view/run-workspace-nav.d.ts +20 -0
  83. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -0
  84. package/dist/components/workflow-run-view/run-workspace-nav.js +277 -0
  85. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -0
  86. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
  87. package/dist/components/workflow-run-view/workflow-run-selection.js +1 -14
  88. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
  89. package/dist/components/workflow-run-view/workflow-run-view.d.ts +10 -10
  90. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  91. package/dist/components/workflow-run-view/workflow-run-view.js +351 -239
  92. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  93. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  94. package/dist/components/workflow-status/status-visuals.js +10 -2
  95. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  96. package/dist/components/workflow-status/workflow-status-icon.d.ts +3 -1
  97. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  98. package/dist/components/workflow-status/workflow-status-icon.js +13 -2
  99. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  100. package/dist/core/entities/job-execution.d.ts +9 -0
  101. package/dist/core/entities/job-execution.d.ts.map +1 -1
  102. package/dist/core/entities/job-execution.js +13 -3
  103. package/dist/core/entities/job-execution.js.map +1 -1
  104. package/dist/core/entities/job.d.ts.map +1 -1
  105. package/dist/core/entities/step-attempt.d.ts.map +1 -1
  106. package/dist/core/entities/step.d.ts +2 -0
  107. package/dist/core/entities/step.d.ts.map +1 -1
  108. package/dist/core/entities/step.js +13 -0
  109. package/dist/core/entities/step.js.map +1 -1
  110. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -1
  111. package/dist/core/entities/workflow-run.d.ts +56 -1
  112. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  113. package/dist/core/entities/workflow-run.js +98 -0
  114. package/dist/core/entities/workflow-run.js.map +1 -1
  115. package/dist/core/run-annotation.d.ts +73 -0
  116. package/dist/core/run-annotation.d.ts.map +1 -0
  117. package/dist/core/run-annotation.js +125 -0
  118. package/dist/core/run-annotation.js.map +1 -0
  119. package/dist/core/workflow-run.d.ts +4 -4
  120. package/dist/core/workflow-run.d.ts.map +1 -1
  121. package/dist/core/workflow-run.js +3 -3
  122. package/dist/core/workflow-run.js.map +1 -1
  123. package/dist/feature.d.ts +20 -16
  124. package/dist/feature.d.ts.map +1 -1
  125. package/dist/feature.js +5 -0
  126. package/dist/feature.js.map +1 -1
  127. package/dist/hooks/api/run-annotation-mapper.d.ts +10 -0
  128. package/dist/hooks/api/run-annotation-mapper.d.ts.map +1 -0
  129. package/dist/hooks/api/run-annotation-mapper.js +22 -0
  130. package/dist/hooks/api/run-annotation-mapper.js.map +1 -0
  131. package/dist/hooks/api/run-annotations.d.ts +25 -0
  132. package/dist/hooks/api/run-annotations.d.ts.map +1 -0
  133. package/dist/hooks/api/run-annotations.js +78 -0
  134. package/dist/hooks/api/run-annotations.js.map +1 -0
  135. package/dist/hooks/api/workflow-runs.d.ts +5 -3
  136. package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
  137. package/dist/hooks/api/workflow-runs.js +5 -4
  138. package/dist/hooks/api/workflow-runs.js.map +1 -1
  139. package/dist/index.d.ts +1 -0
  140. package/dist/index.d.ts.map +1 -1
  141. package/dist/index.js +1 -0
  142. package/dist/index.js.map +1 -1
  143. package/dist/pages/workflow-job-detail-page.d.ts +11 -0
  144. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -0
  145. package/dist/pages/workflow-job-detail-page.js +66 -0
  146. package/dist/pages/workflow-job-detail-page.js.map +1 -0
  147. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
  148. package/dist/pages/workflow-run-detail-page.js +4 -37
  149. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  150. package/dist/routes/inputs.d.ts +52 -34
  151. package/dist/routes/inputs.d.ts.map +1 -1
  152. package/dist/routes/inputs.js +74 -43
  153. package/dist/routes/inputs.js.map +1 -1
  154. package/dist/routes/job-detail.d.ts +10 -0
  155. package/dist/routes/job-detail.d.ts.map +1 -0
  156. package/dist/routes/job-detail.js +27 -0
  157. package/dist/routes/job-detail.js.map +1 -0
  158. package/dist/routes/project-route.d.ts +1 -1
  159. package/dist/routes/run-detail.d.ts.map +1 -1
  160. package/dist/routes/runs.d.ts.map +1 -1
  161. package/dist/routes/workflows.d.ts.map +1 -1
  162. package/dist/tsconfig.test.tsbuildinfo +1 -1
  163. package/package.json +10 -9
  164. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.stories.tsx +1 -1
  165. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.tsx +2 -2
  166. package/src/components/job-detail/job-detail-header.test.tsx +34 -0
  167. package/src/components/job-detail/job-detail-header.tsx +136 -0
  168. package/src/components/job-detail/job-detail-view.stories.tsx +531 -0
  169. package/src/components/job-detail/job-detail-view.tsx +467 -0
  170. package/src/components/job-detail/job-empty-states.test.ts +27 -0
  171. package/src/components/job-detail/job-empty-states.tsx +248 -0
  172. package/src/components/job-detail/job-selection.test.ts +188 -0
  173. package/src/components/job-detail/job-selection.ts +96 -0
  174. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.test.tsx +32 -9
  175. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.tsx +52 -18
  176. package/src/components/job-graph/job-graph-content.tsx +1 -1
  177. package/src/components/step-list/step-list-model.test.ts +1 -1
  178. package/src/components/step-list/step-list-model.ts +2 -2
  179. package/src/components/step-list/step-list.stories.tsx +1 -1
  180. package/src/components/step-list/step-list.test.tsx +33 -0
  181. package/src/components/step-list/step-list.tsx +40 -9
  182. package/src/components/workflow-run-duration-label.tsx +50 -12
  183. package/src/components/workflow-run-list/workflow-run-row.tsx +6 -4
  184. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +38 -0
  185. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +79 -5
  186. package/src/components/workflow-run-summary/workflow-run-summary.tsx +56 -60
  187. package/src/components/workflow-run-tabs/index.ts +3 -3
  188. package/src/components/workflow-run-tabs/run-annotation-count-chip.tsx +63 -0
  189. package/src/components/workflow-run-tabs/run-annotation-item.tsx +123 -0
  190. package/src/components/workflow-run-tabs/run-annotation-list.tsx +258 -0
  191. package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +77 -3
  192. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +92 -17
  193. package/src/components/workflow-run-tabs/run-annotations-empty.tsx +6 -2
  194. package/src/components/workflow-run-tabs/run-annotations.stories.tsx +293 -0
  195. package/src/components/workflow-run-tabs/severity-visuals.ts +37 -0
  196. package/src/components/workflow-run-view/index.ts +0 -1
  197. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +286 -0
  198. package/src/components/workflow-run-view/run-workspace-nav.tsx +314 -0
  199. package/src/components/workflow-run-view/workflow-run-selection.ts +1 -23
  200. package/src/components/workflow-run-view/workflow-run-view.test.tsx +237 -1085
  201. package/src/components/workflow-run-view/workflow-run-view.tsx +450 -323
  202. package/src/components/workflow-status/status-visuals.ts +5 -2
  203. package/src/components/workflow-status/workflow-status-icon.stories.tsx +1 -0
  204. package/src/components/workflow-status/workflow-status-icon.tsx +26 -1
  205. package/src/core/entities/job-execution.ts +17 -2
  206. package/src/core/entities/step.ts +26 -0
  207. package/src/core/entities/workflow-run-display.test.ts +159 -0
  208. package/src/core/entities/workflow-run.ts +139 -1
  209. package/src/core/run-annotation.test.ts +315 -0
  210. package/src/core/run-annotation.ts +238 -0
  211. package/src/core/workflow-run.ts +16 -1
  212. package/src/feature.ts +5 -0
  213. package/src/hooks/api/run-annotation-mapper.ts +30 -0
  214. package/src/hooks/api/run-annotations.ts +137 -0
  215. package/src/hooks/api/workflow-runs.ts +6 -2
  216. package/src/index.ts +1 -0
  217. package/src/page-harness-budget.test.ts +1 -0
  218. package/src/pages/workflow-job-detail-page.test.tsx +605 -0
  219. package/src/pages/workflow-job-detail-page.tsx +84 -0
  220. package/src/pages/workflow-run-detail-page.tsx +3 -43
  221. package/src/pages/workflow-run-pages.test.tsx +77 -332
  222. package/src/routes/inputs.test.ts +56 -6
  223. package/src/routes/inputs.ts +71 -19
  224. package/src/routes/job-detail.tsx +29 -0
  225. package/test/pages.tsx +25 -3
  226. package/test/render.tsx +6 -1
  227. package/tsconfig.build.tsbuildinfo +1 -1
  228. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +0 -18
  229. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +0 -1
  230. package/dist/components/workflow-run-tabs/run-jobs-list.js +0 -107
  231. package/dist/components/workflow-run-tabs/run-jobs-list.js.map +0 -1
  232. package/dist/components/workflow-run-tabs/run-tab-count.d.ts +0 -6
  233. package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +0 -1
  234. package/dist/components/workflow-run-tabs/run-tab-count.js +0 -27
  235. package/dist/components/workflow-run-tabs/run-tab-count.js.map +0 -1
  236. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +0 -8
  237. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +0 -1
  238. package/dist/components/workflow-run-tabs/run-tab-strip.js +0 -82
  239. package/dist/components/workflow-run-tabs/run-tab-strip.js.map +0 -1
  240. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +0 -1
  241. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +0 -1
  242. package/dist/components/workflow-run-view/job-card.d.ts +0 -17
  243. package/dist/components/workflow-run-view/job-card.d.ts.map +0 -1
  244. package/dist/components/workflow-run-view/job-card.js +0 -496
  245. package/dist/components/workflow-run-view/job-card.js.map +0 -1
  246. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +0 -1
  247. package/dist/components/workflow-run-view/job-execution-switcher.js.map +0 -1
  248. package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +0 -1
  249. package/dist/components/workflow-run-view/job-execution-time-text.js.map +0 -1
  250. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +0 -9
  251. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +0 -1
  252. package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +0 -1
  253. package/dist/core/workflow-run-tabs.d.ts +0 -9
  254. package/dist/core/workflow-run-tabs.d.ts.map +0 -1
  255. package/dist/core/workflow-run-tabs.js +0 -3
  256. package/dist/core/workflow-run-tabs.js.map +0 -1
  257. package/src/components/workflow-run-tabs/run-jobs-list.tsx +0 -151
  258. package/src/components/workflow-run-tabs/run-tab-count.tsx +0 -28
  259. package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +0 -52
  260. package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +0 -117
  261. package/src/components/workflow-run-tabs/run-tab-strip.tsx +0 -97
  262. package/src/components/workflow-run-view/job-card.stories.tsx +0 -789
  263. package/src/components/workflow-run-view/job-card.test.tsx +0 -115
  264. package/src/components/workflow-run-view/job-card.tsx +0 -614
  265. package/src/core/workflow-run-tabs.ts +0 -8
  266. /package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.d.ts +0 -0
  267. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.d.ts +0 -0
  268. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.js +0 -0
  269. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.d.ts +0 -0
  270. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.js +0 -0
  271. /package/src/components/{workflow-run-view → job-detail}/job-execution-switcher.tsx +0 -0
  272. /package/src/components/{workflow-run-view → job-detail}/job-execution-time-text.tsx +0 -0
@@ -13,12 +13,15 @@ export interface WorkflowStatusVisual {
13
13
 
14
14
  // The status -> visual mapping shared by the run-header pill (color + label) and
15
15
  // WorkflowStatusIcon (which renders the glyph per kind). The exhaustive switch turns any new
16
- // status the API grows into (DESIGN.md section 9) into a compile error; reserve the `warning`
17
- // tone for the queued/awaiting-* states when they land.
16
+ // status the API grows into (DESIGN.md section 9) into a compile error.
18
17
  export function getWorkflowStatusVisual(status: WorkflowDisplayStatus): WorkflowStatusVisual {
19
18
  switch (status) {
20
19
  case 'pending':
21
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'};
22
25
  case 'running':
23
26
  return {kind: 'running', label: 'Running', dot: 'info', badge: 'info'};
24
27
  case 'listening':
@@ -6,6 +6,7 @@ import {WorkflowStatusIcon} from './workflow-status-icon.js';
6
6
 
7
7
  const statuses: WorkflowDisplayStatus[] = [
8
8
  'pending',
9
+ 'queued',
9
10
  'running',
10
11
  'listening',
11
12
  'succeeded',
@@ -46,6 +46,8 @@ const PENDING_RING_MASK = 'radial-gradient(circle closest-side, transparent 0 52
46
46
 
47
47
  export interface WorkflowStatusIconProps {
48
48
  status: WorkflowDisplayStatus;
49
+ /** Accessible status label when the surrounding surface needs to name its scope. */
50
+ ariaLabel?: string | undefined;
49
51
  /** Optical diameter in px: 14 in the DAG node and run row. */
50
52
  size?: number;
51
53
  /** Pulsing halo for the running state. */
@@ -65,6 +67,7 @@ export interface WorkflowStatusIconProps {
65
67
  */
66
68
  export function WorkflowStatusIcon({
67
69
  status,
70
+ ariaLabel,
68
71
  size = 14,
69
72
  ripple = true,
70
73
  tooltip = true,
@@ -86,6 +89,24 @@ export function WorkflowStatusIcon({
86
89
  <Icon name="pulseLine" size={Math.max(8, Math.round(size * 0.7))} className="text-white" />
87
90
  </span>
88
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
+ );
89
110
  } else if (visual.kind === 'running') {
90
111
  glyph = <Dot variant="info" ripple={ripple} className={box} />;
91
112
  } else if (visual.kind === 'pending') {
@@ -112,7 +133,11 @@ export function WorkflowStatusIcon({
112
133
  }
113
134
 
114
135
  const indicator = (
115
- <span role="img" aria-label={visual.label} className={cn('inline-flex shrink-0', className)}>
136
+ <span
137
+ role="img"
138
+ aria-label={ariaLabel ?? visual.label}
139
+ className={cn('inline-flex shrink-0', className)}
140
+ >
116
141
  {glyph}
117
142
  </span>
118
143
  );
@@ -113,9 +113,24 @@ export function jobExecutionRunTimeFromTimestamps({
113
113
  finishedAt: string | null;
114
114
  }): JobExecutionTime | null {
115
115
  if (startedAt === null) return null;
116
- if (finishedAt === null) return {state: 'live', fromIso: startedAt};
116
+ return elapsedTimeFromTimestamps({from: startedAt, to: finishedAt});
117
+ }
118
+
119
+ /**
120
+ * The elapsed span between two marks, still accruing while the closing mark is missing. Every
121
+ * duration this app shows is one of these, so queue time and run time measure the same way and
122
+ * only differ in which marks they read.
123
+ */
124
+ export function elapsedTimeFromTimestamps({
125
+ from,
126
+ to,
127
+ }: {
128
+ from: string;
129
+ to: string | null;
130
+ }): JobExecutionTime | null {
131
+ if (to === null) return {state: 'live', fromIso: from};
117
132
 
118
- const elapsed = durationBetween(startedAt, finishedAt);
133
+ const elapsed = durationBetween(from, to);
119
134
  return elapsed === null ? null : {state: 'fixed', elapsed};
120
135
  }
121
136
 
@@ -81,3 +81,29 @@ export interface Step {
81
81
  updatedAt: string;
82
82
  attempts: StepAttempt[];
83
83
  }
84
+
85
+ export function resolveStepAttempt(
86
+ step: Step,
87
+ attemptId: string | undefined,
88
+ ): StepAttempt | undefined {
89
+ const attemptById = attemptId
90
+ ? step.attempts.find((attempt) => attempt.id === attemptId)
91
+ : undefined;
92
+ if (attemptById) return attemptById;
93
+
94
+ const currentAttempt = step.attempts.find((attempt) => attempt.attempt === step.currentAttempt);
95
+ if (currentAttempt) return currentAttempt;
96
+
97
+ return step.attempts.reduce<StepAttempt | undefined>((latest, attempt) => {
98
+ if (!latest) return attempt;
99
+ return compareStepAttempts(attempt, latest) > 0 ? attempt : latest;
100
+ }, undefined);
101
+ }
102
+
103
+ export function compareStepAttempts(left: StepAttempt, right: StepAttempt): number {
104
+ return (
105
+ left.attempt - right.attempt ||
106
+ left.executionOrder - right.executionOrder ||
107
+ left.id.localeCompare(right.id)
108
+ );
109
+ }
@@ -0,0 +1,159 @@
1
+ import {
2
+ workflowJob,
3
+ workflowJobExecutionDto,
4
+ workflowRunJobsFixture,
5
+ workflowRunListItem,
6
+ } from '#test/fixtures/workflow-run.js';
7
+ import {
8
+ workflowRunBlockingJob,
9
+ workflowRunDetailDisplay,
10
+ workflowRunListItemDisplay,
11
+ } from './workflow-run.js';
12
+
13
+ const ATTEMPT_STARTED_AT = '2026-05-07T01:00:00.000Z';
14
+ const WORK_STARTED_AT = '2026-05-07T01:02:00.000Z';
15
+ const FINISHED_AT = '2026-05-07T01:05:00.000Z';
16
+
17
+ describe('workflowRunDetailDisplay', () => {
18
+ test('reads a running attempt whose jobs have not started as queued', () => {
19
+ const display = workflowRunDetailDisplay(
20
+ runDetail({jobs: [pendingJob()], startedAt: ATTEMPT_STARTED_AT}),
21
+ );
22
+
23
+ expect(display.status).toBe('queued');
24
+ expect(display.duration).toEqual({kind: 'queue', state: 'live', fromIso: ATTEMPT_STARTED_AT});
25
+ });
26
+
27
+ test('measures run time from the first execution, not from the attempt', () => {
28
+ const display = workflowRunDetailDisplay(
29
+ runDetail({
30
+ jobs: [startedJob(), pendingJob()],
31
+ startedAt: ATTEMPT_STARTED_AT,
32
+ }),
33
+ );
34
+
35
+ expect(display.status).toBe('running');
36
+ expect(display.duration).toEqual({kind: 'run', state: 'live', fromIso: WORK_STARTED_AT});
37
+ });
38
+
39
+ test('does not treat skipped jobs as started work', () => {
40
+ const display = workflowRunDetailDisplay(
41
+ runDetail({
42
+ jobs: [workflowJob({key: 'skipped', status: 'skipped'}), pendingJob()],
43
+ startedAt: ATTEMPT_STARTED_AT,
44
+ }),
45
+ );
46
+
47
+ expect(display.status).toBe('queued');
48
+ expect(display.duration).toMatchObject({kind: 'queue'});
49
+ });
50
+
51
+ test('keeps the queue reading for a run cancelled before anything started', () => {
52
+ const display = workflowRunDetailDisplay(
53
+ runDetail({
54
+ status: 'cancelled',
55
+ jobs: [pendingJob()],
56
+ startedAt: ATTEMPT_STARTED_AT,
57
+ finishedAt: FINISHED_AT,
58
+ }),
59
+ );
60
+
61
+ expect(display.status).toBe('cancelled');
62
+ expect(display.duration).toMatchObject({kind: 'queue', state: 'fixed'});
63
+ });
64
+
65
+ test('does not claim a queue when the run carries no jobs to prove one', () => {
66
+ const display = workflowRunDetailDisplay(runDetail({jobs: [], startedAt: ATTEMPT_STARTED_AT}));
67
+
68
+ expect(display.status).toBe('running');
69
+ expect(display.duration).toEqual({kind: 'run', state: 'live', fromIso: ATTEMPT_STARTED_AT});
70
+ });
71
+
72
+ test('has no duration before the attempt starts', () => {
73
+ expect(workflowRunDetailDisplay(runDetail({jobs: [pendingJob()]})).duration).toBeNull();
74
+ });
75
+ });
76
+
77
+ describe('workflowRunListItemDisplay', () => {
78
+ test('reaches the detail page verdict from job status counts alone', () => {
79
+ const run = workflowRunListItem({
80
+ status: 'running',
81
+ ...workflowRunJobsFixture(['pending', 'pending']),
82
+ started_at: ATTEMPT_STARTED_AT,
83
+ });
84
+
85
+ const display = workflowRunListItemDisplay(run);
86
+
87
+ expect(display.status).toBe('queued');
88
+ expect(display.duration).toEqual({kind: 'queue', state: 'live', fromIso: ATTEMPT_STARTED_AT});
89
+ });
90
+
91
+ test('stays running once any job has left pending', () => {
92
+ const run = workflowRunListItem({
93
+ status: 'running',
94
+ ...workflowRunJobsFixture(['succeeded', 'pending']),
95
+ started_at: ATTEMPT_STARTED_AT,
96
+ });
97
+
98
+ const display = workflowRunListItemDisplay(run);
99
+
100
+ expect(display.status).toBe('running');
101
+ expect(display.duration).toMatchObject({kind: 'run'});
102
+ });
103
+ });
104
+
105
+ describe('workflowRunBlockingJob', () => {
106
+ test('ignores finished queued executions when finding the blocking job', () => {
107
+ const historicalJob = workflowJob({
108
+ key: 'historical',
109
+ job_executions: [
110
+ workflowJobExecutionDto({
111
+ status: 'succeeded',
112
+ queued_at: '2026-05-07T00:59:00.000Z',
113
+ finished_at: '2026-05-07T01:00:00.000Z',
114
+ }),
115
+ ],
116
+ });
117
+ const currentJob = workflowJob({
118
+ key: 'current',
119
+ job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T01:01:00.000Z'})],
120
+ });
121
+
122
+ expect(workflowRunBlockingJob([historicalJob, currentJob])?.key).toBe('current');
123
+ });
124
+ });
125
+
126
+ function pendingJob() {
127
+ return workflowJob({
128
+ key: 'lint',
129
+ job_executions: [workflowJobExecutionDto({queued_at: ATTEMPT_STARTED_AT})],
130
+ });
131
+ }
132
+
133
+ function startedJob() {
134
+ return workflowJob({
135
+ key: 'build',
136
+ status: 'running',
137
+ job_executions: [
138
+ workflowJobExecutionDto({
139
+ status: 'running',
140
+ queued_at: ATTEMPT_STARTED_AT,
141
+ started_at: WORK_STARTED_AT,
142
+ }),
143
+ ],
144
+ });
145
+ }
146
+
147
+ function runDetail({
148
+ status = 'running',
149
+ jobs,
150
+ startedAt = null,
151
+ finishedAt = null,
152
+ }: {
153
+ status?: 'running' | 'cancelled';
154
+ jobs: ReturnType<typeof workflowJob>[];
155
+ startedAt?: string | null;
156
+ finishedAt?: string | null;
157
+ }) {
158
+ return {runAttempt: {status, startedAt, finishedAt}, jobs};
159
+ }
@@ -1,10 +1,15 @@
1
1
  import {type Job, type JobStatus, WORKFLOW_JOB_STATUSES} from './job.js';
2
+ import {elapsedTimeFromTimestamps, type JobExecutionDisplayDuration} from './job-execution.js';
2
3
  import type {WorkflowRunAttempt, WorkflowRunAttemptSummary} from './workflow-run-attempt.js';
3
4
 
4
5
  export type WorkflowRunStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
5
6
  export type WorkflowRunRerunMode = 'all' | 'failed';
6
7
  export type WorkflowStatus = WorkflowRunStatus | (typeof WORKFLOW_JOB_STATUSES)[number];
7
- export type WorkflowDisplayStatus = WorkflowStatus | 'listening';
8
+ /**
9
+ * `listening` and `queued` are display-only: the API never returns them, and both are derived
10
+ * from the jobs a run already carries.
11
+ */
12
+ export type WorkflowDisplayStatus = WorkflowStatus | 'listening' | 'queued';
8
13
 
9
14
  export const WORKFLOW_RUN_STATUSES = [
10
15
  'pending',
@@ -14,6 +19,13 @@ export const WORKFLOW_RUN_STATUSES = [
14
19
  'cancelled',
15
20
  ] as const satisfies readonly WorkflowRunStatus[];
16
21
 
22
+ export const WORKFLOW_DISPLAY_STATUSES = [
23
+ ...WORKFLOW_RUN_STATUSES,
24
+ ...WORKFLOW_JOB_STATUSES,
25
+ 'listening',
26
+ 'queued',
27
+ ] as const satisfies readonly WorkflowDisplayStatus[];
28
+
17
29
  export const TERMINAL_WORKFLOW_RUN_STATUSES = [
18
30
  'succeeded',
19
31
  'failed',
@@ -172,3 +184,129 @@ export function isWorkflowRunTerminal(status: WorkflowRunStatus): boolean {
172
184
  export function isWorkflowStatus(status: string): status is WorkflowStatus {
173
185
  return WORKFLOW_STATUSES.has(status as WorkflowStatus);
174
186
  }
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
+ }