@shipfox/api-triggers 5.0.0 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +51 -0
  3. package/README.md +23 -3
  4. package/dist/core/dispatch-integration-event.d.ts +2 -0
  5. package/dist/core/dispatch-integration-event.d.ts.map +1 -1
  6. package/dist/core/dispatch-integration-event.js +9 -5
  7. package/dist/core/dispatch-integration-event.js.map +1 -1
  8. package/dist/core/drain-cron-schedules.d.ts +2 -0
  9. package/dist/core/drain-cron-schedules.d.ts.map +1 -1
  10. package/dist/core/drain-cron-schedules.js +1 -0
  11. package/dist/core/drain-cron-schedules.js.map +1 -1
  12. package/dist/core/fire-cron.d.ts +6 -2
  13. package/dist/core/fire-cron.d.ts.map +1 -1
  14. package/dist/core/fire-cron.js +8 -5
  15. package/dist/core/fire-cron.js.map +1 -1
  16. package/dist/core/fire-manual.d.ts +6 -2
  17. package/dist/core/fire-manual.d.ts.map +1 -1
  18. package/dist/core/fire-manual.js +8 -4
  19. package/dist/core/fire-manual.js.map +1 -1
  20. package/dist/core/route-event-to-job-listeners.d.ts +2 -0
  21. package/dist/core/route-event-to-job-listeners.d.ts.map +1 -1
  22. package/dist/core/route-event-to-job-listeners.js +2 -3
  23. package/dist/core/route-event-to-job-listeners.js.map +1 -1
  24. package/dist/core/workflows-client.d.ts +69 -0
  25. package/dist/core/workflows-client.d.ts.map +1 -0
  26. package/dist/core/workflows-client.js +20 -0
  27. package/dist/core/workflows-client.js.map +1 -0
  28. package/dist/db/job-listener-subscriptions.d.ts +1 -0
  29. package/dist/db/job-listener-subscriptions.d.ts.map +1 -1
  30. package/dist/db/job-listener-subscriptions.js +6 -0
  31. package/dist/db/job-listener-subscriptions.js.map +1 -1
  32. package/dist/index.d.ts +5 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +56 -50
  35. package/dist/index.js.map +1 -1
  36. package/dist/presentation/e2e-routes.d.ts +3 -0
  37. package/dist/presentation/e2e-routes.d.ts.map +1 -0
  38. package/dist/presentation/e2e-routes.js +31 -0
  39. package/dist/presentation/e2e-routes.js.map +1 -0
  40. package/dist/presentation/index.d.ts +2 -2
  41. package/dist/presentation/index.d.ts.map +1 -1
  42. package/dist/presentation/index.js +2 -2
  43. package/dist/presentation/index.js.map +1 -1
  44. package/dist/presentation/routes/fire-manual.d.ts +2 -1
  45. package/dist/presentation/routes/fire-manual.d.ts.map +1 -1
  46. package/dist/presentation/routes/fire-manual.js +61 -58
  47. package/dist/presentation/routes/fire-manual.js.map +1 -1
  48. package/dist/presentation/routes/index.d.ts +2 -1
  49. package/dist/presentation/routes/index.d.ts.map +1 -1
  50. package/dist/presentation/routes/index.js +23 -21
  51. package/dist/presentation/routes/index.js.map +1 -1
  52. package/dist/presentation/subscribers/index.d.ts +1 -1
  53. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  54. package/dist/presentation/subscribers/index.js +1 -1
  55. package/dist/presentation/subscribers/index.js.map +1 -1
  56. package/dist/presentation/subscribers/on-integration-event-received.d.ts +2 -1
  57. package/dist/presentation/subscribers/on-integration-event-received.d.ts.map +1 -1
  58. package/dist/presentation/subscribers/on-integration-event-received.js +16 -13
  59. package/dist/presentation/subscribers/on-integration-event-received.js.map +1 -1
  60. package/dist/temporal/activities/drain-cron-batch.d.ts +2 -1
  61. package/dist/temporal/activities/drain-cron-batch.d.ts.map +1 -1
  62. package/dist/temporal/activities/drain-cron-batch.js +2 -1
  63. package/dist/temporal/activities/drain-cron-batch.js.map +1 -1
  64. package/dist/temporal/activities/index.d.ts +4 -3
  65. package/dist/temporal/activities/index.d.ts.map +1 -1
  66. package/dist/temporal/activities/index.js +2 -2
  67. package/dist/temporal/activities/index.js.map +1 -1
  68. package/dist/tsconfig.test.tsbuildinfo +1 -1
  69. package/package.json +14 -12
  70. package/src/core/dispatch-integration-event.history-failure.test.ts +11 -6
  71. package/src/core/dispatch-integration-event.test.ts +50 -36
  72. package/src/core/dispatch-integration-event.ts +8 -5
  73. package/src/core/drain-cron-schedules.test.ts +30 -29
  74. package/src/core/drain-cron-schedules.ts +3 -0
  75. package/src/core/fire-cron.test.ts +35 -24
  76. package/src/core/fire-cron.ts +9 -7
  77. package/src/core/fire-manual.test.ts +19 -22
  78. package/src/core/fire-manual.ts +9 -6
  79. package/src/core/record-trigger-history.test.ts +3 -5
  80. package/src/core/route-event-to-job-listeners.test.ts +5 -4
  81. package/src/core/route-event-to-job-listeners.ts +5 -4
  82. package/src/core/workflows-client.test.ts +73 -0
  83. package/src/core/workflows-client.ts +32 -0
  84. package/src/db/job-listener-subscriptions.ts +9 -0
  85. package/src/index.ts +51 -42
  86. package/src/presentation/e2e-routes.test.ts +44 -0
  87. package/src/presentation/e2e-routes.ts +24 -0
  88. package/src/presentation/index.ts +2 -2
  89. package/src/presentation/routes/fire-manual.test.ts +14 -30
  90. package/src/presentation/routes/fire-manual.ts +62 -54
  91. package/src/presentation/routes/index.ts +18 -15
  92. package/src/presentation/subscribers/index.ts +1 -1
  93. package/src/presentation/subscribers/on-integration-event-received.test.ts +6 -3
  94. package/src/presentation/subscribers/on-integration-event-received.ts +20 -16
  95. package/src/temporal/activities/drain-cron-batch.ts +5 -1
  96. package/src/temporal/activities/index.ts +3 -2
  97. 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": "5.0.0",
