@outputai/core 0.10.1-next.f6a7c1a.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 (72) hide show
  1. package/package.json +13 -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 +10 -7
  22. package/src/hooks/index.spec.js +34 -7
  23. package/src/hooks/pending_hooks.js +24 -0
  24. package/src/hooks/pending_hooks.spec.js +62 -0
  25. package/src/interface/evaluator.d.ts +10 -9
  26. package/src/interface/evaluator.js +1 -6
  27. package/src/interface/evaluator.spec.js +10 -10
  28. package/src/interface/index.js +2 -1
  29. package/src/interface/step.d.ts +16 -15
  30. package/src/interface/step.js +1 -6
  31. package/src/interface/step.spec.js +10 -10
  32. package/src/interface/validations/index.js +33 -53
  33. package/src/interface/validations/index.spec.js +25 -17
  34. package/src/interface/webhook.js +1 -2
  35. package/src/interface/webhook.spec.js +25 -19
  36. package/src/interface/workflow.d.ts +25 -18
  37. package/src/interface/workflow.js +24 -52
  38. package/src/interface/workflow.spec.js +109 -108
  39. package/src/interface/workflow_activity_options.js +2 -5
  40. package/src/interface/workflow_utils.d.ts +15 -0
  41. package/src/interface/workflow_utils.js +19 -0
  42. package/src/interface/workflow_utils.spec.js +82 -1
  43. package/src/temporal/client.js +47 -0
  44. package/src/temporal/client.spec.js +124 -0
  45. package/src/temporal/index.d.ts +32 -0
  46. package/src/temporal/index.js +1 -0
  47. package/src/tracing/trace_engine.js +9 -11
  48. package/src/tracing/trace_engine.spec.js +67 -28
  49. package/src/worker/catalog_workflow/catalog_job.js +3 -3
  50. package/src/worker/catalog_workflow/catalog_job.spec.js +1 -1
  51. package/src/worker/configs.js +0 -3
  52. package/src/worker/index.js +21 -8
  53. package/src/worker/index.spec.js +30 -10
  54. package/src/worker/interceptors/activity.js +32 -19
  55. package/src/worker/interceptors/activity.spec.js +71 -166
  56. package/src/worker/interceptors/workflow.js +53 -11
  57. package/src/worker/interceptors/workflow.spec.js +176 -49
  58. package/src/worker/loader/workflows.js +16 -3
  59. package/src/worker/loader/workflows.spec.js +37 -5
  60. package/src/worker/log_hooks.js +13 -10
  61. package/src/worker/log_hooks.spec.js +12 -4
  62. package/src/worker/sinks.js +3 -1
  63. package/src/worker/sinks.spec.js +21 -4
  64. package/src/worker/telemetry.js +2 -1
  65. package/src/worker/temporal_logger.js +33 -0
  66. package/src/worker/temporal_logger.spec.js +90 -0
  67. package/src/helpers/aggregations.js +0 -44
  68. package/src/helpers/aggregations.spec.js +0 -92
  69. package/src/tracing/tools/utils.js +0 -42
  70. package/src/tracing/tools/utils.spec.js +0 -83
  71. package/src/worker/proxy.js +0 -20
  72. package/src/worker/proxy.spec.js +0 -51
