@outputai/core 0.7.1-next.db8ddd7.0 → 0.7.1-next.ed233ce.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.
Files changed (77) hide show
  1. package/bin/worker.sh +6 -0
  2. package/package.json +1 -1
  3. package/src/consts.js +0 -4
  4. package/src/errors.js +6 -2
  5. package/src/interface/evaluator.js +7 -20
  6. package/src/interface/evaluator.spec.js +117 -1
  7. package/src/interface/step.js +8 -9
  8. package/src/interface/step.spec.js +124 -0
  9. package/src/interface/validations/index.js +108 -0
  10. package/src/interface/validations/index.spec.js +182 -0
  11. package/src/interface/validations/schemas.js +113 -0
  12. package/src/interface/validations/schemas.spec.js +209 -0
  13. package/src/interface/webhook.js +1 -1
  14. package/src/interface/webhook.spec.js +1 -1
  15. package/src/interface/workflow.d.ts +10 -9
  16. package/src/interface/workflow.js +76 -164
  17. package/src/interface/workflow.spec.js +637 -521
  18. package/src/interface/workflow_activity_options.js +16 -0
  19. package/src/interface/workflow_utils.js +1 -1
  20. package/src/interface/zod_integration.spec.js +2 -2
  21. package/src/internal_utils/aggregations.js +0 -10
  22. package/src/internal_utils/aggregations.spec.js +1 -48
  23. package/src/internal_utils/errors.js +14 -8
  24. package/src/internal_utils/errors.spec.js +73 -27
  25. package/src/utils/index.d.ts +19 -0
  26. package/src/utils/utils.js +53 -0
  27. package/src/utils/utils.spec.js +105 -1
  28. package/src/worker/bundle.js +26 -0
  29. package/src/worker/bundle.spec.js +53 -0
  30. package/src/worker/bundler_options.js +1 -1
  31. package/src/worker/bundler_options.spec.js +1 -1
  32. package/src/worker/catalog_workflow/catalog_job.js +148 -0
  33. package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
  34. package/src/worker/check.js +24 -0
  35. package/src/worker/connection_monitor.js +112 -0
  36. package/src/worker/connection_monitor.spec.js +199 -0
  37. package/src/worker/index.js +146 -41
  38. package/src/worker/index.spec.js +281 -109
  39. package/src/worker/interceptors/activity.js +7 -24
  40. package/src/worker/interceptors/activity.spec.js +97 -66
  41. package/src/worker/interceptors/index.js +4 -7
  42. package/src/worker/interceptors/modules.js +15 -0
  43. package/src/worker/interceptors/workflow.js +6 -8
  44. package/src/worker/interceptors/workflow.spec.js +49 -42
  45. package/src/worker/interruption.js +33 -0
  46. package/src/worker/interruption.spec.js +86 -0
  47. package/src/worker/loader/activities.js +75 -0
  48. package/src/worker/loader/activities.spec.js +213 -0
  49. package/src/worker/loader/hooks.js +28 -0
  50. package/src/worker/loader/hooks.spec.js +64 -0
  51. package/src/worker/loader/matchers.js +46 -0
  52. package/src/worker/loader/matchers.spec.js +140 -0
  53. package/src/worker/{loader_tools.js → loader/tools.js} +19 -67
  54. package/src/worker/{loader_tools.spec.js → loader/tools.spec.js} +53 -85
  55. package/src/worker/loader/workflows.js +82 -0
  56. package/src/worker/loader/workflows.spec.js +256 -0
  57. package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
  58. package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
  59. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
  60. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
  61. package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
  62. package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
  63. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
  64. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
  65. package/src/interface/validations/runtime.js +0 -20
  66. package/src/interface/validations/runtime.spec.js +0 -29
  67. package/src/interface/validations/schema_utils.js +0 -8
  68. package/src/interface/validations/schema_utils.spec.js +0 -67
  69. package/src/interface/validations/static.js +0 -137
  70. package/src/interface/validations/static.spec.js +0 -397
  71. package/src/interface/workflow.replay_compatibility.spec.js +0 -254
  72. package/src/worker/loader.js +0 -202
  73. package/src/worker/loader.spec.js +0 -498
  74. package/src/worker/shutdown.js +0 -26
  75. package/src/worker/shutdown.spec.js +0 -82
  76. package/src/worker/start_catalog.js +0 -96
  77. package/src/worker/start_catalog.spec.js +0 -179
