@outputai/core 0.10.1-dev.b7b2fbe.0 → 0.10.1-next.2caa4a1.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.
@@ -1,68 +1,75 @@
1
- import { messageBus } from '#bus';
2
- import { BusEventType, ComponentType, WORKFLOW_CATALOG } from '#consts';
1
+ import { mainEventBus, stepEventBus } from '#bus';
2
+ import { BusEventType } from '#consts';
3
3
  import { createChildLogger } from '#logger';
4
+ import { pendingHooks } from './pending_hooks.js';
4
5
 
5
6
  const log = createChildLogger( 'Hooks' );
6
7
 
7
8
  /**
8
- * Invokes an external hook handler function with a try catch around it
9
+ * Invokes a function within a try catch and log the error.
9
10
  *
10
11
  * @param {Function} fn
11
12
  * @param {any} args - Args to invoke the function with
12
13
  * @param {string} hookName - hookName to identify this hook function in the logs
13
14
  */
14
- const safeInvoke = async ( fn, args, hookName ) => {
15
- try {
16
- await fn( args );
17
- } catch ( error ) {
18
- log.error( `${hookName} hook error`, { message: error.message, stack: error.stack } );
19
- }
15
+ const callHookCb = ( fn, args, hookName ) => {
16
+ const promise = Promise.resolve()
17
+ .then( () => fn( args ) )
18
+ .catch( e => log.error( `${hookName} hook error`, { message: e.message, stack: e.stack } ) )
19
+ .finally( () => pendingHooks.delete( promise ) );
20
+ pendingHooks.add( promise );
21
+ return promise;
20
22
  };
21
23
 
22
- /** Triggers on any errors: workflow, activity and runtime */
23
- export const onError = handler => {
24
- messageBus.on( BusEventType.ACTIVITY_ERROR, async payload =>
25
- safeInvoke( handler, { source: 'activity', ...payload }, 'onError' ) );
26
- messageBus.on( BusEventType.WORKFLOW_ERROR, async payload =>
27
- safeInvoke( handler, { source: 'workflow', ...payload }, 'onError' ) );
28
- messageBus.on( BusEventType.RUNTIME_ERROR, async payload =>
29
- safeInvoke( handler, { source: 'runtime', ...payload }, 'onError' ) );
24
+ // General Life-cycle
25
+ // --------------------------------------
26
+ const onError = cb => {
27
+ mainEventBus.on( BusEventType.ACTIVITY_ERROR, async payload =>
28
+ callHookCb( cb, { source: 'activity', ...payload }, 'onError' ) );
29
+ mainEventBus.on( BusEventType.WORKFLOW_ERROR, async payload =>
30
+ callHookCb( cb, { source: 'workflow', ...payload }, 'onError' ) );
31
+ mainEventBus.on( BusEventType.RUNTIME_ERROR, async payload =>
32
+ callHookCb( cb, { source: 'runtime', ...payload }, 'onError' ) );
30
33
  };
31
34
 
32
- /** Listen to worker before start events */
33
- export const onBeforeWorkerStart = handler => messageBus.on( BusEventType.WORKER_BEFORE_START, () =>
34
- safeInvoke( handler, undefined, 'onBeforeWorkerStart' ) );
35
+ const onBeforeWorkerStart = cb => mainEventBus.on( BusEventType.WORKER_BEFORE_START, () => callHookCb( cb, undefined, 'onBeforeWorkerStart' ) );
35
36
 
36
- /** Catalog workflow events should not be emitted */
37
- const shouldEmitWorkflowEvent = workflowDetails => WORKFLOW_CATALOG !== workflowDetails.workflowType;
37
+ // Workflow Life-cycle
38
+ // --------------------------------------
39
+ const onWorkflowStart = cb => mainEventBus.on( BusEventType.WORKFLOW_START, payload => callHookCb( cb, payload, 'onWorkflowStart' ) );
40
+ const onWorkflowEnd = cb => mainEventBus.on( BusEventType.WORKFLOW_END, payload => callHookCb( cb, payload, 'onWorkflowEnd' ) );
41
+ const onWorkflowError = cb => mainEventBus.on( BusEventType.WORKFLOW_ERROR, payload => callHookCb( cb, payload, 'onWorkflowError' ) );
38
42
 
39
- /** Listen to workflow start events, excludes catalog workflow */
40
- export const onWorkflowStart = handler => messageBus.on( BusEventType.WORKFLOW_START, ( { workflowDetails, ...eventFields } ) =>
41
- shouldEmitWorkflowEvent( workflowDetails ) ? safeInvoke( handler, { workflowDetails, ...eventFields }, 'onWorkflowStart' ) : null );
43
+ // Activity Life-cycle
44
+ // --------------------------------------
45
+ const onActivityStart = cb => mainEventBus.on( BusEventType.ACTIVITY_START, fields => callHookCb( cb, fields, 'onActivityStart' ) );
46
+ const onActivityEnd = cb => mainEventBus.on( BusEventType.ACTIVITY_END, fields => callHookCb( cb, fields, 'onActivityEnd' ) );
47
+ const onActivityError = cb => mainEventBus.on( BusEventType.ACTIVITY_ERROR, fields => callHookCb( cb, fields, 'onActivityError' ) );
42
48
 
43
- /** Listen to workflow end events, excludes catalog workflow */
44
- export const onWorkflowEnd = handler => messageBus.on( BusEventType.WORKFLOW_END, ( { workflowDetails, ...eventFields } ) =>
45
- shouldEmitWorkflowEvent( workflowDetails ) ? safeInvoke( handler, { workflowDetails, ...eventFields }, 'onWorkflowEnd' ) : null );
46
-
47
- /** Listen to workflow error events, excludes catalog workflow */
48
- export const onWorkflowError = handler => messageBus.on( BusEventType.WORKFLOW_ERROR, ( { workflowDetails, ...eventFields } ) =>
49
- shouldEmitWorkflowEvent( workflowDetails ) ? safeInvoke( handler, { workflowDetails, ...eventFields }, 'onWorkflowError' ) : null );
50
-
51
- /** Internal activities do not trigger hooks */
52
- const shouldEmitActivityEvent = outputActivityKind => outputActivityKind !== ComponentType.INTERNAL_STEP;
53
-
54
- /** Listen to workflow start events, excludes catalog workflow */
55
- export const onActivityStart = handler => messageBus.on( BusEventType.ACTIVITY_START, ( { outputActivityKind, ...eventFields } ) =>
56
- shouldEmitActivityEvent( outputActivityKind ) ? safeInvoke( handler, { outputActivityKind, ...eventFields }, 'onActivityStart' ) : null );
57
-
58
- /** Listen to workflow end events, excludes catalog workflow */
59
- export const onActivityEnd = handler => messageBus.on( BusEventType.ACTIVITY_END, ( { outputActivityKind, ...eventFields } ) =>
60
- shouldEmitActivityEvent( outputActivityKind ) ? safeInvoke( handler, { outputActivityKind, ...eventFields }, 'onActivityEnd' ) : null );
49
+ // Generic Events
50
+ // --------------------------------------
51
+ /** Listen to both sdk and custom events */
52
+ const on = ( eventName, cb ) => {
53
+ stepEventBus.on( `sdk:${eventName}`, payload => callHookCb( cb, payload, eventName ) );
54
+ stepEventBus.on( `usr:${eventName}`, payload => callHookCb( cb, payload, eventName ) );
55
+ };
61
56
 
62
- /** Listen to workflow error events, excludes catalog workflow */
63
- export const onActivityError = handler => messageBus.on( BusEventType.ACTIVITY_ERROR, ( { outputActivityKind, ...eventFields } ) =>
64
- shouldEmitActivityEvent( outputActivityKind ) ? safeInvoke( handler, { outputActivityKind, ...eventFields }, 'onActivityError' ) : null );
57
+ /**
58
+ * Emits a custom event
59
+ * @param {string} eventName
60
+ * @param {any} payload
61
+ */
62
+ const emit = ( eventName, payload ) => stepEventBus.emit( `usr:${eventName}`, payload );
65
63
 
66
- /** Generic listener for events emitted elsewhere (outside core) */
67
- export const on = ( eventName, handler ) => messageBus.on( `external:${eventName}`, payload =>
68
- safeInvoke( handler, payload, eventName ) );
64
+ export {
65
+ emit,
66
+ on,
67
+ onActivityEnd,
68
+ onActivityError,
69
+ onActivityStart,
70
+ onBeforeWorkerStart,
71
+ onError,
72
+ onWorkflowEnd,
73
+ onWorkflowError,
74
+ onWorkflowStart
75
+ };
@@ -1,22 +1,33 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { BusEventType, ComponentType, WORKFLOW_CATALOG } from '#consts';
2
+ import { BusEventType } from '#consts';
3
3
 
4
4
  const logErrorMock = vi.hoisted( () => vi.fn() );
5
5
  const createChildLoggerMock = vi.hoisted( () =>
6
6
  vi.fn( () => ( { error: logErrorMock } ) )
7
7
  );
8
8
 
9
- const onHandlers = vi.hoisted( () => ( {} ) );
10
- const messageBusMock = vi.hoisted( () => ( {
9
+ const mainOnHandlers = vi.hoisted( () => ( {} ) );
10
+ const stepOnHandlers = vi.hoisted( () => ( {} ) );
11
+ const mainEventBusMock = vi.hoisted( () => ( {
11
12
  on: vi.fn( ( eventType, handler ) => {
12
- onHandlers[eventType] = handler;
13
+ mainOnHandlers[eventType] = handler;
14
+ } )
15
+ } ) );
16
+ const stepEventBusMock = vi.hoisted( () => ( {
17
+ emit: vi.fn( () => true ),
18
+ on: vi.fn( ( eventType, handler ) => {
19
+ stepOnHandlers[eventType] = handler;
13
20
  } )
14
21
  } ) );
15
22
 
16
23
  vi.mock( '#logger', () => ( { createChildLogger: createChildLoggerMock } ) );
17
- vi.mock( '#bus', () => ( { messageBus: messageBusMock } ) );
24
+ vi.mock( '#bus', () => ( {
25
+ mainEventBus: mainEventBusMock,
26
+ stepEventBus: stepEventBusMock
27
+ } ) );
18
28
 
19
29
  import {
30
+ emit,
20
31
  on,
21
32
  onActivityEnd,
22
33
  onActivityError,
@@ -27,6 +38,7 @@ import {
27
38
  onWorkflowError,
28
39
  onWorkflowStart
29
40
  } from './index.js';
41
+ import { pendingHooks } from './pending_hooks.js';
30
42
 
31
43
  const workflowDetails = {
32
44
  workflowId: 'wf-1',
@@ -38,11 +50,6 @@ const workflowDetails = {
38
50
  attempt: 1
39
51
  };
40
52
 
41
- const catalogWorkflowDetails = {
42
- ...workflowDetails,
43
- workflowType: WORKFLOW_CATALOG
44
- };
45
-
46
53
  const activityInfo = {
47
54
  activityId: 'act-1',
48
55
  activityType: 'wf#step',
@@ -61,8 +68,12 @@ const aggregations = {
61
68
  describe( 'hooks/index', () => {
62
69
  beforeEach( () => {
63
70
  vi.clearAllMocks();
64
- Object.keys( onHandlers ).forEach( k => {
65
- delete onHandlers[k];
71
+ pendingHooks.clear();
72
+ Object.keys( mainOnHandlers ).forEach( k => {
73
+ delete mainOnHandlers[k];
74
+ } );
75
+ Object.keys( stepOnHandlers ).forEach( k => {
76
+ delete stepOnHandlers[k];
66
77
  } );
67
78
  } );
68
79
 
@@ -71,9 +82,9 @@ describe( 'hooks/index', () => {
71
82
  const handler = vi.fn().mockResolvedValue( undefined );
72
83
  onError( handler );
73
84
 
74
- expect( messageBusMock.on ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, expect.any( Function ) );
75
- expect( messageBusMock.on ).toHaveBeenCalledWith( BusEventType.WORKFLOW_ERROR, expect.any( Function ) );
76
- expect( messageBusMock.on ).toHaveBeenCalledWith( BusEventType.RUNTIME_ERROR, expect.any( Function ) );
85
+ expect( mainEventBusMock.on ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, expect.any( Function ) );
86
+ expect( mainEventBusMock.on ).toHaveBeenCalledWith( BusEventType.WORKFLOW_ERROR, expect.any( Function ) );
87
+ expect( mainEventBusMock.on ).toHaveBeenCalledWith( BusEventType.RUNTIME_ERROR, expect.any( Function ) );
77
88
  } );
78
89
 
79
90
  it( 'invokes handler with activity-shaped payload, forwarding bus fields', async () => {
@@ -81,7 +92,7 @@ describe( 'hooks/index', () => {
81
92
  onError( handler );
82
93
 
83
94
  const err = new Error( 'act-fail' );
84
- await onHandlers[BusEventType.ACTIVITY_ERROR]( {
95
+ await mainOnHandlers[BusEventType.ACTIVITY_ERROR]( {
85
96
  eventId: 'evt-act-1',
86
97
  eventDate,
87
98
  activityInfo,
@@ -108,7 +119,7 @@ describe( 'hooks/index', () => {
108
119
  onError( handler );
109
120
 
110
121
  const err = new Error( 'wf-fail' );
111
- await onHandlers[BusEventType.WORKFLOW_ERROR]( {
122
+ await mainOnHandlers[BusEventType.WORKFLOW_ERROR]( {
112
123
  eventId: 'evt-wf-1',
113
124
  eventDate,
114
125
  workflowDetails,
@@ -131,7 +142,7 @@ describe( 'hooks/index', () => {
131
142
  onError( handler );
132
143
 
133
144
  const error = new Error( 'rt' );
134
- await onHandlers[BusEventType.RUNTIME_ERROR]( { eventId: 'evt-rt-1', eventDate, error } );
145
+ await mainOnHandlers[BusEventType.RUNTIME_ERROR]( { eventId: 'evt-rt-1', eventDate, error } );
135
146
 
136
147
  expect( handler ).toHaveBeenCalledWith( { eventId: 'evt-rt-1', eventDate, source: 'runtime', error } );
137
148
  } );
@@ -142,68 +153,52 @@ describe( 'hooks/index', () => {
142
153
  const handler = vi.fn().mockResolvedValue( undefined );
143
154
  onBeforeWorkerStart( handler );
144
155
 
145
- expect( messageBusMock.on ).toHaveBeenCalledWith( BusEventType.WORKER_BEFORE_START, expect.any( Function ) );
146
- await onHandlers[BusEventType.WORKER_BEFORE_START]();
156
+ expect( mainEventBusMock.on ).toHaveBeenCalledWith( BusEventType.WORKER_BEFORE_START, expect.any( Function ) );
157
+ await mainOnHandlers[BusEventType.WORKER_BEFORE_START]();
147
158
 
148
159
  expect( handler ).toHaveBeenCalledWith( undefined );
149
160
  } );
150
161
  } );
151
162
 
152
- describe( 'onWorkflowStart', () => {
153
- it( 'skips catalog workflow and forwards bus fields for real workflows', async () => {
154
- const handler = vi.fn().mockResolvedValue( undefined );
155
- onWorkflowStart( handler );
156
-
157
- await Promise.resolve( onHandlers[BusEventType.WORKFLOW_START]( {
158
- eventId: 'evt-ignored', eventDate, workflowDetails: catalogWorkflowDetails
159
- } ) );
160
- expect( handler ).not.toHaveBeenCalled();
161
-
162
- await Promise.resolve( onHandlers[BusEventType.WORKFLOW_START]( {
163
- eventId: 'evt-start-1', eventDate, workflowDetails, extra: 'passthrough'
164
- } ) );
165
- expect( handler ).toHaveBeenCalledWith( {
166
- eventId: 'evt-start-1', eventDate, workflowDetails, extra: 'passthrough'
167
- } );
163
+ it( 'tracks a hook callback until it settles', async () => {
164
+ const deferred = { resolve: null };
165
+ const handlerPromise = new Promise( resolve => {
166
+ deferred.resolve = resolve;
168
167
  } );
169
- } );
168
+ const handler = vi.fn( () => handlerPromise );
169
+ onActivityEnd( handler );
170
170
 
171
- describe( 'onWorkflowEnd', () => {
172
- it( 'skips catalog workflow and forwards bus fields for real workflows', async () => {
173
- const handler = vi.fn().mockResolvedValue( undefined );
174
- onWorkflowEnd( handler );
171
+ const callbackPromise = mainOnHandlers[BusEventType.ACTIVITY_END]( { activityInfo } );
175
172
 
176
- await Promise.resolve( onHandlers[BusEventType.WORKFLOW_END]( {
177
- eventId: 'evt-ignored', eventDate, workflowDetails: catalogWorkflowDetails
178
- } ) );
179
- expect( handler ).not.toHaveBeenCalled();
173
+ expect( pendingHooks.size ).toBe( 1 );
180
174
 
181
- await Promise.resolve( onHandlers[BusEventType.WORKFLOW_END]( {
182
- eventId: 'evt-end-1', eventDate, workflowDetails, extra: 'passthrough'
183
- } ) );
184
- expect( handler ).toHaveBeenCalledWith( {
185
- eventId: 'evt-end-1', eventDate, workflowDetails, extra: 'passthrough'
186
- } );
187
- } );
175
+ deferred.resolve();
176
+ await callbackPromise;
177
+ expect( pendingHooks.size ).toBe( 0 );
188
178
  } );
189
179
 
190
- describe( 'onWorkflowError', () => {
191
- it( 'skips catalog workflow and forwards bus fields for real workflows', async () => {
180
+ describe( 'workflow lifecycle hooks', () => {
181
+ const cases = [
182
+ [ 'onWorkflowStart', onWorkflowStart, BusEventType.WORKFLOW_START, {} ],
183
+ [ 'onWorkflowEnd', onWorkflowEnd, BusEventType.WORKFLOW_END, {} ],
184
+ [ 'onWorkflowError', onWorkflowError, BusEventType.WORKFLOW_ERROR, { error: new Error( 'workflow failed' ) } ]
185
+ ];
186
+
187
+ it.each( cases )( '%s forwards bus fields', async ( _name, registerHook, eventType, extraFields ) => {
192
188
  const handler = vi.fn().mockResolvedValue( undefined );
193
- const err = new Error( 'wf' );
194
- onWorkflowError( handler );
189
+ const payload = {
190
+ eventId: 'evt-workflow-1',
191
+ eventDate,
192
+ workflowDetails,
193
+ extra: 'passthrough',
194
+ ...extraFields
195
+ };
196
+ registerHook( handler );
195
197
 
196
- await Promise.resolve( onHandlers[BusEventType.WORKFLOW_ERROR]( {
197
- eventId: 'evt-ignored', eventDate, workflowDetails: catalogWorkflowDetails, error: err
198
- } ) );
199
- expect( handler ).not.toHaveBeenCalled();
198
+ expect( mainEventBusMock.on ).toHaveBeenCalledWith( eventType, expect.any( Function ) );
199
+ await mainOnHandlers[eventType]( payload );
200
200
 
201
- await Promise.resolve( onHandlers[BusEventType.WORKFLOW_ERROR]( {
202
- eventId: 'evt-err-1', eventDate, workflowDetails, error: err, extra: 'passthrough'
203
- } ) );
204
- expect( handler ).toHaveBeenCalledWith( {
205
- eventId: 'evt-err-1', eventDate, workflowDetails, error: err, extra: 'passthrough'
206
- } );
201
+ expect( handler ).toHaveBeenCalledWith( payload );
207
202
  } );
208
203
  } );
209
204
 
@@ -214,53 +209,49 @@ describe( 'hooks/index', () => {
214
209
  [ 'onActivityError', onActivityError, BusEventType.ACTIVITY_ERROR, { error: new Error( 'activity failed' ) } ]
215
210
  ];
216
211
 
217
- it.each( cases )( '%s skips internal activities and forwards bus fields', async ( _name, registerHook, eventType, extraFields = {} ) => {
212
+ it.each( cases )( '%s forwards internal activity bus fields', async ( _name, registerHook, eventType, extraFields = {} ) => {
218
213
  const handler = vi.fn().mockResolvedValue( undefined );
219
- registerHook( handler );
220
-
221
- expect( messageBusMock.on ).toHaveBeenCalledWith( eventType, expect.any( Function ) );
222
-
223
- await Promise.resolve( onHandlers[eventType]( {
224
- eventId: 'evt-ignored',
225
- eventDate,
226
- activityInfo,
227
- workflowDetails,
228
- outputActivityKind: ComponentType.INTERNAL_STEP,
229
- ...extraFields
230
- } ) );
231
- expect( handler ).not.toHaveBeenCalled();
232
-
233
- await Promise.resolve( onHandlers[eventType]( {
214
+ const payload = {
234
215
  eventId: 'evt-activity-1',
235
216
  eventDate,
236
217
  activityInfo,
237
218
  workflowDetails,
238
- outputActivityKind: ComponentType.STEP,
219
+ outputActivityKind: 'internal_step',
239
220
  extra: 'passthrough',
240
221
  ...extraFields
241
- } ) );
222
+ };
223
+ registerHook( handler );
242
224
 
243
- expect( handler ).toHaveBeenCalledWith( {
244
- eventId: 'evt-activity-1',
245
- eventDate,
246
- activityInfo,
247
- workflowDetails,
248
- outputActivityKind: ComponentType.STEP,
249
- extra: 'passthrough',
250
- ...extraFields
251
- } );
225
+ expect( mainEventBusMock.on ).toHaveBeenCalledWith( eventType, expect.any( Function ) );
226
+ await mainOnHandlers[eventType]( payload );
227
+
228
+ expect( handler ).toHaveBeenCalledWith( payload );
252
229
  } );
253
230
  } );
254
231
 
255
232
  describe( 'on', () => {
256
- it( 'subscribes to external event channel and forwards payload', async () => {
233
+ it( 'subscribes to SDK and user event channels and forwards payloads', async () => {
257
234
  const handler = vi.fn().mockResolvedValue( undefined );
258
235
  on( 'myEvent', handler );
259
236
 
260
- expect( messageBusMock.on ).toHaveBeenCalledWith( 'external:myEvent', expect.any( Function ) );
261
- await onHandlers['external:myEvent']( { foo: 1 } );
237
+ expect( stepEventBusMock.on ).toHaveBeenCalledWith( 'sdk:myEvent', expect.any( Function ) );
238
+ expect( stepEventBusMock.on ).toHaveBeenCalledWith( 'usr:myEvent', expect.any( Function ) );
239
+ await stepOnHandlers['sdk:myEvent']( { payload: { source: 'sdk' } } );
240
+ await stepOnHandlers['usr:myEvent']( { payload: { source: 'user' } } );
241
+
242
+ expect( handler ).toHaveBeenNthCalledWith( 1, { payload: { source: 'sdk' } } );
243
+ expect( handler ).toHaveBeenNthCalledWith( 2, { payload: { source: 'user' } } );
244
+ } );
245
+ } );
246
+
247
+ describe( 'emit', () => {
248
+ it( 'emits payloads on the user event channel', () => {
249
+ const payload = { foo: 1 };
250
+
251
+ const emitted = emit( 'myEvent', payload );
262
252
 
263
- expect( handler ).toHaveBeenCalledWith( { foo: 1 } );
253
+ expect( stepEventBusMock.emit ).toHaveBeenCalledWith( 'usr:myEvent', payload );
254
+ expect( emitted ).toBe( true );
264
255
  } );
265
256
  } );
266
257
  } );
@@ -0,0 +1,24 @@
1
+ import { createChildLogger } from '#logger';
2
+
3
+ export const pendingHooks = new Set();
4
+
5
+ const log = createChildLogger( 'Hooks' );
6
+ const flushTimeoutMs = 30_000;
7
+
8
+ /**
9
+ * Await all pending hooks to flush for a certain time
10
+ */
11
+ export const flushPendingHooks = async () => {
12
+ const state = { timeout: null };
13
+ try {
14
+ const flushed = await Promise.race( [
15
+ Promise.allSettled( [ ...pendingHooks ] ).then( _ => true ),
16
+ new Promise( r => state.timeout = setTimeout( () => r( false ), flushTimeoutMs ) )
17
+ ] );
18
+ if ( !flushed ) {
19
+ log.warn( 'Some hook callbacks exceeded the timeout and will not be awaited', { count: pendingHooks.size } );
20
+ }
21
+ } finally {
22
+ clearTimeout( state.timeout );
23
+ }
24
+ };
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const logMock = vi.hoisted( () => ( { warn: vi.fn() } ) );
4
+
5
+ vi.mock( '#logger', () => ( { createChildLogger: () => logMock } ) );
6
+
7
+ import { flushPendingHooks, pendingHooks } from './pending_hooks.js';
8
+
9
+ describe( 'pending hooks', () => {
10
+ beforeEach( () => {
11
+ pendingHooks.clear();
12
+ vi.clearAllMocks();
13
+ } );
14
+
15
+ afterEach( () => {
16
+ pendingHooks.clear();
17
+ vi.useRealTimers();
18
+ } );
19
+
20
+ it( 'waits for pending hooks to settle', async () => {
21
+ const deferred = { resolve: null };
22
+ const hookPromise = new Promise( resolve => {
23
+ deferred.resolve = resolve;
24
+ } );
25
+ pendingHooks.add( hookPromise );
26
+
27
+ const flushPromise = flushPendingHooks();
28
+ const state = { flushed: false };
29
+ flushPromise.then( () => {
30
+ state.flushed = true;
31
+ } );
32
+
33
+ await Promise.resolve();
34
+ expect( state.flushed ).toBe( false );
35
+
36
+ deferred.resolve();
37
+ await flushPromise;
38
+ expect( state.flushed ).toBe( true );
39
+ } );
40
+
41
+ it( 'stops waiting after the timeout', async () => {
42
+ vi.useFakeTimers();
43
+ pendingHooks.add( new Promise( () => {} ) );
44
+
45
+ const flushPromise = flushPendingHooks();
46
+ await vi.advanceTimersByTimeAsync( 30_000 );
47
+
48
+ await expect( flushPromise ).resolves.toBeUndefined();
49
+ } );
50
+
51
+ it( 'logs the number of hooks that exceeded the timeout', async () => {
52
+ vi.useFakeTimers();
53
+ pendingHooks.add( new Promise( () => {} ) );
54
+ pendingHooks.add( new Promise( () => {} ) );
55
+
56
+ const flushPromise = flushPendingHooks();
57
+ await vi.advanceTimersByTimeAsync( 30_000 );
58
+ await flushPromise;
59
+
60
+ expect( logMock.warn ).toHaveBeenCalledWith( expect.any( String ), { count: 2 } );
61
+ } );
62
+ } );
@@ -8,7 +8,7 @@ import {
8
8
  import { evaluator } from './evaluator.js';
9
9
  import { step } from './step.js';
10
10
  import { workflow } from './workflow.js';
11
- import { executeInParallel } from './workflow_utils.js';
11
+ import { executeInParallel, hasErrorType } from './workflow_utils.js';
12
12
  import { sendHttpRequest, sendPostRequestAndAwaitWebhook } from './webhook.js';
13
13
  import { Logger } from './logger.js';
14
14
 
@@ -23,6 +23,7 @@ export {
23
23
  EvaluationVerdictResult,
24
24
  EvaluationFeedback,
25
25
  executeInParallel,
26
+ hasErrorType,
26
27
  sendHttpRequest,
27
28
  sendPostRequestAndAwaitWebhook
28
29
  };
@@ -74,3 +74,18 @@ export declare function executeInParallel<T extends readonly ( () => Promise<unk
74
74
  onJobCompleted?: ( result: ParallelResults<T>[number] ) => void;
75
75
  }
76
76
  ): Promise<ParallelResults<T>>;
77
+
78
+ /**
79
+ * Checks whether an error or any error in its cause chain matches an Error class.
80
+ *
81
+ * Temporal Application Failures are matched through their serialized `type`,
82
+ * in addition to standard `instanceof` and `name` checks.
83
+ *
84
+ * @param error - Error or failure chain to inspect
85
+ * @param ErrorType - Error class to match
86
+ * @returns Whether the Error class appears in the chain
87
+ */
88
+ export declare function hasErrorType<T extends Error>(
89
+ error: unknown,
90
+ ErrorType: abstract new ( ...args: never[] ) => T
91
+ ): boolean;
@@ -48,3 +48,22 @@ export const executeInParallel = async ( { jobs, concurrency = Infinity, onJobCo
48
48
 
49
49
  return results.sort( ( a, b ) => a.index - b.index );
50
50
  };
51
+
52
+ /**
53
+ * Check if an error, or any parts of its cause chain are instances of a given Error class.
54
+ * Also check .name and .type
55
+ *
56
+ * @param {Error} error
57
+ * @param {Error} ErrorType
58
+ * @returns {boolean}
59
+ */
60
+ export const hasErrorType = ( error, ErrorType, depth = 0 ) => {
61
+ if ( depth > 20 || typeof ErrorType !== 'function' ) {
62
+ return false;
63
+ }
64
+ const expectedType = ErrorType.name;
65
+ if ( error instanceof ErrorType || error?.type === expectedType || error?.name === expectedType || error === expectedType ) {
66
+ return true;
67
+ }
68
+ return error?.cause ? hasErrorType( error.cause, ErrorType, depth + 1 ) : false;
69
+ };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { executeInParallel } from './workflow_utils.js';
2
+ import { hasErrorType, executeInParallel } from './workflow_utils.js';
3
3
 
4
4
  describe( 'executeInParallel', () => {
5
5
  it( 'returns empty array for empty jobs', async () => {
@@ -188,3 +188,84 @@ describe( 'executeInParallel', () => {
188
188
  ] );
189
189
  } );
190
190
  } );
191
+
192
+ describe( 'hasErrorType', () => {
193
+ class FooError extends Error {}
194
+
195
+ it( 'matches a direct error instance', () => {
196
+ expect( hasErrorType( new FooError( 'foo' ), FooError ) ).toBe( true );
197
+ } );
198
+
199
+ it( 'matches an error instance in the cause chain', () => {
200
+ const error = new Error( 'outer', {
201
+ cause: new Error( 'middle', {
202
+ cause: new FooError( 'foo' )
203
+ } )
204
+ } );
205
+
206
+ expect( hasErrorType( error, FooError ) ).toBe( true );
207
+ } );
208
+
209
+ it( 'matches Temporal ApplicationFailure type in the cause chain', () => {
210
+ const applicationFailure = Object.assign( new Error( 'foo' ), {
211
+ name: 'ApplicationFailure',
212
+ type: 'FooError'
213
+ } );
214
+ const activityFailure = Object.assign( new Error( 'Activity execution failed', {
215
+ cause: applicationFailure
216
+ } ), {
217
+ name: 'ActivityFailure'
218
+ } );
219
+
220
+ expect( hasErrorType( activityFailure, FooError ) ).toBe( true );
221
+ } );
222
+
223
+ it( 'matches a serialized error name', () => {
224
+ const error = Object.assign( new Error( 'foo' ), { name: 'FooError' } );
225
+
226
+ expect( hasErrorType( error, FooError ) ).toBe( true );
227
+ } );
228
+
229
+ it( 'matches an error serialized as its type name', () => {
230
+ expect( hasErrorType( 'FooError', FooError ) ).toBe( true );
231
+ } );
232
+
233
+ it( 'matches an error type in a plain object cause chain', () => {
234
+ const error = {
235
+ name: 'ActivityFailure',
236
+ cause: {
237
+ name: 'ApplicationFailure',
238
+ type: 'FooError'
239
+ }
240
+ };
241
+
242
+ expect( hasErrorType( error, FooError ) ).toBe( true );
243
+ } );
244
+
245
+ it.each( [ null, undefined, '', 42, false ] )( 'returns false for unsupported error value %j', error => {
246
+ expect( hasErrorType( error, FooError ) ).toBe( false );
247
+ } );
248
+
249
+ it( 'returns false when ErrorType is not a class', () => {
250
+ expect( hasErrorType( new FooError( 'foo' ), null ) ).toBe( false );
251
+ } );
252
+
253
+ it( 'returns false when the requested error type is absent', () => {
254
+ const error = new Error( 'outer', { cause: new TypeError( 'inner' ) } );
255
+
256
+ expect( hasErrorType( error, FooError ) ).toBe( false );
257
+ } );
258
+
259
+ it( 'keeps plain Error matching backwards compatible', () => {
260
+ expect( hasErrorType( new Error( 'foo' ), Error ) ).toBe( true );
261
+ } );
262
+
263
+ it( 'stops traversing cause chains after the depth limit', () => {
264
+ const createCauseChain = depth => depth === 20 ?
265
+ new FooError( 'foo' ) :
266
+ new Error( `depth-${depth}`, { cause: createCauseChain( depth + 1 ) } );
267
+ const root = new Error( 'root', { cause: createCauseChain( 0 ) } );
268
+
269
+ expect( hasErrorType( root, FooError ) ).toBe( false );
270
+ } );
271
+ } );