@shipfox/client-workflows 0.2.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 (327) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +10 -0
  3. package/.storybook/preview.tsx +97 -0
  4. package/.swcrc +42 -0
  5. package/.turbo/turbo-build.log +2 -0
  6. package/.turbo/turbo-type$colon$emit.log +1 -0
  7. package/.turbo/turbo-type.log +1 -0
  8. package/CHANGELOG.md +162 -0
  9. package/LICENSE +21 -0
  10. package/dist/components/identifier/identifier.d.ts +6 -0
  11. package/dist/components/identifier/identifier.d.ts.map +1 -0
  12. package/dist/components/identifier/identifier.js +144 -0
  13. package/dist/components/identifier/identifier.js.map +1 -0
  14. package/dist/components/identifier/index.d.ts +2 -0
  15. package/dist/components/identifier/index.d.ts.map +1 -0
  16. package/dist/components/identifier/index.js +3 -0
  17. package/dist/components/identifier/index.js.map +1 -0
  18. package/dist/components/job-graph/graph-model.d.ts +27 -0
  19. package/dist/components/job-graph/graph-model.d.ts.map +1 -0
  20. package/dist/components/job-graph/graph-model.js +107 -0
  21. package/dist/components/job-graph/graph-model.js.map +1 -0
  22. package/dist/components/job-graph/index.d.ts +3 -0
  23. package/dist/components/job-graph/index.d.ts.map +1 -0
  24. package/dist/components/job-graph/index.js +3 -0
  25. package/dist/components/job-graph/index.js.map +1 -0
  26. package/dist/components/job-graph/job-duration-format.d.ts +4 -0
  27. package/dist/components/job-graph/job-duration-format.d.ts.map +1 -0
  28. package/dist/components/job-graph/job-duration-format.js +41 -0
  29. package/dist/components/job-graph/job-duration-format.js.map +1 -0
  30. package/dist/components/job-graph/job-duration-label.d.ts +5 -0
  31. package/dist/components/job-graph/job-duration-label.d.ts.map +1 -0
  32. package/dist/components/job-graph/job-duration-label.js +33 -0
  33. package/dist/components/job-graph/job-duration-label.js.map +1 -0
  34. package/dist/components/job-graph/job-graph-content.d.ts +9 -0
  35. package/dist/components/job-graph/job-graph-content.d.ts.map +1 -0
  36. package/dist/components/job-graph/job-graph-content.js +181 -0
  37. package/dist/components/job-graph/job-graph-content.js.map +1 -0
  38. package/dist/components/job-graph/job-graph-view.d.ts +11 -0
  39. package/dist/components/job-graph/job-graph-view.d.ts.map +1 -0
  40. package/dist/components/job-graph/job-graph-view.js +24 -0
  41. package/dist/components/job-graph/job-graph-view.js.map +1 -0
  42. package/dist/components/job-graph/job-graph.d.ts +3 -0
  43. package/dist/components/job-graph/job-graph.d.ts.map +1 -0
  44. package/dist/components/job-graph/job-graph.js +21 -0
  45. package/dist/components/job-graph/job-graph.js.map +1 -0
  46. package/dist/components/job-graph/job-node.d.ts +16 -0
  47. package/dist/components/job-graph/job-node.d.ts.map +1 -0
  48. package/dist/components/job-graph/job-node.js +196 -0
  49. package/dist/components/job-graph/job-node.js.map +1 -0
  50. package/dist/components/job-graph/types.d.ts +9 -0
  51. package/dist/components/job-graph/types.d.ts.map +1 -0
  52. package/dist/components/job-graph/types.js +3 -0
  53. package/dist/components/job-graph/types.js.map +1 -0
  54. package/dist/components/step-list/index.d.ts +3 -0
  55. package/dist/components/step-list/index.d.ts.map +1 -0
  56. package/dist/components/step-list/index.js +3 -0
  57. package/dist/components/step-list/index.js.map +1 -0
  58. package/dist/components/step-list/step-list-model.d.ts +36 -0
  59. package/dist/components/step-list/step-list-model.d.ts.map +1 -0
  60. package/dist/components/step-list/step-list-model.js +135 -0
  61. package/dist/components/step-list/step-list-model.js.map +1 -0
  62. package/dist/components/step-list/step-list.d.ts +32 -0
  63. package/dist/components/step-list/step-list.d.ts.map +1 -0
  64. package/dist/components/step-list/step-list.js +409 -0
  65. package/dist/components/step-list/step-list.js.map +1 -0
  66. package/dist/components/workflow-run-duration-label.d.ts +8 -0
  67. package/dist/components/workflow-run-duration-label.d.ts.map +1 -0
  68. package/dist/components/workflow-run-duration-label.js +84 -0
  69. package/dist/components/workflow-run-duration-label.js.map +1 -0
  70. package/dist/components/workflow-run-list/index.d.ts +2 -0
  71. package/dist/components/workflow-run-list/index.d.ts.map +1 -0
  72. package/dist/components/workflow-run-list/index.js +3 -0
  73. package/dist/components/workflow-run-list/index.js.map +1 -0
  74. package/dist/components/workflow-run-list/run-display.d.ts +5 -0
  75. package/dist/components/workflow-run-list/run-display.d.ts.map +1 -0
  76. package/dist/components/workflow-run-list/run-display.js +19 -0
  77. package/dist/components/workflow-run-list/run-display.js.map +1 -0
  78. package/dist/components/workflow-run-list/types.d.ts +21 -0
  79. package/dist/components/workflow-run-list/types.d.ts.map +1 -0
  80. package/dist/components/workflow-run-list/types.js +3 -0
  81. package/dist/components/workflow-run-list/types.js.map +1 -0
  82. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts +14 -0
  83. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts.map +1 -0
  84. package/dist/components/workflow-run-list/workflow-run-list-content.js +37 -0
  85. package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -0
  86. package/dist/components/workflow-run-list/workflow-run-list-header.d.ts +10 -0
  87. package/dist/components/workflow-run-list/workflow-run-list-header.d.ts.map +1 -0
  88. package/dist/components/workflow-run-list/workflow-run-list-header.js +55 -0
  89. package/dist/components/workflow-run-list/workflow-run-list-header.js.map +1 -0
  90. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts +15 -0
  91. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -0
  92. package/dist/components/workflow-run-list/workflow-run-list-states.js +103 -0
  93. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -0
  94. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts +3 -0
  95. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -0
  96. package/dist/components/workflow-run-list/workflow-run-list-view.js +46 -0
  97. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -0
  98. package/dist/components/workflow-run-list/workflow-run-list.d.ts +3 -0
  99. package/dist/components/workflow-run-list/workflow-run-list.d.ts.map +1 -0
  100. package/dist/components/workflow-run-list/workflow-run-list.js +20 -0
  101. package/dist/components/workflow-run-list/workflow-run-list.js.map +1 -0
  102. package/dist/components/workflow-run-list/workflow-run-row.d.ts +14 -0
  103. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -0
  104. package/dist/components/workflow-run-list/workflow-run-row.js +172 -0
  105. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -0
  106. package/dist/components/workflow-run-summary/index.d.ts +2 -0
  107. package/dist/components/workflow-run-summary/index.d.ts.map +1 -0
  108. package/dist/components/workflow-run-summary/index.js +3 -0
  109. package/dist/components/workflow-run-summary/index.js.map +1 -0
  110. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts +9 -0
  111. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts.map +1 -0
  112. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +138 -0
  113. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -0
  114. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +20 -0
  115. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -0
  116. package/dist/components/workflow-run-summary/workflow-run-summary.js +252 -0
  117. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -0
  118. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts +7 -0
  119. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +1 -0
  120. package/dist/components/workflow-run-view/agent-config-failure-callout.js +84 -0
  121. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +1 -0
  122. package/dist/components/workflow-run-view/index.d.ts +3 -0
  123. package/dist/components/workflow-run-view/index.d.ts.map +1 -0
  124. package/dist/components/workflow-run-view/index.js +4 -0
  125. package/dist/components/workflow-run-view/index.js.map +1 -0
  126. package/dist/components/workflow-run-view/job-card.d.ts +17 -0
  127. package/dist/components/workflow-run-view/job-card.d.ts.map +1 -0
  128. package/dist/components/workflow-run-view/job-card.js +476 -0
  129. package/dist/components/workflow-run-view/job-card.js.map +1 -0
  130. package/dist/components/workflow-run-view/job-execution-switcher.d.ts +10 -0
  131. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +1 -0
  132. package/dist/components/workflow-run-view/job-execution-switcher.js +171 -0
  133. package/dist/components/workflow-run-view/job-execution-switcher.js.map +1 -0
  134. package/dist/components/workflow-run-view/job-execution-time-text.d.ts +6 -0
  135. package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +1 -0
  136. package/dist/components/workflow-run-view/job-execution-time-text.js +24 -0
  137. package/dist/components/workflow-run-view/job-execution-time-text.js.map +1 -0
  138. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +9 -0
  139. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +1 -0
  140. package/dist/components/workflow-run-view/step-attempt-log-panel.js +152 -0
  141. package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +1 -0
  142. package/dist/components/workflow-run-view/workflow-run-selection.d.ts +14 -0
  143. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -0
  144. package/dist/components/workflow-run-view/workflow-run-selection.js +56 -0
  145. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -0
  146. package/dist/components/workflow-run-view/workflow-run-states.d.ts +12 -0
  147. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -0
  148. package/dist/components/workflow-run-view/workflow-run-states.js +91 -0
  149. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -0
  150. package/dist/components/workflow-run-view/workflow-run-view.d.ts +15 -0
  151. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -0
  152. package/dist/components/workflow-run-view/workflow-run-view.js +285 -0
  153. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -0
  154. package/dist/components/workflow-source-panel/index.d.ts +2 -0
  155. package/dist/components/workflow-source-panel/index.d.ts.map +1 -0
  156. package/dist/components/workflow-source-panel/index.js +3 -0
  157. package/dist/components/workflow-source-panel/index.js.map +1 -0
  158. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts +13 -0
  159. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts.map +1 -0
  160. package/dist/components/workflow-source-panel/workflow-source-panel.js +91 -0
  161. package/dist/components/workflow-source-panel/workflow-source-panel.js.map +1 -0
  162. package/dist/components/workflow-status/status-visuals.d.ts +12 -0
  163. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -0
  164. package/dist/components/workflow-status/status-visuals.js +54 -0
  165. package/dist/components/workflow-status/status-visuals.js.map +1 -0
  166. package/dist/components/workflow-status/workflow-status-icon.d.ts +22 -0
  167. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -0
  168. package/dist/components/workflow-status/workflow-status-icon.js +100 -0
  169. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -0
  170. package/dist/core/entities/job-execution.d.ts +69 -0
  171. package/dist/core/entities/job-execution.d.ts.map +1 -0
  172. package/dist/core/entities/job-execution.js +83 -0
  173. package/dist/core/entities/job-execution.js.map +1 -0
  174. package/dist/core/entities/job.d.ts +56 -0
  175. package/dist/core/entities/job.d.ts.map +1 -0
  176. package/dist/core/entities/job.js +71 -0
  177. package/dist/core/entities/job.js.map +1 -0
  178. package/dist/core/entities/step-attempt.d.ts +49 -0
  179. package/dist/core/entities/step-attempt.d.ts.map +1 -0
  180. package/dist/core/entities/step-attempt.js +48 -0
  181. package/dist/core/entities/step-attempt.js.map +1 -0
  182. package/dist/core/entities/step.d.ts +41 -0
  183. package/dist/core/entities/step.d.ts.map +1 -0
  184. package/dist/core/entities/step.js +51 -0
  185. package/dist/core/entities/step.js.map +1 -0
  186. package/dist/core/entities/workflow-run-attempt.d.ts +44 -0
  187. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -0
  188. package/dist/core/entities/workflow-run-attempt.js +51 -0
  189. package/dist/core/entities/workflow-run-attempt.js.map +1 -0
  190. package/dist/core/entities/workflow-run.d.ts +61 -0
  191. package/dist/core/entities/workflow-run.d.ts.map +1 -0
  192. package/dist/core/entities/workflow-run.js +104 -0
  193. package/dist/core/entities/workflow-run.js.map +1 -0
  194. package/dist/core/workflow-run-url-state.d.ts +13 -0
  195. package/dist/core/workflow-run-url-state.d.ts.map +1 -0
  196. package/dist/core/workflow-run-url-state.js +43 -0
  197. package/dist/core/workflow-run-url-state.js.map +1 -0
  198. package/dist/core/workflow-run.d.ts +13 -0
  199. package/dist/core/workflow-run.d.ts.map +1 -0
  200. package/dist/core/workflow-run.js +8 -0
  201. package/dist/core/workflow-run.js.map +1 -0
  202. package/dist/env.d.js +2 -0
  203. package/dist/env.d.js.map +1 -0
  204. package/dist/feature.d.ts +20 -0
  205. package/dist/feature.d.ts.map +1 -0
  206. package/dist/feature.js +27 -0
  207. package/dist/feature.js.map +1 -0
  208. package/dist/hooks/api/workflow-runs.d.ts +109 -0
  209. package/dist/hooks/api/workflow-runs.d.ts.map +1 -0
  210. package/dist/hooks/api/workflow-runs.js +394 -0
  211. package/dist/hooks/api/workflow-runs.js.map +1 -0
  212. package/dist/index.d.ts +5 -0
  213. package/dist/index.d.ts.map +1 -0
  214. package/dist/index.js +5 -0
  215. package/dist/index.js.map +1 -0
  216. package/dist/pages/workflow-run-first-time-use.d.ts +6 -0
  217. package/dist/pages/workflow-run-first-time-use.d.ts.map +1 -0
  218. package/dist/pages/workflow-run-first-time-use.js +17 -0
  219. package/dist/pages/workflow-run-first-time-use.js.map +1 -0
  220. package/dist/pages/workflow-run-page.d.ts +8 -0
  221. package/dist/pages/workflow-run-page.d.ts.map +1 -0
  222. package/dist/pages/workflow-run-page.js +83 -0
  223. package/dist/pages/workflow-run-page.js.map +1 -0
  224. package/dist/routes/run-detail.d.ts +8 -0
  225. package/dist/routes/run-detail.d.ts.map +1 -0
  226. package/dist/routes/run-detail.js +21 -0
  227. package/dist/routes/run-detail.js.map +1 -0
  228. package/dist/routes/runs.d.ts +8 -0
  229. package/dist/routes/runs.d.ts.map +1 -0
  230. package/dist/routes/runs.js +20 -0
  231. package/dist/routes/runs.js.map +1 -0
  232. package/dist/tsconfig.test.tsbuildinfo +1 -0
  233. package/package.json +114 -0
  234. package/src/components/identifier/identifier.tsx +178 -0
  235. package/src/components/identifier/index.ts +1 -0
  236. package/src/components/job-graph/graph-model.test.ts +189 -0
  237. package/src/components/job-graph/graph-model.ts +178 -0
  238. package/src/components/job-graph/index.ts +2 -0
  239. package/src/components/job-graph/job-duration-format.ts +54 -0
  240. package/src/components/job-graph/job-duration-label.tsx +29 -0
  241. package/src/components/job-graph/job-graph-content.tsx +225 -0
  242. package/src/components/job-graph/job-graph-view.test.tsx +298 -0
  243. package/src/components/job-graph/job-graph-view.tsx +45 -0
  244. package/src/components/job-graph/job-graph.stories.tsx +182 -0
  245. package/src/components/job-graph/job-graph.tsx +24 -0
  246. package/src/components/job-graph/job-node.stories.tsx +364 -0
  247. package/src/components/job-graph/job-node.test.tsx +347 -0
  248. package/src/components/job-graph/job-node.tsx +202 -0
  249. package/src/components/job-graph/types.ts +9 -0
  250. package/src/components/step-list/index.ts +6 -0
  251. package/src/components/step-list/step-list-model.test.ts +369 -0
  252. package/src/components/step-list/step-list-model.ts +205 -0
  253. package/src/components/step-list/step-list.stories.tsx +419 -0
  254. package/src/components/step-list/step-list.test.tsx +566 -0
  255. package/src/components/step-list/step-list.tsx +494 -0
  256. package/src/components/workflow-run-duration-label.tsx +118 -0
  257. package/src/components/workflow-run-list/index.ts +1 -0
  258. package/src/components/workflow-run-list/run-display.test.ts +53 -0
  259. package/src/components/workflow-run-list/run-display.ts +22 -0
  260. package/src/components/workflow-run-list/types.ts +22 -0
  261. package/src/components/workflow-run-list/workflow-run-list-content.tsx +58 -0
  262. package/src/components/workflow-run-list/workflow-run-list-header.tsx +53 -0
  263. package/src/components/workflow-run-list/workflow-run-list-states.tsx +86 -0
  264. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +178 -0
  265. package/src/components/workflow-run-list/workflow-run-list-view.tsx +57 -0
  266. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +111 -0
  267. package/src/components/workflow-run-list/workflow-run-list.tsx +29 -0
  268. package/src/components/workflow-run-list/workflow-run-row.tsx +176 -0
  269. package/src/components/workflow-run-summary/index.ts +1 -0
  270. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.test.tsx +230 -0
  271. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +146 -0
  272. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +414 -0
  273. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +354 -0
  274. package/src/components/workflow-run-summary/workflow-run-summary.tsx +295 -0
  275. package/src/components/workflow-run-view/agent-config-failure-callout.stories.tsx +140 -0
  276. package/src/components/workflow-run-view/agent-config-failure-callout.tsx +93 -0
  277. package/src/components/workflow-run-view/index.ts +2 -0
  278. package/src/components/workflow-run-view/job-card.stories.tsx +788 -0
  279. package/src/components/workflow-run-view/job-card.tsx +566 -0
  280. package/src/components/workflow-run-view/job-execution-switcher.tsx +170 -0
  281. package/src/components/workflow-run-view/job-execution-time-text.tsx +34 -0
  282. package/src/components/workflow-run-view/step-attempt-log-panel.test.tsx +290 -0
  283. package/src/components/workflow-run-view/step-attempt-log-panel.tsx +152 -0
  284. package/src/components/workflow-run-view/workflow-run-selection.test.ts +280 -0
  285. package/src/components/workflow-run-view/workflow-run-selection.ts +89 -0
  286. package/src/components/workflow-run-view/workflow-run-states.tsx +70 -0
  287. package/src/components/workflow-run-view/workflow-run-view.test.tsx +1214 -0
  288. package/src/components/workflow-run-view/workflow-run-view.tsx +341 -0
  289. package/src/components/workflow-source-panel/index.ts +1 -0
  290. package/src/components/workflow-source-panel/workflow-source-panel.stories.tsx +110 -0
  291. package/src/components/workflow-source-panel/workflow-source-panel.test.tsx +96 -0
  292. package/src/components/workflow-source-panel/workflow-source-panel.tsx +136 -0
  293. package/src/components/workflow-status/status-visuals.test.ts +38 -0
  294. package/src/components/workflow-status/status-visuals.ts +36 -0
  295. package/src/components/workflow-status/workflow-status-icon.stories.tsx +90 -0
  296. package/src/components/workflow-status/workflow-status-icon.tsx +135 -0
  297. package/src/core/entities/job-execution.ts +139 -0
  298. package/src/core/entities/job.ts +149 -0
  299. package/src/core/entities/step-attempt.ts +89 -0
  300. package/src/core/entities/step.ts +105 -0
  301. package/src/core/entities/workflow-run-attempt.ts +87 -0
  302. package/src/core/entities/workflow-run.ts +183 -0
  303. package/src/core/workflow-run-url-state.ts +61 -0
  304. package/src/core/workflow-run.test.ts +640 -0
  305. package/src/core/workflow-run.ts +66 -0
  306. package/src/env.d.ts +8 -0
  307. package/src/feature.ts +26 -0
  308. package/src/hooks/api/workflow-runs.test.tsx +410 -0
  309. package/src/hooks/api/workflow-runs.ts +485 -0
  310. package/src/index.ts +45 -0
  311. package/src/page-harness-budget.test.ts +33 -0
  312. package/src/pages/workflow-run-first-time-use.tsx +17 -0
  313. package/src/pages/workflow-run-page.test.tsx +517 -0
  314. package/src/pages/workflow-run-page.tsx +89 -0
  315. package/src/routes/run-detail.tsx +15 -0
  316. package/src/routes/runs.tsx +11 -0
  317. package/test/fixtures/logs.ts +13 -0
  318. package/test/fixtures/workflow-run.ts +311 -0
  319. package/test/pages.tsx +79 -0
  320. package/test/render.tsx +40 -0
  321. package/test/setup.ts +3 -0
  322. package/tsconfig.build.json +9 -0
  323. package/tsconfig.build.tsbuildinfo +1 -0
  324. package/tsconfig.json +3 -0
  325. package/tsconfig.test.json +8 -0
  326. package/vercel.json +8 -0
  327. package/vitest.config.ts +70 -0