@@ -1,12 +1,9 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { ACTIVITY_WRAPPER_VERSION_FIELD, BusEventType, Signal } from '#consts';
2
+ import { ApplicationFailure } from '@temporalio/common';
3
+ import { ACTIVITY_WRAPPER_VERSION_FIELD, BusEventType } from '#consts';
3
4
  import { Attribute } from '#trace_attribute';
4
5
 
5
6
  const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
6
- const workflowHandleMock = vi.hoisted( () => ( { signal: vi.fn() } ) );
7
- const getHandleMock = vi.hoisted( () => vi.fn( () => workflowHandleMock ) );
8
- const clientConstructorMock = vi.hoisted( () => vi.fn() );
9
- const logWarnMock = vi.hoisted( () => vi.fn() );
10
7
  const heartbeatMock = vi.hoisted( () => vi.fn() );
11
8
  const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async fn => fn() ) );
12
9
  const activityInfoMock = vi.hoisted( () => ( {
@@ -32,27 +29,19 @@ const workflowDetailsMock = vi.hoisted( () => ( {
32
29
  attempt: 1
33
30
  } ) );
34
31
 
35
- vi.mock( '@temporalio/activity', () => ( {
36
- activityInfo: () => activityInfoMock,
37
- Context: {
38
- current: () => ( {
39
- info: activityInfoMock,
40
- heartbeat: heartbeatMock
41
- } )
42
- }
43
- } ) );
44
-
45
- vi.mock( '@temporalio/client', () => ( {
46
- Client: class Client {
47
- constructor( options ) {
48
- clientConstructorMock( options );
32
+ vi.mock( '@temporalio/activity', async importOriginal => {
33
+ const actual = await importOriginal();
34
+ return {
35
+ ...actual,
36
+ activityInfo: () => activityInfoMock,
37
+ Context: {
38
+ current: () => ( {
39
+ info: activityInfoMock,
40
+ heartbeat: heartbeatMock
41
+ } )
49
42
  }
50
-
51
- workflow = {
52
- getHandle: getHandleMock
53
- };
54
- }
55
- } ) );
43
+ };
44
+ } );
56
45
 
57
46
  vi.mock( '#async_storage', () => ( {
58
47
  Storage: {
@@ -60,10 +49,6 @@ vi.mock( '#async_storage', () => ( {
60
49
  }
61
50
  } ) );
62
51
 
63
- vi.mock( '#logger', () => ( {
64
- createChildLogger: () => ( { warn: logWarnMock } )
65
- } ) );
66
-
67
52
  const addEventStartMock = vi.fn();
68
53
  const addEventEndMock = vi.fn();
69
54
  const addEventErrorMock = vi.fn();
@@ -95,9 +80,6 @@ vi.mock( '../configs.js', () => ( {
95
80
  },
96
81
  get activityHeartbeatIntervalMs() {
97
82
  return parseInt( process.env.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS || '120000', 10 );
98
- },
99
- get namespace() {
100
- return process.env.TEMPORAL_NAMESPACE || 'default';
101
83
  }
102
84
  } ) );
103
85
 
@@ -122,7 +104,6 @@ describe( 'ActivityExecutionInterceptor', () => {
122
104
  beforeEach( () => {
123
105
  vi.clearAllMocks();
124
106
  activityInfoMock.workflowType = 'myWorkflow';
125
- workflowHandleMock.signal.mockResolvedValue( undefined );
126
107
  vi.useFakeTimers();
127
108
  vi.resetModules();
128
109
  // Default: heartbeat enabled with 50ms interval for fast tests
@@ -167,7 +148,6 @@ describe( 'ActivityExecutionInterceptor', () => {
167
148
  } );
168
149
  expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'ok' }, traceInfo: traceInfoMock } );
169
150
  expect( addEventErrorMock ).not.toHaveBeenCalled();
170
- expect( clientConstructorMock ).not.toHaveBeenCalled();
171
151
  expect( runWithContextMock ).toHaveBeenCalledWith(
172
152
  expect.any( Function ),
173
153
  expect.objectContaining( {
@@ -180,7 +160,6 @@ describe( 'ActivityExecutionInterceptor', () => {
180
160
  addAttribute: expect.any( Function )
181
161
  } )
182
162
  );
183
- expect( getHandleMock ).not.toHaveBeenCalled();
184
163
  } );
185
164
 
186
165
  it( 'handles next returning a non-Promise value', async () => {
@@ -199,7 +178,7 @@ describe( 'ActivityExecutionInterceptor', () => {
199
178
  expect( addEventErrorMock ).not.toHaveBeenCalled();
200
179
  } );
201
180
 
202
- it( 'does not signal collected attributes after successful execution', async () => {
181
+ it( 'returns collected aggregations after successful execution', async () => {
203
182
  runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
204
183
  ctx.addAttribute( httpRequestAttribute );
205
184
  return fn();
@@ -218,11 +197,9 @@ describe( 'ActivityExecutionInterceptor', () => {
218
197
  [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
219
198
  } );
220
199
 
221
- expect( workflowHandleMock.signal ).not.toHaveBeenCalled();
222
- expect( clientConstructorMock ).not.toHaveBeenCalled();
223
200
  } );
224
201
 
225
- it( 'signals collected aggregations after failed execution', async () => {
202
+ it( 'stores collected aggregations in ApplicationFailure details after failed execution', async () => {
226
203
  runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
227
204
  ctx.addAttribute( httpRequestAttribute );
228
205
  return fn();
@@ -232,52 +209,106 @@ describe( 'ActivityExecutionInterceptor', () => {
232
209
  const error = new Error( 'step failed' );
233
210
  const next = vi.fn().mockRejectedValue( error );
234
211
 
235
- await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
236
-
237
- expect( clientConstructorMock ).toHaveBeenCalledWith( { connection: undefined, namespace: 'default' } );
238
- expect( getHandleMock ).toHaveBeenCalledWith( 'wf-1' );
239
- expect( workflowHandleMock.signal ).toHaveBeenCalledWith( Signal.SEND_AGGREGATIONS, {
240
- cost: { total: 0 },
241
- tokens: { total: 0 },
242
- httpRequests: { total: 1 }
212
+ const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
213
+ expect( thrown ).toBeInstanceOf( ApplicationFailure );
214
+ expect( thrown ).toMatchObject( {
215
+ message: 'step failed',
216
+ type: 'Error',
217
+ details: [ {
218
+ aggregations: {
219
+ cost: { total: 0 },
220
+ tokens: { total: 0 },
221
+ httpRequests: { total: 1 }
222
+ }
223
+ } ],
224
+ cause: error
243
225
  } );
244
226
  expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, expect.objectContaining( { error } ) );
245
227
  expect( addEventErrorMock ).toHaveBeenCalledOnce();
246
228
  expect( addEventEndMock ).not.toHaveBeenCalled();
247
229
  } );
248
230
 
249
- it( 'does not send fallback signal when failed execution collected no attributes', async () => {
231
+ it( 'appends collected aggregations to existing failure details', async () => {
232
+ runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
233
+ ctx.addAttribute( httpRequestAttribute );
234
+ return fn();
235
+ } );
250
236
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
251
237
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
252
- const next = vi.fn().mockRejectedValue( new Error( 'step failed' ) );
238
+ const error = new Error( 'step failed' );
239
+ error.details = [ { domain: { reason: 'bad-input' } } ];
240
+ const next = vi.fn().mockRejectedValue( error );
241
+
242
+ const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
243
+
244
+ expect( thrown.details ).toEqual( [
245
+ { domain: { reason: 'bad-input' } },
246
+ {
247
+ aggregations: {
248
+ cost: { total: 0 },
249
+ tokens: { total: 0 },
250
+ httpRequests: { total: 1 }
251
+ }
252
+ }
253
+ ] );
254
+ } );
253
255
 
254
- await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
256
+ it( 'rethrows the original error when failed execution collected no attributes', async () => {
257
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
258
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
259
+ const error = new Error( 'step failed' );
260
+ const next = vi.fn().mockRejectedValue( error );
255
261
 
256
- expect( workflowHandleMock.signal ).not.toHaveBeenCalled();
257
- expect( clientConstructorMock ).not.toHaveBeenCalled();
262
+ await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
258
263
  } );
259
264
 
260
- it( 'logs when fallback attribute signal fails', async () => {
261
- const signalError = new Error( 'signal failed' );
262
- workflowHandleMock.signal.mockRejectedValueOnce( signalError );
265
+ it( 'rethrows the original error with existing details when failed execution collected no attributes', async () => {
266
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
267
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
268
+ const error = new Error( 'step failed' );
269
+ error.details = [ { domain: { reason: 'bad-input' } } ];
270
+ const next = vi.fn().mockRejectedValue( error );
271
+
272
+ await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
273
+ expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
274
+ } );
275
+
276
+ it( 'wraps existing ApplicationFailure without creating a self-cause', async () => {
263
277
  runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
264
278
  ctx.addAttribute( httpRequestAttribute );
265
279
  return fn();
266
280
  } );
281
+ const error = ApplicationFailure.create( {
282
+ message: 'application failed',
283
+ type: 'OriginalType',
284
+ nonRetryable: true,
285
+ details: [ { domain: { reason: 'bad-input' } } ]
286
+ } );
267
287
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
268
288
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
269
- const next = vi.fn().mockRejectedValue( new Error( 'step failed' ) );
270
-
271
- await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
289
+ const next = vi.fn().mockRejectedValue( error );
272
290
 
273
- expect( logWarnMock ).toHaveBeenCalledWith( `Signal "${Signal.SEND_AGGREGATIONS}" failed`, expect.objectContaining( {
274
- message: 'signal failed',
275
- activityId: 'act-1',
276
- activityType: 'myWorkflow#myStep',
277
- workflowId: 'wf-1',
278
- workflowType: 'myWorkflow',
279
- runId: 'run-1'
280
- } ) );
291
+ const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
292
+
293
+ expect( thrown ).toBeInstanceOf( ApplicationFailure );
294
+ expect( thrown ).not.toBe( error );
295
+ expect( thrown.cause ).toBe( error );
296
+ expect( thrown.cause ).not.toBe( thrown );
297
+ expect( thrown ).toMatchObject( {
298
+ message: 'application failed',
299
+ type: 'OriginalType',
300
+ nonRetryable: true,
301
+ details: [
302
+ { domain: { reason: 'bad-input' } },
303
+ {
304
+ aggregations: {
305
+ cost: { total: 0 },
306
+ tokens: { total: 0 },
307
+ httpRequests: { total: 1 }
308
+ }
309
+ }
310
+ ]
311
+ } );
281
312
  } );
282
313
 
283
314
  it( 'records trace error event on failed execution', async () => {
@@ -1,14 +1,11 @@
1
- import { dirname, join } from 'path';
2
- import { fileURLToPath } from 'node:url';
3
1
  import { ActivityExecutionInterceptor } from './activity.js';
2
+ import { workflowInterceptorModules } from './modules.js';
4
3
 
5
- const __dirname = dirname( fileURLToPath( import.meta.url ) );
6
-
7
- export const initInterceptors = ( { activities, workflows, connection } ) => ( {
8
- workflowModules: [ join( __dirname, './workflow.js' ) ],
4
+ export const initInterceptors = ( { activities, workflows } ) => ( {
5
+ workflowModules: workflowInterceptorModules,
9
6
  activity: [
10
7
  () => ( {
11
- inbound: new ActivityExecutionInterceptor( { activities, workflows, connection } )
8
+ inbound: new ActivityExecutionInterceptor( { activities, workflows } )
12
9
  } )
13
10
  ]
14
11
  } );
@@ -0,0 +1,15 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ const __dirname = dirname( fileURLToPath( import.meta.url ) );
5
+
6
+ /**
7
+ * Workflow-side interceptor modules bundled into the workflow code.
8
+ *
9
+ * Kept in its own module — free of activity/config imports — so the bundle check
10
+ * (`bundleWorkflows`) can register the exact same modules as the worker without
11
+ * pulling in worker runtime config (e.g. the OUTPUT_CATALOG_ID env validation).
12
+ * This is the single source of truth shared by `initInterceptors` and the check,
13
+ * keeping `output-worker --check` in parity with `Worker.create`.
14
+ */
15
+ export const workflowInterceptorModules = [ join( __dirname, './workflow.js' ) ];
@@ -1,11 +1,13 @@
1
1
  // THIS RUNS IN THE TEMPORAL'S SANDBOX ENVIRONMENT
2
- import { workflowInfo, proxySinks, ApplicationFailure, ContinueAsNew, isCancellation } from '@temporalio/workflow';
2
+ import { workflowInfo, proxySinks, ContinueAsNew, isCancellation } from '@temporalio/workflow';
3
3
  import { memoToHeaders } from './headers.js';
4
4
  import { deepMerge } from '#utils';
5
+ import { buildApplicationFailureWithDetails } from '#internal_utils/errors';
5
6
  import { METADATA_ACCESS_SYMBOL, WorkflowSpecialOutput } from '#consts';
7
+ import { createWorkflowDetails } from '#internal_utils/temporal_context';
8
+
6
9
  // this is a dynamic generated file with activity configs overwrites
7
10
  import stepOptions from '../temp/__activity_options.js';
8
- import { createWorkflowDetails } from '#internal_utils/temporal_context';
9
11
 
10
12
  /*
11
13
  This interceptor adds Memo and serialized workflowInfo() to the Activity invocation headers.
@@ -55,17 +57,13 @@ class WorkflowExecutionInterceptor {
55
57
  }
56
58
 
57
59
  sinks.workflow.error( error );
58
- const failure = new ApplicationFailure( error.message, error.constructor.name, undefined, undefined, error );
59
60
 
60
61
  /*
61
- * If intercepted error has metadata, set it to .details property of Temporal's ApplicationFailure instance.
62
+ * Add internal error .details to Temporal's ApplicationFailure .details
62
63
  * This make it possible for this information be retrieved by Temporal's client instance.
63
64
  * Ref: https://typescript.temporal.io/api/classes/common.ApplicationFailure#details
64
65
  */
65
- if ( error[METADATA_ACCESS_SYMBOL] ) {
66
- failure.details = [ error[METADATA_ACCESS_SYMBOL] ];
67
- }
68
- throw failure;
66
+ throw error[METADATA_ACCESS_SYMBOL] ? buildApplicationFailureWithDetails( error, error[METADATA_ACCESS_SYMBOL] ) : error;
69
67
  }
70
68
  }
71
69
  };
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
-
3
- const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
2
+ import { ApplicationFailure } from '@temporalio/common';
3
+ import { METADATA_ACCESS_SYMBOL } from '#consts';
4
4
 
5
5
  const workflowInfoMock = vi.fn();
6
6
  const workflowStartMock = vi.fn();
@@ -41,16 +41,6 @@ vi.mock( '@temporalio/workflow', () => ( {
41
41
  proxySinks: () => ( {
42
42
  workflow: { start: workflowStartMock, end: workflowEndMock, error: workflowErrorMock }
43
43
  } ),
44
- ApplicationFailure: class ApplicationFailure {
45
- constructor( message, type, nonRetryable, cause, originalError ) {
46
- this.message = message;
47
- this.type = type;
48
- this.nonRetryable = nonRetryable;
49
- this.cause = cause;
50
- this.originalError = originalError;
51
- this.details = undefined;
52
- }
53
- },
54
44
  ContinueAsNew: class ContinueAsNew extends Error {
55
45
  constructor() {
56
46
  super( 'ContinueAsNew' );
@@ -66,15 +56,6 @@ vi.mock( './headers.js', () => ( { memoToHeaders: ( ...args ) => memoToHeadersMo
66
56
  const deepMergeMock = vi.fn( ( a, b ) => ( { ...( a || {} ), ...( b || {} ) } ) );
67
57
  vi.mock( '#utils', () => ( { deepMerge: ( ...args ) => deepMergeMock( ...args ) } ) );
68
58
 
69
- vi.mock( '#consts', async importOriginal => {
70
- const actual = await importOriginal();
71
- return {
72
- ...actual, get METADATA_ACCESS_SYMBOL() {
73
- return METADATA_ACCESS_SYMBOL;
74
- }
75
- };
76
- } );
77
-
78
59
  const stepOptionsDefault = {};
79
60
  vi.mock( '../temp/__activity_options.js', () => ( { default: stepOptionsDefault } ) );
80
61
 
@@ -150,7 +131,7 @@ describe( 'workflow interceptors', () => {
150
131
  expect( workflowErrorMock ).not.toHaveBeenCalled();
151
132
  } );
152
133
 
153
- it( 'calls sinks.workflow.error and throws ApplicationFailure on error', async () => {
134
+ it( 'calls sinks.workflow.error and rethrows workflow errors', async () => {
154
135
  const { interceptors } = await import( './workflow.js' );
155
136
  const { inbound } = interceptors();
156
137
  const interceptor = inbound[0];
@@ -158,41 +139,68 @@ describe( 'workflow interceptors', () => {
158
139
  const err = new Error( 'workflow failed' );
159
140
  const next = vi.fn().mockRejectedValue( err );
160
141
 
161
- await expect( interceptor.execute( input, next ) ).rejects.toMatchObject( {
142
+ await expect( interceptor.execute( input, next ) ).rejects.toBe( err );
143
+ expect( workflowStartMock ).toHaveBeenCalled();
144
+ expect( workflowErrorMock ).toHaveBeenCalledWith( err );
145
+ expect( workflowEndMock ).not.toHaveBeenCalled();
146
+ } );
147
+
148
+ it( 'wraps workflow errors with metadata in ApplicationFailure details', async () => {
149
+ const { interceptors } = await import( './workflow.js' );
150
+ const { inbound } = interceptors();
151
+ const interceptor = inbound[0];
152
+ const trace = { trace: { destinations: { local: '/tmp/trace' } } };
153
+ const err = new Error( 'workflow failed' );
154
+ err[METADATA_ACCESS_SYMBOL] = trace;
155
+ const next = vi.fn().mockRejectedValue( err );
156
+
157
+ const thrown = await interceptor.execute( { args: [ {} ] }, next ).catch( e => e );
158
+
159
+ expect( thrown ).toBeInstanceOf( ApplicationFailure );
160
+ expect( thrown ).toMatchObject( {
162
161
  message: 'workflow failed',
163
162
  type: 'Error',
164
- originalError: err
163
+ details: [ trace ],
164
+ cause: err
165
165
  } );
166
- expect( workflowStartMock ).toHaveBeenCalled();
167
166
  expect( workflowErrorMock ).toHaveBeenCalledWith( err );
168
167
  expect( workflowEndMock ).not.toHaveBeenCalled();
169
168
  } );
170
169
 
171
- it( 'sets failure.details from error metadata when present', async () => {
170
+ it( 'preserves ApplicationFailure metadata when wrapping workflow errors with details', async () => {
172
171
  const { interceptors } = await import( './workflow.js' );
173
- const { ApplicationFailure } = await import( '@temporalio/workflow' );
174
172
  const { inbound } = interceptors();
175
173
  const interceptor = inbound[0];
176
- const meta = { code: 'CUSTOM' };
177
- const err = new Error( 'custom' );
178
- err[METADATA_ACCESS_SYMBOL] = meta;
174
+ const trace = { trace: { destinations: { local: '/tmp/trace' } } };
175
+ const err = ApplicationFailure.create( {
176
+ message: 'domain failed',
177
+ type: 'DomainFailure',
178
+ nonRetryable: true,
179
+ details: [ { domain: { reason: 'bad-input' } } ]
180
+ } );
181
+ err[METADATA_ACCESS_SYMBOL] = trace;
179
182
  const next = vi.fn().mockRejectedValue( err );
180
183
 
181
- const error = await ( async () => {
182
- try {
183
- await interceptor.execute( { args: [ {} ] }, next );
184
- return null;
185
- } catch ( error ) {
186
- return error;
187
- }
188
- } )();
189
- expect( error ).toBeInstanceOf( ApplicationFailure );
190
- expect( error.details ).toEqual( [ meta ] );
184
+ const thrown = await interceptor.execute( { args: [ {} ] }, next ).catch( e => e );
185
+
186
+ expect( thrown ).toBeInstanceOf( ApplicationFailure );
187
+ expect( thrown ).not.toBe( err );
188
+ expect( thrown.cause ).toBe( err );
189
+ expect( thrown.cause ).not.toBe( thrown );
190
+ expect( thrown ).toMatchObject( {
191
+ message: 'domain failed',
192
+ type: 'DomainFailure',
193
+ nonRetryable: true,
194
+ details: [
195
+ { domain: { reason: 'bad-input' } },
196
+ trace
197
+ ]
198
+ } );
199
+ expect( workflowErrorMock ).toHaveBeenCalledWith( err );
191
200
  } );
192
201
 
193
202
  it( 'calls sinks.workflow.error and rethrows cancellation errors without wrapping', async () => {
194
203
  const { interceptors } = await import( './workflow.js' );
195
- const { ApplicationFailure } = await import( '@temporalio/workflow' );
196
204
  const { inbound } = interceptors();
197
205
  const interceptor = inbound[0];
198
206
  const cancellation = new Error( 'Workflow cancelled' );
@@ -201,7 +209,6 @@ describe( 'workflow interceptors', () => {
201
209
 
202
210
  await expect( interceptor.execute( { args: [ {} ] }, next ) ).rejects.toBe( cancellation );
203
211
  expect( isCancellationMock ).toHaveBeenCalledWith( cancellation );
204
- expect( cancellation ).not.toBeInstanceOf( ApplicationFailure );
205
212
  expect( workflowErrorMock ).toHaveBeenCalledWith( cancellation );
206
213
  expect( workflowEndMock ).not.toHaveBeenCalled();
207
214
  } );
@@ -0,0 +1,33 @@
1
+ import { createChildLogger } from '#logger';
2
+
3
+ const log = createChildLogger( 'Interruption' );
4
+
5
+ const FORCE_QUIT_GRACE_MS = 1000;
6
+
7
+ export const setupInterruptionHandler = cb => {
8
+ const state = { interruptionReceivedAt: null };
9
+
10
+ const handle = signal => {
11
+ log.info( 'Signal Received', { signal } );
12
+
13
+ if ( state.interruptionReceivedAt ) {
14
+ const elapsed = Date.now() - state.interruptionReceivedAt;
15
+
16
+ // If running with npx, 2 kill signals are received in rapid succession,
17
+ // this ignores the second interruption when it is right after the first.
18
+ if ( elapsed < FORCE_QUIT_GRACE_MS ) {
19
+ return;
20
+ }
21
+ log.warn( 'Force quitting...' );
22
+ process.exit( 1 );
23
+ return;
24
+ }
25
+
26
+ state.interruptionReceivedAt = Date.now();
27
+ log.warn( 'Initiating shutdown...' );
28
+ cb();
29
+ };
30
+
31
+ process.on( 'SIGTERM', () => handle( 'SIGTERM' ) );
32
+ process.on( 'SIGINT', () => handle( 'SIGINT' ) );
33
+ };
@@ -0,0 +1,86 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { setupInterruptionHandler } from './interruption.js';
3
+
4
+ const { mockLog } = vi.hoisted( () => ( {
5
+ mockLog: { info: vi.fn(), warn: vi.fn() }
6
+ } ) );
7
+
8
+ vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
9
+
10
+ describe( 'setupInterruptionHandler', () => {
11
+ const onHandlers = {};
12
+ const callback = vi.fn();
13
+ const exitMock = vi.fn();
14
+ const originalOn = process.on;
15
+ const originalExit = process.exit;
16
+
17
+ beforeEach( () => {
18
+ vi.clearAllMocks();
19
+ Object.keys( onHandlers ).forEach( key => delete onHandlers[key] );
20
+ process.on = vi.fn( ( event, handler ) => {
21
+ onHandlers[event] = handler;
22
+ } );
23
+ process.exit = exitMock;
24
+ } );
25
+
26
+ afterEach( () => {
27
+ vi.useRealTimers();
28
+ process.on = originalOn;
29
+ process.exit = originalExit;
30
+ } );
31
+
32
+ it( 'registers SIGTERM and SIGINT handlers', () => {
33
+ setupInterruptionHandler( callback );
34
+
35
+ expect( process.on ).toHaveBeenCalledWith( 'SIGTERM', expect.any( Function ) );
36
+ expect( process.on ).toHaveBeenCalledWith( 'SIGINT', expect.any( Function ) );
37
+ } );
38
+
39
+ it( 'logs and invokes callback on first SIGTERM', () => {
40
+ setupInterruptionHandler( callback );
41
+
42
+ onHandlers.SIGTERM();
43
+
44
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Signal Received', { signal: 'SIGTERM' } );
45
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Initiating shutdown...' );
46
+ expect( callback ).toHaveBeenCalledOnce();
47
+ expect( exitMock ).not.toHaveBeenCalled();
48
+ } );
49
+
50
+ it( 'logs and invokes callback on first SIGINT', () => {
51
+ setupInterruptionHandler( callback );
52
+
53
+ onHandlers.SIGINT();
54
+
55
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Signal Received', { signal: 'SIGINT' } );
56
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Initiating shutdown...' );
57
+ expect( callback ).toHaveBeenCalledOnce();
58
+ expect( exitMock ).not.toHaveBeenCalled();
59
+ } );
60
+
61
+ it( 'ignores a second signal received within the grace period', () => {
62
+ vi.useFakeTimers();
63
+ setupInterruptionHandler( callback );
64
+
65
+ onHandlers.SIGTERM();
66
+ onHandlers.SIGINT();
67
+
68
+ expect( callback ).toHaveBeenCalledOnce();
69
+ expect( mockLog.warn ).toHaveBeenCalledTimes( 1 );
70
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Initiating shutdown...' );
71
+ expect( exitMock ).not.toHaveBeenCalled();
72
+ } );
73
+
74
+ it( 'force quits on a second signal after the grace period', () => {
75
+ vi.useFakeTimers();
76
+ setupInterruptionHandler( callback );
77
+
78
+ onHandlers.SIGTERM();
79
+ vi.advanceTimersByTime( 1001 );
80
+ onHandlers.SIGINT();
81
+
82
+ expect( callback ).toHaveBeenCalledOnce();
83
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Force quitting...' );
84
+ expect( exitMock ).toHaveBeenCalledWith( 1 );
85
+ } );
86
+ } );
@@ -0,0 +1,75 @@
1
+ import { dirname } from 'node:path';
2
+ import { getTraceDestinations, sendHttpRequest } from '#internal_activities';
3
+ import { findSharedActivitiesFromWorkflows, importComponents, matchFiles, writeFileInTempDir } from './tools.js';
4
+ import { buildActivityMatcher, staticMatchers } from './matchers.js';
5
+ import { ACTIVITY_SEND_HTTP_REQUEST, ACTIVITY_OPTIONS_FILENAME, SHARED_STEP_PREFIX, ACTIVITY_GET_TRACE_DESTINATIONS } from '#consts';
6
+ import { createChildLogger } from '#logger';
7
+ import { ValidationError } from '#errors';
8
+
9
+ const log = createChildLogger( 'Activities Loader' );
10
+
11
+ /**
12
+ * Load activities:
13
+ * - Scans activities based on workflows, using each workflow folder as a point to lookup for steps, evaluators files;
14
+ * - Scans shared activities in the rootDir;
15
+ * - Loads internal activities as well;
16
+ *
17
+ * Builds a map of activities, where they is generated according to the type of activity and the value is the function itself and return it.
18
+ * - Shared activity keys have a common prefix followed by the activity name;
19
+ * - Internal activities are registered with a fixed key;
20
+ * - Workflow activities keys are composed using the workflow name and the activity name;
21
+ *
22
+ * @param {string} rootDir
23
+ * @param {import('./workflows.js').Workflow[]} workflows
24
+ * @returns {object}
25
+ */
26
+ export async function loadActivities( rootDir, workflows ) {
27
+ const activities = {};
28
+ const activityOptionsMap = {};
29
+
30
+ // Load workflow-based activities
31
+ for ( const { path: workflowPath, name: workflowName, external } of workflows ) {
32
+ const dir = dirname( workflowPath );
33
+ for await ( const { fn, metadata, path } of importComponents( matchFiles( dir, [ buildActivityMatcher( dir ) ] ) ) ) {
34
+ // Activities loaded from a workflow path will use the workflow name as a namespace, which is unique across the platform, avoiding collision
35
+ const activityKey = `${workflowName}#${metadata.name}`;
36
+
37
+ log.info( metadata.name, { workflow: workflowName, type: metadata.type, ...( external && { external } ), path } );
38
+
39
+ if ( activities[activityKey] ) {
40
+ throw new ValidationError( `Activity "${metadata.name}" in workflow "${workflowName}" conflicts with another \
41
+ activity in the same workflow. Activity names must be unique within a workflow.` );
42
+ }
43
+ activities[activityKey] = fn;
44
+ // propagate the custom options set on the step()/evaluator() constructor
45
+ activityOptionsMap[activityKey] = metadata.options?.activityOptions ?? undefined;
46
+ }
47
+ }
48
+
49
+ // Load shared activities/evaluators from local and external npm modules
50
+ const localSharedActivities = matchFiles( rootDir, [ staticMatchers.sharedStepsDir, staticMatchers.sharedEvaluatorsDir ] );
51
+ const externalSharedActivities = findSharedActivitiesFromWorkflows( workflows.filter( w => w.external ) );
52
+ for await ( const { fn, metadata, path } of importComponents( [ ...localSharedActivities, ...externalSharedActivities ] ) ) {
53
+ const external = externalSharedActivities.some( a => a.path === path );
54
+ // Uses a global namespace for shared activities
55
+ const activityKey = `${SHARED_STEP_PREFIX}#${metadata.name}`;
56
+
57
+ log.info( metadata.name, { shared: true, type: metadata.type, ...( external && { external } ), path } );
58
+
59
+ if ( activities[activityKey] ) {
60
+ throw new ValidationError( `Shared activity "${metadata.name}" conflicts with another shared activity. \
61
+ Shared activity names must be unique.` );
62
+ }
63
+ activities[activityKey] = fn;
64
+ activityOptionsMap[activityKey] = metadata.options?.activityOptions ?? undefined;
65
+ }
66
+
67
+ // writes down the activity option overrides
68
+ const optionsContent = `export default ${JSON.stringify( activityOptionsMap, undefined, 2 )};`;
69
+ const optionsFile = writeFileInTempDir( optionsContent, ACTIVITY_OPTIONS_FILENAME );
70
+
71
+ // system activities
72
+ activities[ACTIVITY_SEND_HTTP_REQUEST] = sendHttpRequest;
73
+ activities[ACTIVITY_GET_TRACE_DESTINATIONS] = getTraceDestinations;
74
+ return { activities, optionsFile };
75
+ };