@shipfox/api-triggers 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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-triggers",
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,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.2.0",
26
- "@shipfox/api-definitions-dto": "9.0.2",
25
+ "@shipfox/api-auth-context": "10.0.0",
26
+ "@shipfox/api-definitions-dto": "10.0.0",
27
27
  "@shipfox/api-integration-core-dto": "9.0.2",
28
28
  "@shipfox/api-triggers-dto": "9.0.2",
29
- "@shipfox/api-workflows-dto": "9.0.2",
29
+ "@shipfox/api-workflows-dto": "10.0.0",
30
30
  "@shipfox/config": "1.2.4",
31
- "@shipfox/expression": "1.1.5",
31
+ "@shipfox/expression": "1.2.0",
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.3",
36
- "@shipfox/node-module": "1.0.2",
37
- "@shipfox/node-opentelemetry": "0.6.2",
35
+ "@shipfox/node-fastify": "0.4.0",
36
+ "@shipfox/node-module": "1.0.4",
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.3",
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, project
33
- // mismatch) is recorded and skipped; a transient one is recorded and re-thrown so the outbox
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 type {WorkflowsModuleClient} from './workflows-client.js';
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 {isPermanentStartRunError} from './workflows-client.js';
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: () => ({buffered: true, skipped: false}),
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 opaque and must remain retryable because
12
- * it may have committed before the caller stopped waiting.
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): boolean {
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 {isInterpolationUnresolvableError} from '#core/workflows-client.js';
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',