@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.
- package/package.json +12 -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 +2 -1
- package/src/hooks/index.spec.js +15 -7
- 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/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/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/index.js +10 -1
- package/src/worker/index.spec.js +23 -8
- 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
|
@@ -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 } ) =>
|
|
@@ -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:
|
|
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 =
|
|
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', () => {
|
package/src/worker/sinks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/worker/sinks.spec.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|