@output.ai/core 0.5.7 → 0.5.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/core",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,6 +15,10 @@
15
15
  "./utils": {
16
16
  "types": "./src/utils/index.d.ts",
17
17
  "import": "./src/utils/index.js"
18
+ },
19
+ "./hooks": {
20
+ "types": "./src/hooks/index.d.ts",
21
+ "import": "./src/hooks/index.js"
18
22
  }
19
23
  },
20
24
  "files": [
@@ -48,6 +52,7 @@
48
52
  "access": "public"
49
53
  },
50
54
  "imports": {
55
+ "#bus": "./src/bus.js",
51
56
  "#consts": "./src/consts.js",
52
57
  "#errors": "./src/errors.js",
53
58
  "#logger": "./src/logger.js",
package/src/bus.js ADDED
@@ -0,0 +1,3 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ export const messageBus = new EventEmitter();
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Payload passed to the onError handler when a workflow, activity or runtime error occurs.
3
+ */
4
+ export interface ErrorHookPayload {
5
+ /** Origin of the error: workflow execution, activity execution, or runtime. */
6
+ source: 'workflow' | 'activity' | 'runtime';
7
+ /** Name of the workflow, when the error is scoped to a workflow or activity. */
8
+ workflowName?: string;
9
+ /** Name of the activity, when the error is from an activity. */
10
+ activityName?: string;
11
+ /** The error thrown. */
12
+ error: Error;
13
+ }
14
+
15
+ /**
16
+ * Register a handler invoked on workflow, activity or runtime errors.
17
+ *
18
+ * @param handler - Function called with the error payload.
19
+ */
20
+ export declare function onError( handler: ( payload: ErrorHookPayload ) => void ): void;
@@ -0,0 +1,21 @@
1
+ import { messageBus } from '#bus';
2
+ import { createChildLogger } from '#logger';
3
+
4
+ const log = createChildLogger( 'Hooks' );
5
+
6
+ export const onError = handler => {
7
+ const invokeHandler = async args => {
8
+ try {
9
+ await handler( args );
10
+ } catch ( error ) {
11
+ log.error( 'onError hook error', { error } );
12
+ }
13
+ };
14
+
15
+ messageBus.on( 'activity:error', async ( { workflowName, activityName, error } ) =>
16
+ invokeHandler( { source: 'activity', workflowName, activityName, error } ) );
17
+ messageBus.on( 'workflow:error', async ( { workflowName, error } ) =>
18
+ invokeHandler( { source: 'workflow', workflowName, error } ) );
19
+ messageBus.on( 'runtime:error', async ( { error } ) =>
20
+ invokeHandler( { source: 'runtime', error } ) );
21
+ };
@@ -24,7 +24,9 @@ const envVarSchema = z.object( {
24
24
  // How often the worker sends a heartbeat to the Temporal Service during activity execution
25
25
  OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 2 * 60 * 1000 ) ), // 2min
26
26
  // Whether to send activity heartbeats (enabled by default)
27
- OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) )
27
+ OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) ),
28
+ // Time to allow for hooks to flush before shutdown
29
+ OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 3000 ) )
28
30
  } );
29
31
 
30
32
  const { data: envVars, error } = envVarSchema.safeParse( process.env );
@@ -44,3 +46,4 @@ export const taskQueue = envVars.OUTPUT_CATALOG_ID;
44
46
  export const catalogId = envVars.OUTPUT_CATALOG_ID;
45
47
  export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS;
46
48
  export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
49
+ export const processFailureShutdownDelay = envVars.OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY;
@@ -1,6 +1,6 @@
1
1
  import { Worker, NativeConnection } from '@temporalio/worker';
2
2
  import * as configs from './configs.js';
3
- import { loadActivities, loadWorkflows, createWorkflowsEntryPoint } from './loader.js';
3
+ import { loadActivities, loadHooks, loadWorkflows, createWorkflowsEntryPoint } from './loader.js';
4
4
  import { sinks } from './sinks.js';
5
5
  import { createCatalog } from './catalog_workflow/index.js';
6
6
  import { init as initTracing } from '#tracing';
@@ -9,6 +9,7 @@ import { initInterceptors } from './interceptors.js';
9
9
  import { createChildLogger } from '#logger';
10
10
  import { registerShutdown } from './shutdown.js';
11
11
  import { startCatalog } from './start_catalog.js';
