@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
@@ -0,0 +1,248 @@
1
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
2
+ import type {Job, JobExecution, Step, StepError} from '#core/workflow-run.js';
3
+ import {
4
+ AGENT_CONFIG_ISSUES,
5
+ deriveJobDisplayStatus,
6
+ deriveJobExecutionDisplayStatus,
7
+ STEP_ERROR_REASONS,
8
+ } from '#core/workflow-run.js';
9
+ import type {StepListEmptyState} from '../step-list/index.js';
10
+ import {formatJobExecutionTime} from './job-execution-time-text.js';
11
+
12
+ export function emptyStateForJob(
13
+ job: Job,
14
+ jobExecution: JobExecution,
15
+ ): StepListEmptyState | undefined {
16
+ if (job.carriedOver) {
17
+ return {
18
+ title: 'Carried over from a previous attempt',
19
+ description: 'This job did not execute in this run.',
20
+ status: 'succeeded',
21
+ };
22
+ }
23
+
24
+ const displayStatus =
25
+ job.mode === 'listening'
26
+ ? deriveJobExecutionDisplayStatus(jobExecution)
27
+ : deriveJobDisplayStatus(job);
28
+
29
+ if (jobExecution.status === 'running' && displayStatus === 'pending') {
30
+ return {
31
+ title: 'Runner preparing job',
32
+ description: 'A runner is assigned. Steps will appear here when work begins.',
33
+ status: displayStatus,
34
+ };
35
+ }
36
+
37
+ if (displayStatus === 'pending') {
38
+ return {
39
+ title: 'Waiting for this job to start',
40
+ description: 'Steps will appear here once the job starts.',
41
+ status: displayStatus,
42
+ };
43
+ }
44
+
45
+ if (displayStatus === 'running') {
46
+ return {
47
+ title: 'Waiting for the first step',
48
+ description: 'The first running step will appear here shortly.',
49
+ status: displayStatus,
50
+ };
51
+ }
52
+
53
+ if (displayStatus === 'cancelled') {
54
+ return {
55
+ title: 'Cancelled before start',
56
+ description: 'This job was cancelled before any step started.',
57
+ status: displayStatus,
58
+ };
59
+ }
60
+
61
+ if (displayStatus === 'failed') {
62
+ return {
63
+ title: 'Job failed before its first step started',
64
+ description: preStepFailureDescription(jobExecution.statusReason ?? job.statusReason),
65
+ status: displayStatus,
66
+ };
67
+ }
68
+
69
+ if (displayStatus === 'succeeded') {
70
+ return {
71
+ title: 'Completed without recorded steps',
72
+ description: `Execution #${jobExecution.sequence} succeeded before any step output was recorded.`,
73
+ status: displayStatus,
74
+ };
75
+ }
76
+
77
+ return undefined;
78
+ }
79
+
80
+ export function emptyStateForMissingExecution(job: Job): StepListEmptyState {
81
+ if (job.carriedOver) {
82
+ return {
83
+ title: 'Carried over from a previous attempt',
84
+ description: 'This job did not execute in this run.',
85
+ status: 'succeeded',
86
+ };
87
+ }
88
+
89
+ if (job.mode === 'listening' && job.listenerStatus === 'listening') {
90
+ return {
91
+ title: 'Waiting for trigger events',
92
+ description: 'Matching trigger events will create job executions here.',
93
+ status: deriveJobDisplayStatus(job),
94
+ };
95
+ }
96
+
97
+ if (job.mode === 'listening' && job.listenerStatus === 'resolved') {
98
+ return {
99
+ title: 'Listener resolved without executions',
100
+ description: 'No matching trigger event created a job execution before the listener stopped.',
101
+ status: job.status,
102
+ };
103
+ }
104
+
105
+ if (job.status === 'pending') {
106
+ return {
107
+ title: 'Waiting for this job to start',
108
+ description: 'No execution has been created for this job yet.',
109
+ status: 'pending',
110
+ };
111
+ }
112
+
113
+ if (job.status === 'skipped') {
114
+ return {
115
+ title: 'This job was skipped',
116
+ description: skippedJobDescription(job.statusReason),
117
+ status: 'skipped',
118
+ };
119
+ }
120
+
121
+ if (job.status === 'cancelled') {
122
+ return {
123
+ title: 'Cancelled before start',
124
+ description: 'This job was cancelled before an execution was created.',
125
+ status: 'cancelled',
126
+ };
127
+ }
128
+
129
+ return {
130
+ title: 'Execution details unavailable',
131
+ description: 'This job finished, but no job execution record is available.',
132
+ status: job.status,
133
+ };
134
+ }
135
+
136
+ export function skippedJobDescription(reason: Job['statusReason']): string {
137
+ switch (reason) {
138
+ case 'dependency_not_completed':
139
+ return 'A required job did not complete, so this job was skipped.';
140
+ case 'default_gate_rejected':
141
+ return 'A required job did not succeed, so this job was skipped.';
142
+ case 'condition_false':
143
+ case 'condition_rejected':
144
+ return 'The job condition did not match, so this job was skipped.';
145
+ case 'condition_errored':
146
+ return 'The job condition could not be evaluated, so this job was skipped.';
147
+ case 'user_cancelled':
148
+ case 'run_cancelled':
149
+ case 'timed_out':
150
+ case 'runner_lost':
151
+ case 'step_failed':
152
+ case 'unknown':
153
+ case null:
154
+ return 'This job did not start.';
155
+ }
156
+ }
157
+
158
+ function preStepFailureDescription(reason: string | null): string {
159
+ switch (reason) {
160
+ case 'runner_lost':
161
+ return 'The runner stopped responding before work began. Check runner availability before re-running the workflow.';
162
+ case 'timed_out':
163
+ return 'The job timed out before work began. Check runner capacity and timeout configuration before re-running the workflow.';
164
+ case 'user_cancelled':
165
+ case 'run_cancelled':
166
+ return 'The execution ended while the run was being cancelled.';
167
+ case 'step_failed':
168
+ return 'The execution failed before step details were recorded. Review run annotations before re-running the workflow.';
169
+ case 'condition_errored':
170
+ return 'The job condition could not be evaluated. Review run annotations before re-running the workflow.';
171
+ case 'dependency_not_completed':
172
+ case 'default_gate_rejected':
173
+ return 'A required job did not complete successfully. Resolve that failure before re-running the workflow.';
174
+ case 'condition_false':
175
+ case 'condition_rejected':
176
+ return 'The job condition did not match, so no work started.';
177
+ case 'unknown':
178
+ case null:
179
+ return 'Shipfox did not record a failure reason. Re-run only after checking the runner and workflow configuration.';
180
+ default:
181
+ return `The execution ended because ${humanizeFailureReason(reason)}. Resolve that condition before re-running the workflow.`;
182
+ }
183
+ }
184
+
185
+ function humanizeFailureReason(reason: string): string {
186
+ return reason.replaceAll('_', ' ').replaceAll('-', ' ');
187
+ }
188
+
189
+ export function CarriedOverStepPanel() {
190
+ return (
191
+ <EmptyState
192
+ className="min-h-120 rounded-8 border border-border-neutral-base bg-background-components-base px-16 py-20"
193
+ icon="componentLine"
194
+ title="Carried over from a previous attempt"
195
+ description="Not executed in this run."
196
+ variant="compact"
197
+ />
198
+ );
199
+ }
200
+
201
+ export function toSelectedAttemptError(
202
+ step: Step,
203
+ error: Record<string, unknown> | null,
204
+ ): StepError | null {
205
+ if (error === null) return null;
206
+
207
+ const rawReason = error.reason;
208
+ const parsedReason =
209
+ typeof rawReason === 'string' &&
210
+ STEP_ERROR_REASONS.has(rawReason as StepError['reason'] & string)
211
+ ? (rawReason as NonNullable<StepError['reason']>)
212
+ : undefined;
213
+ const rawAgentConfigIssue = error.agentConfigIssue ?? error.agent_config_issue;
214
+ const agentConfigIssue =
215
+ typeof rawAgentConfigIssue === 'string' &&
216
+ AGENT_CONFIG_ISSUES.has(rawAgentConfigIssue as NonNullable<StepError['agentConfigIssue']>)
217
+ ? (rawAgentConfigIssue as NonNullable<StepError['agentConfigIssue']>)
218
+ : undefined;
219
+ const exitCode = error.exitCode ?? error.exit_code;
220
+ const resolvedReason = parsedReason
221
+ ? parsedReason
222
+ : agentConfigIssue
223
+ ? 'agent_config_invalid'
224
+ : undefined;
225
+
226
+ if (resolvedReason === undefined) return null;
227
+
228
+ return {
229
+ message: typeof error.message === 'string' ? error.message : '',
230
+ exitCode: exitCode === null || typeof exitCode === 'number' ? exitCode : null,
231
+ signal: typeof error.signal === 'string' ? error.signal : undefined,
232
+ reason: resolvedReason,
233
+ agentConfigIssue,
234
+ category: step.type === 'setup' || step.type === 'checkout' ? 'setup' : 'user',
235
+ };
236
+ }
237
+
238
+ export function isAgentConfigFailure(step: Step, error: StepError | null): boolean {
239
+ return step.type === 'agent' && error?.reason === 'agent_config_invalid';
240
+ }
241
+
242
+ export function jobSucceededSummary(job: Job, execution: JobExecution): string | undefined {
243
+ if (job.carriedOver || execution.status !== 'succeeded') return undefined;
244
+ const stepCount = execution.steps.filter((step) => step.status === 'succeeded').length;
245
+ if (stepCount === 0) return undefined;
246
+ const duration = execution.displayDuration;
247
+ return `${stepCount} step${stepCount === 1 ? '' : 's'} succeeded${duration ? ` in ${formatJobExecutionTime(duration)}` : ''}`;
248
+ }
@@ -0,0 +1,188 @@
1
+ import {
2
+ workflowJobDto,
3
+ workflowJobExecutionDto,
4
+ workflowRunDetail,
5
+ workflowStepAttemptDto,
6
+ workflowStepDto,
7
+ } from '#test/fixtures/workflow-run.js';
8
+ import {resolveWorkflowJobSelection, workflowJobLandingSelection} from './job-selection.js';
9
+
10
+ describe('resolveWorkflowJobSelection', () => {
11
+ test('resolves a deep-linked step and exact attempt within the job', () => {
12
+ const step = workflowStepDto({
13
+ id: 'step-build',
14
+ attempts: [
15
+ workflowStepAttemptDto({id: 'attempt-1', step_id: 'step-build', attempt: 1}),
16
+ workflowStepAttemptDto({id: 'attempt-2', step_id: 'step-build', attempt: 2}),
17
+ ],
18
+ });
19
+ const job = workflowRunDetail({
20
+ jobs: [workflowJobDto({id: 'job-build', steps: [step]})],
21
+ }).jobs[0];
22
+ if (!job) throw new Error('Expected a job');
23
+
24
+ const resolved = resolveWorkflowJobSelection({
25
+ job,
26
+ selection: {
27
+ stepId: 'step-build',
28
+ stepAttemptId: 'attempt-2',
29
+ jobExecutionId: 'missing-execution',
30
+ },
31
+ });
32
+
33
+ expect(resolved.step?.id).toBe('step-build');
34
+ expect(resolved.attempt?.id).toBe('attempt-2');
35
+ expect(resolved.selectedAttemptId).toBe('attempt-2');
36
+ });
37
+
38
+ test('drops a step id belonging to another job and uses the default execution', () => {
39
+ const otherJobStep = workflowStepDto({id: 'step-other'});
40
+ const job = workflowRunDetail({
41
+ jobs: [
42
+ workflowJobDto({
43
+ id: 'job-build',
44
+ job_executions: [
45
+ workflowJobExecutionDto({id: 'execution-old', job_id: 'job-build', sequence: 1}),
46
+ workflowJobExecutionDto({id: 'execution-build', job_id: 'job-build', sequence: 2}),
47
+ ],
48
+ }),
49
+ workflowJobDto({id: 'job-other', steps: [otherJobStep], position: 1}),
50
+ ],
51
+ }).jobs[0];
52
+ if (!job) throw new Error('Expected a job');
53
+
54
+ const resolved = resolveWorkflowJobSelection({
55
+ job,
56
+ selection: {stepId: 'step-other', jobExecutionId: 'execution-old'},
57
+ });
58
+
59
+ expect(resolved.step).toBeUndefined();
60
+ expect(resolved.jobExecution?.id).toBe('execution-build');
61
+ });
62
+
63
+ test('a step wins over a valid execution id from another execution', () => {
64
+ const step = workflowStepDto({id: 'step-build', job_execution_id: 'execution-two'});
65
+ const job = workflowRunDetail({
66
+ jobs: [
67
+ workflowJobDto({
68
+ id: 'job-build',
69
+ job_executions: [
70
+ workflowJobExecutionDto({id: 'execution-one', job_id: 'job-build', sequence: 1}),
71
+ workflowJobExecutionDto({
72
+ id: 'execution-two',
73
+ job_id: 'job-build',
74
+ sequence: 2,
75
+ steps: [step],
76
+ }),
77
+ ],
78
+ }),
79
+ ],
80
+ }).jobs[0];
81
+ if (!job) throw new Error('Expected a job');
82
+
83
+ const resolved = resolveWorkflowJobSelection({
84
+ job,
85
+ selection: {stepId: 'step-build', jobExecutionId: 'execution-one'},
86
+ });
87
+
88
+ expect(resolved.jobExecution?.id).toBe('execution-two');
89
+ });
90
+ });
91
+
92
+ describe('workflowJobLandingSelection', () => {
93
+ test('selects the running step', () => {
94
+ const job = workflowRunDetail({
95
+ jobs: [
96
+ workflowJobDto({
97
+ steps: [
98
+ workflowStepDto({
99
+ id: 'step-finished',
100
+ status: 'succeeded',
101
+ attempts: [workflowStepAttemptDto({status: 'succeeded'})],
102
+ }),
103
+ workflowStepDto({
104
+ id: 'step-running',
105
+ position: 1,
106
+ status: 'running',
107
+ attempts: [workflowStepAttemptDto({id: 'attempt-running', status: 'running'})],
108
+ }),
109
+ ],
110
+ }),
111
+ ],
112
+ }).jobs[0]?.jobExecutions[0];
113
+
114
+ expect(workflowJobLandingSelection(job)).toEqual({
115
+ stepId: 'step-running',
116
+ attemptId: 'attempt-running',
117
+ });
118
+ });
119
+
120
+ test('selects the first failed step when no step is running', () => {
121
+ const job = workflowRunDetail({
122
+ jobs: [
123
+ workflowJobDto({
124
+ steps: [
125
+ workflowStepDto({
126
+ id: 'step-failed-first',
127
+ status: 'failed',
128
+ attempts: [workflowStepAttemptDto({id: 'attempt-failed-first', status: 'failed'})],
129
+ }),
130
+ workflowStepDto({
131
+ id: 'step-failed-second',
132
+ position: 1,
133
+ status: 'failed',
134
+ attempts: [workflowStepAttemptDto({status: 'failed'})],
135
+ }),
136
+ ],
137
+ }),
138
+ ],
139
+ }).jobs[0]?.jobExecutions[0];
140
+
141
+ expect(workflowJobLandingSelection(job)).toEqual({
142
+ stepId: 'step-failed-first',
143
+ attemptId: 'attempt-failed-first',
144
+ });
145
+ });
146
+
147
+ test('selects the current attempt for the first failed step', () => {
148
+ const job = workflowRunDetail({
149
+ jobs: [
150
+ workflowJobDto({
151
+ steps: [
152
+ workflowStepDto({
153
+ id: 'step-failed',
154
+ status: 'failed',
155
+ current_attempt: 2,
156
+ attempts: [
157
+ workflowStepAttemptDto({id: 'attempt-old', attempt: 1, status: 'failed'}),
158
+ workflowStepAttemptDto({id: 'attempt-current', attempt: 2, status: 'failed'}),
159
+ ],
160
+ }),
161
+ ],
162
+ }),
163
+ ],
164
+ }).jobs[0]?.jobExecutions[0];
165
+
166
+ expect(workflowJobLandingSelection(job)).toEqual({
167
+ stepId: 'step-failed',
168
+ attemptId: 'attempt-current',
169
+ });
170
+ });
171
+
172
+ test('selects nothing when every step succeeded', () => {
173
+ const job = workflowRunDetail({
174
+ jobs: [
175
+ workflowJobDto({
176
+ steps: [
177
+ workflowStepDto({
178
+ status: 'succeeded',
179
+ attempts: [workflowStepAttemptDto({status: 'succeeded'})],
180
+ }),
181
+ ],
182
+ }),
183
+ ],
184
+ }).jobs[0]?.jobExecutions[0];
185
+
186
+ expect(workflowJobLandingSelection(job)).toBeUndefined();
187
+ });
188
+ });
@@ -0,0 +1,96 @@
1
+ import {
2
+ compareStepAttempts,
3
+ type Job,
4
+ type JobExecution,
5
+ resolveJobExecution,
6
+ resolveStepAttempt,
7
+ type Step,
8
+ type StepAttempt,
9
+ } from '#core/workflow-run.js';
10
+ import type {WorkflowJobSearch} from '#routes/inputs.js';
11
+
12
+ export interface ResolvedWorkflowJobSelection {
13
+ jobExecution: JobExecution | undefined;
14
+ step: Step | undefined;
15
+ attempt: StepAttempt | undefined;
16
+ selectedAttemptId: string | null;
17
+ }
18
+
19
+ export interface WorkflowJobLandingSelection {
20
+ stepId: string;
21
+ attemptId: string;
22
+ }
23
+
24
+ /**
25
+ * Resolves job-scoped URL state. A valid step is authoritative over an execution id because a
26
+ * step carries its owning execution. A step from another job is ignored and falls back to the
27
+ * job's default execution rather than following a mangled URL into a different job.
28
+ */
29
+ export function resolveWorkflowJobSelection({
30
+ job,
31
+ selection,
32
+ }: {
33
+ job: Job;
34
+ selection: WorkflowJobSearch;
35
+ }): ResolvedWorkflowJobSelection {
36
+ const step = selection.stepId ? findStep(job, selection.stepId) : undefined;
37
+ if (step) {
38
+ const attempt = resolveStepAttempt(step, selection.stepAttemptId);
39
+ const jobExecution = job.jobExecutions.find(
40
+ (candidate) => candidate.id === step.jobExecutionId,
41
+ );
42
+ return {
43
+ jobExecution: jobExecution ?? resolveJobExecution(job, undefined),
44
+ step,
45
+ attempt,
46
+ selectedAttemptId: attempt?.id ?? null,
47
+ };
48
+ }
49
+
50
+ return {
51
+ jobExecution: selection.stepId
52
+ ? resolveJobExecution(job, undefined)
53
+ : resolveJobExecution(job, selection.jobExecutionId),
54
+ step: undefined,
55
+ attempt: undefined,
56
+ selectedAttemptId: null,
57
+ };
58
+ }
59
+
60
+ /** Picks the running step, then the first failed step, for the first useful page state. */
61
+ export function workflowJobLandingSelection(
62
+ jobExecution: JobExecution | undefined,
63
+ ): WorkflowJobLandingSelection | undefined {
64
+ if (!jobExecution) return undefined;
65
+
66
+ const entries = orderedStepAttempts(jobExecution);
67
+ const running = [...entries].reverse().find((entry) => entry.attempt.status === 'running');
68
+ const failedStep = [...jobExecution.steps]
69
+ .sort(compareSteps)
70
+ .find((step) => step.status === 'failed');
71
+ const failedAttempt = failedStep ? resolveStepAttempt(failedStep, undefined) : undefined;
72
+ const failed =
73
+ failedStep && failedAttempt ? {step: failedStep, attempt: failedAttempt} : undefined;
74
+ const selected = running ?? failed;
75
+ return selected ? {stepId: selected.step.id, attemptId: selected.attempt.id} : undefined;
76
+ }
77
+
78
+ function findStep(job: Job, stepId: string): Step | undefined {
79
+ for (const execution of job.jobExecutions) {
80
+ const step = execution.steps.find((candidate) => candidate.id === stepId);
81
+ if (step) return step;
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ function orderedStepAttempts(jobExecution: JobExecution) {
87
+ return [...jobExecution.steps]
88
+ .sort((left, right) => left.position - right.position || left.id.localeCompare(right.id))
89
+ .flatMap((step) =>
90
+ [...step.attempts].sort(compareStepAttempts).map((attempt) => ({step, attempt})),
91
+ );
92
+ }
93
+
94
+ function compareSteps(left: Step, right: Step): number {
95
+ return left.position - right.position || left.id.localeCompare(right.id);
96
+ }
@@ -2,11 +2,12 @@ import {configureApiClient} from '@shipfox/client-api';
2
2
  import {type StepLogSnapshot, stepLogsQueryKeys} from '@shipfox/client-logs';
3
3
  import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
4
4
  import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
5
- import type {ReactNode} from 'react';
5
+ import {createRef, type ReactNode} from 'react';
6
6
  import {inlineLogBody, outputLine} from '#test/fixtures/logs.js';
7
7
  import {StepAttemptLogPanel} from './step-attempt-log-panel.js';
8
8
 
9
9
  const STEP_ID = '99999999-9999-4999-8999-999999999999';
10
+ const WAITING_FOR_OUTPUT_PATTERN = /Waiting for output ·/;
10
11
  type TestLogRecord = StepLogSnapshot['records'][number];
11
12
 
12
13
  function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
@@ -51,11 +52,20 @@ function renderPanel(
51
52
  const wrapper = ({children}: {children: ReactNode}) => (
52
53
  <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
53
54
  );
55
+ const pageScrollRef = createRef<HTMLDivElement>();
54
56
 
55
57
  return {
56
58
  queryClient,
57
59
  ...render(
58
- <StepAttemptLogPanel stepId={STEP_ID} attempt={1} attemptStatus="running" {...props} />,
60
+ <div ref={pageScrollRef}>
61
+ <StepAttemptLogPanel
62
+ stepId={STEP_ID}
63
+ attempt={1}
64
+ attemptStatus="running"
65
+ pageScrollRef={pageScrollRef}
66
+ {...props}
67
+ />
68
+ </div>,
59
69
  {wrapper},
60
70
  ),
61
71
  };
@@ -86,6 +96,19 @@ describe('StepAttemptLogPanel', () => {
86
96
  );
87
97
  });
88
98
 
99
+ test('shows elapsed waiting-for-output time for a running attempt', async () => {
100
+ vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-06-26T12:01:14.000Z'));
101
+ configureApiClient({
102
+ baseUrl: 'https://api.example.test',
103
+ fetchImpl: vi.fn(async () => jsonResponse({code: 'not-found'}, {status: 404})),
104
+ });
105
+
106
+ renderPanel({attemptStartedAt: '2026-06-26T11:59:00.000Z'});
107
+
108
+ expect(await screen.findByText(WAITING_FOR_OUTPUT_PATTERN)).toBeInTheDocument();
109
+ expect(screen.getByText('2m 14s')).toHaveAttribute('aria-hidden', 'true');
110
+ });
111
+
89
112
  test('waits for missing logs on a terminal attempt until the stream appears', async () => {
90
113
  const fetchImpl = vi
91
114
  .fn()
@@ -228,12 +251,12 @@ describe('StepAttemptLogPanel', () => {
228
251
  });
229
252
  const queryKey = stepLogsQueryKeys.detail(STEP_ID, 1);
230
253
  queryClient.setQueryData(queryKey, snapshot([outputRecord('first\n')]));
231
- renderPanel({attemptStatus: 'succeeded'}, {queryClient});
232
- const logRows = await screen.findByRole('log');
233
- Object.defineProperty(logRows, 'scrollHeight', {configurable: true, value: 600});
234
- Object.defineProperty(logRows, 'clientHeight', {configurable: true, value: 200});
235
- logRows.scrollTop = 320;
236
- fireEvent.scroll(logRows);
254
+ const {container} = renderPanel({attemptStatus: 'succeeded'}, {queryClient});
255
+ const pageScroll = container.firstElementChild as HTMLElement;
256
+ Object.defineProperty(pageScroll, 'scrollHeight', {configurable: true, value: 600});
257
+ Object.defineProperty(pageScroll, 'clientHeight', {configurable: true, value: 200});
258
+ pageScroll.scrollTop = 320;
259
+ fireEvent.scroll(pageScroll);
237
260
 
238
261
  act(() => {
239
262
  queryClient.setQueryData(
@@ -243,7 +266,7 @@ describe('StepAttemptLogPanel', () => {
243
266
  });
244
267
 
245
268
  await waitFor(() => expect(screen.getByText('second')).toBeInTheDocument());
246
- expect(logRows.scrollTop).toBe(320);
269
+ expect(pageScroll.scrollTop).toBe(320);
247
270
  });
248
271
 
249
272
  test('passes failure anchoring for failed attempts', async () => {