@outputai/core 0.5.1-next.93f660c.0 → 0.5.2-next.17d8711.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/core",
3
- "version": "0.5.1-next.93f660c.0",
3
+ "version": "0.5.2-next.17d8711.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { messageBus } from '#bus';
3
+ import { emitEvent } from './events.js';
4
+
5
+ const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6
+
7
+ describe( 'eventId integration', () => {
8
+ beforeEach( () => {
9
+ messageBus.removeAllListeners();
10
+ } );
11
+
12
+ it( 'stamps a UUID v4 eventId on every emit (end-to-end via messageBus)', () => {
13
+ const handler = vi.fn();
14
+ messageBus.on( 'external:cost:llm:request', handler );
15
+
16
+ emitEvent( 'cost:llm:request', { modelId: 'gpt-4o' } );
17
+
18
+ expect( handler ).toHaveBeenCalledWith( expect.objectContaining( {
19
+ eventId: expect.stringMatching( UUID_V4_REGEX ),
20
+ modelId: 'gpt-4o'
21
+ } ) );
22
+ } );
23
+
24
+ it( 'cost:http:request and http:request for the same fetch get distinct eventIds', () => {
25
+ const costHandler = vi.fn();
26
+ const reqHandler = vi.fn();
27
+ messageBus.on( 'external:cost:http:request', costHandler );
28
+ messageBus.on( 'external:http:request', reqHandler );
29
+
30
+ const sharedRequestId = 'req-xyz';
31
+ emitEvent( 'cost:http:request', { requestId: sharedRequestId, url: 'https://x.test', cost: 1 } );
32
+ emitEvent( 'http:request', { requestId: sharedRequestId, url: 'https://x.test', status: 200 } );
33
+
34
+ const costEventId = costHandler.mock.calls[0][0].eventId;
35
+ const reqEventId = reqHandler.mock.calls[0][0].eventId;
36
+ expect( costEventId ).toMatch( UUID_V4_REGEX );
37
+ expect( reqEventId ).toMatch( UUID_V4_REGEX );
38
+ expect( costEventId ).not.toBe( reqEventId );
39
+ } );
40
+
41
+ it( 'honors a caller-supplied eventId end-to-end', () => {
42
+ const handler = vi.fn();
43
+ messageBus.on( 'external:custom:event', handler );
44
+
45
+ emitEvent( 'custom:event', { eventId: 'fixed-id-123', payload: 'hi' } );
46
+
47
+ expect( handler ).toHaveBeenCalledWith( expect.objectContaining( {
48
+ eventId: 'fixed-id-123',
49
+ payload: 'hi'
50
+ } ) );
51
+ } );
52
+ } );
@@ -13,6 +13,9 @@ vi.mock( '#bus', () => ( {
13
13
 
14
14
  import { emitEvent } from './events.js';
15
15
 
16
+ // `eventId` stamping is the bus layer's responsibility (see bus.spec.js + the
17
+ // integration tests in event_id_integration.spec.js). Assertions here use
18
+ // `objectContaining` so they don't have to know about that enrichment.
16
19
  describe( 'emitEvent', () => {
17
20
  beforeEach( () => {
18
21
  vi.clearAllMocks();
@@ -26,12 +29,12 @@ describe( 'emitEvent', () => {
26
29
 
27
30
  emitEvent( 'cost:llm:request', { modelId: 'gpt-4o' } );
28
31
 
29
- expect( emitMock ).toHaveBeenCalledWith( 'external:cost:llm:request', {
32
+ expect( emitMock ).toHaveBeenCalledWith( 'external:cost:llm:request', expect.objectContaining( {
30
33
  workflowId: 'wf-1',
31
34
  runId: 'run-1',
32
35
  activityId: 'act-1',
33
36
  modelId: 'gpt-4o'
34
- } );
37
+ } ) );
35
38
  } );
36
39
 
37
40
  it( 'handles missing executionContext gracefully', () => {
@@ -39,12 +42,12 @@ describe( 'emitEvent', () => {
39
42
 
40
43
  emitEvent( 'foo:bar', { x: 1 } );
41
44
 
42
- expect( emitMock ).toHaveBeenCalledWith( 'external:foo:bar', {
45
+ expect( emitMock ).toHaveBeenCalledWith( 'external:foo:bar', expect.objectContaining( {
43
46
  workflowId: undefined,
44
47
  runId: undefined,
45
48
  activityId: undefined,
46
49
  x: 1
47
- } );
50
+ } ) );
48
51
  } );
49
52
 
50
53
  it( 'handles missing payload', () => {
@@ -55,11 +58,11 @@ describe( 'emitEvent', () => {
55
58
 
56
59
  emitEvent( 'lifecycle:start' );
57
60
 
58
- expect( emitMock ).toHaveBeenCalledWith( 'external:lifecycle:start', {
61
+ expect( emitMock ).toHaveBeenCalledWith( 'external:lifecycle:start', expect.objectContaining( {
59
62
  workflowId: 'wf-2',
60
63
  runId: 'run-2',
61
64
  activityId: 'act-2'
62
- } );
65
+ } ) );
63
66
  } );
64
67
 
65
68
  it( 'does not let payload override workflowId / runId / activityId', () => {
@@ -77,11 +80,11 @@ describe( 'emitEvent', () => {
77
80
 
78
81
  // Context fields are spread after the payload, so caller-supplied
79
82
  // workflowId / runId / activityId cannot escape the executionContext.
80
- expect( emitMock ).toHaveBeenCalledWith( 'external:cost:http:request', {
83
+ expect( emitMock ).toHaveBeenCalledWith( 'external:cost:http:request', expect.objectContaining( {
81
84
  workflowId: 'wf-3',
82
85
  runId: 'run-3',
83
86
  activityId: 'act-3',
84
87
  url: 'https://example.com'
85
- } );
88
+ } ) );
86
89
  } );
87
90
  } );
package/src/bus.js CHANGED
@@ -1,3 +1,21 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { isPlainObject } from '#utils';
2
4
 
3
- export const messageBus = new EventEmitter();
5
+ const emitter = new EventEmitter();
6
+ const originalEmit = emitter.emit.bind( emitter );
7
+
8
+ const attachEventId = payload => ( { ...payload, eventId: payload.eventId ?? randomUUID() } );
9
+
10
+ /**
11
+ * Every object payload emitted through `messageBus` is stamped with a UUID v4 `eventId`.
12
+ */
13
+ emitter.emit = ( event, ...args ) => {
14
+ const [ payload, ...rest ] = args;
15
+ if ( !isPlainObject( payload ) ) {
16
+ return originalEmit( event, ...args );
17
+ }
18
+ return originalEmit( event, attachEventId( payload ), ...rest );
19
+ };
20
+
21
+ export const messageBus = emitter;
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { messageBus } from './bus.js';
3
+
4
+ const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5
+
6
+ describe( 'messageBus', () => {
7
+ beforeEach( () => {
8
+ messageBus.removeAllListeners();
9
+ } );
10
+
11
+ describe( 'eventId stamping', () => {
12
+ it( 'stamps a UUID v4 eventId on every object payload', () => {
13
+ const handler = vi.fn();
14
+ messageBus.on( 'test:event', handler );
15
+
16
+ messageBus.emit( 'test:event', { foo: 'bar' } );
17
+
18
+ expect( handler ).toHaveBeenCalledWith( expect.objectContaining( {
19
+ foo: 'bar',
20
+ eventId: expect.stringMatching( UUID_V4_REGEX )
21
+ } ) );
22
+ } );
23
+
24
+ it( 'gives distinct emits distinct eventIds', () => {
25
+ const handler = vi.fn();
26
+ messageBus.on( 'test:event', handler );
27
+
28
+ messageBus.emit( 'test:event', { i: 1 } );
29
+ messageBus.emit( 'test:event', { i: 2 } );
30
+
31
+ const first = handler.mock.calls[0][0].eventId;
32
+ const second = handler.mock.calls[1][0].eventId;
33
+ expect( first ).toMatch( UUID_V4_REGEX );
34
+ expect( second ).toMatch( UUID_V4_REGEX );
35
+ expect( first ).not.toBe( second );
36
+ } );
37
+
38
+ it( 'preserves a caller-supplied eventId (deterministic retry case)', () => {
39
+ const handler = vi.fn();
40
+ messageBus.on( 'test:event', handler );
41
+
42
+ messageBus.emit( 'test:event', { eventId: 'fixed-id', foo: 'bar' } );
43
+
44
+ expect( handler ).toHaveBeenCalledWith( expect.objectContaining( {
45
+ eventId: 'fixed-id',
46
+ foo: 'bar'
47
+ } ) );
48
+ } );
49
+
50
+ it( 'does not mutate the caller-supplied payload object', () => {
51
+ const handler = vi.fn();
52
+ messageBus.on( 'test:event', handler );
53
+
54
+ const payload = { foo: 'bar' };
55
+ messageBus.emit( 'test:event', payload );
56
+
57
+ expect( payload ).not.toHaveProperty( 'eventId' );
58
+ } );
59
+ } );
60
+
61
+ describe( 'pass-through behavior', () => {
62
+ it( 'passes primitive payloads through unchanged', () => {
63
+ const handler = vi.fn();
64
+ messageBus.on( 'test:event', handler );
65
+
66
+ messageBus.emit( 'test:event', 'a-string' );
67
+ messageBus.emit( 'test:event', 42 );
68
+ messageBus.emit( 'test:event', true );
69
+
70
+ expect( handler ).toHaveBeenNthCalledWith( 1, 'a-string' );
71
+ expect( handler ).toHaveBeenNthCalledWith( 2, 42 );
72
+ expect( handler ).toHaveBeenNthCalledWith( 3, true );
73
+ } );
74
+
75
+ it( 'passes null and undefined payloads through unchanged', () => {
76
+ const handler = vi.fn();
77
+ messageBus.on( 'test:event', handler );
78
+
79
+ messageBus.emit( 'test:event', null );
80
+ messageBus.emit( 'test:event' );
81
+
82
+ expect( handler ).toHaveBeenNthCalledWith( 1, null );
83
+ expect( handler ).toHaveBeenNthCalledWith( 2 );
84
+ } );
85
+
86
+ it( 'passes array payloads through unchanged (no key injection)', () => {
87
+ const handler = vi.fn();
88
+ messageBus.on( 'test:event', handler );
89
+
90
+ messageBus.emit( 'test:event', [ 1, 2, 3 ] );
91
+
92
+ expect( handler ).toHaveBeenCalledWith( [ 1, 2, 3 ] );
93
+ } );
94
+
95
+ it( 'forwards additional positional args untouched', () => {
96
+ const handler = vi.fn();
97
+ messageBus.on( 'test:event', handler );
98
+
99
+ messageBus.emit( 'test:event', { foo: 'bar' }, 'extra', 99 );
100
+
101
+ expect( handler ).toHaveBeenCalledWith(
102
+ expect.objectContaining( { foo: 'bar', eventId: expect.stringMatching( UUID_V4_REGEX ) } ),
103
+ 'extra',
104
+ 99
105
+ );
106
+ } );
107
+ } );
108
+ } );
@@ -2,6 +2,8 @@
2
2
  * Payload passed to the onError handler when a workflow, activity or runtime error occurs.
3
3
  */
4
4
  export interface ErrorHookPayload {
5
+ /** UUID v4 stamped per emit. Stable per-emit idempotency key. */
6
+ eventId: string;
5
7
  /** Origin of the error: workflow execution, activity execution, or runtime. */
6
8
  source: 'workflow' | 'activity' | 'runtime';
7
9
  /** Name of the workflow, when the error is scoped to a workflow or activity. */
@@ -16,6 +18,8 @@ export interface ErrorHookPayload {
16
18
  * Payload passed to the onWorkflowStart handler when a workflow run begins.
17
19
  */
18
20
  export interface WorkflowStartHookPayload {
21
+ /** UUID v4 stamped per emit. Stable per-emit idempotency key. */
22
+ eventId: string;
19
23
  /** Workflow id (stable across retries / continue-as-new). */
20
24
  id: string;
21
25
  /** Temporal run id for the current execution attempt. */
@@ -28,6 +32,8 @@ export interface WorkflowStartHookPayload {
28
32
  * Payload passed to the onWorkflowEnd handler when a workflow run completes successfully.
29
33
  */
30
34
  export interface WorkflowEndHookPayload {
35
+ /** UUID v4 stamped per emit. Stable per-emit idempotency key. */
36
+ eventId: string;
31
37
  /** Workflow id (stable across retries / continue-as-new). */
32
38
  id: string;
33
39
  /** Temporal run id for the current execution attempt. */
@@ -42,6 +48,8 @@ export interface WorkflowEndHookPayload {
42
48
  * Payload passed to the onWorkflowError handler when a workflow run fails.
43
49
  */
44
50
  export interface WorkflowErrorHookPayload {
51
+ /** UUID v4 stamped per emit. Stable per-emit idempotency key. */
52
+ eventId: string;
45
53
  /** Workflow id (stable across retries / continue-as-new). */
46
54
  id: string;
47
55
  /** Temporal run id for the current execution attempt. */
@@ -107,6 +115,8 @@ export declare function onWorkflowError( handler: ( payload: WorkflowErrorHookPa
107
115
  * to the fields listed here.
108
116
  */
109
117
  export interface HttpRequestHookPayload {
118
+ /** UUID v4 stamped per emit. Stable per-emit idempotency key. */
119
+ eventId: string;
110
120
  /** Workflow id (stable across retries / continue-as-new). */
111
121
  workflowId: string;
112
122
  /** Temporal run id for the current execution attempt. */
@@ -21,12 +21,12 @@ const safeInvoke = async ( fn, args, hookName ) => {
21
21
 
22
22
  /** Triggers on any errors: workflow, activity and runtime */
23
23
  export const onError = handler => {
24
- messageBus.on( BusEventType.ACTIVITY_ERROR, async ( { id, name, workflowId, workflowName, error } ) =>
25
- safeInvoke( handler, { source: 'activity', activityId: id, activityName: name, workflowId, workflowName, error }, 'onError' ) );
26
- messageBus.on( BusEventType.WORKFLOW_ERROR, async ( { id, name, error } ) =>
27
- safeInvoke( handler, { source: 'workflow', workflowId: id, workflowName: name, error }, 'onError' ) );
28
- messageBus.on( BusEventType.RUNTIME_ERROR, async ( { error } ) =>
29
- safeInvoke( handler, { source: 'runtime', error }, 'onError' ) );
24
+ messageBus.on( BusEventType.ACTIVITY_ERROR, async ( { eventId, id, name, workflowId, workflowName, error } ) =>
25
+ safeInvoke( handler, { eventId, source: 'activity', activityId: id, activityName: name, workflowId, workflowName, error }, 'onError' ) );
26
+ messageBus.on( BusEventType.WORKFLOW_ERROR, async ( { eventId, id, name, error } ) =>
27
+ safeInvoke( handler, { eventId, source: 'workflow', workflowId: id, workflowName: name, error }, 'onError' ) );
28
+ messageBus.on( BusEventType.RUNTIME_ERROR, async ( { eventId, error } ) =>
29
+ safeInvoke( handler, { eventId, source: 'runtime', error }, 'onError' ) );
30
30
  };
31
31
 
32
32
  /** Listen to worker before start events */
@@ -34,16 +34,16 @@ export const onBeforeWorkerStart = handler => messageBus.on( BusEventType.WORKER
34
34
  safeInvoke( handler, undefined, 'onBeforeWorkerStart' ) );
35
35
 
36
36
  /** Listen to workflow start events, excludes catalog workflow */
37
- export const onWorkflowStart = handler => messageBus.on( BusEventType.WORKFLOW_START, ( { id, runId, name } ) =>
38
- WORKFLOW_CATALOG !== name ? safeInvoke( handler, { id, runId, name }, 'onWorkflowStart' ) : null );
37
+ export const onWorkflowStart = handler => messageBus.on( BusEventType.WORKFLOW_START, ( { eventId, id, runId, name } ) =>
38
+ WORKFLOW_CATALOG !== name ? safeInvoke( handler, { eventId, id, runId, name }, 'onWorkflowStart' ) : null );
39
39
 
40
40
  /** Listen to workflow end events, excludes catalog workflow */
41
- export const onWorkflowEnd = handler => messageBus.on( BusEventType.WORKFLOW_END, ( { id, runId, name, duration } ) =>
42
- WORKFLOW_CATALOG !== name ? safeInvoke( handler, { id, runId, name, duration }, 'onWorkflowEnd' ) : null );
41
+ export const onWorkflowEnd = handler => messageBus.on( BusEventType.WORKFLOW_END, ( { eventId, id, runId, name, duration } ) =>
42
+ WORKFLOW_CATALOG !== name ? safeInvoke( handler, { eventId, id, runId, name, duration }, 'onWorkflowEnd' ) : null );
43
43
 
44
44
  /** Listen to workflow error events, excludes catalog workflow */
45
- export const onWorkflowError = handler => messageBus.on( BusEventType.WORKFLOW_ERROR, ( { id, runId, name, duration, error } ) =>
46
- WORKFLOW_CATALOG !== name ? safeInvoke( handler, { id, runId, name, duration, error }, 'onWorkflowError' ) : null );
45
+ export const onWorkflowError = handler => messageBus.on( BusEventType.WORKFLOW_ERROR, ( { eventId, id, runId, name, duration, error } ) =>
46
+ WORKFLOW_CATALOG !== name ? safeInvoke( handler, { eventId, id, runId, name, duration, error }, 'onWorkflowError' ) : null );
47
47
 
48
48
  /** Generic listener for events emitted elsewhere (outside core) */
49
49
  export const on = ( eventName, handler ) => messageBus.on( `external:${eventName}`, payload =>
@@ -43,12 +43,13 @@ describe( 'hooks/index', () => {
43
43
  expect( messageBusMock.on ).toHaveBeenCalledWith( BusEventType.RUNTIME_ERROR, expect.any( Function ) );
44
44
  } );
45
45
 
46
- it( 'invokes handler with activity-shaped payload', async () => {
46
+ it( 'invokes handler with activity-shaped payload, forwarding eventId', async () => {
47
47
  const handler = vi.fn().mockResolvedValue( undefined );
48
48
  onError( handler );
49
49
 
50
50
  const err = new Error( 'act-fail' );
51
51
  await onHandlers[BusEventType.ACTIVITY_ERROR]( {
52
+ eventId: 'evt-act-1',
52
53
  id: 'act-1',
53
54
  name: 'wf#step',
54
55
  workflowId: 'wf-run-1',
@@ -57,6 +58,7 @@ describe( 'hooks/index', () => {
57
58
  } );
58
59
 
59
60
  expect( handler ).toHaveBeenCalledWith( {
61
+ eventId: 'evt-act-1',
60
62
  source: 'activity',
61
63
  activityId: 'act-1',
62
64
  activityName: 'wf#step',
@@ -66,18 +68,20 @@ describe( 'hooks/index', () => {
66
68
  } );
67
69
  } );
68
70
 
69
- it( 'invokes handler with workflow-shaped payload', async () => {
71
+ it( 'invokes handler with workflow-shaped payload, forwarding eventId', async () => {
70
72
  const handler = vi.fn().mockResolvedValue( undefined );
71
73
  onError( handler );
72
74
 
73
75
  const err = new Error( 'wf-fail' );
74
76
  await onHandlers[BusEventType.WORKFLOW_ERROR]( {
77
+ eventId: 'evt-wf-1',
75
78
  id: 'wf-run-2',
76
79
  name: 'myWorkflow',
77
80
  error: err
78
81
  } );
79
82
 
80
83
  expect( handler ).toHaveBeenCalledWith( {
84
+ eventId: 'evt-wf-1',
81
85
  source: 'workflow',
82
86
  workflowId: 'wf-run-2',
83
87
  workflowName: 'myWorkflow',
@@ -90,9 +94,9 @@ describe( 'hooks/index', () => {
90
94
  onError( handler );
91
95
 
92
96
  const error = new Error( 'rt' );
93
- await onHandlers[BusEventType.RUNTIME_ERROR]( { error } );
97
+ await onHandlers[BusEventType.RUNTIME_ERROR]( { eventId: 'evt-rt-1', error } );
94
98
 
95
- expect( handler ).toHaveBeenCalledWith( { source: 'runtime', error } );
99
+ expect( handler ).toHaveBeenCalledWith( { eventId: 'evt-rt-1', source: 'runtime', error } );
96
100
  } );
97
101
  } );
98
102
 
@@ -109,56 +113,60 @@ describe( 'hooks/index', () => {
109
113
  } );
110
114
 
111
115
  describe( 'onWorkflowStart', () => {
112
- it( 'skips catalog workflow name', async () => {
116
+ it( 'skips catalog workflow and forwards eventId for real workflows', async () => {
113
117
  const handler = vi.fn().mockResolvedValue( undefined );
114
118
  onWorkflowStart( handler );
115
119
 
116
- await Promise.resolve( onHandlers[BusEventType.WORKFLOW_START]( { id: '1', name: WORKFLOW_CATALOG } ) );
120
+ await Promise.resolve( onHandlers[BusEventType.WORKFLOW_START]( {
121
+ eventId: 'evt-ignored', id: '1', name: WORKFLOW_CATALOG
122
+ } ) );
117
123
  expect( handler ).not.toHaveBeenCalled();
118
124
 
119
- await Promise.resolve( onHandlers[BusEventType.WORKFLOW_START]( { id: '2', name: 'myWorkflow' } ) );
120
- expect( handler ).toHaveBeenCalledWith( { id: '2', name: 'myWorkflow' } );
125
+ await Promise.resolve( onHandlers[BusEventType.WORKFLOW_START]( {
126
+ eventId: 'evt-start-1', id: '2', runId: 'run-2', name: 'myWorkflow'
127
+ } ) );
128
+ expect( handler ).toHaveBeenCalledWith( {
129
+ eventId: 'evt-start-1', id: '2', runId: 'run-2', name: 'myWorkflow'
130
+ } );
121
131
  } );
122
132
  } );
123
133
 
124
134
  describe( 'onWorkflowEnd', () => {
125
- it( 'skips catalog workflow name', async () => {
135
+ it( 'skips catalog workflow and forwards eventId for real workflows', async () => {
126
136
  const handler = vi.fn().mockResolvedValue( undefined );
127
137
  onWorkflowEnd( handler );
128
138
 
129
139
  await Promise.resolve( onHandlers[BusEventType.WORKFLOW_END]( {
130
- id: '1',
131
- name: WORKFLOW_CATALOG,
132
- duration: 10
140
+ eventId: 'evt-ignored', id: '1', name: WORKFLOW_CATALOG, duration: 10
133
141
  } ) );
134
142
  expect( handler ).not.toHaveBeenCalled();
135
143
 
136
- await Promise.resolve( onHandlers[BusEventType.WORKFLOW_END]( { id: '2', name: 'myWorkflow', duration: 5 } ) );
137
- expect( handler ).toHaveBeenCalledWith( { id: '2', name: 'myWorkflow', duration: 5 } );
144
+ await Promise.resolve( onHandlers[BusEventType.WORKFLOW_END]( {
145
+ eventId: 'evt-end-1', id: '2', runId: 'run-2', name: 'myWorkflow', duration: 5
146
+ } ) );
147
+ expect( handler ).toHaveBeenCalledWith( {
148
+ eventId: 'evt-end-1', id: '2', runId: 'run-2', name: 'myWorkflow', duration: 5
149
+ } );
138
150
  } );
139
151
  } );
140
152
 
141
153
  describe( 'onWorkflowError', () => {
142
- it( 'skips catalog workflow name', async () => {
154
+ it( 'skips catalog workflow and forwards eventId for real workflows', async () => {
143
155
  const handler = vi.fn().mockResolvedValue( undefined );
144
156
  const err = new Error( 'wf' );
145
157
  onWorkflowError( handler );
146
158
 
147
159
  await Promise.resolve( onHandlers[BusEventType.WORKFLOW_ERROR]( {
148
- id: '1',
149
- name: WORKFLOW_CATALOG,
150
- duration: 1,
151
- error: err
160
+ eventId: 'evt-ignored', id: '1', name: WORKFLOW_CATALOG, duration: 1, error: err
152
161
  } ) );
153
162
  expect( handler ).not.toHaveBeenCalled();
154
163
 
155
164
  await Promise.resolve( onHandlers[BusEventType.WORKFLOW_ERROR]( {
156
- id: '2',
157
- name: 'myWorkflow',
158
- duration: 2,
159
- error: err
165
+ eventId: 'evt-err-1', id: '2', runId: 'run-2', name: 'myWorkflow', duration: 2, error: err
160
166
  } ) );
161
- expect( handler ).toHaveBeenCalledWith( { id: '2', name: 'myWorkflow', duration: 2, error: err } );
167
+ expect( handler ).toHaveBeenCalledWith( {
168
+ eventId: 'evt-err-1', id: '2', runId: 'run-2', name: 'myWorkflow', duration: 2, error: err
169
+ } );
162
170
  } );
163
171
  } );
164
172
 
@@ -4,7 +4,7 @@ import { serializeError } from './tools/utils.js';
4
4
  import { isStringboolTrue } from '#utils';
5
5
  import * as localProcessor from './processors/local/index.js';
6
6
  import * as s3Processor from './processors/s3/index.js';
7
- import { ComponentType, Signal } from '#consts';
7
+ import { ComponentType } from '#consts';
8
8
  import { createChildLogger } from '#logger';
9
9
  import { EventAction } from './trace_consts.js';
10
10
  import { BaseAttribute } from './trace_attribute.js';
@@ -93,14 +93,14 @@ export const addEventAction = ( action, { kind, name, id, parentId, details, exe
93
93
  export function addEventActionWithContext( action, options ) {
94
94
  const storeContent = Storage.load();
95
95
  if ( storeContent ) { // If there is no storageContext this was not called from a Temporal environment
96
- const { parentId, parentName, executionContext, workflowHandle } = storeContent;
96
+ const { parentId, executionContext, sendAttributeSignal } = storeContent;
97
97
  if ( action === EventAction.ADD_ATTR ) {
98
98
  const attribute = options.details;
99
99
  if ( !( attribute instanceof BaseAttribute ) ) {
100
- throw new Error( `${EventAction.ADD_ATTR} called argument that is not a BaseAttribute instance` );
100
+ throw new Error( `Event ${EventAction.ADD_ATTR} argument is not a BaseAttribute instance` );
101
+ } else {
102
+ sendAttributeSignal( options.details );
101
103
  }
102
- attribute.setActivity( parentId, parentName );
103
- workflowHandle.signal( Signal.ADD_ATTRIBUTE, attribute );
104
104
  }
105
105
  addEventAction( action, { ...options, parentId, executionContext } );
106
106
  }
@@ -5,6 +5,12 @@ vi.mock( '#async_storage', () => ( {
5
5
  Storage: { load: storageLoadMock }
6
6
  } ) );
7
7
 
8
+ const logWarnMock = vi.fn();
9
+ const logErrorMock = vi.fn();
10
+ vi.mock( '#logger', () => ( {
11
+ createChildLogger: () => ( { warn: logWarnMock, error: logErrorMock } )
12
+ } ) );
13
+
8
14
  const localInitMock = vi.fn( async () => {} );
9
15
  const localExecMock = vi.fn();
10
16
  const localGetDestinationMock = vi.fn( () => '/local/path.json' );
@@ -117,6 +123,62 @@ describe( 'tracing/trace_engine', () => {
117
123
  expect( payload.entry.action ).toBe( 'tick' );
118
124
  } );
119
125
 
126
+ it( 'addEventActionWithContext() sends ADD_ATTR attributes through storage context', async () => {
127
+ process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
128
+ const sendAttributeSignalMock = vi.fn();
129
+ const executionContext = { runId: 'r1', disableTrace: false };
130
+ storageLoadMock.mockReturnValue( {
131
+ parentId: 'ctx-p',
132
+ executionContext,
133
+ sendAttributeSignal: sendAttributeSignalMock
134
+ } );
135
+ const { init, addEventActionWithContext } = await loadTraceEngine();
136
+ const { EventAction } = await import( './trace_consts.js' );
137
+ const { Attribute } = await import( './trace_attribute.js' );
138
+ await init();
139
+
140
+ const attribute = new Attribute.HTTPRequestCount( 'https://example.test', 'req-1' );
141
+ addEventActionWithContext( EventAction.ADD_ATTR, { kind: 'http', name: 'request', id: 'req-1', details: attribute } );
142
+
143
+ expect( sendAttributeSignalMock ).toHaveBeenCalledTimes( 1 );
144
+ expect( sendAttributeSignalMock ).toHaveBeenCalledWith( attribute );
145
+ expect( localExecMock ).toHaveBeenCalledTimes( 1 );
146
+ expect( localExecMock.mock.calls[0][0] ).toEqual( {
147
+ executionContext,
148
+ entry: {
149
+ kind: 'http',
150
+ action: EventAction.ADD_ATTR,
151
+ name: 'request',
152
+ id: 'req-1',
153
+ parentId: 'ctx-p',
154
+ timestamp: expect.any( Number ),
155
+ details: attribute
156
+ }
157
+ } );
158
+ } );
159
+
160
+ it( 'addEventActionWithContext() throws on invalid ADD_ATTR signal payloads', async () => {
161
+ process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
162
+ const sendAttributeSignalMock = vi.fn();
163
+ storageLoadMock.mockReturnValue( {
164
+ parentId: 'ctx-p',
165
+ executionContext: { runId: 'r1', disableTrace: false },
166
+ sendAttributeSignal: sendAttributeSignalMock
167
+ } );
168
+ const { init, addEventActionWithContext } = await loadTraceEngine();
169
+ const { EventAction } = await import( './trace_consts.js' );
170
+ await init();
171
+
172
+ const invalidAttribute = { type: 'not-a-base-attribute' };
173
+ expect( () => addEventActionWithContext(
174
+ EventAction.ADD_ATTR,
175
+ { kind: 'http', name: 'request', id: 'req-1', details: invalidAttribute }
176
+ ) ).toThrow( /not a BaseAttribute instance/ );
177
+
178
+ expect( sendAttributeSignalMock ).not.toHaveBeenCalled();
179
+ expect( localExecMock ).not.toHaveBeenCalled();
180
+ } );
181
+
120
182
  it( 'addEventActionWithContext() does not emit when storage executionContext.disableTrace is true', async () => {
121
183
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
122
184
  storageLoadMock.mockReturnValue( {
@@ -132,3 +132,16 @@ export function deepMerge( a: object, b: object ): object;
132
132
  * @returns Short string using A–Z, a–z, 0–9, `_`, `-` (typically 21–22 chars).
133
133
  */
134
134
  export function toUrlSafeBase64( uuid: string ): string;
135
+
136
+ /**
137
+ * Similar to native Promise.allSettled, but rejects with `{ isTimeout: true }`
138
+ * if the execution exceeds the given timeout.
139
+ *
140
+ * @param promises - Values or promises to wait for.
141
+ * @param timeoutMs - Maximum wait time in milliseconds.
142
+ * @returns Native Promise.allSettled-style results.
143
+ */
144
+ export function allSettledWithTimeout<T>(
145
+ promises: Array<T | PromiseLike<T>>,
146
+ timeoutMs: number
147
+ ): Promise<PromiseSettledResult<Awaited<T>>[]>;
@@ -209,3 +209,42 @@ export const toUrlSafeBase64 = uuid => {
209
209
  const toDigits = n => n <= 0n ? [] : toDigits( n / base ).concat( alphabet[Number( n % base )] );
210
210
  return toDigits( BigInt( '0x' + hex ) ).join( '' );
211
211
  };
212
+
213
+ /**
214
+ * Similar to native Promise.allSettled but throws an Error if the execution exceeds a given time.
215
+ *
216
+ * The error thrown will have attribute `.isTimeout` as `true`.
217
+ *
218
+ * @template T
219
+ * @param {Array<T | PromiseLike<T>>} promises
220
+ * @param {number} timeoutMs
221
+ * @returns {Promise<PromiseSettledResult<Awaited<T>>[]>}
222
+ * @throws {Error & { isTimeout: true }}
223
+ */
224
+ export const allSettledWithTimeout = ( () => {
225
+ class TimeoutError extends Error {
226
+ isTimeout = true;
227
+ constructor() {
228
+ super( 'Timed out before completing all promises' );
229
+ }
230
+ }
231
+
232
+ return async ( promises, timeoutMs ) => {
233
+ if ( promises.length === 0 ) {
234
+ return [];
235
+ }
236
+
237
+ const state = { timeoutMonitor: null };
238
+
239
+ try {
240
+ return await Promise.race( [
241
+ Promise.allSettled( promises ),
242
+ new Promise( ( _, reject ) => {
243
+ state.timeoutMonitor = setTimeout( () => reject( new TimeoutError() ), timeoutMs );
244
+ } )
245
+ ] );
246
+ } finally {
247
+ clearTimeout( state.timeoutMonitor );
248
+ }
249
+ };
250
+ } )();
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi } from 'vitest';
2
2
  import { Readable } from 'node:stream';
3
3
  import {
4
4
  clone,
@@ -6,7 +6,8 @@ import {
6
6
  serializeFetchResponse,
7
7
  deepMerge,
8
8
  isPlainObject,
9
- toUrlSafeBase64
9
+ toUrlSafeBase64,
10
+ allSettledWithTimeout
10
11
  } from './utils.js';
11
12
 
12
13
  describe( 'clone', () => {
@@ -22,6 +23,44 @@ describe( 'clone', () => {
22
23
  } );
23
24
  } );
24
25
 
26
+ describe( 'allSettledWithTimeout', () => {
27
+ it( 'returns an empty array when no promises are provided', async () => {
28
+ await expect( allSettledWithTimeout( [], 100 ) ).resolves.toEqual( [] );
29
+ } );
30
+
31
+ it( 'returns native allSettled results when all promises settle before timeout', async () => {
32
+ const error = new Error( 'boom' );
33
+ const result = await allSettledWithTimeout( [
34
+ Promise.resolve( 'ok' ),
35
+ Promise.reject( error ),
36
+ 42
37
+ ], 100 );
38
+
39
+ expect( result ).toEqual( [
40
+ { status: 'fulfilled', value: 'ok' },
41
+ { status: 'rejected', reason: error },
42
+ { status: 'fulfilled', value: 42 }
43
+ ] );
44
+ } );
45
+
46
+ it( 'rejects with a timeout-shaped error when promises do not settle in time', async () => {
47
+ vi.useFakeTimers();
48
+ try {
49
+ const pending = new Promise( () => {} );
50
+ const result = allSettledWithTimeout( [ pending ], 1000 );
51
+ const assertion = expect( result ).rejects.toMatchObject( {
52
+ isTimeout: true,
53
+ message: 'Timed out before completing all promises'
54
+ } );
55
+
56
+ await vi.advanceTimersByTimeAsync( 1000 );
57
+ await assertion;
58
+ } finally {
59
+ vi.useRealTimers();
60
+ }
61
+ } );
62
+ } );
63
+
25
64
  describe( 'serializeFetchResponse', () => {
26
65
  it( 'serializes JSON response body and flattens headers', async () => {
27
66
  const payload = { a: 1, b: 'two' };
@@ -2,10 +2,16 @@ import { Context } from '@temporalio/activity';
2
2
  import { Storage } from '#async_storage';
3
3
  import * as Tracing from '#tracing';
4
4
  import { headersToObject } from '../sandboxed_utils.js';
5
- import { BusEventType, METADATA_ACCESS_SYMBOL } from '#consts';
5
+ import { BusEventType, METADATA_ACCESS_SYMBOL, Signal } from '#consts';
6
6
  import { activityHeartbeatEnabled, activityHeartbeatIntervalMs, namespace } from '../configs.js';
7
7
  import { messageBus } from '#bus';
8
8
  import { Client } from '@temporalio/client';
9
+ import { createChildLogger } from '#logger';
10
+ import { allSettledWithTimeout } from '#utils';
11
+
12
+ const log = createChildLogger( 'ActivityInterceptor' );
13
+
14
+ const IN_FLIGHT_SIGNALS_TIMEOUT_MS = 30_000;
9
15
 
10
16
  /*
11
17
  This interceptor wraps every activity execution with cross-cutting concerns:
@@ -36,6 +42,20 @@ export class ActivityExecutionInterceptor {
36
42
  this.connection = connection;
37
43
  };
38
44
 
45
+ /**
46
+ * Returns a workflow entry by its name or throws error
47
+ * @param {string} workflowName
48
+ * @returns {object} Workflow entry
49
+ * @throws {Error}
50
+ */
51
+ getWorkflowEntry( workflowName ) {
52
+ const workflowEntry = this.workflowsMap.get( workflowName );
53
+ if ( !workflowEntry ) {
54
+ throw new Error( `Activity interceptor: workflow "${workflowName}" not found in workflowsMap.` );
55
+ }
56
+ return workflowEntry;
57
+ }
58
+
39
59
  async execute( input, next ) {
40
60
  const startDate = Date.now();
41
61
  const client = new Client( { connection: this.connection, namespace } );
@@ -43,31 +63,66 @@ export class ActivityExecutionInterceptor {
43
63
  const { workflowExecution: { workflowId }, activityId: id, activityType: name, workflowType: workflowName } = Context.current().info;
44
64
  const { executionContext } = headersToObject( input.headers );
45
65
  const { type: kind } = this.activities?.[name]?.[METADATA_ACCESS_SYMBOL];
66
+ const { path: workflowFilename } = this.getWorkflowEntry( workflowName );
46
67
 
47
68
  const workflowHandle = client.workflow.getHandle( workflowId );
48
69
 
70
+ const state = {
71
+ heartbeat: null,
72
+ activityOutput: undefined,
73
+ signals: []
74
+ };
75
+
76
+ const errorContext = {
77
+ workflowId,
78
+ workflowName,
79
+ activityId: id,
80
+ activityName: name
81
+ };
82
+
83
+ const sendAttributeSignal = attribute => {
84
+ attribute.setActivity( id, name );
85
+ state.signals.push(
86
+ workflowHandle
87
+ .signal( Signal.ADD_ATTRIBUTE, attribute )
88
+ .catch( e =>
89
+ log.warn( `Signal "${Signal.ADD_ATTRIBUTE}" failed`, { message: e.message, stack: e.stack, activityId: id, ...errorContext } )
90
+ )
91
+ );
92
+ };
93
+
94
+ const flushSignals = async signals => {
95
+ try {
96
+ await allSettledWithTimeout( signals, IN_FLIGHT_SIGNALS_TIMEOUT_MS );
97
+ } catch ( error ) {
98
+ if ( error.isTimeout ) {
99
+ log.warn( 'Some usage/cost attributes were missed because not all activity signals were sent to the workflow', errorContext );
100
+ } else {
101
+ throw error;
102
+ }
103
+ }
104
+ };
105
+
106
+ // Wraps the execution with accessible metadata for the activity
107
+ const ctx = { parentId: id, executionContext, workflowFilename, sendAttributeSignal };
108
+
49
109
  messageBus.emit( BusEventType.ACTIVITY_START, { id, name, kind, workflowId, workflowName } );
50
110
  Tracing.addEventStart( { id, name, kind, parentId: workflowId, details: input.args[0], executionContext } );
51
111
 
52
- const workflowEntry = this.workflowsMap.get( workflowName );
53
- if ( !workflowEntry ) {
54
- const availableWorkflows = [ ...this.workflowsMap.keys() ].join( ', ' );
55
- throw new Error( `Activity interceptor: workflow "${workflowName}" not found in workflowsMap. Available: [${availableWorkflows}]` );
56
- }
57
- const workflowFilename = workflowEntry.path;
58
-
59
- const intervals = { heartbeat: null };
60
112
  try {
61
113
  // Sends heartbeat to communicate that activity is still alive
62
- intervals.heartbeat = activityHeartbeatEnabled && setInterval( () => Context.current().heartbeat(), activityHeartbeatIntervalMs );
114
+ state.heartbeat = activityHeartbeatEnabled && setInterval( () => Context.current().heartbeat(), activityHeartbeatIntervalMs );
63
115
 
64
- // Wraps the execution with accessible metadata for the activity
65
- const ctx = { parentId: id, parentName: name, executionContext, workflowFilename, workflowHandle };
66
- const output = await Storage.runWithContext( async _ => next( input ), ctx );
116
+ try {
117
+ state.activityOutput = await Storage.runWithContext( async _ => next( input ), ctx );
118
+ } finally {
119
+ // Ensure in-flight signals are delivered (up to a reasonable time) before handling errors
120
+ await flushSignals( state.signals );
121
+ }
67
122
 
68
123
  messageBus.emit( BusEventType.ACTIVITY_END, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate } );
69
- Tracing.addEventEnd( { id, details: output, executionContext } );
70
- return output;
124
+ Tracing.addEventEnd( { id, details: state.activityOutput, executionContext } );
125
+ return state.activityOutput;
71
126
 
72
127
  } catch ( error ) {
73
128
  messageBus.emit( BusEventType.ACTIVITY_ERROR, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate, error } );
@@ -75,7 +130,7 @@ export class ActivityExecutionInterceptor {
75
130
 
76
131
  throw error;
77
132
  } finally {
78
- clearInterval( intervals.heartbeat );
133
+ clearInterval( state.heartbeat );
79
134
  }
80
135
  }
81
136
  };
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { BusEventType } from '#consts';
2
+ import { BusEventType, Signal } from '#consts';
3
3
 
4
4
  const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
5
5
  const workflowHandleMock = vi.hoisted( () => ( { signal: vi.fn() } ) );
6
6
  const getHandleMock = vi.hoisted( () => vi.fn( () => workflowHandleMock ) );
7
7
  const clientConstructorMock = vi.hoisted( () => vi.fn() );
8
+ const allSettledWithTimeoutMock = vi.hoisted( () => vi.fn().mockResolvedValue( [] ) );
9
+ const logWarnMock = vi.hoisted( () => vi.fn() );
8
10
 
9
11
  const heartbeatMock = vi.fn();
10
12
  const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async fn => fn() ) );
@@ -42,6 +44,15 @@ vi.mock( '#async_storage', () => ( {
42
44
  }
43
45
  } ) );
44
46
 
47
+ vi.mock( '#utils', async importOriginal => {
48
+ const actual = await importOriginal();
49
+ return { ...actual, allSettledWithTimeout: allSettledWithTimeoutMock };
50
+ } );
51
+
52
+ vi.mock( '#logger', () => ( {
53
+ createChildLogger: () => ( { warn: logWarnMock } )
54
+ } ) );
55
+
45
56
  const addEventStartMock = vi.fn();
46
57
  const addEventEndMock = vi.fn();
47
58
  const addEventErrorMock = vi.fn();
@@ -93,6 +104,8 @@ const makeInput = () => ( {
93
104
  describe( 'ActivityExecutionInterceptor', () => {
94
105
  beforeEach( () => {
95
106
  vi.clearAllMocks();
107
+ allSettledWithTimeoutMock.mockResolvedValue( [] );
108
+ workflowHandleMock.signal.mockResolvedValue( undefined );
96
109
  vi.useFakeTimers();
97
110
  vi.resetModules();
98
111
  // Default: heartbeat enabled with 50ms interval for fast tests
@@ -129,15 +142,83 @@ describe( 'ActivityExecutionInterceptor', () => {
129
142
  expect.any( Function ),
130
143
  expect.objectContaining( {
131
144
  parentId: 'act-1',
132
- parentName: 'myWorkflow#myStep',
133
145
  executionContext: { workflowId: 'wf-1' },
134
146
  workflowFilename: '/workflows/myWorkflow.js',
135
- workflowHandle: workflowHandleMock
147
+ sendAttributeSignal: expect.any( Function )
136
148
  } )
137
149
  );
138
150
  expect( getHandleMock ).toHaveBeenCalledWith( 'wf-1' );
139
151
  } );
140
152
 
153
+ it( 'handles next returning a non-Promise value', async () => {
154
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
155
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
156
+ const next = vi.fn( () => ( { result: 'sync' } ) );
157
+
158
+ await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'sync' } );
159
+
160
+ expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
161
+ expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
162
+ expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'sync' }, executionContext: { workflowId: 'wf-1' } } );
163
+ expect( addEventErrorMock ).not.toHaveBeenCalled();
164
+ } );
165
+
166
+ it( 'handles signal flush timeout after successful execution', async () => {
167
+ const timeoutError = Object.assign( new Error( 'timeout' ), { isTimeout: true } );
168
+ allSettledWithTimeoutMock.mockRejectedValueOnce( timeoutError );
169
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
170
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
171
+ const next = vi.fn().mockResolvedValue( { result: 'ok' } );
172
+
173
+ await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
174
+
175
+ expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
176
+ expect( logWarnMock ).toHaveBeenCalledWith(
177
+ 'Some usage/cost attributes were missed because not all activity signals were sent to the workflow',
178
+ { workflowId: 'wf-1', workflowName: 'myWorkflow', activityId: 'act-1', activityName: 'myWorkflow#myStep' }
179
+ );
180
+ expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
181
+ expect( addEventEndMock ).toHaveBeenCalledOnce();
182
+ expect( addEventErrorMock ).not.toHaveBeenCalled();
183
+ } );
184
+
185
+ it( 'handles signal flush timeout after failed execution', async () => {
186
+ const timeoutError = Object.assign( new Error( 'timeout' ), { isTimeout: true } );
187
+ allSettledWithTimeoutMock.mockRejectedValueOnce( timeoutError );
188
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
189
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
190
+ const error = new Error( 'step failed' );
191
+ const next = vi.fn().mockRejectedValue( error );
192
+
193
+ await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
194
+
195
+ expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
196
+ expect( logWarnMock ).toHaveBeenCalledWith(
197
+ 'Some usage/cost attributes were missed because not all activity signals were sent to the workflow',
198
+ { workflowId: 'wf-1', workflowName: 'myWorkflow', activityId: 'act-1', activityName: 'myWorkflow#myStep' }
199
+ );
200
+ expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, expect.objectContaining( { error } ) );
201
+ expect( addEventErrorMock ).toHaveBeenCalledOnce();
202
+ expect( addEventEndMock ).not.toHaveBeenCalled();
203
+ } );
204
+
205
+ it( 'exposes sendAttributeSignal in activity context', async () => {
206
+ const attribute = { setActivity: vi.fn() };
207
+ runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
208
+ ctx.sendAttributeSignal( attribute );
209
+ return fn();
210
+ } );
211
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
212
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
213
+ const next = vi.fn().mockResolvedValue( { result: 'ok' } );
214
+
215
+ await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
216
+
217
+ expect( attribute.setActivity ).toHaveBeenCalledWith( 'act-1', 'myWorkflow#myStep' );
218
+ expect( workflowHandleMock.signal ).toHaveBeenCalledWith( Signal.ADD_ATTRIBUTE, attribute );
219
+ expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [ expect.any( Promise ) ], 30_000 );
220
+ } );
221
+
141
222
  it( 'records trace error event on failed execution', async () => {
142
223
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
143
224
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );