@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,9 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { ApplicationFailure } from '@temporalio/common';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { ApplicationFailure, CompleteAsyncError } from '@temporalio/common';
|
|
3
|
+
import { ActivitySpecialOutput, BusEventType, METADATA_ACCESS_SYMBOL } from '#consts';
|
|
4
|
+
import { TransparentFatalError } from '#errors';
|
|
5
5
|
|
|
6
|
-
const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
|
|
7
6
|
const heartbeatMock = vi.hoisted( () => vi.fn() );
|
|
8
7
|
const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async fn => fn() ) );
|
|
9
8
|
const activityInfoMock = vi.hoisted( () => ( {
|
|
@@ -64,15 +63,6 @@ vi.mock( './headers.js', () => ( {
|
|
|
64
63
|
const mainEventBusEmitMock = vi.fn();
|
|
65
64
|
vi.mock( '#bus', () => ( { mainEventBus: { emit: mainEventBusEmitMock } } ) );
|
|
66
65
|
|
|
67
|
-
vi.mock( '#consts', async importOriginal => {
|
|
68
|
-
const actual = await importOriginal();
|
|
69
|
-
return {
|
|
70
|
-
...actual, get METADATA_ACCESS_SYMBOL() {
|
|
71
|
-
return METADATA_ACCESS_SYMBOL;
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
} );
|
|
75
|
-
|
|
76
66
|
vi.mock( '../configs.js', () => ( {
|
|
77
67
|
get activityHeartbeatEnabled() {
|
|
78
68
|
return process.env.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED !== 'false';
|
|
@@ -93,24 +83,12 @@ const makeInput = () => ( {
|
|
|
93
83
|
headers: {}
|
|
94
84
|
} );
|
|
95
85
|
|
|
96
|
-
const httpRequestAttribute = {
|
|
97
|
-
type: Attribute.HTTPRequestCount.TYPE,
|
|
98
|
-
url: 'https://api.example.test/items',
|
|
99
|
-
requestId: 'req-1'
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const httpRequestAggregations = {
|
|
103
|
-
cost: { total: 0 },
|
|
104
|
-
tokens: { total: 0 },
|
|
105
|
-
httpRequests: { total: 1 }
|
|
106
|
-
};
|
|
107
|
-
|
|
108
86
|
describe( 'ActivityExecutionInterceptor', () => {
|
|
109
87
|
beforeEach( () => {
|
|
110
88
|
vi.clearAllMocks();
|
|
111
89
|
activityInfoMock.workflowType = 'myWorkflow';
|
|
90
|
+
delete activityInfoMock.retryPolicy;
|
|
112
91
|
vi.useFakeTimers();
|
|
113
|
-
vi.resetModules();
|
|
114
92
|
// Default: heartbeat enabled with 50ms interval for fast tests
|
|
115
93
|
vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED', 'true' );
|
|
116
94
|
vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS', '50' );
|
|
@@ -126,22 +104,14 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
126
104
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
127
105
|
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
128
106
|
|
|
129
|
-
|
|
130
|
-
vi.advanceTimersByTime( 0 );
|
|
131
|
-
const output = await promise;
|
|
132
|
-
|
|
133
|
-
expect( output ).toEqual( {
|
|
134
|
-
output: { result: 'ok' },
|
|
135
|
-
aggregations: null,
|
|
136
|
-
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1
|
|
137
|
-
} );
|
|
107
|
+
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
|
|
138
108
|
expect( mainEventBusEmitMock ).toHaveBeenCalledWith(
|
|
139
109
|
BusEventType.ACTIVITY_START,
|
|
140
110
|
{ activityInfo: activityInfoMock, workflowDetails: workflowDetailsMock, outputActivityKind: 'step' }
|
|
141
111
|
);
|
|
142
112
|
expect( mainEventBusEmitMock ).toHaveBeenCalledWith(
|
|
143
113
|
BusEventType.ACTIVITY_END,
|
|
144
|
-
{ activityInfo: activityInfoMock,
|
|
114
|
+
{ activityInfo: activityInfoMock, workflowDetails: workflowDetailsMock, outputActivityKind: 'step' }
|
|
145
115
|
);
|
|
146
116
|
expect( addEventStartMock ).toHaveBeenCalledWith( {
|
|
147
117
|
id: 'act-1',
|
|
@@ -161,74 +131,36 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
161
131
|
workflowDetails: workflowDetailsMock,
|
|
162
132
|
outputActivityKind: 'step',
|
|
163
133
|
workflowFilename: '/workflows/myWorkflow.js',
|
|
164
|
-
traceInfo: traceInfoMock
|
|
165
|
-
addAttribute: expect.any( Function )
|
|
134
|
+
traceInfo: traceInfoMock
|
|
166
135
|
} )
|
|
167
136
|
);
|
|
168
137
|
} );
|
|
169
138
|
|
|
170
|
-
it( '
|
|
171
|
-
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
172
|
-
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
173
|
-
const next = vi.fn( () => ( { result: 'sync' } ) );
|
|
174
|
-
|
|
175
|
-
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
|
|
176
|
-
output: { result: 'sync' },
|
|
177
|
-
aggregations: null,
|
|
178
|
-
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1
|
|
179
|
-
} );
|
|
180
|
-
|
|
181
|
-
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
|
|
182
|
-
expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'sync' }, traceInfo: traceInfoMock } );
|
|
183
|
-
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
184
|
-
} );
|
|
185
|
-
|
|
186
|
-
it( 'returns collected aggregations after successful execution', async () => {
|
|
187
|
-
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
188
|
-
ctx.addAttribute( httpRequestAttribute );
|
|
189
|
-
return fn();
|
|
190
|
-
} );
|
|
191
|
-
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
192
|
-
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
193
|
-
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
194
|
-
|
|
195
|
-
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
|
|
196
|
-
output: { result: 'ok' },
|
|
197
|
-
aggregations: httpRequestAggregations,
|
|
198
|
-
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1
|
|
199
|
-
} );
|
|
200
|
-
|
|
201
|
-
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, {
|
|
202
|
-
activityInfo: activityInfoMock,
|
|
203
|
-
aggregations: httpRequestAggregations,
|
|
204
|
-
workflowDetails: workflowDetailsMock,
|
|
205
|
-
outputActivityKind: 'step'
|
|
206
|
-
} );
|
|
207
|
-
} );
|
|
208
|
-
|
|
209
|
-
it( 'stores collected aggregations in ApplicationFailure details after failed execution', async () => {
|
|
210
|
-
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
211
|
-
ctx.addAttribute( httpRequestAttribute );
|
|
212
|
-
return fn();
|
|
213
|
-
} );
|
|
139
|
+
it( 'wraps native errors with serialized details', async () => {
|
|
214
140
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
215
141
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
216
142
|
const error = new Error( 'step failed' );
|
|
143
|
+
error.code = 'ESTEP';
|
|
217
144
|
const next = vi.fn().mockRejectedValue( error );
|
|
218
145
|
|
|
219
146
|
const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
|
|
147
|
+
|
|
220
148
|
expect( thrown ).toBeInstanceOf( ApplicationFailure );
|
|
221
149
|
expect( thrown ).toMatchObject( {
|
|
222
150
|
message: 'step failed',
|
|
223
151
|
type: 'Error',
|
|
224
152
|
details: [ {
|
|
225
|
-
|
|
153
|
+
error: {
|
|
154
|
+
name: 'Error',
|
|
155
|
+
message: 'step failed',
|
|
156
|
+
code: 'ESTEP'
|
|
157
|
+
}
|
|
226
158
|
} ],
|
|
227
|
-
cause: error
|
|
159
|
+
cause: error,
|
|
160
|
+
nonRetryable: false
|
|
228
161
|
} );
|
|
229
162
|
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
|
|
230
163
|
activityInfo: activityInfoMock,
|
|
231
|
-
aggregations: httpRequestAggregations,
|
|
232
164
|
workflowDetails: workflowDetailsMock,
|
|
233
165
|
outputActivityKind: 'step',
|
|
234
166
|
error
|
|
@@ -237,105 +169,93 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
237
169
|
expect( addEventEndMock ).not.toHaveBeenCalled();
|
|
238
170
|
} );
|
|
239
171
|
|
|
240
|
-
it( '
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
} );
|
|
172
|
+
it( 'marks inherited configured error types as non-retryable', async () => {
|
|
173
|
+
class ValidationError extends Error {}
|
|
174
|
+
class SpecificValidationError extends ValidationError {}
|
|
175
|
+
activityInfoMock.retryPolicy = { nonRetryableErrorTypes: [ 'ValidationError' ] };
|
|
245
176
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
246
177
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
247
|
-
const error = new
|
|
248
|
-
error.details = [ { domain: { reason: 'bad-input' } } ];
|
|
178
|
+
const error = new SpecificValidationError( 'invalid input' );
|
|
249
179
|
const next = vi.fn().mockRejectedValue( error );
|
|
250
180
|
|
|
251
181
|
const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
|
|
252
182
|
|
|
253
|
-
expect( thrown
|
|
254
|
-
|
|
255
|
-
{ aggregations: httpRequestAggregations }
|
|
256
|
-
] );
|
|
257
|
-
} );
|
|
258
|
-
|
|
259
|
-
it( 'rethrows the original error when failed execution collected no attributes', async () => {
|
|
260
|
-
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
261
|
-
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
262
|
-
const error = new Error( 'step failed' );
|
|
263
|
-
const next = vi.fn().mockRejectedValue( error );
|
|
264
|
-
|
|
265
|
-
await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
|
|
266
|
-
} );
|
|
267
|
-
|
|
268
|
-
it( 'rethrows the original error with existing details when failed execution collected no attributes', async () => {
|
|
269
|
-
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
270
|
-
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
271
|
-
const error = new Error( 'step failed' );
|
|
272
|
-
error.details = [ { domain: { reason: 'bad-input' } } ];
|
|
273
|
-
const next = vi.fn().mockRejectedValue( error );
|
|
274
|
-
|
|
275
|
-
await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
|
|
276
|
-
expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
|
|
277
|
-
} );
|
|
278
|
-
|
|
279
|
-
it( 'wraps existing ApplicationFailure without creating a self-cause', async () => {
|
|
280
|
-
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
281
|
-
ctx.addAttribute( httpRequestAttribute );
|
|
282
|
-
return fn();
|
|
283
|
-
} );
|
|
284
|
-
const error = ApplicationFailure.create( {
|
|
285
|
-
message: 'application failed',
|
|
286
|
-
type: 'OriginalType',
|
|
183
|
+
expect( thrown ).toMatchObject( {
|
|
184
|
+
type: 'SpecificValidationError',
|
|
287
185
|
nonRetryable: true,
|
|
288
|
-
|
|
186
|
+
cause: error
|
|
289
187
|
} );
|
|
188
|
+
} );
|
|
189
|
+
|
|
190
|
+
it( 'uses a transparent error cause while remaining non-retryable without retry policy metadata', async () => {
|
|
290
191
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
291
192
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
193
|
+
const cause = new TypeError( 'provider rejected request' );
|
|
194
|
+
cause.code = 'EAUTH';
|
|
195
|
+
const error = new TransparentFatalError( cause );
|
|
292
196
|
const next = vi.fn().mockRejectedValue( error );
|
|
293
197
|
|
|
294
198
|
const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
|
|
295
199
|
|
|
296
|
-
expect( thrown ).toBeInstanceOf( ApplicationFailure );
|
|
297
|
-
expect( thrown ).not.toBe( error );
|
|
298
|
-
expect( thrown.cause ).toBe( error );
|
|
299
|
-
expect( thrown.cause ).not.toBe( thrown );
|
|
300
200
|
expect( thrown ).toMatchObject( {
|
|
301
|
-
message: '
|
|
302
|
-
type: '
|
|
201
|
+
message: 'provider rejected request',
|
|
202
|
+
type: 'TypeError',
|
|
303
203
|
nonRetryable: true,
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
204
|
+
cause,
|
|
205
|
+
details: [ {
|
|
206
|
+
error: {
|
|
207
|
+
name: 'TypeError',
|
|
208
|
+
message: 'provider rejected request',
|
|
209
|
+
code: 'EAUTH'
|
|
210
|
+
}
|
|
211
|
+
} ]
|
|
212
|
+
} );
|
|
213
|
+
expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: cause, traceInfo: traceInfoMock } );
|
|
214
|
+
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
|
|
215
|
+
activityInfo: activityInfoMock,
|
|
216
|
+
workflowDetails: workflowDetailsMock,
|
|
217
|
+
outputActivityKind: 'step',
|
|
218
|
+
error: cause
|
|
308
219
|
} );
|
|
309
220
|
} );
|
|
310
221
|
|
|
311
|
-
it( '
|
|
222
|
+
it( 'rethrows existing Temporal failures unchanged', async () => {
|
|
312
223
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
313
224
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
314
|
-
const error =
|
|
225
|
+
const error = ApplicationFailure.nonRetryable( 'domain failed', 'DomainFailure', { reason: 'invalid' } );
|
|
315
226
|
const next = vi.fn().mockRejectedValue( error );
|
|
316
227
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
await expect( promise ).rejects.toThrow( 'step failed' );
|
|
321
|
-
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.any( Object ) );
|
|
228
|
+
await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
|
|
229
|
+
expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: error, traceInfo: traceInfoMock } );
|
|
322
230
|
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
|
|
323
231
|
activityInfo: activityInfoMock,
|
|
324
|
-
aggregations: null,
|
|
325
232
|
workflowDetails: workflowDetailsMock,
|
|
326
233
|
outputActivityKind: 'step',
|
|
327
234
|
error
|
|
328
235
|
} );
|
|
329
|
-
expect( addEventStartMock ).toHaveBeenCalledOnce();
|
|
330
|
-
expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: error, traceInfo: traceInfoMock } );
|
|
331
|
-
expect( addEventEndMock ).not.toHaveBeenCalled();
|
|
332
236
|
} );
|
|
333
237
|
|
|
334
|
-
it( '
|
|
238
|
+
it( 'records async completion handoff as a trace end only', async () => {
|
|
239
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
240
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
241
|
+
const error = new CompleteAsyncError();
|
|
242
|
+
const next = vi.fn().mockRejectedValue( error );
|
|
243
|
+
|
|
244
|
+
await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
|
|
245
|
+
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.any( Object ) );
|
|
246
|
+
expect( mainEventBusEmitMock ).toHaveBeenCalledTimes( 1 );
|
|
247
|
+
expect( addEventEndMock ).toHaveBeenCalledWith( {
|
|
248
|
+
id: 'act-1',
|
|
249
|
+
details: ActivitySpecialOutput.ASYNC_HANDOFF,
|
|
250
|
+
traceInfo: traceInfoMock
|
|
251
|
+
} );
|
|
252
|
+
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
253
|
+
} );
|
|
254
|
+
|
|
255
|
+
it( 'sends periodic heartbeats and stops after successful execution', async () => {
|
|
335
256
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
336
257
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
337
258
|
|
|
338
|
-
// next() resolves only after we manually resolve it, simulating a long-running activity
|
|
339
259
|
const deferred = { resolve: null };
|
|
340
260
|
const next = vi.fn().mockImplementation( () => new Promise( r => {
|
|
341
261
|
deferred.resolve = r;
|
|
@@ -356,16 +276,6 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
356
276
|
|
|
357
277
|
deferred.resolve( { result: 'done' } );
|
|
358
278
|
await promise;
|
|
359
|
-
} );
|
|
360
|
-
|
|
361
|
-
it( 'clears heartbeat interval after activity completes', async () => {
|
|
362
|
-
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
363
|
-
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
364
|
-
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
365
|
-
|
|
366
|
-
const promise = interceptor.execute( makeInput(), next );
|
|
367
|
-
vi.advanceTimersByTime( 0 );
|
|
368
|
-
await promise;
|
|
369
279
|
|
|
370
280
|
heartbeatMock.mockClear();
|
|
371
281
|
vi.advanceTimersByTime( 500 );
|
|
@@ -379,7 +289,7 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
379
289
|
|
|
380
290
|
const promise = interceptor.execute( makeInput(), next );
|
|
381
291
|
vi.advanceTimersByTime( 0 );
|
|
382
|
-
await promise.
|
|
292
|
+
await expect( promise ).rejects.toBeInstanceOf( ApplicationFailure );
|
|
383
293
|
|
|
384
294
|
heartbeatMock.mockClear();
|
|
385
295
|
vi.advanceTimersByTime( 500 );
|
|
@@ -391,7 +301,6 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
391
301
|
const workflows = [ { name: 'myWorkflow', path: '/workflows/myWorkflow.js', aliases: [ 'myWorkflowOld' ] } ];
|
|
392
302
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows } );
|
|
393
303
|
|
|
394
|
-
// Override context to use alias as workflowType
|
|
395
304
|
activityInfoMock.workflowType = 'myWorkflowOld';
|
|
396
305
|
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
397
306
|
|
|
@@ -399,14 +308,10 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
399
308
|
vi.advanceTimersByTime( 0 );
|
|
400
309
|
await promise;
|
|
401
310
|
|
|
402
|
-
// Should resolve to the correct path despite using the alias
|
|
403
311
|
expect( runWithContextMock ).toHaveBeenCalledWith(
|
|
404
312
|
expect.any( Function ),
|
|
405
313
|
expect.objectContaining( { workflowFilename: '/workflows/myWorkflow.js' } )
|
|
406
314
|
);
|
|
407
|
-
|
|
408
|
-
// Restore for other tests
|
|
409
|
-
activityInfoMock.workflowType = 'myWorkflow';
|
|
410
315
|
} );
|
|
411
316
|
|
|
412
317
|
it( 'does not heartbeat when OUTPUT_ACTIVITY_HEARTBEAT_ENABLED is false', async () => {
|
|
@@ -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
|
};
|