4
+ "version": "7.0.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -26,22 +26,22 @@
26
26
  "cron-parser": "^5.6.1",
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
- "@shipfox/api-auth-context": "5.0.0",
30
- "@shipfox/api-definitions-dto": "5.0.0",
31
- "@shipfox/api-integration-core-dto": "5.0.0",
32
- "@shipfox/api-projects": "5.0.0",
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
33
  "@shipfox/api-triggers-dto": "5.0.0",
34
- "@shipfox/api-workflows": "5.0.0",
35
- "@shipfox/api-workflows-dto": "5.0.0",
34
+ "@shipfox/api-workflows-dto": "6.0.0",
36
35
  "@shipfox/config": "1.2.2",
37
36
  "@shipfox/expression": "1.1.3",
38
- "@shipfox/node-drizzle": "0.3.1",
39
- "@shipfox/node-fastify": "0.2.3",
40
- "@shipfox/node-module": "0.3.2",
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
41
  "@shipfox/node-opentelemetry": "0.5.2",
42
- "@shipfox/node-outbox": "0.2.3",
42
+ "@shipfox/node-outbox": "0.2.4",
43
43
  "@shipfox/node-postgres": "0.4.2",
44
- "@shipfox/node-temporal": "0.3.1",
44
+ "@shipfox/node-temporal": "0.3.2",
45
45
  "@shipfox/workflow-document": "2.1.1"
46
46
  },
