@shipfox/api-workflows 9.2.0 → 10.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 (41) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +57 -0
  3. package/dist/core/errors.d.ts +12 -0
  4. package/dist/core/errors.d.ts.map +1 -1
  5. package/dist/core/errors.js +18 -0
  6. package/dist/core/errors.js.map +1 -1
  7. package/dist/core/workspace-admission.d.ts +3 -0
  8. package/dist/core/workspace-admission.d.ts.map +1 -0
  9. package/dist/core/workspace-admission.js +32 -0
  10. package/dist/core/workspace-admission.js.map +1 -0
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +5 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/presentation/inter-module.d.ts +2 -0
  16. package/dist/presentation/inter-module.d.ts.map +1 -1
  17. package/dist/presentation/inter-module.js +40 -5
  18. package/dist/presentation/inter-module.js.map +1 -1
  19. package/dist/presentation/routes/index.d.ts +4 -1
  20. package/dist/presentation/routes/index.d.ts.map +1 -1
  21. package/dist/presentation/routes/index.js +1 -1
  22. package/dist/presentation/routes/index.js.map +1 -1
  23. package/dist/presentation/routes/rerun-run.d.ts +2 -1
  24. package/dist/presentation/routes/rerun-run.d.ts.map +1 -1
  25. package/dist/presentation/routes/rerun-run.js +22 -2
  26. package/dist/presentation/routes/rerun-run.js.map +1 -1
  27. package/dist/tsconfig.test.tsbuildinfo +1 -1
  28. package/package.json +17 -16
  29. package/src/core/errors.ts +21 -0
  30. package/src/core/workspace-admission.test.ts +64 -0
  31. package/src/core/workspace-admission.ts +43 -0
  32. package/src/index.ts +5 -0
  33. package/src/presentation/dto/step.test.ts +20 -0
  34. package/src/presentation/inter-module.test.ts +198 -1
  35. package/src/presentation/inter-module.ts +55 -3
  36. package/src/presentation/routes/index.test.ts +1 -0
  37. package/src/presentation/routes/index.ts +6 -2
  38. package/src/presentation/routes/rerun-run.test.ts +58 -1
  39. package/src/presentation/routes/rerun-run.ts +34 -2
  40. package/test/fixtures/runners-inter-module.ts +3 -0
  41. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-workflows",
3
3
  "license": "MIT",
4
- "version": "9.2.0",
4
+ "version": "10.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -22,28 +22,29 @@
22
22
  "@temporalio/workflow": "1.18.1",
23
23
  "drizzle-orm": "^0.45.2",
24
24
  "zod": "^4.4.3",
25
- "@shipfox/api-auth-context": "9.2.0",
26
- "@shipfox/api-auth-dto": "9.2.0",
27
- "@shipfox/api-agent-dto": "9.0.2",
28
- "@shipfox/annotations-dto": "9.0.2",
29
- "@shipfox/api-definitions-dto": "9.0.2",
30
- "@shipfox/api-projects-dto": "9.2.0",
25
+ "@shipfox/api-definitions-dto": "10.0.0",
26
+ "@shipfox/api-auth-context": "10.0.0",
31
27
  "@shipfox/api-integration-core-dto": "9.0.2",
32
- "@shipfox/api-runners-dto": "9.0.2",
28
+ "@shipfox/api-projects-dto": "10.0.0",
29
+ "@shipfox/api-runners-dto": "10.0.0",
33
30
  "@shipfox/api-secrets-dto": "9.0.2",
34
- "@shipfox/api-workflows-dto": "9.0.2",
31
+ "@shipfox/api-workflows-dto": "10.0.0",
32
+ "@shipfox/api-workspaces-dto": "10.0.0",
35
33
  "@shipfox/inter-module": "0.2.2",
36
- "@shipfox/expression": "1.1.5",
37
- "@shipfox/node-drizzle": "0.3.4",
34
+ "@shipfox/expression": "1.2.0",
38
35
  "@shipfox/node-error-monitoring": "0.3.0",
