@outputai/core 0.5.3-next.0eeffec.0 → 0.5.3-next.bdf47aa.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.
@@ -5,6 +5,7 @@ import {
5
5
  serializeBodyAndInferContentType,
6
6
  serializeFetchResponse,
7
7
  deepMerge,
8
+ deepMergeWithResolver,
8
9
  isPlainObject,
9
10
  toUrlSafeBase64,
10
11
  allSettledWithTimeout
@@ -21,6 +22,86 @@ describe( 'clone', () => {
21
22
  expect( copied.nested.b ).toBe( 3 );
22
23
  expect( copied ).not.toBe( original );
23
24
  } );
25
+
26
+ it( 'deep copies JSON-compatible arrays and objects', () => {
27
+ const original = {
28
+ arr: [ 1, { nested: true } ],
29
+ str: 'value',
30
+ bool: false,
31
+ nil: null
32
+ };
33
+ const copied = clone( original );
34
+
35
+ copied.arr[1].nested = false;
36
+
37
+ expect( copied ).toEqual( {
38
+ arr: [ 1, { nested: false } ],
39
+ str: 'value',
40
+ bool: false,
41
+ nil: null
42
+ } );
43
+ expect( original.arr[1].nested ).toBe( true );
44
+ expect( copied ).not.toBe( original );
45
+ expect( copied.arr ).not.toBe( original.arr );
46
+ } );
47
+
48
+ it( 'returns primitive JSON values when they can be parsed', () => {
49
+ expect( clone( null ) ).toBeNull();
50
+ expect( clone( true ) ).toBe( true );
51
+ expect( clone( false ) ).toBe( false );
52
+ expect( clone( 123 ) ).toBe( 123 );
53
+ expect( clone( 'hello' ) ).toBe( 'hello' );
54
+ } );
55
+
56
+ it( 'returns original values when JSON serialization produces no parseable payload', () => {
57
+ const sym = Symbol( 'x' );
58
+ const fn = () => {};
59
+ class Foo {}
60
+
61
+ expect( clone( undefined ) ).toBeUndefined();
62
+ expect( clone( sym ) ).toBe( sym );
63
+ expect( clone( fn ) ).toBe( fn );
64
+ expect( clone( Foo ) ).toBe( Foo );
65
+ expect( clone( Date ) ).toBe( Date );
66
+ expect( clone( Object ) ).toBe( Object );
67
+ expect( clone( Number ) ).toBe( Number );
68
+ } );
69
+
70
+ it( 'returns original values when JSON serialization throws', () => {
71
+ const circular = { name: 'circular' };
72
+ circular.self = circular;
73
+ const bigint = 1n;
74
+
75
+ expect( clone( circular ) ).toBe( circular );
76
+ expect( clone( bigint ) ).toBe( bigint );
77
+ } );
78
+
79
+ it( 'keeps JSON.stringify semantics for special numeric values', () => {
80
+ expect( clone( NaN ) ).toBeNull();
81
+ expect( clone( Infinity ) ).toBeNull();
82
+ expect( clone( -Infinity ) ).toBeNull();
83
+ } );
84
+
85
+ it( 'keeps JSON.stringify semantics for non-plain object instances', () => {
86
+ const date = new Date( '2025-01-01T00:00:00.000Z' );
87
+
88
+ expect( clone( date ) ).toBe( '2025-01-01T00:00:00.000Z' );
89
+ expect( clone( /abc/ ) ).toEqual( {} );
90
+ expect( clone( new Map( [ [ 'a', 1 ] ] ) ) ).toEqual( {} );
91
+ expect( clone( new Set( [ 1, 2 ] ) ) ).toEqual( {} );
92
+ } );
93
+
94
+ it( 'drops object properties that JSON.stringify omits', () => {
95
+ const sym = Symbol( 'x' );
96
+ const original = {
97
+ kept: 'yes',
98
+ missing: undefined,
99
+ fn: () => {},
100
+ sym
101
+ };
102
+
103
+ expect( clone( original ) ).toEqual( { kept: 'yes' } );
104
+ } );
24
105
  } );
25
106
 
