@outputai/core 0.5.2 → 0.5.3-next.69060d7.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.2",
3
+ "version": "0.5.3-next.69060d7.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -66,6 +66,7 @@
66
66
  "#errors": "./src/errors.js",
67
67
  "#logger": "./src/logger.js",
68
68
  "#utils": "./src/utils/index.js",
69
+ "#internal_utils/*": "./src/internal_utils/*.js",
69
70
  "#tracing": "./src/tracing/internal_interface.js",
70
71
  "#trace_attribute": "./src/tracing/trace_attribute.js",
71
72
  "#async_storage": "./src/async_storage.js",
package/src/consts.js CHANGED
@@ -1,10 +1,12 @@
1
1
  export const ACTIVITY_GET_TRACE_DESTINATIONS = '__internal#getTraceDestinations';
2
2
  export const ACTIVITY_OPTIONS_FILENAME = '__activity_options.js';
3
3
  export const ACTIVITY_SEND_HTTP_REQUEST = '__internal#sendHttpRequest';
4
+ export const ACTIVITY_WRAPPER_VERSION_FIELD = '__output_activity_wrapper_version';
4
5
  export const METADATA_ACCESS_SYMBOL = Symbol( '__metadata' );
5
6
  export const SHARED_STEP_PREFIX = '$shared';
6
7
  export const WORKFLOW_CATALOG = '$catalog';
7
8
  export const WORKFLOWS_INDEX_FILENAME = '__workflows_entrypoint.js';
9
+ export const WORKFLOW_WRAPPER_VERSION_FIELD = '__output_workflow_wrapper_version';
8
10
 
