@outputai/core 0.10.1-next.fc0a41f.0 → 0.11.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 (63) hide show
  1. package/package.json +12 -7
  2. package/src/consts.js +5 -2
  3. package/src/errors.d.ts +12 -2
  4. package/src/errors.js +17 -3
  5. package/src/errors.spec.js +19 -0
  6. package/src/helpers/activity_options.js +21 -0
  7. package/src/helpers/activity_options.spec.js +55 -0
  8. package/src/helpers/error_serializer.js +236 -0
  9. package/src/helpers/error_serializer.spec.js +413 -0
  10. package/src/helpers/errors.js +55 -13
  11. package/src/helpers/errors.spec.js +166 -66
  12. package/src/helpers/fetch.js +1 -38
  13. package/src/helpers/fetch.spec.js +1 -39
  14. package/src/helpers/function.js +13 -0
  15. package/src/helpers/function.spec.js +23 -1
  16. package/src/helpers/redact.js +51 -0
  17. package/src/helpers/redact.spec.js +66 -0
  18. package/src/helpers/string.js +10 -0
  19. package/src/helpers/string.spec.js +36 -1
  20. package/src/hooks/index.d.ts +35 -44
  21. package/src/hooks/index.js +2 -1
  22. package/src/hooks/index.spec.js +15 -7
  23. package/src/interface/evaluator.d.ts +10 -9
  24. package/src/interface/evaluator.js +1 -6
  25. package/src/interface/evaluator.spec.js +10 -10
  26. package/src/interface/step.d.ts +16 -15
  27. package/src/interface/step.js +1 -6
  28. package/src/interface/step.spec.js +10 -10
  29. package/src/interface/validations/index.js +33 -53
  30. package/src/interface/validations/index.spec.js +25 -17
  31. package/src/interface/webhook.js +1 -2
  32. package/src/interface/webhook.spec.js +25 -19
  33. package/src/interface/workflow.d.ts +25 -18
  34. package/src/interface/workflow.js +24 -52
  35. package/src/interface/workflow.spec.js +109 -108
  36. package/src/interface/workflow_activity_options.js +2 -5
  37. package/src/temporal/client.js +47 -0
  38. package/src/temporal/client.spec.js +124 -0
  39. package/src/temporal/index.d.ts +32 -0
  40. package/src/temporal/index.js +1 -0
  41. package/src/tracing/trace_engine.js +9 -11
  42. package/src/tracing/trace_engine.spec.js +67 -28
  43. package/src/worker/catalog_workflow/catalog_job.js +3 -3
  44. package/src/worker/catalog_workflow/catalog_job.spec.js +1 -1
  45. package/src/worker/index.js +10 -1
  46. package/src/worker/index.spec.js +23 -8
  47. package/src/worker/interceptors/activity.js +32 -19
  48. package/src/worker/interceptors/activity.spec.js +71 -166
  49. package/src/worker/interceptors/workflow.js +53 -11
  50. package/src/worker/interceptors/workflow.spec.js +176 -49
  51. package/src/worker/loader/workflows.js +16 -3
  52. package/src/worker/loader/workflows.spec.js +37 -5
  53. package/src/worker/log_hooks.js +13 -10
  54. package/src/worker/log_hooks.spec.js +12 -4
  55. package/src/worker/sinks.js +3 -1
  56. package/src/worker/sinks.spec.js +21 -4
  57. package/src/worker/telemetry.js +2 -1
  58. package/src/worker/temporal_logger.js +33 -0
  59. package/src/worker/temporal_logger.spec.js +90 -0
  60. package/src/helpers/aggregations.js +0 -44
  61. package/src/helpers/aggregations.spec.js +0 -92
  62. package/src/tracing/tools/utils.js +0 -42
  63. package/src/tracing/tools/utils.spec.js +0 -83
@@ -2,10 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { z } from 'zod';
3
3
  import {
4
4
  ACTIVITY_GET_TRACE_DESTINATIONS,
5
- ACTIVITY_WRAPPER_VERSION_FIELD,
6
- INVOKE_ACTIVITY_SYMBOL,
7
- METADATA_ACCESS_SYMBOL,
8
- WORKFLOW_WRAPPER_VERSION_FIELD
5
+ INVOKE_ACTIVITY_SYMBOL
9
6
  } from '#consts';
10
7
  import { ValidationError } from '#errors';
11
8
 
@@ -13,11 +10,12 @@ const inWorkflowContextMock = vi.hoisted( () => vi.fn() );
13
10
  const proxyActivitiesMock = vi.hoisted( () => vi.fn() );
14
11
  const executeChildMock = vi.hoisted( () => vi.fn() );
15
12
  const workflowInfoMock = vi.hoisted( () => vi.fn() );