12
+ import { messageBus } from '#bus';
12
13
 
13
14
  const log = createChildLogger( 'Worker' );
14
15
 
@@ -28,6 +29,9 @@ const callerDir = process.argv[2];
28
29
  maxConcurrentWorkflowTaskPolls
29
30
  } = configs;
30
31
 
32
+ log.info( 'Loading config...', { callerDir } );
33
+ await loadHooks( callerDir );
34
+
31
35
  log.info( 'Loading workflows...', { callerDir } );
32
36
  const workflows = await loadWorkflows( callerDir );
33
37
 
@@ -77,5 +81,7 @@ const callerDir = process.argv[2];
77
81
  process.exit( 0 );
78
82
  } )().catch( error => {
79
83
  log.error( 'Fatal error', { message: error.message, stack: error.stack } );
80
- process.exit( 1 );
84
+ messageBus.emit( 'runtime:error', { error } );
85
+ log.info( `Exiting in ${configs.processFailureShutdownDelay}ms` );
86
+ setTimeout( () => process.exit( 1 ), configs.processFailureShutdownDelay );
81
87
  } );
@@ -17,16 +17,22 @@ const configValues = {
17
17
  maxConcurrentActivityTaskExecutions: 40,
18
18
  maxCachedWorkflows: 1000,
19
19
  maxConcurrentActivityTaskPolls: 5,
20
- maxConcurrentWorkflowTaskPolls: 5
20
+ maxConcurrentWorkflowTaskPolls: 5,
21
+ processFailureShutdownDelay: 0
21
22
  };
22
23
  vi.mock( './configs.js', () => configValues );
23
24
 
25
+ const messageBusMock = { on: vi.fn(), emit: vi.fn() };
26
+ vi.mock( '#bus', () => ( { messageBus: messageBusMock } ) );
27
+
24
28
  const loadWorkflowsMock = vi.fn().mockResolvedValue( [] );
25
29
  const loadActivitiesMock = vi.fn().mockResolvedValue( {} );
30
+ const loadHooksMock = vi.fn().mockResolvedValue( undefined );
26
31
  const createWorkflowsEntryPointMock = vi.fn().mockReturnValue( '/fake/workflows/path.js' );
27
32
  vi.mock( './loader.js', () => ( {
28
33
  loadWorkflows: loadWorkflowsMock,
29
34
  loadActivities: loadActivitiesMock,
35
+ loadHooks: loadHooksMock,
30
36
  createWorkflowsEntryPoint: createWorkflowsEntryPointMock
31
37
  } ) );
32
38
 
@@ -83,8 +89,9 @@ describe( 'worker/index', () => {
83
89
  import( './index.js' );
84
90
 
85
91
  await vi.waitFor( () => {
86
- expect( loadWorkflowsMock ).toHaveBeenCalledWith( '/test/caller/dir' );
92
+ expect( loadHooksMock ).toHaveBeenCalledWith( '/test/caller/dir' );
87
93
  } );
94
+ expect( loadWorkflowsMock ).toHaveBeenCalledWith( '/test/caller/dir' );
88
95
  expect( loadActivitiesMock ).toHaveBeenCalledWith( '/test/caller/dir', [] );
89
96
  expect( createWorkflowsEntryPointMock ).toHaveBeenCalledWith( [] );
90
97
  expect( initTracing ).toHaveBeenCalled();
@@ -157,6 +164,8 @@ describe( 'worker/index', () => {
157
164
  await vi.waitFor( () => {
158
165
  expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.any( Object ) );
159
166
  } );
160
- expect( exitMock ).toHaveBeenCalledWith( 1 );
167
+ await vi.waitFor( () => {
168
+ expect( exitMock ).toHaveBeenCalledWith( 1 );
169
+ } );
161
170
  } );
162
171
  } );
@@ -5,6 +5,7 @@ import { headersToObject } from '../sandboxed_utils.js';
5
5
  import { LifecycleEvent, METADATA_ACCESS_SYMBOL } from '#consts';
6
6
  import { activityHeartbeatEnabled, activityHeartbeatIntervalMs } from '../configs.js';
7
7
  import { createChildLogger } from '#logger';
8
+ import { messageBus } from '#bus';
8
9
 
9
10
  const log = createChildLogger( 'Activity' );
