@output.ai/core 0.4.9 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/core",
3
- "version": "0.4.9",
3
+ "version": "0.5.1",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.d.ts CHANGED
@@ -95,6 +95,17 @@ export type ParallelJobResult<T> =
95
95
  ╰─────────╯
96
96
  */
97
97
 
98
+ /**
99
+ * Options for a step.
100
+ */
101
+ export type StepOptions = {
102
+
103
+ /**
104
+ * Temporal activity options for this step.
105
+ */
106
+ activityOptions?: TemporalActivityOptions
107
+ };
108
+
98
109
  /**
99
110
  * The handler function of a step.
100
111
  *
@@ -205,7 +216,7 @@ export type StepFunctionWrapper<StepFunction> =
205
216
  * @param params.inputSchema - Zod schema for the `fn` input
206
217
  * @param params.outputSchema - Zod schema for the `fn` output
207
218
  * @param params.fn - A handler function containing the step code
208
- * @param params.options - Temporal Activity options
219
+ * @param params.options - Optional step options.
209
220
  * @returns The same handler function set at `fn`
210
221
  */
211
222
  export declare function step<
@@ -217,7 +228,7 @@ export declare function step<
217
228
  inputSchema?: InputSchema;
218
229
  outputSchema?: OutputSchema;
219
230
  fn: StepFunction<InputSchema, OutputSchema>;
220
- options?: TemporalActivityOptions;
231
+ options?: StepOptions;
221
232
  } ): StepFunctionWrapper<StepFunction<InputSchema, OutputSchema>>;
222
233
 
223
234
  /*
@@ -235,7 +246,7 @@ export declare function step<
235
246
  export type WorkflowInvocationConfiguration<Context extends WorkflowContext = WorkflowContext> = {
236
247
 
237
248
  /**
238
- * Native Temporal Activity options
249
+ * Temporal activity options for this invocation (overrides the workflow's default activity options).
239
250
  */
240
251
  options?: TemporalActivityOptions,
241
252
 
@@ -251,6 +262,22 @@ export type WorkflowInvocationConfiguration<Context extends WorkflowContext = Wo
251
262
  context?: DeepPartial<Context>
252
263
  };
253
264
 
265
+ /**
266
+ * Options for a workflow.
267
+ */
268
+ export type WorkflowOptions = {
269
+
270
+ /**
271
+ * Temporal activity options for activities invoked by this workflow.
272
+ */
273
+ activityOptions?: TemporalActivityOptions,
274
+
275
+ /**
276
+ * When `true`, disables trace file generation for this workflow. Only has effect when tracing is enabled.
277
+ */
278
+ disableTrace?: boolean
279
+ };
280
+
254
281
  /**
255
282
  * The second argument passed to the workflow's `fn` function.
256
283
  */
@@ -467,7 +494,7 @@ export type WorkflowFunctionWrapper<WorkflowFunction> =
467
494
  * @param params.inputSchema - Zod schema for workflow input
468
495
  * @param params.outputSchema - Zod schema for workflow output
469
496
  * @param params.fn - A function containing the workflow code
470
- * @param params.options - Temporal Activity options
497
+ * @param params.options - Optional workflow options.
471
498
  * @returns The same handler function set at `fn` with a different signature
472
499
  */
473
500
  export declare function workflow<
@@ -479,7 +506,7 @@ export declare function workflow<
479
506
  inputSchema?: InputSchema;
480
507
  outputSchema?: OutputSchema;
481
508
  fn: WorkflowFunction<InputSchema, OutputSchema>;
482
- options?: TemporalActivityOptions;
509
+ options?: WorkflowOptions;
483
510
  } ): WorkflowFunctionWrapper<WorkflowFunction<InputSchema, OutputSchema>>;
484
511
 
485
512
  /*
@@ -648,6 +675,17 @@ export class EvaluationBooleanResult extends EvaluationResult {
648
675
  constructor( args: EvaluationResultArgs<boolean> );
649
676
  }
650
677
 
678
+ /**
679
+ * Options for an evaluator.
680
+ */
681
+ export type EvaluatorOptions = {
682
+
683
+ /**
684
+ * Temporal activity options for this evaluator.
685
+ */
686
+ activityOptions?: TemporalActivityOptions
687
+ };
688
+
651
689
  /**
652
690
  * The handler function of an evaluator.
653
691
  *
@@ -690,7 +728,7 @@ export type EvaluatorFunctionWrapper<EvaluatorFunction> =
690
728
  * @param params.description - Description of the evaluator
691
729
  * @param params.inputSchema - Zod schema for the `fn` input
692
730
  * @param params.fn - A function containing the evaluator code
693
- * @param params.options - Temporal Activity options
731
+ * @param params.options - Optional evaluator options.
694
732
  * @returns A wrapper function around the `fn` function
695
733
  */
696
734
  export declare function evaluator<
@@ -701,7 +739,7 @@ export declare function evaluator<
701
739
  description?: string;
702
740
  inputSchema: InputSchema;
703
741
  fn: EvaluatorFunction<InputSchema, Result>;
704
- options?: TemporalActivityOptions;
742
+ options?: EvaluatorOptions;
705
743
  } ): EvaluatorFunctionWrapper<EvaluatorFunction<InputSchema, Result>>;
706
744
 
