@shipfox/api-triggers 4.0.0 → 6.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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +65 -0
- package/README.md +23 -3
- package/dist/core/dispatch-integration-event.d.ts +2 -0
- package/dist/core/dispatch-integration-event.d.ts.map +1 -1
- package/dist/core/dispatch-integration-event.js +9 -5
- package/dist/core/dispatch-integration-event.js.map +1 -1
- package/dist/core/drain-cron-schedules.d.ts +2 -0
- package/dist/core/drain-cron-schedules.d.ts.map +1 -1
- package/dist/core/drain-cron-schedules.js +1 -0
- package/dist/core/drain-cron-schedules.js.map +1 -1
- package/dist/core/fire-cron.d.ts +6 -2
- package/dist/core/fire-cron.d.ts.map +1 -1
- package/dist/core/fire-cron.js +8 -5
- package/dist/core/fire-cron.js.map +1 -1
- package/dist/core/fire-manual.d.ts +6 -2
- package/dist/core/fire-manual.d.ts.map +1 -1
- package/dist/core/fire-manual.js +8 -4
- package/dist/core/fire-manual.js.map +1 -1
- package/dist/core/route-event-to-job-listeners.d.ts +2 -0
- package/dist/core/route-event-to-job-listeners.d.ts.map +1 -1
- package/dist/core/route-event-to-job-listeners.js +2 -3
- package/dist/core/route-event-to-job-listeners.js.map +1 -1
- package/dist/core/workflows-client.d.ts +69 -0
- package/dist/core/workflows-client.d.ts.map +1 -0
- package/dist/core/workflows-client.js +20 -0
- package/dist/core/workflows-client.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -50
- package/dist/index.js.map +1 -1
- package/dist/presentation/index.d.ts +2 -2
- package/dist/presentation/index.d.ts.map +1 -1
- package/dist/presentation/index.js +2 -2
- package/dist/presentation/index.js.map +1 -1
- package/dist/presentation/routes/fire-manual.d.ts +2 -1
- package/dist/presentation/routes/fire-manual.d.ts.map +1 -1
- package/dist/presentation/routes/fire-manual.js +61 -58
- package/dist/presentation/routes/fire-manual.js.map +1 -1
- package/dist/presentation/routes/index.d.ts +2 -1
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +23 -21
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/subscribers/index.d.ts +1 -1
- package/dist/presentation/subscribers/index.d.ts.map +1 -1
- package/dist/presentation/subscribers/index.js +1 -1
- package/dist/presentation/subscribers/index.js.map +1 -1
- package/dist/presentation/subscribers/on-integration-event-received.d.ts +2 -1
- package/dist/presentation/subscribers/on-integration-event-received.d.ts.map +1 -1
- package/dist/presentation/subscribers/on-integration-event-received.js +16 -13
- package/dist/presentation/subscribers/on-integration-event-received.js.map +1 -1
- package/dist/temporal/activities/drain-cron-batch.d.ts +2 -1
- package/dist/temporal/activities/drain-cron-batch.d.ts.map +1 -1
- package/dist/temporal/activities/drain-cron-batch.js +2 -1
- package/dist/temporal/activities/drain-cron-batch.js.map +1 -1
- package/dist/temporal/activities/index.d.ts +4 -3
- package/dist/temporal/activities/index.d.ts.map +1 -1
- package/dist/temporal/activities/index.js +2 -2
- package/dist/temporal/activities/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +27 -35
- package/src/core/dispatch-integration-event.history-failure.test.ts +11 -6
- package/src/core/dispatch-integration-event.test.ts +50 -36
- package/src/core/dispatch-integration-event.ts +8 -5
- package/src/core/drain-cron-schedules.test.ts +30 -29
- package/src/core/drain-cron-schedules.ts +3 -0
- package/src/core/fire-cron.test.ts +35 -24
- package/src/core/fire-cron.ts +9 -7
- package/src/core/fire-manual.test.ts +19 -22
- package/src/core/fire-manual.ts +9 -6
- package/src/core/record-trigger-history.test.ts +3 -5
- package/src/core/route-event-to-job-listeners.test.ts +5 -4
- package/src/core/route-event-to-job-listeners.ts +5 -4
- package/src/core/workflows-client.test.ts +73 -0
- package/src/core/workflows-client.ts +32 -0
- package/src/index.ts +49 -42
- package/src/presentation/index.ts +2 -2
- package/src/presentation/routes/fire-manual.test.ts +14 -30
- package/src/presentation/routes/fire-manual.ts +62 -54
- package/src/presentation/routes/index.ts +18 -15
- package/src/presentation/subscribers/index.ts +1 -1
- package/src/presentation/subscribers/on-integration-event-received.test.ts +6 -3
- package/src/presentation/subscribers/on-integration-event-received.ts +20 -16
- package/src/temporal/activities/drain-cron-batch.ts +5 -1
- package/src/temporal/activities/index.ts +3 -2
- 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": "
|
|
4
|
+
"version": "6.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -13,22 +13,12 @@
|
|
|
13
13
|
"types": "dist/index.d.ts",
|
|
14
14
|
"imports": {
|
|
15
15
|
"#test/*": "./test/*",
|
|
16
|
-
"#*":
|
|
17
|
-
"workspace-source": "./src/*",
|
|
18
|
-
"development": "./src/*",
|
|
19
|
-
"default": "./dist/*"
|
|
20
|
-
}
|
|
16
|
+
"#*": "./dist/*"
|
|
21
17
|
},
|
|
22
18
|
"exports": {
|
|
23
19
|
".": {
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"default": "./src/index.ts"
|
|
27
|
-
},
|
|
28
|
-
"default": {
|
|
29
|
-
"types": "./dist/index.d.ts",
|
|
30
|
-
"default": "./dist/index.js"
|
|
31
|
-
}
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
32
22
|
}
|
|
33
23
|
},
|
|
34
24
|
"dependencies": {
|
|
@@ -36,23 +26,23 @@
|
|
|
36
26
|
"cron-parser": "^5.6.1",
|
|
37
27
|
"drizzle-orm": "^0.45.2",
|
|
38
28
|
"zod": "^4.4.3",
|
|
39
|
-
"@shipfox/api-auth-context": "
|
|
40
|
-
"@shipfox/api-definitions-dto": "
|
|
41
|
-
"@shipfox/api-integration-core-dto": "
|
|
42
|
-
"@shipfox/api-projects": "
|
|
43
|
-
"@shipfox/api-triggers-dto": "
|
|
44
|
-
"@shipfox/api-workflows": "
|
|
45
|
-
"@shipfox/
|
|
46
|
-
"@shipfox/
|
|
47
|
-
"@shipfox/
|
|
48
|
-
"@shipfox/node-drizzle": "0.3.
|
|
49
|
-
"@shipfox/node-fastify": "0.2.
|
|
50
|
-
"@shipfox/node-module": "0.
|
|
51
|
-
"@shipfox/node-opentelemetry": "0.5.
|
|
52
|
-
"@shipfox/node-outbox": "0.2.
|
|
53
|
-
"@shipfox/node-postgres": "0.4.
|
|
54
|
-
"@shipfox/node-temporal": "0.3.
|
|
55
|
-
"@shipfox/workflow-document": "2.1.
|
|
29
|
+
"@shipfox/api-auth-context": "6.0.0",
|
|
30
|
+
"@shipfox/api-definitions-dto": "6.0.0",
|
|
31
|
+
"@shipfox/api-integration-core-dto": "6.0.0",
|
|
32
|
+
"@shipfox/api-projects": "6.0.0",
|
|
33
|
+
"@shipfox/api-triggers-dto": "5.0.0",
|
|
34
|
+
"@shipfox/api-workflows-dto": "6.0.0",
|
|
35
|
+
"@shipfox/config": "1.2.2",
|
|
36
|
+
"@shipfox/expression": "1.1.3",
|
|
37
|
+
"@shipfox/inter-module": "0.2.0",
|
|
38
|
+
"@shipfox/node-drizzle": "0.3.2",
|
|
39
|
+
"@shipfox/node-fastify": "0.2.4",
|
|
40
|
+
"@shipfox/node-module": "0.4.0",
|
|
41
|
+
"@shipfox/node-opentelemetry": "0.5.2",
|
|
42
|
+
"@shipfox/node-outbox": "0.2.4",
|
|
43
|
+
"@shipfox/node-postgres": "0.4.2",
|
|
44
|
+
"@shipfox/node-temporal": "0.3.2",
|
|
45
|
+
"@shipfox/workflow-document": "2.1.1"
|
|
56
46
|
},
|
|
57
47
|
"devDependencies": {
|
|
58
48
|
"@temporalio/activity": "1.18.1",
|
|
@@ -65,16 +55,18 @@
|
|
|
65
55
|
"fastify": "^5.3.3",
|
|
66
56
|
"fastify-type-provider-zod": "^6.0.0",
|
|
67
57
|
"fishery": "^2.4.0",
|
|
68
|
-
"@shipfox/biome": "1.8.
|
|
69
|
-
"@shipfox/
|
|
58
|
+
"@shipfox/biome": "1.8.2",
|
|
59
|
+
"@shipfox/depcruise": "1.0.2",
|
|
60
|
+
"@shipfox/swc": "1.2.6",
|
|
70
61
|
"@shipfox/ts-config": "1.3.8",
|
|
71
|
-
"@shipfox/typescript": "1.1.
|
|
72
|
-
"@shipfox/vitest": "1.2.
|
|
62
|
+
"@shipfox/typescript": "1.1.7",
|
|
63
|
+
"@shipfox/vitest": "1.2.3"
|
|
73
64
|
},
|
|
74
65
|
"scripts": {
|
|
75
66
|
"build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
|
|
76
67
|
"check": "shipfox-biome-check",
|
|
77
68
|
"check:fix": "shipfox-biome-check --write",
|
|
69
|
+
"depcruise": "shipfox-depcruise",
|
|
78
70
|
"test": "shipfox-vitest-run",
|
|
79
71
|
"test:watch": "shipfox-vitest-watch",
|
|
80
72
|
"type": "shipfox-tsc-check",
|
|
@@ -4,12 +4,6 @@ const runWorkflow = vi.fn();
|
|
|
4
4
|
const deliverEventToListener = vi.fn();
|
|
5
5
|
const insertReceivedEvent = vi.fn();
|
|
6
6
|
|
|
7
|
-
vi.mock('@shipfox/api-workflows', () => ({
|
|
8
|
-
runWorkflow: (...args: unknown[]) => runWorkflow(...args),
|
|
9
|
-
deliverEventToListener: (...args: unknown[]) => deliverEventToListener(...args),
|
|
10
|
-
isPermanentRunWorkflowError: () => false,
|
|
11
|
-
}));
|
|
12
|
-
|
|
13
7
|
vi.mock('#db/event-history.js', () => ({
|
|
14
8
|
insertReceivedEvent: (...args: unknown[]) => insertReceivedEvent(...args),
|
|
15
9
|
markReceivedEventDiscarded: vi.fn(),
|
|
@@ -27,6 +21,16 @@ vi.mock('#db/event-history.js', () => ({
|
|
|
27
21
|
// Import after mocks so the code under test sees the spies.
|
|
28
22
|
const {dispatchIntegrationEvent} = await import('./dispatch-integration-event.js');
|
|
29
23
|
|
|
24
|
+
const workflows = {
|
|
25
|
+
startRunFromTrigger: (...args: unknown[]) => runWorkflow(...args),
|
|
26
|
+
deliverEventToJobListener: (...args: unknown[]) => deliverEventToListener(...args),
|
|
27
|
+
getStepLogContext: async () => ({harness: 'pi' as const}),
|
|
28
|
+
getLeasedAgentToolContext: async () => ({
|
|
29
|
+
workspaceId: crypto.randomUUID(),
|
|
30
|
+
integrations: [],
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
|
|
30
34
|
describe('dispatchIntegrationEvent resilience to history-write failure', () => {
|
|
31
35
|
beforeEach(() => {
|
|
32
36
|
runWorkflow.mockReset();
|
|
@@ -48,6 +52,7 @@ describe('dispatchIntegrationEvent resilience to history-write failure', () => {
|
|
|
48
52
|
|
|
49
53
|
await expect(
|
|
50
54
|
dispatchIntegrationEvent({
|
|
55
|
+
workflows,
|
|
51
56
|
eventRef: crypto.randomUUID(),
|
|
52
57
|
workspaceId,
|
|
53
58
|
provider: 'github',
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
|
|
2
|
+
import {createInterModuleKnownError} from '@shipfox/inter-module';
|
|
1
3
|
import {eq} from 'drizzle-orm';
|
|
2
4
|
import {db} from '#db/db.js';
|
|
3
5
|
import {triggersDecisions} from '#db/schema/decisions.js';
|
|
@@ -7,37 +9,33 @@ import {jobListenerSubscriptionFactory, triggerSubscriptionFactory} from '#test/
|
|
|
7
9
|
const runWorkflow = vi.fn();
|
|
8
10
|
const deliverEventToListener = vi.fn();
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
// dispatcher's permanent/transient branching is exercised without loading the workflows module graph.
|
|
12
|
-
vi.mock('@shipfox/api-workflows', () => {
|
|
13
|
-
class DefinitionNotFoundError extends Error {
|
|
14
|
-
constructor(definitionId: string) {
|
|
15
|
-
super(`Definition not found: ${definitionId}`);
|
|
16
|
-
this.name = 'DefinitionNotFoundError';
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
class ProjectMismatchError extends Error {
|
|
20
|
-
constructor(definitionProjectId: string, requestProjectId: string) {
|
|
21
|
-
super(
|
|
22
|
-
`Definition belongs to project ${definitionProjectId}, but request targets project ${requestProjectId}`,
|
|
23
|
-
);
|
|
24
|
-
this.name = 'ProjectMismatchError';
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return {
|
|
28
|
-
runWorkflow: (...args: unknown[]) => runWorkflow(...args),
|
|
29
|
-
deliverEventToListener: (...args: unknown[]) => deliverEventToListener(...args),
|
|
30
|
-
DefinitionNotFoundError,
|
|
31
|
-
ProjectMismatchError,
|
|
32
|
-
isPermanentRunWorkflowError: (error: unknown) =>
|
|
33
|
-
error instanceof DefinitionNotFoundError || error instanceof ProjectMismatchError,
|
|
34
|
-
};
|
|
35
|
-
});
|
|
12
|
+
const {dispatchIntegrationEvent} = await import('./dispatch-integration-event.js');
|
|
36
13
|
|
|
37
|
-
|
|
14
|
+
const workflows = {
|
|
15
|
+
startRunFromTrigger: (...args: unknown[]) => runWorkflow(...args),
|
|
16
|
+
deliverEventToJobListener: (...args: unknown[]) => deliverEventToListener(...args),
|
|
17
|
+
getStepLogContext: async () => ({harness: 'pi' as const}),
|
|
18
|
+
getLeasedAgentToolContext: async () => ({
|
|
19
|
+
workspaceId: crypto.randomUUID(),
|
|
20
|
+
integrations: [],
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function definitionNotFound(_definitionId: string) {
|
|
25
|
+
return createInterModuleKnownError(
|
|
26
|
+
workflowsInterModuleContract.methods.startRunFromTrigger,
|
|
27
|
+
'definition-not-found',
|
|
28
|
+
{definitionId: crypto.randomUUID()},
|
|
29
|
+
);
|
|
30
|
+
}
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
function projectMismatch() {
|
|
33
|
+
return createInterModuleKnownError(
|
|
34
|
+
workflowsInterModuleContract.methods.startRunFromTrigger,
|
|
35
|
+
'project-mismatch',
|
|
36
|
+
{},
|
|
37
|
+
);
|
|
38
|
+
}
|
|
41
39
|
|
|
42
40
|
interface DispatchOverrides {
|
|
43
41
|
eventRef?: string;
|
|
@@ -53,6 +51,7 @@ interface DispatchOverrides {
|
|
|
53
51
|
|
|
54
52
|
function dispatch(overrides: DispatchOverrides = {}): Promise<void> {
|
|
55
53
|
return dispatchIntegrationEvent({
|
|
54
|
+
workflows,
|
|
56
55
|
eventRef: overrides.eventRef ?? crypto.randomUUID(),
|
|
57
56
|
provider: overrides.provider ?? overrides.source ?? 'github',
|
|
58
57
|
source: overrides.source ?? 'github',
|
|
@@ -317,7 +316,7 @@ describe('dispatchIntegrationEvent', () => {
|
|
|
317
316
|
await dispatch({workspaceId, eventRef});
|
|
318
317
|
|
|
319
318
|
expect(runWorkflow).toHaveBeenCalledWith(
|
|
320
|
-
expect.objectContaining({
|
|
319
|
+
expect.objectContaining({idempotencyKey: `${subscription.id}:${eventRef}`}),
|
|
321
320
|
);
|
|
322
321
|
});
|
|
323
322
|
|
|
@@ -335,6 +334,21 @@ describe('dispatchIntegrationEvent', () => {
|
|
|
335
334
|
expect(runWorkflow).toHaveBeenCalledWith(expect.objectContaining({inputs: {env: 'staging'}}));
|
|
336
335
|
});
|
|
337
336
|
|
|
337
|
+
test('omits inputs when the subscription has no configured inputs', async () => {
|
|
338
|
+
const workspaceId = crypto.randomUUID();
|
|
339
|
+
await triggerSubscriptionFactory.create({
|
|
340
|
+
workspaceId,
|
|
341
|
+
source: 'github',
|
|
342
|
+
event: 'push',
|
|
343
|
+
config: {},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
await dispatch({workspaceId});
|
|
347
|
+
|
|
348
|
+
const [payload] = runWorkflow.mock.calls[0] as [Record<string, unknown>];
|
|
349
|
+
expect(payload).not.toHaveProperty('inputs');
|
|
350
|
+
});
|
|
351
|
+
|
|
338
352
|
test('runs only subscriptions whose trigger filter matches the payload', async () => {
|
|
339
353
|
const workspaceId = crypto.randomUUID();
|
|
340
354
|
const matching = await triggerSubscriptionFactory.create({
|
|
@@ -789,7 +803,7 @@ describe('dispatchIntegrationEvent trigger history', () => {
|
|
|
789
803
|
});
|
|
790
804
|
const run = {id: crypto.randomUUID(), name: 'Build and test'};
|
|
791
805
|
runWorkflow.mockImplementation(({projectId}: {projectId: string}) => {
|
|
792
|
-
if (projectId === poison.projectId) throw
|
|
806
|
+
if (projectId === poison.projectId) throw definitionNotFound('def-gone');
|
|
793
807
|
return run;
|
|
794
808
|
});
|
|
795
809
|
|
|
@@ -805,7 +819,7 @@ describe('dispatchIntegrationEvent trigger history', () => {
|
|
|
805
819
|
const errored = decisions.find((d) => d.subscriptionId === poison.id);
|
|
806
820
|
const triggered = decisions.find((d) => d.subscriptionId === healthy.id);
|
|
807
821
|
expect(errored?.decision).toBe('dispatch-error');
|
|
808
|
-
expect(errored?.reason).toContain('
|
|
822
|
+
expect(errored?.reason).toContain('definition-not-found');
|
|
809
823
|
expect(triggered?.decision).toBe('triggered');
|
|
810
824
|
expect(triggered?.runId).toBe(run.id);
|
|
811
825
|
});
|
|
@@ -826,7 +840,7 @@ describe('dispatchIntegrationEvent trigger history', () => {
|
|
|
826
840
|
config: {},
|
|
827
841
|
});
|
|
828
842
|
runWorkflow.mockImplementation(() => {
|
|
829
|
-
throw
|
|
843
|
+
throw projectMismatch();
|
|
830
844
|
});
|
|
831
845
|
|
|
832
846
|
await dispatch({workspaceId, eventRef});
|
|
@@ -858,7 +872,7 @@ describe('dispatchIntegrationEvent trigger history', () => {
|
|
|
858
872
|
config: {},
|
|
859
873
|
});
|
|
860
874
|
runWorkflow.mockImplementation(({projectId}: {projectId: string}) => {
|
|
861
|
-
if (projectId === permanent.projectId) throw
|
|
875
|
+
if (projectId === permanent.projectId) throw definitionNotFound('def-gone');
|
|
862
876
|
throw new Error('transient boom');
|
|
863
877
|
});
|
|
864
878
|
|
|
@@ -900,7 +914,7 @@ describe('dispatchIntegrationEvent trigger history', () => {
|
|
|
900
914
|
// A later permanent failure must not downgrade a run recorded during a prior
|
|
901
915
|
// transiently failed attempt.
|
|
902
916
|
runWorkflow.mockImplementation(() => {
|
|
903
|
-
throw
|
|
917
|
+
throw definitionNotFound('def-gone');
|
|
904
918
|
});
|
|
905
919
|
await dispatch({workspaceId, eventRef});
|
|
906
920
|
|
|
@@ -939,7 +953,7 @@ describe('dispatchIntegrationEvent trigger history', () => {
|
|
|
939
953
|
if (!event) throw new Error('received event not found');
|
|
940
954
|
expect(await decisionsForEvent(event.id)).toHaveLength(1);
|
|
941
955
|
// `runWorkflow` is mocked here; run-row dedup depends on stable keys at its boundary.
|
|
942
|
-
const keys = runWorkflow.mock.calls.map(([params]) => params.
|
|
956
|
+
const keys = runWorkflow.mock.calls.map(([params]) => params.idempotencyKey);
|
|
943
957
|
expect(keys).toEqual([`${subscription.id}:${eventRef}`, `${subscription.id}:${eventRef}`]);
|
|
944
958
|
});
|
|
945
959
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import {isPermanentRunWorkflowError, runWorkflow} from '@shipfox/api-workflows';
|
|
2
1
|
import {findMatchingSubscriptions} from '#db/subscriptions.js';
|
|
3
2
|
import {
|
|
4
3
|
eventOutcomeCount,
|
|
@@ -8,8 +7,10 @@ import {
|
|
|
8
7
|
import {evaluateTriggerFilter, readConfigInputs} from './config.js';
|
|
9
8
|
import {beginTriggerHistory, toReason} from './record-trigger-history.js';
|
|
10
9
|
import {routeEventToJobListeners} from './route-event-to-job-listeners.js';
|
|
10
|
+
import {isPermanentStartRunError, type WorkflowsModuleClient} from './workflows-client.js';
|
|
11
11
|
|
|
12
12
|
export interface DispatchIntegrationEventParams {
|
|
13
|
+
workflows: WorkflowsModuleClient;
|
|
13
14
|
eventRef: string;
|
|
14
15
|
workspaceId: string;
|
|
15
16
|
provider: string;
|
|
@@ -79,8 +80,9 @@ export async function dispatchIntegrationEvent(
|
|
|
79
80
|
}
|
|
80
81
|
triggerEngagedCount += 1;
|
|
81
82
|
|
|
83
|
+
const inputs = readConfigInputs(subscription);
|
|
82
84
|
try {
|
|
83
|
-
const run = await
|
|
85
|
+
const run = await params.workflows.startRunFromTrigger({
|
|
84
86
|
workspaceId: subscription.workspaceId,
|
|
85
87
|
projectId: subscription.projectId,
|
|
86
88
|
definitionId: subscription.workflowDefinitionId,
|
|
@@ -91,8 +93,8 @@ export async function dispatchIntegrationEvent(
|
|
|
91
93
|
deliveryId: params.deliveryId,
|
|
92
94
|
data: params.payload,
|
|
93
95
|
},
|
|
94
|
-
inputs:
|
|
95
|
-
|
|
96
|
+
...(inputs === undefined ? {} : {inputs}),
|
|
97
|
+
idempotencyKey: `${subscription.id}:${params.eventRef}`,
|
|
96
98
|
});
|
|
97
99
|
await history.triggered(subscription, run);
|
|
98
100
|
triggeredCount += 1;
|
|
@@ -101,7 +103,7 @@ export async function dispatchIntegrationEvent(
|
|
|
101
103
|
await history.dispatchErrored(subscription, toReason(error));
|
|
102
104
|
// Track presence with a flag, not `firstTransientError === undefined`: a thrown
|
|
103
105
|
// value of `undefined` is still a transient failure and must drive the replay.
|
|
104
|
-
if (!
|
|
106
|
+
if (!isPermanentStartRunError(error) && !sawTransientError) {
|
|
105
107
|
sawTransientError = true;
|
|
106
108
|
firstTransientError = error;
|
|
107
109
|
}
|
|
@@ -109,6 +111,7 @@ export async function dispatchIntegrationEvent(
|
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
const listenerResult = await routeEventToJobListeners({
|
|
114
|
+
workflows: params.workflows,
|
|
112
115
|
history,
|
|
113
116
|
eventRef: params.eventRef,
|
|
114
117
|
workspaceId: params.workspaceId,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
|
|
2
|
+
import {createInterModuleKnownError} from '@shipfox/inter-module';
|
|
1
3
|
import {eq} from 'drizzle-orm';
|
|
2
4
|
import type {CronSchedule} from '#core/entities/cron-schedule.js';
|
|
3
5
|
import type {TriggerSubscription} from '#core/entities/subscription.js';
|
|
@@ -10,24 +12,17 @@ import {cronScheduleFactory, triggerSubscriptionFactory} from '#test/index.js';
|
|
|
10
12
|
|
|
11
13
|
const runWorkflow = vi.fn();
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
class DefinitionNotFoundError extends Error {
|
|
15
|
-
constructor(definitionId: string) {
|
|
16
|
-
super(`Definition not found: ${definitionId}`);
|
|
17
|
-
this.name = 'DefinitionNotFoundError';
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return {
|
|
21
|
-
runWorkflow: (...args: unknown[]) => runWorkflow(...args),
|
|
22
|
-
DefinitionNotFoundError,
|
|
23
|
-
isPermanentRunWorkflowError: (error: unknown) => error instanceof DefinitionNotFoundError,
|
|
24
|
-
};
|
|
25
|
-
});
|
|
15
|
+
const {drainDueCronSchedules} = await import('./drain-cron-schedules.js');
|
|
26
16
|
|
|
27
|
-
|
|
17
|
+
const workflows = {startRunFromTrigger: (...args: unknown[]) => runWorkflow(...args)} as never;
|
|
28
18
|
|
|
29
|
-
|
|
30
|
-
|
|
19
|
+
function drain(params: {
|
|
20
|
+
batchSize: number;
|
|
21
|
+
jitterWindowSeconds: number;
|
|
22
|
+
onScheduleProcessed?: () => void;
|
|
23
|
+
}) {
|
|
24
|
+
return drainDueCronSchedules({workflows, ...params});
|
|
25
|
+
}
|
|
31
26
|
|
|
32
27
|
const MINUTE_MS = 60 * 1000;
|
|
33
28
|
|
|
@@ -62,7 +57,7 @@ async function createCronSchedule(params: {
|
|
|
62
57
|
|
|
63
58
|
function callsWithKey(key: string) {
|
|
64
59
|
return runWorkflow.mock.calls.filter(
|
|
65
|
-
([params]) => (params as {
|
|
60
|
+
([params]) => (params as {idempotencyKey?: string}).idempotencyKey === key,
|
|
66
61
|
);
|
|
67
62
|
}
|
|
68
63
|
|
|
@@ -87,7 +82,7 @@ describe('drainDueCronSchedules', () => {
|
|
|
87
82
|
const run = {id: crypto.randomUUID(), name: 'Cron run'};
|
|
88
83
|
runWorkflow.mockResolvedValue(run);
|
|
89
84
|
|
|
90
|
-
const summary = await
|
|
85
|
+
const summary = await drain({batchSize: 1000, jitterWindowSeconds: 0});
|
|
91
86
|
|
|
92
87
|
expect(callsWithKey(key)).toHaveLength(1);
|
|
93
88
|
expect(summary.fired).toBeGreaterThanOrEqual(1);
|
|
@@ -114,7 +109,7 @@ describe('drainDueCronSchedules', () => {
|
|
|
114
109
|
const {subscription, key} = await createCronSchedule({nextFireAt: future});
|
|
115
110
|
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'r'});
|
|
116
111
|
|
|
117
|
-
await
|
|
112
|
+
await drain({batchSize: 1000, jitterWindowSeconds: 0});
|
|
118
113
|
|
|
119
114
|
expect(callsWithKey(key)).toHaveLength(0);
|
|
120
115
|
const row = await reload(subscription.id);
|
|
@@ -129,7 +124,7 @@ describe('drainDueCronSchedules', () => {
|
|
|
129
124
|
});
|
|
130
125
|
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'r'});
|
|
131
126
|
|
|
132
|
-
await
|
|
127
|
+
await drain({batchSize: 1000, jitterWindowSeconds: 0});
|
|
133
128
|
|
|
134
129
|
expect(callsWithKey(key)).toHaveLength(1);
|
|
135
130
|
const row = await reload(subscription.id);
|
|
@@ -145,10 +140,10 @@ describe('drainDueCronSchedules', () => {
|
|
|
145
140
|
});
|
|
146
141
|
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'r'});
|
|
147
142
|
|
|
148
|
-
await
|
|
143
|
+
await drain({batchSize: 1000, jitterWindowSeconds: 0});
|
|
149
144
|
|
|
150
145
|
const [params] = callsWithKey(key)[0] as [Record<string, unknown>];
|
|
151
|
-
expect(params.
|
|
146
|
+
expect(params.idempotencyKey).toBe(key);
|
|
152
147
|
expect(params.inputs).toEqual({environment: 'staging'});
|
|
153
148
|
expect(params.triggerPayload).toEqual({
|
|
154
149
|
provider: 'cron',
|
|
@@ -162,9 +157,15 @@ describe('drainDueCronSchedules', () => {
|
|
|
162
157
|
const {subscription, key} = await createCronSchedule({
|
|
163
158
|
nextFireAt: new Date(Date.now() - MINUTE_MS),
|
|
164
159
|
});
|
|
165
|
-
runWorkflow.mockRejectedValue(
|
|
160
|
+
runWorkflow.mockRejectedValue(
|
|
161
|
+
createInterModuleKnownError(
|
|
162
|
+
workflowsInterModuleContract.methods.startRunFromTrigger,
|
|
163
|
+
'definition-not-found',
|
|
164
|
+
{definitionId: crypto.randomUUID()},
|
|
165
|
+
),
|
|
166
|
+
);
|
|
166
167
|
|
|
167
|
-
await
|
|
168
|
+
await drain({batchSize: 1000, jitterWindowSeconds: 0});
|
|
168
169
|
|
|
169
170
|
const [event] = await db()
|
|
170
171
|
.select()
|
|
@@ -187,7 +188,7 @@ describe('drainDueCronSchedules', () => {
|
|
|
187
188
|
});
|
|
188
189
|
runWorkflow.mockRejectedValue(new Error('database unavailable'));
|
|
189
190
|
|
|
190
|
-
await
|
|
191
|
+
await drain({batchSize: 1000, jitterWindowSeconds: 0});
|
|
191
192
|
|
|
192
193
|
const row = await reload(subscription.id);
|
|
193
194
|
expect(row.nextFireAt.getTime()).toBe(schedule.nextFireAt.getTime());
|
|
@@ -199,7 +200,7 @@ describe('drainDueCronSchedules', () => {
|
|
|
199
200
|
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'r'});
|
|
200
201
|
const onScheduleProcessed = vi.fn();
|
|
201
202
|
|
|
202
|
-
const summary = await
|
|
203
|
+
const summary = await drain({
|
|
203
204
|
batchSize: 1000,
|
|
204
205
|
jitterWindowSeconds: 0,
|
|
205
206
|
onScheduleProcessed,
|
|
@@ -216,7 +217,7 @@ describe('drainDueCronSchedules', () => {
|
|
|
216
217
|
}
|
|
217
218
|
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'r'});
|
|
218
219
|
|
|
219
|
-
const summary = await
|
|
220
|
+
const summary = await drain({batchSize: 3, jitterWindowSeconds: 0});
|
|
220
221
|
|
|
221
222
|
expect(summary.claimed).toBe(3);
|
|
222
223
|
expect(runWorkflow).toHaveBeenCalledTimes(3);
|
|
@@ -241,8 +242,8 @@ describe('drainDueCronSchedules', () => {
|
|
|
241
242
|
});
|
|
242
243
|
|
|
243
244
|
await Promise.all([
|
|
244
|
-
|
|
245
|
-
|
|
245
|
+
drain({batchSize: 1000, jitterWindowSeconds: 0}),
|
|
246
|
+
drain({batchSize: 1000, jitterWindowSeconds: 0}),
|
|
246
247
|
]);
|
|
247
248
|
|
|
248
249
|
for (const {subscription, schedule, key} of mine) {
|
|
@@ -3,8 +3,10 @@ import {advanceCronSchedule, claimDueCronSchedules, selectDbNow} from '#db/cron-
|
|
|
3
3
|
import {db} from '#db/db.js';
|
|
4
4
|
import {computeNextFireAt} from './compute-next-fire-at.js';
|
|
5
5
|
import {fireCronSubscription} from './fire-cron.js';
|
|
6
|
+
import type {WorkflowsModuleClient} from './workflows-client.js';
|
|
6
7
|
|
|
7
8
|
export interface DrainDueCronSchedulesParams {
|
|
9
|
+
readonly workflows: WorkflowsModuleClient;
|
|
8
10
|
readonly batchSize: number;
|
|
9
11
|
readonly jitterWindowSeconds: number;
|
|
10
12
|
/**
|
|
@@ -67,6 +69,7 @@ export async function drainDueCronSchedules(
|
|
|
67
69
|
lastFiredAt: scheduledSlot,
|
|
68
70
|
});
|
|
69
71
|
return await fireCronSubscription({
|
|
72
|
+
workflows: params.workflows,
|
|
70
73
|
subscriptionId: schedule.subscriptionId,
|
|
71
74
|
scheduledSlot,
|
|
72
75
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
|
|
2
|
+
import {createInterModuleKnownError} from '@shipfox/inter-module';
|
|
1
3
|
import {eq} from 'drizzle-orm';
|
|
2
4
|
import {db} from '#db/db.js';
|
|
3
5
|
import {triggersDecisions} from '#db/schema/decisions.js';
|
|
@@ -7,27 +9,10 @@ import {TriggerSubscriptionNotCronError} from './errors.js';
|
|
|
7
9
|
|
|
8
10
|
const runWorkflow = vi.fn();
|
|
9
11
|
|
|
10
|
-
// Keep a real-enough permanent-error class + the classifier so the cron path's
|
|
11
|
-
// permanent/transient branching is exercised without loading the workflows module graph.
|
|
12
|
-
vi.mock('@shipfox/api-workflows', () => {
|
|
13
|
-
class DefinitionNotFoundError extends Error {
|
|
14
|
-
constructor(definitionId: string) {
|
|
15
|
-
super(`Definition not found: ${definitionId}`);
|
|
16
|
-
this.name = 'DefinitionNotFoundError';
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
return {
|
|
20
|
-
runWorkflow: (...args: unknown[]) => runWorkflow(...args),
|
|
21
|
-
DefinitionNotFoundError,
|
|
22
|
-
isPermanentRunWorkflowError: (error: unknown) => error instanceof DefinitionNotFoundError,
|
|
23
|
-
};
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
import {DefinitionNotFoundError} from '@shipfox/api-workflows';
|
|
27
|
-
|
|
28
|
-
// Import after mocks so the function under test sees the spy.
|
|
29
12
|
const {fireCronSubscription} = await import('./fire-cron.js');
|
|
30
13
|
|
|
14
|
+
const workflows = {startRunFromTrigger: (...args: unknown[]) => runWorkflow(...args)} as never;
|
|
15
|
+
|
|
31
16
|
const SLOT = new Date('2026-07-05T02:00:00.000Z');
|
|
32
17
|
|
|
33
18
|
function eventsForWorkspace(workspaceId: string) {
|
|
@@ -59,6 +44,7 @@ describe('fireCronSubscription', () => {
|
|
|
59
44
|
runWorkflow.mockResolvedValue(run);
|
|
60
45
|
|
|
61
46
|
const result = await fireCronSubscription({
|
|
47
|
+
workflows,
|
|
62
48
|
subscriptionId: subscription.id,
|
|
63
49
|
scheduledSlot: SLOT,
|
|
64
50
|
});
|
|
@@ -72,7 +58,7 @@ describe('fireCronSubscription', () => {
|
|
|
72
58
|
scheduleId: subscription.id,
|
|
73
59
|
});
|
|
74
60
|
expect(payload.inputs).toEqual({environment: 'staging'});
|
|
75
|
-
expect(payload.
|
|
61
|
+
expect(payload.idempotencyKey).toBe(`${subscription.id}:${SLOT.toISOString()}`);
|
|
76
62
|
const [event] = await eventsForWorkspace(subscription.workspaceId);
|
|
77
63
|
if (!event) throw new Error('received event not found');
|
|
78
64
|
expect(event.origin).toBe('cron');
|
|
@@ -92,9 +78,16 @@ describe('fireCronSubscription', () => {
|
|
|
92
78
|
event: 'tick',
|
|
93
79
|
config: {},
|
|
94
80
|
});
|
|
95
|
-
runWorkflow.mockRejectedValue(
|
|
81
|
+
runWorkflow.mockRejectedValue(
|
|
82
|
+
createInterModuleKnownError(
|
|
83
|
+
workflowsInterModuleContract.methods.startRunFromTrigger,
|
|
84
|
+
'definition-not-found',
|
|
85
|
+
{definitionId: crypto.randomUUID()},
|
|
86
|
+
),
|
|
87
|
+
);
|
|
96
88
|
|
|
97
89
|
const result = await fireCronSubscription({
|
|
90
|
+
workflows,
|
|
98
91
|
subscriptionId: subscription.id,
|
|
99
92
|
scheduledSlot: SLOT,
|
|
100
93
|
});
|
|
@@ -106,7 +99,25 @@ describe('fireCronSubscription', () => {
|
|
|
106
99
|
expect(event.processedAt).toBeInstanceOf(Date);
|
|
107
100
|
const decisions = await decisionsForEvent(event.id);
|
|
108
101
|
expect(decisions[0]?.decision).toBe('dispatch-error');
|
|
109
|
-
expect(decisions[0]?.reason).toContain('
|
|
102
|
+
expect(decisions[0]?.reason).toContain('definition-not-found');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('omits inputs when the subscription has no configured inputs', async () => {
|
|
106
|
+
const subscription = await triggerSubscriptionFactory.create({
|
|
107
|
+
source: 'cron',
|
|
108
|
+
event: 'tick',
|
|
109
|
+
config: {},
|
|
110
|
+
});
|
|
111
|
+
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'Cron run'});
|
|
112
|
+
|
|
113
|
+
await fireCronSubscription({
|
|
114
|
+
workflows,
|
|
115
|
+
subscriptionId: subscription.id,
|
|
116
|
+
scheduledSlot: SLOT,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const [payload] = runWorkflow.mock.calls[0] as [Record<string, unknown>];
|
|
120
|
+
expect(payload).not.toHaveProperty('inputs');
|
|
110
121
|
});
|
|
111
122
|
|
|
112
123
|
test('re-throws and leaves the event non-terminal on a transient failure', async () => {
|
|
@@ -118,7 +129,7 @@ describe('fireCronSubscription', () => {
|
|
|
118
129
|
runWorkflow.mockRejectedValue(new Error('cron boom'));
|
|
119
130
|
|
|
120
131
|
await expect(
|
|
121
|
-
fireCronSubscription({subscriptionId: subscription.id, scheduledSlot: SLOT}),
|
|
132
|
+
fireCronSubscription({workflows, subscriptionId: subscription.id, scheduledSlot: SLOT}),
|
|
122
133
|
).rejects.toThrow('cron boom');
|
|
123
134
|
|
|
124
135
|
const [event] = await eventsForWorkspace(subscription.workspaceId);
|
|
@@ -137,7 +148,7 @@ describe('fireCronSubscription', () => {
|
|
|
137
148
|
});
|
|
138
149
|
|
|
139
150
|
await expect(
|
|
140
|
-
fireCronSubscription({subscriptionId: subscription.id, scheduledSlot: SLOT}),
|
|
151
|
+
fireCronSubscription({workflows, subscriptionId: subscription.id, scheduledSlot: SLOT}),
|
|
141
152
|
).rejects.toThrow(TriggerSubscriptionNotCronError);
|
|
142
153
|
|
|
143
154
|
expect(await eventsForWorkspace(subscription.workspaceId)).toHaveLength(0);
|