@@ -0,0 +1,38 @@
1
+ import {
2
+ type JobStatusDto,
3
+ jobStatusSchema,
4
+ type WorkflowRunStatusDto,
5
+ workflowRunStatusSchema,
6
+ } from '@shipfox/api-workflows-dto';
7
+ import {getWorkflowStatusVisual} from './status-visuals.js';
8
+
9
+ const EXPECTED_RUN_LABELS: Record<WorkflowRunStatusDto, string> = {
10
+ pending: 'Pending',
11
+ running: 'Running',
12
+ succeeded: 'Succeeded',
13
+ failed: 'Failed',
14
+ cancelled: 'Cancelled',
15
+ };
16
+
17
+ const EXPECTED_JOB_LABELS: Record<JobStatusDto, string> = {
18
+ ...EXPECTED_RUN_LABELS,
19
+ skipped: 'Skipped',
20
+ };
21
+
22
+ describe('getWorkflowStatusVisual', () => {
23
+ test.each(
24
+ workflowRunStatusSchema.options,
25
+ )('maps the run %s status to its own label', (status) => {
26
+ expect(getWorkflowStatusVisual(status).label).toBe(EXPECTED_RUN_LABELS[status]);
27
+ });
28
+
29
+ test.each(jobStatusSchema.options)('maps the job %s status to its own label', (status) => {
30
+ expect(getWorkflowStatusVisual(status).label).toBe(EXPECTED_JOB_LABELS[status]);
31
+ });
32
+
33
+ test('returns the shared running visual', () => {
34
+ const visual = getWorkflowStatusVisual('running');
35
+
36
+ expect(visual).toEqual({kind: 'running', label: 'Running', dot: 'info', badge: 'info'});
37
+ });
38
+ });
@@ -0,0 +1,36 @@
1
+ import type {BadgeVariant} from '@shipfox/react-ui/badge';
2
+ import type {DotVariant} from '@shipfox/react-ui/dot';
3
+ import type {WorkflowStatus} from '#core/workflow-run.js';
4
+
5
+ export type {WorkflowStatus};
6
+
7
+ export interface WorkflowStatusVisual {
8
+ kind: WorkflowStatus;
9
+ label: string;
10
+ dot: DotVariant;
11
+ badge: BadgeVariant;
12
+ }
13
+
14
+ // The status -> visual mapping shared by the run-header pill (color + label) and
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.
18
+ export function getWorkflowStatusVisual(status: WorkflowStatus): WorkflowStatusVisual {
19
+ switch (status) {
20
+ case 'pending':
21
+ return {kind: 'pending', label: 'Pending', dot: 'neutral', badge: 'neutral'};
22
+ case 'running':
23
+ return {kind: 'running', label: 'Running', dot: 'info', badge: 'info'};
24
+ case 'succeeded':
25
+ return {kind: 'succeeded', label: 'Succeeded', dot: 'success', badge: 'success'};
26
+ case 'failed':
27
+ return {kind: 'failed', label: 'Failed', dot: 'error', badge: 'error'};
28
+ case 'cancelled':
29
+ return {kind: 'cancelled', label: 'Cancelled', dot: 'neutral', badge: 'neutral'};
30
+ case 'skipped':
31
+ return {kind: 'skipped', label: 'Skipped', dot: 'neutral', badge: 'neutral'};
32
+ }
33
+
34
+ const exhaustive: never = status;
35
+ return exhaustive;
36
+ }
@@ -0,0 +1,90 @@
1
+ import {Code, Text} from '@shipfox/react-ui/typography';
2
+ import type {Meta, StoryObj} from '@storybook/react';
3
+ import type {JobStatus} from '#core/workflow-run.js';
4
+ import {getWorkflowStatusVisual} from './status-visuals.js';
5
+ import {WorkflowStatusIcon} from './workflow-status-icon.js';
6
+
7
+ const statuses: JobStatus[] = ['pending', 'running', 'succeeded', 'failed', 'cancelled', 'skipped'];
8
+
9
+ const meta = {
10
+ title: 'Workflows/StatusIcon',
11
+ component: WorkflowStatusIcon,
12
+ parameters: {layout: 'centered'},
13
+ argTypes: {
14
+ status: {
15
+ control: 'select',
16
+ options: statuses,
17
+ },
18
+ size: {
19
+ control: {type: 'number', min: 8, max: 24, step: 1},
20
+ },
21
+ ripple: {
22
+ control: 'boolean',
23
+ },
24
+ },
25
+ args: {
26
+ status: 'running',
27
+ size: 14,
28
+ ripple: false,
29
+ },
30
+ decorators: [
31
+ (Story) => (
32
+ <div className="bg-background-neutral-base p-24">
33
+ <Story />
34
+ </div>
35
+ ),
36
+ ],
37
+ } satisfies Meta<typeof WorkflowStatusIcon>;
38
+
39
+ export default meta;
40
+ type Story = StoryObj<typeof meta>;
41
+
42
+ // Every state at the surface size (14px) plus a compact size, sharing the tuned glyph scale.
43
+ export const Playground: Story = {};
44
+
45
+ export const Variants: Story = {
46
+ render: () => (
47
+ <div className="flex flex-col gap-32">
48
+ <Scale label="DAG node / run row (14px)" size={14} />
49
+ <Scale label="Compact (12px)" size={12} />
50
+ </div>
51
+ ),
52
+ };
53
+
54
+ export const InContext: Story = {
55
+ render: () => (
56
+ <div className="grid grid-cols-2 gap-12">
57
+ {statuses.map((status) => (
58
+ <div
59
+ key={status}
60
+ className="flex w-208 items-center gap-8 rounded-8 border border-border-neutral-base bg-background-components-base px-10 py-7"
61
+ >
62
+ <WorkflowStatusIcon status={status} size={14} />
63
+ <Code variant="label" bold className="truncate text-foreground-neutral-base">
64
+ {status}-job
65
+ </Code>
66
+ </div>
67
+ ))}
68
+ </div>
69
+ ),
70
+ };
71
+
72
+ function Scale({label, size, ripple}: {label: string; size: number; ripple?: boolean}) {
73
+ return (
74
+ <div className="flex flex-col gap-12">
75
+ <Code variant="label" className="text-foreground-neutral-subtle">
76
+ {label}
77
+ </Code>
78
+ <div className="flex items-start gap-32">
79
+ {statuses.map((status) => (
80
+ <div key={status} className="flex flex-col items-center gap-6">
81
+ <WorkflowStatusIcon status={status} size={size} ripple={ripple} />
82
+ <Text size="xs" className="text-foreground-neutral-muted">
83
+ {getWorkflowStatusVisual(status).label}
84
+ </Text>
85
+ </div>
86
+ ))}
87
+ </div>
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,135 @@
1
+ import {Dot, type DotVariant} from '@shipfox/react-ui/dot';
2
+ import {Icon, type IconName} from '@shipfox/react-ui/icon';
3
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
4
+ import {cn} from '@shipfox/react-ui/utils';
5
+ import type {ReactNode} from 'react';
6
+ import {getWorkflowStatusVisual, type WorkflowStatus} from './status-visuals.js';
7
+
8
+ // Status glyphs use the same saturated accent tokens as IconBadge/StatusBadge.
9
+ const toneByVariant: Record<DotVariant, string> = {
10
+ neutral: 'text-tag-neutral-icon',
11
+ info: 'text-tag-blue-icon',
12
+ feature: 'text-tag-purple-icon',
13
+ success: 'text-tag-success-icon',
14
+ warning: 'text-tag-warning-icon',
15
+ error: 'text-tag-error-icon',
16
+ };
17
+
18
+ // Terminal states are self-contained solid discs; the shape names the state. Running and
19
+ // pending render their own shapes (the live Dot and a bold ring), so they aren't here.
20
+ const glyphByKind: Partial<Record<WorkflowStatus, IconName>> = {
21
+ succeeded: 'checkCircleSolid',
22
+ failed: 'xCircleSolid',
23
+ cancelled: 'forbid2Fill',
24
+ skipped: 'forbid2Fill',
25
+ };
26
+
27
+ // The solid discs fill ~90-95% of their viewBox and forbid2Fill ~83%, so one numeric size
28
+ // renders different diameters. These multipliers land every disc at the running Dot's diameter.
29
+ const glyphScale: Partial<Record<IconName, number>> = {
30
+ checkCircleSolid: 1.12,
31
+ xCircleSolid: 1.06,
32
+ forbid2Fill: 1.22,
33
+ };
34
+
35
+ // Only the two sizes the app uses are mapped; both are literal strings so Tailwind emits them.
36
+ const dotSizeClass: Record<number, string> = {
37
+ 12: 'size-12',
38
+ 14: 'size-14',
39
+ 20: 'size-20',
40
+ };
41
+
42
+ // Pending is a neutral ring: a filled disc with a transparent center punched out by a radial
43
+ // mask. Reads as "not started" and stays visually consistent with the solid discs - much
44
+ // bolder than a dotted outline. Tune the mask stops to make the band thicker or thinner.
45
+ const PENDING_RING_MASK = 'radial-gradient(circle closest-side, transparent 0 52%, #000 56%)';
46
+
47
+ export interface WorkflowStatusIconProps {
48
+ status: WorkflowStatus;
49
+ jobMode?: 'one_shot' | 'listening';
50
+ /** Optical diameter in px: 14 in the DAG node and run row. */
51
+ size?: number;
52
+ /** Pulsing halo for the running state. */
53
+ ripple?: boolean;
54
+ /**
55
+ * Wrap the indicator in a hover tooltip naming the status. Defaults on. Pass false where
56
+ * the status text is already visible next to it (the DAG node) to avoid a redundant tooltip.
57
+ */
58
+ tooltip?: boolean;
59
+ className?: string;
60
+ }
61
+
62
+ /**
63
+ * The job/run/step state indicator: an icon-in-circle glyph whose shape (not just color)
64
+ * names the state. Carries the status as its accessible name (`role="img"`) and, by default,
65
+ * a hover tooltip, so the state is reachable by pointer and assistive tech everywhere.
66
+ */
67
+ export function WorkflowStatusIcon({
68
+ status,
69
+ jobMode = 'one_shot',
70
+ size = 14,
71
+ ripple = true,
72
+ tooltip = true,
73
+ className,
74
+ }: WorkflowStatusIconProps) {
75
+ const visual = getWorkflowStatusVisual(status);
76
+ const box = dotSizeClass[size] ?? 'size-14';
77
+
78
+ let glyph: ReactNode;
79
+ if (visual.kind === 'running') {
80
+ glyph =
81
+ jobMode === 'listening' ? (
82
+ <span
83
+ className={cn(
84
+ 'inline-flex shrink-0 items-center justify-center rounded-full bg-current',
85
+ box,
86
+ toneByVariant.info,
87
+ )}
88
+ >
89
+ <Icon
90
+ name="pulseLine"
91
+ size={Math.max(8, Math.round(size * 0.7))}
92
+ className="text-white"
93
+ />
94
+ </span>
95
+ ) : (
96
+ <Dot variant="info" ripple={ripple} className={box} />
97
+ );
98
+ } else if (visual.kind === 'pending') {
99
+ glyph = (
100
+ <span
101
+ className={cn('rounded-full bg-current', box, toneByVariant.neutral)}
102
+ style={{maskImage: PENDING_RING_MASK, WebkitMaskImage: PENDING_RING_MASK}}
103
+ />
104
+ );
105
+ } else {
106
+ const name = glyphByKind[visual.kind] ?? 'forbid2Fill';
107
+ glyph = (
108
+ <span
109
+ className={cn(
110
+ 'inline-flex shrink-0 items-center justify-center',
111
+ box,
112
+ toneByVariant[visual.dot],
113
+ (visual.kind === 'cancelled' || visual.kind === 'skipped') && 'opacity-70',
114
+ )}
115
+ >
116
+ <Icon name={name} size={Math.round(size * (glyphScale[name] ?? 1))} />
117
+ </span>
118
+ );
119
+ }
120
+
121
+ const indicator = (
122
+ <span role="img" aria-label={visual.label} className={cn('inline-flex shrink-0', className)}>
123
+ {glyph}
124
+ </span>
125
+ );
126
+
127
+ if (!tooltip) return indicator;
128
+
129
+ return (
130
+ <Tooltip>
131
+ <TooltipTrigger asChild>{indicator}</TooltipTrigger>
132
+ <TooltipContent>{visual.label}</TooltipContent>
133
+ </Tooltip>
134
+ );
135
+ }
@@ -0,0 +1,139 @@
1
+ import type {
2
+ WorkflowExecutionEventDto,
3
+ WorkflowRunJobExecutionDetailDto,
4
+ } from '@shipfox/api-workflows-dto';
5
+ import {type Duration, intervalToDuration} from 'date-fns';
6
+ import {type Step, toStep} from './step.js';
7
+
8
+ export type JobExecutionStatus = WorkflowRunJobExecutionDetailDto['status'];
9
+ export type JobExecutionTime =
10
+ | {state: 'fixed'; elapsed: Duration}
11
+ | {state: 'live'; fromIso: string};
12
+ export type JobExecutionDisplayDuration =
13
+ | ({kind: 'queue'} & JobExecutionTime)
14
+ | ({kind: 'run'} & JobExecutionTime);
15
+
16
+ interface JobExecutionFields {
17
+ id: string;
18
+ jobId: string;
19
+ sequence: number;
20
+ name: string;
21
+ status: JobExecutionStatus;
22
+ statusReason: string | null;
23
+ triggerEvents: WorkflowExecutionEventDto[];
24
+ queuedAt: string | null;
25
+ startedAt: string | null;
26
+ finishedAt: string | null;
27
+ timedOutAt: string | null;
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ steps: Step[];
31
+ }
32
+
33
+ export class JobExecution {
34
+ id!: string;
35
+ jobId!: string;
36
+ sequence!: number;
37
+ name!: string;
38
+ status!: JobExecutionStatus;
39
+ statusReason!: string | null;
40
+ triggerEvents!: WorkflowExecutionEventDto[];
41
+ queuedAt!: string | null;
42
+ startedAt!: string | null;
43
+ finishedAt!: string | null;
44
+ timedOutAt!: string | null;
45
+ createdAt!: string;
46
+ updatedAt!: string;
47
+ steps!: Step[];
48
+
49
+ constructor(fields: JobExecutionFields) {
50
+ Object.assign(this, fields);
51
+ }
52
+
53
+ get queueTime(): JobExecutionTime | null {
54
+ return jobExecutionQueueTimeFromTimestamps(this);
55
+ }
56
+
57
+ get runTime(): JobExecutionTime | null {
58
+ return jobExecutionRunTimeFromTimestamps(this);
59
+ }
60
+
61
+ get displayDuration(): JobExecutionDisplayDuration | null {
62
+ return jobExecutionDisplayDurationFromTimestamps(this);
63
+ }
64
+ }
65
+
66
+ export function toJobExecution(dto: WorkflowRunJobExecutionDetailDto): JobExecution {
67
+ return new JobExecution({
68
+ id: dto.id,
69
+ jobId: dto.job_id,
70
+ sequence: dto.sequence,
71
+ name: dto.name,
72
+ status: dto.status,
73
+ statusReason: dto.status_reason,
74
+ triggerEvents: dto.trigger_events ?? [],
75
+ queuedAt: dto.queued_at ?? null,
76
+ startedAt: dto.started_at ?? null,
77
+ finishedAt: dto.finished_at ?? null,
78
+ timedOutAt: dto.timed_out_at ?? null,
79
+ createdAt: dto.created_at,
80
+ updatedAt: dto.updated_at,
81
+ steps: dto.steps.map(toStep),
82
+ });
83
+ }
84
+
85
+ export function jobExecutionQueueTimeFromTimestamps({
86
+ queuedAt,
87
+ startedAt,
88
+ finishedAt,
89
+ }: {
90
+ queuedAt: string | null;
91
+ startedAt: string | null;
92
+ finishedAt: string | null;
93
+ }): JobExecutionTime | null {
94
+ if (queuedAt === null) return null;
95
+ if (finishedAt !== null && startedAt === null) return null;
96
+ if (startedAt === null) return {state: 'live', fromIso: queuedAt};
97
+
98
+ const elapsed = durationBetween(queuedAt, startedAt);
99
+ return elapsed === null ? null : {state: 'fixed', elapsed};
100
+ }
101
+
102
+ export function jobExecutionRunTimeFromTimestamps({
103
+ startedAt,
104
+ finishedAt,
105
+ }: {
106
+ startedAt: string | null;
107
+ finishedAt: string | null;
108
+ }): JobExecutionTime | null {
109
+ if (startedAt === null) return null;
110
+ if (finishedAt === null) return {state: 'live', fromIso: startedAt};
111
+
112
+ const elapsed = durationBetween(startedAt, finishedAt);
113
+ return elapsed === null ? null : {state: 'fixed', elapsed};
114
+ }
115
+
116
+ export function jobExecutionDisplayDurationFromTimestamps(timestamps: {
117
+ queuedAt: string | null;
118
+ startedAt: string | null;
119
+ finishedAt: string | null;
120
+ }): JobExecutionDisplayDuration | null {
121
+ const runTime = jobExecutionRunTimeFromTimestamps(timestamps);
122
+ if (runTime !== null) return {kind: 'run', ...runTime};
123
+
124
+ const queueTime = jobExecutionQueueTimeFromTimestamps(timestamps);
125
+ if (queueTime !== null) return {kind: 'queue', ...queueTime};
126
+
127
+ return null;
128
+ }
129
+
130
+ function durationBetween(from: string, to: string): Duration | null {
131
+ const start = new Date(from);
132
+ const end = new Date(to);
133
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return null;
134
+
135
+ return intervalToDuration({
136
+ start,
137
+ end: end < start ? start : end,
138
+ });
139
+ }
@@ -0,0 +1,149 @@
1
+ import type {
2
+ JobListeningDto,
3
+ JobModeDto,
4
+ JobStatusDto,
5
+ JobStatusReasonDto,
6
+ ListenerStatusDto,
7
+ ResolutionReasonDto,
8
+ WorkflowRunJobDetailDto,
9
+ } from '@shipfox/api-workflows-dto';
10
+ import {
11
+ type JobExecution,
12
+ type JobExecutionDisplayDuration,
13
+ toJobExecution,
14
+ } from './job-execution.js';
15
+
16
+ export type JobStatus = JobStatusDto;
17
+ export type JobMode = JobModeDto;
18
+ export type ListenerStatus = ListenerStatusDto;
19
+ export type ResolutionReason = ResolutionReasonDto;
20
+ export type JobStatusReason = JobStatusReasonDto;
21
+
22
+ export type JobDisplayDuration = JobExecutionDisplayDuration;
23
+
24
+ export const WORKFLOW_JOB_STATUSES = [
25
+ 'pending',
26
+ 'running',
27
+ 'succeeded',
28
+ 'failed',
29
+ 'cancelled',
30
+ 'skipped',
31
+ ] as const satisfies readonly JobStatus[];
32
+
33
+ export const TERMINAL_WORKFLOW_JOB_STATUSES = [
34
+ 'succeeded',
35
+ 'failed',
36
+ 'cancelled',
37
+ 'skipped',
38
+ ] as const satisfies readonly JobStatus[];
39
+
40
+ const TERMINAL_JOB_STATUS_SET = new Set<JobStatus>(TERMINAL_WORKFLOW_JOB_STATUSES);
41
+
42
+ interface JobFields {
43
+ id: string;
44
+ runAttemptId: string;
45
+ key: string;
46
+ name: string | null;
47
+ mode: JobMode;
48
+ status: JobStatus;
49
+ statusReason: JobStatusReason | null;
50
+ carriedOver: boolean;
51
+ listening: JobListeningDto | null;
52
+ listenerStatus: ListenerStatus;
53
+ resolutionReason: ResolutionReason | null;
54
+ dependencies: string[];
55
+ position: number;
56
+ createdAt: string;
57
+ updatedAt: string;
58
+ jobExecutions: JobExecution[];
59
+ }
60
+
61
+ export class Job {
62
+ id!: string;
63
+ runAttemptId!: string;
64
+ key!: string;
65
+ name!: string | null;
66
+ mode!: JobMode;
67
+ status!: JobStatus;
68
+ statusReason!: JobStatusReason | null;
69
+ carriedOver!: boolean;
70
+ listening!: JobListeningDto | null;
71
+ listenerStatus!: ListenerStatus;
72
+ resolutionReason!: ResolutionReason | null;
73
+ dependencies!: string[];
74
+ position!: number;
75
+ createdAt!: string;
76
+ updatedAt!: string;
77
+ jobExecutions!: JobExecution[];
78
+
79
+ constructor(fields: JobFields) {
80
+ Object.assign(this, fields);
81
+ }
82
+
83
+ get displayName(): string {
84
+ return this.name || this.key;
85
+ }
86
+
87
+ get displayDuration(): JobDisplayDuration | null {
88
+ if (this.mode === 'listening') return null;
89
+ if (this.jobExecutions.length !== 1) return null;
90
+ return this.jobExecutions[0]?.displayDuration ?? null;
91
+ }
92
+
93
+ get executionCountVisible(): boolean {
94
+ return (
95
+ this.jobExecutions.length > 0 && (this.mode === 'listening' || this.jobExecutions.length > 1)
96
+ );
97
+ }
98
+ }
99
+
100
+ export function isTerminalJobStatus(status: JobStatus): boolean {
101
+ return TERMINAL_JOB_STATUS_SET.has(status);
102
+ }
103
+
104
+ export function resolveJobExecution(
105
+ job: Job,
106
+ jobExecutionId: string | undefined,
107
+ ): JobExecution | undefined {
108
+ const selectedExecution = jobExecutionId
109
+ ? job.jobExecutions.find((jobExecution) => jobExecution.id === jobExecutionId)
110
+ : undefined;
111
+ if (selectedExecution) return selectedExecution;
112
+
113
+ return defaultJobExecution(job);
114
+ }
115
+
116
+ export function defaultJobExecution(job: Job): JobExecution | undefined {
117
+ const runningExecution = job.jobExecutions.find(
118
+ (jobExecution) => jobExecution.status === 'running',
119
+ );
120
+ if (runningExecution) return runningExecution;
121
+
122
+ return job.jobExecutions.reduce<JobExecution | undefined>((latest, jobExecution) => {
123
+ if (!latest) return jobExecution;
124
+ return jobExecution.sequence > latest.sequence ? jobExecution : latest;
125
+ }, undefined);
126
+ }
127
+
128
+ export function toJob(dto: WorkflowRunJobDetailDto): Job {
129
+ const jobExecutions = dto.job_executions.map(toJobExecution);
130
+
131
+ return new Job({
132
+ id: dto.id,
133
+ runAttemptId: dto.run_attempt_id,
134
+ key: dto.key,
135
+ name: dto.name,
136
+ mode: dto.mode,
137
+ status: dto.status,
138
+ statusReason: dto.status_reason,
139
+ carriedOver: dto.carried_over,
140
+ listening: dto.listening,
141
+ listenerStatus: dto.listener_status,
142
+ resolutionReason: dto.resolution_reason,
143
+ dependencies: dto.dependencies,
144
+ position: dto.position,
145
+ createdAt: dto.created_at,
146
+ updatedAt: dto.updated_at,
147
+ jobExecutions,
148
+ });
149
+ }
@@ -0,0 +1,89 @@
1
+ import type {StepAttemptDto, StepGateResultDto} from '@shipfox/api-workflows-dto';
2
+ import {type Duration, intervalToDuration} from 'date-fns';
3
+
4
+ export type StepGateResult = StepGateResultDto;
5
+ export type StepAttemptDisplayDuration =
6
+ | {state: 'fixed'; elapsed: Duration}
7
+ | {state: 'live'; fromIso: string};
8
+
9
+ interface StepAttemptFields {
10
+ id: string;
11
+ stepId: string;
12
+ jobExecutionId: string;
13
+ attempt: number;
14
+ executionOrder: number;
15
+ status: string;
16
+ exitCode: number | null;
17
+ output: Record<string, unknown> | null;
18
+ error: Record<string, unknown> | null;
19
+ gateResult: StepGateResult;
20
+ restartFeedback: string | null;
21
+ startedAt: string;
22
+ finishedAt: string | null;
23
+ }
24
+
25
+ export class StepAttempt {
26
+ id!: string;
27
+ stepId!: string;
28
+ jobExecutionId!: string;
29
+ attempt!: number;
30
+ executionOrder!: number;
31
+ status!: string;
32
+ exitCode!: number | null;
33
+ output!: Record<string, unknown> | null;
34
+ error!: Record<string, unknown> | null;
35
+ gateResult!: StepGateResult;
36
+ restartFeedback!: string | null;
37
+ startedAt!: string;
38
+ finishedAt!: string | null;
39
+
40
+ constructor(fields: StepAttemptFields) {
41
+ Object.assign(this, fields);
42
+ }
43
+
44
+ get displayDuration(): StepAttemptDisplayDuration | null {
45
+ return stepAttemptDisplayDurationFromTimestamps(this);
46
+ }
47
+ }
48
+
49
+ export function toStepAttempt(dto: StepAttemptDto, jobExecutionId: string): StepAttempt {
50
+ return new StepAttempt({
51
+ id: dto.id,
52
+ stepId: dto.step_id,
53
+ jobExecutionId,
54
+ attempt: dto.attempt,
55
+ executionOrder: dto.execution_order,
56
+ status: dto.status,
57
+ exitCode: dto.exit_code ?? null,
58
+ output: dto.output ?? null,
59
+ error: dto.error ?? null,
60
+ gateResult: dto.gate_result ?? null,
61
+ restartFeedback: dto.restart_feedback ?? null,
62
+ startedAt: dto.started_at,
63
+ finishedAt: dto.finished_at ?? null,
64
+ });
65
+ }
66
+
67
+ export function stepAttemptDisplayDurationFromTimestamps({
68
+ startedAt,
69
+ finishedAt,
70
+ }: {
71
+ startedAt: string;
72
+ finishedAt: string | null;
73
+ }): StepAttemptDisplayDuration | null {
74
+ if (finishedAt === null) return {state: 'live', fromIso: startedAt};
75
+
76
+ const elapsed = durationBetween(startedAt, finishedAt);
77
+ return elapsed === null ? null : {state: 'fixed', elapsed};
78
+ }
79
+
80
+ function durationBetween(from: string, to: string): Duration | null {
81
+ const start = new Date(from);
82
+ const end = new Date(to);
83
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return null;
84
+
85
+ return intervalToDuration({
86
+ start,
87
+ end: end < start ? start : end,
88
+ });
89
+ }