10
11
  /*
@@ -56,6 +57,8 @@ export class ActivityExecutionInterceptor {
56
57
  event: LifecycleEvent.ERROR, kind, ...logContext, durationMs: Date.now() - startDate, error: error.message
57
58
  } );
58
59
  addEventError( { details: error, ...traceArguments } );
60
+
61
+ messageBus.emit( 'activity:error', { error, workflowName, activityName: activityType } );
59
62
  throw error;
60
63
 
61
64
  } finally {
@@ -1,5 +1,5 @@
1
1
  import { dirname, join } from 'node:path';
2
- import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { EOL } from 'node:os';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { getTraceDestinations, sendHttpRequest } from '#internal_activities';
@@ -114,6 +114,24 @@ export async function loadWorkflows( rootDir ) {
114
114
  return workflows;
115
115
  };
116
116
 
117
+ /**
118
+ * Loads the hook files from package.json's output config section.
119
+ *
120
+ * @param {string} rootDir
121
+ * @returns {void}
122
+ */
123
+ export async function loadHooks( rootDir ) {
124
+ const packageFile = join( rootDir, 'package.json' );
125
+ if ( existsSync( packageFile ) ) {
126
+ const pkg = await import( packageFile, { with: { type: 'json' } } );
127
+ for ( const path of pkg.default.output?.hookFiles ?? [] ) {
128
+ const hookFile = join( rootDir, path );
129
+ await import( hookFile );
130
+ log.info( 'Hook file loaded', { path } );
131
+ }
132
+ }
133
+ };
134
+
117
135
  /**
118
136
  * Creates a temporary index file importing all workflows for Temporal.
119
137
  *
@@ -1,4 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
2
4
 
3
5
  vi.mock( '#consts', () => ( {
4
6
  ACTIVITY_SEND_HTTP_REQUEST: '__internal#sendHttpRequest',
@@ -22,11 +24,15 @@ vi.mock( './loader_tools.js', async importOriginal => {
22
24
  return { ...actual, importComponents: importComponentsMock };
23
25
  } );
24
26
 
25
- const mkdirSyncMock = vi.fn();
26
- const writeFileSyncMock = vi.fn();
27
+ const fsMocks = vi.hoisted( () => ( {
28
+ mkdirSync: vi.fn(),
29
+ writeFileSync: vi.fn(),
30
+ existsSync: vi.fn().mockReturnValue( false )
31
+ } ) );
27
32
  vi.mock( 'node:fs', () => ( {
28
- mkdirSync: mkdirSyncMock,
29
- writeFileSync: writeFileSyncMock
33
+ mkdirSync: fsMocks.mkdirSync,
34
+ writeFileSync: fsMocks.writeFileSync,
35
+ existsSync: fsMocks.existsSync
30
36
  } ) );
31
37
 
32
38
  describe( 'worker/loader', () => {
@@ -54,14 +60,14 @@ describe( 'worker/loader', () => {
54
60
  expect( activities['__internal#sendHttpRequest'] ).toBe( sendHttpRequestMock );
55
61
 
56
62
  // options file written with the collected activityOptions map
57
- expect( writeFileSyncMock ).toHaveBeenCalledTimes( 1 );
58
- const [ writtenPath, contents ] = writeFileSyncMock.mock.calls[0];
63
+ expect( fsMocks.writeFileSync ).toHaveBeenCalledTimes( 1 );
64
+ const [ writtenPath, contents ] = fsMocks.writeFileSync.mock.calls[0];
59
65
  expect( writtenPath ).toMatch( /temp\/__activity_options\.js$/ );
60
66
  expect( contents ).toContain( 'export default' );
61
67
  expect( JSON.parse( contents.replace( /^export default\s*/, '' ).replace( /;\s*$/, '' ) ) ).toEqual( {
62
68
  'A#Act1': { retry: { maximumAttempts: 3 } }
63
69
  } );
64
- expect( mkdirSyncMock ).toHaveBeenCalled();
70
+ expect( fsMocks.mkdirSync ).toHaveBeenCalled();
65
71
  } );
66
72
 