13
+ const upsertMemoMock = vi.hoisted( () => vi.fn() );
16
14
  const continueAsNewMock = vi.hoisted( () => vi.fn() );
17
15
  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() );
16
+ const parseInputMock = vi.hoisted( () => vi.fn( input => input ) );
17
+ const parseOutputMock = vi.hoisted( () => vi.fn( output => output ) );
18
+ const parseInvocationOptionsMock = vi.hoisted( () => vi.fn( options => options ) );
21
19
  const validatorConstructorMock = vi.hoisted( () => vi.fn() );
22
20
  const createWorkflowMock = vi.hoisted( () => vi.fn( ( { handler } ) => handler ) );
23
21
 
@@ -29,9 +27,9 @@ vi.mock( './validations/index.js', () => {
29
27
 
30
28
  constructor( ...args ) {
31
29
  validatorConstructorMock( ...args );
32
- this.validateInput = validateInputMock;
33
- this.validateOutput = validateOutputMock;
34
- this.validateInvocationOptions = validateInvocationOptionsMock;
30
+ this.parseInput = parseInputMock;
31
+ this.parseOutput = parseOutputMock;
32
+ this.parseInvocationOptions = parseInvocationOptionsMock;
35
33
  }
36
34
  }
37
35
 
@@ -50,6 +48,7 @@ vi.mock( '@temporalio/workflow', async importOriginal => {
50
48
  proxyActivities: proxyActivitiesMock,
51
49
  executeChild: executeChildMock,
52
50
  workflowInfo: workflowInfoMock,
51
+ upsertMemo: upsertMemoMock,
53
52
  continueAsNew: continueAsNewMock,
54
53
  uuid4: () => '550e8400e29b41d4a716446655440000'
55
54
  };
@@ -60,28 +59,24 @@ const baseWorkflowInfo = () => ( {
60
59
  workflowType: 'test_wf',
61
60
  runId: 'run-123',
62
61
  startTime: new Date( '2025-01-01T00:00:00.000Z' ),
63
- memo: {},
62
+ memo: { traceInfo: { workflowId: 'workflow-123' } },
64
63
  continueAsNewSuggested: false
65
64
  } );
66
65
 
67
66
  const setWorkflowInfo = overrides => {
67
+ const base = baseWorkflowInfo();
68
68
  const info = {
69
- ...baseWorkflowInfo(),
69
+ ...base,
70
70
  ...overrides,
71
- memo: overrides?.memo ?? {}
71
+ memo: overrides?.memo ?? base.memo
72
72
  };
73
73
  workflowInfoMock.mockImplementation( () => info );
74
74
  return info;
75
75
  };
76
76
 
77
- const activityOutput = output => ( {
78
- output,
79
- [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
80
- } );
81
-
82
77
  const createActivities = handlers => new Proxy( {}, {
83
78
  get: ( _, prop ) => typeof prop === 'string' ?
84
- handlers[prop] ?? vi.fn().mockResolvedValue( activityOutput( undefined ) ) :
79
+ handlers[prop] ?? vi.fn().mockResolvedValue( undefined ) :
85
80
  undefined
86
81
  } );
87
82
 
@@ -112,10 +107,10 @@ describe( 'workflow()', () => {
112
107
  vi.clearAllMocks();
113
108
  delete globalThis[INVOKE_ACTIVITY_SYMBOL];
114
109
  inWorkflowContextMock.mockReturnValue( true );
115
- executeChildMock.mockResolvedValue( { output: {} } );
110
+ executeChildMock.mockResolvedValue( {} );
116
111
  setWorkflowInfo();
117
112
  mockActivities( {
118
- [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( { local: '/tmp/trace' } ) )
113
+ [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( { local: '/tmp/trace' } )
119
114
  } );
120
115
  } );
121
116
 
@@ -210,9 +205,9 @@ describe( 'workflow()', () => {
210
205
  value: 'test-workflow-ok',
211
206
  extra: 'custom'
212
207
  } );
213
- expect( validateInvocationOptionsMock ).toHaveBeenCalledWith( { context: { extra: 'custom' } } );
214
- expect( validateInputMock ).toHaveBeenCalledWith( { suffix: '-ok' } );
215
- expect( validateOutputMock ).toHaveBeenCalledWith( { value: 'test-workflow-ok', extra: 'custom' } );
208
+ expect( parseInvocationOptionsMock ).toHaveBeenCalledWith( { context: { extra: 'custom' } } );
209
+ expect( parseInputMock ).toHaveBeenCalledWith( { suffix: '-ok' } );
210
+ expect( parseOutputMock ).toHaveBeenCalledWith( { value: 'test-workflow-ok', extra: 'custom' } );
216
211
  expect( workflowInfoMock ).not.toHaveBeenCalled();
217
212
  expect( proxyActivitiesMock ).not.toHaveBeenCalled();
218
213
  } );
@@ -220,7 +215,7 @@ describe( 'workflow()', () => {
220
215
  it( 'does not run fn when plain function input validation fails', async () => {
221
216
  const { workflow } = await import( './workflow.js' );
222
217
  const error = new ValidationError( 'invalid input' );
223
- validateInputMock.mockImplementationOnce( () => {
218
+ parseInputMock.mockImplementationOnce( () => {
224
219
  throw error;
225
220
  } );
226
221
  const fn = vi.fn();
@@ -234,13 +229,13 @@ describe( 'workflow()', () => {
234
229
 
235
230
  await expect( wf( { value: 1 } ) ).rejects.toBe( error );
236
231
  expect( fn ).not.toHaveBeenCalled();
237
- expect( validateOutputMock ).not.toHaveBeenCalled();
232
+ expect( parseOutputMock ).not.toHaveBeenCalled();
238
233
  } );
239
234
 
240
235
  it( 'propagates plain function output validation errors after fn runs', async () => {
241
236
  const { workflow } = await import( './workflow.js' );
242
237
  const error = new ValidationError( 'invalid output' );
243
- validateOutputMock.mockImplementationOnce( () => {
238
+ parseOutputMock.mockImplementationOnce( () => {
244
239
  throw error;
245
240
  } );
246
241
  const output = { result: 1 };
@@ -257,7 +252,7 @@ describe( 'workflow()', () => {
257
252
  expect( fn ).toHaveBeenCalledWith( { value: 'ok' }, expect.objectContaining( {
258
253
  info: { workflowId: 'test-workflow', runId: 'test-run' }
259
254
  } ) );
260
- expect( validateOutputMock ).toHaveBeenCalledWith( output );
255
+ expect( parseOutputMock ).toHaveBeenCalledWith( output );
261
256
  } );
262
257
  } );
263
258
 
@@ -268,7 +263,7 @@ describe( 'workflow()', () => {
268
263
  installGlobalDispatcher( 'run-123' );
269
264
  const memo = {
270
265
  traceInfo: { workflowId: 'root-workflow' },
271
- parentActivityOptions: {
266
+ activityOptions: {
272
267
  heartbeatTimeout: '30s',
273
268
  retry: {
274
269
  maximumInterval: '20s',
@@ -277,7 +272,7 @@ describe( 'workflow()', () => {
277
272
  }
278
273
  };
279
274
  setWorkflowInfo( { memo } );
280
- executeChildMock.mockResolvedValueOnce( { output: { child: 'ok' } } );
275
+ executeChildMock.mockResolvedValueOnce( { child: 'ok' } );
281
276
 
282
277
  const wf = workflow( workflowDefinition( {
283
278
  name: 'child_target_wf',
@@ -294,7 +289,7 @@ describe( 'workflow()', () => {
294
289
  retry: { maximumAttempts: 7 }
295
290
  }
296
291
  } ) ).resolves.toEqual( { child: 'ok' } );
297
- expect( validateInvocationOptionsMock ).toHaveBeenCalledWith( {
292
+ expect( parseInvocationOptionsMock ).toHaveBeenCalledWith( {
298
293
  detached: true,
299
294
  context: { testOnly: true },
300
295
  activityOptions: {
@@ -326,7 +321,7 @@ describe( 'workflow()', () => {
326
321
  const { ParentClosePolicy } = await import( '@temporalio/workflow' );
327
322
  installGlobalDispatcher( 'run-123' );
328
323
  setWorkflowInfo( { memo: { traceInfo: { workflowId: 'root-workflow' } } } );
329
- executeChildMock.mockResolvedValueOnce( { output: 'done' } );
324
+ executeChildMock.mockResolvedValueOnce( 'done' );
330
325
 
331
326
  const wf = workflow( workflowDefinition( {
332
327
  name: 'no_input_child_wf',
@@ -356,6 +351,20 @@ describe( 'workflow()', () => {
356
351
  } );
357
352
 
358
353
  describe( 'workflow execution path', () => {
354
+ it( 'validates invocation options before reading workflow information', async () => {
355
+ const { workflow } = await import( './workflow.js' );
356
+ const error = new ValidationError( 'invalid invocation options' );
357
+ parseInvocationOptionsMock.mockImplementationOnce( () => {
358
+ throw error;
359
+ } );
360
+ const wf = workflow( workflowDefinition() );
361
+
362
+ await expect( wf( {}, { invalid: true } ) ).rejects.toBe( error );
363
+
364
+ expect( upsertMemoMock ).not.toHaveBeenCalled();
365
+ expect( workflowInfoMock ).not.toHaveBeenCalled();
366
+ } );
367
+
359
368
  it( 'rejects a global activity dispatcher left by another workflow run', async () => {
360
369
  const { workflow } = await import( './workflow.js' );
361
370
  const fn = vi.fn().mockResolvedValue( {} );
@@ -373,9 +382,9 @@ describe( 'workflow()', () => {
373
382
  expect( fn ).not.toHaveBeenCalled();
374
383
  } );
375
384
 
376
- it( 'initializes root memo, skips trace destinations when trace is disabled, validates output, and returns an envelope', async () => {
385
+ it( 'skips trace destinations when trace is disabled and returns raw output', async () => {
377
386
  const { workflow } = await import( './workflow.js' );
378
- const getTraceDestinations = vi.fn().mockResolvedValue( activityOutput( { local: '/tmp/root-trace' } ) );
387
+ const getTraceDestinations = vi.fn().mockResolvedValue( { local: '/tmp/root-trace' } );
379
388
  const info = setWorkflowInfo( { workflowType: 'root_wf', memo: {} } );
380
389
  mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: getTraceDestinations } );
381
390
 
@@ -392,32 +401,28 @@ describe( 'workflow()', () => {
392
401
  fn: async ( _, context ) => ( { ok: context.info.workflowId === 'workflow-123' } )
393
402
  } ) );
394
403
 
395
- await expect( wf( {} ) ).resolves.toEqual( {
396
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
397
- output: { ok: true },
398
- trace: { destinations: {} }
399
- } );
404
+ await expect( wf( {} ) ).resolves.toEqual( { ok: true } );
400
405
  expect( info.memo.traceInfo ).toBeUndefined();
401
406
  expect( info.memo.activityOptions ).toBeUndefined();
402
- expect( info.memo.parentActivityOptions ).toEqual( expect.objectContaining( {
403
- startToCloseTimeout: '5m',
404
- heartbeatTimeout: '5m',
405
- retry: expect.objectContaining( { maximumAttempts: 5 } )
406
- } ) );
407
407
  expect( proxyActivitiesMock ).toHaveBeenCalledWith( expect.objectContaining( {
408
408
  startToCloseTimeout: '5m',
409
409
  heartbeatTimeout: '5m',
410
410
  retry: expect.objectContaining( { maximumAttempts: 5 } )
411
411
  } ) );
412
412
  expect( getTraceDestinations ).not.toHaveBeenCalled();
413
+ expect( upsertMemoMock ).toHaveBeenCalledOnce();
414
+ expect( upsertMemoMock ).toHaveBeenCalledWith( {
415
+ activityOptions: proxyActivitiesMock.mock.calls[0][0]
416
+ } );
413
417
  } );
414
418
 
415
419
  it( 'resolves activity options by invocation, definition, inherited memo, then default precedence', async () => {
416
420
  const { workflow } = await import( './workflow.js' );
417
- const info = setWorkflowInfo( {
421
+ setWorkflowInfo( {
418
422
  workflowType: 'activity_options_wf',
419
423
  memo: {
420
- parentActivityOptions: {
424
+ traceInfo: { workflowId: 'workflow-123' },
425
+ activityOptions: {
421
426
  heartbeatTimeout: '30s',
422
427
  retry: {
423
428
  maximumInterval: '30s',
@@ -458,18 +463,22 @@ describe( 'workflow()', () => {
458
463
  backoffCoefficient: 3,
459
464
  maximumInterval: '30s',
460
465
  maximumAttempts: 9,
461
- nonRetryableErrorTypes: [ ValidationError.name, 'FatalError' ]
466
+ nonRetryableErrorTypes: [ 'FatalError' ]
462
467
  }
463
468
  } );
464
- expect( info.memo.parentActivityOptions ).toEqual( proxyActivitiesMock.mock.calls[0][0] );
469
+ expect( upsertMemoMock ).toHaveBeenCalledOnce();
470
+ expect( upsertMemoMock ).toHaveBeenCalledWith( {
471
+ activityOptions: proxyActivitiesMock.mock.calls[0][0],
472
+ trace: { local: '/tmp/trace' }
473
+ } );
465
474
  } );
466
475
 
467
476
  it( 'runs non-root workflow execution without rebuilding trace info or fetching trace destinations', async () => {
468
477
  const { workflow } = await import( './workflow.js' );
469
- const getTraceDestinations = vi.fn().mockResolvedValue( activityOutput( { local: '/tmp/trace' } ) );
478
+ const getTraceDestinations = vi.fn().mockResolvedValue( { local: '/tmp/trace' } );
470
479
  const memo = {
471
480
  traceInfo: { workflowId: 'root-workflow' },
472
- parentActivityOptions: {
481
+ activityOptions: {
473
482
  startToCloseTimeout: '9m',
474
483
  retry: { maximumAttempts: 8 }
475
484
  }
@@ -493,29 +502,27 @@ describe( 'workflow()', () => {
493
502
  fn: async () => ( { ok: true } )
494
503
  } ) );
495
504
 
496
- await expect( wf( {} ) ).resolves.toEqual( {
497
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
498
- output: { ok: true }
499
- } );
505
+ await expect( wf( {} ) ).resolves.toEqual( { ok: true } );
500
506
  expect( info.memo.traceInfo ).toBe( memo.traceInfo );
501
- expect( info.memo.activityOptions ).toBeUndefined();
502
- expect( info.memo.parentActivityOptions ).toEqual( expect.objectContaining( {
503
- startToCloseTimeout: '1m',
504
- heartbeatTimeout: '5m',
505
- retry: expect.objectContaining( { maximumAttempts: 2 } )
506
- } ) );
507
507
  expect( proxyActivitiesMock ).toHaveBeenCalledWith( expect.objectContaining( {
508
508
  startToCloseTimeout: '1m',
509
509
  heartbeatTimeout: '5m',
510
510
  retry: expect.objectContaining( { maximumAttempts: 2 } )
511
511
  } ) );
512
512
  expect( getTraceDestinations ).not.toHaveBeenCalled();
513
+ expect( upsertMemoMock ).toHaveBeenCalledOnce();
514
+ expect( upsertMemoMock ).toHaveBeenCalledWith( {
515
+ activityOptions: proxyActivitiesMock.mock.calls[0][0]
516
+ } );
513
517
  } );
514
518
 
515
- it( 'returns empty trace destinations when getTraceDestinations returns no destinations', async () => {
519
+ it( 'stores empty trace destinations in memo and returns raw output', async () => {
516
520
  const { workflow } = await import( './workflow.js' );
517
- setWorkflowInfo( { workflowType: 'no_trace_dest_wf' } );
518
- mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( {} ) ) } );
521
+ setWorkflowInfo( {
522
+ workflowType: 'no_trace_dest_wf',
523
+ memo: { traceInfo: { workflowId: 'workflow-123' } }
524
+ } );
525
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( {} ) } );
519
526
 
520
527
  const wf = workflow( workflowDefinition( {
521
528
  name: 'no_trace_dest_wf',
@@ -523,10 +530,11 @@ describe( 'workflow()', () => {
523
530
  fn: async () => ( { ok: true } )
524
531
  } ) );
525
532
 
526
- await expect( wf( {} ) ).resolves.toEqual( {
527
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
528
- output: { ok: true },
529
- trace: { destinations: {} }
533
+ await expect( wf( {} ) ).resolves.toEqual( { ok: true } );
534
+ expect( upsertMemoMock ).toHaveBeenCalledOnce();
535
+ expect( upsertMemoMock ).toHaveBeenCalledWith( {
536
+ activityOptions: proxyActivitiesMock.mock.calls[0][0],
537
+ trace: {}
530
538
  } );
531
539
  } );
532
540
 
@@ -543,30 +551,38 @@ describe( 'workflow()', () => {
543
551
  fn: async () => ( { result: 1 } )
544
552
  } ) );
545
553
 
546
- validateInputMock.mockImplementationOnce( () => {
554
+ parseInputMock.mockImplementationOnce( () => {
547
555
  throw inputError;
548
556
  } );
549
557
  await expect( wf( { value: 1 } ) ).rejects.toBe( inputError );
550
- expect( inputError[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
558
+ expect( upsertMemoMock ).toHaveBeenCalledWith( expect.objectContaining( {
559
+ trace: { local: '/tmp/trace' }
560
+ } ) );
551
561
 
552
562
  delete globalThis[INVOKE_ACTIVITY_SYMBOL];
553
- setWorkflowInfo( { workflowType: 'runtime_validation_wf', memo: {} } );
554
- validateOutputMock.mockImplementationOnce( () => {
563
+ setWorkflowInfo( {
564
+ workflowType: 'runtime_validation_wf',
565
+ memo: { traceInfo: { workflowId: 'workflow-123' } }
566
+ } );
567
+ upsertMemoMock.mockClear();
568
+ parseOutputMock.mockImplementationOnce( () => {
555
569
  throw outputError;
556
570
  } );
557
571
  await expect( wf( { value: 'ok' } ) ).rejects.toBe( outputError );
558
- expect( outputError[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
572
+ expect( upsertMemoMock ).toHaveBeenCalledWith( expect.objectContaining( {
573
+ trace: { local: '/tmp/trace' }
574
+ } ) );
559
575
  } );
560
576
  } );
561
577
 
562
578
  describe( 'activity dispatchers', () => {
563
- it( 'invokes workflow-scoped activities and unwraps activity output', async () => {
579
+ it( 'invokes workflow-scoped activities and returns their raw outputs', async () => {
564
580
  const { workflow } = await import( './workflow.js' );
565
581
  setWorkflowInfo( { workflowType: 'dispatch_wf' } );
566
- const step = vi.fn().mockResolvedValue( activityOutput( 'step-output' ) );
567
- const evaluator = vi.fn().mockResolvedValue( activityOutput( 'eval-output' ) );
582
+ const step = vi.fn().mockResolvedValue( 'step-output' );
583
+ const evaluator = vi.fn().mockResolvedValue( 'eval-output' );
568
584
  mockActivities( {
569
- [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ),
585
+ [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( {} ),
570
586
  'dispatch_wf#stepA': step,
571
587
  'dispatch_wf#evalA': evaluator
572
588
  } );
@@ -582,11 +598,7 @@ describe( 'workflow()', () => {
582
598
  }
583
599
  } ) );
584
600
 
585
- await expect( wf( {} ) ).resolves.toEqual( {
586
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
587
- output: { stepResult: 'step-output', evalResult: 'eval-output' },
588
- trace: { destinations: {} }
589
- } );
601
+ await expect( wf( {} ) ).resolves.toEqual( { stepResult: 'step-output', evalResult: 'eval-output' } );
590
602
  expect( step ).toHaveBeenCalledWith( { a: 1 }, { b: 2 } );
591
603
  expect( evaluator ).toHaveBeenCalledWith( { c: 3 } );
592
604
  } );
@@ -594,10 +606,10 @@ describe( 'workflow()', () => {
594
606
  it( 'invokes shared activities through the workflow namespace', async () => {
595
607
  const { workflow } = await import( './workflow.js' );
596
608
  setWorkflowInfo( { workflowType: 'shared_dispatch_wf' } );
597
- const sharedStep = vi.fn().mockResolvedValue( activityOutput( 'shared-step-output' ) );
598
- const sharedEvaluator = vi.fn().mockResolvedValue( activityOutput( 'shared-eval-output' ) );
609
+ const sharedStep = vi.fn().mockResolvedValue( 'shared-step-output' );
610
+ const sharedEvaluator = vi.fn().mockResolvedValue( 'shared-eval-output' );
599
611
  mockActivities( {
600
- [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ),
612
+ [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( {} ),
601
613
  'shared_dispatch_wf#stepA': sharedStep,
602
614
  'shared_dispatch_wf#evalA': sharedEvaluator
603
615
  } );
@@ -614,9 +626,8 @@ describe( 'workflow()', () => {
614
626
  } ) );
615
627
 
616
628
  await expect( wf( {} ) ).resolves.toEqual( {
617
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
618
- output: { stepResult: 'shared-step-output', evalResult: 'shared-eval-output' },
619
- trace: { destinations: {} }
629
+ stepResult: 'shared-step-output',
630
+ evalResult: 'shared-eval-output'
620
631
  } );
621
632
  expect( sharedStep ).toHaveBeenCalledWith();
622
633
  expect( sharedEvaluator ).toHaveBeenCalledWith( { x: 1 } );
@@ -624,7 +635,7 @@ describe( 'workflow()', () => {
624
635
  } );
625
636
 
626
637
  describe( 'error handling', () => {
627
- it( 'attaches root trace destinations to root workflow errors and preserves existing details', async () => {
638
+ it( 'stores root trace destinations in memo and preserves workflow errors', async () => {
628
639
  const { workflow } = await import( './workflow.js' );
629
640
  const error = new Error( 'root failed with details' );
630
641
  error.details = [ { domain: { reason: 'bad-input' } } ];
@@ -640,27 +651,12 @@ describe( 'workflow()', () => {
640
651
  const thrown = await wf( {} ).catch( e => e );
641
652
  expect( thrown ).toBe( error );
642
653
  expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
643
- expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
644
- } );
645
-
646
- it( 'attaches empty trace destinations to root workflow errors when no destinations are available', async () => {
647
- const { workflow } = await import( './workflow.js' );
648
- setWorkflowInfo( { workflowType: 'root_error_no_trace_wf' } );
649
- mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( {} ) ) } );
650
- const error = new Error( 'root failed without trace' );
651
-
652
- const wf = workflow( workflowDefinition( {
653
- name: 'root_error_no_trace_wf',
654
- fn: async () => {
655
- throw error;
656
- }
654
+ expect( upsertMemoMock ).toHaveBeenCalledWith( expect.objectContaining( {
655
+ trace: { local: '/tmp/trace' }
657
656
  } ) );
658
-
659
- await expect( wf( {} ) ).rejects.toBe( error );
660
- expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: {} } } );
661
657
  } );
662
658
 
663
- it( 'attaches trace metadata to existing root ApplicationFailure without wrapping it', async () => {
659
+ it( 'preserves existing root ApplicationFailure metadata', async () => {
664
660
  const { workflow } = await import( './workflow.js' );
665
661
  const { ApplicationFailure } = await import( '@temporalio/workflow' );
666
662
  setWorkflowInfo( { workflowType: 'root_application_failure_wf' } );
@@ -683,7 +679,9 @@ describe( 'workflow()', () => {
683
679
  expect( thrown ).toBe( error );
684
680
  expect( error.type ).toBe( 'OriginalType' );
685
681
  expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
686
- expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
682
+ expect( upsertMemoMock ).toHaveBeenCalledWith( expect.objectContaining( {
683
+ trace: { local: '/tmp/trace' }
684
+ } ) );
687
685
  } );
688
686
 
689
687
  it( 'rethrows non-root workflow errors without ApplicationFailure wrapping', async () => {
@@ -703,7 +701,10 @@ describe( 'workflow()', () => {
703
701
  } ) );
704
702
 
705
703
  await expect( wf( {} ) ).rejects.toBe( error );
706
- expect( error[METADATA_ACCESS_SYMBOL] ).toBeUndefined();
704
+ expect( upsertMemoMock ).toHaveBeenCalledOnce();
705
+ expect( upsertMemoMock ).toHaveBeenCalledWith( {
706
+ activityOptions: proxyActivitiesMock.mock.calls[0][0]
707
+ } );
707
708
  } );
708
709
  } );
709
710
  } );
@@ -1,5 +1,3 @@
1
- import { FatalError, ValidationError } from '#errors';
2
-
3
1
  export const defaultOptions = {
4
2
  activityOptions: {
5
3
  startToCloseTimeout: '20m',
@@ -9,8 +7,7 @@ export const defaultOptions = {
9
7
  backoffCoefficient: 2.0,
10
8
  maximumInterval: '2m',
11
9
  maximumAttempts: 3,
12
- nonRetryableErrorTypes: [ ValidationError.name, FatalError.name ]
10
+ nonRetryableErrorTypes: []
13
11
  }
14
- },
15
- disableTrace: false
12
+ }
16
13
  };
@@ -0,0 +1,47 @@
1
+ import { Client } from '@temporalio/client';
2
+ import { Storage } from '#async_storage';
3
+ import { FatalError } from '#errors';
4
+
5
+ // store the instance creation configs
6
+ const config = {
7
+ connection: null,
8
+ namespace: null
9
+ };
10
+
11
+ // store a single instance that is created on demand
12
+ const instance = {
13
+ client: null
14
+ };
15
+
16
+ // Adds the config necessary to init Clients
17
+ export const setupClientConfig = ( { connection, namespace } ) => {
18
+ config.connection = new Proxy( connection, {
19
+ get( target, property, receiver ) {
20
+ if ( property === 'close' ) {
21
+ return async () => {
22
+ throw new FatalError( 'Client connection cannot be closed. It is owned by the worker.' );
23
+ };
24
+ }
25
+ return Reflect.get( target, property, receiver );
26
+ }
27
+ } );
28
+ config.namespace = namespace;
29
+ };
30
+
31
+ /** Return a new Temporal client instance */
32
+ export const createTemporalClient = () => {
33
+ if ( !config.connection ) {
34
+ throw new FatalError( 'createTemporalClient() can only be called from within the worker runtime.' );
35
+ }
36
+ return new Client( { ...config } );
37
+ };
38
+
39
+ /** Get the workflow handle for the current workflow execution */
40
+ export const getCurrentWorkflowHandle = () => {
41
+ const ctx = Storage.load();
42
+ if ( !ctx || !config.connection ) {
43
+ throw new FatalError( 'getCurrentWorkflowHandle() can only be called from Temporal activities running in the worker.' );
44
+ }
45
+ const { workflowId, runId } = ctx.activityInfo.workflowExecution;
46
+ return ( instance.client ??= new Client( config ) ).workflow.getHandle( workflowId, runId );
47
+ };
@@ -0,0 +1,124 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const loadMock = vi.fn();
4
+ vi.mock( '#async_storage', () => ( {
5
+ Storage: { load: loadMock }
6
+ } ) );
7
+
8
+ const getHandleMock = vi.fn();
9
+ const clientConstructorMock = vi.fn( function () {
10
+ this.workflow = { getHandle: getHandleMock };
11
+ } );
12
+ vi.mock( '@temporalio/client', () => ( {
13
+ Client: clientConstructorMock
14
+ } ) );
15
+
16
+ const connection = {
17
+ fake: 'native-connection',
18
+ close: vi.fn().mockResolvedValue( undefined )
19
+ };
20
+ const activityContext = {
21
+ activityInfo: {
22
+ workflowExecution: {
23
+ workflowId: 'workflow-1',
24
+ runId: 'run-1'
25
+ }
26
+ }
27
+ };
28
+
29
+ describe( 'Temporal client', () => {
30
+ beforeEach( () => {
31
+ vi.clearAllMocks();
32
+ vi.resetModules();
33
+ } );
34
+
35
+ describe( 'createTemporalClient', () => {
36
+ it( 'throws a FatalError outside the worker runtime', async () => {
37
+ const { createTemporalClient } = await import( './client.js' );
38
+ const { FatalError } = await import( '#errors' );
39
+
40
+ expect( () => createTemporalClient() ).toThrow( FatalError );
41
+ } );
42
+
43
+ it( 'creates a client with the worker connection and namespace', async () => {
44
+ const { createTemporalClient, setupClientConfig } = await import( './client.js' );
45
+ setupClientConfig( { connection, namespace: 'namespace-1' } );
46
+
47
+ const client = createTemporalClient();
48
+
49
+ expect( clientConstructorMock ).toHaveBeenCalledWith( {
50
+ connection: expect.anything(),
51
+ namespace: 'namespace-1'
52
+ } );
53
+ expect( clientConstructorMock.mock.calls[0][0].connection ).not.toBe( connection );
54
+ expect( client ).toBeInstanceOf( clientConstructorMock );
55
+ } );
56
+
57
+ it( 'delegates connection access but prevents closing the worker connection', async () => {
58
+ const { createTemporalClient, setupClientConfig } = await import( './client.js' );
59
+ const { FatalError } = await import( '#errors' );
60
+ setupClientConfig( { connection, namespace: 'namespace-1' } );
61
+
62
+ createTemporalClient();
63
+ const clientConnection = clientConstructorMock.mock.calls[0][0].connection;
64
+
65
+ expect( clientConnection.fake ).toBe( 'native-connection' );
66
+ await expect( clientConnection.close() ).rejects.toThrow( FatalError );
67
+ expect( connection.close ).not.toHaveBeenCalled();
68
+ } );
69
+
70
+ it( 'returns a new client on each call', async () => {
71
+ const { createTemporalClient, setupClientConfig } = await import( './client.js' );
72
+ setupClientConfig( { connection, namespace: 'namespace-1' } );
73
+
74
+ expect( createTemporalClient() ).not.toBe( createTemporalClient() );
75
+ expect( clientConstructorMock ).toHaveBeenCalledTimes( 2 );
76
+ } );
77
+ } );
78
+
79
+ describe( 'getCurrentWorkflowHandle', () => {
80
+ it( 'throws a FatalError outside a Temporal activity context', async () => {
81
+ loadMock.mockReturnValue( undefined );
82
+ const { getCurrentWorkflowHandle, setupClientConfig } = await import( './client.js' );
83
+ const { FatalError } = await import( '#errors' );
84
+ setupClientConfig( { connection, namespace: 'namespace-1' } );
85
+
86
+ expect( () => getCurrentWorkflowHandle() ).toThrow( FatalError );
87
+ } );
88
+
89
+ it( 'throws a FatalError outside the worker runtime', async () => {
90
+ loadMock.mockReturnValue( activityContext );
91
+ const { getCurrentWorkflowHandle } = await import( './client.js' );
92
+ const { FatalError } = await import( '#errors' );
93
+
94
+ expect( () => getCurrentWorkflowHandle() ).toThrow( FatalError );
95
+ } );
96
+
97
+ it( 'returns the handle for the current workflow execution', async () => {
98
+ const handle = { signal: vi.fn() };
99
+ loadMock.mockReturnValue( activityContext );
100
+ getHandleMock.mockReturnValue( handle );
101
+ const { getCurrentWorkflowHandle, setupClientConfig } = await import( './client.js' );
102
+ setupClientConfig( { connection, namespace: 'namespace-1' } );
103
+
104
+ expect( getCurrentWorkflowHandle() ).toBe( handle );
105
+ expect( clientConstructorMock ).toHaveBeenCalledWith( {
106
+ connection: expect.anything(),
107
+ namespace: 'namespace-1'
108
+ } );
109
+ expect( getHandleMock ).toHaveBeenCalledWith( 'workflow-1', 'run-1' );
110
+ } );
111
+
112
+ it( 'reuses its client across calls', async () => {
113
+ loadMock.mockReturnValue( activityContext );
114
+ const { getCurrentWorkflowHandle, setupClientConfig } = await import( './client.js' );
115
+ setupClientConfig( { connection, namespace: 'namespace-1' } );
116
+
117
+ getCurrentWorkflowHandle();
118
+ getCurrentWorkflowHandle();
119
+
120
+ expect( clientConstructorMock ).toHaveBeenCalledTimes( 1 );
121
+ expect( getHandleMock ).toHaveBeenCalledTimes( 2 );
122
+ } );
123
+ } );
124
+ } );