@shipfox/client-workflows 17.0.0 → 18.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 (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-type.log +0 -1
  3. package/CHANGELOG.md +12 -0
  4. package/dist/components/job-detail/job-context-panel.d.ts.map +1 -1
  5. package/dist/components/job-detail/job-context-panel.js +5 -1
  6. package/dist/components/job-detail/job-context-panel.js.map +1 -1
  7. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
  8. package/dist/components/job-detail/job-detail-view.js +34 -27
  9. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  10. package/dist/components/job-detail/job-empty-states.d.ts +4 -0
  11. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -1
  12. package/dist/components/job-detail/job-empty-states.js +43 -3
  13. package/dist/components/job-detail/job-empty-states.js.map +1 -1
  14. package/dist/components/job-detail/step-troubleshooting.js +4 -0
  15. package/dist/components/job-detail/step-troubleshooting.js.map +1 -1
  16. package/dist/components/step-list/step-list-model.d.ts.map +1 -1
  17. package/dist/components/step-list/step-list-model.js +1 -0
  18. package/dist/components/step-list/step-list-model.js.map +1 -1
  19. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
  20. package/dist/components/workflow-run-view/run-workspace-nav.js +6 -5
  21. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  22. package/dist/core/entities/job-execution.d.ts +2 -0
  23. package/dist/core/entities/job-execution.d.ts.map +1 -1
  24. package/dist/core/entities/job-execution.js.map +1 -1
  25. package/dist/core/entities/job.d.ts +1 -1
  26. package/dist/core/entities/job.d.ts.map +1 -1
  27. package/dist/core/entities/job.js.map +1 -1
  28. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  29. package/dist/hooks/api/workflow-run-mapper.js +1 -0
  30. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  31. package/dist/tsconfig.test.tsbuildinfo +1 -1
  32. package/package.json +2 -2
  33. package/src/components/job-detail/job-context-panel.tsx +4 -0
  34. package/src/components/job-detail/job-detail-view.tsx +37 -33
  35. package/src/components/job-detail/job-empty-states.test.ts +105 -1
  36. package/src/components/job-detail/job-empty-states.tsx +56 -2
  37. package/src/components/job-detail/step-troubleshooting.tsx +4 -0
  38. package/src/components/step-list/step-list-model.ts +1 -0
  39. package/src/components/workflow-run-view/run-workspace-nav.tsx +13 -6
  40. package/src/core/entities/job-execution.ts +2 -0
  41. package/src/core/entities/job.ts +3 -1
  42. package/src/hooks/api/workflow-run-mapper.ts +1 -0
  43. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-workflows",
3
3
  "license": "MIT",
4
- "version": "17.0.0",
4
+ "version": "18.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -29,7 +29,7 @@
29
29
  "date-fns": "^4.1.0",
30
30
  "@shipfox/annotations-dto": "12.3.0",
31
31
  "@shipfox/api-definitions-dto": "12.3.0",
32
- "@shipfox/api-workflows-dto": "12.3.0",
32
+ "@shipfox/api-workflows-dto": "12.5.0",
33
33
  "@shipfox/client-api": "6.0.1",
34
34
  "@shipfox/client-logs": "17.0.0",
35
35
  "@shipfox/client-projects": "17.0.0",
@@ -27,6 +27,7 @@ export function JobContextPanel({job, execution}: {job: Job; execution: JobExecu
27
27
  execution.triggerEvents.length ||
28
28
  job.success ||
29
29
  statusReason ||
30
+ execution.statusReasonMessage ||
30
31
  trace?.length ||
31
32
  hasTiming,
32
33
  );
@@ -114,6 +115,9 @@ function JobContextSheet({
114
115
  {statusReason ? (
115
116
  <ContextValue label="Status reason" value={humanize(statusReason)} />
116
117
  ) : null}
118
+ {execution.statusReasonMessage ? (
119
+ <ContextValue label="Failure details" value={execution.statusReasonMessage} />
120
+ ) : null}
117
121
  {execution.triggerEvents.length ? (
118
122
  <JsonCode
119
123
  title={`Trigger events (${execution.triggerEvents.length})`}
@@ -33,6 +33,7 @@ import {
33
33
  emptyStateForJob,
34
34
  emptyStateForMissingExecution,
35
35
  jobSucceededSummary,
36
+ MaterializedOutputFailureNotice,
36
37
  } from './job-empty-states.js';
37
38
  import {
38
39
  resolveWorkflowJobSelection,
@@ -258,39 +259,42 @@ export function JobDetailView({
258
259
  Logs
259
260
  </Text>
260
261
  {selectedJobExecution ? (
261
- <StepList
262
- job={job}
263
- jobExecution={selectedJobExecution}
264
- selectedAttemptId={selectedAttemptId}
265
- defaultSelectedAttemptId={landingSelection?.attemptId}
266
- onSelectedAttemptChange={selectAttempt}
267
- inspectorOpenAttemptId={inspectorOpenAttemptId}
268
- onInspectorOpenChange={onInspectorOpenChange}
269
- autoSelectActiveAttempt
270
- emptyState={emptyStateForJob(job, selectedJobExecution)}
271
- showHeader={false}
272
- className="rounded-none border-0 bg-transparent"
273
- renderExpandedStep={(context) => (
274
- <ExpandedStep context={context} pageScrollRef={pageScrollRef} />
275
- )}
276
- renderInspector={(entry) => (
277
- <StepInspectorSheet
278
- entry={entry}
279
- open
280
- onOpenChange={(open) => onInspectorOpenChange(open ? entry.id : null)}
281
- workspaceSlug={workspaceSlug}
282
- projectSlug={projectSlug}
283
- workflowRunId={run.id}
284
- runAttempt={run.runAttempt.attempt}
285
- jobId={job.id}
286
- annotationCount={annotationCountForStep(
287
- annotationSummaryQuery.data,
288
- entry.step.id,
289
- entry.attempt,
290
- )}
291
- />
292
- )}
293
- />
262
+ <>
263
+ <MaterializedOutputFailureNotice jobExecution={selectedJobExecution} />
264
+ <StepList
265
+ job={job}
266
+ jobExecution={selectedJobExecution}
267
+ selectedAttemptId={selectedAttemptId}
268
+ defaultSelectedAttemptId={landingSelection?.attemptId}
269
+ onSelectedAttemptChange={selectAttempt}
270
+ inspectorOpenAttemptId={inspectorOpenAttemptId}
271
+ onInspectorOpenChange={onInspectorOpenChange}
272
+ autoSelectActiveAttempt
273
+ emptyState={emptyStateForJob(job, selectedJobExecution)}
274
+ showHeader={false}
275
+ className="rounded-none border-0 bg-transparent"
276
+ renderExpandedStep={(context) => (
277
+ <ExpandedStep context={context} pageScrollRef={pageScrollRef} />
278
+ )}
279
+ renderInspector={(entry) => (
280
+ <StepInspectorSheet
281
+ entry={entry}
282
+ open
283
+ onOpenChange={(open) => onInspectorOpenChange(open ? entry.id : null)}
284
+ workspaceSlug={workspaceSlug}
285
+ projectSlug={projectSlug}
286
+ workflowRunId={run.id}
287
+ runAttempt={run.runAttempt.attempt}
288
+ jobId={job.id}
289
+ annotationCount={annotationCountForStep(
290
+ annotationSummaryQuery.data,
291
+ entry.step.id,
292
+ entry.attempt,
293
+ )}
294
+ />
295
+ )}
296
+ />
297
+ </>
294
298
  ) : (
295
299
  <EmptyStateForMissingExecution job={job} />
296
300
  )}
@@ -3,7 +3,13 @@ import {
3
3
  workflowJobExecutionDto,
4
4
  workflowStepDto,
5
5
  } from '#test/fixtures/workflow-run.js';
6
- import {jobSucceededSummary} from './job-empty-states.js';
6
+ import {
7
+ emptyStateForJob,
8
+ emptyStateForMissingExecution,
9
+ jobSucceededSummary,
10
+ outputFailureDescriptionForExecution,
11
+ skippedJobDescription,
12
+ } from './job-empty-states.js';
7
13
 
8
14
  describe('jobSucceededSummary', () => {
9
15
  test('counts only succeeded steps when skipped steps are present', () => {
@@ -25,3 +31,101 @@ describe('jobSucceededSummary', () => {
25
31
  expect(jobSucceededSummary(job, execution)).toBe('1 step succeeded');
26
32
  });
27
33
  });
34
+
35
+ describe('skippedJobDescription', () => {
36
+ test('explains when materialized output exceeds the configured size limit', () => {
37
+ expect(skippedJobDescription('output_too_large')).toBe(
38
+ 'The materialized job output exceeded its configured size limit.',
39
+ );
40
+ });
41
+ });
42
+
43
+ describe('materialized output failure descriptions', () => {
44
+ const fallback =
45
+ 'A materialized job output could not be persisted: it exceeded a size or entry cap, contained a non-JSON-safe value, or referenced an unresolved value. Check the output mapping and values before re-running the workflow.';
46
+
47
+ test('uses the server-authored message for an execution with recorded steps', () => {
48
+ const job = workflowJob({
49
+ status: 'failed',
50
+ status_reason: 'output_invalid',
51
+ job_executions: [
52
+ workflowJobExecutionDto({
53
+ status: 'failed',
54
+ status_reason: 'output_invalid',
55
+ status_reason_message: 'Job output "payload" cannot be persisted as JSON: undefined.',
56
+ steps: [workflowStepDto({status: 'succeeded'})],
57
+ }),
58
+ ],
59
+ });
60
+ const execution = job.jobExecutions[0];
61
+ if (!execution) throw new Error('Expected a job execution');
62
+
63
+ expect(outputFailureDescriptionForExecution(execution)).toBe(
64
+ 'Job output "payload" cannot be persisted as JSON: undefined.',
65
+ );
66
+ });
67
+
68
+ test('keeps the generic fallback for old output-invalid rows without a message', () => {
69
+ const job = workflowJob({
70
+ status: 'failed',
71
+ status_reason: 'output_invalid',
72
+ job_executions: [
73
+ workflowJobExecutionDto({
74
+ status: 'failed',
75
+ status_reason: 'output_invalid',
76
+ steps: [workflowStepDto({status: 'succeeded'})],
77
+ }),
78
+ ],
79
+ });
80
+ const execution = job.jobExecutions[0];
81
+ if (!execution) throw new Error('Expected a job execution');
82
+
83
+ expect(outputFailureDescriptionForExecution(execution)).toBe(fallback);
84
+ });
85
+
86
+ test('uses the message in the empty state when output materialization failed before steps', () => {
87
+ const job = workflowJob({
88
+ status: 'failed',
89
+ status_reason: 'output_invalid',
90
+ job_executions: [
91
+ workflowJobExecutionDto({
92
+ status: 'failed',
93
+ status_reason: 'output_invalid',
94
+ status_reason_message: 'Job outputs cannot define more than 10 entries (found 11)',
95
+ steps: [],
96
+ }),
97
+ ],
98
+ });
99
+ const execution = job.jobExecutions[0];
100
+ if (!execution) throw new Error('Expected a job execution');
101
+
102
+ expect(emptyStateForJob(job, execution)).toMatchObject({
103
+ description: 'Job outputs cannot define more than 10 entries (found 11)',
104
+ });
105
+ });
106
+
107
+ test('keeps output failures scoped to the selected execution', () => {
108
+ const job = workflowJob({
109
+ status: 'failed',
110
+ status_reason: 'output_invalid',
111
+ job_executions: [
112
+ workflowJobExecutionDto({
113
+ status: 'succeeded',
114
+ status_reason: null,
115
+ steps: [workflowStepDto({status: 'succeeded'})],
116
+ }),
117
+ ],
118
+ });
119
+ const execution = job.jobExecutions[0];
120
+ if (!execution) throw new Error('Expected a job execution');
121
+
122
+ expect(outputFailureDescriptionForExecution(execution)).toBeUndefined();
123
+ expect(
124
+ emptyStateForMissingExecution(
125
+ workflowJob({status: 'failed', status_reason: 'output_invalid'}),
126
+ ),
127
+ ).toMatchObject({
128
+ description: fallback,
129
+ });
130
+ });
131
+ });
@@ -1,3 +1,4 @@
1
+ import {Callout, CalloutContent, CalloutDescription, CalloutTitle} from '@shipfox/react-ui/callout';
1
2
  import {EmptyState} from '@shipfox/react-ui/empty-state';
2
3
  import type {Job, JobExecution, Step, StepError} from '#core/workflow-run.js';
3
4
  import {
@@ -9,6 +10,44 @@ import {
9
10
  import type {StepListEmptyState} from '../step-list/index.js';
10
11
  import {formatJobExecutionTime} from './job-execution-time-text.js';
11
12
 
13
+ const MATERIALIZED_OUTPUT_FAILURE_DESCRIPTION =
14
+ 'A materialized job output could not be persisted: it exceeded a size or entry cap, contained a non-JSON-safe value, or referenced an unresolved value. Check the output mapping and values before re-running the workflow.';
15
+ const OUTPUT_TOO_LARGE_FAILURE_DESCRIPTION =
16
+ 'The materialized job output exceeded its configured size limit. Review the failure details before re-running the workflow.';
17
+
18
+ export function outputFailureDescriptionForExecution(
19
+ jobExecution: JobExecution,
20
+ ): string | undefined {
21
+ if (jobExecution.steps.length === 0) return undefined;
22
+
23
+ if (jobExecution.statusReason === 'output_invalid') {
24
+ return jobExecution.statusReasonMessage || MATERIALIZED_OUTPUT_FAILURE_DESCRIPTION;
25
+ }
26
+ if (jobExecution.statusReason === 'output_too_large') {
27
+ return jobExecution.statusReasonMessage || OUTPUT_TOO_LARGE_FAILURE_DESCRIPTION;
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ export function MaterializedOutputFailureNotice({jobExecution}: {jobExecution: JobExecution}) {
33
+ const description = outputFailureDescriptionForExecution(jobExecution);
34
+ if (!description) return null;
35
+
36
+ return (
37
+ <Callout
38
+ role="alert"
39
+ type="error"
40
+ variant="secondary"
41
+ className="border-b border-border-neutral-base px-row py-row"
42
+ >
43
+ <CalloutContent>
44
+ <CalloutTitle>Job output could not be persisted</CalloutTitle>
45
+ <CalloutDescription>{description}</CalloutDescription>
46
+ </CalloutContent>
47
+ </Callout>
48
+ );
49
+ }
50
+
12
51
  export function emptyStateForJob(
13
52
  job: Job,
14
53
  jobExecution: JobExecution,
@@ -64,7 +103,11 @@ export function emptyStateForJob(
64
103
  if (displayStatus === 'failed') {
65
104
  return {
66
105
  title: 'Job failed before its first step started',
67
- description: preStepFailureDescription(jobExecution.statusReason ?? job.statusReason, runner),
106
+ description: preStepFailureDescription(
107
+ jobExecution.statusReason ?? job.statusReason,
108
+ runner,
109
+ jobExecution.statusReasonMessage,
110
+ ),
68
111
  status: displayStatus,
69
112
  };
70
113
  }
@@ -159,14 +202,21 @@ export function skippedJobDescription(reason: Job['statusReason']): string {
159
202
  case 'run_cancelled':
160
203
  case 'timed_out':
161
204
  case 'runner_lost':
205
+ case 'output_invalid':
162
206
  case 'step_failed':
163
207
  case 'unknown':
164
208
  case null:
165
209
  return 'This job did not start.';
210
+ case 'output_too_large':
211
+ return 'The materialized job output exceeded its configured size limit.';
166
212
  }
167
213
  }
168
214
 
169
- function preStepFailureDescription(reason: string | null, runner: string[] | null = null): string {
215
+ function preStepFailureDescription(
216
+ reason: string | null,
217
+ runner: string[] | null = null,
218
+ statusReasonMessage: string | null = null,
219
+ ): string {
170
220
  const runnerCopy = runner?.length ? ` Required runner labels: ${runner.join(', ')}.` : '';
171
221
 
172
222
  switch (reason) {
@@ -179,6 +229,10 @@ function preStepFailureDescription(reason: string | null, runner: string[] | nul
179
229
  return 'The execution ended while the run was being cancelled.';
180
230
  case 'step_failed':
181
231
  return `The execution failed before step details were recorded.${runnerCopy} Review run annotations before re-running the workflow.`;
232
+ case 'output_too_large':
233
+ return statusReasonMessage || OUTPUT_TOO_LARGE_FAILURE_DESCRIPTION;
234
+ case 'output_invalid':
235
+ return statusReasonMessage || MATERIALIZED_OUTPUT_FAILURE_DESCRIPTION;
182
236
  case 'condition_errored':
183
237
  return 'The job condition could not be evaluated. Review run annotations before re-running the workflow.';
184
238
  case 'dependency_not_completed':
@@ -442,6 +442,8 @@ function failureTitle(reason: string | JobStatusReason): string {
442
442
  return 'Agent harness was unavailable';
443
443
  case 'runner_lost':
444
444
  return 'Runner stopped responding';
445
+ case 'output_too_large':
446
+ return 'Job output exceeded its size limit';
445
447
  case 'timed_out':
446
448
  return 'Step timed out';
447
449
  case 'step_failed':
@@ -489,6 +491,8 @@ function failureDescription(reason: string | JobStatusReason): string {
489
491
  return 'The runner could not start the agent harness.';
490
492
  case 'runner_lost':
491
493
  return 'The runner stopped responding before the step completed.';
494
+ case 'output_too_large':
495
+ return 'The materialized job output exceeded its configured size limit.';
492
496
  case 'timed_out':
493
497
  return 'The step exceeded its configured time limit.';
494
498
  case 'dependency_not_completed':
@@ -77,6 +77,7 @@ export function emptyJobExecutionForJob(job: Job): JobExecution {
77
77
  name: job.name ?? job.key,
78
78
  status: job.status === 'skipped' ? 'cancelled' : job.status,
79
79
  statusReason: job.statusReason,
80
+ statusReasonMessage: null,
80
81
  runner: job.runner,
81
82
  outputs: job.outputs,
82
83
  triggerEvents: [],
@@ -53,8 +53,12 @@ export function RunWorkspaceNav({
53
53
 
54
54
  return (
55
55
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
56
- <aside className="w-full shrink-0 border-b border-border-neutral-base bg-background-neutral-background min-[768px]:w-240 min-[768px]:border-b-0 min-[768px]:border-r">
57
- <Collapsible open={mobileOpen} onOpenChange={setMobileOpen}>
56
+ <aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base bg-background-neutral-background min-[768px]:w-240 min-[768px]:border-b-0 min-[768px]:border-r">
57
+ <Collapsible
58
+ open={mobileOpen}
59
+ onOpenChange={setMobileOpen}
60
+ className="flex min-h-0 flex-col min-[768px]:flex-1"
61
+ >
58
62
  <CollapsibleTrigger asChild>
59
63
  <button
60
64
  type="button"
@@ -82,7 +86,7 @@ export function RunWorkspaceNav({
82
86
  </CollapsibleTrigger>
83
87
  <div
84
88
  className={cn(
85
- 'max-h-[50vh] overflow-y-auto p-tight min-[768px]:block min-[768px]:max-h-none min-[768px]:p-[12px]',
89
+ 'flex min-h-0 max-h-[50vh] flex-col overflow-y-auto p-tight scrollbar min-[768px]:min-h-0 min-[768px]:max-h-none min-[768px]:flex-1 min-[768px]:flex min-[768px]:overflow-hidden min-[768px]:p-[12px]',
86
90
  !mobileOpen && 'hidden',
87
91
  )}
88
92
  >
@@ -136,7 +140,7 @@ function RunWorkspaceNavContent({
136
140
  }, [currentJobId, mobileOpen]);
137
141
 
138
142
  return (
139
- <nav aria-label="Run workspace" className="flex min-w-0 flex-col gap-group">
143
+ <nav aria-label="Run workspace" className="flex min-h-0 min-w-0 flex-1 flex-col gap-group">
140
144
  <ul>
141
145
  <li>
142
146
  <RunSectionLink
@@ -151,7 +155,10 @@ function RunWorkspaceNavContent({
151
155
  </li>
152
156
  </ul>
153
157
 
154
- <section aria-labelledby="run-workspace-jobs-heading" className="min-w-0">
158
+ <section
159
+ aria-labelledby="run-workspace-jobs-heading"
160
+ className="flex min-h-0 min-w-0 flex-1 flex-col"
161
+ >
155
162
  <div className="flex items-center justify-between gap-inline px-tight pb-[6px]">
156
163
  <Text
157
164
  as="h2"
@@ -166,7 +173,7 @@ function RunWorkspaceNavContent({
166
173
  {jobs.length}
167
174
  </Text>
168
175
  </div>
169
- <ol className="max-h-[320px] overflow-y-auto">
176
+ <ol className="min-h-0 flex-1 overflow-y-auto scrollbar">
170
177
  {jobs.map((job) => {
171
178
  const current = job.id === currentJobId;
172
179
  const execution = defaultJobExecution(job);
@@ -29,6 +29,7 @@ interface JobExecutionFields {
29
29
  name: string;
30
30
  status: JobExecutionStatus;
31
31
  statusReason: string | null;
32
+ statusReasonMessage: string | null;
32
33
  runner: string[] | null;
33
34
  outputs: Record<string, unknown> | null;
34
35
  triggerEvents: WorkflowExecutionEvent[];
@@ -49,6 +50,7 @@ export class JobExecution {
49
50
  name!: string;
50
51
  status!: JobExecutionStatus;
51
52
  statusReason!: string | null;
53
+ statusReasonMessage!: string | null;
52
54
  runner!: string[] | null;
53
55
  outputs!: Record<string, unknown> | null;
54
56
  triggerEvents!: WorkflowExecutionEvent[];
@@ -20,8 +20,10 @@ export type JobStatusReason =
20
20
  | 'run_cancelled'
21
21
  | 'timed_out'
22
22
  | 'runner_lost'
23
+ | 'output_too_large'
23
24
  | 'step_failed'
24
- | 'unknown';
25
+ | 'unknown'
26
+ | 'output_invalid';
25
27
  export interface JobListening {
26
28
  on: Array<{
27
29
  source: string;
@@ -165,6 +165,7 @@ export function toJobExecution(dto: WorkflowRunJobExecutionDetailDto): JobExecut
165
165
  name: dto.name,
166
166
  status: dto.status,
167
167
  statusReason: dto.status_reason,
168
+ statusReasonMessage: dto.status_reason_message ?? null,
168
169
  runner: dto.runner ?? null,
169
170
  outputs: dto.outputs ?? null,
170
171
  triggerEvents: (dto.trigger_events ?? []).map(toWorkflowExecutionEvent),