@outputai/core 0.10.1-next.f6a7c1a.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.
- package/package.json +13 -7
- package/src/consts.js +5 -2
- package/src/errors.d.ts +12 -2
- package/src/errors.js +17 -3
- package/src/errors.spec.js +19 -0
- package/src/helpers/activity_options.js +21 -0
- package/src/helpers/activity_options.spec.js +55 -0
- package/src/helpers/error_serializer.js +236 -0
- package/src/helpers/error_serializer.spec.js +413 -0
- package/src/helpers/errors.js +55 -13
- package/src/helpers/errors.spec.js +166 -66
- package/src/helpers/fetch.js +1 -38
- package/src/helpers/fetch.spec.js +1 -39
- package/src/helpers/function.js +13 -0
- package/src/helpers/function.spec.js +23 -1
- package/src/helpers/redact.js +51 -0
- package/src/helpers/redact.spec.js +66 -0
- package/src/helpers/string.js +10 -0
- package/src/helpers/string.spec.js +36 -1
- package/src/hooks/index.d.ts +35 -44
- package/src/hooks/index.js +10 -7
- package/src/hooks/index.spec.js +34 -7
- package/src/hooks/pending_hooks.js +24 -0
- package/src/hooks/pending_hooks.spec.js +62 -0
- package/src/interface/evaluator.d.ts +10 -9
- package/src/interface/evaluator.js +1 -6
- package/src/interface/evaluator.spec.js +10 -10
- package/src/interface/index.js +2 -1
- package/src/interface/step.d.ts +16 -15
- package/src/interface/step.js +1 -6
- package/src/interface/step.spec.js +10 -10
- package/src/interface/validations/index.js +33 -53
- package/src/interface/validations/index.spec.js +25 -17
- package/src/interface/webhook.js +1 -2
- package/src/interface/webhook.spec.js +25 -19
- package/src/interface/workflow.d.ts +25 -18
- package/src/interface/workflow.js +24 -52
- package/src/interface/workflow.spec.js +109 -108
- package/src/interface/workflow_activity_options.js +2 -5
- package/src/interface/workflow_utils.d.ts +15 -0
- package/src/interface/workflow_utils.js +19 -0
- package/src/interface/workflow_utils.spec.js +82 -1
- package/src/temporal/client.js +47 -0
- package/src/temporal/client.spec.js +124 -0
- package/src/temporal/index.d.ts +32 -0
- package/src/temporal/index.js +1 -0
- package/src/tracing/trace_engine.js +9 -11
- package/src/tracing/trace_engine.spec.js +67 -28
- package/src/worker/catalog_workflow/catalog_job.js +3 -3
- package/src/worker/catalog_workflow/catalog_job.spec.js +1 -1
- package/src/worker/configs.js +0 -3
- package/src/worker/index.js +21 -8
- package/src/worker/index.spec.js +30 -10
- package/src/worker/interceptors/activity.js +32 -19
- package/src/worker/interceptors/activity.spec.js +71 -166
- package/src/worker/interceptors/workflow.js +53 -11
- package/src/worker/interceptors/workflow.spec.js +176 -49
- package/src/worker/loader/workflows.js +16 -3
- package/src/worker/loader/workflows.spec.js +37 -5
- package/src/worker/log_hooks.js +13 -10
- package/src/worker/log_hooks.spec.js +12 -4
- package/src/worker/sinks.js +3 -1
- package/src/worker/sinks.spec.js +21 -4
- package/src/worker/telemetry.js +2 -1
- package/src/worker/temporal_logger.js +33 -0
- package/src/worker/temporal_logger.spec.js +90 -0
- package/src/helpers/aggregations.js +0 -44
- package/src/helpers/aggregations.spec.js +0 -92
- package/src/tracing/tools/utils.js +0 -42
- package/src/tracing/tools/utils.spec.js +0 -83
- package/src/worker/proxy.js +0 -20
- package/src/worker/proxy.spec.js +0 -51
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// THIS RUNS IN THE TEMPORAL'S SANDBOX ENVIRONMENT
|
|
2
|
-
import { workflowInfo, proxySinks, ContinueAsNew, isCancellation } from '@temporalio/workflow';
|
|
2
|
+
import { workflowInfo, proxySinks, ContinueAsNew, isCancellation, ApplicationFailure, TemporalFailure, upsertMemo } from '@temporalio/workflow';
|
|
3
3
|
import { memoToHeaders } from './headers.js';
|
|
4
4
|
import { deepMerge } from '#helpers/object';
|
|
5
|
-
import {
|
|
6
|
-
import { METADATA_ACCESS_SYMBOL, WorkflowSpecialOutput } from '#consts';
|
|
5
|
+
import { WorkflowSpecialOutput } from '#consts';
|
|
7
6
|
import { createWorkflowDetails } from '#helpers/temporal_context';
|
|
7
|
+
import { TraceInfo } from '#helpers/trace_info';
|
|
8
|
+
import { FatalError, TransparentFatalError } from '#errors';
|
|
9
|
+
import { serializeError } from '#helpers/error_serializer';
|
|
10
|
+
import { enforceActivityOptions } from '#helpers/activity_options';
|
|
8
11
|
|
|
9
|
-
//
|
|
12
|
+
// these are a dynamic generated file with activity configs overwrites and workflow options
|
|
10
13
|
import activityOptionsMap from '../temp/__activity_options.js';
|
|
14
|
+
import workflowOptionsMap from '../temp/__workflow_options.js';
|
|
11
15
|
|
|
12
16
|
/*
|
|
13
17
|
This interceptor adds Memo and serialized workflowInfo() to the Activity invocation headers.
|
|
@@ -27,6 +31,8 @@ class HeadersInjectionInterceptor {
|
|
|
27
31
|
if ( activityOptionsOverrides ) {
|
|
28
32
|
input.options = deepMerge( input.options ?? {}, activityOptionsOverrides );
|
|
29
33
|
}
|
|
34
|
+
// Re-enforce framework options after component overrides
|
|
35
|
+
input.options = enforceActivityOptions( input.options );
|
|
30
36
|
return next( input );
|
|
31
37
|
}
|
|
32
38
|
};
|
|
@@ -35,6 +41,15 @@ const sinks = proxySinks();
|
|
|
35
41
|
|
|
36
42
|
class WorkflowExecutionInterceptor {
|
|
37
43
|
async execute( input, next ) {
|
|
44
|
+
const { workflowType, root } = workflowInfo();
|
|
45
|
+
const traceEnabled = workflowOptionsMap[workflowType]?.disableTrace !== true;
|
|
46
|
+
const isRoot = !root; // root workflow doesn't have the root block
|
|
47
|
+
|
|
48
|
+
upsertMemo( {
|
|
49
|
+
payloadVersion: '2',
|
|
50
|
+
...( isRoot && traceEnabled && { traceInfo: TraceInfo.build() } )
|
|
51
|
+
} );
|
|
52
|
+
|
|
38
53
|
sinks.workflow.start( input.args[0] );
|
|
39
54
|
try {
|
|
40
55
|
const output = await next( input );
|
|
@@ -51,19 +66,46 @@ class WorkflowExecutionInterceptor {
|
|
|
51
66
|
throw error;
|
|
52
67
|
}
|
|
53
68
|
|
|
69
|
+
/**
|
|
70
|
+
* We send serialized error to the sink, because temporal loses Error meta-information, like name, type, etc
|
|
71
|
+
* when crossing the workflow sandbox barrier. The plain object doesn't lose anything.
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* This happens when the workflow is cancelled.
|
|
75
|
+
* Re-throw the error as it is.
|
|
76
|
+
* Sink back the error without stack (not necessary for this).
|
|
77
|
+
*/
|
|
54
78
|
if ( isCancellation( error ) ) {
|
|
55
|
-
sinks.workflow.error( error );
|
|
79
|
+
sinks.workflow.error( serializeError( error, { dropKeys: [ 'stack' ] } ) );
|
|
56
80
|
throw error;
|
|
57
81
|
}
|
|
58
82
|
|
|
59
|
-
|
|
83
|
+
/**
|
|
84
|
+
* This represents an error in the workflow itself, in this case the sink receives the error serialize with stack
|
|
85
|
+
* An Application failure is constructed to finish the workflow run and the serialized error is added to its details
|
|
86
|
+
* so the API or other consumers can read it. Stack is omitted
|
|
87
|
+
*/
|
|
88
|
+
if ( error instanceof FatalError ) {
|
|
89
|
+
const unwrappedError = error instanceof TransparentFatalError ? error.cause : error;
|
|
90
|
+
sinks.workflow.error( serializeError( unwrappedError ) );
|
|
91
|
+
throw ApplicationFailure.fromError( unwrappedError, {
|
|
92
|
+
cause: unwrappedError,
|
|
93
|
+
nonRetryable: true,
|
|
94
|
+
details: [ { error: serializeError( unwrappedError, { dropKeys: [ 'stack' ] } ) } ]
|
|
95
|
+
} );
|
|
96
|
+
};
|
|
60
97
|
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* Ref: https://typescript.temporal.io/api/classes/common.ApplicationFailure#details
|
|
98
|
+
/**
|
|
99
|
+
* This is mostly likely an error in one Activity (or other Temporal parts)
|
|
100
|
+
* Re-throw as it is and sink back the error without failure, for the same reason as in cancellation.
|
|
65
101
|
*/
|
|
66
|
-
|
|
102
|
+
if ( error instanceof TemporalFailure ) {
|
|
103
|
+
sinks.workflow.error( serializeError( error ) );
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Workflow Task failure, do not sink as this retries the Task only, not the Workflow
|
|
108
|
+
throw error;
|
|
67
109
|
}
|
|
68
110
|
}
|
|
69
111
|
};
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { ApplicationFailure } from '@temporalio/common';
|
|
3
|
-
import {
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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( {
|
|
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( {
|
|
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( '
|
|
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 ).
|
|
231
|
+
expect( workflowErrorMock ).not.toHaveBeenCalled();
|
|
145
232
|
expect( workflowEndMock ).not.toHaveBeenCalled();
|
|
146
233
|
} );
|
|
147
234
|
|
|
148
|
-
it(
|
|
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
|
|
153
|
-
|
|
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
|
|
163
|
-
|
|
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(
|
|
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( '
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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: '
|
|
192
|
-
type: '
|
|
284
|
+
message: 'provider rejected request',
|
|
285
|
+
type: 'TypeError',
|
|
193
286
|
nonRetryable: true,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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(
|
|
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(
|
|
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( '
|
|
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
|
-
|
|
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.
|
|
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(
|
|
248
|
-
const [ contents, filename ] = writeFileInTempDirMock.mock.calls[
|
|
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
|
} );
|
package/src/worker/log_hooks.js
CHANGED
|
@@ -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 ) &&
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 ) &&
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 } ) =>
|