707
745
  /*
@@ -34,32 +34,42 @@ export const prioritySchema = z.object( {
34
34
  priorityKey: z.number().min( 1 ).optional()
35
35
  } );
36
36
 
37
- const stepAndWorkflowSchema = z.strictObject( {
37
+ const baseSchema = z.strictObject( {
38
38
  name: z.string().regex( /^[a-z_][a-z0-9_]*$/i ),
39
39
  description: z.string().optional(),
40
40
  inputSchema: z.any().optional().superRefine( refineSchema ),
41
41
  outputSchema: z.any().optional().superRefine( refineSchema ),
42
42
  fn: z.function(),
43
- options: z.strictObject( {
44
- activityId: z.string().optional(),
45
- cancellationType: z.enum( [ 'TRY_CANCEL', 'WAIT_CANCELLATION_COMPLETED', 'ABANDON' ] ).optional(),
46
- heartbeatTimeout: durationSchema.optional(),
47
- priority: prioritySchema.optional(),
48
- retry: z.strictObject( {
49
- initialInterval: durationSchema.optional(),
50
- backoffCoefficient: z.number().gte( 1 ).optional(),
51
- maximumInterval: durationSchema.optional(),
52
- maximumAttempts: z.number().gte( 1 ).int().optional(),
53
- nonRetryableErrorTypes: z.array( z.string() ).optional()
54
- } ).optional(),
55
- scheduleToCloseTimeout: durationSchema.optional(),
56
- scheduleToStartTimeout: durationSchema.optional(),
57
- startToCloseTimeout: durationSchema.optional(),
58
- summary: z.string().optional()
43
+ options: z.object( {
44
+ activityOptions: z.strictObject( {
45
+ activityId: z.string().optional(),
46
+ cancellationType: z.enum( [ 'TRY_CANCEL', 'WAIT_CANCELLATION_COMPLETED', 'ABANDON' ] ).optional(),
47
+ heartbeatTimeout: durationSchema.optional(),
48
+ priority: prioritySchema.optional(),
49
+ retry: z.strictObject( {
50
+ initialInterval: durationSchema.optional(),
51
+ backoffCoefficient: z.number().gte( 1 ).optional(),
52
+ maximumInterval: durationSchema.optional(),
53
+ maximumAttempts: z.number().gte( 1 ).int().optional(),
54
+ nonRetryableErrorTypes: z.array( z.string() ).optional()
55
+ } ).optional(),
56
+ scheduleToCloseTimeout: durationSchema.optional(),
57
+ scheduleToStartTimeout: durationSchema.optional(),
58
+ startToCloseTimeout: durationSchema.optional(),
59
+ summary: z.string().optional()
60
+ } ).optional()
59
61
  } ).optional()
60
62
  } );
61
63
 
62
- const evaluatorSchema = stepAndWorkflowSchema.omit( { outputSchema: true } );
64
+ const stepSchema = baseSchema;
65
+
66
+ const workflowSchema = baseSchema.extend( {
67
+ options: baseSchema.shape.options.unwrap().extend( {
68
+ disableTrace: z.boolean().optional().default( false )
69
+ } ).optional()
70
+ } );
71
+
72
+ const evaluatorSchema = baseSchema.omit( { outputSchema: true } );
63
73
 
64
74
  const httpRequestSchema = z.object( {
65
75
  url: z.url( { protocol: /^https?$/ } ),
@@ -82,7 +92,7 @@ const validateAgainstSchema = ( schema, args ) => {
82
92
  * @throws {StaticValidationError} Throws if args are invalid
83
93
  */
84
94
  export function validateStep( args ) {
85
- validateAgainstSchema( stepAndWorkflowSchema, args );
95
+ validateAgainstSchema( stepSchema, args );
86
96
  };
87
97
 
88
98
  /**
@@ -102,7 +112,7 @@ export function validateEvaluator( args ) {
102
112
  * @throws {StaticValidationError} Throws if args are invalid
103
113
  */
104
114
  export function validateWorkflow( args ) {
105
- validateAgainstSchema( stepAndWorkflowSchema, args );
115
+ validateAgainstSchema( workflowSchema, args );
106
116
  };
107
117
 