9
11
  export const ComponentType = {
10
12
  EVALUATOR: 'evaluator',
@@ -34,7 +36,7 @@ export const BusEventType = {
34
36
  };
35
37
 
36
38
  export const Signal = {
37
- ADD_ATTRIBUTE: 'add_attribute'
39
+ SEND_AGGREGATIONS: 'send_aggregations'
38
40
  };
39
41
 
40
42
  export const WorkflowSpecialOutput = {
@@ -3,11 +3,19 @@ import { proxyActivities, inWorkflowContext, executeChild, workflowInfo, uuid4,
3
3
  import { defineSignal, setHandler } from '@temporalio/workflow';
4
4
  import { validateWorkflow } from './validations/static.js';
5
5
  import { validateWithSchema } from './validations/runtime.js';
6
- import { SHARED_STEP_PREFIX, ACTIVITY_GET_TRACE_DESTINATIONS, METADATA_ACCESS_SYMBOL, Signal } from '#consts';
6
+ import {
7
+ ACTIVITY_GET_TRACE_DESTINATIONS,
8
+ ACTIVITY_WRAPPER_VERSION_FIELD,
9
+ METADATA_ACCESS_SYMBOL,
10
+ SHARED_STEP_PREFIX,
11
+ Signal,
12
+ WORKFLOW_WRAPPER_VERSION_FIELD
13
+ } from '#consts';
7
14
  import { deepMerge, setMetadata, toUrlSafeBase64 } from '#utils';
8
15
  import { FatalError, ValidationError } from '#errors';
9
16
  import { Context } from './workflow_context.js';
10
- import { aggregateAttributes } from './aggregations.js';
17
+ import { aggregateAttributes, mergeAggregations } from '#internal_utils/aggregations';
18
+ import { extractErrorDetail } from '#internal_utils/errors';
11
19
 
12
20
  const defaultOptions = {
13
21
  activityOptions: {
@@ -24,8 +32,10 @@ const defaultOptions = {
24
32
  disableTrace: false
25
33
  };
26
34
 
27
- export const extractErrorDetail = ( e, key ) =>
28
- e ? ( e.details?.find?.( d => d[key] )?.[key] ?? extractErrorDetail( e.cause, key ) ) : null;
35
+ /**
36
+ * Checks if the activity result uses the internal wrapper
37
+ */
38
+ const isActivityResultWrapped = result => result?.[ACTIVITY_WRAPPER_VERSION_FIELD] > 0;
29
39
 
30
40
  export function workflow( { name, description, inputSchema, outputSchema, fn, options = {}, aliases = [] } ) {
31
41
  validateWorkflow( { name, description, inputSchema, outputSchema, fn, options, aliases } );
@@ -56,7 +66,6 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
56
66
  }
57
67
 
58
68
  const { workflowId, runId, memo, startTime } = workflowInfo();
59
-
60
69
  const context = Context.build( { workflowId, runId, continueAsNew, isContinueAsNewSuggested: () => workflowInfo().continueAsNewSuggested } );
61
70
 
62
71
  // Root workflows will not have the execution context yet, since it is set here.
@@ -78,32 +87,82 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
78
87
  activityOptions: memo.activityOptions ?? activityOptions // Also preserve the original activity options
79
88
  } );
80
89
 
81
- // Run the internal activity to retrieve the workflow trace destinations (only for root workflows, not nested)
82
- const traceDestinations = isRoot ? ( await steps[ACTIVITY_GET_TRACE_DESTINATIONS]( executionContext ) ) : null;
90
+ /**
91
+ * Run the internal activity to retrieve the workflow trace destinations
92
+ * This only happens at the root workflow because nested share the same trace file
93
+ * @IMPORTANT Keep support for deprecated non-wrapped activity result to allow for Temporal replays.
94
+ * @TODO [OUT-468]
95
+ */
96
+ const getTraceDestinations = async () => {
97
+ const result = await steps[ACTIVITY_GET_TRACE_DESTINATIONS]( executionContext );
98
+ return isActivityResultWrapped( result ) ? result.output : result;
99
+ };
100
+
101
+ // Creates the result wrapper with information about the workflow
102
+ const workflowResult = {
103
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
104
+ aggregations: null,
105
+ ...( isRoot && {
106
+ trace: {
107
+ destinations: await getTraceDestinations()
108
+ }
109
+ } )
110
+ };
111
+
112
+ // Combine aggregations in the workflow result aggregations, mutating it
113
+ const mergeAggregationsInWorkflowResult = aggregations => {
114
+ workflowResult.aggregations = mergeAggregations( workflowResult.aggregations, aggregations );
115
+ };
83
116
 
84
- const attributes = [];
85
- setHandler( defineSignal( Signal.ADD_ATTRIBUTE ), e => attributes.push( e ) );
117
+ setHandler( defineSignal( Signal.SEND_AGGREGATIONS ), aggregations => {
118
+ mergeAggregationsInWorkflowResult( aggregations );
119
+ } );
120
+
121
+ /**
122
+ * @IMPORTANT Keep support for deprecated add_attribute Signal to allow for Temporal replays.
123
+ * @TODO This can be removed 30days after this release
124
+ */
125
+ setHandler( defineSignal( 'add_attribute' ), attribute => {
126
+ mergeAggregationsInWorkflowResult( aggregateAttributes( [ attribute ] ) );
127
+ } );
128
+
129
+ /**
130
+ * Invoke a step and unwraps the result to extract and merge "aggregations" and return only the output.
131
+ *
132
+ * @IMPORTANT Keep support for deprecated non-wrapped activity result to allow for Temporal replays.
133
+ * @TODO [OUT-468]
134
+ * @param {Function} step
135
+ * @param {...any} args
136
+ * @returns {any} The step "output"
137
+ */
138
+ const callStepAndUnwrapResult = async ( step, ...args ) => {
139
+ const result = await step( ...args );
140
+ if ( !isActivityResultWrapped( result ) ) {
141
+ return result;
142
+ }
143
+ const { output, aggregations } = result;
144
+ if ( aggregations ) {
145
+ mergeAggregationsInWorkflowResult( aggregations );
146
+ }
147
+ return output;
148
+ };
86
149
 
87
150
  try {
88
151
  // validation comes after setting memo to have that info already set for interceptor even if validations fail
89
152
  validateWithSchema( inputSchema, input, `Workflow ${name} input` );
90
153
 
91
154
  const dispatchers = {
92
- invokeStep: async ( stepName, input, options ) => steps[`${name}#${stepName}`]( input, options ),
93
- invokeSharedStep: async ( stepName, input, options ) => steps[`${SHARED_STEP_PREFIX}#${stepName}`]( input, options ),
94
- invokeEvaluator: async ( evaluatorName, input, options ) => steps[`${name}#${evaluatorName}`]( input, options ),
95
- invokeSharedEvaluator: async ( evaluatorName, input, options ) => steps[`${SHARED_STEP_PREFIX}#${evaluatorName}`]( input, options ),
96
-
97
- /**
98
- * Start a child workflow
99
- *
100
- * @param {string} childName
101
- * @param {unknown} input
102
- * @param {object} extra
103
- * @param {boolean} extra.detached
104
- * @param {import('@temporalio/workflow').ActivityOptions} extra.options
105
- * @returns {Promise<unknown>}
106
- */
155
+ /* This are shortcuts to invoke activities as steps/evaluators both shared and non shared */
156
+ invokeStep: async ( stepName, input, options ) =>
157
+ callStepAndUnwrapResult( steps[`${name}#${stepName}`], input, options ),
158
+ invokeSharedStep: async ( stepName, input, options ) =>
159
+ callStepAndUnwrapResult( steps[`${SHARED_STEP_PREFIX}#${stepName}`], input, options ),
160
+ invokeEvaluator: async ( evaluatorName, input, options ) =>
161
+ callStepAndUnwrapResult( steps[`${name}#${evaluatorName}`], input, options ),
162
+ invokeSharedEvaluator: async ( evaluatorName, input, options ) =>
163
+ callStepAndUnwrapResult( steps[`${SHARED_STEP_PREFIX}#${evaluatorName}`], input, options ),
164
+
165
+ // Start a new child workflow
107
166
  startWorkflow: async ( childName, input, extra = {} ) => {
108
167
  try {
109
168
  const result = await executeChild( childName, {
@@ -116,33 +175,43 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
116
175
  ...( extra?.options?.activityOptions && { activityOptions: deepMerge( activityOptions, extra.options.activityOptions ) } )
117
176
  }
118
177
  } );
119
- attributes.push( ...( result.attributes ?? [] ) );
178
+ /**
179
+ * @IMPORTANT Keep support for deprecated ".attributes" from workflow results to allow for Temporal replays.
180
+ * @TODO [OUT-468]
181
+ */
182
+ if ( result?.attributes ) {
183
+ mergeAggregationsInWorkflowResult( aggregateAttributes( result.attributes ) );
184
+ }
185
+ if ( result?.aggregations ) {
186
+ mergeAggregationsInWorkflowResult( result.aggregations );
187
+ }
120
188
  return result.output;
121
189
  } catch ( error ) {
122
- attributes.push( ...( extractErrorDetail( error, 'attributes' ) ?? [] ) );
190
+ /**
191
+ * @IMPORTANT Keep support for deprecated ".attributes" from workflow errors to allow for Temporal replays.
192
+ * @TODO [OUT-468]
193
+ */
194
+ const attributesFromError = extractErrorDetail( error, 'attributes' );
195
+ if ( attributesFromError ) {
196
+ mergeAggregationsInWorkflowResult( aggregateAttributes( attributesFromError ) );
197
+ }
198
+ const aggregationsFromError = extractErrorDetail( error, 'aggregations' );
199
+ if ( aggregationsFromError ) {
200
+ mergeAggregationsInWorkflowResult( aggregationsFromError );
201
+ }
123
202
  throw error;
124
203
  }
125
204
  }
126
205
  };
127
206
 
128
- const output = await fn.call( dispatchers, input, context );
207
+ workflowResult.output = await fn.call( dispatchers, input, context );
129
208
 
130
- validateWithSchema( outputSchema, output, `Workflow ${name} output` );
131
-
132
- if ( isRoot ) {
133
- // Append the trace info to the result of the workflow
134
- return { output, trace: { destinations: traceDestinations }, attributes, aggregations: aggregateAttributes( attributes ) };
135
- }
209
+ validateWithSchema( outputSchema, workflowResult.output, `Workflow ${name} output` );
136
210
 
137
- return { output, attributes };
211
+ return workflowResult;
138
212
  } catch ( e ) {
139
- // Append the extra info as metadata of the error, so it can be read by the interceptor.
140
- e[METADATA_ACCESS_SYMBOL] = { ...( e[METADATA_ACCESS_SYMBOL] ?? {} ), attributes };
141
- // if it is roo also add trace/aggregations
142
- if ( isRoot ) {
143
- e[METADATA_ACCESS_SYMBOL].trace = { destinations: traceDestinations };
144
- e[METADATA_ACCESS_SYMBOL].aggregations = aggregateAttributes( attributes );
145
- }
213
+ // Append the result as metadata of the error, so it can be read by the interceptor.
214
+ e[METADATA_ACCESS_SYMBOL] = { ...( e[METADATA_ACCESS_SYMBOL] ?? {} ), ...workflowResult };
146
215
  throw e;
147
216
  }
148
217
  };
@@ -0,0 +1,254 @@
1
+ import { ACTIVITY_GET_TRACE_DESTINATIONS, METADATA_ACCESS_SYMBOL, WORKFLOW_WRAPPER_VERSION_FIELD } from '#consts';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { z } from 'zod';
4
+
5
+ const inWorkflowContextMock = vi.hoisted( () => vi.fn( () => true ) );
6
+ const defineSignalMock = vi.hoisted( () => vi.fn( name => name ) );
7
+ const setHandlerMock = vi.hoisted( () => vi.fn() );
8
+ const traceDestinationsStepMock = vi.fn().mockResolvedValue( { local: '/tmp/trace' } );
9
+ const executeChildMock = vi.fn().mockResolvedValue( undefined );
10
+ const continueAsNewMock = vi.fn().mockResolvedValue( undefined );
11
+
12
+ const createStepsProxy = ( stepSpy = vi.fn() ) =>
13
+ new Proxy( {}, {
14
+ get: ( _, prop ) => {
15
+ if ( prop === ACTIVITY_GET_TRACE_DESTINATIONS ) {
16
+ return traceDestinationsStepMock;
17
+ }
18
+ if ( typeof prop === 'string' && prop.includes( '#' ) ) {
19
+ return stepSpy;
20
+ }
21
+ return vi.fn();
22
+ }
23
+ } );
24
+
25
+ const proxyActivitiesMock = vi.fn( () => createStepsProxy() );
26
+
27
+ const workflowInfoReturn = {
28
+ workflowId: 'wf-test-123',
29
+ workflowType: 'test_wf',
30
+ memo: {},
31
+ startTime: new Date( '2025-01-01T00:00:00Z' ),
32
+ continueAsNewSuggested: false
33
+ };
34
+ const workflowInfoMock = vi.fn( () => ( { ...workflowInfoReturn } ) );
35
+
36
+ vi.mock( '@temporalio/workflow', () => ( {
37
+ proxyActivities: ( ...args ) => proxyActivitiesMock( ...args ),
38
+ inWorkflowContext: inWorkflowContextMock,
39
+ executeChild: ( ...args ) => executeChildMock( ...args ),
40
+ workflowInfo: workflowInfoMock,
41
+ uuid4: () => '550e8400e29b41d4a716446655440000',
42
+ ParentClosePolicy: { TERMINATE: 'TERMINATE', ABANDON: 'ABANDON' },
43
+ ChildWorkflowFailure: class ChildWorkflowFailure extends Error {
44
+ constructor( message, cause ) {
45
+ super( message );
46
+ this.name = 'ChildWorkflowFailure';
47
+ this.cause = cause;
48
+ }
49
+ },
50
+ continueAsNew: continueAsNewMock,
51
+ defineSignal: ( ...args ) => defineSignalMock( ...args ),
52
+ setHandler: ( ...args ) => setHandlerMock( ...args )
53
+ } ) );
54
+
55
+ vi.mock( '#consts', async importOriginal => {
56
+ const actual = await importOriginal();
57
+ return {
58
+ ...actual,
59
+ SHARED_STEP_PREFIX: '__shared',
60
+ ACTIVITY_GET_TRACE_DESTINATIONS: '__internal#getTraceDestinations'
61
+ };
62
+ } );
63
+
64
+ describe( 'workflow() replay compatibility', () => {
65
+ beforeEach( () => {
66
+ vi.clearAllMocks();
67
+ inWorkflowContextMock.mockReturnValue( true );
68
+ defineSignalMock.mockImplementation( name => name );
69
+ workflowInfoReturn.memo = {};
70
+ workflowInfoMock.mockReturnValue( { ...workflowInfoReturn } );
71
+ proxyActivitiesMock.mockImplementation( () => createStepsProxy() );
72
+ traceDestinationsStepMock.mockResolvedValue( { local: '/tmp/trace' } );
73
+ } );
74
+
75
+ it( 'preserves old plain trace destination activity results', async () => {
76
+ const { workflow } = await import( './workflow.js' );
77
+
78
+ const wf = workflow( {
79
+ name: 'root_wf',
80
+ description: 'Root',
81
+ inputSchema: z.object( {} ),
82
+ outputSchema: z.object( { v: z.number() } ),
83
+ fn: async () => ( { v: 42 } )
84
+ } );
85
+
86
+ const result = await wf( {} );
87
+ expect( result ).toEqual( {
88
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
89
+ output: { v: 42 },
90
+ trace: { destinations: { local: '/tmp/trace' } },
91
+ aggregations: null
92
+ } );
93
+ } );
94
+
95
+ it( 'converts old add_attribute signals into aggregations', async () => {
96
+ const { workflow } = await import( './workflow.js' );
97
+ const { Attribute } = await import( '#trace_attribute' );
98
+ const handlers = { addAttribute: () => {} };
99
+ setHandlerMock.mockImplementation( ( signalName, handler ) => {
100
+ if ( signalName === 'add_attribute' ) {
101
+ handlers.addAttribute = handler;
102
+ }
103
+ } );
104
+
105
+ const httpRequest = {
106
+ type: Attribute.HTTPRequestCount.TYPE,
107
+ url: 'https://api.example.test/items',
108
+ requestId: 'req-1'
109
+ };
110
+ const httpCost = {
111
+ type: Attribute.HTTPRequestCost.TYPE,
112
+ url: 'https://api.example.test/items',
113
+ requestId: 'req-1',
114
+ total: 2.5
115
+ };
116
+ const llmUsage = {
117
+ type: Attribute.LLMUsage.TYPE,
118
+ modelId: 'gpt-4o',
119
+ total: 0.25,
120
+ usage: [
121
+ { type: 'input', ppm: 5, amount: 20_000, total: 0.1 },
122
+ { type: 'output', ppm: 30, amount: 5_000, total: 0.15 }
123
+ ],
124
+ tokensUsed: 25_000
125
+ };
126
+
127
+ const wf = workflow( {
128
+ name: 'attr_wf',
129
+ description: 'Attributes',
130
+ inputSchema: z.object( {} ),
131
+ outputSchema: z.object( { ok: z.boolean() } ),
132
+ fn: async () => {
133
+ handlers.addAttribute( httpRequest );
134
+ handlers.addAttribute( httpCost );
135
+ handlers.addAttribute( llmUsage );
136
+ return { ok: true };
137
+ }
138
+ } );
139
+
140
+ const result = await wf( {} );
141
+ expect( result ).not.toHaveProperty( 'attributes' );
142
+ expect( result.aggregations ).toEqual( {
143
+ cost: { total: 2.75 },
144
+ tokens: {
145
+ total: 25_000,
146
+ input: 20_000,
147
+ output: 5_000
148
+ },
149
+ httpRequests: { total: 1 }
150
+ } );
151
+ } );
152
+
153
+ it( 'preserves old plain activity results from steps', async () => {
154
+ const stepSpy = vi.fn().mockResolvedValue( { legacy: true } );
155
+ proxyActivitiesMock.mockImplementation( () => createStepsProxy( stepSpy ) );
156
+ const { workflow } = await import( './workflow.js' );
157
+
158
+ const wf = workflow( {
159
+ name: 'legacy_step_wf',
160
+ description: 'Legacy step result',
161
+ inputSchema: z.object( {} ),
162
+ outputSchema: z.object( { legacy: z.boolean() } ),
163
+ async fn() {
164
+ return this.invokeStep( 'myStep', { foo: 1 } );
165
+ }
166
+ } );
167
+
168
+ const result = await wf( {} );
169
+ expect( result.output ).toEqual( { legacy: true } );
170
+ expect( result.aggregations ).toEqual( null );
171
+ } );
172
+
173
+ it( 'converts old child workflow attributes into parent aggregations', async () => {
174
+ const { workflow } = await import( './workflow.js' );
175
+ const { Attribute } = await import( '#trace_attribute' );
176
+ const childAttribute = {
177
+ type: Attribute.LLMUsage.TYPE,
178
+ modelId: 'gpt-4o',
179
+ total: 0.4,
180
+ tokensUsed: 20,
181
+ usage: [
182
+ { type: 'input', ppm: 10, amount: 20, total: 0.4 }
183
+ ]
184
+ };
185
+ executeChildMock.mockResolvedValueOnce( {
186
+ output: { child: 'ok' },
187
+ attributes: [ childAttribute ]
188
+ } );
189
+
190
+ const wf = workflow( {
191
+ name: 'merge_child_wf',
192
+ description: 'Merge child attributes',
193
+ inputSchema: z.object( {} ),
194
+ outputSchema: z.object( { child: z.string() } ),
195
+ async fn() {
196
+ return this.startWorkflow( 'child_wf', { id: 1 } );
197
+ }
198
+ } );
199
+
200
+ const result = await wf( {} );
201
+ expect( result ).toEqual( {
202
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
203
+ output: { child: 'ok' },
204
+ trace: { destinations: { local: '/tmp/trace' } },
205
+ aggregations: {
206
+ cost: { total: 0.4 },
207
+ tokens: {
208
+ total: 20,
209
+ input: 20
210
+ },
211
+ httpRequests: { total: 0 }
212
+ }
213
+ } );
214
+ } );
215
+
216
+ it( 'converts old child workflow error attributes into parent error metadata aggregations', async () => {
217
+ const { workflow } = await import( './workflow.js' );
218
+ const { ChildWorkflowFailure } = await import( '@temporalio/workflow' );
219
+ const { Attribute } = await import( '#trace_attribute' );
220
+ const childAttribute = {
221
+ type: Attribute.HTTPRequestCost.TYPE,
222
+ url: 'https://api.example.test',
223
+ requestId: 'req-child',
224
+ total: 2
225
+ };
226
+ const childError = new ChildWorkflowFailure( 'child failed', {
227
+ message: 'Child workflow execution failed',
228
+ details: [ { attributes: [ childAttribute ] } ]
229
+ } );
230
+ executeChildMock.mockRejectedValueOnce( childError );
231
+
232
+ const wf = workflow( {
233
+ name: 'child_error_wf',
234
+ description: 'Child error attributes',
235
+ inputSchema: z.object( {} ),
236
+ outputSchema: z.object( {} ),
237
+ async fn() {
238
+ await this.startWorkflow( 'child_wf', { id: 1 } );
239
+ return {};
240
+ }
241
+ } );
242
+
243
+ await expect( wf( {} ) ).rejects.toThrow( 'child failed' );
244
+ expect( childError[METADATA_ACCESS_SYMBOL] ).toEqual( {
245
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
246
+ trace: { destinations: { local: '/tmp/trace' } },
247
+ aggregations: {
248
+ cost: { total: 2 },
249
+ tokens: { total: 0 },
250
+ httpRequests: { total: 0 }
251
+ }
252
+ } );
253
+ } );
254
+ } );