@outputai/core 0.10.1-next.fc0a41f.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +12 -7
  2. package/src/consts.js +5 -2
  3. package/src/errors.d.ts +12 -2
  4. package/src/errors.js +17 -3
  5. package/src/errors.spec.js +19 -0
  6. package/src/helpers/activity_options.js +21 -0
  7. package/src/helpers/activity_options.spec.js +55 -0
  8. package/src/helpers/error_serializer.js +236 -0
  9. package/src/helpers/error_serializer.spec.js +413 -0
  10. package/src/helpers/errors.js +55 -13
  11. package/src/helpers/errors.spec.js +166 -66
  12. package/src/helpers/fetch.js +1 -38
  13. package/src/helpers/fetch.spec.js +1 -39
  14. package/src/helpers/function.js +13 -0
  15. package/src/helpers/function.spec.js +23 -1
  16. package/src/helpers/redact.js +51 -0
  17. package/src/helpers/redact.spec.js +66 -0
  18. package/src/helpers/string.js +10 -0
  19. package/src/helpers/string.spec.js +36 -1
  20. package/src/hooks/index.d.ts +35 -44
  21. package/src/hooks/index.js +2 -1
  22. package/src/hooks/index.spec.js +15 -7
  23. package/src/interface/evaluator.d.ts +10 -9
  24. package/src/interface/evaluator.js +1 -6
  25. package/src/interface/evaluator.spec.js +10 -10
  26. package/src/interface/step.d.ts +16 -15
  27. package/src/interface/step.js +1 -6
  28. package/src/interface/step.spec.js +10 -10
  29. package/src/interface/validations/index.js +33 -53
  30. package/src/interface/validations/index.spec.js +25 -17
  31. package/src/interface/webhook.js +1 -2
  32. package/src/interface/webhook.spec.js +25 -19
  33. package/src/interface/workflow.d.ts +25 -18
  34. package/src/interface/workflow.js +24 -52
  35. package/src/interface/workflow.spec.js +109 -108
  36. package/src/interface/workflow_activity_options.js +2 -5
  37. package/src/temporal/client.js +47 -0
  38. package/src/temporal/client.spec.js +124 -0
  39. package/src/temporal/index.d.ts +32 -0
  40. package/src/temporal/index.js +1 -0
  41. package/src/tracing/trace_engine.js +9 -11
  42. package/src/tracing/trace_engine.spec.js +67 -28
  43. package/src/worker/catalog_workflow/catalog_job.js +3 -3
  44. package/src/worker/catalog_workflow/catalog_job.spec.js +1 -1
  45. package/src/worker/index.js +10 -1
  46. package/src/worker/index.spec.js +23 -8
  47. package/src/worker/interceptors/activity.js +32 -19
  48. package/src/worker/interceptors/activity.spec.js +71 -166
  49. package/src/worker/interceptors/workflow.js +53 -11
  50. package/src/worker/interceptors/workflow.spec.js +176 -49
  51. package/src/worker/loader/workflows.js +16 -3
  52. package/src/worker/loader/workflows.spec.js +37 -5
  53. package/src/worker/log_hooks.js +13 -10
  54. package/src/worker/log_hooks.spec.js +12 -4
  55. package/src/worker/sinks.js +3 -1
  56. package/src/worker/sinks.spec.js +21 -4
  57. package/src/worker/telemetry.js +2 -1
  58. package/src/worker/temporal_logger.js +33 -0
  59. package/src/worker/temporal_logger.spec.js +90 -0
  60. package/src/helpers/aggregations.js +0 -44
  61. package/src/helpers/aggregations.spec.js +0 -92
  62. package/src/tracing/tools/utils.js +0 -42
  63. package/src/tracing/tools/utils.spec.js +0 -83
@@ -1,12 +1,14 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { ApplicationFailure } from '@temporalio/common';
3
- import { METADATA_ACCESS_SYMBOL } from '#consts';
3
+ import { FatalError, TransparentFatalError, ValidationError } from '#errors';
4
4
 
5
5
  const workflowInfoMock = vi.fn();
6
6
  const workflowStartMock = vi.fn();
7
7
  const workflowEndMock = vi.fn();
8
8
  const workflowErrorMock = vi.fn();
9
9
  const isCancellationMock = vi.fn();
10
+ const upsertMemoMock = vi.fn();
11
+ const traceInfoBuildMock = vi.fn();
10
12
  const startTime = new Date( '2026-06-02T09:00:00.000Z' );
11
13
  const runStartTime = new Date( '2026-06-02T09:05:00.000Z' );
