@shipfox/api-workflows 9.0.3 → 9.3.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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +30 -0
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +18 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/workspace-admission.d.ts +3 -0
- package/dist/core/workspace-admission.d.ts.map +1 -0
- package/dist/core/workspace-admission.js +32 -0
- package/dist/core/workspace-admission.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/presentation/inter-module.d.ts +2 -0
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +40 -5
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/routes/index.d.ts +4 -1
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +1 -1
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/rerun-run.d.ts +2 -1
- package/dist/presentation/routes/rerun-run.d.ts.map +1 -1
- package/dist/presentation/routes/rerun-run.js +22 -2
- package/dist/presentation/routes/rerun-run.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +14 -13
- package/src/core/errors.ts +21 -0
- package/src/core/workspace-admission.test.ts +64 -0
- package/src/core/workspace-admission.ts +43 -0
- package/src/index.ts +5 -0
- package/src/presentation/inter-module.test.ts +198 -1
- package/src/presentation/inter-module.ts +55 -3
- package/src/presentation/routes/index.test.ts +1 -0
- package/src/presentation/routes/index.ts +6 -2
- package/src/presentation/routes/rerun-run.test.ts +58 -1
- package/src/presentation/routes/rerun-run.ts +34 -2
- package/test/fixtures/auth-inter-module.ts +8 -0
- 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.0
|
|
4
|
+
"version": "9.3.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.0
|
|
26
|
-
"@shipfox/api-auth-dto": "9.0
|
|
25
|
+
"@shipfox/api-auth-context": "9.3.0",
|
|
26
|
+
"@shipfox/api-auth-dto": "9.3.0",
|
|
27
27
|
"@shipfox/api-agent-dto": "9.0.2",
|
|
28
|
-
"@shipfox/api-definitions-dto": "9.0.2",
|
|
29
28
|
"@shipfox/annotations-dto": "9.0.2",
|
|
30
|
-
"@shipfox/api-
|
|
29
|
+
"@shipfox/api-definitions-dto": "9.0.2",
|
|
30
|
+
"@shipfox/api-projects-dto": "9.2.0",
|
|
31
31
|
"@shipfox/api-integration-core-dto": "9.0.2",
|
|
32
32
|
"@shipfox/api-runners-dto": "9.0.2",
|
|
33
33
|
"@shipfox/api-secrets-dto": "9.0.2",
|
|
34
|
-
"@shipfox/api-workflows-dto": "9.0
|
|
34
|
+
"@shipfox/api-workflows-dto": "9.3.0",
|
|
35
|
+
"@shipfox/api-workspaces-dto": "9.3.0",
|
|
35
36
|
"@shipfox/inter-module": "0.2.2",
|
|
36
37
|
"@shipfox/expression": "1.1.5",
|
|
37
|
-
"@shipfox/node-fastify": "0.3.3",
|
|
38
|
-
"@shipfox/node-error-monitoring": "0.3.0",
|
|
39
38
|
"@shipfox/node-drizzle": "0.3.4",
|
|
40
|
-
"@shipfox/node-
|
|
41
|
-
"@shipfox/node-
|
|
39
|
+
"@shipfox/node-error-monitoring": "0.3.0",
|
|
40
|
+
"@shipfox/node-fastify": "0.3.4",
|
|
41
|
+
"@shipfox/node-module": "1.0.3",
|
|
42
|
+
"@shipfox/node-opentelemetry": "0.6.3",
|
|
42
43
|
"@shipfox/node-outbox": "0.2.6",
|
|
43
|
-
"@shipfox/node-temporal": "0.4.3",
|
|
44
44
|
"@shipfox/node-postgres": "0.4.4",
|
|
45
|
-
"@shipfox/
|
|
46
|
-
"@shipfox/runner-labels": "0.1.3"
|
|
45
|
+
"@shipfox/node-temporal": "0.4.4",
|
|
46
|
+
"@shipfox/runner-labels": "0.1.3",
|
|
47
|
+
"@shipfox/workflow-document": "2.1.3"
|
|
47
48
|
},
|
|
48
49
|
"imports": {
|
|
49
50
|
"#*": "./dist/*"
|
package/src/core/errors.ts
CHANGED
|
@@ -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
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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),
|