39
- "@shipfox/node-fastify": "0.3.3",
40
- "@shipfox/node-module": "1.0.2",
41
- "@shipfox/node-opentelemetry": "0.6.2",
36
+ "@shipfox/node-fastify": "0.4.0",
37
+ "@shipfox/node-module": "1.0.4",
38
+ "@shipfox/node-opentelemetry": "0.6.3",
42
39
  "@shipfox/node-outbox": "0.2.6",
43
40
  "@shipfox/node-postgres": "0.4.4",
44
- "@shipfox/node-temporal": "0.4.3",
41
+ "@shipfox/node-temporal": "0.4.4",
45
42
  "@shipfox/runner-labels": "0.1.3",
46
- "@shipfox/workflow-document": "2.1.3"
43
+ "@shipfox/workflow-document": "2.1.3",
44
+ "@shipfox/api-auth-dto": "10.0.0",
45
+ "@shipfox/api-agent-dto": "10.0.0",
46
+ "@shipfox/annotations-dto": "9.0.2",
47
+ "@shipfox/node-drizzle": "0.3.4"
47
48
  },
48
49
  "imports": {
49
50
  "#*": "./dist/*"
@@ -17,6 +17,27 @@ export class ProjectMismatchError extends Error {
17
17
  }
18
18
  }
19
19
 