12
14
  const workflowDetails = {
@@ -36,19 +38,26 @@ const workflowInfo = {
36
38
  memo: { traceInfo: { runId: 'root-run' } }
37
39
  };
38
40
 
39
- vi.mock( '@temporalio/workflow', () => ( {
40
- workflowInfo: ( ...args ) => workflowInfoMock( ...args ),
41
- proxySinks: () => ( {
42
- workflow: { start: workflowStartMock, end: workflowEndMock, error: workflowErrorMock }
43
- } ),
44
- ContinueAsNew: class ContinueAsNew extends Error {
45
- constructor() {
46
- super( 'ContinueAsNew' );
47
- this.name = 'ContinueAsNew';
48
- }
49
- },
50
- isCancellation: ( ...args ) => isCancellationMock( ...args )
51
- } ) );
41
+ vi.mock( '@temporalio/workflow', async importOriginal => {
42
+ const actual = await importOriginal();
43
+ return {
44
+ ...actual,
45
+ workflowInfo: ( ...args ) => workflowInfoMock( ...args ),
46
+ proxySinks: () => ( {
47
+ workflow: { start: workflowStartMock, end: workflowEndMock, error: workflowErrorMock }
48
+ } ),
49
+ ContinueAsNew: class ContinueAsNew extends Error {
50
+ constructor() {
51
+ super( 'ContinueAsNew' );
52
+ this.name = 'ContinueAsNew';
53
+ }
54
+ },
55
+ isCancellation: ( ...args ) => isCancellationMock( ...args ),
56
+ upsertMemo: ( ...args ) => upsertMemoMock( ...args )
57
+ };
58
+ } );
59
+
60
+ vi.mock( '#helpers/trace_info', () => ( { TraceInfo: { build: ( ...args ) => traceInfoBuildMock( ...args ) } } ) );
52
61
 
53
62
  const memoToHeadersMock = vi.fn( memo => ( memo ? { ...memo, __asHeaders: true } : {} ) );
54
63
  vi.mock( './headers.js', () => ( { memoToHeaders: ( ...args ) => memoToHeadersMock( ...args ) } ) );
@@ -59,11 +68,17 @@ vi.mock( '#helpers/object', () => ( { deepMerge: ( ...args ) => deepMergeMock( .
59
68
  const activityOptionsDefault = {};
60
69
  vi.mock( '../temp/__activity_options.js', () => ( { default: activityOptionsDefault } ) );
61
70
 
71
+ const workflowOptionsDefault = {};
72
+ vi.mock( '../temp/__workflow_options.js', () => ( { default: workflowOptionsDefault } ) );
73
+
62
74
  describe( 'workflow interceptors', () => {
63
75
  beforeEach( () => {
64
76
  vi.clearAllMocks();
65
77
  Object.keys( activityOptionsDefault ).forEach( key => delete activityOptionsDefault[key] );
78
+ Object.keys( workflowOptionsDefault ).forEach( key => delete workflowOptionsDefault[key] );
79
+ workflowOptionsDefault.MyWorkflow = { disableTrace: false };
66
80
  isCancellationMock.mockReturnValue( false );
81
+ traceInfoBuildMock.mockReturnValue( { workflowId: 'workflow-1', runId: 'run-1' } );
67
82
  workflowInfoMock.mockReturnValue( workflowInfo );
68
83
  } );
69
84
 
@@ -72,7 +87,7 @@ describe( 'workflow interceptors', () => {
72
87
  const { interceptors } = await import( './workflow.js' );
73
88
  const { outbound } = interceptors();
74
89
  const interceptor = outbound[0];
75
- const input = { headers: { existing: 'header' }, activityType: 'MyWorkflow#step1' };
90
+ const input = { headers: { existing: 'header' }, activityType: 'MyWorkflow#step1', options: {} };
76
91
  const next = vi.fn().mockResolvedValue( 'result' );
77
92
 
78
93
  memoToHeadersMock.mockReturnValue( { traceInfo: workflowInfo.memo.traceInfo, workflowDetails } );
@@ -99,7 +114,11 @@ describe( 'workflow interceptors', () => {
99
114
  memo: { traceInfo: workflowInfo.memo.traceInfo }
100
115
  } );
101
116
  memoToHeadersMock.mockReturnValue( {} );
102
- deepMergeMock.mockReturnValue( { heartbeatTimeout: 10, scheduleToCloseTimeout: 60 } );
117
+ deepMergeMock.mockReturnValue( {
118
+ heartbeatTimeout: 10,
119
+ scheduleToCloseTimeout: 60,
120
+ retry: { maximumAttempts: 2, nonRetryableErrorTypes: [ 'StepError' ] }
121
+ } );
103
122
 
104
123
  const { interceptors } = await import( './workflow.js' );
105
124
  const { outbound } = interceptors();
@@ -110,11 +129,79 @@ describe( 'workflow interceptors', () => {
110
129
  await interceptor.scheduleActivity( input, next );
111
130
 
112
131
  expect( deepMergeMock ).toHaveBeenCalledWith( { heartbeatTimeout: 10 }, { scheduleToCloseTimeout: 60 } );
113
- expect( input.options ).toEqual( { heartbeatTimeout: 10, scheduleToCloseTimeout: 60 } );
132
+ expect( input.options ).toEqual( {
133
+ heartbeatTimeout: 10,
134
+ scheduleToCloseTimeout: 60,
135
+ retry: {
136
+ maximumAttempts: 2,
137
+ nonRetryableErrorTypes: [ 'StepError', 'FatalError' ]
138
+ }
139
+ } );
140
+ } );
141
+
142
+ it( 'adds FatalError to activity retry options without duplicates', async () => {
143
+ const { interceptors } = await import( './workflow.js' );
144
+ const { outbound } = interceptors();
145
+ const interceptor = outbound[0];
146
+ const input = {
147
+ headers: {},
148
+ activityType: 'MyWorkflow#step1',
149
+ options: {
150
+ retry: {
151
+ maximumAttempts: 4,
152
+ nonRetryableErrorTypes: [ 'DomainError', 'FatalError' ]
153
+ }
154
+ }
155
+ };
156
+ const next = vi.fn().mockResolvedValue( undefined );
157
+
158
+ await interceptor.scheduleActivity( input, next );
159
+
160
+ expect( input.options.retry ).toEqual( {
161
+ maximumAttempts: 4,
162
+ nonRetryableErrorTypes: [ 'DomainError', 'FatalError' ]
163
+ } );
114
164
  } );
115
165
  } );
116
166
 
117
167
  describe( 'WorkflowExecutionInterceptor', () => {
168
+ it( 'upserts root trace info before starting the workflow trace', async () => {
169
+ const { interceptors } = await import( './workflow.js' );
170
+ const { inbound } = interceptors();
171
+ const traceInfo = { workflowId: 'workflow-1', runId: 'run-1' };
172
+ traceInfoBuildMock.mockReturnValue( traceInfo );
173
+
174
+ await inbound[0].execute( { args: [ {} ] }, vi.fn().mockResolvedValue( undefined ) );
175
+
176
+ expect( traceInfoBuildMock ).toHaveBeenCalledOnce();
177
+ expect( upsertMemoMock ).toHaveBeenCalledWith( { payloadVersion: '2', traceInfo } );
178
+ expect( upsertMemoMock.mock.invocationCallOrder[0] ).toBeLessThan( workflowStartMock.mock.invocationCallOrder[0] );
179
+ } );
180
+
181
+ it.each( [
182
+ {
183
+ name: 'tracing is disabled',
184
+ info: workflowInfo,
185
+ options: { disableTrace: true }
186
+ },
187
+ {
188
+ name: 'the workflow has a root',
189
+ info: { ...workflowInfo, root: { workflowId: 'root-workflow', runId: 'root-run' } },
190
+ options: { disableTrace: false }
191
+ }
192
+ ] )( 'does not build trace info when $name', async ( { info, options } ) => {
193
+ workflowInfoMock.mockReturnValue( info );
194
+ workflowOptionsDefault.MyWorkflow = options;
195
+ const { interceptors } = await import( './workflow.js' );
196
+ const { inbound } = interceptors();
197
+
198
+ await inbound[0].execute( { args: [ {} ] }, vi.fn().mockResolvedValue( undefined ) );
199
+
200
+ expect( traceInfoBuildMock ).not.toHaveBeenCalled();
201
+ expect( upsertMemoMock ).toHaveBeenCalledOnce();
202
+ expect( upsertMemoMock ).toHaveBeenCalledWith( { payloadVersion: '2' } );
203
+ } );
204
+
118
205
  it( 'calls sinks.workflow.start, next, then sinks.workflow.end on success', async () => {
119
206
  const { interceptors } = await import( './workflow.js' );
120
207
  const { inbound } = interceptors();
@@ -131,7 +218,7 @@ describe( 'workflow interceptors', () => {
131
218
  expect( workflowErrorMock ).not.toHaveBeenCalled();
132
219
  } );
133
220
 
134
- it( 'calls sinks.workflow.error and rethrows workflow errors', async () => {
221
+ it( 'rethrows native errors as Workflow Task failures without a terminal sink event', async () => {
135
222
  const { interceptors } = await import( './workflow.js' );
136
223
  const { inbound } = interceptors();
137
224
  const interceptor = inbound[0];
@@ -141,17 +228,19 @@ describe( 'workflow interceptors', () => {
141
228
 
142
229
  await expect( interceptor.execute( input, next ) ).rejects.toBe( err );
143
230
  expect( workflowStartMock ).toHaveBeenCalled();
144
- expect( workflowErrorMock ).toHaveBeenCalledWith( err );
231
+ expect( workflowErrorMock ).not.toHaveBeenCalled();
145
232
  expect( workflowEndMock ).not.toHaveBeenCalled();
146
233
  } );
147
234
 
148
- it( 'wraps workflow errors with metadata in ApplicationFailure details', async () => {
235
+ it.each( [
236
+ { ErrorType: FatalError, type: 'FatalError' },
237
+ { ErrorType: ValidationError, type: 'ValidationError' }
238
+ ] )( 'converts $type into a non-retryable ApplicationFailure with serialized details', async ( { ErrorType, type } ) => {
149
239
  const { interceptors } = await import( './workflow.js' );
150
240
  const { inbound } = interceptors();
151
241
  const interceptor = inbound[0];
152
- const trace = { trace: { destinations: { local: '/tmp/trace' } } };
153
- const err = new Error( 'workflow failed' );
154
- err[METADATA_ACCESS_SYMBOL] = trace;
242
+ const err = new ErrorType( 'workflow failed' );
243
+ err.code = 'EWORKFLOW';
155
244
  const next = vi.fn().mockRejectedValue( err );
156
245
 
157
246
  const thrown = await interceptor.execute( { args: [ {} ] }, next ).catch( e => e );
@@ -159,44 +248,78 @@ describe( 'workflow interceptors', () => {
159
248
  expect( thrown ).toBeInstanceOf( ApplicationFailure );
160
249
  expect( thrown ).toMatchObject( {
161
250
  message: 'workflow failed',
162
- type: 'Error',
163
- details: [ trace ],
251
+ type,
252
+ nonRetryable: true,
253
+ details: [ {
254
+ error: {
255
+ name: type,
256
+ message: 'workflow failed',
257
+ code: 'EWORKFLOW'
258
+ }
259
+ } ],
164
260
  cause: err
165
261
  } );
166
- expect( workflowErrorMock ).toHaveBeenCalledWith( err );
262
+ expect( workflowErrorMock ).toHaveBeenCalledWith( expect.objectContaining( {
263
+ name: type,
264
+ message: 'workflow failed',
265
+ code: 'EWORKFLOW',
266
+ stack: expect.any( String )
267
+ } ) );
268
+ expect( workflowErrorMock.mock.calls[0][0] ).not.toBeInstanceOf( Error );
167
269
  expect( workflowEndMock ).not.toHaveBeenCalled();
168
270
  } );
169
271
 
170
- it( 'preserves ApplicationFailure metadata when wrapping workflow errors with details', async () => {
272
+ it( 'uses a transparent error cause for the sink and ApplicationFailure', async () => {
171
273
  const { interceptors } = await import( './workflow.js' );
172
274
  const { inbound } = interceptors();
173
275
  const interceptor = inbound[0];
174
- const trace = { trace: { destinations: { local: '/tmp/trace' } } };
175
- const err = ApplicationFailure.create( {
176
- message: 'domain failed',
177
- type: 'DomainFailure',
178
- nonRetryable: true,
179
- details: [ { domain: { reason: 'bad-input' } } ]
180
- } );
181
- err[METADATA_ACCESS_SYMBOL] = trace;
182
- const next = vi.fn().mockRejectedValue( err );
276
+ const cause = new TypeError( 'provider rejected request' );
277
+ cause.code = 'EAUTH';
278
+ const error = new TransparentFatalError( cause );
279
+ const next = vi.fn().mockRejectedValue( error );
183
280
 
184
281
  const thrown = await interceptor.execute( { args: [ {} ] }, next ).catch( e => e );
185
282
 
186
- expect( thrown ).toBeInstanceOf( ApplicationFailure );
187
- expect( thrown ).not.toBe( err );
188
- expect( thrown.cause ).toBe( err );
189
- expect( thrown.cause ).not.toBe( thrown );
190
283
  expect( thrown ).toMatchObject( {
191
- message: 'domain failed',
192
- type: 'DomainFailure',
284
+ message: 'provider rejected request',
285
+ type: 'TypeError',
193
286
  nonRetryable: true,
194
- details: [
195
- { domain: { reason: 'bad-input' } },
196
- trace
197
- ]
287
+ cause,
288
+ details: [ {
289
+ error: {
290
+ name: 'TypeError',
291
+ message: 'provider rejected request',
292
+ code: 'EAUTH'
293
+ }
294
+ } ]
198
295
  } );
199
- expect( workflowErrorMock ).toHaveBeenCalledWith( err );
296
+ expect( workflowErrorMock ).toHaveBeenCalledWith( expect.objectContaining( {
297
+ name: 'TypeError',
298
+ message: 'provider rejected request',
299
+ code: 'EAUTH',
300
+ stack: expect.any( String )
301
+ } ) );
302
+ expect( workflowErrorMock.mock.calls[0][0] ).not.toHaveProperty( 'cause' );
303
+ expect( workflowEndMock ).not.toHaveBeenCalled();
304
+ } );
305
+
306
+ it( 'sinks and rethrows existing Temporal failures unchanged', async () => {
307
+ const { interceptors } = await import( './workflow.js' );
308
+ const { inbound } = interceptors();
309
+ const interceptor = inbound[0];
310
+ const err = ApplicationFailure.retryable( 'domain failed', 'DomainFailure', { reason: 'invalid' } );
311
+ const next = vi.fn().mockRejectedValue( err );
312
+
313
+ await expect( interceptor.execute( { args: [ {} ] }, next ) ).rejects.toBe( err );
314
+ expect( workflowErrorMock ).toHaveBeenCalledWith( expect.objectContaining( {
315
+ name: 'ApplicationFailure',
316
+ message: 'domain failed',
317
+ type: 'DomainFailure',
318
+ details: [ { reason: 'invalid' } ],
319
+ stack: expect.any( String )
320
+ } ) );
321
+ expect( workflowErrorMock.mock.calls[0][0] ).not.toBeInstanceOf( Error );
322
+ expect( workflowEndMock ).not.toHaveBeenCalled();
200
323
  } );
201
324
 
202
325
  it( 'calls sinks.workflow.error and rethrows cancellation errors without wrapping', async () => {
@@ -209,11 +332,15 @@ describe( 'workflow interceptors', () => {
209
332
 
210
333
  await expect( interceptor.execute( { args: [ {} ] }, next ) ).rejects.toBe( cancellation );
211
334
  expect( isCancellationMock ).toHaveBeenCalledWith( cancellation );
212
- expect( workflowErrorMock ).toHaveBeenCalledWith( cancellation );
335
+ expect( workflowErrorMock ).toHaveBeenCalledWith( {
336
+ name: 'Error',
337
+ message: 'Workflow cancelled'
338
+ } );
339
+ expect( workflowErrorMock.mock.calls[0][0] ).not.toBeInstanceOf( Error );
213
340
  expect( workflowEndMock ).not.toHaveBeenCalled();
214
341
  } );
215
342
 
216
- it( 'on ContinueAsNew calls sinks.trace.addWorkflowEventEnd and rethrows', async () => {
343
+ it( 'ends the workflow trace and rethrows ContinueAsNew', async () => {
217
344
  const { ContinueAsNew } = await import( '@temporalio/workflow' );
218
345
  const { WorkflowSpecialOutput } = await import( '#consts' );
219
346
  const { interceptors } = await import( './workflow.js' );
@@ -1,7 +1,7 @@
1
1
  import { EOL } from 'node:os';
2
2
  import { writeFileInTempDir, findWorkflowsInNodeModules, importComponents, matchFiles } from './tools.js';
3
3
  import { staticMatchers } from './matchers.js';
4
- import { WORKFLOWS_INDEX_FILENAME, WORKFLOW_CATALOG } from '#consts';
4
+ import { WORKFLOWS_INDEX_FILENAME, WORKFLOW_CATALOG, WORKFLOW_OPTIONS_FILENAME } from '#consts';
5
5
  import { createChildLogger } from '#logger';
6
6
  import { ValidationError } from '#errors';
7
7
 
@@ -38,6 +38,7 @@ const createWorkflowsEntrypoint = workflows => {
38
38
  * @typedef LoadWorkflowsResult
39
39
  * @property {Workflow[]} workflows - Loaded workflows
40
40
  * @property {string} entrypoint - Index file loading all workflows
41
+ * @property {string} optionsFile - File exporting options for each workflow and their aliases
41
42
  */
42
43
  /**
43
44
  * Scan and find workflow.js files and import them.
@@ -47,6 +48,8 @@ const createWorkflowsEntrypoint = workflows => {
47
48
  */
48
49
  export async function loadWorkflows( rootDir ) {
49
50
  const workflowNames = new Set();
51
+ const workflowOptions = new Map();
52
+
50
53
  const workflows = [];
51
54
  const localWorkflows = matchFiles( rootDir, [ staticMatchers.workflowFile ] );
52
55
  const externalWorkflows = findWorkflowsInNodeModules( rootDir );
@@ -55,7 +58,7 @@ export async function loadWorkflows( rootDir ) {
55
58
  if ( staticMatchers.workflowPathHasShared( path ) ) {
56
59
  throw new ValidationError( 'Workflow directory can\'t be named "shared"' );
57
60
  }
58
- const { name, aliases } = metadata;
61
+ const { name, aliases, options } = metadata;
59
62
  if ( workflowNames.has( name ) ) {
60
63
  throw new ValidationError( `Workflow name "${name}" conflicts with another workflow or alias. \
61
64
  Workflow names and aliases must be unique.` );
@@ -64,6 +67,8 @@ Workflow names and aliases must be unique.` );
64
67
  throw new ValidationError( `Workflow name "${name}" is reserved for the internal catalog workflow.` );
65
68
  }
66
69
  workflowNames.add( name );
70
+ workflowOptions.set( name, { disableTrace: options?.disableTrace ?? false } );
71
+
67
72
  for ( const alias of aliases ?? [] ) {
68
73
  if ( workflowNames.has( alias ) ) {
69
74
  throw new ValidationError( `Workflow "${name}" alias "${alias}" conflicts with another workflow or alias. \
@@ -73,10 +78,18 @@ Workflow names and aliases must be unique.` );
73
78
  throw new ValidationError( `Workflow "${name}" alias "${alias}" is reserved for the internal catalog workflow.` );
74
79
  }
75
80
  workflowNames.add( alias );
81
+ workflowOptions.set( alias, { disableTrace: options?.disableTrace ?? false } );
76
82
  }
77
83
 
78
84
  log.info( name, { path, aliases, ...( external && { external } ) } );
79
85
  workflows.push( { ...metadata, path, external } );
80
86
  }
81
- return { workflows, entrypoint: createWorkflowsEntrypoint( workflows ) };
87
+
88
+ workflowOptions.set( WORKFLOW_CATALOG, { disableTrace: true } );
89
+
90
+ // writes down the static workflow options, currently only disable trace
91
+ const optionsContent = `export default ${JSON.stringify( Object.fromEntries( workflowOptions ), undefined, 2 )};`;
92
+ const optionsFile = writeFileInTempDir( optionsContent, WORKFLOW_OPTIONS_FILENAME );
93
+
94
+ return { workflows, entrypoint: createWorkflowsEntrypoint( workflows ), optionsFile };
82
95
  };
@@ -5,7 +5,8 @@ vi.mock( '#consts', () => ( {
5
5
  ACTIVITY_GET_TRACE_DESTINATIONS: '__internal#getTraceDestinations',
6
6
  WORKFLOWS_INDEX_FILENAME: '__workflows_entrypoint.js',
7
7
  WORKFLOW_CATALOG: 'catalog',
8
- ACTIVITY_OPTIONS_FILENAME: '__activity_options.js'
8
+ ACTIVITY_OPTIONS_FILENAME: '__activity_options.js',
9
+ WORKFLOW_OPTIONS_FILENAME: '__workflow_options.js'
9
10
  } ) );
10
11
 
11
12
  const { importComponentsMock, findWorkflowsInNodeModulesMock, matchFilesMock, writeFileInTempDirMock } = vi.hoisted( () => ( {
@@ -44,7 +45,7 @@ describe( 'loadWorkflows', () => {
44
45
  matchFilesMock.mockReset();
45
46
  matchFilesMock.mockReturnValue( [] );
46
47
  writeFileInTempDirMock.mockReset();
47
- writeFileInTempDirMock.mockReturnValue( '/tmp/__workflows_entrypoint.js' );
48
+ writeFileInTempDirMock.mockImplementation( ( _, filename ) => `/tmp/${filename}` );
48
49
  workflowPathHasSharedMock.mockReset();
49
50
  workflowPathHasSharedMock.mockReturnValue( false );
50
51
  } );
@@ -58,9 +59,10 @@ describe( 'loadWorkflows', () => {
58
59
  yield { metadata: { name: 'Flow1', description: 'd' }, path: '/b/workflow.js' };
59
60
  } );
60
61
 
61
- const { workflows, entrypoint } = await loadWorkflows( '/root' );
62
+ const { workflows, entrypoint, optionsFile } = await loadWorkflows( '/root' );
62
63
  expect( workflows ).toEqual( [ { name: 'Flow1', description: 'd', path: '/b/workflow.js', external: false } ] );
63
64
  expect( entrypoint ).toBe( '/tmp/__workflows_entrypoint.js' );
65
+ expect( optionsFile ).toBe( '/tmp/__workflow_options.js' );
64
66
  expect( importComponentsMock ).toHaveBeenNthCalledWith( 1, localFiles );
65
67
  expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledOnce();
66
68
  expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledWith( '/root' );
@@ -244,12 +246,42 @@ describe( 'loadWorkflows', () => {
244
246
  const { entrypoint } = await loadWorkflows( '/root' );
245
247
 
246
248
  expect( entrypoint ).toBe( '/tmp/__workflows_entrypoint.js' );
247
- expect( writeFileInTempDirMock ).toHaveBeenCalledTimes( 1 );
248
- const [ contents, filename ] = writeFileInTempDirMock.mock.calls[0];
249
+ expect( writeFileInTempDirMock ).toHaveBeenCalledTimes( 2 );
250
+ const [ contents, filename ] = writeFileInTempDirMock.mock.calls.find( ( [ , calledFilename ] ) =>
251
+ calledFilename === '__workflows_entrypoint.js'
252
+ );
249
253
  expect( filename ).toBe( '__workflows_entrypoint.js' );
250
254
  expect( contents ).toContain( 'export { default as W } from \'/abs/wf.js\';' );
251
255
  expect( contents ).toContain( 'export { default as W_old } from \'/abs/wf.js\';' );
252
256
  expect( contents ).toContain( 'export { default as W_legacy } from \'/abs/wf.js\';' );
253
257
  expect( contents ).toContain( 'export { default as catalog }' );
254
258
  } );
259
+
260
+ it( 'writes trace options for workflow names, aliases, and the catalog workflow', async () => {
261
+ const { loadWorkflows } = await import( './workflows.js' );
262
+ importComponentsMock.mockImplementationOnce( async function *() {
263
+ yield {
264
+ metadata: { name: 'Traced', aliases: [ 'Traced_old' ] },
265
+ path: '/abs/traced.js'
266
+ };
267
+ yield {
268
+ metadata: { name: 'Untraced', aliases: [ 'Untraced_old' ], options: { disableTrace: true } },
269
+ path: '/abs/untraced.js'
270
+ };
271
+ } );
272
+
273
+ await loadWorkflows( '/root' );
274
+
275
+ const [ contents, filename ] = writeFileInTempDirMock.mock.calls.find( ( [ , calledFilename ] ) =>
276
+ calledFilename === '__workflow_options.js'
277
+ );
278
+ expect( filename ).toBe( '__workflow_options.js' );
279
+ expect( contents ).toBe( `export default ${JSON.stringify( {
280
+ Traced: { disableTrace: false },
281
+ Traced_old: { disableTrace: false },
282
+ Untraced: { disableTrace: true },
283
+ Untraced_old: { disableTrace: true },
284
+ catalog: { disableTrace: true }
285
+ }, undefined, 2 )};` );
286
+ } );
255
287
  } );
@@ -1,6 +1,7 @@
1
1
  import { mainEventBus } from '#bus';
2
2
  import { createChildLogger } from '#logger';
3
3
  import { ACTIVITY_GET_TRACE_DESTINATIONS, BusEventType, LifecycleEvent, WORKFLOW_CATALOG } from '#consts';
4
+ import { serializeError } from '#helpers/error_serializer';
4
5
 
5
6
  const activityLog = createChildLogger( 'Activity' );
6
7
  const workflowLog = createChildLogger( 'Workflow' );
@@ -40,11 +41,12 @@ mainEventBus.on( BusEventType.ACTIVITY_END, ( { activityInfo, outputActivityKind
40
41
  );
41
42
 
42
43
  mainEventBus.on( BusEventType.ACTIVITY_ERROR, ( { activityInfo, outputActivityKind, error } ) =>
43
- shouldLogActivity( activityInfo ) && activityLog.error( `Error ${activityInfo.activityType} ${outputActivityKind}: ${error.constructor.name}`, {
44
- event: LifecycleEvent.ERROR,
45
- ...serializedActivityFields( activityInfo ),
46
- error: error.message
47
- } )
44
+ shouldLogActivity( activityInfo ) &&
45
+ activityLog.error( `Error ${activityInfo.activityType} ${outputActivityKind}: ${error.name ?? error.constructor.name}`, {
46
+ event: LifecycleEvent.ERROR,
47
+ ...serializedActivityFields( activityInfo ),
48
+ error: serializeError( error, { dropKeys: [ 'stack' ] } )
49
+ } )
48
50
  );
49
51
 
50
52
  mainEventBus.on( BusEventType.ACTIVITY_LOG, ( { level, message, metadata, activityInfo } ) =>
@@ -83,11 +85,12 @@ mainEventBus.on( BusEventType.WORKFLOW_END, ( { workflowDetails } ) =>
83
85
  );
84
86
 
85
87
  mainEventBus.on( BusEventType.WORKFLOW_ERROR, ( { workflowDetails, error } ) =>
86
- shouldLogWorkflow( workflowDetails ) && workflowLog.error( `Error ${workflowDetails.workflowType} workflow: ${error.constructor.name}`, {
87
- event: LifecycleEvent.ERROR,
88
- ...serializeWorkflowFields( workflowDetails ),
89
- error: error.message
90
- } )
88
+ shouldLogWorkflow( workflowDetails ) &&
89
+ workflowLog.error( `Error ${workflowDetails.workflowType} workflow: ${error.name ?? error.constructor.name}`, {
90
+ event: LifecycleEvent.ERROR,
91
+ ...serializeWorkflowFields( workflowDetails ),
92
+ error: serializeError( error, { dropKeys: [ 'stack' ] } )
93
+ } )
91
94
  );
92
95
 
93
96
  mainEventBus.on( BusEventType.WORKFLOW_LOG, ( { level, message, metadata, workflowDetails } ) =>
@@ -27,9 +27,14 @@ const mainEventBusMock = vi.hoisted( () => ( {
27
27
  onHandlers[eventType] = handler;
28
28
  } )
29
29
  } ) );
30
+ const serializeErrorMock = vi.hoisted( () => vi.fn( error => ( {
31
+ name: error.name,
32
+ message: error.message
33
+ } ) ) );
30
34
 
31
35
  vi.mock( '#logger', () => ( { createChildLogger: createChildLoggerMock } ) );
32
36
  vi.mock( '#bus', () => ( { mainEventBus: mainEventBusMock } ) );
37
+ vi.mock( '#helpers/error_serializer', () => ( { serializeError: serializeErrorMock } ) );
33
38
 
34
39
  import './log_hooks.js';
35
40
 
@@ -104,6 +109,7 @@ describe( 'log_hooks', () => {
104
109
 
105
110
  it( 'ACTIVITY_ERROR logs full message and second arg', () => {
106
111
  const err = new Error( 'step failed' );
112
+ err.name = 'ProviderError';
107
113
  onHandlers[BusEventType.ACTIVITY_ERROR]( {
108
114
  ...basePayload,
109
115
  duration: 100,
@@ -112,7 +118,7 @@ describe( 'log_hooks', () => {
112
118
 
113
119
  expect( activityLogMock.error ).toHaveBeenCalledTimes( 1 );
114
120
  expect( activityLogMock.error ).toHaveBeenCalledWith(
115
- 'Error myWorkflow#myStep step: Error',
121
+ 'Error myWorkflow#myStep step: ProviderError',
116
122
  {
117
123
  event: LifecycleEvent.ERROR,
118
124
  activityId: 'act-1',
@@ -120,9 +126,10 @@ describe( 'log_hooks', () => {
120
126
  workflowId: 'wf-1',
121
127
  workflowType: 'myWorkflow',
122
128
  runId: 'run-1',
123
- error: 'step failed'
129
+ error: { name: 'ProviderError', message: 'step failed' }
124
130
  }
125
131
  );
132
+ expect( serializeErrorMock ).toHaveBeenCalledWith( err, { dropKeys: [ 'stack' ] } );
126
133
  } );
127
134
 
128
135
  it( 'ACTIVITY_ERROR does not log trace destination activity', () => {
@@ -229,7 +236,7 @@ describe( 'log_hooks', () => {
229
236
  } );
230
237
 
231
238
  it( 'WORKFLOW_ERROR logs full message and second arg', () => {
232
- const err = new TypeError( 'workflow boom' );
239
+ const err = { name: 'TypeError', message: 'workflow boom' };
233
240
  onHandlers[BusEventType.WORKFLOW_ERROR]( {
234
241
  ...basePayload,
235
242
  duration: 150,
@@ -244,9 +251,10 @@ describe( 'log_hooks', () => {
244
251
  workflowId: 'wf-1',
245
252
  workflowType: 'myWorkflow',
246
253
  runId: 'run-1',
247
- error: 'workflow boom'
254
+ error: { name: 'TypeError', message: 'workflow boom' }
248
255
  }
249
256
  );
257
+ expect( serializeErrorMock ).toHaveBeenCalledWith( err, { dropKeys: [ 'stack' ] } );
250
258
  } );
251
259
 
252
260
  it( 'WORKFLOW_ERROR does not log when workflowType is WORKFLOW_CATALOG', () => {
@@ -2,6 +2,7 @@ import { BusEventType, ComponentType } from '#consts';
2
2
  import * as Tracing from '#tracing';
3
3
  import { mainEventBus } from '#bus';
4
4
  import { createWorkflowDetails } from '#helpers/temporal_context';
5
+ import { rehydrateError } from '#helpers/errors';
5
6
 
6
7
  // This sink allow for sandbox Temporal environment to send trace logs back to the main thread.
7
8
  export const sinks = {
@@ -48,7 +49,8 @@ export const sinks = {
48
49
  error: {
49
50
  fn: ( workflowInfo, error ) => {
50
51
  const { runId, memo: { traceInfo } } = workflowInfo;
51
- mainEventBus.emit( BusEventType.WORKFLOW_ERROR, { workflowDetails: createWorkflowDetails( workflowInfo ), error } );
52
+ // re-hydrate workflow error
53
+ mainEventBus.emit( BusEventType.WORKFLOW_ERROR, { workflowDetails: createWorkflowDetails( workflowInfo ), error: rehydrateError( error ) } );
52
54
  if ( traceInfo ) {
53
55
  Tracing.addEventError( { id: runId, details: error, traceInfo } );
54
56
  }
@@ -15,6 +15,8 @@ const createWorkflowDetailsMock = vi.hoisted( () => vi.fn( workflowInfo => ( {
15
15
  runId: workflowInfo.runId
16
16
  } ) ) );
17
17
 
18
+ const rehydrateErrorMock = vi.hoisted( () => vi.fn( error => error ) );
19
+
18
20
  vi.mock( '#bus', () => ( { mainEventBus: mainEventBusMock } ) );
19
21
  vi.mock( '#tracing', () => ( {
20
22
  addEventStart: addEventStartMock,
@@ -24,6 +26,9 @@ vi.mock( '#tracing', () => ( {
24
26
  vi.mock( '#helpers/temporal_context', () => ( {
25
27
  createWorkflowDetails: createWorkflowDetailsMock
26
28
  } ) );
29
+ vi.mock( '#helpers/errors', () => ( {
30
+ rehydrateError: rehydrateErrorMock
31
+ } ) );
27
32
 
28
33
  describe( 'worker/sinks', () => {
29
34
  const workflowInfo = {
@@ -128,11 +133,17 @@ describe( 'worker/sinks', () => {
128
133
 
129
134
  it( 'workflow.error emits workflow error events and trace error events', async () => {
130
135
  const { sinks } = await import( './sinks.js' );
131
- const error = new Error( 'workflow failed' );
136
+ const error = { name: 'Error', message: 'workflow failed' };
137
+ const rehydrated = new Error( 'workflow failed' );
138
+ rehydrateErrorMock.mockReturnValueOnce( rehydrated );
132
139
 
133
140
  sinks.workflow.error.fn( workflowInfo, error );
134
141
 
135
- expect( mainEventBusMock.emit ).toHaveBeenCalledWith( BusEventType.WORKFLOW_ERROR, { workflowDetails, error } );
142
+ expect( rehydrateErrorMock ).toHaveBeenCalledWith( error );
143
+ expect( mainEventBusMock.emit ).toHaveBeenCalledWith(
144
+ BusEventType.WORKFLOW_ERROR,
145
+ { workflowDetails, error: rehydrated }
146
+ );
136
147
  expect( addEventErrorMock ).toHaveBeenCalledWith( {
137
148
  id: 'run-1',
138
149
  details: error,
@@ -142,11 +153,17 @@ describe( 'worker/sinks', () => {
142
153
 
143
154
  it( 'workflow.error skips tracing when trace info is absent', async () => {
144
155
  const { sinks } = await import( './sinks.js' );
145
- const error = new Error( 'workflow failed' );
156
+ const error = { name: 'Error', message: 'workflow failed' };
157
+ const rehydrated = new Error( 'workflow failed' );
158
+ rehydrateErrorMock.mockReturnValueOnce( rehydrated );
146
159
 
147
160
  sinks.workflow.error.fn( { ...workflowInfo, memo: {} }, error );
148
161
 
149
- expect( mainEventBusMock.emit ).toHaveBeenCalledWith( BusEventType.WORKFLOW_ERROR, { workflowDetails, error } );
162
+ expect( rehydrateErrorMock ).toHaveBeenCalledWith( error );
163
+ expect( mainEventBusMock.emit ).toHaveBeenCalledWith(
164
+ BusEventType.WORKFLOW_ERROR,
165
+ { workflowDetails, error: rehydrated }
166
+ );
150
167
  expect( addEventErrorMock ).not.toHaveBeenCalled();
151
168
  } );
152
169