@outputai/core 0.7.1-next.ae5bab4.0 → 0.7.1-next.ba2fb0b.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/bin/worker.sh +6 -0
  2. package/package.json +1 -1
  3. package/src/consts.js +0 -4
  4. package/src/errors.js +6 -2
  5. package/src/hooks/index.d.ts +10 -0
  6. package/src/interface/evaluator.js +7 -20
  7. package/src/interface/evaluator.spec.js +117 -1
  8. package/src/interface/step.js +8 -9
  9. package/src/interface/step.spec.js +124 -0
  10. package/src/interface/validations/index.js +108 -0
  11. package/src/interface/validations/index.spec.js +182 -0
  12. package/src/interface/validations/schemas.js +113 -0
  13. package/src/interface/validations/schemas.spec.js +209 -0
  14. package/src/interface/webhook.js +1 -1
  15. package/src/interface/webhook.spec.js +1 -1
  16. package/src/interface/workflow.d.ts +10 -9
  17. package/src/interface/workflow.js +76 -164
  18. package/src/interface/workflow.spec.js +637 -521
  19. package/src/interface/workflow_activity_options.js +16 -0
  20. package/src/interface/workflow_utils.js +1 -1
  21. package/src/interface/zod_integration.spec.js +2 -2
  22. package/src/internal_utils/aggregations.js +0 -10
  23. package/src/internal_utils/aggregations.spec.js +1 -48
  24. package/src/internal_utils/errors.js +14 -8
  25. package/src/internal_utils/errors.spec.js +73 -27
  26. package/src/utils/index.d.ts +19 -0
  27. package/src/utils/utils.js +53 -0
  28. package/src/utils/utils.spec.js +105 -1
  29. package/src/worker/bundle.js +26 -0
  30. package/src/worker/bundle.spec.js +53 -0
  31. package/src/worker/bundler_options.js +1 -1
  32. package/src/worker/bundler_options.spec.js +1 -1
  33. package/src/worker/catalog_workflow/catalog_job.js +148 -0
  34. package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
  35. package/src/worker/check.js +24 -0
  36. package/src/worker/connection_monitor.js +112 -0
  37. package/src/worker/connection_monitor.spec.js +199 -0
  38. package/src/worker/index.js +146 -41
  39. package/src/worker/index.spec.js +281 -109
  40. package/src/worker/interceptors/activity.js +7 -24
  41. package/src/worker/interceptors/activity.spec.js +97 -66
  42. package/src/worker/interceptors/index.js +4 -7
  43. package/src/worker/interceptors/modules.js +15 -0
  44. package/src/worker/interceptors/workflow.js +6 -8
  45. package/src/worker/interceptors/workflow.spec.js +49 -42
  46. package/src/worker/interruption.js +33 -0
  47. package/src/worker/interruption.spec.js +98 -0
  48. package/src/worker/loader/activities.js +75 -0
  49. package/src/worker/loader/activities.spec.js +213 -0
  50. package/src/worker/loader/hooks.js +28 -0
  51. package/src/worker/loader/hooks.spec.js +64 -0
  52. package/src/worker/loader/matchers.js +46 -0
  53. package/src/worker/loader/matchers.spec.js +140 -0
  54. package/src/worker/{loader_tools.js → loader/tools.js} +19 -67
  55. package/src/worker/{loader_tools.spec.js → loader/tools.spec.js} +53 -85
  56. package/src/worker/loader/workflows.js +82 -0
  57. package/src/worker/loader/workflows.spec.js +256 -0
  58. package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
  59. package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
  60. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
  61. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
  62. package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
  63. package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
  64. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
  65. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
  66. package/src/interface/validations/runtime.js +0 -20
  67. package/src/interface/validations/runtime.spec.js +0 -29
  68. package/src/interface/validations/schema_utils.js +0 -8
  69. package/src/interface/validations/schema_utils.spec.js +0 -67
  70. package/src/interface/validations/static.js +0 -137
  71. package/src/interface/validations/static.spec.js +0 -397
  72. package/src/interface/workflow.replay_compatibility.spec.js +0 -254
  73. package/src/worker/loader.js +0 -202
  74. package/src/worker/loader.spec.js +0 -498
  75. package/src/worker/shutdown.js +0 -26
  76. package/src/worker/shutdown.spec.js +0 -82
  77. package/src/worker/start_catalog.js +0 -96
  78. package/src/worker/start_catalog.spec.js +0 -179
@@ -1,684 +1,800 @@
1
- import { ACTIVITY_WRAPPER_VERSION_FIELD, Signal, WORKFLOW_WRAPPER_VERSION_FIELD } from '#consts';
2
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
3
2
  import { z } from 'zod';