108
118
  /**
@@ -73,85 +73,92 @@ describe( 'interface/validator', () => {
73
73
  expect( () => validateStep( { ...validArgs, fn: 'not-fn' } ) ).toThrow( error );
74
74
  } );
75
75
 
76
- it( 'passes with options.retry (second-level options)', () => {
76
+ it( 'passes with options.activityOptions.retry (second-level options)', () => {
77
77
  const args = {
78
78
  ...validArgs,
79
79
  options: {
80
- retry: {
81
- initialInterval: '1s',
82
- backoffCoefficient: 2,
83
- maximumInterval: '10s',
84
- maximumAttempts: 3,
85
- nonRetryableErrorTypes: [ 'SomeError' ]
80
+ activityOptions: {
81
+ retry: {
82
+ initialInterval: '1s',
83
+ backoffCoefficient: 2,
84
+ maximumInterval: '10s',
85
+ maximumAttempts: 3,
86
+ nonRetryableErrorTypes: [ 'SomeError' ]
87
+ }
86
88
  }
87
89
  }
88
90
  };
89
91
  expect( () => validateStep( args ) ).not.toThrow();
90
92
  } );
91
93
 
92
- it( 'passes with options.activityId as string', () => {
93
- expect( () => validateStep( { ...validArgs, options: { activityId: 'act-123' } } ) ).not.toThrow();
94
+ it( 'passes with options.activityOptions.activityId as string', () => {
95
+ expect( () => validateStep( { ...validArgs, options: { activityOptions: { activityId: 'act-123' } } } ) ).not.toThrow();
94
96
  } );
95
97
 
96
- it( 'rejects non-string options.activityId', () => {
97
- expect( () => validateStep( { ...validArgs, options: { activityId: 123 } } ) ).toThrow( StaticValidationError );
98
+ it( 'rejects non-string options.activityOptions.activityId', () => {
99
+ expect( () => validateStep( { ...validArgs, options: { activityOptions: { activityId: 123 } } } ) ).toThrow( StaticValidationError );
98
100
  } );
99
101
 
100
- it( 'passes with valid options.cancellationType values', () => {
102
+ it( 'passes with valid options.activityOptions.cancellationType values', () => {
101
103
  for ( const v of [ 'TRY_CANCEL', 'WAIT_CANCELLATION_COMPLETED', 'ABANDON' ] ) {
102
- expect( () => validateStep( { ...validArgs, options: { cancellationType: v } } ) ).not.toThrow();
104
+ expect( () => validateStep( { ...validArgs, options: { activityOptions: { cancellationType: v } } } ) ).not.toThrow();
103
105
  }
104
106
  } );
105
107
 
106
- it( 'rejects invalid options.cancellationType', () => {
107
- expect( () => validateStep( { ...validArgs, options: { cancellationType: 'INVALID' } } ) ).toThrow( StaticValidationError );
108
+ it( 'rejects invalid options.activityOptions.cancellationType', () => {
109
+ const args = { ...validArgs, options: { activityOptions: { cancellationType: 'INVALID' } } };
110
+ expect( () => validateStep( args ) ).toThrow( StaticValidationError );
108
111
  } );
109
112
 
110
- it( 'accepts duration fields in options', () => {
113
+ it( 'accepts duration fields in options.activityOptions', () => {
111
114
  const options = {
112
- heartbeatTimeout: '1s',
113
- scheduleToCloseTimeout: '2m',
114
- scheduleToStartTimeout: '3m',
115
- startToCloseTimeout: '4m'
115
+ activityOptions: {
116
+ heartbeatTimeout: '1s',
117
+ scheduleToCloseTimeout: '2m',
118
+ scheduleToStartTimeout: '3m',
119
+ startToCloseTimeout: '4m'
120
+ }
116
121
  };
117
122
  expect( () => validateStep( { ...validArgs, options } ) ).not.toThrow();
118
123
  } );
119
124
 
120
- it( 'rejects invalid duration string in heartbeatTimeout', () => {
121
- expect( () => validateStep( { ...validArgs, options: { heartbeatTimeout: '5x' } } ) ).toThrow( StaticValidationError );
125
+ it( 'rejects invalid duration string in options.activityOptions.heartbeatTimeout', () => {
126
+ expect( () => validateStep( { ...validArgs, options: { activityOptions: { heartbeatTimeout: '5x' } } } ) ).toThrow( StaticValidationError );
122
127
  } );
123
128
 
124
- it( 'passes with options.summary string', () => {
125
- expect( () => validateStep( { ...validArgs, options: { summary: 'brief' } } ) ).not.toThrow();
129
+ it( 'passes with options.activityOptions.summary string', () => {
130
+ expect( () => validateStep( { ...validArgs, options: { activityOptions: { summary: 'brief' } } } ) ).not.toThrow();
126
131
  } );
127
132
 
128
- it( 'rejects non-string options.summary', () => {
129
- expect( () => validateStep( { ...validArgs, options: { summary: 42 } } ) ).toThrow( StaticValidationError );
133
+ it( 'rejects non-string options.activityOptions.summary', () => {
134
+ expect( () => validateStep( { ...validArgs, options: { activityOptions: { summary: 42 } } } ) ).toThrow( StaticValidationError );
130
135
  } );
131
136
 
132
- it( 'passes with options.priority valid payload', () => {
137
+ it( 'passes with options.activityOptions.priority valid payload', () => {
133
138
  const options = {
134
- priority: {
135
- fairnessKey: 'user-1',
136
- fairnessWeight: 1.5,
137
- priorityKey: 10
139
+ activityOptions: {
140
+ priority: {
141
+ fairnessKey: 'user-1',
142
+ fairnessWeight: 1.5,
143
+ priorityKey: 10
144
+ }
138
145
  }
139
146
  };
140
147
  expect( () => validateStep( { ...validArgs, options } ) ).not.toThrow();
141
148
  } );
142
149
 
143
- it( 'rejects invalid options.priority values', () => {
144
- const options = { priority: { fairnessWeight: 0, priorityKey: 0 } };
150
+ it( 'rejects invalid options.activityOptions.priority values', () => {
151
+ const options = { activityOptions: { priority: { fairnessWeight: 0, priorityKey: 0 } } };
145
152
  expect( () => validateStep( { ...validArgs, options } ) ).toThrow( StaticValidationError );
146
153
  } );
147
154
 
148
- it( 'rejects invalid options.retry values', () => {
149
- const options = { retry: { backoffCoefficient: 0.5, maximumAttempts: 0, nonRetryableErrorTypes: [ 1 ] } };
155
+ it( 'rejects invalid options.activityOptions.retry values', () => {
156
+ const options = { activityOptions: { retry: { backoffCoefficient: 0.5, maximumAttempts: 0, nonRetryableErrorTypes: [ 1 ] } } };
150
157
  expect( () => validateStep( { ...validArgs, options } ) ).toThrow( StaticValidationError );
151
158
  } );
152
159
 
153
- it( 'rejects unknown keys inside options due to strictObject', () => {
154
- expect( () => validateStep( { ...validArgs, options: { unknownKey: true } } ) ).toThrow( StaticValidationError );
160
+ it( 'rejects unknown keys inside options.activityOptions due to strictObject', () => {
161
+ expect( () => validateStep( { ...validArgs, options: { activityOptions: { unknownKey: true } } } ) ).toThrow( StaticValidationError );
155
162
  } );
156
163
 
157
164
  it( 'rejects unknown top-level keys due to strictObject', () => {
@@ -163,6 +170,25 @@ describe( 'interface/validator', () => {
163
170
  it( 'passes for valid args', () => {
164
171
  expect( () => validateWorkflow( { ...validArgs } ) ).not.toThrow();
165
172
  } );
173
+
174
+ it( 'passes with options.disableTrace true', () => {
175
+ expect( () => validateWorkflow( { ...validArgs, options: { disableTrace: true } } ) ).not.toThrow();
176
+ } );
177
+
178
+ it( 'passes with options.disableTrace false', () => {
179
+ expect( () => validateWorkflow( { ...validArgs, options: { disableTrace: false } } ) ).not.toThrow();
180
+ } );
181
+
182
+ it( 'passes with options.activityOptions and options.disableTrace', () => {
183
+ expect( () => validateWorkflow( {
184
+ ...validArgs,
185
+ options: { activityOptions: { activityId: 'wf-1' }, disableTrace: true }
186
+ } ) ).not.toThrow();
187
+ } );
188
+
189
+ it( 'rejects non-boolean options.disableTrace', () => {
190
+ expect( () => validateWorkflow( { ...validArgs, options: { disableTrace: 'yes' } } ) ).toThrow( StaticValidationError );
191
+ } );
166
192
  } );
167
193
 
168
194
  describe( 'validateEvaluator', () => {
@@ -3,7 +3,7 @@ import { proxyActivities, inWorkflowContext, executeChild, workflowInfo, uuid4,
3
3
  import { validateWorkflow } from './validations/static.js';
4
4
  import { validateWithSchema } from './validations/runtime.js';
5
5
  import { SHARED_STEP_PREFIX, ACTIVITY_GET_TRACE_DESTINATIONS, METADATA_ACCESS_SYMBOL } from '#consts';
6
- import { deepMerge, mergeActivityOptions, setMetadata, toUrlSafeBase64 } from '#utils';
6
+ import { deepMerge, setMetadata, toUrlSafeBase64 } from '#utils';
7
7
  import { FatalError, ValidationError } from '#errors';
8
8
 
9
9
  /**
@@ -38,22 +38,25 @@ class Context {
38
38
  }
39
39
  };
40
40
 
41
- const defaultActivityOptions = {
42
- startToCloseTimeout: '20m',
43
- heartbeatTimeout: '5m',
44
- retry: {
45
- initialInterval: '10s',
46
- backoffCoefficient: 2.0,
47
- maximumInterval: '2m',
48
- maximumAttempts: 3,
49
- nonRetryableErrorTypes: [ ValidationError.name, FatalError.name ]
50
- }
41
+ const defaultOptions = {
42
+ activityOptions: {
43
+ startToCloseTimeout: '20m',
44
+ heartbeatTimeout: '5m',
45
+ retry: {
46
+ initialInterval: '10s',
47
+ backoffCoefficient: 2.0,
48
+ maximumInterval: '2m',
49
+ maximumAttempts: 3,
50
+ nonRetryableErrorTypes: [ ValidationError.name, FatalError.name ]
51
+ }
52
+ },
53
+ disableTrace: false
51
54
  };
52
55
 
53
- export function workflow( { name, description, inputSchema, outputSchema, fn, options } ) {
56
+ export function workflow( { name, description, inputSchema, outputSchema, fn, options = {} } ) {
54
57
  validateWorkflow( { name, description, inputSchema, outputSchema, fn, options } );
55
58
 
56
- const activityOptions = mergeActivityOptions( defaultActivityOptions, options );
59
+ const { disableTrace, activityOptions } = deepMerge( defaultOptions, options );
57
60
  const steps = proxyActivities( activityOptions );
58
61
 
59
62
  /**
@@ -68,7 +71,7 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
68
71
  if ( !inWorkflowContext() ) {
69
72
  validateWithSchema( inputSchema, input, `Workflow ${name} input` );
70
73
  const context = Context.build( { workflowId: 'test-workflow', continueAsNew: async () => {}, isContinueAsNewSuggested: () => false } );
71
- const output = await fn( input, deepMerge( context, extra.context ?? {} ) );
74
+ const output = await fn( input, deepMerge( context, extra.context ) );
72
75
  validateWithSchema( outputSchema, output, `Workflow ${name} output` );
73
76
  return output;
74
77
  }
@@ -86,6 +89,7 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
86
89
  const executionContext = memo.executionContext ?? {
87
90
  workflowId,
88
91
  workflowName: name,
92
+ disableTrace,
89
93
  startTime: startTime.getTime()
90
94
  };
91
95
 
@@ -94,8 +98,8 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
94
98
  activityOptions: memo.activityOptions ?? activityOptions // Also preserve the original activity options
95
99
  } );
96
100
 
97
- // Run the internal activity to retrieve the workflow trace destinations (if it is root workflow, not nested)
98
- const traceDestinations = isRoot ? ( await steps[ACTIVITY_GET_TRACE_DESTINATIONS]( { startTime, workflowId, workflowName: name } ) ) : null;
101
+ // Run the internal activity to retrieve the workflow trace destinations (only for root workflows, not nested)
102
+ const traceDestinations = isRoot ? ( await steps[ACTIVITY_GET_TRACE_DESTINATIONS]( executionContext ) ) : null;
99
103
  const traceObject = { trace: { destinations: traceDestinations } };
100
104
 
101
105
  try {
@@ -127,7 +131,7 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
127
131
  executionContext,
128
132
  parentId: workflowId,
129
133
  // new configuration for activities of the child workflow, this will be omitted so it will use what that workflow have defined
130
- ...( extra?.options && { activityOptions: mergeActivityOptions( activityOptions, extra.options ) } )
134
+ ...( extra?.options?.activityOptions && { activityOptions: deepMerge( activityOptions, extra.options.activityOptions ) } )
131
135
  }
132
136
  } )
133
137
  }, input, context );
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { z } from 'zod';
3
+
4
+ const traceDestinationsStepMock = vi.fn().mockResolvedValue( { local: '/tmp/trace' } );
5
+ const proxyActivitiesMock = vi.fn( () => new Proxy( {}, {
6
+ get: ( _, prop ) => {
7
+ if ( prop === '__internal#getTraceDestinations' ) {
8
+ return traceDestinationsStepMock;
9
+ }
10
+ return vi.fn();
11
+ }
12
+ } ) );
13
+ const workflowInfoMock = vi.fn( () => ( {
14
+ workflowId: 'wf-test-123',
15
+ workflowType: 'test_wf',
16
+ memo: {},
17
+ startTime: new Date( '2025-01-01T00:00:00Z' ),
18
+ continueAsNewSuggested: false
19
+ } ) );
20
+
21
+ vi.mock( '@temporalio/workflow', () => ( {
22
+ proxyActivities: proxyActivitiesMock,
23
+ inWorkflowContext: () => true,
24
+ executeChild: vi.fn(),
25
+ workflowInfo: workflowInfoMock,
26
+ uuid4: () => 'uuid-mock',
27
+ ParentClosePolicy: { TERMINATE: 'TERMINATE', ABANDON: 'ABANDON' },
28
+ continueAsNew: vi.fn()
29
+ } ) );
30
+
31
+ vi.mock( '#consts', () => ( {
32
+ SHARED_STEP_PREFIX: '__shared',
33
+ ACTIVITY_GET_TRACE_DESTINATIONS: '__internal#getTraceDestinations',
34
+ METADATA_ACCESS_SYMBOL: Symbol( 'metadata' )
35
+ } ) );
36
+
37
+ describe( 'workflow()', () => {
38
+ beforeEach( () => {
39
+ vi.clearAllMocks();
40
+ workflowInfoMock.mockReturnValue( {
41
+ workflowId: 'wf-test-123',
42
+ workflowType: 'test_wf',
43
+ memo: {},
44
+ startTime: new Date( '2025-01-01T00:00:00Z' ),
45
+ continueAsNewSuggested: false
46
+ } );
47
+ } );
48
+
49
+ it( 'does not throw when options is omitted (disableTrace defaults to false)', async () => {
50
+ const { workflow } = await import( './workflow.js' );
51
+
52
+ const wf = workflow( {
53
+ name: 'no_options_wf',
54
+ description: 'Workflow without options',
55
+ inputSchema: z.object( { value: z.string() } ),
56
+ outputSchema: z.object( { value: z.string() } ),
57
+ fn: async ( { value } ) => ( { value } )
58
+ } );
59
+
60
+ const result = await wf( { value: 'hello' } );
61
+ expect( result.output ).toEqual( { value: 'hello' } );
62
+ } );
63
+
64
+ it( 'respects disableTrace: true when options is provided', async () => {
65
+ const { workflow } = await import( './workflow.js' );
66
+
67
+ const wf = workflow( {
68
+ name: 'trace_disabled_wf',
69
+ description: 'Workflow with tracing disabled',
70
+ inputSchema: z.object( { value: z.string() } ),
71
+ outputSchema: z.object( { value: z.string() } ),
72
+ options: { disableTrace: true },
73
+ fn: async ( { value } ) => ( { value } )
74
+ } );
75
+
76
+ const result = await wf( { value: 'hello' } );
77
+ expect( result.output ).toEqual( { value: 'hello' } );
78
+ } );
79
+ } );
@@ -1,10 +1,9 @@
1
1
  import { FatalError } from '#errors';
2
2
  import { fetch } from 'undici';
3
- import { setMetadata, isStringboolTrue, serializeFetchResponse, serializeBodyAndInferContentType } from '#utils';
3
+ import { setMetadata, serializeFetchResponse, serializeBodyAndInferContentType } from '#utils';
4
4
  import { ComponentType } from '#consts';
5
- import * as localProcessor from '../tracing/processors/local/index.js';
6
- import * as s3Processor from '../tracing/processors/s3/index.js';
7
5
  import { createChildLogger } from '#logger';
6
+ import { getDestinations } from '#tracing';
8
7
 
9
8
  const log = createChildLogger( 'HttpClient' );
10
9
 
@@ -57,17 +56,11 @@ export const sendHttpRequest = async ( { url, method, payload = undefined, heade
57
56
  setMetadata( sendHttpRequest, { type: ComponentType.INTERNAL_STEP } );
58
57
 
59
58
  /**
60
- * Resolve and return all possible trace destinations based on env var flags
59
+ * Invokes a trace method that resolves all trace output paths based on the executionContext
61
60
  *
62
- * @param {Object} options
63
- * @param {Date} startTime - Workflow startTime
64
- * @param {string} workflowId
65
- * @param {string} workflowName
66
- * @returns {object} Information about enabled workflows
61
+ * @param {object} executionContext
62
+ * @returns {object} Information about enabled destinations
67
63
  */