67
73
  it( 'loadActivities omits activity options when component has no options or no activityOptions', async () => {
@@ -74,7 +80,7 @@ describe( 'worker/loader', () => {
74
80
 
75
81
  await loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] );
76
82
  const written = JSON.parse(
77
- writeFileSyncMock.mock.calls[0][1].replace( /^export default\s*/, '' ).replace( /;\s*$/, '' )
83
+ fsMocks.writeFileSync.mock.calls[0][1].replace( /^export default\s*/, '' ).replace( /;\s*$/, '' )
78
84
  );
79
85
  expect( written['A#NoOptions'] ).toBeUndefined();
80
86
  expect( written['A#EmptyOptions'] ).toBeUndefined();
@@ -97,12 +103,12 @@ describe( 'worker/loader', () => {
97
103
  const workflows = [ { name: 'W', path: '/abs/wf.js' } ];
98
104
  const entry = createWorkflowsEntryPoint( workflows );
99
105
 
100
- expect( writeFileSyncMock ).toHaveBeenCalledTimes( 1 );
101
- const [ writtenPath, contents ] = writeFileSyncMock.mock.calls[0];
106
+ expect( fsMocks.writeFileSync ).toHaveBeenCalledTimes( 1 );
107
+ const [ writtenPath, contents ] = fsMocks.writeFileSync.mock.calls[0];
102
108
  expect( entry ).toBe( writtenPath );
103
109
  expect( contents ).toContain( 'export { default as W } from \'/abs/wf.js\';' );
104
110
  expect( contents ).toContain( 'export { default as catalog }' );
105
- expect( mkdirSyncMock ).toHaveBeenCalledTimes( 1 );
111
+ expect( fsMocks.mkdirSync ).toHaveBeenCalledTimes( 1 );
106
112
  } );
107
113
 
108
114
  it( 'loadActivities uses folder-based matchers for steps/evaluators and shared', async () => {
@@ -198,4 +204,33 @@ describe( 'worker/loader', () => {
198
204
  expect( activities['$shared#SharedEvalPrimary'] ).toBeTypeOf( 'function' );
199
205
  expect( activities['$shared#SharedEvalSecondary'] ).toBeTypeOf( 'function' );
200
206
  } );
207
+
208
+ describe( 'loadHooks', () => {
209
+ it( 'resolves without importing when package.json does not exist', async () => {
210
+ fsMocks.existsSync.mockReturnValue( false );
211
+ const { loadHooks } = await import( './loader.js' );
212
+ await expect( loadHooks( '/root' ) ).resolves.toBeUndefined();
213
+ expect( fsMocks.existsSync ).toHaveBeenCalledWith( join( '/root', 'package.json' ) );
214
+ } );
215
+
216
+ it( 'imports hook files listed in package.json output.hookFiles', async () => {
217
+ vi.doUnmock( 'node:fs' );
218
+ vi.resetModules();
219
+ const fs = await import( 'node:fs' );
220
+ const tmpDir = fs.mkdtempSync( join( tmpdir(), 'loader-spec-' ) );
221
+ try {
222
+ fs.writeFileSync( join( tmpDir, 'package.json' ), JSON.stringify( {
223
+ output: { hookFiles: [ 'hook.js' ] }
224
+ } ) );
225
+ fs.writeFileSync( join( tmpDir, 'hook.js' ), 'globalThis.__loadHooksTestLoaded = true;' );
226
+
227
+ const { loadHooks } = await import( './loader.js' );
228
+ await loadHooks( tmpDir );
229
+ expect( globalThis.__loadHooksTestLoaded ).toBe( true );
230
+ } finally {
231
+ delete globalThis.__loadHooksTestLoaded;
232
+ fs.rmSync( tmpDir, { recursive: true, force: true } );
233
+ }
234
+ } );
235
+ } );
201
236
  } );
@@ -122,11 +122,11 @@ export const staticMatchers = {
122
122
  * @param {string} path - Path to test
123
123
  * @returns {boolean}
124
124
  */
125
- sharedStepsDir: v => v.includes( `${sep}shared${sep}steps${sep}` ),
125
+ sharedStepsDir: v => v.includes( `${sep}shared${sep}steps${sep}` ) && v.endsWith( '.js' ),
126
126
  /**
127
127
  * Matches the shared folder for evaluators src/shared/evaluators/../evaluator_file.js
128
128
  * @param {string} path - Path to test
129
129
  * @returns {boolean}
130
130
  */
131
- sharedEvaluatorsDir: v => v.includes( `${sep}shared${sep}evaluators${sep}` )
131
+ sharedEvaluatorsDir: v => v.includes( `${sep}shared${sep}evaluators${sep}` ) && v.endsWith( '.js' )
132
132
  };
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest';
2
2
  import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
3
  import { join, sep } from 'node:path';
4
- import { importComponents } from './loader_tools.js';
4
+ import { importComponents, staticMatchers } from './loader_tools.js';
5
5
 
6
6
  describe( '.importComponents', () => {
7
7
  const TEMP_BASE = join( process.cwd(), 'sdk/core/temp_test_modules' );
@@ -116,3 +116,41 @@ describe( '.importComponents', () => {
116
116
  rmSync( root, { recursive: true, force: true } );
117
117
  } );
118
118
  } );
119
+
120
+ describe( '.staticMatchers', () => {
121
+ describe( '.sharedStepsDir', () => {
122
+ it( 'matches .js files inside shared/steps/', () => {
123
+ expect( staticMatchers.sharedStepsDir( `${sep}app${sep}dist${sep}shared${sep}steps${sep}tools.js` ) ).toBe( true );
124
+ } );
125
+
126
+ it( 'matches .js files in nested subdirectories of shared/steps/', () => {
127
+ expect( staticMatchers.sharedStepsDir( `${sep}app${sep}dist${sep}shared${sep}steps${sep}utils${sep}helper.js` ) ).toBe( true );
128
+ } );
129
+
130
+ it( 'rejects .ts files inside shared/steps/', () => {
131
+ expect( staticMatchers.sharedStepsDir( `${sep}app${sep}src${sep}shared${sep}steps${sep}tools.ts` ) ).toBe( false );
132
+ } );
133
+
134
+ it( 'rejects non-.js files inside shared/steps/', () => {
135
+ expect( staticMatchers.sharedStepsDir( `${sep}app${sep}dist${sep}shared${sep}steps${sep}readme.md` ) ).toBe( false );
136
+ } );
137
+ } );
138
+
139
+ describe( '.sharedEvaluatorsDir', () => {
140
+ it( 'matches .js files inside shared/evaluators/', () => {
141
+ expect( staticMatchers.sharedEvaluatorsDir( `${sep}app${sep}dist${sep}shared${sep}evaluators${sep}quality.js` ) ).toBe( true );
142
+ } );
143
+
144
+ it( 'matches .js files in nested subdirectories of shared/evaluators/', () => {
145
+ expect( staticMatchers.sharedEvaluatorsDir( `${sep}app${sep}dist${sep}shared${sep}evaluators${sep}utils${sep}helper.js` ) ).toBe( true );
146
+ } );
147
+
148
+ it( 'rejects .ts files inside shared/evaluators/', () => {
149
+ expect( staticMatchers.sharedEvaluatorsDir( `${sep}app${sep}src${sep}shared${sep}evaluators${sep}quality.ts` ) ).toBe( false );
150
+ } );
151
+
152
+ it( 'rejects non-.js files inside shared/evaluators/', () => {
153
+ expect( staticMatchers.sharedEvaluatorsDir( `${sep}app${sep}dist${sep}shared${sep}evaluators${sep}readme.md` ) ).toBe( false );
154
+ } );
155
+ } );
156
+ } );
@@ -1,6 +1,7 @@
1
1
  import { LifecycleEvent, WORKFLOW_CATALOG } from '#consts';
2
2
  import { addEventStart, addEventEnd, addEventError } from '#tracing';
3
3
  import { createChildLogger } from '#logger';
4
+ import { messageBus } from '#bus';
4
5
 
5
6
  const log = createChildLogger( 'Workflow' );
6
7
 
@@ -81,9 +82,10 @@ export const sinks = {
81
82
  },
82
83
 
83
84
  addWorkflowEventError: {
84
- fn: ( workflowInfo, details ) => {
85
- logWorkflowEvent( LifecycleEvent.ERROR, workflowInfo, details );
86
- addWorkflowEvent( addEventError, workflowInfo, details );
85
+ fn: ( workflowInfo, error ) => {
86
+ logWorkflowEvent( LifecycleEvent.ERROR, workflowInfo, error );
87
+ addWorkflowEvent( addEventError, workflowInfo, error );
88
+ messageBus.emit( 'workflow:error', { error, workflowName: workflowInfo.workflowType } );
87
89
  },
88
90
  callDuringReplay: false
89
91
  },