@shipfox/client-workflows 22.0.2 → 23.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 (34) hide show
  1. package/.storybook/preview.tsx +2 -2
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +43 -0
  4. package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -1
  5. package/dist/components/job-detail/agent-config-failure-callout.js +15 -0
  6. package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -1
  7. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -1
  8. package/dist/components/job-detail/job-empty-states.js +8 -0
  9. package/dist/components/job-detail/job-empty-states.js.map +1 -1
  10. package/dist/core/entities/job.d.ts +2 -2
  11. package/dist/core/entities/job.d.ts.map +1 -1
  12. package/dist/core/entities/job.js.map +1 -1
  13. package/dist/core/entities/step.d.ts +2 -0
  14. package/dist/core/entities/step.d.ts.map +1 -1
  15. package/dist/core/entities/step.js.map +1 -1
  16. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  17. package/dist/hooks/api/workflow-run-mapper.js +6 -0
  18. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  19. package/dist/pages/project-workflows-page.d.ts.map +1 -1
  20. package/dist/pages/project-workflows-page.js +74 -24
  21. package/dist/pages/project-workflows-page.js.map +1 -1
  22. package/dist/tsconfig.test.tsbuildinfo +1 -1
  23. package/package.json +9 -9
  24. package/src/components/job-detail/agent-config-failure-callout.stories.tsx +29 -0
  25. package/src/components/job-detail/agent-config-failure-callout.tsx +18 -0
  26. package/src/components/job-detail/job-empty-states.test.ts +21 -0
  27. package/src/components/job-detail/job-empty-states.tsx +10 -0
  28. package/src/core/entities/job.ts +2 -2
  29. package/src/core/entities/step.ts +2 -0
  30. package/src/hooks/api/workflow-run-mapper.ts +4 -0
  31. package/src/pages/project-workflows-page.test.tsx +85 -5
  32. package/src/pages/project-workflows-page.tsx +71 -17
  33. package/test/fixtures/workflow-run.ts +2 -0
  34. 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": "22.0.2",
4
+ "version": "23.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -28,15 +28,15 @@
28
28
  "@swc/helpers": "^0.5.17",
29
29
  "date-fns": "^4.1.0",
30
30
  "@shipfox/annotations-dto": "12.3.0",
31
- "@shipfox/api-definitions-dto": "12.3.0",
32
- "@shipfox/api-workflows-dto": "13.0.0",
31
+ "@shipfox/api-definitions-dto": "14.0.0",
32
+ "@shipfox/api-workflows-dto": "14.0.0",
33
33
  "@shipfox/client-api": "6.0.1",
34
- "@shipfox/client-logs": "22.0.2",
35
- "@shipfox/client-projects": "22.0.2",
36
- "@shipfox/client-shell": "22.0.2",
37
- "@shipfox/client-triggers": "22.0.2",
38
- "@shipfox/client-ui": "22.0.2",
39
- "@shipfox/react-ui": "2.1.1"
34
+ "@shipfox/client-logs": "22.0.3",
35
+ "@shipfox/client-projects": "23.0.0",
36
+ "@shipfox/client-shell": "23.0.0",
37
+ "@shipfox/client-triggers": "23.0.0",
38
+ "@shipfox/client-ui": "22.0.3",
39
+ "@shipfox/react-ui": "2.2.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@tanstack/react-query": "^5.101.0",
@@ -83,6 +83,19 @@ const errorCases: Array<{
83
83
  category: 'user',
84
84
  },
85
85
  },
86
+ {
87
+ label: 'Managed-only policy',
88
+ error: {
89
+ message: 'This instance only supports provider `shipfox`.',
90
+ code: 'workspace-providers-disabled',
91
+ managedProviderId: 'shipfox',
92
+ exitCode: null,
93
+ signal: undefined,
94
+ reason: 'agent_config_invalid',
95
+ agentConfigIssue: 'provider_unsupported',
96
+ category: 'user',
97
+ },
98
+ },
86
99
  ];