68
- export const getTraceDestinations = ( { startTime, workflowId, workflowName } ) => ( {
69
- local: isStringboolTrue( process.env.OUTPUT_TRACE_LOCAL_ON ) ? localProcessor.getDestination( { startTime, workflowId, workflowName } ) : null,
70
- remote: isStringboolTrue( process.env.OUTPUT_TRACE_REMOTE_ON ) ? s3Processor.getDestination( { startTime, workflowId, workflowName } ) : null
71
- } );
64
+ export const getTraceDestinations = executionContext => getDestinations( executionContext );
72
65
 
73
66
  setMetadata( getTraceDestinations, { type: ComponentType.INTERNAL_STEP } );
@@ -1,9 +1,9 @@
1
- import { addEventPhase, init } from './trace_engine.js';
1
+ import { addEventPhase, init, getDestinations } from './trace_engine.js';
2
2
 
3
3
  /**
4
4
  * Init method, if not called, no processors are attached and trace functions are dummy
5
5
  */
6
- export { init };
6
+ export { init, getDestinations };
7
7
 
8
8
  /**
9
9
  * Trace nomenclature
@@ -101,10 +101,10 @@ export const exec = ( { entry, executionContext } ) => {
101
101
  *
102
102
  * This uses the optional OUTPUT_TRACE_HOST_PATH to return values relative to the host OS, not the container, if applicable.
103
103
  *
104
- * @param {object} args
105
- * @param {string} args.startTime - The start time of the workflow
106
- * @param {string} args.workflowId - The id of the workflow execution
107
- * @param {string} args.workflowName - The name of the workflow
104
+ * @param {object} executionContext
105
+ * @param {string} executionContext.startTime - The start time of the workflow
106
+ * @param {string} executionContext.workflowId - The id of the workflow execution
107
+ * @param {string} executionContext.workflowName - The name of the workflow
108
108
  * @returns {string} The absolute path where the trace will be saved
109
109
  */