@@ -6,6 +6,7 @@ const {
6
6
  configValues,
7
7
  connectionMonitorInstance,
8
8
  createCatalogMock,
9
+ flushPendingHooksMock,
9
10
  initInterceptorsMock,
10
11
  mainEventBusMock,
11
12
  mockConnection,
@@ -13,7 +14,10 @@ const {
13
14
  mockWorker,
14
15
  promises,
15
16
  resetPromises,
17
+ serializeErrorMock,
18
+ setupClientConfigMock,
16
19
  setupInterruptionHandlerMock,
20
+ setupTemporalLoggerMock,
17
21
  setupTelemetryMock
18
22
  } = vi.hoisted( () => {
19
23
  const createDeferred = () => {
@@ -46,7 +50,6 @@ const {
46
50
  maxConcurrentActivityTaskPolls: 5,
47
51
  maxConcurrentWorkflowTaskPolls: 5,
48
52
  workerTuner: undefined,
49
- processFailureShutdownDelay: 0,
50
53
  shutdownForceTime: undefined,
51
54
  shutdownGraceTime: undefined
52
55
  };
@@ -105,6 +108,7 @@ const {
105
108
  connectionMonitorInstance,
106
109
  createCatalogMock: vi.fn().mockReturnValue( { workflows: [], activities: {} } ),
107
110
  createWorkflowsEntryPointMock: vi.fn().mockReturnValue( '/fake/workflows/path.js' ),
111
+ flushPendingHooksMock: vi.fn().mockResolvedValue( undefined ),
108
112
  hashSourceCodeMock: vi.fn().mockResolvedValue( 'catalog-hash' ),
109
113
  initInterceptorsMock: vi.fn().mockReturnValue( [] ),
110
114
  loadActivitiesMock: vi.fn().mockResolvedValue( {} ),
@@ -116,19 +120,24 @@ const {
116
120
  mockWorker,
117
121
  promises,
118
122
  resetPromises,
123
+ serializeErrorMock: vi.fn( error => ( {
124
+ name: error.name,
125
+ message: error.message,
126
+ stack: error.stack
127
+ } ) ),
128
+ setupClientConfigMock: vi.fn(),
119
129
  setupInterruptionHandlerMock: vi.fn(),
130
+ setupTemporalLoggerMock: vi.fn(),
120
131
  setupTelemetryMock: vi.fn()
121
132
  };
122
133
  } );
123
134
 
124
135
  vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
125
- vi.mock( '#consts', async importOriginal => {
126
- const actual = await importOriginal();
127
- return { ...actual };
128
- } );
136
+ vi.mock( '#helpers/error_serializer', () => ( { serializeError: serializeErrorMock } ) );
129
137
  const initTracing = vi.fn().mockResolvedValue( undefined );
130
138
  vi.mock( '#tracing', () => ( { init: initTracing } ) );
131
139
  vi.mock( '#bus', () => ( { mainEventBus: mainEventBusMock } ) );
140
+ vi.mock( '#hooks/pending_hooks', () => ( { flushPendingHooks: flushPendingHooksMock } ) );
132
141
 
133
142
  const loadWorkflowsMock = vi.fn().mockResolvedValue( { workflows: [], entrypoint: '/fake/workflows/path.js' } );
134
143
  const loadActivitiesMock = vi.fn().mockResolvedValue( { activities: {} } );
@@ -145,7 +154,6 @@ vi.mock( './sinks.js', () => ( { sinks: {} } ) );
145
154
  vi.mock( './catalog_workflow/index.js', () => ( { createCatalog: createCatalogMock } ) );
146
155
  vi.mock( './bundler_options.js', () => ( { webpackConfigHook: vi.fn() } ) );
147
156
  vi.mock( './interceptors/index.js', () => ( { initInterceptors: initInterceptorsMock } ) );
148
- vi.mock( './proxy.js', () => ( { bootstrapFetchProxy: vi.fn() } ) );
149
157
  vi.mock( './telemetry.js', () => ( { setupTelemetry: setupTelemetryMock } ) );
150
158
  vi.mock( './interruption.js', () => ( { setupInterruptionHandler: setupInterruptionHandlerMock } ) );
151
159
  vi.mock( './global_functions.js', () => ( { bindGlobalFunctions: bindGlobalFunctionsMock } ) );
@@ -160,6 +168,8 @@ vi.mock( './catalog_workflow/catalog_job.js', () => ( {
160
168
  } )
161
169
  } ) );
162
170
  vi.mock( './log_hooks.js', () => ( {} ) );
171
+ vi.mock( './temporal_logger.js', () => ( { setupTemporalLogger: setupTemporalLoggerMock } ) );
172
+ vi.mock( '#temporal/client', () => ( { setupClientConfig: setupClientConfigMock } ) );
163
173
  vi.mock( '@temporalio/worker', () => ( {
164
174
  NativeConnection: { connect: vi.fn().mockResolvedValue( mockConnection ) },
165
175
  Worker: { create: vi.fn().mockResolvedValue( mockWorker ) }
@@ -216,6 +226,7 @@ describe( 'worker/index', () => {
216
226
  await vi.waitFor( () => expect( Worker.create ).toHaveBeenCalled() );
217
227
 
218
228
  expect( loadHooksMock ).toHaveBeenCalledWith( '/test/caller/dir' );
229
+ expect( setupTemporalLoggerMock ).toHaveBeenCalledOnce();
219
230
  expect( loadWorkflowsMock ).toHaveBeenCalledWith( '/test/caller/dir' );
220
231
  expect( loadActivitiesMock ).toHaveBeenCalledWith( '/test/caller/dir', [] );
221
232
  expect( initTracing ).toHaveBeenCalled();
@@ -227,6 +238,10 @@ describe( 'worker/index', () => {
227
238
  apiKey: undefined,
228
239
  proxy: undefined
229
240
  } );
241
+ expect( setupClientConfigMock ).toHaveBeenCalledWith( {
242
+ connection: mockConnection,
243
+ namespace: configValues.namespace
244
+ } );
230
245
  expect( bindGlobalFunctionsMock ).toHaveBeenCalledTimes( 1 );
231
246
  expect( bindGlobalFunctionsMock.mock.invocationCallOrder[0] ).toBeLessThan(
232
247
  NativeConnection.connect.mock.invocationCallOrder[0]
@@ -262,6 +277,7 @@ describe( 'worker/index', () => {
262
277
  expect( catalogJobInstance.run ).toHaveBeenCalled();
263
278
 
264
279
  await settleWorker();
280
+ await vi.waitFor( () => expect( flushPendingHooksMock ).toHaveBeenCalledOnce() );
265
281
  expect( mockLog.info ).toHaveBeenCalledWith( 'Bye' );
266
282
  } );
267
283
 
@@ -354,11 +370,15 @@ describe( 'worker/index', () => {
354
370
 
355
371
  await vi.waitFor( () => {
356
372
  expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
357
- error: 'Big Failure'
373
+ error: expect.objectContaining( { message: 'Big Failure' } )
358
374
  } ) );
359
375
  } );
376
+ expect( serializeErrorMock ).toHaveBeenCalledWith( error );
360
377
  expect( mainEventBusMock.emit ).toHaveBeenCalledWith( expect.any( String ), { error } );
361
378
  await vi.waitFor( () => expect( exitMock ).toHaveBeenCalledWith( 1 ) );
379
+ expect( flushPendingHooksMock ).toHaveBeenCalledOnce();
380
+ expect( mainEventBusMock.emit.mock.invocationCallOrder[0] ).toBeLessThan( flushPendingHooksMock.mock.invocationCallOrder[0] );
381
+ expect( flushPendingHooksMock.mock.invocationCallOrder[0] ).toBeLessThan( exitMock.mock.invocationCallOrder[0] );
362
382
  } );
363
383
 
364
384
  it( 'throws connection monitor errors after graceful shutdown', async () => {
@@ -373,7 +393,7 @@ describe( 'worker/index', () => {
373
393
 
374
394
  await vi.waitFor( () => {
375
395
  expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
376
- error: 'connection lost'
396
+ error: expect.objectContaining( { message: 'connection lost' } )
377
397
  } ) );
378
398
  } );
379
399
  expect( mockWorker.shutdown ).toHaveBeenCalledOnce();
@@ -393,7 +413,7 @@ describe( 'worker/index', () => {
393
413
 
394
414
  await vi.waitFor( () => {
395
415
  expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
396
- error: 'catalog failed'
416
+ error: expect.objectContaining( { message: 'catalog failed' } )
397
417
  } ) );
398
418
  } );
399
419
  expect( mockWorker.shutdown ).toHaveBeenCalledOnce();
@@ -413,7 +433,7 @@ describe( 'worker/index', () => {
413
433
  expect( catalogJobInstance.interrupt ).not.toHaveBeenCalled();
414
434
  await vi.waitFor( () => {
415
435
  expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
416
- error: 'worker create failed'
436
+ error: expect.objectContaining( { message: 'worker create failed' } )
417
437
  } ) );
418
438
  } );
419
439
  } );
@@ -1,12 +1,14 @@
1
- import { Context, activityInfo as activityInfoFn } from '@temporalio/activity';
1
+ import { Context, activityInfo as activityInfoFn, ApplicationFailure } from '@temporalio/activity';
2
+ import { CompleteAsyncError, TemporalFailure } from '@temporalio/common';
2
3
  import { Storage } from '#async_storage';
3
4
  import * as Tracing from '#tracing';
4
5
  import { headersToObject } from './headers.js';
5
- import { ACTIVITY_WRAPPER_VERSION_FIELD, BusEventType, METADATA_ACCESS_SYMBOL } from '#consts';
6
+ import { ActivitySpecialOutput, BusEventType, METADATA_ACCESS_SYMBOL } from '#consts';
6
7
  import { activityHeartbeatEnabled, activityHeartbeatIntervalMs } from '../configs.js';
7
8
  import { mainEventBus } from '#bus';
8
- import { aggregateAttributes } from '#helpers/aggregations';
9
- import { buildApplicationFailureWithDetails } from '#helpers/errors';
9
+ import { inheritsFromAnyNamedType } from '#helpers/errors';
10
+ import { serializeError } from '#helpers/error_serializer';
11
+ import { FatalError, TransparentFatalError } from '#errors';
10
12
 
11
13
  /*
12
14
  This interceptor wraps every activity execution with cross-cutting concerns:
@@ -51,12 +53,9 @@ export class ActivityExecutionInterceptor {
51
53
  }
52
54
 
53
55
  const state = {
54
- heartbeat: null,
55
- attributes: []
56
+ heartbeat: null
56
57
  };
57
58
 
58
- const addAttribute = attribute => state.attributes.push( attribute );
59
-
60
59
  // Adds context accessible information
61
60
  const storageContext = {
62
61
  parentId: activityId,
@@ -64,8 +63,7 @@ export class ActivityExecutionInterceptor {
64
63
  activityInfo,
65
64
  workflowDetails,
66
65
  workflowFilename,
67
- traceInfo,
68
- addAttribute
66
+ traceInfo
69
67
  };
70
68
 
71
69
  mainEventBus.emit( BusEventType.ACTIVITY_START, { activityInfo, workflowDetails, outputActivityKind } );
@@ -77,19 +75,34 @@ export class ActivityExecutionInterceptor {
77
75
 
78
76
  const output = await Storage.runWithContext( async _ => next( input ), storageContext );
79
77
 
80
- const aggregations = state.attributes.length > 0 ? aggregateAttributes( state.attributes ) : null;
81
-
82
- mainEventBus.emit( BusEventType.ACTIVITY_END, { activityInfo, aggregations, workflowDetails, outputActivityKind } );
78
+ mainEventBus.emit( BusEventType.ACTIVITY_END, { activityInfo, workflowDetails, outputActivityKind } );
83
79
  Tracing.addEventEnd( { id: activityId, details: output, traceInfo } );
84
80
 
85
- return { [ACTIVITY_WRAPPER_VERSION_FIELD]: 1, output, aggregations };
81
+ return output;
86
82
 
87
83
  } catch ( error ) {
88
- const aggregations = state.attributes.length > 0 ? aggregateAttributes( state.attributes ) : null;
89
- mainEventBus.emit( BusEventType.ACTIVITY_ERROR, { activityInfo, aggregations, workflowDetails, outputActivityKind, error } );
90
- Tracing.addEventError( { id: activityId, details: error, traceInfo } );
91
-
92
- throw aggregations ? buildApplicationFailureWithDetails( error, { aggregations } ) : error;
84
+ // Record async completion handoff as a trace end without emitting a bus event.
85
+ if ( error instanceof CompleteAsyncError ) {
86
+ Tracing.addEventEnd( { id: activityId, details: ActivitySpecialOutput.ASYNC_HANDOFF, traceInfo } );
87
+ throw error;
88
+ }
89
+
90
+ const unwrappedError = error instanceof TransparentFatalError ? error.cause : error;
91
+
92
+ Tracing.addEventError( { id: activityId, details: unwrappedError, traceInfo } );
93
+ mainEventBus.emit( BusEventType.ACTIVITY_ERROR, { activityInfo, workflowDetails, outputActivityKind, error: unwrappedError } );
94
+
95
+ // Native Temporal errors are just re-thrown
96
+ if ( error instanceof TemporalFailure ) {
97
+ throw error;
98
+ }
99
+
100
+ const nonRetryable = error instanceof FatalError || inheritsFromAnyNamedType( error, activityInfo.retryPolicy?.nonRetryableErrorTypes ?? [] );
101
+ throw ApplicationFailure.fromError( unwrappedError, {
102
+ nonRetryable,
103
+ cause: unwrappedError,
104
+ details: [ { error: serializeError( unwrappedError, { dropKeys: [ 'stack' ] } ) } ]
105
+ } );
93
106
  } finally {
94
107
  clearInterval( state.heartbeat );
95
108
  }
@@ -1,9 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { ApplicationFailure } from '@temporalio/common';
3
- import { ACTIVITY_WRAPPER_VERSION_FIELD, BusEventType } from '#consts';
4
- import { Attribute } from '#trace_attribute';
2
+ import { ApplicationFailure, CompleteAsyncError } from '@temporalio/common';
3
+ import { ActivitySpecialOutput, BusEventType, METADATA_ACCESS_SYMBOL } from '#consts';
4
+ import { TransparentFatalError } from '#errors';
5
5
 
6
- const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
7
6
  const heartbeatMock = vi.hoisted( () => vi.fn() );
8
7
  const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async fn => fn() ) );
9
8
  const activityInfoMock = vi.hoisted( () => ( {
@@ -64,15 +63,6 @@ vi.mock( './headers.js', () => ( {
64
63
  const mainEventBusEmitMock = vi.fn();
65
64
  vi.mock( '#bus', () => ( { mainEventBus: { emit: mainEventBusEmitMock } } ) );
66
65
 
67
- vi.mock( '#consts', async importOriginal => {
68
- const actual = await importOriginal();
69
- return {
70
- ...actual, get METADATA_ACCESS_SYMBOL() {
71
- return METADATA_ACCESS_SYMBOL;
72
- }
73
- };
74
- } );
75
-
76
66
  vi.mock( '../configs.js', () => ( {
77
67
  get activityHeartbeatEnabled() {
78
68
  return process.env.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED !== 'false';
@@ -93,24 +83,12 @@ const makeInput = () => ( {
93
83
  headers: {}
94
84
  } );
95
85
 
96
- const httpRequestAttribute = {
97
- type: Attribute.HTTPRequestCount.TYPE,
98
- url: 'https://api.example.test/items',
99
- requestId: 'req-1'
100
- };
101
-
102
- const httpRequestAggregations = {
103
- cost: { total: 0 },
104
- tokens: { total: 0 },
105
- httpRequests: { total: 1 }
106
- };
107
-
108
86
  describe( 'ActivityExecutionInterceptor', () => {
109
87
  beforeEach( () => {
110
88
  vi.clearAllMocks();
111
89
  activityInfoMock.workflowType = 'myWorkflow';
90
+ delete activityInfoMock.retryPolicy;
112
91
  vi.useFakeTimers();
113
- vi.resetModules();
114
92
  // Default: heartbeat enabled with 50ms interval for fast tests
115
93
  vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED', 'true' );
116
94
  vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS', '50' );
@@ -126,22 +104,14 @@ describe( 'ActivityExecutionInterceptor', () => {
126
104
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
127
105
  const next = vi.fn().mockResolvedValue( { result: 'ok' } );
128
106
 
129
- const promise = interceptor.execute( makeInput(), next );
130
- vi.advanceTimersByTime( 0 );
131
- const output = await promise;
132
-
133
- expect( output ).toEqual( {
134
- output: { result: 'ok' },
135
- aggregations: null,
136
- [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
137
- } );
107
+ await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
138
108
  expect( mainEventBusEmitMock ).toHaveBeenCalledWith(
139
109
  BusEventType.ACTIVITY_START,
140
110
  { activityInfo: activityInfoMock, workflowDetails: workflowDetailsMock, outputActivityKind: 'step' }
141
111
  );
142
112
  expect( mainEventBusEmitMock ).toHaveBeenCalledWith(
143
113
  BusEventType.ACTIVITY_END,
144
- { activityInfo: activityInfoMock, aggregations: null, workflowDetails: workflowDetailsMock, outputActivityKind: 'step' }
114
+ { activityInfo: activityInfoMock, workflowDetails: workflowDetailsMock, outputActivityKind: 'step' }
145
115
  );
146
116
  expect( addEventStartMock ).toHaveBeenCalledWith( {
147
117
  id: 'act-1',
@@ -161,74 +131,36 @@ describe( 'ActivityExecutionInterceptor', () => {
161
131
  workflowDetails: workflowDetailsMock,
162
132
  outputActivityKind: 'step',
163
133
  workflowFilename: '/workflows/myWorkflow.js',
164
- traceInfo: traceInfoMock,
165
- addAttribute: expect.any( Function )
134
+ traceInfo: traceInfoMock
166
135
  } )
167
136
  );
168
137
  } );
169
138
 
170
- it( 'handles next returning a non-Promise value', async () => {
171
- const { ActivityExecutionInterceptor } = await import( './activity.js' );
172
- const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
173
- const next = vi.fn( () => ( { result: 'sync' } ) );
174
-
175
- await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
176
- output: { result: 'sync' },
177
- aggregations: null,
178
- [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
179
- } );
180
-
181
- expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
182
- expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'sync' }, traceInfo: traceInfoMock } );
183
- expect( addEventErrorMock ).not.toHaveBeenCalled();
184
- } );
185
-
186
- it( 'returns collected aggregations after successful execution', async () => {
187
- runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
188
- ctx.addAttribute( httpRequestAttribute );
189
- return fn();
190
- } );
191
- const { ActivityExecutionInterceptor } = await import( './activity.js' );
192
- const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
193
- const next = vi.fn().mockResolvedValue( { result: 'ok' } );
194
-
195
- await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
196
- output: { result: 'ok' },
197
- aggregations: httpRequestAggregations,
198
- [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
199
- } );
200
-
201
- expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, {
202
- activityInfo: activityInfoMock,
203
- aggregations: httpRequestAggregations,
204
- workflowDetails: workflowDetailsMock,
205
- outputActivityKind: 'step'
206
- } );
207
- } );
208
-
209
- it( 'stores collected aggregations in ApplicationFailure details after failed execution', async () => {
210
- runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
211
- ctx.addAttribute( httpRequestAttribute );
212
- return fn();
213
- } );
139
+ it( 'wraps native errors with serialized details', async () => {
214
140
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
215
141
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
216
142
  const error = new Error( 'step failed' );
143
+ error.code = 'ESTEP';
217
144
  const next = vi.fn().mockRejectedValue( error );
218
145
 
219
146
  const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
147
+
220
148
  expect( thrown ).toBeInstanceOf( ApplicationFailure );
221
149
  expect( thrown ).toMatchObject( {
222
150
  message: 'step failed',
223
151
  type: 'Error',
224
152
  details: [ {
225
- aggregations: httpRequestAggregations
153
+ error: {
154
+ name: 'Error',
155
+ message: 'step failed',
156
+ code: 'ESTEP'
157
+ }
226
158
  } ],
227
- cause: error
159
+ cause: error,
160
+ nonRetryable: false
228
161
  } );
229
162
  expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
230
163
  activityInfo: activityInfoMock,
231
- aggregations: httpRequestAggregations,
232
164
  workflowDetails: workflowDetailsMock,
233
165
  outputActivityKind: 'step',
234
166
  error
@@ -237,105 +169,93 @@ describe( 'ActivityExecutionInterceptor', () => {
237
169
  expect( addEventEndMock ).not.toHaveBeenCalled();
238
170
  } );
239
171
 
240
- it( 'appends collected aggregations to existing failure details', async () => {
241
- runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
242
- ctx.addAttribute( httpRequestAttribute );
243
- return fn();
244
- } );
172
+ it( 'marks inherited configured error types as non-retryable', async () => {
173
+ class ValidationError extends Error {}
174
+ class SpecificValidationError extends ValidationError {}
175
+ activityInfoMock.retryPolicy = { nonRetryableErrorTypes: [ 'ValidationError' ] };
245
176
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
246
177
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
247
- const error = new Error( 'step failed' );
248
- error.details = [ { domain: { reason: 'bad-input' } } ];
178
+ const error = new SpecificValidationError( 'invalid input' );
249
179
  const next = vi.fn().mockRejectedValue( error );
250
180
 
251
181
  const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
252
182
 
253
- expect( thrown.details ).toEqual( [
254
- { domain: { reason: 'bad-input' } },
255
- { aggregations: httpRequestAggregations }
256
- ] );
257
- } );
258
-
259
- it( 'rethrows the original error when failed execution collected no attributes', async () => {
260
- const { ActivityExecutionInterceptor } = await import( './activity.js' );
261
- const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
262
- const error = new Error( 'step failed' );
263
- const next = vi.fn().mockRejectedValue( error );
264
-
265
- await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
266
- } );
267
-
268
- it( 'rethrows the original error with existing details when failed execution collected no attributes', async () => {
269
- const { ActivityExecutionInterceptor } = await import( './activity.js' );
270
- const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
271
- const error = new Error( 'step failed' );
272
- error.details = [ { domain: { reason: 'bad-input' } } ];
273
- const next = vi.fn().mockRejectedValue( error );
274
-
275
- await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
276
- expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
277
- } );
278
-
279
- it( 'wraps existing ApplicationFailure without creating a self-cause', async () => {
280
- runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
281
- ctx.addAttribute( httpRequestAttribute );
282
- return fn();
283
- } );
284
- const error = ApplicationFailure.create( {
285
- message: 'application failed',
286
- type: 'OriginalType',
183
+ expect( thrown ).toMatchObject( {
184
+ type: 'SpecificValidationError',
287
185
  nonRetryable: true,
288
- details: [ { domain: { reason: 'bad-input' } } ]
186
+ cause: error
289
187
  } );
188
+ } );
189
+
190
+ it( 'uses a transparent error cause while remaining non-retryable without retry policy metadata', async () => {
290
191
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
291
192
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
193
+ const cause = new TypeError( 'provider rejected request' );
194
+ cause.code = 'EAUTH';
195
+ const error = new TransparentFatalError( cause );
292
196
  const next = vi.fn().mockRejectedValue( error );
293
197
 
294
198
  const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
295
199
 
296
- expect( thrown ).toBeInstanceOf( ApplicationFailure );
297
- expect( thrown ).not.toBe( error );
298
- expect( thrown.cause ).toBe( error );
299
- expect( thrown.cause ).not.toBe( thrown );
300
200
  expect( thrown ).toMatchObject( {
301
- message: 'application failed',
302
- type: 'OriginalType',
201
+ message: 'provider rejected request',
202
+ type: 'TypeError',
303
203
  nonRetryable: true,
304
- details: [
305
- { domain: { reason: 'bad-input' } },
306
- { aggregations: httpRequestAggregations }
307
- ]
204
+ cause,
205
+ details: [ {
206
+ error: {
207
+ name: 'TypeError',
208
+ message: 'provider rejected request',
209
+ code: 'EAUTH'
210
+ }
211
+ } ]
212
+ } );
213
+ expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: cause, traceInfo: traceInfoMock } );
214
+ expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
215
+ activityInfo: activityInfoMock,
216
+ workflowDetails: workflowDetailsMock,
217
+ outputActivityKind: 'step',
218
+ error: cause
308
219
  } );
309
220
  } );
310
221
 
311
- it( 'records trace error event on failed execution', async () => {
222
+ it( 'rethrows existing Temporal failures unchanged', async () => {
312
223
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
313
224
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
314
- const error = new Error( 'step failed' );
225
+ const error = ApplicationFailure.nonRetryable( 'domain failed', 'DomainFailure', { reason: 'invalid' } );
315
226
  const next = vi.fn().mockRejectedValue( error );
316
227
 
317
- const promise = interceptor.execute( makeInput(), next );
318
- vi.advanceTimersByTime( 0 );
319
-
320
- await expect( promise ).rejects.toThrow( 'step failed' );
321
- expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.any( Object ) );
228
+ await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
229
+ expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: error, traceInfo: traceInfoMock } );
322
230
  expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
323
231
  activityInfo: activityInfoMock,
324
- aggregations: null,
325
232
  workflowDetails: workflowDetailsMock,
326
233
  outputActivityKind: 'step',
327
234
  error
328
235
  } );
329
- expect( addEventStartMock ).toHaveBeenCalledOnce();
330
- expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: error, traceInfo: traceInfoMock } );
331
- expect( addEventEndMock ).not.toHaveBeenCalled();
332
236
  } );
333
237
 
334
- it( 'sends periodic heartbeats during activity execution', async () => {
238
+ it( 'records async completion handoff as a trace end only', async () => {
239
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
240
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
241
+ const error = new CompleteAsyncError();
242
+ const next = vi.fn().mockRejectedValue( error );
243
+
244
+ await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
245
+ expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.any( Object ) );
246
+ expect( mainEventBusEmitMock ).toHaveBeenCalledTimes( 1 );
247
+ expect( addEventEndMock ).toHaveBeenCalledWith( {
248
+ id: 'act-1',
249
+ details: ActivitySpecialOutput.ASYNC_HANDOFF,
250
+ traceInfo: traceInfoMock
251
+ } );
252
+ expect( addEventErrorMock ).not.toHaveBeenCalled();
253
+ } );
254
+
255
+ it( 'sends periodic heartbeats and stops after successful execution', async () => {
335
256
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
336
257
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
337
258
 
338
- // next() resolves only after we manually resolve it, simulating a long-running activity
339
259
  const deferred = { resolve: null };
340
260
  const next = vi.fn().mockImplementation( () => new Promise( r => {
341
261
  deferred.resolve = r;
@@ -356,16 +276,6 @@ describe( 'ActivityExecutionInterceptor', () => {
356
276
 
357
277
  deferred.resolve( { result: 'done' } );
358
278
  await promise;
359
- } );
360
-
361
- it( 'clears heartbeat interval after activity completes', async () => {
362
- const { ActivityExecutionInterceptor } = await import( './activity.js' );
363
- const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
364
- const next = vi.fn().mockResolvedValue( { result: 'ok' } );
365
-
366
- const promise = interceptor.execute( makeInput(), next );
367
- vi.advanceTimersByTime( 0 );
368
- await promise;
369
279
 
370
280
  heartbeatMock.mockClear();
371
281
  vi.advanceTimersByTime( 500 );
@@ -379,7 +289,7 @@ describe( 'ActivityExecutionInterceptor', () => {
379
289
 
380
290
  const promise = interceptor.execute( makeInput(), next );
381
291
  vi.advanceTimersByTime( 0 );
382
- await promise.catch( () => {} );
292
+ await expect( promise ).rejects.toBeInstanceOf( ApplicationFailure );
383
293
 
384
294
  heartbeatMock.mockClear();
385
295
  vi.advanceTimersByTime( 500 );
@@ -391,7 +301,6 @@ describe( 'ActivityExecutionInterceptor', () => {
391
301
  const workflows = [ { name: 'myWorkflow', path: '/workflows/myWorkflow.js', aliases: [ 'myWorkflowOld' ] } ];
392
302
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows } );
393
303
 
394
- // Override context to use alias as workflowType
395
304
  activityInfoMock.workflowType = 'myWorkflowOld';
396
305
  const next = vi.fn().mockResolvedValue( { result: 'ok' } );
397
306
 
@@ -399,14 +308,10 @@ describe( 'ActivityExecutionInterceptor', () => {
399
308
  vi.advanceTimersByTime( 0 );
400
309
  await promise;
401
310
 
402
- // Should resolve to the correct path despite using the alias
403
311
  expect( runWithContextMock ).toHaveBeenCalledWith(
404
312
  expect.any( Function ),
405
313
  expect.objectContaining( { workflowFilename: '/workflows/myWorkflow.js' } )
406
314
  );
407
-
408
- // Restore for other tests
409
- activityInfoMock.workflowType = 'myWorkflow';
410
315
  } );
411
316
 
412
317
  it( 'does not heartbeat when OUTPUT_ACTIVITY_HEARTBEAT_ENABLED is false', async () => {