87
100
 
88
101
  export const Playground: Story = {};
@@ -113,6 +126,22 @@ export const TestProviderUnsupported: Story = {
113
126
  play: assertCallout('Choose a supported model provider', false),
114
127
  };
115
128
 
129
+ export const TestManagedOnlyPolicy: Story = {
130
+ args: {
131
+ error: {
132
+ message: 'This instance only supports provider `shipfox`.',
133
+ code: 'workspace-providers-disabled',
134
+ managedProviderId: 'shipfox',
135
+ exitCode: null,
136
+ signal: undefined,
137
+ reason: 'agent_config_invalid',
138
+ agentConfigIssue: 'provider_unsupported',
139
+ category: 'user',
140
+ },
141
+ },
142
+ play: assertCallout('Use shipfox for this instance', false),
143
+ };
144
+
116
145
  function makeError(agentConfigIssue: AgentConfigIssueValue): StepError {
117
146
  return {
118
147
  message: 'Agent configuration is invalid',
@@ -9,6 +9,8 @@ import {Button} from '@shipfox/react-ui/button';
9
9
  import {Link} from '@tanstack/react-router';
10
10
  import type {AgentStepConfig, StepError} from '#core/workflow-run.js';
11
11
 
12
+ const MANAGED_ONLY_PROVIDER_CODE = 'workspace-providers-disabled';
13
+
12
14
  export function AgentConfigFailureCallout({
13
15
  workspaceSlug,
14
16
  config,
@@ -47,6 +49,15 @@ function agentConfigFailureCopy(
47
49
  config: AgentStepConfig | null,
48
50
  error: StepError | null,
49
51
  ): {title: string; description: string; showProviderCta: boolean} {
52
+ const managedOnlyProvider = managedOnlyProviderFromError(error);
53
+ if (managedOnlyProvider) {
54
+ return {
55
+ title: `Use ${managedOnlyProvider} for this instance`,
56
+ description: `This instance only supports provider \`${managedOnlyProvider}\`. Update this step to use \`${managedOnlyProvider}\`, or remove its provider field to use the managed default.`,
57
+ showProviderCta: false,
58
+ };
59
+ }
60
+
50
61
  const provider = configValue(config?.provider, 'the selected provider');
51
62
  const model = configValue(config?.model, 'the selected model');
52
63
 
@@ -92,6 +103,13 @@ function agentConfigFailureCopy(
92
103
  }
93
104
  }
94
105
 
106
+ function managedOnlyProviderFromError(error: StepError | null): string | undefined {
107
+ if (error?.code !== MANAGED_ONLY_PROVIDER_CODE && error?.managedProviderId === undefined) {
108
+ return undefined;
109
+ }
110
+ return error.managedProviderId ?? 'the managed provider';
111
+ }
112
+
95
113
  function configValue(value: string | null | undefined, fallback: string): string {
96
114
  return value ?? fallback;
97
115
  }
@@ -1,3 +1,4 @@
1
+ import type {Step} from '#core/workflow-run.js';
1
2
  import {
2
3
  workflowJob,
3
4
  workflowJobExecutionDto,
@@ -9,6 +10,7 @@ import {
9
10
  jobSucceededSummary,
10
11
  outputFailureDescriptionForExecution,
11
12
  skippedJobDescription,
13
+ toSelectedAttemptError,
12
14
  } from './job-empty-states.js';
13
15
 
14
16
  describe('jobSucceededSummary', () => {
@@ -32,6 +34,25 @@ describe('jobSucceededSummary', () => {
32
34
  });
33
35
  });
34
36
 
37
+ describe('toSelectedAttemptError', () => {
38
+ test('preserves managed-provider metadata from a historical attempt', () => {
39
+ const error = toSelectedAttemptError({type: 'agent'} as Step, {
40
+ message: 'This instance only supports provider `shipfox`.',
41
+ code: 'workspace-providers-disabled',
42
+ managedProviderId: 'shipfox',
43
+ reason: 'agent_config_invalid',
44
+ agentConfigIssue: 'provider_unsupported',
45
+ });
46
+
47
+ expect(error).toMatchObject({
48
+ code: 'workspace-providers-disabled',
49
+ managedProviderId: 'shipfox',
50
+ reason: 'agent_config_invalid',
51
+ agentConfigIssue: 'provider_unsupported',
52
+ });
53
+ });
54
+ });
55
+
35
56
  describe('skippedJobDescription', () => {
36
57
  test('explains when materialized output exceeds the configured size limit', () => {
37
58
  expect(skippedJobDescription('output_too_large')).toBe(
@@ -292,8 +292,18 @@ export function toSelectedAttemptError(
292
292
 
293
293
  if (resolvedReason === undefined) return null;
294
294
 
295
+ const code = typeof error.code === 'string' ? error.code : undefined;
296
+ const managedProviderId =
297
+ typeof error.managedProviderId === 'string'
298
+ ? error.managedProviderId
299
+ : typeof error.managed_provider_id === 'string'
300
+ ? error.managed_provider_id
301
+ : undefined;
302
+
295
303
  return {
296
304
  message: typeof error.message === 'string' ? error.message : '',
305
+ ...(code === undefined ? {} : {code}),
306
+ ...(managedProviderId === undefined ? {} : {managedProviderId}),
297
307
  exitCode: exitCode === null || typeof exitCode === 'number' ? exitCode : null,
298
308
  signal: typeof error.signal === 'string' ? error.signal : undefined,
299
309
  reason: resolvedReason,
@@ -28,13 +28,13 @@ export type JobStatusReason =
28
28
  export interface JobListening {
29
29
  on: Array<{
30
30
  source: string;
31
- event: string;
31
+ event?: string | undefined;
32
32
  inputs?: Record<string, unknown> | undefined;
33
33
  filter?: string | undefined;
34
34
  }>;
35
35
  until: Array<{
36
36
  source: string;
37
- event: string;
37
+ event?: string | undefined;
38
38
  inputs?: Record<string, unknown> | undefined;
39
39
  filter?: string | undefined;
40
40
  }> | null;
@@ -51,6 +51,8 @@ export interface StepSourceLocation {
51
51
 
52
52
  export interface StepError {
53
53
  message: string;
54
+ code?: string | undefined;
55
+ managedProviderId?: string | undefined;
54
56
  exitCode: number | null;
55
57
  signal: string | undefined;
56
58
  reason: StepErrorReason | undefined;
@@ -217,6 +217,10 @@ export function toStep(dto: WorkflowRunStepDetailDto): Step {
217
217
  error: dto.error
218
218
  ? {
219
219
  message: dto.error.message,
220
+ ...(dto.error.code === undefined ? {} : {code: dto.error.code}),
221
+ ...(dto.error.managed_provider_id === undefined
222
+ ? {}
223
+ : {managedProviderId: dto.error.managed_provider_id}),
220
224
  exitCode: dto.error.exit_code ?? null,
221
225
  signal: dto.error.signal,
222
226
  reason: dto.error.reason,
@@ -1,5 +1,5 @@
1
1
  import {configureApiClient} from '@shipfox/client-api';
2
- import {fireEvent, screen, waitFor} from '@testing-library/react';
2
+ import {fireEvent, screen, waitFor, within} from '@testing-library/react';
3
3
  import {
4
4
  jsonResponse,
5
5
  PROJECT_TEST_WID,
@@ -69,11 +69,12 @@ describe('ProjectWorkflowsPage', () => {
69
69
  finished_at: null,
70
70
  last_error_code: 'no-workflow-files',
71
71
  last_error_message: 'No workflow files found',
72
- warnings: [
72
+ diagnostics: [
73
73
  {
74
74
  code: 're-evaluating-command',
75
75
  message: 'Workflow data is re-executed as shell code.',
76
76
  path: 'jobs.build.steps.0.run',
77
+ severity: 'warning',
77
78
  },
78
79
  ],
79
80
  },
@@ -104,16 +105,20 @@ describe('ProjectWorkflowsPage', () => {
104
105
  finished_at: '2026-05-07T01:00:00.000Z',
105
106
  last_error_code: null,
106
107
  last_error_message: null,
107
- warnings: [
108
+ diagnostics: [
108
109
  {
109
110
  code: 're-evaluating-command',
110
111
  message: 'Workflow data is re-executed as shell code.',
111
112
  path: 'jobs.build.steps.0.run',
113
+ file_path: '.shipfox/workflows/warning.yml',
114
+ severity: 'warning',
112
115
  },
113
116
  {
114
117
  code: 're-evaluating-command',
115
118
  message: 'Workflow data is re-executed as shell code.',
116
119
  path: 'jobs.build.steps.0.run',
120
+ file_path: '.shipfox/workflows/warning.yml',
121
+ severity: 'warning',
117
122
  },
118
123
  ],
119
124
  },
@@ -125,12 +130,87 @@ describe('ProjectWorkflowsPage', () => {
125
130
  renderWorkflowsPage();
126
131
 
127
132
  expect(await screen.findByText('Workflow definition warnings')).toBeInTheDocument();
133
+ // Both warnings group under the shared workflow file; the file label renders once.
128
134
  expect(screen.getAllByText('Workflow data is re-executed as shell code.')).toHaveLength(2);
129
- expect(screen.getAllByRole('listitem')).toHaveLength(2);
135
+ expect(screen.getAllByText('.shipfox/workflows/warning.yml')).toHaveLength(1);
130
136
  expect(screen.getAllByText('jobs.build.steps.0.run')).toHaveLength(2);
137
+ expect(screen.getByRole('status')).toBeInTheDocument();
131
138
  expect(screen.queryByText('Workflow sync failed')).not.toBeInTheDocument();
132
139
  });
133
140
 
141
+ test('shows errors and warnings together with severity labels, grouped by file path', async () => {
142
+ configureApiClient({
143
+ fetchImpl: createProjectDetailFetch({
144
+ definitions: jsonResponse(
145
+ definitionsDto({
146
+ sync: {
147
+ ref: 'main',
148
+ status: 'succeeded',
149
+ last_sync_at: '2026-05-07T01:00:00.000Z',
150
+ started_at: '2026-05-07T00:59:55.000Z',
151
+ finished_at: '2026-05-07T01:00:00.000Z',
152
+ last_error_code: null,
153
+ last_error_message: null,
154
+ diagnostics: [
155
+ {
156
+ code: 'invalid-trigger-event',
157
+ message: 'Trigger event is never delivered by this source.',
158
+ path: 'triggers.on_deploy',
159
+ file_path: '.shipfox/workflows/deploy.yml',
160
+ severity: 'error',
161
+ },
162
+ {
163
+ code: 'unknown-trigger-source',
164
+ message: 'No connection matches this source slug.',
165
+ path: 'triggers.on_deploy',
166
+ file_path: '.shipfox/workflows/deploy.yml',
167
+ severity: 'warning',
168
+ },
169
+ {
170
+ code: 're-evaluating-command',
171
+ message: 'Workflow data is re-executed as shell code.',
172
+ path: 'jobs.build.steps.0.run',
173
+ file_path: '.shipfox/workflows/build.yml',
174
+ severity: 'warning',
175
+ },
176
+ ],
177
+ },
178
+ }),
179
+ ),
180
+ }),
181
+ });
182
+
183
+ renderWorkflowsPage();
184
+
185
+ expect(await screen.findByText('Workflow definition diagnostics')).toBeInTheDocument();
186
+ const diagnosticsCallout = screen.getByRole('status');
187
+ expect(diagnosticsCallout).toBeInTheDocument();
188
+ // Two groups: the deploy workflow file and the build workflow file.
189
+ expect(within(diagnosticsCallout).getAllByText('.shipfox/workflows/deploy.yml')).toHaveLength(
190
+ 1,
191
+ );
192
+ expect(within(diagnosticsCallout).getAllByText('.shipfox/workflows/build.yml')).toHaveLength(1);
193
+ expect(within(diagnosticsCallout).getAllByText('triggers.on_deploy')).toHaveLength(2);
194
+ expect(within(diagnosticsCallout).getAllByText('jobs.build.steps.0.run')).toHaveLength(1);
195
+ const errorRow = within(diagnosticsCallout).getByText(
196
+ 'Trigger event is never delivered by this source.',
197
+ );
198
+ const warningRow = within(diagnosticsCallout).getByText(
199
+ 'No connection matches this source slug.',
200
+ );
201
+ expect(errorRow).toHaveClass('text-tag-error-text');
202
+ expect(warningRow).not.toHaveClass('text-tag-error-text');
203
+ expect(
204
+ within(diagnosticsCallout).getByText('Error:', {selector: 'span.font-medium'}),
205
+ ).toBeInTheDocument();
206
+ expect(
207
+ within(diagnosticsCallout).getAllByText('Warning:', {selector: 'span.font-medium'}),
208
+ ).toHaveLength(2);
209
+ expect(
210
+ within(diagnosticsCallout).getByText('Workflow data is re-executed as shell code.'),
211
+ ).toBeInTheDocument();
212
+ });
213
+
134
214
  test('opens and closes the definition drawer by clicking the row', async () => {
135
215
  configureApiClient({fetchImpl: createProjectDetailFetch()});
136
216
 
@@ -301,7 +381,7 @@ function baseDefinitionsDto() {
301
381
  finished_at: '2026-05-07T01:00:00.000Z',
302
382
  last_error_code: null,
303
383
  last_error_message: null,
304
- warnings: [],
384
+ diagnostics: [],
305
385
  },
306
386
  };
307
387
  }
@@ -1,6 +1,7 @@
1
1
  import {ApiError} from '@shipfox/client-api';
2
2
  import {
3
3
  type Definition,
4
+ type DefinitionSyncDiagnostic,
4
5
  type DefinitionSyncSummary,
5
6
  SourceStrip,
6
7
  useDefinitionsInfiniteQuery,
@@ -101,7 +102,7 @@ function ProjectWorkflowsPageInner({projectId}: {projectId: string}) {
101
102
  />
102
103
 
103
104
  <WorkflowSyncAlert sync={sync} />
104
- <WorkflowSyncWarnings sync={sync} />
105
+ <WorkflowSyncDiagnostics sync={sync} />
105
106
 
106
107
  <WorkflowDefinitionsList
107
108
  definitions={definitions}
@@ -382,30 +383,54 @@ function WorkflowSyncAlert({sync}: {sync: DefinitionSyncSummary | null | undefin
382
383
  );
383
384
  }
384
385
 
385
- function WorkflowSyncWarnings({sync}: {sync: DefinitionSyncSummary | null | undefined}) {
386
- if (sync?.status !== 'succeeded' || sync.warnings.length === 0) return null;
386
+ function WorkflowSyncDiagnostics({sync}: {sync: DefinitionSyncSummary | null | undefined}) {
387
+ if (sync?.status !== 'succeeded' || sync.diagnostics.length === 0) return null;
387
388
 
388
- const seenKeys = new Map<string, number>();
389
- const warningItems = sync.warnings.map((warning) => {
390
- const baseKey = `${warning.code}-${warning.path ?? 'workflow'}-${warning.message}`;
391
- const occurrence = seenKeys.get(baseKey) ?? 0;
392
- seenKeys.set(baseKey, occurrence + 1);
393
- return {key: `${baseKey}-${occurrence}`, warning};
394
- });
389
+ const hasErrors = sync.diagnostics.some((diagnostic) => diagnostic.severity === 'error');
390
+ const hasWarnings = sync.diagnostics.some((diagnostic) => diagnostic.severity === 'warning');
391
+ const groups = groupDiagnosticsByFilePath(sync.diagnostics);
392
+ const title =
393
+ hasErrors && hasWarnings
394
+ ? 'Workflow definition diagnostics'
395
+ : hasErrors
396
+ ? 'Workflow definition errors'
397
+ : 'Workflow definition warnings';
395
398
 
396
399
  return (
397
- <Callout role="status" type="warning">
400
+ <Callout role="status" type={hasErrors ? 'error' : 'warning'}>
398
401
  <div className="flex flex-col gap-inline">
399
402
  <Text size="sm" bold>
400
- Workflow definition warnings
403
+ {title}
401
404
  </Text>
402
405
  <ul className="flex flex-col gap-tight">
403
- {warningItems.map(({key, warning}) => (
404
- <li key={key}>
405
- <Text size="sm">{warning.message}</Text>
406
- {warning.path ? (
407
- <Code className="text-foreground-neutral-muted">{warning.path}</Code>
406
+ {groups.map((group) => (
407
+ <li key={group.key} className="flex flex-col gap-tight">
408
+ {group.filePath ? (
409
+ <Code className="text-foreground-neutral-muted">{group.filePath}</Code>
408
410
  ) : null}
411
+ <ul className="flex flex-col gap-tight">
412
+ {group.items.map(({key, diagnostic}) => {
413
+ const severityLabel = diagnostic.severity === 'error' ? 'Error' : 'Warning';
414
+
415
+ return (
416
+ <li key={key}>
417
+ {diagnostic.path ? (
418
+ <Code className="text-foreground-neutral-muted">{diagnostic.path}</Code>
419
+ ) : null}
420
+ <Text size="sm">
421
+ <span className="font-medium">{severityLabel}:</span>{' '}
422
+ <span
423
+ className={
424
+ diagnostic.severity === 'error' ? 'text-tag-error-text' : undefined
425
+ }
426
+ >
427
+ {diagnostic.message}
428
+ </span>
429
+ </Text>
430
+ </li>
431
+ );
432
+ })}
433
+ </ul>
409
434
  </li>
410
435
  ))}
411
436
  </ul>
@@ -414,6 +439,35 @@ function WorkflowSyncWarnings({sync}: {sync: DefinitionSyncSummary | null | unde
414
439
  );
415
440
  }
416
441
 
442
+ interface DiagnosticGroup {
443
+ key: string;
444
+ filePath: string | undefined;
445
+ items: {key: string; diagnostic: DefinitionSyncDiagnostic}[];
446
+ }
447
+
448
+ function groupDiagnosticsByFilePath(
449
+ diagnostics: readonly DefinitionSyncDiagnostic[],
450
+ ): DiagnosticGroup[] {
451
+ const groups: DiagnosticGroup[] = [];
452
+ const indexByFilePath = new Map<string, number>();
453
+ for (const diagnostic of diagnostics) {
454
+ const filePathKey = diagnostic.filePath ?? '';
455
+ const groupIndex = indexByFilePath.get(filePathKey);
456
+ if (groupIndex === undefined) {
457
+ indexByFilePath.set(filePathKey, groups.length);
458
+ groups.push({
459
+ key: `${filePathKey}-${groups.length}`,
460
+ filePath: diagnostic.filePath,
461
+ items: [{key: `${filePathKey}-0`, diagnostic}],
462
+ });
463
+ } else {
464
+ const group = groups[groupIndex];
465
+ if (group) group.items.push({key: `${filePathKey}-${group.items.length}`, diagnostic});
466
+ }
467
+ }
468
+ return groups;
469
+ }
470
+
417
471
  function DefinitionSheet({
418
472
  definition,
419
473
  onOpenChange,
@@ -66,6 +66,8 @@ export function workflowRunDto(
66
66
  name: 'deploy-web',
67
67
  workflow_name: 'deploy-web',
68
68
  status: 'running',
69
+ origin: 'synced',
70
+ dev_source: null,
69
71
  current_attempt: 1,
70
72
  latest_attempt: 1,
71
73
  trigger_provider: null,