110
110
  export const getDestination = ( { startTime, workflowId, workflowName } ) =>
@@ -104,10 +104,10 @@ export const exec = async ( { entry, executionContext } ) => {
104
104
 
105
105
  /**
106
106
  * Returns where the trace is saved
107
- * @param {object} args
108
- * @param {string} args.startTime - The start time of the workflow
109
- * @param {string} args.workflowId - The id of the workflow execution
110
- * @param {string} args.workflowName - The name of the workflow
107
+ * @param {object} executionContext
108
+ * @param {string} executionContext.startTime - The start time of the workflow
109
+ * @param {string} executionContext.workflowId - The id of the workflow execution
110
+ * @param {string} executionContext.workflowName - The name of the workflow
111
111
  * @returns {string} The S3 url of the trace file
112
112
  */
113
113
  export const getDestination = ( { startTime, workflowId, workflowName } ) =>
@@ -12,24 +12,41 @@ const log = createChildLogger( 'Tracing' );
12
12
  const traceBus = new EventEmitter();
13
13
  const processors = [
14
14
  {
15
- isOn: isStringboolTrue( process.env.OUTPUT_TRACE_LOCAL_ON ),
15
+ enabled: isStringboolTrue( process.env.OUTPUT_TRACE_LOCAL_ON ),
16
16
  name: 'LOCAL',
17
17
  init: localProcessor.init,
18
- exec: localProcessor.exec
18
+ exec: localProcessor.exec,
19
+ getDestination: localProcessor.getDestination
19
20
  },
20
21
  {
21
- isOn: isStringboolTrue( process.env.OUTPUT_TRACE_REMOTE_ON ),
22
+ enabled: isStringboolTrue( process.env.OUTPUT_TRACE_REMOTE_ON ),
22
23
  name: 'REMOTE',
23
24
  init: s3Processor.init,
24
- exec: s3Processor.exec
25
+ exec: s3Processor.exec,
26
+ getDestination: localProcessor.getDestination
25
27
  }
26
28
  ];
27
29
 
30
+ /**
31
+ * Returns the destinations for a given execution context
32
+ *
33
+ * @param {object} executionContext
34
+ * @param {string} executionContext.startTime
35
+ * @param {string} executionContext.workflowId
36
+ * @param {string} executionContext.workflowName
37
+ * @param {boolean} executionContext.disableTrace
38
+ * @returns {object} A trace destinations object: { [dest-name]: 'path' }
39
+ */
40
+ export const getDestinations = executionContext =>
41
+ processors.reduce( ( o, p ) =>
42
+ Object.assign( o, { [p.name.toLowerCase()]: p.enabled && !executionContext.disableTrace ? p.getDestination( executionContext ) : null } )
43
+ , {} );
44
+
28
45
  /**
29
46
  * Starts processors based on env vars and attach them to the main bus to listen trace events
30
47
  */
31
48
  export const init = async () => {
32
- for ( const p of processors.filter( p => p.isOn ) ) {
49
+ for ( const p of processors.filter( p => p.enabled ) ) {
33
50
  await p.init();
34
51
  traceBus.addListener( 'entry', async ( ...args ) => {
35
52
  try {
@@ -54,8 +71,8 @@ const serializeDetails = details => details instanceof Error ? serializeError( d
54
71
  * @returns {void}
55
72
  */
56
73
  export const addEventPhase = ( phase, { kind, name, id, parentId, details, executionContext } ) => {
57
- // Ignores internal steps in the actual trace files
58
- if ( kind !== ComponentType.INTERNAL_STEP ) {
74
+ // Ignores internal steps in the actual trace files, ignore trace if the flag is true
75
+ if ( kind !== ComponentType.INTERNAL_STEP && !executionContext.disableTrace ) {
59
76
  traceBus.emit( 'entry', {
60
77
  executionContext,
61
78
  entry: { kind, phase, name, id, parentId, phase, timestamp: Date.now(), details: serializeDetails( details ) }
@@ -7,16 +7,20 @@ vi.mock( '#async_storage', () => ( {
7
7
 
8
8
  const localInitMock = vi.fn( async () => {} );
9
9
  const localExecMock = vi.fn();
10
+ const localGetDestinationMock = vi.fn( () => '/local/path.json' );
10
11
  vi.mock( './processors/local/index.js', () => ( {
11
12
  init: localInitMock,
12
- exec: localExecMock
13
+ exec: localExecMock,
14
+ getDestination: localGetDestinationMock
13
15
  } ) );
14
16
 
15
17
  const s3InitMock = vi.fn( async () => {} );
16
18
  const s3ExecMock = vi.fn();
19
+ const s3GetDestinationMock = vi.fn( () => 'https://bucket.s3.amazonaws.com/key.json' );
17
20
  vi.mock( './processors/s3/index.js', () => ( {
18
21
  init: s3InitMock,
19
- exec: s3ExecMock
22
+ exec: s3ExecMock,
23
+ getDestination: s3GetDestinationMock
20
24
  } ) );
21
25
 
22
26
  async function loadTraceEngine() {
@@ -42,13 +46,17 @@ describe( 'tracing/trace_engine', () => {
42
46
  expect( localInitMock ).toHaveBeenCalledTimes( 1 );
43
47
  expect( s3InitMock ).not.toHaveBeenCalled();
44
48
 
45
- addEventPhase( 'start', { kind: 'step', name: 'N', id: '1', parentId: 'p', details: { ok: true } } );
49
+ const executionContext = { disableTrace: false };
50
+ addEventPhase( 'start', {
51
+ kind: 'step', name: 'N', id: '1', parentId: 'p', details: { ok: true }, executionContext
52
+ } );
46
53
  expect( localExecMock ).toHaveBeenCalledTimes( 1 );
47
54
  const payload = localExecMock.mock.calls[0][0];
48
55
  expect( payload.entry.name ).toBe( 'N' );
49
56
  expect( payload.entry.kind ).toBe( 'step' );
50
57
  expect( payload.entry.phase ).toBe( 'start' );
51
58
  expect( payload.entry.details ).toEqual( { ok: true } );
59
+ expect( payload.executionContext ).toBe( executionContext );
52
60
  } );
53
61
 
54
62
  it( 'addEventPhase() emits an entry consumed by processors', async () => {
@@ -56,7 +64,10 @@ describe( 'tracing/trace_engine', () => {
56
64
  const { init, addEventPhase } = await loadTraceEngine();
57
65
  await init();
58
66
 
59
- addEventPhase( 'end', { kind: 'workflow', name: 'W', id: '2', parentId: 'p2', details: 'done' } );
67
+ addEventPhase( 'end', {
68
+ kind: 'workflow', name: 'W', id: '2', parentId: 'p2', details: 'done',
69
+ executionContext: { disableTrace: false }
70
+ } );
60
71
  expect( localExecMock ).toHaveBeenCalledTimes( 1 );
61
72
  const payload = localExecMock.mock.calls[0][0];
62
73
  expect( payload.entry.name ).toBe( 'W' );
@@ -64,21 +75,49 @@ describe( 'tracing/trace_engine', () => {
64
75
  expect( payload.entry.details ).toBe( 'done' );
65
76
  } );
66
77
 
78
+ it( 'addEventPhase() does not emit when executionContext.disableTrace is true', async () => {
79
+ process.env.OUTPUT_TRACE_LOCAL_ON = '1';
80
+ const { init, addEventPhase } = await loadTraceEngine();
81
+ await init();
82
+
83
+ addEventPhase( 'start', {
84
+ kind: 'step', name: 'X', id: '1', parentId: 'p', details: {},
85
+ executionContext: { disableTrace: true }
86
+ } );
87
+ expect( localExecMock ).not.toHaveBeenCalled();
88
+ } );
89
+
67
90
  it( 'addEventPhaseWithContext() uses storage when available', async () => {
68
91
  process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
69
- storageLoadMock.mockReturnValue( { parentId: 'ctx-p', executionContext: { runId: 'r1' } } );
92
+ storageLoadMock.mockReturnValue( {
93
+ parentId: 'ctx-p',
94
+ executionContext: { runId: 'r1', disableTrace: false }
95
+ } );
70
96
  const { init, addEventPhaseWithContext } = await loadTraceEngine();
71
97
  await init();
72
98
 
73
99
  addEventPhaseWithContext( 'tick', { kind: 'step', name: 'S', id: '3', details: 1 } );
74
100
  expect( localExecMock ).toHaveBeenCalledTimes( 1 );
75
101
  const payload = localExecMock.mock.calls[0][0];
76
- expect( payload.executionContext ).toEqual( { runId: 'r1' } );
102
+ expect( payload.executionContext ).toEqual( { runId: 'r1', disableTrace: false } );
77
103
  expect( payload.entry.parentId ).toBe( 'ctx-p' );
78
104
  expect( payload.entry.name ).toBe( 'S' );
79
105
  expect( payload.entry.phase ).toBe( 'tick' );
80
106
  } );
81
107
 
108
+ it( 'addEventPhaseWithContext() does not emit when storage executionContext.disableTrace is true', async () => {
109
+ process.env.OUTPUT_TRACE_LOCAL_ON = '1';
110
+ storageLoadMock.mockReturnValue( {
111
+ parentId: 'ctx-p',
112
+ executionContext: { runId: 'r1', disableTrace: true }
113
+ } );
114
+ const { init, addEventPhaseWithContext } = await loadTraceEngine();
115
+ await init();
116
+
117
+ addEventPhaseWithContext( 'tick', { kind: 'step', name: 'S', id: '3', details: 1 } );
118
+ expect( localExecMock ).not.toHaveBeenCalled();
119
+ } );
120
+
82
121
  it( 'addEventPhaseWithContext() is a no-op when storage is absent', async () => {
83
122
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
84
123
  storageLoadMock.mockReturnValue( undefined );
@@ -88,4 +127,24 @@ describe( 'tracing/trace_engine', () => {
88
127
  addEventPhaseWithContext( 'noop', { kind: 'step', name: 'X', id: '4', details: null } );
89
128
  expect( localExecMock ).not.toHaveBeenCalled();
90
129
  } );
130
+
131
+ it( 'getDestinations() returns null for each value when executionContext.disableTrace is true', async () => {
132
+ process.env.OUTPUT_TRACE_LOCAL_ON = '1';
133
+ process.env.OUTPUT_TRACE_REMOTE_ON = '1';
134
+ const { getDestinations } = await loadTraceEngine();
135
+ const result = getDestinations( { workflowId: 'w1', workflowName: 'WF', startTime: 1, disableTrace: true } );
136
+ expect( result ).toEqual( { local: null, remote: null } );
137
+ expect( localGetDestinationMock ).not.toHaveBeenCalled();
138
+ expect( s3GetDestinationMock ).not.toHaveBeenCalled();
139
+ } );
140
+
141
+ it( 'getDestinations() returns processor destinations when disableTrace is false', async () => {
142
+ process.env.OUTPUT_TRACE_LOCAL_ON = '1';
143
+ process.env.OUTPUT_TRACE_REMOTE_ON = '0';
144
+ const { getDestinations } = await loadTraceEngine();
145
+ const executionContext = { workflowId: 'w1', workflowName: 'WF', startTime: 1, disableTrace: false };
146
+ const result = getDestinations( executionContext );
147
+ expect( result ).toEqual( { local: '/local/path.json', remote: null } );
148
+ expect( localGetDestinationMock ).toHaveBeenCalledWith( executionContext );
149
+ } );
91
150
  } );
@@ -33,14 +33,6 @@ export function throws( error: Error ): void;
33
33
  */
34
34
  export function setMetadata( target: object, value: object ): void;
35
35
 
36
- /**
37
- * Merge two temporal activity options
38
- */
39
- export function mergeActivityOptions(
40
- base?: import( '@temporalio/workflow' ).ActivityOptions,
41
- ext?: import( '@temporalio/workflow' ).ActivityOptions
42
- ): import( '@temporalio/workflow' ).ActivityOptions;
43
-
44
36
  /** Represents a {Response} serialized to plain object */
45
37
  export type SerializedFetchResponse = {
46
38
  /** The response url */
@@ -37,17 +37,6 @@ export const throws = e => {
37
37
  export const setMetadata = ( target, values ) =>
38
38
  Object.defineProperty( target, METADATA_ACCESS_SYMBOL, { value: values, writable: false, enumerable: false, configurable: false } );
39
39
 
40
- /**
41
- * Merge two temporal activity options
42
- * @param {import('@temporalio/workflow').ActivityOptions} base
43
- * @param {import('@temporalio/workflow').ActivityOptions} ext
44
- * @returns {import('@temporalio/workflow').ActivityOptions}
45
- */
46
- export const mergeActivityOptions = ( base = {}, ext = {} ) =>
47
- Object.entries( ext ).reduce( ( options, [ k, v ] ) =>
48
- Object.assign( options, { [k]: typeof v === 'object' ? mergeActivityOptions( options[k], v ) : v } )
49
- , clone( base ) );
50
-
51
40
  /**
52
41
  * Returns true if string value is stringbool and true
53
42
  * @param {string} v
@@ -179,6 +168,7 @@ export const shuffleArray = arr => arr
179
168
  * - Object "b" fields that don't exist on object "a" will be created;
180
169
  * - Object "a" fields that don't exist on object "b" will not be touched;
181
170
  *
171
+ * If "b" isn't an object, a new object equal to "a" is returned
182
172
  *
183
173
  * @param {object} a - The base object
184
174
  * @param {object} b - The target object
@@ -189,7 +179,7 @@ export const deepMerge = ( a, b ) => {
189
179
  throw new Error( 'Parameter "a" is not an object.' );
190
180
  }
191
181
  if ( !isPlainObject( b ) ) {
192
- throw new Error( 'Parameter "b" is not an object.' );
182
+ return clone( a );
193
183
  }
194
184
  return Object.entries( b ).reduce( ( obj, [ k, v ] ) =>
195
185
  Object.assign( obj, { [k]: isPlainObject( v ) && isPlainObject( a[k] ) ? deepMerge( a[k], v ) : v } )
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
2
2
  import { Readable } from 'node:stream';
3
3
  import {
4
4
  clone,
5
- mergeActivityOptions,
6
5
  serializeBodyAndInferContentType,
7
6
  serializeFetchResponse,
8
7
  deepMerge,
@@ -223,50 +222,6 @@ describe( 'serializeBodyAndInferContentType', () => {
223
222
  } );
224
223
  } );
225
224
 
226
- describe( 'mergeActivityOptions', () => {
227
- it( 'recursively merges nested objects', () => {
228
- const base = {
229
- taskQueue: 'q1',
230
- retry: { maximumAttempts: 3, backoffCoefficient: 2 }
231
- };
232
- const ext = {
233
- retry: { maximumAttempts: 5, initialInterval: '1s' }
234
- };
235
-
236
- const result = mergeActivityOptions( base, ext );
237
-
238
- expect( result ).toEqual( {
239
- taskQueue: 'q1',
240
- retry: { maximumAttempts: 5, backoffCoefficient: 2, initialInterval: '1s' }
241
- } );
242
- } );
243
-
244
- it( 'omitted properties in second do not overwrite first', () => {
245
- const base = {
246
- taskQueue: 'q2',
247
- retry: { initialInterval: '2s', backoffCoefficient: 2 }
248
- };
249
- const ext = {
250
- retry: { backoffCoefficient: 3 }
251
- };
252
-
253
- const result = mergeActivityOptions( base, ext );
254
-
255
- expect( result.retry.initialInterval ).toBe( '2s' );
256
- expect( result.retry.backoffCoefficient ).toBe( 3 );
257
- expect( result.taskQueue ).toBe( 'q2' );
258
- } );
259
-
260
- it( 'handles omitted second argument by returning a clone', () => {
261
- const base = { taskQueue: 'q3', retry: { maximumAttempts: 2 } };
262
-
263
- const result = mergeActivityOptions( base );
264
-
265
- expect( result ).toEqual( base );
266
- expect( result ).not.toBe( base );
267
- } );
268
- } );
269
-
270
225
  describe( 'deepMerge', () => {
271
226
  it( 'Overwrites properties in object "a"', () => {
272
227
  const a = {
@@ -316,13 +271,43 @@ describe( 'deepMerge', () => {
316
271
  } );
317
272
  } );
318
273
 
319
- it( 'Throw error on non iterable object types', () => {
320
- expect( () => deepMerge( Function, Function ) ).toThrow( Error );
321
- expect( () => deepMerge( () => {}, () => {} ) ).toThrow( Error );
322
- expect( () => deepMerge( 'a', 'a' ) ).toThrow( Error );
323
- expect( () => deepMerge( true, true ) ).toThrow( Error );
324
- expect( () => deepMerge( /a/, /a/ ) ).toThrow( Error );
325
- expect( () => deepMerge( [], [] ) ).toThrow( Error );
274
+ it( 'Merge object is a clone', () => {
275
+ const a = {
276
+ a: 1
277
+ };
278
+ const b = {
279
+ b: 1
280
+ };
281
+ const result = deepMerge( a, b );
282
+ a.a = 2;
283
+ b.b = 2;
284
+ expect( result.a ).toEqual( 1 );
285
+ } );
286
+
287
+ it( 'Returns copy of "a" if "b" is not an object', () => {
288
+ const a = {
289
+ a: 1
290
+ };
291
+ expect( deepMerge( a, null ) ).toEqual( { a: 1 } );
292
+ expect( deepMerge( a, undefined ) ).toEqual( { a: 1 } );
293
+ } );
294
+
295
+ it( 'Copy of object "a" is a clone', () => {
296
+ const a = {
297
+ a: 1
298
+ };
299
+ const result = deepMerge( a, null );
300
+ a.a = 2;
301
+ expect( result.a ).toEqual( 1 );
302
+ } );
303
+
304
+ it( 'Throws when first argument is not a plain object', () => {
305
+ expect( () => deepMerge( Function ) ).toThrow( Error );
306
+ expect( () => deepMerge( () => {} ) ).toThrow( Error );
307
+ expect( () => deepMerge( 'a' ) ).toThrow( Error );
308
+ expect( () => deepMerge( true ) ).toThrow( Error );
309
+ expect( () => deepMerge( /a/ ) ).toThrow( Error );
310
+ expect( () => deepMerge( [] ) ).toThrow( Error );
326
311
  expect( () => deepMerge( class Foo {}, class Foo {} ) ).toThrow( Error );
327
312
  expect( () => deepMerge( Number.constructor, Number.constructor ) ).toThrow( Error );
328
313
  expect( () => deepMerge( Number.constructor.prototype, Number.constructor.prototype ) ).toThrow( Error );
@@ -377,6 +362,8 @@ describe( 'isPlainObject', () => {
377
362
  } );
378
363
 
379
364
  it( 'Returns false for primitives', () => {
365
+ expect( isPlainObject( null ) ).toBe( false );
366
+ expect( isPlainObject( undefined ) ).toBe( false );
380
367
  expect( isPlainObject( false ) ).toBe( false );
381
368
  expect( isPlainObject( true ) ).toBe( false );
382
369
  expect( isPlainObject( 1 ) ).toBe( false );
@@ -1,7 +1,7 @@
1
1
  // THIS RUNS IN THE TEMPORAL'S SANDBOX ENVIRONMENT
2
2
  import { workflowInfo, proxySinks, ApplicationFailure, ContinueAsNew } from '@temporalio/workflow';
3
3
  import { memoToHeaders } from '../sandboxed_utils.js';
4
- import { mergeActivityOptions } from '#utils';
4
+ import { deepMerge } from '#utils';
5
5
  import { METADATA_ACCESS_SYMBOL } from '#consts';
6
6
  // this is a dynamic generated file with activity configs overwrites
7
7
  import stepOptions from '../temp/__activity_options.js';
@@ -22,7 +22,7 @@ class HeadersInjectionInterceptor {
22
22
  // apply per-invocation options passed as second argument by rewritten calls
23
23
  const options = stepOptions[input.activityType];
24
24
  if ( options ) {
25
- input.options = mergeActivityOptions( memo.activityOptions, options );
25
+ input.options = deepMerge( memo.activityOptions, options );
26
26
  }
27
27
  return next( input );
28
28
  }
@@ -72,7 +72,7 @@ export async function loadActivities( rootDir, workflows ) {
72
72
  const activityKey = generateActivityKey( { namespace: workflowName, activityName: metadata.name } );
73
73
  activities[activityKey] = fn;
74
74
  // propagate the custom options set on the step()/evaluator() constructor
75
- activityOptionsMap[activityKey] = metadata.options ?? undefined;
75
+ activityOptionsMap[activityKey] = metadata.options?.activityOptions ?? undefined;
76
76
  }
77
77
  }
78
78
 
@@ -82,7 +82,7 @@ export async function loadActivities( rootDir, workflows ) {
82
82
  // The namespace for shared activities is fixed
83
83
  const activityKey = generateActivityKey( { namespace: SHARED_STEP_PREFIX, activityName: metadata.name } );
84
84
  activities[activityKey] = fn;
85
- activityOptionsMap[activityKey] = metadata.options ?? undefined;
85
+ activityOptionsMap[activityKey] = metadata.options?.activityOptions ?? undefined;
86
86
  }
87
87
 
88
88
  // writes down the activity option overrides
@@ -37,9 +37,13 @@ describe( 'worker/loader', () => {
37
37
  it( 'loadActivities returns map including system activity and writes options file', async () => {
38
38
  const { loadActivities } = await import( './loader.js' );
39
39
 
40
- // First call: workflow directory scan
40
+ // First call: workflow directory scan (options.activityOptions propagated to activity options file)
41
41
  importComponentsMock.mockImplementationOnce( async function *() {
42
- yield { fn: () => {}, metadata: { name: 'Act1', options: { retry: { maximumAttempts: 3 } } }, path: '/a/steps.js' };
42
+ yield {
43
+ fn: () => {},
44
+ metadata: { name: 'Act1', options: { activityOptions: { retry: { maximumAttempts: 3 } } } },
45
+ path: '/a/steps.js'
46
+ };
43
47
  } );
44
48
  // Second call: shared activities scan (no results)
45
49
  importComponentsMock.mockImplementationOnce( async function *() {} );
@@ -49,7 +53,7 @@ describe( 'worker/loader', () => {
49
53
  expect( activities['A#Act1'] ).toBeTypeOf( 'function' );
50
54
  expect( activities['__internal#sendHttpRequest'] ).toBe( sendHttpRequestMock );
51
55
 
52
- // options file written with the collected map
56
+ // options file written with the collected activityOptions map
53
57
  expect( writeFileSyncMock ).toHaveBeenCalledTimes( 1 );
54
58
  const [ writtenPath, contents ] = writeFileSyncMock.mock.calls[0];
55
59
  expect( writtenPath ).toMatch( /temp\/__activity_options\.js$/ );
@@ -60,6 +64,22 @@ describe( 'worker/loader', () => {
60
64
  expect( mkdirSyncMock ).toHaveBeenCalled();
61
65
  } );
62
66
 
67
+ it( 'loadActivities omits activity options when component has no options or no activityOptions', async () => {
68
+ const { loadActivities } = await import( './loader.js' );
69
+ importComponentsMock.mockImplementationOnce( async function *() {
70
+ yield { fn: () => {}, metadata: { name: 'NoOptions' }, path: '/a/steps.js' };
71
+ yield { fn: () => {}, metadata: { name: 'EmptyOptions', options: {} }, path: '/a/steps2.js' };
72
+ } );
73
+ importComponentsMock.mockImplementationOnce( async function *() {} );
74
+
75
+ await loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] );
76
+ const written = JSON.parse(
77
+ writeFileSyncMock.mock.calls[0][1].replace( /^export default\s*/, '' ).replace( /;\s*$/, '' )
78
+ );
79
+ expect( written['A#NoOptions'] ).toBeUndefined();
80
+ expect( written['A#EmptyOptions'] ).toBeUndefined();
81
+ } );
82
+
63
83
  it( 'loadWorkflows returns array of workflows with metadata', async () => {
64
84
  const { loadWorkflows } = await import( './loader.js' );
65
85