@shipfox/api-triggers 9.2.0 → 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 +17 -0
- package/dist/core/dispatch-integration-event.d.ts.map +1 -1
- package/dist/core/dispatch-integration-event.js +3 -2
- package/dist/core/dispatch-integration-event.js.map +1 -1
- package/dist/core/route-event-to-job-listeners.d.ts +1 -1
- package/dist/core/route-event-to-job-listeners.d.ts.map +1 -1
- package/dist/core/route-event-to-job-listeners.js +2 -1
- package/dist/core/route-event-to-job-listeners.js.map +1 -1
- package/dist/core/workflows-client.d.ts +25 -3
- package/dist/core/workflows-client.d.ts.map +1 -1
- package/dist/core/workflows-client.js +15 -2
- package/dist/core/workflows-client.js.map +1 -1
- package/dist/presentation/routes/fire-manual.d.ts.map +1 -1
- package/dist/presentation/routes/fire-manual.js +19 -1
- package/dist/presentation/routes/fire-manual.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/core/dispatch-integration-event.ts +3 -2
- package/src/core/route-event-to-job-listeners.test.ts +30 -0
- package/src/core/route-event-to-job-listeners.ts +5 -2
- package/src/core/workflows-client.test.ts +51 -2
- package/src/core/workflows-client.ts +32 -3
- package/src/presentation/routes/fire-manual.test.ts +66 -0
- package/src/presentation/routes/fire-manual.ts +24 -1
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-triggers",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.3.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -22,22 +22,22 @@
|
|
|
22
22
|
"cron-parser": "^5.6.1",
|
|
23
23
|
"drizzle-orm": "^0.45.2",
|
|
24
24
|
"zod": "^4.4.3",
|
|
25
|
-
"@shipfox/api-auth-context": "9.
|
|
25
|
+
"@shipfox/api-auth-context": "9.3.0",
|
|
26
26
|
"@shipfox/api-definitions-dto": "9.0.2",
|
|
27
27
|
"@shipfox/api-integration-core-dto": "9.0.2",
|
|
28
|
+
"@shipfox/api-workflows-dto": "9.3.0",
|
|
28
29
|
"@shipfox/api-triggers-dto": "9.0.2",
|
|
29
|
-
"@shipfox/api-workflows-dto": "9.0.2",
|
|
30
|
-
"@shipfox/config": "1.2.4",
|
|
31
30
|
"@shipfox/expression": "1.1.5",
|
|
31
|
+
"@shipfox/config": "1.2.4",
|
|
32
32
|
"@shipfox/inter-module": "0.2.2",
|
|
33
33
|
"@shipfox/node-drizzle": "0.3.4",
|
|
34
34
|
"@shipfox/node-error-monitoring": "0.3.0",
|
|
35
|
-
"@shipfox/node-fastify": "0.3.
|
|
36
|
-
"@shipfox/node-module": "1.0.
|
|
37
|
-
"@shipfox/node-opentelemetry": "0.6.
|
|
35
|
+
"@shipfox/node-fastify": "0.3.4",
|
|
36
|
+
"@shipfox/node-module": "1.0.3",
|
|
37
|
+
"@shipfox/node-opentelemetry": "0.6.3",
|
|
38
38
|
"@shipfox/node-outbox": "0.2.6",
|
|
39
39
|
"@shipfox/node-postgres": "0.4.4",
|
|
40
|
-
"@shipfox/node-temporal": "0.4.
|
|
40
|
+
"@shipfox/node-temporal": "0.4.4",
|
|
41
41
|
"@shipfox/workflow-document": "2.1.3"
|
|
42
42
|
},
|
|
43
43
|
"imports": {
|
|
@@ -29,8 +29,9 @@ export interface DispatchIntegrationEventParams {
|
|
|
29
29
|
// nothing about github, gitlab, etc.
|
|
30
30
|
//
|
|
31
31
|
// Continue-on-error: every matched subscription and listener is attempted so one broken
|
|
32
|
-
// subscription cannot starve its siblings. A permanent failure (deleted definition,
|
|
33
|
-
// mismatch) is recorded and skipped; a transient one is recorded
|
|
32
|
+
// subscription cannot starve its siblings. A permanent admission failure (deleted definition,
|
|
33
|
+
// project mismatch, suspended workspace) is recorded and skipped; a transient one is recorded
|
|
34
|
+
// and re-thrown so the outbox
|
|
34
35
|
// replays the whole event and converges (succeeded siblings dedup on the idempotency key). The
|
|
35
36
|
// event reaches a terminal outcome only when no transient error remains: `routed` if any run
|
|
36
37
|
// was created or any listening job matched, `discarded` if nothing matched, otherwise `errored`.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
|
|
2
|
+
import {createInterModuleKnownError} from '@shipfox/inter-module';
|
|
1
3
|
import {jobListenerSubscriptionFactory} from '#test/index.js';
|
|
2
4
|
import type {TriggerHistoryRecorder} from './record-trigger-history.js';
|
|
3
5
|
|
|
@@ -195,6 +197,34 @@ describe('routeEventToJobListeners', () => {
|
|
|
195
197
|
});
|
|
196
198
|
});
|
|
197
199
|
|
|
200
|
+
it.each([
|
|
201
|
+
'workspace-not-found',
|
|
202
|
+
'workspace-suspended',
|
|
203
|
+
'workspace-deleted',
|
|
204
|
+
] as const)('does not retry a %s workspace listener delivery', async (code) => {
|
|
205
|
+
const workspaceId = crypto.randomUUID();
|
|
206
|
+
await jobListenerSubscriptionFactory.create({
|
|
207
|
+
workspaceId,
|
|
208
|
+
source: 'github',
|
|
209
|
+
event: 'pull_request_review',
|
|
210
|
+
});
|
|
211
|
+
const error = createInterModuleKnownError(
|
|
212
|
+
workflowsInterModuleContract.methods.deliverEventToJobListener,
|
|
213
|
+
code,
|
|
214
|
+
{workspaceId},
|
|
215
|
+
);
|
|
216
|
+
deliverEventToListener.mockRejectedValueOnce(error);
|
|
217
|
+
|
|
218
|
+
const result = await route({workspaceId});
|
|
219
|
+
|
|
220
|
+
expect(listenerDispatchErrored).toHaveBeenCalledTimes(1);
|
|
221
|
+
expect(result).toMatchObject({
|
|
222
|
+
engagedCount: 1,
|
|
223
|
+
acceptedJobCount: 0,
|
|
224
|
+
transientErrored: false,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
198
228
|
it('delivers only when a listener filter matches the payload and snapshot', async () => {
|
|
199
229
|
const workspaceId = crypto.randomUUID();
|
|
200
230
|
const jobId = crypto.randomUUID();
|
|
@@ -3,7 +3,10 @@ import {findMatchingJobListenerSubscriptions} from '#db/job-listener-subscriptio
|
|
|
3
3
|
import {evaluateStoredFilter, type StoredFilterEvaluation} from './config.js';
|
|
4
4
|
import type {JobListenerSubscription} from './entities/job-listener-subscription.js';
|
|
5
5
|
import {type TriggerHistoryRecorder, toReason} from './record-trigger-history.js';
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
isPermanentDeliverEventToJobListenerError,
|
|
8
|
+
type WorkflowsModuleClient,
|
|
9
|
+
} from './workflows-client.js';
|
|
7
10
|
|
|
8
11
|
export interface RouteEventToJobListenersParams {
|
|
9
12
|
workflows: WorkflowsModuleClient;
|
|
@@ -90,7 +93,7 @@ export async function routeEventToJobListeners(
|
|
|
90
93
|
} catch (error) {
|
|
91
94
|
dispatchErrorCount += 1;
|
|
92
95
|
await params.history.listenerDispatchErrored(subscription, toReason(error));
|
|
93
|
-
if (!sawTransientError) {
|
|
96
|
+
if (!isPermanentDeliverEventToJobListenerError(error) && !sawTransientError) {
|
|
94
97
|
sawTransientError = true;
|
|
95
98
|
firstTransientError = error;
|
|
96
99
|
}
|
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
defineInterModulePresentation,
|
|
9
9
|
} from '@shipfox/inter-module';
|
|
10
10
|
import {createFakeInterModuleClients} from '@shipfox/node-module/inter-module/testing';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isPermanentDeliverEventToJobListenerError,
|
|
13
|
+
isPermanentStartRunError,
|
|
14
|
+
} from './workflows-client.js';
|
|
12
15
|
|
|
13
16
|
const input = {
|
|
14
17
|
workspaceId: '00000000-0000-4000-8000-000000000001',
|
|
@@ -24,6 +27,20 @@ const input = {
|
|
|
24
27
|
idempotencyKey: 'subscription-1:event-1',
|
|
25
28
|
};
|
|
26
29
|
|
|
30
|
+
const listenerInput = {
|
|
31
|
+
jobId: '00000000-0000-4000-8000-000000000006',
|
|
32
|
+
disposition: 'fire' as const,
|
|
33
|
+
eventRef: 'event-1',
|
|
34
|
+
deliveryId: 'delivery-1',
|
|
35
|
+
source: 'github',
|
|
36
|
+
event: 'push',
|
|
37
|
+
provider: 'github',
|
|
38
|
+
payload: {action: 'opened'},
|
|
39
|
+
receivedAt: '2026-07-26T00:00:00.000Z',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const listenerErrorInput = {...listenerInput, jobId: '00000000-0000-4000-8000-000000000007'};
|
|
43
|
+
|
|
27
44
|
function localWorkflowsClient(): WorkflowsModuleClient {
|
|
28
45
|
return createFakeInterModuleClients({
|
|
29
46
|
workflows: defineInterModulePresentation(workflowsInterModuleContract, {
|
|
@@ -35,7 +52,16 @@ function localWorkflowsClient(): WorkflowsModuleClient {
|
|
|
35
52
|
{definitionId},
|
|
36
53
|
);
|
|
37
54
|
},
|
|
38
|
-
deliverEventToJobListener: () =>
|
|
55
|
+
deliverEventToJobListener: ({jobId}) => {
|
|
56
|
+
if (jobId === listenerErrorInput.jobId) {
|
|
57
|
+
throw createInterModuleKnownError(
|
|
58
|
+
workflowsInterModuleContract.methods.deliverEventToJobListener,
|
|
59
|
+
'workspace-not-found',
|
|
60
|
+
{workspaceId: input.workspaceId},
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return {buffered: true, skipped: false};
|
|
64
|
+
},
|
|
39
65
|
getStepLogContext: () => ({harness: 'pi' as const}),
|
|
40
66
|
getLeasedAgentToolContext: () => ({
|
|
41
67
|
workspaceId: '00000000-0000-4000-8000-000000000006',
|
|
@@ -61,6 +87,15 @@ async function runConsumerSuite(client: WorkflowsModuleClient): Promise<void> {
|
|
|
61
87
|
|
|
62
88
|
const result = client.startRunFromTrigger({...input, definitionId: crypto.randomUUID()});
|
|
63
89
|
await expect(result).rejects.toSatisfy(isPermanentStartRunError);
|
|
90
|
+
|
|
91
|
+
await expect(client.deliverEventToJobListener(listenerInput)).resolves.toEqual({
|
|
92
|
+
buffered: true,
|
|
93
|
+
skipped: false,
|
|
94
|
+
});
|
|
95
|
+
await expect(client.deliverEventToJobListener(listenerErrorInput)).rejects.toMatchObject({
|
|
96
|
+
code: 'workspace-not-found',
|
|
97
|
+
details: {workspaceId: input.workspaceId},
|
|
98
|
+
});
|
|
64
99
|
}
|
|
65
100
|
|
|
66
101
|
describe('WorkflowsModuleClient consumer parity', () => {
|
|
@@ -70,4 +105,18 @@ describe('WorkflowsModuleClient consumer parity', () => {
|
|
|
70
105
|
await runConsumerSuite(local);
|
|
71
106
|
await runConsumerSuite(serializedWorkflowsClient(local));
|
|
72
107
|
});
|
|
108
|
+
|
|
109
|
+
test.each([
|
|
110
|
+
'workspace-not-found',
|
|
111
|
+
'workspace-suspended',
|
|
112
|
+
'workspace-deleted',
|
|
113
|
+
] as const)('treats a %s listener delivery as permanent', (code) => {
|
|
114
|
+
const error = createInterModuleKnownError(
|
|
115
|
+
workflowsInterModuleContract.methods.deliverEventToJobListener,
|
|
116
|
+
code,
|
|
117
|
+
{workspaceId: input.workspaceId},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(isPermanentDeliverEventToJobListenerError(error)).toBe(true);
|
|
121
|
+
});
|
|
73
122
|
});
|
|
@@ -6,15 +6,44 @@ import {isInterModuleKnownError} from '@shipfox/inter-module';
|
|
|
6
6
|
|
|
7
7
|
export type {WorkflowsModuleClient};
|
|
8
8
|
|
|
9
|
+
type StartRunKnownError = NonNullable<ReturnType<typeof startRunKnownError>>;
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Workflows declares only failures that can never succeed on retry for trigger
|
|
11
|
-
* run creation. Every other outcome is
|
|
12
|
-
* it may have committed before the
|
|
13
|
+
* run creation, including workspace admission failures. Every other outcome is
|
|
14
|
+
* opaque and must remain retryable because it may have committed before the
|
|
15
|
+
* caller stopped waiting.
|
|
13
16
|
*/
|
|
14
|
-
export function isPermanentStartRunError(error: unknown):
|
|
17
|
+
export function isPermanentStartRunError(error: unknown): error is StartRunKnownError {
|
|
15
18
|
return isInterModuleKnownError(workflowsInterModuleContract.methods.startRunFromTrigger, error);
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
/** Known listener admission failures cannot succeed by replaying the same event. */
|
|
22
|
+
export function isPermanentDeliverEventToJobListenerError(error: unknown): boolean {
|
|
23
|
+
return isInterModuleKnownError(
|
|
24
|
+
workflowsInterModuleContract.methods.deliverEventToJobListener,
|
|
25
|
+
error,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isWorkspaceSuspendedError(
|
|
30
|
+
error: unknown,
|
|
31
|
+
): error is Extract<StartRunKnownError, {code: 'workspace-suspended'}> {
|
|
32
|
+
return isPermanentStartRunError(error) && error.code === 'workspace-suspended';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isWorkspaceNotFoundError(
|
|
36
|
+
error: unknown,
|
|
37
|
+
): error is Extract<StartRunKnownError, {code: 'workspace-not-found'}> {
|
|
38
|
+
return isPermanentStartRunError(error) && error.code === 'workspace-not-found';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isWorkspaceDeletedError(
|
|
42
|
+
error: unknown,
|
|
43
|
+
): error is Extract<StartRunKnownError, {code: 'workspace-deleted'}> {
|
|
44
|
+
return isPermanentStartRunError(error) && error.code === 'workspace-deleted';
|
|
45
|
+
}
|
|
46
|
+
|
|
18
47
|
export function isInterpolationUnresolvableError(
|
|
19
48
|
error: unknown,
|
|
20
49
|
): error is Extract<ReturnType<typeof startRunKnownError>, {code: 'interpolation-unresolvable'}> {
|
|
@@ -89,6 +89,72 @@ describe('POST /:definitionId/fire-manual', () => {
|
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
test('maps suspended workspace to 409', async () => {
|
|
93
|
+
const definitionId = crypto.randomUUID();
|
|
94
|
+
await triggerSubscriptionFactory.create({workspaceId, workflowDefinitionId: definitionId});
|
|
95
|
+
fireManualSubscriptionMock.mockRejectedValue(
|
|
96
|
+
createInterModuleKnownError(
|
|
97
|
+
workflowsInterModuleContract.methods.startRunFromTrigger,
|
|
98
|
+
'workspace-suspended',
|
|
99
|
+
{workspaceId},
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const res = await app.inject({
|
|
104
|
+
method: 'POST',
|
|
105
|
+
url: `/${definitionId}/fire-manual`,
|
|
106
|
+
payload: {},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(res.statusCode).toBe(409);
|
|
110
|
+
expect(res.json()).toMatchObject({
|
|
111
|
+
code: 'workspace-suspended',
|
|
112
|
+
message: 'Workspace is suspended',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('maps missing workspace to 404', async () => {
|
|
117
|
+
const definitionId = crypto.randomUUID();
|
|
118
|
+
await triggerSubscriptionFactory.create({workspaceId, workflowDefinitionId: definitionId});
|
|
119
|
+
fireManualSubscriptionMock.mockRejectedValue(
|
|
120
|
+
createInterModuleKnownError(
|
|
121
|
+
workflowsInterModuleContract.methods.startRunFromTrigger,
|
|
122
|
+
'workspace-not-found',
|
|
123
|
+
{workspaceId},
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const res = await app.inject({
|
|
128
|
+
method: 'POST',
|
|
129
|
+
url: `/${definitionId}/fire-manual`,
|
|
130
|
+
payload: {},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(res.statusCode).toBe(404);
|
|
134
|
+
expect(res.json()).toMatchObject({code: 'workspace-not-found', message: 'Workspace not found'});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('maps deleted workspace to 404', async () => {
|
|
138
|
+
const definitionId = crypto.randomUUID();
|
|
139
|
+
await triggerSubscriptionFactory.create({workspaceId, workflowDefinitionId: definitionId});
|
|
140
|
+
fireManualSubscriptionMock.mockRejectedValue(
|
|
141
|
+
createInterModuleKnownError(
|
|
142
|
+
workflowsInterModuleContract.methods.startRunFromTrigger,
|
|
143
|
+
'workspace-deleted',
|
|
144
|
+
{workspaceId},
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const res = await app.inject({
|
|
149
|
+
method: 'POST',
|
|
150
|
+
url: `/${definitionId}/fire-manual`,
|
|
151
|
+
payload: {},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(res.statusCode).toBe(404);
|
|
155
|
+
expect(res.json()).toMatchObject({code: 'workspace-deleted', message: 'Workspace is deleted'});
|
|
156
|
+
});
|
|
157
|
+
|
|
92
158
|
test('returns 404 when the manual trigger is unavailable', async () => {
|
|
93
159
|
const res = await app.inject({
|
|
94
160
|
method: 'POST',
|
|
@@ -8,7 +8,12 @@ import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {ManualTriggerNotFoundError} from '#core/errors.js';
|
|
10
10
|
import {fireManualSubscription} from '#core/fire-manual.js';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isInterpolationUnresolvableError,
|
|
13
|
+
isWorkspaceDeletedError,
|
|
14
|
+
isWorkspaceNotFoundError,
|
|
15
|
+
isWorkspaceSuspendedError,
|
|
16
|
+
} from '#core/workflows-client.js';
|
|
12
17
|
import {getManualSubscriptionByDefinitionId} from '#db/subscriptions.js';
|
|
13
18
|
|
|
14
19
|
export function createFireManualTriggerRoute(workflows: WorkflowsModuleClient) {
|
|
@@ -37,6 +42,24 @@ export function createFireManualTriggerRoute(workflows: WorkflowsModuleClient) {
|
|
|
37
42
|
if (error instanceof ManualTriggerNotFoundError) {
|
|
38
43
|
throw new ClientError(error.message, 'manual-trigger-not-found', {status: 404});
|
|
39
44
|
}
|
|
45
|
+
if (isWorkspaceSuspendedError(error)) {
|
|
46
|
+
throw new ClientError('Workspace is suspended', 'workspace-suspended', {
|
|
47
|
+
status: 409,
|
|
48
|
+
cause: error,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (isWorkspaceDeletedError(error)) {
|
|
52
|
+
throw new ClientError('Workspace is deleted', 'workspace-deleted', {
|
|
53
|
+
status: 404,
|
|
54
|
+
cause: error,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (isWorkspaceNotFoundError(error)) {
|
|
58
|
+
throw new ClientError('Workspace not found', 'workspace-not-found', {
|
|
59
|
+
status: 404,
|
|
60
|
+
cause: error,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
40
63
|
if (isInterpolationUnresolvableError(error)) {
|
|
41
64
|
throw new ClientError(
|
|
42
65
|
'Workflow interpolation cannot be resolved',
|