@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 +1 -1
- package/src/index.d.ts +45 -7
- package/src/interface/validations/static.js +30 -20
- package/src/interface/validations/static.spec.js +63 -37
- package/src/interface/workflow.js +21 -17
- package/src/interface/workflow.spec.js +79 -0
- package/src/internal_activities/index.js +6 -13
- package/src/tracing/internal_interface.js +2 -2
- package/src/tracing/processors/local/index.js +4 -4
- package/src/tracing/processors/s3/index.js +4 -4
- package/src/tracing/trace_engine.js +24 -7
- package/src/tracing/trace_engine.spec.js +65 -6
- package/src/utils/index.d.ts +0 -8
- package/src/utils/utils.js +2 -12
- package/src/utils/utils.spec.js +39 -52
- package/src/worker/interceptors/workflow.js +2 -2
- package/src/worker/loader.js +2 -2
- package/src/worker/loader.spec.js +23 -3
package/package.json
CHANGED
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 -
|
|
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?:
|
|
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
|
-
*
|
|
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 -
|
|
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?:
|
|
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 -
|
|
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?:
|
|
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
|
|
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.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 =
|
|
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 (
|
|
98
|
-
const traceDestinations = isRoot ? ( await steps[ACTIVITY_GET_TRACE_DESTINATIONS](
|
|
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:
|
|
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,
|
|
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
|
-
*
|
|
59
|
+
* Invokes a trace method that resolves all trace output paths based on the executionContext
|
|
61
60
|
*
|
|
62
|
-
* @param {
|
|
63
|
-
* @
|
|
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 =
|
|
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}
|
|
105
|
-
* @param {string}
|
|
106
|
-
* @param {string}
|
|
107
|
-
* @param {string}
|
|
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}
|
|
108
|
-
* @param {string}
|
|
109
|
-
* @param {string}
|
|
110
|
-
* @param {string}
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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', {
|
|
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( {
|
|
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
|
} );
|
package/src/utils/index.d.ts
CHANGED
|
@@ -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 */
|
package/src/utils/utils.js
CHANGED
|
@@ -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
|
-
|
|
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 } )
|
package/src/utils/utils.spec.js
CHANGED
|
@@ -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( '
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 {
|
|
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 =
|
|
25
|
+
input.options = deepMerge( memo.activityOptions, options );
|
|
26
26
|
}
|
|
27
27
|
return next( input );
|
|
28
28
|
}
|
package/src/worker/loader.js
CHANGED
|
@@ -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 {
|
|
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
|
|