26
107
  describe( 'allSettledWithTimeout', () => {
@@ -353,6 +434,57 @@ describe( 'deepMerge', () => {
353
434
  } );
354
435
  } );
355
436
 
437
+ describe( 'deepMergeWithResolver', () => {
438
+ it( 'uses resolver for existing leaf values, including nested leaves', () => {
439
+ const a = {
440
+ cost: { total: 1 },
441
+ tokens: { total: 2, input: 3 }
442
+ };
443
+ const b = {
444
+ cost: { total: 4 },
445
+ tokens: { total: 5, input: 6, output: 7 }
446
+ };
447
+
448
+ expect( deepMergeWithResolver( a, b, ( x, y ) => x + y ) ).toEqual( {
449
+ cost: { total: 5 },
450
+ tokens: { total: 7, input: 9, output: 7 }
451
+ } );
452
+ } );
453
+
454
+ it( 'copies values from "b" when they do not exist in "a"', () => {
455
+ const resolver = vi.fn( ( x, y ) => x + y );
456
+
457
+ expect( deepMergeWithResolver( { a: 1 }, { b: 2, nested: { c: 3 } }, resolver ) ).toEqual( {
458
+ a: 1,
459
+ b: 2,
460
+ nested: { c: 3 }
461
+ } );
462
+ expect( resolver ).not.toHaveBeenCalled();
463
+ } );
464
+
465
+ it( 'keeps extra values from "a" when absent from "b"', () => {
466
+ expect( deepMergeWithResolver( { a: 1, nested: { kept: 2 } }, { b: 3 }, ( x, y ) => x + y ) ).toEqual( {
467
+ a: 1,
468
+ nested: { kept: 2 },
469
+ b: 3
470
+ } );
471
+ } );
472
+
473
+ it( 'returns a clone of "a" when "b" is not an object', () => {
474
+ const a = { nested: { value: 1 } };
475
+ const result = deepMergeWithResolver( a, null, ( x, y ) => x + y );
476
+
477
+ a.nested.value = 2;
478
+ expect( result ).toEqual( { nested: { value: 1 } } );
479
+ } );
480
+
481
+ it( 'throws when first argument is not a plain object', () => {
482
+ expect( () => deepMergeWithResolver( null, {}, ( x, y ) => x + y ) ).toThrow( Error );
483
+ expect( () => deepMergeWithResolver( [], {}, ( x, y ) => x + y ) ).toThrow( Error );
484
+ expect( () => deepMergeWithResolver( 'a', {}, ( x, y ) => x + y ) ).toThrow( Error );
485
+ } );
486
+ } );
487
+
356
488
  describe( 'isPlainObject', () => {
357
489
  it( 'Detects plain objects', () => {
358
490
  expect( isPlainObject( {} ) ).toBe( true );
@@ -7,12 +7,15 @@ import { defineQuery, setHandler, condition, defineUpdate } from '@temporalio/wo
7
7
  *
8
8
  * @param {object} catalog - The catalog information
9
9
  */
10
- export default async function catalogWorkflow( catalog ) {
10
+ export default async function catalogWorkflow( catalog, catalogHash ) {
11
11
  const state = { canEnd: false };
12
12
 
13
13
  // Returns the catalog
14
14
  setHandler( defineQuery( 'get' ), () => catalog );
15
15
 
16
+ // Returns the catalog hash
17
+ setHandler( defineQuery( 'get_hash' ), () => catalogHash );
18
+
16
19
  // Politely respond to a ping
17
20
  setHandler( defineQuery( 'ping' ), () => 'pong' );
18
21
 
@@ -25,11 +25,6 @@ const envVarSchema = z.object( {
25
25
  OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 2 * 60 * 1000 ) ), // 2min
26
26
  // Whether to send activity heartbeats (enabled by default)
27
27
  OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) ),
28
- // When true, activities fire Temporal signals carrying attribute/event data (LLM usage,
29
- // HTTP request count/cost) back to the workflow for aggregation in the result.
30
- // Defaulted OFF: the current emission architecture bloats Temporal history.
31
- // Set to "true" to opt in to per-event attribute collection and aggregations.
32
- OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: z.transform( v => v === undefined ? false : isStringboolTrue( v ) ),
33
28
  // Time to allow for hooks to flush before shutdown
34
29
  OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 3000 ) ),
35
30
  // HTTP CONNECT proxy for Temporal gRPC connections (e.g. "proxy-host:8080").
@@ -58,6 +53,5 @@ export const taskQueue = envVars.OUTPUT_CATALOG_ID;
58
53
  export const catalogId = envVars.OUTPUT_CATALOG_ID;
59
54
  export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS;
60
55
  export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
61
- export const enableAttributeSignalEmission = envVars.OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION;
62
56
  export const processFailureShutdownDelay = envVars.OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY;
63
57
  export const grpcProxy = envVars.TEMPORAL_GRPC_PROXY;
@@ -11,8 +11,7 @@ const CONFIG_KEYS = [
11
11
  'TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_POLLS',
12
12
  'TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_POLLS',
13
13
  'OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS',
14
- 'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED',
15
- 'OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION'
14
+ 'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED'
16
15
  ];
17
16
 
18
17
  const setEnv = ( overrides = {} ) => {
@@ -64,7 +63,6 @@ describe( 'worker/configs', () => {
64
63
  expect( configs.maxConcurrentWorkflowTaskPolls ).toBe( 5 );
65
64
  expect( configs.activityHeartbeatIntervalMs ).toBe( 2 * 60 * 1000 );
66
65
  expect( configs.activityHeartbeatEnabled ).toBe( true );
67
- expect( configs.enableAttributeSignalEmission ).toBe( false );
68
66
  expect( configs.taskQueue ).toBe( 'test-catalog' );
69
67
  expect( configs.catalogId ).toBe( 'test-catalog' );
70
68
  } );
@@ -122,30 +120,6 @@ describe( 'worker/configs', () => {
122
120
  expect( configsDefault.activityHeartbeatEnabled ).toBe( true );
123
121
  } );
124
122
 
125
- it( 'OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: "true"|"1"|"on" → true', async () => {
126
- for ( const val of [ 'true', '1', 'on' ] ) {
127
- setEnv( { OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: val } );
128
- const configs = await loadConfigs();
129
- expect( configs.enableAttributeSignalEmission ).toBe( true );
130
- clearEnv();
131
- }
132
- } );
133
-
134
- it( 'OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: "false"|other → false, undefined → false (default off)', async () => {
135
- setEnv( { OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: 'false' } );
136
- const configsFalse = await loadConfigs();
137
- expect( configsFalse.enableAttributeSignalEmission ).toBe( false );
138
-
139
- setEnv( { OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: '0' } );
140
- const configsZero = await loadConfigs();
141
- expect( configsZero.enableAttributeSignalEmission ).toBe( false );
142
-
143
- clearEnv();
144
- setEnv(); // only OUTPUT_CATALOG_ID; OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION absent → default false
145
- const configsDefault = await loadConfigs();
146
- expect( configsDefault.enableAttributeSignalEmission ).toBe( false );
147
- } );
148
-
149
123
  it( 'parses TEMPORAL_ADDRESS and TEMPORAL_NAMESPACE', async () => {
150
124
  setEnv( { TEMPORAL_ADDRESS: 'temporal:7233', TEMPORAL_NAMESPACE: 'my-ns' } );
151
125
  const configs = await loadConfigs();
@@ -13,6 +13,7 @@ import { bootstrapFetchProxy } from './proxy.js';
13
13
  import { messageBus } from '#bus';
14
14
  import './log_hooks.js';
15
15
  import { BusEventType } from '#consts';
16
+ import { hashSourceCode } from './loader_tools.js';
16
17
 
17
18
  const log = createChildLogger( 'Worker' );
18
19
 
@@ -54,6 +55,9 @@ const callerDir = process.argv[2];
54
55
  log.info( 'Creating workflows catalog...' );
55
56
  const catalog = createCatalog( { workflows, activities } );
56
57
 
58
+ log.info( 'Computing catalog source code hash...' );
59
+ const catalogHash = await hashSourceCode( callerDir );
60
+
57
61
  log.info( 'Connecting Temporal...' );
58
62
  const proxy = grpcProxy ? { type: 'http-connect', targetHost: grpcProxy } : undefined;
59
63
  if ( proxy ) {
@@ -81,7 +85,7 @@ const callerDir = process.argv[2];
81
85
  registerShutdown( { worker, log } );
82
86
 
83
87
  log.info( 'Running worker...' );
84
- await Promise.all( [ worker.run(), startCatalog( { connection, namespace, catalog } ) ] );
88
+ await Promise.all( [ worker.run(), startCatalog( { connection, namespace, catalog, catalogHash } ) ] );
85
89
 
86
90
  log.info( 'Closing connection...' );
87
91
  await connection.close();
@@ -40,6 +40,9 @@ vi.mock( './loader.js', () => ( {
40
40
  createWorkflowsEntryPoint: createWorkflowsEntryPointMock
41
41
  } ) );
42
42
 
43
+ const hashSourceCodeMock = vi.fn().mockResolvedValue( 'catalog-hash' );
44
+ vi.mock( './loader_tools.js', () => ( { hashSourceCode: hashSourceCodeMock } ) );
45
+
43
46
  vi.mock( './sinks.js', () => ( { sinks: {} } ) );
44
47
 
45
48
  const createCatalogMock = vi.fn().mockReturnValue( { workflows: [], activities: {} } );
@@ -105,6 +108,7 @@ describe( 'worker/index', () => {
105
108
  expect( createWorkflowsEntryPointMock ).toHaveBeenCalledWith( [] );
106
109
  expect( initTracing ).toHaveBeenCalled();
107
110
  expect( createCatalogMock ).toHaveBeenCalledWith( { workflows: [], activities: {} } );
111
+ expect( hashSourceCodeMock ).toHaveBeenCalledWith( '/test/caller/dir' );
108
112
  expect( bootstrapFetchProxyMock ).toHaveBeenCalled();
109
113
  expect( NativeConnection.connect ).toHaveBeenCalledWith( {
110
114
  address: configValues.address,
@@ -128,7 +132,8 @@ describe( 'worker/index', () => {
128
132
  expect( startCatalogMock ).toHaveBeenCalledWith( {
129
133
  connection: mockConnection,
130
134
  namespace: configValues.namespace,
131
- catalog: { workflows: [], activities: {} }
135
+ catalog: { workflows: [], activities: {} },
136
+ catalogHash: 'catalog-hash'
132
137
  } );
133
138
 
134
139
  runState.resolve();
@@ -2,17 +2,15 @@ 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, Signal } from '#consts';
6
- import { activityHeartbeatEnabled, activityHeartbeatIntervalMs, enableAttributeSignalEmission, namespace } from '../configs.js';
5
+ import { ACTIVITY_WRAPPER_VERSION_FIELD, BusEventType, METADATA_ACCESS_SYMBOL, Signal } from '#consts';
6
+ import { activityHeartbeatEnabled, activityHeartbeatIntervalMs, namespace } from '../configs.js';
7
7
  import { messageBus } from '#bus';
8
8
  import { Client } from '@temporalio/client';
9
9
  import { createChildLogger } from '#logger';
10
- import { allSettledWithTimeout } from '#utils';
10
+ import { aggregateAttributes } from '#internal_utils/aggregations';
11
11
 
12
12
  const log = createChildLogger( 'ActivityInterceptor' );
13
13
 
14
- const IN_FLIGHT_SIGNALS_TIMEOUT_MS = 30_000;
15
-
16
14
  /*
17
15
  This interceptor wraps every activity execution with cross-cutting concerns:
18
16
 
@@ -58,56 +56,40 @@ export class ActivityExecutionInterceptor {
58
56
 
59
57
  async execute( input, next ) {
60
58
  const startDate = Date.now();
61
- const client = new Client( { connection: this.connection, namespace } );
62
59
 
63
60
  const { workflowExecution: { workflowId }, activityId: id, activityType: name, workflowType: workflowName } = Context.current().info;
64
61
  const { executionContext } = headersToObject( input.headers );
65
62
  const { type: kind } = this.activities?.[name]?.[METADATA_ACCESS_SYMBOL];
66
63
  const { path: workflowFilename } = this.getWorkflowEntry( workflowName );
67
64
 
68
- const workflowHandle = client.workflow.getHandle( workflowId );
69
-
70
65
  const state = {
71
66
  heartbeat: null,
72
- activityOutput: undefined,
73
- signals: []
67
+ attributes: []
74
68
  };
75
69
 
76
- const errorContext = {
77
- workflowId,
78
- workflowName,
79
- activityId: id,
80
- activityName: name
81
- };
82
-
83
- const sendAttributeSignal = attribute => {
84
- if ( !enableAttributeSignalEmission ) {
85
- return;
86
- }
87
- attribute.setActivity( id, name );
88
- state.signals.push(
89
- workflowHandle
90
- .signal( Signal.ADD_ATTRIBUTE, attribute )
91
- .catch( e =>
92
- log.warn( `Signal "${Signal.ADD_ATTRIBUTE}" failed`, { message: e.message, stack: e.stack, activityId: id, ...errorContext } )
93
- )
94
- );
95
- };
96
-
97
- const flushSignals = async signals => {
98
- try {
99
- await allSettledWithTimeout( signals, IN_FLIGHT_SIGNALS_TIMEOUT_MS );
100
- } catch ( error ) {
101
- if ( error.isTimeout ) {
102
- log.warn( 'Some usage/cost attributes were missed because not all activity signals were sent to the workflow', errorContext );
103
- } else {
104
- throw error;
70
+ const addAttribute = attribute => state.attributes.push( attribute );
71
+
72
+ const sendAggregationsViaSignal = async () => {
73
+ if ( state.attributes.length > 0 ) {
74
+ try {
75
+ const client = new Client( { connection: this.connection, namespace } );
76
+ const workflowHandle = client.workflow.getHandle( workflowId );
77
+ await workflowHandle.signal( Signal.SEND_AGGREGATIONS, aggregateAttributes( state.attributes ) );
78
+ } catch ( error ) {
79
+ log.warn( `Signal "${Signal.SEND_AGGREGATIONS}" failed`, {
80
+ message: error.message,
81
+ stack: error.stack,
82
+ activityId: id,
83
+ activityName: name,
84
+ workflowId,
85
+ workflowName
86
+ } );
105
87
  }
106
88
  }
107
89
  };
108
90
 
109
91
  // Wraps the execution with accessible metadata for the activity
110
- const ctx = { parentId: id, executionContext, workflowFilename, sendAttributeSignal };
92
+ const ctx = { parentId: id, executionContext, workflowFilename, addAttribute };
111
93
 
112
94
  messageBus.emit( BusEventType.ACTIVITY_START, { id, name, kind, workflowId, workflowName } );
113
95
  Tracing.addEventStart( { id, name, kind, parentId: workflowId, details: input.args[0], executionContext } );
@@ -116,21 +98,22 @@ export class ActivityExecutionInterceptor {
116
98
  // Sends heartbeat to communicate that activity is still alive
117
99
  state.heartbeat = activityHeartbeatEnabled && setInterval( () => Context.current().heartbeat(), activityHeartbeatIntervalMs );
118
100
 
119
- try {
120
- state.activityOutput = await Storage.runWithContext( async _ => next( input ), ctx );
121
- } finally {
122
- // Ensure in-flight signals are delivered (up to a reasonable time) before handling errors
123
- await flushSignals( state.signals );
124
- }
101
+ const output = await Storage.runWithContext( async _ => next( input ), ctx );
125
102
 
126
103
  messageBus.emit( BusEventType.ACTIVITY_END, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate } );
127
- Tracing.addEventEnd( { id, details: state.activityOutput, executionContext } );
128
- return state.activityOutput;
104
+ Tracing.addEventEnd( { id, details: output, executionContext } );
105
+ return {
106
+ [ACTIVITY_WRAPPER_VERSION_FIELD]: 1,
107
+ output,
108
+ aggregations: state.attributes.length > 0 ? aggregateAttributes( state.attributes ) : null
109
+ };
129
110
 
130
111
  } catch ( error ) {
131
112
  messageBus.emit( BusEventType.ACTIVITY_ERROR, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate, error } );
132
113
  Tracing.addEventError( { id, details: error, executionContext } );
133
114
 
115
+ await sendAggregationsViaSignal();
116
+
134
117
  throw error;
135
118
  } finally {
136
119
  clearInterval( state.heartbeat );
@@ -1,11 +1,11 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { BusEventType, Signal } from '#consts';
2
+ import { ACTIVITY_WRAPPER_VERSION_FIELD, BusEventType, Signal } from '#consts';
3
+ import { Attribute } from '#trace_attribute';
3
4
 
4
5
  const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
5
6
  const workflowHandleMock = vi.hoisted( () => ( { signal: vi.fn() } ) );
6
7
  const getHandleMock = vi.hoisted( () => vi.fn( () => workflowHandleMock ) );
7
8
  const clientConstructorMock = vi.hoisted( () => vi.fn() );
8
- const allSettledWithTimeoutMock = vi.hoisted( () => vi.fn().mockResolvedValue( [] ) );
9
9
  const logWarnMock = vi.hoisted( () => vi.fn() );
10
10
 
11
11
  const heartbeatMock = vi.fn();
@@ -44,11 +44,6 @@ vi.mock( '#async_storage', () => ( {
44
44
  }
45
45
  } ) );
46
46
 
47
- vi.mock( '#utils', async importOriginal => {
48
- const actual = await importOriginal();
49
- return { ...actual, allSettledWithTimeout: allSettledWithTimeoutMock };
50
- } );
51
-
52
47
  vi.mock( '#logger', () => ( {
53
48
  createChildLogger: () => ( { warn: logWarnMock } )
54
49
  } ) );
@@ -85,9 +80,6 @@ vi.mock( '../configs.js', () => ( {
85
80
  get activityHeartbeatIntervalMs() {
86
81
  return parseInt( process.env.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS || '120000', 10 );
87
82
  },
88
- get enableAttributeSignalEmission() {
89
- return process.env.OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION === 'true';
90
- },
91
83
  get namespace() {
92
84
  return process.env.TEMPORAL_NAMESPACE || 'default';
93
85
  }
@@ -104,18 +96,21 @@ const makeInput = () => ( {
104
96
  headers: {}
105
97
  } );
106
98
 
99
+ const httpRequestAttribute = {
100
+ type: Attribute.HTTPRequestCount.TYPE,
101
+ url: 'https://api.example.test/items',
102
+ requestId: 'req-1'
103
+ };
104
+
107
105
  describe( 'ActivityExecutionInterceptor', () => {
108
106
  beforeEach( () => {
109
107
  vi.clearAllMocks();
110
- allSettledWithTimeoutMock.mockResolvedValue( [] );
111
108
  workflowHandleMock.signal.mockResolvedValue( undefined );
112
109
  vi.useFakeTimers();
113
110
  vi.resetModules();
114
111
  // Default: heartbeat enabled with 50ms interval for fast tests
115
112
  vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED', 'true' );
116
113
  vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS', '50' );
117
- // Default: attribute signal emission enabled so existing tests can verify signal-sending behaviour
118
- vi.stubEnv( 'OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION', 'true' );
119
114
  } );
120
115
 
121
116
  afterEach( () => {
@@ -132,7 +127,11 @@ describe( 'ActivityExecutionInterceptor', () => {
132
127
  vi.advanceTimersByTime( 0 );
133
128
  const output = await promise;
134
129
 
135
- expect( output ).toEqual( { result: 'ok' } );
130
+ expect( output ).toEqual( {
131
+ output: { result: 'ok' },
132
+ aggregations: null,
133
+ [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
134
+ } );
136
135
  expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.objectContaining( {
137
136
  id: 'act-1', name: 'myWorkflow#myStep', kind: 'step', workflowId: 'wf-1', workflowName: 'myWorkflow'
138
137
  } ) );
@@ -142,17 +141,17 @@ describe( 'ActivityExecutionInterceptor', () => {
142
141
  expect( addEventStartMock ).toHaveBeenCalledOnce();
143
142
  expect( addEventEndMock ).toHaveBeenCalledOnce();
144
143
  expect( addEventErrorMock ).not.toHaveBeenCalled();
145
- expect( clientConstructorMock ).toHaveBeenCalledWith( { connection: undefined, namespace: 'default' } );
144
+ expect( clientConstructorMock ).not.toHaveBeenCalled();
146
145
  expect( runWithContextMock ).toHaveBeenCalledWith(
147
146
  expect.any( Function ),
148
147
  expect.objectContaining( {
149
148
  parentId: 'act-1',
150
149
  executionContext: { workflowId: 'wf-1' },
151
150
  workflowFilename: '/workflows/myWorkflow.js',
152
- sendAttributeSignal: expect.any( Function )
151
+ addAttribute: expect.any( Function )
153
152
  } )
154
153
  );
155
- expect( getHandleMock ).toHaveBeenCalledWith( 'wf-1' );
154
+ expect( getHandleMock ).not.toHaveBeenCalled();
156
155
  } );
157
156
 
158
157
  it( 'handles next returning a non-Promise value', async () => {
@@ -160,36 +159,45 @@ describe( 'ActivityExecutionInterceptor', () => {
160
159
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
161
160
  const next = vi.fn( () => ( { result: 'sync' } ) );
162
161
 
163
- await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'sync' } );
162
+ await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
163
+ output: { result: 'sync' },
164
+ aggregations: null,
165
+ [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
166
+ } );
164
167
 
165
- expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
166
168
  expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
167
169
  expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'sync' }, executionContext: { workflowId: 'wf-1' } } );
168
170
  expect( addEventErrorMock ).not.toHaveBeenCalled();
169
171
  } );
170
172
 
171
- it( 'handles signal flush timeout after successful execution', async () => {
172
- const timeoutError = Object.assign( new Error( 'timeout' ), { isTimeout: true } );
173
- allSettledWithTimeoutMock.mockRejectedValueOnce( timeoutError );
173
+ it( 'does not signal collected attributes after successful execution', async () => {
174
+ runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
175
+ ctx.addAttribute( httpRequestAttribute );
176
+ return fn();
177
+ } );
174
178
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
175
179
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
176
180
  const next = vi.fn().mockResolvedValue( { result: 'ok' } );
177
181
 
178
- await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
182
+ await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
183
+ output: { result: 'ok' },
184
+ aggregations: {
185
+ cost: { total: 0 },
186
+ tokens: { total: 0 },
187
+ httpRequests: { total: 1 }
188
+ },
189
+ [ACTIVITY_WRAPPER_VERSION_FIELD]: 1
190
+ } );
179
191
 
180
- expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
181
- expect( logWarnMock ).toHaveBeenCalledWith(
182
- 'Some usage/cost attributes were missed because not all activity signals were sent to the workflow',
183
- { workflowId: 'wf-1', workflowName: 'myWorkflow', activityId: 'act-1', activityName: 'myWorkflow#myStep' }
184
- );
185
- expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
186
- expect( addEventEndMock ).toHaveBeenCalledOnce();
187
- expect( addEventErrorMock ).not.toHaveBeenCalled();
192
+ expect( workflowHandleMock.signal ).not.toHaveBeenCalled();
193
+ expect( clientConstructorMock ).not.toHaveBeenCalled();
188
194
  } );
189
195
 
190
- it( 'handles signal flush timeout after failed execution', async () => {
191
- const timeoutError = Object.assign( new Error( 'timeout' ), { isTimeout: true } );
192
- allSettledWithTimeoutMock.mockRejectedValueOnce( timeoutError );
196
+ it( 'signals collected aggregations after failed execution', async () => {
197
+ runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
198
+ ctx.addAttribute( httpRequestAttribute );
199
+ return fn();
200
+ } );
193
201
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
194
202
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
195
203
  const error = new Error( 'step failed' );
@@ -197,49 +205,49 @@ describe( 'ActivityExecutionInterceptor', () => {
197
205
 
198
206
  await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
199
207
 
200
- expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
201
- expect( logWarnMock ).toHaveBeenCalledWith(
202
- 'Some usage/cost attributes were missed because not all activity signals were sent to the workflow',
203
- { workflowId: 'wf-1', workflowName: 'myWorkflow', activityId: 'act-1', activityName: 'myWorkflow#myStep' }
204
- );
208
+ expect( clientConstructorMock ).toHaveBeenCalledWith( { connection: undefined, namespace: 'default' } );
209
+ expect( getHandleMock ).toHaveBeenCalledWith( 'wf-1' );
210
+ expect( workflowHandleMock.signal ).toHaveBeenCalledWith( Signal.SEND_AGGREGATIONS, {
211
+ cost: { total: 0 },
212
+ tokens: { total: 0 },
213
+ httpRequests: { total: 1 }
214
+ } );
205
215
  expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, expect.objectContaining( { error } ) );
206
216
  expect( addEventErrorMock ).toHaveBeenCalledOnce();
207
217
  expect( addEventEndMock ).not.toHaveBeenCalled();
208
218
  } );
209
219
 
210
- it( 'exposes sendAttributeSignal in activity context', async () => {
211
- const attribute = { setActivity: vi.fn() };
212
- runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
213
- ctx.sendAttributeSignal( attribute );
214
- return fn();
215
- } );
220
+ it( 'does not send fallback signal when failed execution collected no attributes', async () => {
216
221
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
217
222
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
218
- const next = vi.fn().mockResolvedValue( { result: 'ok' } );
223
+ const next = vi.fn().mockRejectedValue( new Error( 'step failed' ) );
219
224
 
220
- await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
225
+ await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
221
226
 
222
- expect( attribute.setActivity ).toHaveBeenCalledWith( 'act-1', 'myWorkflow#myStep' );
223
- expect( workflowHandleMock.signal ).toHaveBeenCalledWith( Signal.ADD_ATTRIBUTE, attribute );
224
- expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [ expect.any( Promise ) ], 30_000 );
227
+ expect( workflowHandleMock.signal ).not.toHaveBeenCalled();
228
+ expect( clientConstructorMock ).not.toHaveBeenCalled();
225
229
  } );
226
230
 
227
- it( 'does not signal when OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION is false', async () => {
228
- vi.stubEnv( 'OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION', 'false' );
229
- const attribute = { setActivity: vi.fn() };
231
+ it( 'logs when fallback attribute signal fails', async () => {
232
+ const signalError = new Error( 'signal failed' );
233
+ workflowHandleMock.signal.mockRejectedValueOnce( signalError );
230
234
  runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
231
- ctx.sendAttributeSignal( attribute );
235
+ ctx.addAttribute( httpRequestAttribute );
232
236
  return fn();
233
237
  } );
234
238
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
235
239
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
236
- const next = vi.fn().mockResolvedValue( { result: 'ok' } );
240
+ const next = vi.fn().mockRejectedValue( new Error( 'step failed' ) );
237
241
 
238
- await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
242
+ await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
239
243
 
240
- expect( attribute.setActivity ).not.toHaveBeenCalled();
241
- expect( workflowHandleMock.signal ).not.toHaveBeenCalled();
242
- expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
244
+ expect( logWarnMock ).toHaveBeenCalledWith( `Signal "${Signal.SEND_AGGREGATIONS}" failed`, expect.objectContaining( {
245
+ message: 'signal failed',
246
+ activityId: 'act-1',
247
+ activityName: 'myWorkflow#myStep',
248
+ workflowId: 'wf-1',
249
+ workflowName: 'myWorkflow'
250
+ } ) );
243
251
  } );
244
252
 
245
253
  it( 'records trace error event on failed execution', async () => {
@@ -6,5 +6,9 @@ const __dirname = dirname( fileURLToPath( import.meta.url ) );
6
6
 
7
7
  export const initInterceptors = ( { activities, workflows, connection } ) => ( {
8
8
  workflowModules: [ join( __dirname, './interceptors/workflow.js' ) ],
9
- activityInbound: [ () => new ActivityExecutionInterceptor( { activities, workflows, connection } ) ]
9
+ activity: [
10
+ () => ( {
11
+ inbound: new ActivityExecutionInterceptor( { activities, workflows, connection } )
12
+ } )
13
+ ]
10
14
  } );