@outputai/core 0.13.1-next.a28b894.0 → 0.14.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.13.1-next.a28b894.0",
3
+ "version": "0.14.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "engines": {
@@ -61,6 +61,18 @@ export const flattenPrototypeChain = ( target, depth = 0, result = [], maxDepth
61
61
  !target || depth >= maxDepth ? result :
62
62
  flattenPrototypeChain( Object.getPrototypeOf( target ), depth + 1, result.concat( target ), maxDepth );
63
63
 
64
+ // Realm agnostic check for DOMException, works for AbortController errors even on environments without DOMExceptions
65
+ export const isDomException = target => Object.prototype.toString.call( target ) === '[object DOMException]';
66
+
67
+ const evaluateSpecialObjectsKeysList = target => {
68
+ // DOMException has a special serialization because it carries a bunch of legacy constant fields
69
+ // https://developer.mozilla.org/en-US/docs/Web/API/DOMException
70
+ if ( isDomException( target ) ) {
71
+ return [ 'name', 'message', 'code', 'cause', 'stack' ];
72
+ }
73
+ return null;
74
+ };
75
+
64
76
  /**
65
77
  * Define the best "name" for an Error object
66
78
  * Rules: Assigned .name property > inherited .name if not "Error" > constructor.name
@@ -176,6 +188,8 @@ const serializeValue = ( target, options, state = { depth: 0, seen: new GlobalCo
176
188
  const prototypes = flattenPrototypeChain( target );
177
189
  const receiver = target;
178
190
 
191
+ const strictKeysList = evaluateSpecialObjectsKeysList( target );
192
+
179
193
  const props = prototypes.reduce( ( projection, proto ) => {
180
194
  if ( Object.keys( projection ).length > MAX_OBJECT_KEYS ) {
181
195
  return projection;
@@ -187,6 +201,10 @@ const serializeValue = ( target, options, state = { depth: 0, seen: new GlobalCo
187
201
  continue;
188
202
  }
189
203
 
204
+ if ( Array.isArray( strictKeysList ) && !strictKeysList.includes( key ) ) {
205
+ continue;
206
+ }
207
+
190
208
  const sanitizedKey = truncateString( key, MAX_OBJECT_KEY_LEN );
191
209
  if ( Object.hasOwn( projection, sanitizedKey ) ) { // do not overwrite similar keys
192
210
  continue;
@@ -212,7 +230,7 @@ const serializeValue = ( target, options, state = { depth: 0, seen: new GlobalCo
212
230
  if ( !options.ignoredKeys.includes( 'name' ) && target instanceof Error ) {
213
231
  const name = resolveErrorName( target );
214
232
  if ( name !== undefined ) {
215
- props.name = name;
233
+ props.name = typeof name === 'string' ? truncateString( name ) : serializeValue( name, options, nextState );
216
234
  }
217
235
  }
218
236
 
@@ -1,6 +1,7 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import {
3
3
  flattenPrototypeChain,
4
+ isDomException,
4
5
  resolveErrorName,
5
6
  serializeError,
6
7
  truncateArray,
@@ -43,6 +44,33 @@ describe( 'error serializer helpers', () => {
43
44
  } );
44
45
  } );
45
46
 
47
+ describe( 'isDomException', () => {
48
+ afterEach( () => {
49
+ vi.unstubAllGlobals();
50
+ } );
51
+
52
+ it( 'detects DOMExceptions, including abort reasons, and rejects lookalikes', () => {
53
+ expect( isDomException( new DOMException( 'aborted', 'AbortError' ) ) ).toBe( true );
54
+ expect( isDomException( AbortSignal.abort().reason ) ).toBe( true );
55
+ expect( isDomException( new Error( 'aborted' ) ) ).toBe( false );
56
+ expect( isDomException( { name: 'AbortError', code: 20 } ) ).toBe( false );
57
+ expect( isDomException( undefined ) ).toBe( false );
58
+ expect( isDomException( null ) ).toBe( false );
59
+ } );
60
+
61
+ it( 'detects DOMExceptions where the global is unavailable, as in the workflow sandbox', () => {
62
+ const error = new DOMException( 'aborted', 'AbortError' );
63
+ vi.stubGlobal( 'DOMException', undefined );
64
+
65
+ expect( isDomException( error ) ).toBe( true );
66
+ expect( serializeError( error, { dropKeys: [ 'stack' ] } ) ).toEqual( {
67
+ name: 'AbortError',
68
+ message: 'aborted',
69
+ code: 20
70
+ } );
71
+ } );
72
+ } );
73
+
46
74
  describe( 'resolveErrorName', () => {
47
75
  it( 'uses explicit, inherited, and constructor names in precedence order', () => {
48
76
  class ConstructorNamedError extends Error {}
@@ -194,6 +222,33 @@ describe( 'serializeError', () => {
194
222
  expect( serializeError( /request-\d+/gi ) ).toBe( '/request-\\d+/gi' );
195
223
  } );
196
224
 
225
+ it( 'serializes DOMException values without the legacy prototype constants', () => {
226
+ const error = new DOMException( 'Cancelled by caller', 'AbortError' );
227
+
228
+ expect( serializeError( error ) ).toEqual( {
229
+ name: 'AbortError',
230
+ message: 'Cancelled by caller',
231
+ code: 20,
232
+ stack: expect.stringContaining( 'AbortError: Cancelled by caller' )
233
+ } );
234
+ expect( serializeError( error, { dropKeys: [ /stack/ ] } ) ).toEqual( {
235
+ name: 'AbortError',
236
+ message: 'Cancelled by caller',
237
+ code: 20
238
+ } );
239
+ } );
240
+
241
+ it( 'marks circular DOMException fields and drops properties outside the allowed list', () => {
242
+ const error = new DOMException( 'aborted', 'AbortError' );
243
+ Object.defineProperty( error, 'message', { value: error, enumerable: true } );
244
+ error.attempt = 2;
245
+
246
+ expect( serializeError( { reason: error, sibling: 'preserved' }, { dropKeys: [ 'stack' ] } ) ).toEqual( {
247
+ reason: { name: 'AbortError', message: '[Circular Reference]', code: 20 },
248
+ sibling: 'preserved'
249
+ } );
250
+ } );
251
+
197
252
  it( 'serializes bigint values with an n suffix', () => {
198
253
  expect( serializeError( 42n ) ).toBe( '42n' );
199
254
  expect( serializeError( -42n ) ).toBe( '-42n' );
@@ -298,6 +353,17 @@ describe( 'serializeError', () => {
298
353
  expect( serialized['...'] ).toBe( '[Maximum Object Properties Reached]' );
299
354
  } );
300
355
 
356
+ it( 'serializes the resolved error name instead of returning it raw', () => {
357
+ const circular = new Error( 'boom' );
358
+ circular.name = circular;
359
+ const oversized = new Error( 'boom' );
360
+ oversized.name = 'n'.repeat( 16_385 );
361
+
362
+ expect( serializeError( circular, { dropKeys: [ 'stack' ] } ).name ).toBe( '[Circular Reference]' );
363
+ expect( serializeError( oversized, { dropKeys: [ 'stack' ] } ).name )
364
+ .toBe( `${'n'.repeat( 16_384 )}... [1 more characters omitted]` );
365
+ } );
366
+
301
367
  it( 'retains error identity when properties reach the object limit', () => {
302
368
  class DetailedError extends Error {}
303
369
  const error = new DetailedError( 'failed' );
@@ -40,10 +40,13 @@ export class ActivityExecutionInterceptor {
40
40
 
41
41
  async execute( input, next ) {
42
42
  const activityInfo = activityInfoFn();
43
- const { workflowExecution: { runId }, activityId, activityType, workflowType } = activityInfo;
43
+ const { workflowExecution: { runId }, activityId, attempt, activityType, workflowType } = activityInfo;
44
44
  const { traceInfo, workflowDetails } = headersToObject( input.headers );
45
45
  const outputActivityKind = this.activityKindMap.get( activityType );
46
46
  const workflowFilename = this.workflowsPathMap.get( workflowType );
47
+ // Retries reuse the same activityId, so the attempt is appended to keep each attempt a discrete trace entry.
48
+ // Also prefix with the runId to avoid conflict with child workflows.
49
+ const traceId = `${runId}:${activityId}:${attempt}`;
47
50
 
48
51
  if ( !outputActivityKind ) {
49
52
  throw new Error( `Activity interceptor: activity "${activityType}" was not registered.` );
@@ -58,7 +61,7 @@ export class ActivityExecutionInterceptor {
58
61
 
59
62
  // Adds context accessible information
60
63
  const storageContext = {
61
- parentId: activityId,
64
+ parentId: traceId,
62
65
  outputActivityKind,
63
66
  activityInfo,
64
67
  workflowDetails,
@@ -67,7 +70,7 @@ export class ActivityExecutionInterceptor {
67
70
  };
68
71
 
69
72
  mainEventBus.emit( BusEventType.ACTIVITY_START, { activityInfo, workflowDetails, outputActivityKind } );
70
- Tracing.addEventStart( { id: activityId, name: activityType, kind: outputActivityKind, parentId: runId, details: input.args[0], traceInfo } );
73
+ Tracing.addEventStart( { id: traceId, name: activityType, kind: outputActivityKind, parentId: runId, details: input.args[0], traceInfo } );
71
74
 
72
75
  try {
73
76
  // Sends heartbeat to communicate that activity is still alive
@@ -76,20 +79,20 @@ export class ActivityExecutionInterceptor {
76
79
  const output = await Storage.runWithContext( async _ => next( input ), storageContext );
77
80
 
78
81
  mainEventBus.emit( BusEventType.ACTIVITY_END, { activityInfo, workflowDetails, outputActivityKind } );
79
- Tracing.addEventEnd( { id: activityId, details: output, traceInfo } );
82
+ Tracing.addEventEnd( { id: traceId, details: output, traceInfo } );
80
83
 
81
84
  return output;
82
85
 
83
86
  } catch ( error ) {
84
87
  // Record async completion handoff as a trace end without emitting a bus event.
85
88
  if ( error instanceof CompleteAsyncError ) {
86
- Tracing.addEventEnd( { id: activityId, details: ActivitySpecialOutput.ASYNC_HANDOFF, traceInfo } );
89
+ Tracing.addEventEnd( { id: traceId, details: ActivitySpecialOutput.ASYNC_HANDOFF, traceInfo } );
87
90
  throw error;
88
91
  }
89
92
 
90
93
  const unwrappedError = error instanceof TransparentFatalError ? error.cause : error;
91
94
 
92
- Tracing.addEventError( { id: activityId, details: unwrappedError, traceInfo } );
95
+ Tracing.addEventError( { id: traceId, details: unwrappedError, traceInfo } );
93
96
  mainEventBus.emit( BusEventType.ACTIVITY_ERROR, { activityInfo, workflowDetails, outputActivityKind, error: unwrappedError } );
94
97
 
95
98
  // Native Temporal errors are just re-thrown
@@ -8,6 +8,7 @@ const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async f
8
8
  const activityInfoMock = vi.hoisted( () => ( {
9
9
  workflowExecution: { workflowId: 'wf-1', runId: 'run-1' },
10
10
  activityId: 'act-1',
11
+ attempt: 1,
11
12
  activityType: 'myWorkflow#myStep',
12
13
  workflowType: 'myWorkflow'
13
14
  } ) );
@@ -87,6 +88,8 @@ describe( 'ActivityExecutionInterceptor', () => {
87
88
  beforeEach( () => {
88
89
  vi.clearAllMocks();
89
90
  activityInfoMock.workflowType = 'myWorkflow';
91
+ activityInfoMock.workflowExecution.runId = 'run-1';
92
+ activityInfoMock.attempt = 1;
90
93
  delete activityInfoMock.retryPolicy;
91
94
  vi.useFakeTimers();
92
95
  // Default: heartbeat enabled with 50ms interval for fast tests
@@ -114,19 +117,19 @@ describe( 'ActivityExecutionInterceptor', () => {
114
117
  { activityInfo: activityInfoMock, workflowDetails: workflowDetailsMock, outputActivityKind: 'step' }
115
118
  );
116
119
  expect( addEventStartMock ).toHaveBeenCalledWith( {
117
- id: 'act-1',
120
+ id: 'run-1:act-1:1',
118
121
  name: 'myWorkflow#myStep',
119
122
  kind: 'step',
120
123
  parentId: 'run-1',
121
124
  details: { someInput: 'data' },
122
125
  traceInfo: traceInfoMock
123
126
  } );
124
- expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'ok' }, traceInfo: traceInfoMock } );
127
+ expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'run-1:act-1:1', details: { result: 'ok' }, traceInfo: traceInfoMock } );
125
128
  expect( addEventErrorMock ).not.toHaveBeenCalled();
126
129
  expect( runWithContextMock ).toHaveBeenCalledWith(
127
130
  expect.any( Function ),
128
131
  expect.objectContaining( {
129
- parentId: 'act-1',
132
+ parentId: 'run-1:act-1:1',
130
133
  activityInfo: activityInfoMock,
131
134
  workflowDetails: workflowDetailsMock,
132
135
  outputActivityKind: 'step',
@@ -136,6 +139,37 @@ describe( 'ActivityExecutionInterceptor', () => {
136
139
  );
137
140
  } );
138
141
 
142
+ it( 'scopes trace ids by attempt so retries do not reuse the same id', async () => {
143
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
144
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
145
+ activityInfoMock.attempt = 3;
146
+ const next = vi.fn().mockResolvedValue( { result: 'ok' } );
147
+
148
+ await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
149
+ expect( addEventStartMock ).toHaveBeenCalledWith( expect.objectContaining( { id: 'run-1:act-1:3', parentId: 'run-1' } ) );
150
+ expect( addEventEndMock ).toHaveBeenCalledWith( expect.objectContaining( { id: 'run-1:act-1:3' } ) );
151
+ expect( runWithContextMock ).toHaveBeenCalledWith(
152
+ expect.any( Function ),
153
+ expect.objectContaining( { parentId: 'run-1:act-1:3' } )
154
+ );
155
+ } );
156
+
157
+ it( 'scopes trace ids by run so a child workflow reusing an activityId gets a distinct id', async () => {
158
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
159
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
160
+ const next = vi.fn().mockResolvedValue( { result: 'ok' } );
161
+
162
+ // Temporal numbers activityIds per run, so a parent and its child both reach the same 'act-1' on attempt 1.
163
+ await interceptor.execute( makeInput(), next );
164
+ activityInfoMock.workflowExecution.runId = 'run-2';
165
+ await interceptor.execute( makeInput(), next );
166
+
167
+ expect( addEventStartMock.mock.calls.map( ( [ { id, parentId } ] ) => ( { id, parentId } ) ) ).toEqual( [
168
+ { id: 'run-1:act-1:1', parentId: 'run-1' },
169
+ { id: 'run-2:act-1:1', parentId: 'run-2' }
170
+ ] );
171
+ } );
172
+
139
173
  it( 'wraps native errors with serialized details', async () => {
140
174
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
141
175
  const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
@@ -210,7 +244,7 @@ describe( 'ActivityExecutionInterceptor', () => {
210
244
  }
211
245
  } ]
212
246
  } );
213
- expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: cause, traceInfo: traceInfoMock } );
247
+ expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'run-1:act-1:1', details: cause, traceInfo: traceInfoMock } );
214
248
  expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
215
249
  activityInfo: activityInfoMock,
216
250
  workflowDetails: workflowDetailsMock,
@@ -226,7 +260,7 @@ describe( 'ActivityExecutionInterceptor', () => {
226
260
  const next = vi.fn().mockRejectedValue( error );
227
261
 
228
262
  await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
229
- expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: error, traceInfo: traceInfoMock } );
263
+ expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'run-1:act-1:1', details: error, traceInfo: traceInfoMock } );
230
264
  expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
231
265
  activityInfo: activityInfoMock,
232
266
  workflowDetails: workflowDetailsMock,
@@ -245,7 +279,7 @@ describe( 'ActivityExecutionInterceptor', () => {
245
279
  expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.any( Object ) );
246
280
  expect( mainEventBusEmitMock ).toHaveBeenCalledTimes( 1 );
247
281
  expect( addEventEndMock ).toHaveBeenCalledWith( {
248
- id: 'act-1',
282
+ id: 'run-1:act-1:1',
249
283
  details: ActivitySpecialOutput.ASYNC_HANDOFF,
250
284
  traceInfo: traceInfoMock
251
285
  } );
@@ -65,7 +65,7 @@ export const sinks = {
65
65
  trace: {
66
66
  start: {
67
67
  fn: ( workflowInfo, { id, name, kind, details } ) =>
68
- Tracing.addEventStart( { id, kind, name, details, parentId: workflowInfo.parent?.runId, traceInfo: workflowInfo.memo.traceInfo } ),
68
+ Tracing.addEventStart( { id, kind, name, details, parentId: workflowInfo.runId, traceInfo: workflowInfo.memo.traceInfo } ),
69
69
  callDuringReplay: false
70
70
  },
71
71
 
@@ -167,7 +167,7 @@ describe( 'worker/sinks', () => {
167
167
  expect( addEventErrorMock ).not.toHaveBeenCalled();
168
168
  } );
169
169
 
170
- it( 'trace.start records trace start events with workflow parent context', async () => {
170
+ it( 'trace.start parents trace start events on the current workflow, not its parent workflow', async () => {
171
171
  const { sinks } = await import( './sinks.js' );
172
172
 
173
173
  sinks.trace.start.fn( workflowInfo, {
@@ -182,7 +182,7 @@ describe( 'worker/sinks', () => {
182
182
  kind: ComponentType.STEP,
183
183
  name: 'myStep',
184
184
  details: { input: true },
185
- parentId: 'parent-run-1',
185
+ parentId: 'run-1',
186
186
  traceInfo: { traceId: 'trace-1' }
187
187
  } );
188
188
  } );