3
+ import {
4
+ ACTIVITY_GET_TRACE_DESTINATIONS,
5
+ ACTIVITY_WRAPPER_VERSION_FIELD,
6
+ METADATA_ACCESS_SYMBOL,
7
+ SHARED_STEP_PREFIX,
8
+ WORKFLOW_WRAPPER_VERSION_FIELD
9
+ } from '#consts';
10
+ import { ValidationError } from '#errors';
11
+
12
+ const inWorkflowContextMock = vi.hoisted( () => vi.fn() );
13
+ const proxyActivitiesMock = vi.hoisted( () => vi.fn() );
14
+ const executeChildMock = vi.hoisted( () => vi.fn() );
15
+ const workflowInfoMock = vi.hoisted( () => vi.fn() );
16
+ const continueAsNewMock = vi.hoisted( () => vi.fn() );
17
+ const validateDefinitionMock = vi.hoisted( () => vi.fn() );
18
+ const validateInputMock = vi.hoisted( () => vi.fn() );
19
+ const validateOutputMock = vi.hoisted( () => vi.fn() );
20
+ const validateInvocationOptionsMock = vi.hoisted( () => vi.fn() );
21
+ const validatorConstructorMock = vi.hoisted( () => vi.fn() );
22
+
23
+ vi.mock( './validations/index.js', () => {
24
+ class WorkflowValidator {
25
+ static validateDefinition( ...args ) {
26
+ return validateDefinitionMock( ...args );
27
+ }
4
28
 
5
- const inWorkflowContextMock = vi.hoisted( () => vi.fn( () => true ) );
6
- const defineSignalMock = vi.hoisted( () => vi.fn( name => name ) );
7
- const setHandlerMock = vi.hoisted( () => vi.fn() );
8
- const workflowContextBuildMock = vi.hoisted( () => vi.fn() );
9
- const traceInfoBuildMock = vi.hoisted( () => vi.fn() );
10
- const traceDestinationsStepMock = vi.fn().mockResolvedValue( { local: '/tmp/trace' } );
11
- const executeChildMock = vi.fn().mockResolvedValue( undefined );
12
- const continueAsNewMock = vi.fn().mockResolvedValue( undefined );
13
-
14
- const createStepsProxy = ( stepSpy = vi.fn() ) =>
15
- new Proxy( {}, {
16
- get: ( _, prop ) => {
17
- if ( prop === '__internal#getTraceDestinations' ) {
18
- return traceDestinationsStepMock;
19
- }
20
- if ( typeof prop === 'string' && ( prop.includes( '#' ) ) ) {
21
- return stepSpy;
22
- }
23
- return vi.fn();
29
+ constructor( ...args ) {
30
+ validatorConstructorMock( ...args );
31
+ this.validateInput = validateInputMock;
32
+ this.validateOutput = validateOutputMock;
33
+ this.validateInvocationOptions = validateInvocationOptionsMock;
24
34
  }
25
- } );
35
+ }
26
36
 
27
- const stepSpyRef = { current: vi.fn().mockResolvedValue( {} ) };
28
- const proxyActivitiesMock = vi.fn( () => {
29
- stepSpyRef.current = vi.fn().mockResolvedValue( {} );
30
- return createStepsProxy( stepSpyRef.current );
37
+ return { WorkflowValidator };
31
38
  } );
32
39
 
33
- const workflowInfoReturn = {
34
- workflowId: 'wf-test-123',
40
+ vi.mock( '@temporalio/workflow', async importOriginal => {
41
+ const actual = await importOriginal();
42
+ return {
43
+ ...actual,
44
+ inWorkflowContext: inWorkflowContextMock,
45
+ proxyActivities: proxyActivitiesMock,
46
+ executeChild: executeChildMock,
47
+ workflowInfo: workflowInfoMock,
48
+ continueAsNew: continueAsNewMock,
49
+ uuid4: () => '550e8400e29b41d4a716446655440000'
50
+ };
51
+ } );
52
+
53
+ const baseWorkflowInfo = () => ( {
54
+ workflowId: 'workflow-123',
35
55
  workflowType: 'test_wf',
36
- runId: 'run-test-123',
56
+ runId: 'run-123',
57
+ startTime: new Date( '2025-01-01T00:00:00.000Z' ),
37
58
  memo: {},
38
- startTime: new Date( '2025-01-01T00:00:00Z' ),
39
59
  continueAsNewSuggested: false
60
+ } );
61
+
62
+ const setWorkflowInfo = overrides => {
63
+ const info = {
64
+ ...baseWorkflowInfo(),
65
+ ...overrides,
66
+ memo: overrides?.memo ?? {}
67
+ };
68
+ workflowInfoMock.mockImplementation( () => info );
69
+ return info;
40
70
  };
41
- const workflowInfoMock = vi.fn( () => ( { ...workflowInfoReturn } ) );
42
-
43
- vi.mock( '@temporalio/workflow', () => ( {
44
- proxyActivities: ( ...args ) => proxyActivitiesMock( ...args ),
45
- inWorkflowContext: inWorkflowContextMock,
46
- executeChild: ( ...args ) => executeChildMock( ...args ),
47
- workflowInfo: workflowInfoMock,
48
- uuid4: () => '550e8400e29b41d4a716446655440000',
49
- ParentClosePolicy: { TERMINATE: 'TERMINATE', ABANDON: 'ABANDON' },
50
- ChildWorkflowFailure: class ChildWorkflowFailure extends Error {
51
- constructor( message, cause ) {
52
- super( message );
53
- this.name = 'ChildWorkflowFailure';
54
- this.cause = cause;
55
- }
56
- },
57
- continueAsNew: continueAsNewMock,
58
- defineSignal: ( ...args ) => defineSignalMock( ...args ),
59
- setHandler: ( ...args ) => setHandlerMock( ...args )
60
- } ) );
61
71
 
62
- vi.mock( '#internal_utils/workflow_context', () => ( {
63
- WorkflowContext: { build: workflowContextBuildMock }
64
- } ) );
72
+ const activityOutput = output => ( {
73
+ output,
74
+ [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
75
+ } );
65
76
 
66
- vi.mock( '#internal_utils/trace_info', () => ( {
67
- TraceInfo: { build: traceInfoBuildMock }
68
- } ) );
77
+ const createActivities = handlers => new Proxy( {}, {
78
+ get: ( _, prop ) => typeof prop === 'string' ?
79
+ handlers[prop] ?? vi.fn().mockResolvedValue( activityOutput( undefined ) ) :
80
+ undefined
81
+ } );
69
82
 
70
- vi.mock( '#consts', async importOriginal => {
71
- const actual = await importOriginal();
72
- return {
73
- ...actual,
74
- SHARED_STEP_PREFIX: '__shared',
75
- ACTIVITY_GET_TRACE_DESTINATIONS: '__internal#getTraceDestinations'
76
- };
83
+ const mockActivities = handlers => {
84
+ const activities = createActivities( handlers );
85
+ proxyActivitiesMock.mockReturnValue( activities );
86
+ return activities;
87
+ };
88
+
89
+ const workflowDefinition = overrides => ( {
90
+ name: 'test_wf',
91
+ description: 'Test workflow',
92
+ inputSchema: z.object( {} ),
93
+ outputSchema: z.object( {} ),
94
+ fn: async () => ( {} ),
95
+ ...overrides
77
96
  } );
78
97
 
98
+ const invokeWorkflowFromHelper = ( wf, input, options ) => wf( input, options );
99
+
79
100
  describe( 'workflow()', () => {
80
101
  beforeEach( () => {
81
102
  vi.clearAllMocks();
82
103
  inWorkflowContextMock.mockReturnValue( true );
83
- defineSignalMock.mockImplementation( name => name );
84
- workflowInfoReturn.memo = {};
85
- delete workflowInfoReturn.root;
86
- workflowInfoMock.mockReturnValue( { ...workflowInfoReturn } );
87
- workflowContextBuildMock.mockReturnValue( {
88
- control: {},
89
- info: { workflowId: 'test-workflow', runId: 'test-run' }
104
+ executeChildMock.mockResolvedValue( { output: {} } );
105
+ setWorkflowInfo();
106
+ mockActivities( {
107
+ [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( { local: '/tmp/trace' } ) )
108
+ } );
109
+ } );
110
+
111
+ it( 'validates workflow definition at creation time', async () => {
112
+ const { workflow } = await import( './workflow.js' );
113
+ const inputSchema = z.object( {} );
114
+ const outputSchema = z.object( {} );
115
+ const fn = async () => ( {} );
116
+ const options = { activityOptions: { retry: { maximumAttempts: 1 } } };
117
+
118
+ workflow( {
119
+ name: 'definition_wf',
120
+ description: 'Definition workflow',
121
+ inputSchema,
122
+ outputSchema,
123
+ fn,
124
+ options,
125
+ aliases: [ 'old_definition_wf' ]
126
+ } );
127
+
128
+ expect( validateDefinitionMock ).toHaveBeenCalledWith( {
129
+ name: 'definition_wf',
130
+ description: 'Definition workflow',
131
+ inputSchema,
132
+ outputSchema,
133
+ fn,
134
+ options,
135
+ aliases: [ 'old_definition_wf' ]
136
+ } );
137
+ expect( validatorConstructorMock ).toHaveBeenCalledWith( {
138
+ name: 'definition_wf',
139
+ inputSchema,
140
+ outputSchema
141
+ } );
142
+ } );
143
+
144
+ it( 'propagates definition validation errors', async () => {
145
+ const { workflow } = await import( './workflow.js' );
146
+ const error = new ValidationError( 'invalid definition' );
147
+ validateDefinitionMock.mockImplementationOnce( () => {
148
+ throw error;
90
149
  } );
91
- traceInfoBuildMock.mockImplementation( ( { disableTrace } ) => ( {
92
- workflowId: 'trace-workflow-id',
93
- workflowType: 'trace-workflow-type',
94
- runId: 'trace-run-id',
95
- startTime: 12345,
96
- disableTrace
150
+
151
+ expect( () => workflow( workflowDefinition( { name: 'invalid_name' } ) ) ).toThrow( error );
152
+ } );
153
+
154
+ it( 'attaches workflow metadata to the wrapper', async () => {
155
+ const { workflow } = await import( './workflow.js' );
156
+ const inputSchema = z.object( { value: z.string() } );
157
+ const outputSchema = z.object( { ok: z.boolean() } );
158
+
159
+ const wf = workflow( workflowDefinition( {
160
+ name: 'metadata_wf',
161
+ description: 'Metadata workflow',
162
+ inputSchema,
163
+ outputSchema,
164
+ aliases: [ 'metadata_alias' ],
165
+ fn: async () => ( { ok: true } )
97
166
  } ) );
98
- proxyActivitiesMock.mockImplementation( () => {
99
- stepSpyRef.current = vi.fn().mockResolvedValue( {} );
100
- return createStepsProxy( stepSpyRef.current );
167
+
168
+ const [ metadataSymbol ] = Object.getOwnPropertySymbols( wf );
169
+ expect( wf[metadataSymbol] ).toEqual( {
170
+ name: 'metadata_wf',
171
+ description: 'Metadata workflow',
172
+ inputSchema,
173
+ outputSchema,
174
+ aliases: [ 'metadata_alias' ]
101
175
  } );
102
176
  } );
103
177
 
104
- describe( 'options and defaults', () => {
105
- it( 'does not throw when options is omitted (disableTrace defaults to false)', async () => {
178
+ describe( 'outside Temporal workflow context', () => {
179
+ beforeEach( () => {
180
+ inWorkflowContextMock.mockReturnValue( false );
181
+ } );
182
+
183
+ it( 'runs as a plain function with real test WorkflowContext and merged extra context', async () => {
106
184
  const { workflow } = await import( './workflow.js' );
107
185
 
108
- const wf = workflow( {
109
- name: 'no_options_wf',
110
- description: 'Workflow without options',
111
- inputSchema: z.object( { value: z.string() } ),
112
- outputSchema: z.object( { value: z.string() } ),
113
- fn: async ( { value } ) => ( { value } )
114
- } );
186
+ const wf = workflow( workflowDefinition( {
187
+ name: 'plain_wf',
188
+ inputSchema: z.object( { suffix: z.string() } ),
189
+ outputSchema: z.object( { value: z.string(), extra: z.string() } ),
190
+ fn: async ( input, context ) => ( {
191
+ value: `${context.info.workflowId}${input.suffix}`,
192
+ extra: context.extra
193
+ } )
194
+ } ) );
115
195
 
116
- const result = await wf( { value: 'hello' } );
117
- expect( result.output ).toEqual( { value: 'hello' } );
196
+ await expect( wf( { suffix: '-ok' }, { context: { extra: 'custom' } } ) ).resolves.toEqual( {
197
+ value: 'test-workflow-ok',
198
+ extra: 'custom'
199
+ } );
200
+ expect( validateInvocationOptionsMock ).toHaveBeenCalledWith( { context: { extra: 'custom' } } );
201
+ expect( validateInputMock ).toHaveBeenCalledWith( { suffix: '-ok' } );
202
+ expect( validateOutputMock ).toHaveBeenCalledWith( { value: 'test-workflow-ok', extra: 'custom' } );
203
+ expect( workflowInfoMock ).not.toHaveBeenCalled();
204
+ expect( proxyActivitiesMock ).not.toHaveBeenCalled();
118
205
  } );
119
206
 
120
- it( 'respects disableTrace: true when options is provided', async () => {
207
+ it( 'does not run fn when plain function input validation fails', async () => {
121
208
  const { workflow } = await import( './workflow.js' );
209
+ const error = new ValidationError( 'invalid input' );
210
+ validateInputMock.mockImplementationOnce( () => {
211
+ throw error;
212
+ } );
213
+ const fn = vi.fn();
122
214
 
123
- const wf = workflow( {
124
- name: 'trace_disabled_wf',
125
- description: 'Workflow with tracing disabled',
215
+ const wf = workflow( workflowDefinition( {
216
+ name: 'plain_validation_wf',
126
217
  inputSchema: z.object( { value: z.string() } ),
127
- outputSchema: z.object( { value: z.string() } ),
128
- options: { disableTrace: true },
129
- fn: async ( { value } ) => ( { value } )
130
- } );
218
+ outputSchema: z.object( { result: z.string() } ),
219
+ fn
220
+ } ) );
131
221
 
132
- const result = await wf( { value: 'hello' } );
133
- expect( result.output ).toEqual( { value: 'hello' } );
222
+ await expect( wf( { value: 1 } ) ).rejects.toBe( error );
223
+ expect( fn ).not.toHaveBeenCalled();
224
+ expect( validateOutputMock ).not.toHaveBeenCalled();
134
225
  } );
135
226
 
136
- it( 'merges custom activityOptions with defaults via deepMerge', async () => {
227
+ it( 'propagates plain function output validation errors after fn runs', async () => {
137
228
  const { workflow } = await import( './workflow.js' );
138
-
139
- workflow( {
140
- name: 'custom_activity_wf',
141
- description: 'Workflow with custom activity options',
142
- inputSchema: z.object( {} ),
143
- outputSchema: z.object( {} ),
144
- options: {
145
- activityOptions: {
146
- startToCloseTimeout: '5m',
147
- retry: { maximumAttempts: 5 }
148
- }
149
- },
150
- fn: async () => ( {} )
229
+ const error = new ValidationError( 'invalid output' );
230
+ validateOutputMock.mockImplementationOnce( () => {
231
+ throw error;
151
232
  } );
233
+ const output = { result: 1 };
234
+ const fn = vi.fn().mockResolvedValue( output );
152
235
 
153
- expect( proxyActivitiesMock ).toHaveBeenCalledWith(
154
- expect.objectContaining( {
155
- startToCloseTimeout: '5m',
156
- retry: expect.objectContaining( { maximumAttempts: 5 } )
157
- } )
158
- );
236
+ const wf = workflow( workflowDefinition( {
237
+ name: 'plain_output_validation_wf',
238
+ inputSchema: z.object( { value: z.string() } ),
239
+ outputSchema: z.object( { result: z.string() } ),
240
+ fn
241
+ } ) );
242
+
243
+ await expect( wf( { value: 'ok' } ) ).rejects.toBe( error );
244
+ expect( fn ).toHaveBeenCalledWith( { value: 'ok' }, expect.objectContaining( {
245
+ info: { workflowId: 'test-workflow', runId: 'test-run' }
246
+ } ) );
247
+ expect( validateOutputMock ).toHaveBeenCalledWith( output );
159
248
  } );
160
249
  } );
161
250
 
162
- describe( 'wrapper metadata', () => {
163
- it( 'attaches name, description, inputSchema, outputSchema to wrapper via setMetadata', async () => {
251
+ describe( 'child workflow trigger path', () => {
252
+ it( 'starts a child workflow when memo.stack already contains the current workflowId', async () => {
164
253
  const { workflow } = await import( './workflow.js' );
165
- const inputSchema = z.object( { x: z.number() } );
166
- const outputSchema = z.object( { y: z.number() } );
167
-
168
- const wf = workflow( {
169
- name: 'meta_wf',
170
- description: 'Meta workflow',
171
- inputSchema,
172
- outputSchema,
173
- fn: async input => ( { y: input.x } )
254
+ const { ParentClosePolicy } = await import( '@temporalio/workflow' );
255
+ const memo = {
256
+ stack: [ 'root-workflow', 'workflow-123' ],
257
+ traceInfo: { workflowId: 'root-workflow' },
258
+ activityOptions: {
259
+ startToCloseTimeout: '10m',
260
+ retry: { maximumAttempts: 2 }
261
+ }
262
+ };
263
+ setWorkflowInfo( { memo } );
264
+ executeChildMock.mockResolvedValueOnce( { output: { child: 'ok' } } );
265
+
266
+ const wf = workflow( workflowDefinition( {
267
+ name: 'child_target_wf',
268
+ inputSchema: z.object( { id: z.number() } ),
269
+ outputSchema: z.object( { child: z.string() } ),
270
+ fn: vi.fn()
271
+ } ) );
272
+
273
+ await expect( wf( { id: 1 }, {
274
+ detached: true,
275
+ activityOptions: {
276
+ startToCloseTimeout: '2m',
277
+ retry: { maximumAttempts: 7 }
278
+ }
279
+ } ) ).resolves.toEqual( { child: 'ok' } );
280
+ expect( validateInvocationOptionsMock ).toHaveBeenCalledWith( {
281
+ detached: true,
282
+ activityOptions: {
283
+ startToCloseTimeout: '2m',
284
+ retry: { maximumAttempts: 7 }
285
+ }
174
286
  } );
175
287
 
176
- const symbols = Object.getOwnPropertySymbols( wf );
177
- expect( symbols ).toHaveLength( 1 );
178
- const meta = wf[symbols[0]];
179
- expect( meta ).toEqual( { name: 'meta_wf', description: 'Meta workflow', inputSchema, outputSchema, aliases: [] } );
288
+ expect( executeChildMock ).toHaveBeenCalledWith( 'child_target_wf', {
289
+ args: [ { id: 1 } ],
290
+ workflowId: expect.stringMatching( /^workflow-123-/ ),
291
+ parentClosePolicy: ParentClosePolicy.ABANDON,
292
+ memo: {
293
+ stack: [ 'root-workflow', 'workflow-123' ],
294
+ traceInfo: { workflowId: 'root-workflow' },
295
+ activityOptions: {
296
+ startToCloseTimeout: '2m',
297
+ retry: { maximumAttempts: 7 }
298
+ }
299
+ }
300
+ } );
301
+ expect( proxyActivitiesMock ).not.toHaveBeenCalled();
180
302
  } );
181
- } );
182
303
 
183
- describe( 'when not in workflow context (unit-test path)', () => {
184
- it( 'validates input, runs fn with test context, validates output, returns plain output', async () => {
185
- inWorkflowContextMock.mockReturnValue( false );
304
+ it( 'uses empty args and terminate policy by default for child workflow execution', async () => {
186
305
  const { workflow } = await import( './workflow.js' );
306
+ const { ParentClosePolicy } = await import( '@temporalio/workflow' );
307
+ setWorkflowInfo( { memo: { stack: [ 'workflow-123' ], activityOptions: { heartbeatTimeout: '1m' } } } );
308
+ executeChildMock.mockResolvedValueOnce( { output: 'done' } );
309
+
310
+ const wf = workflow( workflowDefinition( {
311
+ name: 'no_input_child_wf',
312
+ inputSchema: undefined,
313
+ outputSchema: z.string(),
314
+ fn: vi.fn()
315
+ } ) );
187
316
 
188
- const wf = workflow( {
189
- name: 'unit_path_wf',
190
- description: 'Unit path',
191
- inputSchema: z.object( { a: z.string() } ),
192
- outputSchema: z.object( { b: z.string() } ),
193
- fn: async ( input, context ) => ( {
194
- b: String( context.info.workflowId ) + input.a
317
+ await expect( wf() ).resolves.toBe( 'done' );
318
+ expect( executeChildMock ).toHaveBeenCalledWith( 'no_input_child_wf', expect.objectContaining( {
319
+ args: [],
320
+ parentClosePolicy: ParentClosePolicy.TERMINATE,
321
+ memo: { stack: [ 'workflow-123' ], activityOptions: { heartbeatTimeout: '1m' } }
322
+ } ) );
323
+ } );
324
+
325
+ it( 'starts a child workflow when the call is made through a helper outside the handler', async () => {
326
+ const { workflow } = await import( './workflow.js' );
327
+ const { ParentClosePolicy } = await import( '@temporalio/workflow' );
328
+ const getTraceDestinations = vi.fn().mockResolvedValue( activityOutput( null ) );
329
+ const info = setWorkflowInfo( { workflowType: 'indirect_parent_wf', memo: {} } );
330
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: getTraceDestinations } );
331
+ executeChildMock.mockResolvedValueOnce( { output: { child: 'ok' } } );
332
+ const childFn = vi.fn();
333
+
334
+ const childWorkflow = workflow( workflowDefinition( {
335
+ name: 'indirect_child_wf',
336
+ inputSchema: z.object( { id: z.number() } ),
337
+ outputSchema: z.object( { child: z.string() } ),
338
+ fn: childFn
339
+ } ) );
340
+ const parentWorkflow = workflow( workflowDefinition( {
341
+ name: 'indirect_parent_wf',
342
+ outputSchema: z.object( { child: z.string() } ),
343
+ fn: async () => invokeWorkflowFromHelper( childWorkflow, { id: 1 }, {
344
+ activityOptions: {
345
+ retry: { maximumAttempts: 1 }
346
+ }
195
347
  } )
196
- } );
348
+ } ) );
197
349
 
198
- const result = await wf( { a: '-ok' } );
199
- expect( result ).toEqual( { b: 'test-workflow-ok' } );
200
- expect( workflowInfoMock ).not.toHaveBeenCalled();
201
- expect( traceDestinationsStepMock ).not.toHaveBeenCalled();
350
+ await expect( parentWorkflow( {} ) ).resolves.toEqual( {
351
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
352
+ output: { child: 'ok' }
353
+ } );
354
+ expect( childFn ).not.toHaveBeenCalled();
355
+ expect( executeChildMock ).toHaveBeenCalledWith( 'indirect_child_wf', expect.objectContaining( {
356
+ args: [ { id: 1 } ],
357
+ parentClosePolicy: ParentClosePolicy.TERMINATE,
358
+ memo: expect.objectContaining( {
359
+ stack: [ 'workflow-123' ],
360
+ traceInfo: info.memo.traceInfo,
361
+ activityOptions: expect.objectContaining( {
362
+ retry: expect.objectContaining( { maximumAttempts: 1 } )
363
+ } )
364
+ } )
365
+ } ) );
366
+ expect( getTraceDestinations ).toHaveBeenCalledWith( info.memo.traceInfo );
202
367
  } );
203
368
 
204
- it( 'merges extra.context into context when provided', async () => {
205
- inWorkflowContextMock.mockReturnValue( false );
369
+ it( 'falls back to workflow type matching when replaying an old child call without memo.stack', async () => {
206
370
  const { workflow } = await import( './workflow.js' );
371
+ const { ParentClosePolicy } = await import( '@temporalio/workflow' );
372
+ setWorkflowInfo( { workflowType: 'old_parent_wf', memo: { traceInfo: { workflowId: 'root-workflow' } } } );
373
+ executeChildMock.mockResolvedValueOnce( { output: { child: 'replayed' } } );
374
+ const fn = vi.fn();
207
375
 
208
- const wf = workflow( {
209
- name: 'extra_ctx_wf',
210
- description: 'Extra context',
211
- inputSchema: z.object( {} ),
212
- outputSchema: z.object( { id: z.string() } ),
213
- fn: async ( _, context ) => ( { id: context.extraId ?? 'default' } )
214
- } );
376
+ const wf = workflow( workflowDefinition( {
377
+ name: 'old_child_wf',
378
+ inputSchema: z.object( { id: z.number() } ),
379
+ outputSchema: z.object( { child: z.string() } ),
380
+ fn
381
+ } ) );
215
382
 
216
- const result = await wf( {}, { context: { extraId: 'injected' } } );
217
- expect( result ).toEqual( { id: 'injected' } );
383
+ await expect( wf( { id: 7 } ) ).resolves.toEqual( { child: 'replayed' } );
384
+ expect( fn ).not.toHaveBeenCalled();
385
+ expect( executeChildMock ).toHaveBeenCalledWith( 'old_child_wf', expect.objectContaining( {
386
+ args: [ { id: 7 } ],
387
+ parentClosePolicy: ParentClosePolicy.TERMINATE,
388
+ memo: { traceInfo: { workflowId: 'root-workflow' } }
389
+ } ) );
390
+ expect( proxyActivitiesMock ).not.toHaveBeenCalled();
218
391
  } );
219
- } );
220
392
 
221
- describe( 'input and output validation', () => {
222
- it( 'throws ValidationError when input does not match inputSchema', async () => {
393
+ it( 'does not fallback to child execution when the replayed workflow type matches an alias', async () => {
223
394
  const { workflow } = await import( './workflow.js' );
224
- const { ValidationError } = await import( '#errors' );
225
-
226
- const wf = workflow( {
227
- name: 'validate_in_wf',
228
- description: 'Input validation',
229
- inputSchema: z.object( { required: z.string() } ),
230
- outputSchema: z.object( {} ),
231
- fn: async () => ( {} )
232
- } );
395
+ setWorkflowInfo( { workflowType: 'old_root_wf', memo: {} } );
396
+
397
+ const wf = workflow( workflowDefinition( {
398
+ name: 'renamed_root_wf',
399
+ aliases: [ 'old_root_wf' ],
400
+ outputSchema: z.object( { ok: z.boolean() } ),
401
+ fn: async () => ( { ok: true } )
402
+ } ) );
233
403
 
234
- await expect( wf( { wrong: 1 } ) ).rejects.toThrow( ValidationError );
235
- await expect( wf( { wrong: 1 } ) ).rejects.toThrow( /Workflow validate_in_wf input/ );
404
+ await expect( wf( {} ) ).resolves.toEqual( {
405
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
406
+ output: { ok: true },
407
+ trace: { destinations: { local: '/tmp/trace' } }
408
+ } );
409
+ expect( executeChildMock ).not.toHaveBeenCalled();
236
410
  } );
237
411
 
238
- it( 'throws ValidationError when output does not match outputSchema', async () => {
412
+ it( 'propagates executeChild errors without root ApplicationFailure wrapping', async () => {
239
413
  const { workflow } = await import( './workflow.js' );
240
- const { ValidationError } = await import( '#errors' );
241
-
242
- const wf = workflow( {
243
- name: 'validate_out_wf',
244
- description: 'Output validation',
245
- inputSchema: z.object( {} ),
246
- outputSchema: z.object( { required: z.string() } ),
247
- fn: async () => ( { other: 1 } )
248
- } );
414
+ const error = new Error( 'child failed' );
415
+ setWorkflowInfo( { memo: { stack: [ 'workflow-123' ] } } );
416
+ executeChildMock.mockRejectedValueOnce( error );
417
+
418
+ const wf = workflow( workflowDefinition( { name: 'failing_child_wf' } ) );
249
419
 
250
- await expect( wf( {} ) ).rejects.toThrow( ValidationError );
251
- await expect( wf( {} ) ).rejects.toThrow( /Workflow validate_out_wf output/ );
420
+ await expect( wf( {} ) ).rejects.toBe( error );
252
421
  } );
253
422
  } );
254
423
 
255
- describe( 'root workflow (in workflow context)', () => {
256
- it( 'unwraps wrapped trace destinations and assigns traceInfo to memo', async () => {
257
- traceDestinationsStepMock.mockResolvedValueOnce( {
258
- output: { local: '/tmp/wrapped-trace' },
259
- aggregations: null,
260
- [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
261
- } );
424
+ describe( 'workflow execution path', () => {
425
+ it( 'initializes root memo, gets trace destinations, validates output, and returns an envelope', async () => {
262
426
  const { workflow } = await import( './workflow.js' );
427
+ const getTraceDestinations = vi.fn().mockResolvedValue( activityOutput( { local: '/tmp/root-trace' } ) );
428
+ const info = setWorkflowInfo( { workflowType: 'root_wf', memo: {} } );
429
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: getTraceDestinations } );
263
430
 
264
- const wf = workflow( {
265
- name: 'wrapped_trace_wf',
266
- description: 'Wrapped trace',
267
- inputSchema: z.object( {} ),
431
+ const wf = workflow( workflowDefinition( {
432
+ name: 'root_wf',
268
433
  outputSchema: z.object( { ok: z.boolean() } ),
269
- fn: async () => ( { ok: true } )
270
- } );
434
+ options: {
435
+ disableTrace: true,
436
+ activityOptions: {
437
+ startToCloseTimeout: '5m',
438
+ retry: { maximumAttempts: 5 }
439
+ }
440
+ },
441
+ fn: async ( _, context ) => ( { ok: context.info.workflowId === 'workflow-123' } )
442
+ } ) );
271
443
 
272
- const result = await wf( {} );
273
- expect( traceDestinationsStepMock ).toHaveBeenCalledTimes( 1 );
274
- expect( result ).toEqual( {
444
+ await expect( wf( {} ) ).resolves.toEqual( {
275
445
  [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
276
446
  output: { ok: true },
277
- trace: { destinations: { local: '/tmp/wrapped-trace' } },
278
- aggregations: null
279
- } );
280
- const memo = workflowInfoMock().memo;
281
- expect( memo.traceInfo ).toEqual( {
282
- workflowId: 'trace-workflow-id',
283
- workflowType: 'trace-workflow-type',
284
- runId: 'trace-run-id',
285
- startTime: 12345,
286
- disableTrace: false
447
+ trace: { destinations: { local: '/tmp/root-trace' } }
448
+ } );
449
+ expect( info.memo.stack ).toEqual( [ 'workflow-123' ] );
450
+ expect( info.memo.traceInfo ).toEqual( {
451
+ workflowId: 'workflow-123',
452
+ workflowType: 'root_wf',
453
+ runId: 'run-123',
454
+ startTime: new Date( '2025-01-01T00:00:00.000Z' ).getTime(),
455
+ disableTrace: true
287
456
  } );
288
- expect( traceInfoBuildMock ).toHaveBeenCalledWith( { disableTrace: false } );
289
- expect( traceDestinationsStepMock ).toHaveBeenCalledWith( memo.traceInfo );
457
+ expect( info.memo.activityOptions ).toEqual( expect.objectContaining( {
458
+ startToCloseTimeout: '5m',
459
+ heartbeatTimeout: '5m',
460
+ retry: expect.objectContaining( { maximumAttempts: 5 } )
461
+ } ) );
462
+ expect( proxyActivitiesMock ).toHaveBeenCalledWith( info.memo.activityOptions );
463
+ expect( getTraceDestinations ).toHaveBeenCalledWith( info.memo.traceInfo );
290
464
  } );
291
465
 
292
- it( 'collects batched aggregation signals from failed activities', async () => {
466
+ it( 'runs non-root workflow execution without rebuilding trace info or fetching trace destinations', async () => {
293
467
  const { workflow } = await import( './workflow.js' );
294
- const handlers = { sendAggregations: () => {} };
295
- setHandlerMock.mockImplementation( ( signalName, handler ) => {
296
- if ( signalName === Signal.SEND_AGGREGATIONS ) {
297
- handlers.sendAggregations = handler;
468
+ const getTraceDestinations = vi.fn().mockResolvedValue( activityOutput( { local: '/tmp/trace' } ) );
469
+ const memo = {
470
+ stack: [ 'root-workflow' ],
471
+ traceInfo: { workflowId: 'root-workflow' },
472
+ activityOptions: {
473
+ startToCloseTimeout: '9m',
474
+ retry: { maximumAttempts: 8 }
298
475
  }
476
+ };
477
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: getTraceDestinations } );
478
+ const info = setWorkflowInfo( {
479
+ workflowId: 'child-workflow',
480
+ root: { workflowId: 'root-workflow', runId: 'root-run' },
481
+ memo
299
482
  } );
300
483
 
301
- const wf = workflow( {
302
- name: 'batched_attr_wf',
303
- description: 'Batched aggregations',
304
- inputSchema: z.object( {} ),
484
+ const wf = workflow( workflowDefinition( {
485
+ name: 'nested_wf',
486
+ options: {
487
+ activityOptions: {
488
+ startToCloseTimeout: '1m',
489
+ retry: { maximumAttempts: 2 }
490
+ }
491
+ },
305
492
  outputSchema: z.object( { ok: z.boolean() } ),
306
- fn: async () => {
307
- handlers.sendAggregations( { cost: { total: 3 }, tokens: { total: 0 }, httpRequests: { total: 1 } } );
308
- return { ok: true };
309
- }
310
- } );
493
+ fn: async () => ( { ok: true } )
494
+ } ) );
311
495
 
312
- const result = await wf( {} );
313
- expect( result.cost ).toBeUndefined();
314
- expect( result ).not.toHaveProperty( 'attributes' );
315
- expect( result.aggregations.cost ).toEqual( { total: 3 } );
316
- expect( result.aggregations.httpRequests ).toEqual( { total: 1 } );
496
+ await expect( wf( {} ) ).resolves.toEqual( {
497
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
498
+ output: { ok: true }
499
+ } );
500
+ expect( info.memo.stack ).toEqual( [ 'root-workflow', 'child-workflow' ] );
501
+ expect( info.memo.traceInfo ).toBe( memo.traceInfo );
502
+ expect( info.memo.activityOptions ).toEqual( expect.objectContaining( {
503
+ startToCloseTimeout: '9m',
504
+ retry: expect.objectContaining( { maximumAttempts: 8 } )
505
+ } ) );
506
+ expect( proxyActivitiesMock ).toHaveBeenCalledWith( info.memo.activityOptions );
507
+ expect( getTraceDestinations ).not.toHaveBeenCalled();
317
508
  } );
318
509
 
319
- it( 'sets traceInfo.disableTrace when options.disableTrace is true', async () => {
510
+ it( 'omits trace from the root result when getTraceDestinations returns no destinations', async () => {
320
511
  const { workflow } = await import( './workflow.js' );
512
+ setWorkflowInfo( { workflowType: 'no_trace_dest_wf' } );
513
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ) } );
321
514
 
322
- const wf = workflow( {
323
- name: 'root_no_trace_wf',
324
- description: 'Root no trace',
325
- inputSchema: z.object( {} ),
326
- outputSchema: z.object( {} ),
327
- options: { disableTrace: true },
328
- fn: async () => ( {} )
329
- } );
330
-
331
- await wf( {} );
332
- expect( workflowInfoMock().memo.traceInfo ).toEqual( expect.objectContaining( {
333
- workflowId: 'trace-workflow-id',
334
- workflowType: 'trace-workflow-type',
335
- runId: 'trace-run-id',
336
- disableTrace: true
515
+ const wf = workflow( workflowDefinition( {
516
+ name: 'no_trace_dest_wf',
517
+ outputSchema: z.object( { ok: z.boolean() } ),
518
+ fn: async () => ( { ok: true } )
337
519
  } ) );
338
- expect( traceInfoBuildMock ).toHaveBeenCalledWith( { disableTrace: true } );
339
- } );
340
- } );
341
520
 
342
- describe( 'child workflow (memo.traceInfo already set)', () => {
343
- it( 'does not call getTraceDestinations and returns an internal output envelope', async () => {
344
- const traceInfo = { workflowId: 'parent-1', workflowType: 'parent_wf', runId: 'parent-run' };
345
- workflowInfoMock.mockReturnValue( {
346
- ...workflowInfoReturn,
347
- root: { workflowId: 'root-wf', runId: 'root-run' },
348
- memo: { traceInfo }
521
+ await expect( wf( {} ) ).resolves.toEqual( {
522
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
523
+ output: { ok: true }
349
524
  } );
350
- const { workflow } = await import( './workflow.js' );
525
+ } );
351
526
 
352
- const wf = workflow( {
353
- name: 'child_wf',
354
- description: 'Child',
355
- inputSchema: z.object( {} ),
356
- outputSchema: z.object( { x: z.string() } ),
357
- fn: async () => ( { x: 'child' } )
527
+ it( 'supports old unwrapped trace destination activity results during replay', async () => {
528
+ const { workflow } = await import( './workflow.js' );
529
+ setWorkflowInfo( { workflowType: 'old_trace_payload_wf' } );
530
+ mockActivities( {
531
+ [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( { local: '/tmp/old-trace' } )
358
532
  } );
359
533
 
360
- const result = await wf( {} );
361
- expect( traceDestinationsStepMock ).not.toHaveBeenCalled();
362
- expect( workflowInfoMock().memo.traceInfo ).toBe( traceInfo );
363
- expect( result ).toEqual( {
534
+ const wf = workflow( workflowDefinition( {
535
+ name: 'old_trace_payload_wf',
536
+ outputSchema: z.object( { ok: z.boolean() } ),
537
+ fn: async () => ( { ok: true } )
538
+ } ) );
539
+
540
+ await expect( wf( {} ) ).resolves.toEqual( {
364
541
  [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
365
- output: { x: 'child' },
366
- aggregations: null
542
+ output: { ok: true },
543
+ trace: { destinations: { local: '/tmp/old-trace' } }
367
544
  } );
368
545
  } );
369
- } );
370
-
371
- describe( 'bound this: invokeStep, invokeSharedStep, invokeEvaluator', () => {
372
- it( 'invokeStep unwraps step output and merges step aggregations', async () => {
373
- const stepSpy = vi.fn().mockResolvedValue( {
374
- output: { value: 'wrapped' },
375
- aggregations: { cost: { total: 0 }, tokens: { total: 0 }, httpRequests: { total: 1 } },
376
- [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
377
- } );
378
- proxyActivitiesMock.mockImplementation( () => createStepsProxy( stepSpy ) );
379
546
 
547
+ it( 'supports old null trace destination activity results during replay', async () => {
380
548
  const { workflow } = await import( './workflow.js' );
549
+ setWorkflowInfo( { workflowType: 'old_null_trace_payload_wf' } );
550
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( null ) } );
381
551
 
382
- const wf = workflow( {
383
- name: 'unwrap_step_wf',
384
- description: 'Unwrap step',
385
- inputSchema: z.object( {} ),
386
- outputSchema: z.object( { value: z.string() } ),
387
- async fn() {
388
- return this.invokeStep( 'myStep', { foo: 1 } );
389
- }
390
- } );
552
+ const wf = workflow( workflowDefinition( {
553
+ name: 'old_null_trace_payload_wf',
554
+ outputSchema: z.object( { ok: z.boolean() } ),
555
+ fn: async () => ( { ok: true } )
556
+ } ) );
391
557
 
392
- const result = await wf( {} );
393
- expect( result.output ).toEqual( { value: 'wrapped' } );
394
- expect( result ).not.toHaveProperty( 'attributes' );
395
- expect( result.aggregations.httpRequests ).toEqual( { total: 1 } );
558
+ await expect( wf( {} ) ).resolves.toEqual( {
559
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
560
+ output: { ok: true }
561
+ } );
396
562
  } );
397
563
 
398
- it( 'invokeSharedStep calls steps with SHARED_STEP_PREFIX#stepName', async () => {
564
+ it( 'validates input and output inside workflow context', async () => {
399
565
  const { workflow } = await import( './workflow.js' );
400
- const sharedSpy = vi.fn().mockResolvedValue( {} );
401
- proxyActivitiesMock.mockImplementation( () => new Proxy( {}, {
402
- get: ( _, prop ) => {
403
- if ( prop === '__internal#getTraceDestinations' ) {
404
- return traceDestinationsStepMock;
405
- }
406
- if ( prop === '__shared#sharedStep' ) {
407
- return sharedSpy;
408
- }
409
- return vi.fn();
410
- }
566
+ const inputError = new ValidationError( 'invalid workflow input' );
567
+ const outputError = new ValidationError( 'invalid workflow output' );
568
+ setWorkflowInfo( { workflowType: 'runtime_validation_wf' } );
569
+
570
+ const wf = workflow( workflowDefinition( {
571
+ name: 'runtime_validation_wf',
572
+ inputSchema: z.object( { value: z.string() } ),
573
+ outputSchema: z.object( { result: z.string() } ),
574
+ fn: async () => ( { result: 1 } )
411
575
  } ) );
412
576
 
413
- const wf = workflow( {
414
- name: 'shared_wf',
415
- description: 'Shared',
416
- inputSchema: z.object( {} ),
417
- outputSchema: z.object( {} ),
418
- async fn() {
419
- await this.invokeSharedStep( 'sharedStep', { data: 2 } );
420
- return {};
421
- }
577
+ validateInputMock.mockImplementationOnce( () => {
578
+ throw inputError;
422
579
  } );
580
+ await expect( wf( { value: 1 } ) ).rejects.toBe( inputError );
581
+ expect( inputError[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
423
582
 
424
- await wf( {} );
425
- expect( sharedSpy ).toHaveBeenCalledWith( { data: 2 }, undefined );
583
+ setWorkflowInfo( { workflowType: 'runtime_validation_wf', memo: {} } );
584
+ validateOutputMock.mockImplementationOnce( () => {
585
+ throw outputError;
586
+ } );
587
+ await expect( wf( { value: 'ok' } ) ).rejects.toBe( outputError );
588
+ expect( outputError[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
426
589
  } );
590
+ } );
427
591
 
428
- it( 'invokeEvaluator calls steps with workflowName#evaluatorName', async () => {
429
- const evalSpy = vi.fn().mockResolvedValue( true );
430
- proxyActivitiesMock.mockImplementation( () => new Proxy( {}, {
431
- get: ( _, prop ) => {
432
- if ( prop === '__internal#getTraceDestinations' ) {
433
- return traceDestinationsStepMock;
434
- }
435
- if ( prop === 'eval_wf#myEvaluator' ) {
436
- return evalSpy;
437
- }
438
- return vi.fn();
592
+ describe( 'activity dispatchers', () => {
593
+ it( 'invokes workflow-scoped steps and evaluators and unwraps activity output', async () => {
594
+ const { workflow } = await import( './workflow.js' );
595
+ setWorkflowInfo( { workflowType: 'dispatch_wf' } );
596
+ const step = vi.fn().mockResolvedValue( activityOutput( 'step-output' ) );
597
+ const evaluator = vi.fn().mockResolvedValue( activityOutput( 'eval-output' ) );
598
+ mockActivities( {
599
+ [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ),
600
+ 'dispatch_wf#stepA': step,
601
+ 'dispatch_wf#evalA': evaluator
602
+ } );
603
+
604
+ const wf = workflow( workflowDefinition( {
605
+ name: 'dispatch_wf',
606
+ outputSchema: z.object( { stepResult: z.string(), evalResult: z.string() } ),
607
+ async fn() {
608
+ return {
609
+ stepResult: await this.invokeStep( 'stepA', { a: 1 }, { b: 2 } ),
610
+ evalResult: await this.invokeEvaluator( 'evalA', { c: 3 } )
611
+ };
439
612
  }
440
613
  } ) );
441
614
 
442
- const { workflow } = await import( './workflow.js' );
615
+ await expect( wf( {} ) ).resolves.toEqual( {
616
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
617
+ output: { stepResult: 'step-output', evalResult: 'eval-output' }
618
+ } );
619
+ expect( step ).toHaveBeenCalledWith( { a: 1 }, { b: 2 } );
620
+ expect( evaluator ).toHaveBeenCalledWith( { c: 3 } );
621
+ } );
443
622
 
444
- const wf = workflow( {
445
- name: 'eval_wf',
446
- description: 'Eval',
447
- inputSchema: z.object( {} ),
448
- outputSchema: z.object( {} ),
623
+ it( 'invokes shared steps and shared evaluators with the shared prefix', async () => {
624
+ const { workflow } = await import( './workflow.js' );
625
+ setWorkflowInfo( { workflowType: 'shared_dispatch_wf' } );
626
+ const sharedStep = vi.fn().mockResolvedValue( activityOutput( 'shared-step-output' ) );
627
+ const sharedEvaluator = vi.fn().mockResolvedValue( activityOutput( 'shared-eval-output' ) );
628
+ mockActivities( {
629
+ [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ),
630
+ [`${SHARED_STEP_PREFIX}#stepA`]: sharedStep,
631
+ [`${SHARED_STEP_PREFIX}#evalA`]: sharedEvaluator
632
+ } );
633
+
634
+ const wf = workflow( workflowDefinition( {
635
+ name: 'shared_dispatch_wf',
636
+ outputSchema: z.object( { stepResult: z.string(), evalResult: z.string() } ),
449
637
  async fn() {
450
- await this.invokeEvaluator( 'myEvaluator', { x: 3 } );
451
- return {};
638
+ return {
639
+ stepResult: await this.invokeSharedStep( 'stepA' ),
640
+ evalResult: await this.invokeSharedEvaluator( 'evalA', { x: 1 } )
641
+ };
452
642
  }
453
- } );
643
+ } ) );
454
644
 
455
- await wf( {} );
456
- expect( evalSpy ).toHaveBeenCalledWith( { x: 3 }, undefined );
645
+ await expect( wf( {} ) ).resolves.toEqual( {
646
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
647
+ output: { stepResult: 'shared-step-output', evalResult: 'shared-eval-output' }
648
+ } );
649
+ expect( sharedStep ).toHaveBeenCalledWith();
650
+ expect( sharedEvaluator ).toHaveBeenCalledWith( { x: 1 } );
457
651
  } );
458
- } );
459
652
 
460
- describe( 'startWorkflow', () => {
461
- it( 'calls executeChild with correct args and TERMINATE when not detached', async () => {
653
+ it( 'supports old unwrapped step and evaluator activity results during replay', async () => {
462
654
  const { workflow } = await import( './workflow.js' );
463
- const { ParentClosePolicy } = await import( '@temporalio/workflow' );
464
- executeChildMock.mockResolvedValueOnce( { output: {}, aggregations: null } );
465
-
466
- const wf = workflow( {
467
- name: 'parent_wf',
468
- description: 'Parent',
469
- inputSchema: z.object( {} ),
470
- outputSchema: z.object( {} ),
655
+ setWorkflowInfo( { workflowType: 'old_activity_payload_wf' } );
656
+ const step = vi.fn().mockResolvedValue( 'legacy-step-output' );
657
+ const evaluator = vi.fn().mockResolvedValue( 'legacy-eval-output' );
658
+ mockActivities( {
659
+ [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ),
660
+ 'old_activity_payload_wf#stepA': step,
661
+ 'old_activity_payload_wf#evalA': evaluator
662
+ } );
663
+
664
+ const wf = workflow( workflowDefinition( {
665
+ name: 'old_activity_payload_wf',
666
+ outputSchema: z.object( { stepResult: z.string(), evalResult: z.string() } ),
471
667
  async fn() {
472
- await this.startWorkflow( 'child_wf', { id: 1 } );
473
- return {};
668
+ return {
669
+ stepResult: await this.invokeStep( 'stepA' ),
670
+ evalResult: await this.invokeEvaluator( 'evalA' )
671
+ };
474
672
  }
475
- } );
673
+ } ) );
476
674
 
477
- await wf( {} );
478
- expect( executeChildMock ).toHaveBeenCalledWith( 'child_wf', {
479
- args: [ { id: 1 } ],
480
- workflowId: expect.stringMatching( /^wf-test-123-/ ),
481
- parentClosePolicy: ParentClosePolicy.TERMINATE,
482
- memo: expect.objectContaining( {
483
- traceInfo: {
484
- workflowId: 'trace-workflow-id',
485
- workflowType: 'trace-workflow-type',
486
- runId: 'trace-run-id',
487
- startTime: 12345,
488
- disableTrace: false
489
- },
490
- activityOptions: expect.objectContaining( {
491
- startToCloseTimeout: '20m',
492
- heartbeatTimeout: '5m'
493
- } )
494
- } )
675
+ await expect( wf( {} ) ).resolves.toEqual( {
676
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
677
+ output: { stepResult: 'legacy-step-output', evalResult: 'legacy-eval-output' }
495
678
  } );
496
- const [ , childOptions ] = executeChildMock.mock.calls[0];
497
- expect( childOptions.memo ).not.toHaveProperty( 'executionContext' );
498
- expect( childOptions.memo ).not.toHaveProperty( 'parentId' );
499
679
  } );
680
+ } );
500
681
 
501
- it( 'uses ABANDON when extra.detached is true', async () => {
682
+ describe( 'error handling', () => {
683
+ it( 'attaches root trace destinations to root workflow errors before rethrowing', async () => {
502
684
  const { workflow } = await import( './workflow.js' );
503
- const { ParentClosePolicy } = await import( '@temporalio/workflow' );
504
- executeChildMock.mockResolvedValueOnce( { output: {}, aggregations: null } );
685
+ const error = new Error( 'root failed' );
686
+ setWorkflowInfo( { workflowType: 'root_error_wf' } );
505
687
 
506
- const wf = workflow( {
507
- name: 'detach_wf',
508
- description: 'Detach',
509
- inputSchema: z.object( {} ),
510
- outputSchema: z.object( {} ),
511
- async fn() {
512
- await this.startWorkflow( 'child_wf', null, { detached: true } );
513
- return {};
688
+ const wf = workflow( workflowDefinition( {
689
+ name: 'root_error_wf',
690
+ fn: async () => {
691
+ throw error;
514
692
  }
515
- } );
516
-
517
- await wf( {} );
518
- expect( executeChildMock ).toHaveBeenCalledWith( 'child_wf', expect.objectContaining( {
519
- args: [ null ],
520
- parentClosePolicy: ParentClosePolicy.ABANDON
521
693
  } ) );
694
+
695
+ const thrown = await wf( {} ).catch( e => e );
696
+ expect( thrown ).toBe( error );
697
+ expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
522
698
  } );
523
699
 
524
- it( 'passes empty args when input is omitted', async () => {
700
+ it( 'preserves existing error details when attaching root trace metadata', async () => {
525
701
  const { workflow } = await import( './workflow.js' );
526
- executeChildMock.mockResolvedValueOnce( { output: {}, aggregations: null } );
702
+ const error = new Error( 'root failed with details' );
703
+ error.details = [ { domain: { reason: 'bad-input' } } ];
704
+ setWorkflowInfo( { workflowType: 'root_error_existing_details_wf' } );
527
705
 
528
- const wf = workflow( {
529
- name: 'no_input_wf',
530
- description: 'No input',
531
- inputSchema: z.object( {} ),
532
- outputSchema: z.object( {} ),
533
- async fn() {
534
- await this.startWorkflow( 'child_wf' );
535
- return {};
706
+ const wf = workflow( workflowDefinition( {
707
+ name: 'root_error_existing_details_wf',
708
+ fn: async () => {
709
+ throw error;
536
710
  }
537
- } );
538
-
539
- await wf( {} );
540
- expect( executeChildMock ).toHaveBeenCalledWith( 'child_wf', expect.objectContaining( {
541
- args: []
542
711
  } ) );
712
+
713
+ await expect( wf( {} ) ).rejects.toBe( error );
714
+ expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
715
+ expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
543
716
  } );
544
717
 
545
- it( 'merges per-child activity options into the propagated memo', async () => {
718
+ it( 'rethrows root workflow errors without metadata when trace destinations are unavailable', async () => {
546
719
  const { workflow } = await import( './workflow.js' );
547
- executeChildMock.mockResolvedValueOnce( { output: {}, aggregations: null } );
720
+ setWorkflowInfo( { workflowType: 'root_error_no_trace_wf' } );
721
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ) } );
722
+ const error = new Error( 'root failed without trace' );
548
723
 
549
- const wf = workflow( {
550
- name: 'child_options_wf',
551
- description: 'Child options',
552
- inputSchema: z.object( {} ),
553
- outputSchema: z.object( {} ),
554
- async fn() {
555
- await this.startWorkflow( 'child_wf', { id: 1 }, {
556
- options: {
557
- activityOptions: {
558
- startToCloseTimeout: '2m',
559
- retry: { maximumAttempts: 7 }
560
- }
561
- }
562
- } );
563
- return {};
724
+ const wf = workflow( workflowDefinition( {
725
+ name: 'root_error_no_trace_wf',
726
+ fn: async () => {
727
+ throw error;
564
728
  }
565
- } );
566
-
567
- await wf( {} );
568
- expect( executeChildMock ).toHaveBeenCalledWith( 'child_wf', expect.objectContaining( {
569
- memo: expect.objectContaining( {
570
- traceInfo: expect.objectContaining( {
571
- workflowId: 'trace-workflow-id',
572
- runId: 'trace-run-id'
573
- } ),
574
- activityOptions: expect.objectContaining( {
575
- startToCloseTimeout: '2m',
576
- heartbeatTimeout: '5m',
577
- retry: expect.objectContaining( {
578
- initialInterval: '10s',
579
- maximumAttempts: 7
580
- } )
581
- } )
582
- } )
583
729
  } ) );
730
+
731
+ await expect( wf( {} ) ).rejects.toBe( error );
732
+ expect( error[METADATA_ACCESS_SYMBOL] ).toBeUndefined();
584
733
  } );
585
734
 
586
- it( 'returns child output and merges child workflow aggregations into the root aggregations', async () => {
735
+ it( 'preserves existing error details when trace destinations are unavailable', async () => {
587
736
  const { workflow } = await import( './workflow.js' );
588
- executeChildMock.mockResolvedValueOnce( {
589
- output: { child: 'ok' },
590
- aggregations: {
591
- cost: { total: 1.5 },
592
- tokens: { total: 4, input: 4 },
593
- httpRequests: { total: 2 }
594
- }
595
- } );
737
+ setWorkflowInfo( { workflowType: 'root_error_existing_details_no_trace_wf' } );
738
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ) } );
739
+ const error = new Error( 'root failed without trace' );
740
+ error.details = [ { domain: { reason: 'bad-input' } } ];
596
741
 
597
- const wf = workflow( {
598
- name: 'merge_child_aggregations_wf',
599
- description: 'Merge child aggregations',
600
- inputSchema: z.object( {} ),
601
- outputSchema: z.object( { child: z.string() } ),
602
- async fn() {
603
- return this.startWorkflow( 'child_wf', { id: 1 } );
742
+ const wf = workflow( workflowDefinition( {
743
+ name: 'root_error_existing_details_no_trace_wf',
744
+ fn: async () => {
745
+ throw error;
604
746
  }
605
- } );
747
+ } ) );
606
748
 
607
- const result = await wf( {} );
608
- expect( result ).toEqual( {
609
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
610
- output: { child: 'ok' },
611
- trace: { destinations: { local: '/tmp/trace' } },
612
- aggregations: {
613
- cost: { total: 1.5 },
614
- tokens: { total: 4, input: 4 },
615
- httpRequests: { total: 2 }
616
- }
617
- } );
749
+ await expect( wf( {} ) ).rejects.toBe( error );
750
+ expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
751
+ expect( error[METADATA_ACCESS_SYMBOL] ).toBeUndefined();
618
752
  } );
619
753
 
620
- it( 'merges child error aggregations before rethrowing to root metadata', async () => {
754
+ it( 'attaches trace metadata to existing root ApplicationFailure without wrapping it', async () => {
621
755
  const { workflow } = await import( './workflow.js' );
622
- const { ChildWorkflowFailure } = await import( '@temporalio/workflow' );
623
- const { METADATA_ACCESS_SYMBOL } = await import( '#consts' );
624
- const childError = new ChildWorkflowFailure( 'child failed', {
625
- message: 'Child workflow execution failed',
626
- details: [ {
627
- aggregations: {
628
- cost: { total: 3 },
629
- tokens: { total: 8, output: 8 },
630
- httpRequests: { total: 0 }
631
- }
632
- } ]
756
+ const { ApplicationFailure } = await import( '@temporalio/workflow' );
757
+ setWorkflowInfo( { workflowType: 'root_application_failure_wf' } );
758
+ const error = ApplicationFailure.create( {
759
+ message: 'root application failed',
760
+ type: 'OriginalType',
761
+ nonRetryable: true,
762
+ details: [ { domain: { reason: 'bad-input' } } ]
633
763
  } );
634
- executeChildMock.mockRejectedValueOnce( childError );
635
764
 
636
- const wf = workflow( {
637
- name: 'child_error_aggregations_wf',
638
- description: 'Child error aggregations',
639
- inputSchema: z.object( {} ),
640
- outputSchema: z.object( {} ),
641
- async fn() {
642
- await this.startWorkflow( 'child_wf', { id: 1 } );
643
- return {};
765
+ const wf = workflow( workflowDefinition( {
766
+ name: 'root_application_failure_wf',
767
+ fn: async () => {
768
+ throw error;
644
769
  }
645
- } );
770
+ } ) );
646
771
 
647
- await expect( wf( {} ) ).rejects.toThrow( 'child failed' );
648
- expect( childError[METADATA_ACCESS_SYMBOL] ).toEqual( {
649
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
650
- trace: { destinations: { local: '/tmp/trace' } },
651
- aggregations: {
652
- cost: { total: 3 },
653
- tokens: { total: 8, output: 8 },
654
- httpRequests: { total: 0 }
655
- }
656
- } );
772
+ const thrown = await wf( {} ).catch( e => e );
773
+
774
+ expect( thrown ).toBe( error );
775
+ expect( error.type ).toBe( 'OriginalType' );
776
+ expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
777
+ expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
657
778
  } );
658
- } );
659
779
 
660
- describe( 'error handling (root workflow)', () => {
661
- it( 'rethrows error from fn with trace and aggregation metadata', async () => {
780
+ it( 'rethrows non-root workflow errors without ApplicationFailure wrapping', async () => {
662
781
  const { workflow } = await import( './workflow.js' );
663
- const { METADATA_ACCESS_SYMBOL } = await import( '#consts' );
664
- const error = new Error( 'workflow failed' );
665
-
666
- const wf = workflow( {
667
- name: 'err_wf',
668
- description: 'Error',
669
- inputSchema: z.object( {} ),
670
- outputSchema: z.object( {} ),
782
+ const error = new Error( 'nested failed' );
783
+ setWorkflowInfo( {
784
+ workflowId: 'nested-workflow',
785
+ root: { workflowId: 'root-workflow', runId: 'root-run' },
786
+ memo: { stack: [ 'root-workflow' ], traceInfo: { workflowId: 'root-workflow' } }
787
+ } );
788
+
789
+ const wf = workflow( workflowDefinition( {
790
+ name: 'nested_error_wf',
671
791
  fn: async () => {
672
792
  throw error;
673
793
  }
674
- } );
794
+ } ) );
675
795
 
676
- await expect( wf( {} ) ).rejects.toThrow( 'workflow failed' );
677
- expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( {
678
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
679
- trace: { destinations: { local: '/tmp/trace' } },
680
- aggregations: null
681
- } );
796
+ await expect( wf( {} ) ).rejects.toBe( error );
797
+ expect( error[METADATA_ACCESS_SYMBOL] ).toBeUndefined();
682
798
  } );
683
799
  } );
684
800
  } );