20
+ export class WorkspaceSuspendedError extends Error {
21
+ constructor(readonly workspaceId: string) {
22
+ super(`Workspace is suspended: ${workspaceId}`);
23
+ this.name = 'WorkspaceSuspendedError';
24
+ }
25
+ }
26
+
27
+ export class WorkspaceDeletedError extends Error {
28
+ constructor(readonly workspaceId: string) {
29
+ super(`Workspace is deleted: ${workspaceId}`);
30
+ this.name = 'WorkspaceDeletedError';
31
+ }
32
+ }
33
+
34
+ export class WorkspaceNotFoundError extends Error {
35
+ constructor(readonly workspaceId: string) {
36
+ super(`Workspace not found: ${workspaceId}`);
37
+ this.name = 'WorkspaceNotFoundError';
38
+ }
39
+ }
40
+
20
41
  export class AgentConfigUnresolvableError extends Error {
21
42
  constructor(
22
43
  readonly definitionId: string,
@@ -0,0 +1,64 @@
1
+ import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
2
+ import {createInterModuleKnownError} from '@shipfox/inter-module';
3
+ import {WorkspaceDeletedError, WorkspaceNotFoundError, WorkspaceSuspendedError} from './errors.js';
4
+ import {assertWorkspaceAdmitsNewJobs} from './workspace-admission.js';
5
+
6
+ const workspaceId = '00000000-0000-4000-8000-000000000001';
7
+
8
+ describe('assertWorkspaceAdmitsNewJobs', () => {
9
+ test('allows active workspaces', async () => {
10
+ const getWorkspaceOperatingState = vi.fn().mockResolvedValue({status: 'active'});
11
+
12
+ await assertWorkspaceAdmitsNewJobs({getWorkspaceOperatingState}, workspaceId);
13
+
14
+ expect(getWorkspaceOperatingState).toHaveBeenCalledWith({workspaceId});
15
+ });
16
+
17
+ test.each([
18
+ ['suspended', WorkspaceSuspendedError],
19
+ ['deleted', WorkspaceDeletedError],
20
+ ] as const)('rejects %s workspaces', async (status, errorType) => {
21
+ const getWorkspaceOperatingState = vi.fn().mockResolvedValue({status});
22
+
23
+ await expect(
24
+ assertWorkspaceAdmitsNewJobs({getWorkspaceOperatingState}, workspaceId),
25
+ ).rejects.toBeInstanceOf(errorType);
26
+ });
27
+
28
+ test('translates a missing workspace from the Workspaces contract', async () => {
29
+ const error = createInterModuleKnownError(
30
+ workspacesInterModuleContract.methods.getWorkspaceOperatingState,
31
+ 'workspace-not-found',
32
+ {workspaceId},
33
+ );
34
+ const getWorkspaceOperatingState = vi.fn().mockRejectedValue(error);
35
+
36
+ const caught = await assertWorkspaceAdmitsNewJobs(
37
+ {getWorkspaceOperatingState},
38
+ workspaceId,
39
+ ).catch((error: unknown) => error);
40
+
41
+ expect(caught).toBeInstanceOf(WorkspaceNotFoundError);
42
+ expect(caught).toMatchObject({
43
+ workspaceId,
44
+ name: 'WorkspaceNotFoundError',
45
+ });
46
+ });
47
+
48
+ test('rethrows transient Workspaces failures', async () => {
49
+ const error = new Error('workspace service unavailable');
50
+ const getWorkspaceOperatingState = vi.fn().mockRejectedValue(error);
51
+
52
+ await expect(
53
+ assertWorkspaceAdmitsNewJobs({getWorkspaceOperatingState}, workspaceId),
54
+ ).rejects.toBe(error);
55
+ });
56
+
57
+ test('fails closed for an unknown workspace status', async () => {
58
+ const getWorkspaceOperatingState = vi.fn().mockResolvedValue({status: 'unknown'});
59
+
60
+ await expect(
61
+ assertWorkspaceAdmitsNewJobs({getWorkspaceOperatingState}, workspaceId),
62
+ ).rejects.toThrow('Unhandled workspace status');
63
+ });
64
+ });
@@ -0,0 +1,43 @@
1
+ import {
2
+ type WorkspacesInterModuleClient,
3
+ workspacesInterModuleContract,
4
+ } from '@shipfox/api-workspaces-dto/inter-module';
5
+ import {isInterModuleKnownError} from '@shipfox/inter-module';
6
+ import {WorkspaceDeletedError, WorkspaceNotFoundError, WorkspaceSuspendedError} from './errors.js';
7
+
8
+ export async function assertWorkspaceAdmitsNewJobs(
9
+ workspaces: Pick<WorkspacesInterModuleClient, 'getWorkspaceOperatingState'>,
10
+ workspaceId: string,
11
+ ): Promise<void> {
12
+ let state: Awaited<ReturnType<WorkspacesInterModuleClient['getWorkspaceOperatingState']>>;
13
+ try {
14
+ state = await workspaces.getWorkspaceOperatingState({workspaceId});
15
+ } catch (error) {
16
+ if (
17
+ isInterModuleKnownError(
18
+ workspacesInterModuleContract.methods.getWorkspaceOperatingState,
19
+ error,
20
+ ) &&
21
+ error.code === 'workspace-not-found'
22
+ ) {
23
+ throw new WorkspaceNotFoundError(error.details.workspaceId);
24
+ }
25
+ throw error;
26
+ }
27
+
28
+ const {status} = state;
29
+ switch (status) {
30
+ case 'active':
31
+ return;
32
+ case 'suspended':
33
+ throw new WorkspaceSuspendedError(workspaceId);
34
+ case 'deleted':
35
+ throw new WorkspaceDeletedError(workspaceId);
36
+ default:
37
+ return assertNever(status);
38
+ }
39
+ }
40
+
41
+ function assertNever(value: never): never {
42
+ throw new Error(`Unhandled workspace status: ${JSON.stringify(value)}`);
43
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  type WorkflowsEventMapDto,
23
23
  workflowsEventSchemas,
24
24
  } from '@shipfox/api-workflows-dto';
25
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
25
26
  import {type ShipfoxModule, subscriberFactory} from '@shipfox/node-module';
26
27
  import {db, migrationsPath, workflowsOutbox} from '#db/index.js';
27
28
  import {registerWorkflowsServiceMetrics} from '#metrics/index.js';
@@ -83,6 +84,7 @@ export function createWorkflowsModule({
83
84
  projects,
84
85
  runners,
85
86
  secrets,
87
+ workspaces,
86
88
  }: {
87
89
  agent: AgentInterModuleClient;
88
90
  definitions: DefinitionsInterModuleClient;
@@ -92,6 +94,7 @@ export function createWorkflowsModule({
92
94
  projects: ProjectsModuleClient;
93
95
  runners: RunnersInterModuleClient;
94
96
  secrets: SecretsInterModuleClient;
97
+ workspaces: WorkspacesInterModuleClient;
95
98
  }): ShipfoxModule {
96
99
  return {
97
100
  name: 'workflows',
@@ -104,6 +107,7 @@ export function createWorkflowsModule({
104
107
  projects,
105
108
  runners,
106
109
  secrets,
110
+ workspaces,
107
111
  }),
108
112
  metrics: registerWorkflowsServiceMetrics,
109
113
  publishers: [
@@ -135,6 +139,7 @@ export function createWorkflowsModule({
135
139
  projects,
136
140
  runners,
137
141
  secrets,
142
+ workspaces,
138
143
  }),
139
144
  ],
140
145
  };
@@ -49,6 +49,26 @@ describe('fromStepErrorDto', () => {
49
49
  });
50
50
  });
51
51
 
52
+ it('round-trips an agent harness availability failure without inventing an issue code', () => {
53
+ const persisted = fromStepErrorDto({
54
+ message: 'Pi extension setup failed: Unknown option: --mcp-config',
55
+ reason: 'agent_harness_unavailable',
56
+ });
57
+
58
+ expect(persisted).toEqual({
59
+ message: 'Pi extension setup failed: Unknown option: --mcp-config',
60
+ reason: 'agent_harness_unavailable',
61
+ });
62
+
63
+ const dto = toStepDto(step({type: 'agent', error: persisted}));
64
+
65
+ expect(dto.error).toEqual({
66
+ message: 'Pi extension setup failed: Unknown option: --mcp-config',
67
+ reason: 'agent_harness_unavailable',
68
+ category: 'user',
69
+ });
70
+ });
71
+
52
72
  it('persists config error field and source diagnostics', () => {
53
73
  const persisted = fromStepErrorDto({
54
74
  message: 'Could not resolve env.VERSION',
@@ -1,5 +1,6 @@
1
1
  import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
2
- import {isInterModuleKnownError} from '@shipfox/inter-module';
2
+ import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
3
+ import {createInterModuleKnownError, isInterModuleKnownError} from '@shipfox/inter-module';
3
4
  import {InvalidJobRunnerLabelsError} from '#core/errors.js';
4
5
  import {
5
6
  AgentConfigUnresolvableError,
@@ -11,12 +12,16 @@ import {
11
12
  import {createWorkflowsInterModulePresentation, toStartRunKnownError} from './inter-module.js';
12
13
 
13
14
  const mocks = vi.hoisted(() => ({
15
+ deliverEventToListener: vi.fn(),
14
16
  getJobScope: vi.fn(),
15
17
  getStepById: vi.fn(),
16
18
  getStepByIdForJobExecution: vi.fn(),
17
19
  }));
18
20
 
19
21
  vi.mock('#db/index.js', () => mocks);
22
+ vi.mock('#db/job-listener-events.js', () => ({
23
+ deliverEventToListener: mocks.deliverEventToListener,
24
+ }));
20
25
 
21
26
  const input = {
22
27
  workspaceId: '00000000-0000-4000-8000-000000000001',
@@ -37,6 +42,8 @@ describe('Workflows inter-module presentation', () => {
37
42
  mocks.getJobScope.mockReset();
38
43
  mocks.getStepById.mockReset();
39
44
  mocks.getStepByIdForJobExecution.mockReset();
45
+ mocks.deliverEventToListener.mockReset();
46
+ mocks.deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
40
47
  });
41
48
 
42
49
  it('returns only the resolved harness for Logs', async () => {
@@ -48,6 +55,7 @@ describe('Workflows inter-module presentation', () => {
48
55
  projects: {} as never,
49
56
  runners: {} as never,
50
57
  secrets: {} as never,
58
+ workspaces: {getWorkspaceOperatingState: vi.fn()} as never,
51
59
  });
52
60
 
53
61
  const result = await presentation.handlers.getStepLogContext(
@@ -103,6 +111,7 @@ describe('Workflows inter-module presentation', () => {
103
111
  projects: {} as never,
104
112
  runners: runners as never,
105
113
  secrets: {} as never,
114
+ workspaces: {getWorkspaceOperatingState: vi.fn()} as never,
106
115
  });
107
116
 
108
117
  const result = await presentation.handlers.getLeasedAgentToolContext(input, {
@@ -146,4 +155,192 @@ describe('Workflows inter-module presentation', () => {
146
155
  result.code,
147
156
  ).toBe(code);
148
157
  });
158
+
159
+ test('maps a missing workspace from the Workspace contract for start-run', async () => {
160
+ const getWorkspaceOperatingState = vi
161
+ .fn()
162
+ .mockRejectedValue(
163
+ createInterModuleKnownError(
164
+ workspacesInterModuleContract.methods.getWorkspaceOperatingState,
165
+ 'workspace-not-found',
166
+ {workspaceId: input.workspaceId},
167
+ ),
168
+ );
169
+ const presentation = createWorkflowsInterModulePresentation({
170
+ agent: {} as never,
171
+ definitions: {} as never,
172
+ integrations: {} as never,
173
+ projects: {} as never,
174
+ runners: {} as never,
175
+ secrets: {} as never,
176
+ workspaces: {getWorkspaceOperatingState} as never,
177
+ });
178
+
179
+ const error = await Promise.resolve(
180
+ presentation.handlers.startRunFromTrigger(input, {
181
+ signal: new AbortController().signal,
182
+ }),
183
+ ).catch((caught: unknown) => caught);
184
+
185
+ expect(
186
+ isInterModuleKnownError(workflowsInterModuleContract.methods.startRunFromTrigger, error) &&
187
+ error.code,
188
+ ).toBe('workspace-not-found');
189
+ });
190
+
191
+ test.each([
192
+ ['suspended', 'workspace-suspended'],
193
+ ['deleted', 'workspace-deleted'],
194
+ ] as const)('rejects new workflow runs for %s workspaces before loading the definition', async (status, code) => {
195
+ const getWorkspaceOperatingState = vi.fn().mockResolvedValue({status});
196
+ const presentation = createWorkflowsInterModulePresentation({
197
+ agent: {} as never,
198
+ definitions: {} as never,
199
+ integrations: {} as never,
200
+ projects: {} as never,
201
+ runners: {} as never,
202
+ secrets: {} as never,
203
+ workspaces: {getWorkspaceOperatingState} as never,
204
+ });
205
+
206
+ const error = await Promise.resolve(
207
+ presentation.handlers.startRunFromTrigger(input, {
208
+ signal: new AbortController().signal,
209
+ }),
210
+ ).catch((caught: unknown) => caught);
211
+
212
+ expect(getWorkspaceOperatingState).toHaveBeenCalledWith({workspaceId: input.workspaceId});
213
+ expect(
214
+ isInterModuleKnownError(workflowsInterModuleContract.methods.startRunFromTrigger, error) &&
215
+ error.code,
216
+ ).toBe(code);
217
+ });
218
+
219
+ test('maps a missing workspace from the Workspace contract for listener delivery', async () => {
220
+ const jobId = '00000000-0000-4000-8000-000000000006';
221
+ const workspaceId = '00000000-0000-4000-8000-000000000007';
222
+ mocks.getJobScope.mockResolvedValue({workspaceId, projectId: input.projectId});
223
+ const getWorkspaceOperatingState = vi
224
+ .fn()
225
+ .mockRejectedValue(
226
+ createInterModuleKnownError(
227
+ workspacesInterModuleContract.methods.getWorkspaceOperatingState,
228
+ 'workspace-not-found',
229
+ {workspaceId},
230
+ ),
231
+ );
232
+ const presentation = createWorkflowsInterModulePresentation({
233
+ agent: {} as never,
234
+ definitions: {} as never,
235
+ integrations: {} as never,
236
+ projects: {} as never,
237
+ runners: {} as never,
238
+ secrets: {} as never,
239
+ workspaces: {getWorkspaceOperatingState} as never,
240
+ });
241
+
242
+ const error = await Promise.resolve(
243
+ presentation.handlers.deliverEventToJobListener(
244
+ {
245
+ jobId,
246
+ disposition: 'fire',
247
+ eventRef: 'event-1',
248
+ deliveryId: 'delivery-1',
249
+ source: 'github',
250
+ event: 'push',
251
+ provider: 'github',
252
+ payload: {},
253
+ receivedAt: '2026-07-20T12:00:00.000Z',
254
+ },
255
+ {signal: new AbortController().signal},
256
+ ),
257
+ ).catch((caught: unknown) => caught);
258
+
259
+ expect(
260
+ isInterModuleKnownError(
261
+ workflowsInterModuleContract.methods.deliverEventToJobListener,
262
+ error,
263
+ ) && error.code,
264
+ ).toBe('workspace-not-found');
265
+ });
266
+
267
+ test.each([
268
+ ['suspended', 'workspace-suspended'],
269
+ ['deleted', 'workspace-deleted'],
270
+ ] as const)('rejects listener execution materialization for %s workspaces', async (status, code) => {
271
+ const jobId = '00000000-0000-4000-8000-000000000006';
272
+ const workspaceId = '00000000-0000-4000-8000-000000000007';
273
+ mocks.getJobScope.mockResolvedValue({workspaceId, projectId: input.projectId});
274
+ const getWorkspaceOperatingState = vi.fn().mockResolvedValue({status});
275
+ const presentation = createWorkflowsInterModulePresentation({
276
+ agent: {} as never,
277
+ definitions: {} as never,
278
+ integrations: {} as never,
279
+ projects: {} as never,
280
+ runners: {} as never,
281
+ secrets: {} as never,
282
+ workspaces: {getWorkspaceOperatingState} as never,
283
+ });
284
+
285
+ const error = await Promise.resolve(
286
+ presentation.handlers.deliverEventToJobListener(
287
+ {
288
+ jobId,
289
+ disposition: 'fire',
290
+ eventRef: 'event-1',
291
+ deliveryId: 'delivery-1',
292
+ source: 'github',
293
+ event: 'push',
294
+ provider: 'github',
295
+ payload: {},
296
+ receivedAt: '2026-07-20T12:00:00.000Z',
297
+ },
298
+ {signal: new AbortController().signal},
299
+ ),
300
+ ).catch((caught: unknown) => caught);
301
+
302
+ expect(getWorkspaceOperatingState).toHaveBeenCalledWith({workspaceId});
303
+ expect(mocks.deliverEventToListener).not.toHaveBeenCalled();
304
+ expect(
305
+ isInterModuleKnownError(
306
+ workflowsInterModuleContract.methods.deliverEventToJobListener,
307
+ error,
308
+ ) && error.code,
309
+ ).toBe(code);
310
+ });
311
+
312
+ test('allows resolve deliveries for suspended workspaces', async () => {
313
+ const jobId = '00000000-0000-4000-8000-000000000006';
314
+ mocks.getJobScope.mockResolvedValue({workspaceId: input.workspaceId});
315
+ const getWorkspaceOperatingState = vi.fn().mockResolvedValue({status: 'suspended'});
316
+ const presentation = createWorkflowsInterModulePresentation({
317
+ agent: {} as never,
318
+ definitions: {} as never,
319
+ integrations: {} as never,
320
+ projects: {} as never,
321
+ runners: {} as never,
322
+ secrets: {} as never,
323
+ workspaces: {getWorkspaceOperatingState} as never,
324
+ });
325
+
326
+ await expect(
327
+ presentation.handlers.deliverEventToJobListener(
328
+ {
329
+ jobId,
330
+ disposition: 'resolve',
331
+ eventRef: 'event-1',
332
+ deliveryId: 'delivery-1',
333
+ source: 'github',
334
+ event: 'push',
335
+ provider: 'github',
336
+ payload: {},
337
+ receivedAt: '2026-07-20T12:00:00.000Z',
338
+ },
339
+ {signal: new AbortController().signal},
340
+ ),
341
+ ).resolves.toEqual({buffered: true, skipped: false});
342
+ expect(mocks.getJobScope).not.toHaveBeenCalled();
343
+ expect(getWorkspaceOperatingState).not.toHaveBeenCalled();
344
+ expect(mocks.deliverEventToListener).toHaveBeenCalled();
345
+ });
149
346
  });
@@ -6,13 +6,20 @@ import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module'
6
6
  import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
7
7
  import type {SecretsInterModuleClient} from '@shipfox/api-secrets-dto/inter-module';
8
8
  import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
9
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
9
10
  import {
10
11
  createInterModuleKnownError,
11
12
  defineInterModulePresentation,
13
+ type InterModuleKnownErrorFor,
12
14
  type InterModulePresentation,
13
15
  } from '@shipfox/inter-module';
14
16
  import {DEFAULT_HARNESS, harnessSchema} from '@shipfox/workflow-document';
15
- import {InvalidJobRunnerLabelsError} from '#core/errors.js';
17
+ import {
18
+ InvalidJobRunnerLabelsError,
19
+ WorkspaceDeletedError,
20
+ WorkspaceNotFoundError,
21
+ WorkspaceSuspendedError,
22
+ } from '#core/errors.js';
16
23
  import {
17
24
  AgentConfigUnresolvableError,
18
25
  AgentIntegrationMaterializationError,
@@ -21,12 +28,18 @@ import {
21
28
  ProjectMismatchError,
22
29
  runWorkflow,
23
30
  } from '#core/index.js';
31
+ import {assertWorkspaceAdmitsNewJobs} from '#core/workspace-admission.js';
24
32
  import {getJobScope, getStepById, getStepByIdForJobExecution} from '#db/index.js';
25
33
  import {deliverEventToListener} from '#db/job-listener-events.js';
26
34
 
35
+ type WorkspaceAdmissionKnownError = InterModuleKnownErrorFor<
36
+ typeof workflowsInterModuleContract.methods.deliverEventToJobListener
37
+ >;
38
+
27
39
  export function createWorkflowsInterModulePresentation(params: {
28
40
  agent: AgentInterModuleClient;
29
41
  definitions: DefinitionsInterModuleClient;
42
+ workspaces: WorkspacesInterModuleClient;
30
43
  secrets: Pick<SecretsInterModuleClient, 'getVariablesByNamespace'>;
31
44
  runners: RunnersInterModuleClient;
32
45
  integrations: IntegrationsModuleClient;
@@ -35,6 +48,7 @@ export function createWorkflowsInterModulePresentation(params: {
35
48
  return defineInterModulePresentation(workflowsInterModuleContract, {
36
49
  startRunFromTrigger: async (input) => {
37
50
  try {
51
+ await assertWorkspaceAdmitsNewJobs(params.workspaces, input.workspaceId);
38
52
  const run = await runWorkflow(
39
53
  params.definitions,
40
54
  {
@@ -55,8 +69,20 @@ export function createWorkflowsInterModulePresentation(params: {
55
69
  throw toStartRunKnownError(error, input.definitionId);
56
70
  }
57
71
  },
58
- deliverEventToJobListener: async (input) =>
59
- await deliverEventToListener({...input, receivedAt: new Date(input.receivedAt)}),
72
+ deliverEventToJobListener: async (input) => {
73
+ const method = workflowsInterModuleContract.methods.deliverEventToJobListener;
74
+ try {
75
+ if (input.disposition === 'fire') {
76
+ const scope = await getJobScope(input.jobId);
77
+ if (scope) await assertWorkspaceAdmitsNewJobs(params.workspaces, scope.workspaceId);
78
+ }
79
+ return await deliverEventToListener({...input, receivedAt: new Date(input.receivedAt)});
80
+ } catch (error) {
81
+ const workspaceError = toWorkspaceAdmissionKnownError(method, error);
82
+ if (workspaceError !== undefined) throw workspaceError;
83
+ throw error;
84
+ }
85
+ },
60
86
  getStepLogContext: async ({stepId}) => {
61
87
  const step = await getStepById(stepId);
62
88
  const parsed = harnessSchema.safeParse(step?.config.harness);
@@ -98,6 +124,8 @@ export function createWorkflowsInterModulePresentation(params: {
98
124
 
99
125
  export function toStartRunKnownError(error: unknown, definitionId: string): unknown {
100
126
  const method = workflowsInterModuleContract.methods.startRunFromTrigger;
127
+ const workspaceError = toWorkspaceAdmissionKnownError(method, error);
128
+ if (workspaceError !== undefined) return workspaceError;
101
129
  if (error instanceof DefinitionNotFoundError) {
102
130
  return createInterModuleKnownError(method, 'definition-not-found', {definitionId});
103
131
  }
@@ -127,3 +155,27 @@ export function toStartRunKnownError(error: unknown, definitionId: string): unkn
127
155
  }
128
156
  return error;
129
157
  }
158
+
159
+ function toWorkspaceAdmissionKnownError(
160
+ method:
161
+ | typeof workflowsInterModuleContract.methods.startRunFromTrigger
162
+ | typeof workflowsInterModuleContract.methods.deliverEventToJobListener,
163
+ error: unknown,
164
+ ): WorkspaceAdmissionKnownError | undefined {
165
+ if (error instanceof WorkspaceSuspendedError) {
166
+ return createInterModuleKnownError(method, 'workspace-suspended', {
167
+ workspaceId: error.workspaceId,
168
+ });
169
+ }
170
+ if (error instanceof WorkspaceDeletedError) {
171
+ return createInterModuleKnownError(method, 'workspace-deleted', {
172
+ workspaceId: error.workspaceId,
173
+ });
174
+ }
175
+ if (error instanceof WorkspaceNotFoundError) {
176
+ return createInterModuleKnownError(method, 'workspace-not-found', {
177
+ workspaceId: error.workspaceId,
178
+ });
179
+ }
180
+ return undefined;
181
+ }
@@ -43,6 +43,7 @@ describe('workflow route auth', () => {
43
43
  projects: projectsTestClient,
44
44
  runners: runnersTestClient,
45
45
  secrets: createTestSecretsClient(),
46
+ workspaces: {getWorkspaceOperatingState: vi.fn()} as never,
46
47
  });
47
48
  test('uses user auth', () => {
48
49
  expect(workflowRoutes[0]?.auth).toBe(AUTH_USER);
@@ -6,6 +6,7 @@ import type {IntegrationsModuleClient} from '@shipfox/api-integration-core-dto/i
6
6
  import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
7
7
  import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
8
8
  import type {SecretsInterModuleClient} from '@shipfox/api-secrets-dto/inter-module';
9
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
9
10
  import type {RouteGroup} from '@shipfox/node-fastify';
10
11
  import {createAgentRuntimeConfigRoute} from './agent-runtime-config.js';
11
12
  import {cancelRunRoute} from './cancel-run.js';
@@ -27,9 +28,12 @@ type WorkflowRouteClients = {
27
28
  projects: ProjectsModuleClient;
28
29
  runners: RunnersInterModuleClient;
29
30
  secrets: SecretsInterModuleClient;
31
+ workspaces: WorkspacesInterModuleClient;
30
32
  };
31
33
 
32
- export function createLeaseTokenRouteGroup(params: WorkflowRouteClients): RouteGroup {
34
+ type LeaseTokenRouteClients = Omit<WorkflowRouteClients, 'workspaces'>;
35
+
36
+ export function createLeaseTokenRouteGroup(params: LeaseTokenRouteClients): RouteGroup {
33
37
  return {
34
38
  // The lease token names the job, so the path carries no job id ("current").
35
39
  prefix: '/runs/jobs/current',
@@ -59,7 +63,7 @@ export function createWorkflowRoutes(params: WorkflowRouteClients): RouteGroup[]
59
63
  listRunAttemptsRoute(params.projects),
60
64
  getRunRoute(params.projects),
61
65
  cancelRunRoute(params.projects),
62
- rerunRunRoute(params.projects),
66
+ rerunRunRoute(params.projects, params.workspaces),
63
67
  ],
64
68
  },
65
69
  createLeaseTokenRouteGroup(params),