47
47
  "devDependencies": {
@@ -56,6 +56,7 @@
56
56
  "fastify-type-provider-zod": "^6.0.0",
57
57
  "fishery": "^2.4.0",
58
58
  "@shipfox/biome": "1.8.2",
59
+ "@shipfox/depcruise": "1.0.2",
59
60
  "@shipfox/swc": "1.2.6",
60
61
  "@shipfox/ts-config": "1.3.8",
61
62
  "@shipfox/typescript": "1.1.7",
@@ -65,6 +66,7 @@
65
66
  "build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
66
67
  "check": "shipfox-biome-check",
67
68
  "check:fix": "shipfox-biome-check --write",
69
+ "depcruise": "shipfox-depcruise",
68
70
  "test": "shipfox-vitest-run",
69
71
  "test:watch": "shipfox-vitest-watch",
70
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
- // Mock the package root but keep real-enough permanent-error classes + the classifier, so the
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
- import {DefinitionNotFoundError, ProjectMismatchError} from '@shipfox/api-workflows';
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
- // Import after mocks so the core dispatcher sees the spy.
40
- const {dispatchIntegrationEvent} = await import('./dispatch-integration-event.js');
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({triggerIdempotencyKey: `${subscription.id}:${eventRef}`}),
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 new DefinitionNotFoundError('def-gone');
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('Definition not found');
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 new ProjectMismatchError('proj-a', 'proj-b');
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 new DefinitionNotFoundError('def-gone');
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 new DefinitionNotFoundError('def-gone');
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.triggerIdempotencyKey);
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 runWorkflow({
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: readConfigInputs(subscription),
95
- triggerIdempotencyKey: `${subscription.id}:${params.eventRef}`,
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 (!isPermanentRunWorkflowError(error) && !sawTransientError) {
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
- vi.mock('@shipfox/api-workflows', () => {
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
- import {DefinitionNotFoundError} from '@shipfox/api-workflows';
17
+ const workflows = {startRunFromTrigger: (...args: unknown[]) => runWorkflow(...args)} as never;
28
18
 
29
- // Import after mocks so the function under test sees the spy.
30
- const {drainDueCronSchedules} = await import('./drain-cron-schedules.js');
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 {triggerIdempotencyKey?: string}).triggerIdempotencyKey === key,
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 drainDueCronSchedules({batchSize: 1000, jitterWindowSeconds: 0});
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 drainDueCronSchedules({batchSize: 1000, jitterWindowSeconds: 0});
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 drainDueCronSchedules({batchSize: 1000, jitterWindowSeconds: 0});
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 drainDueCronSchedules({batchSize: 1000, jitterWindowSeconds: 0});
143
+ await drain({batchSize: 1000, jitterWindowSeconds: 0});
149
144
 
150
145
  const [params] = callsWithKey(key)[0] as [Record<string, unknown>];
151
- expect(params.triggerIdempotencyKey).toBe(key);
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(new DefinitionNotFoundError('def-gone'));
160
+ runWorkflow.mockRejectedValue(
161
+ createInterModuleKnownError(
162
+ workflowsInterModuleContract.methods.startRunFromTrigger,
163
+ 'definition-not-found',
164
+ {definitionId: crypto.randomUUID()},
165
+ ),
166
+ );
166
167
 
167
- await drainDueCronSchedules({batchSize: 1000, jitterWindowSeconds: 0});
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 drainDueCronSchedules({batchSize: 1000, jitterWindowSeconds: 0});
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 drainDueCronSchedules({
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 drainDueCronSchedules({batchSize: 3, jitterWindowSeconds: 0});
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
- drainDueCronSchedules({batchSize: 1000, jitterWindowSeconds: 0}),
245
- drainDueCronSchedules({batchSize: 1000, jitterWindowSeconds: 0}),
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.triggerIdempotencyKey).toBe(`${subscription.id}:${SLOT.toISOString()}`);
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(new DefinitionNotFoundError('def-gone'));
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('Definition not found');
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);
@@ -1,11 +1,12 @@
1
- import {isPermanentRunWorkflowError, runWorkflow, type WorkflowRun} from '@shipfox/api-workflows';
2
1
  import {getTriggerSubscriptionById} from '#db/subscriptions.js';
3
2
  import {cronFiredCount, cronFireLag} from '#metrics/instance.js';
4
3
  import {readConfigInputs} from './config.js';
5
4
  import {TriggerSubscriptionNotCronError, TriggerSubscriptionNotFoundError} from './errors.js';
6
5
  import {beginTriggerHistory, toReason} from './record-trigger-history.js';
6
+ import {isPermanentStartRunError, type WorkflowsModuleClient} from './workflows-client.js';
7
7
 
8
8
  export interface FireCronSubscriptionParams {
9
+ workflows: WorkflowsModuleClient;
9
10
  subscriptionId: string;
10
11
  /**
11
12
  * The `next_fire_at` value the schedule held when it was claimed. Anchoring the
@@ -16,7 +17,7 @@ export interface FireCronSubscriptionParams {
16
17
  }
17
18
 
18
19
  export type FireCronSubscriptionResult =
19
- | {outcome: 'fired'; run: WorkflowRun}
20
+ | {outcome: 'fired'; run: {id: string; name: string}}
20
21
  | {outcome: 'errored'};
21
22
 
22
23
  /**
@@ -56,9 +57,10 @@ export async function fireCronSubscription(
56
57
  eventRef,
57
58
  };
58
59
 
59
- let run: WorkflowRun;
60
+ const inputs = readConfigInputs(subscription);
61
+ let run: {id: string; name: string};
60
62
  try {
61
- run = await runWorkflow({
63
+ run = await params.workflows.startRunFromTrigger({
62
64
  workspaceId: subscription.workspaceId,
63
65
  projectId: subscription.projectId,
64
66
  definitionId: subscription.workflowDefinitionId,
@@ -68,13 +70,13 @@ export async function fireCronSubscription(
68
70
  event: 'tick',
69
71
  scheduleId: subscription.id,
70
72
  },
71
- inputs: readConfigInputs(subscription),
72
- triggerIdempotencyKey: eventRef,
73
+ ...(inputs === undefined ? {} : {inputs}),
74
+ idempotencyKey: eventRef,
73
75
  });
74
76
  } catch (error) {
75
77
  const failure = await beginTriggerHistory(historyBase);
76
78
  await failure.dispatchErrored(subscription, toReason(error));
77
- if (isPermanentRunWorkflowError(error)) {
79
+ if (isPermanentStartRunError(error)) {
78
80
  recordFire('errored', params.scheduledSlot);
79
81
  await failure.allErrored(1);
80
82
  return {outcome: 'errored'};