@outputai/core 0.7.1-next.de30052.0 → 0.8.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.
Files changed (77) hide show
  1. package/bin/worker.sh +6 -0
  2. package/package.json +1 -1
  3. package/src/consts.js +0 -4
  4. package/src/errors.js +6 -2
  5. package/src/interface/evaluator.js +7 -20
  6. package/src/interface/evaluator.spec.js +117 -1
  7. package/src/interface/step.js +8 -9
  8. package/src/interface/step.spec.js +124 -0
  9. package/src/interface/validations/index.js +108 -0
  10. package/src/interface/validations/index.spec.js +182 -0
  11. package/src/interface/validations/schemas.js +113 -0
  12. package/src/interface/validations/schemas.spec.js +209 -0
  13. package/src/interface/webhook.js +1 -1
  14. package/src/interface/webhook.spec.js +1 -1
  15. package/src/interface/workflow.d.ts +10 -9
  16. package/src/interface/workflow.js +76 -164
  17. package/src/interface/workflow.spec.js +637 -521
  18. package/src/interface/workflow_activity_options.js +16 -0
  19. package/src/interface/workflow_utils.js +1 -1
  20. package/src/interface/zod_integration.spec.js +2 -2
  21. package/src/internal_utils/aggregations.js +0 -10
  22. package/src/internal_utils/aggregations.spec.js +1 -48
  23. package/src/internal_utils/errors.js +14 -8
  24. package/src/internal_utils/errors.spec.js +73 -27
  25. package/src/utils/index.d.ts +19 -0
  26. package/src/utils/utils.js +53 -0
  27. package/src/utils/utils.spec.js +105 -1
  28. package/src/worker/bundle.js +26 -0
  29. package/src/worker/bundle.spec.js +53 -0
  30. package/src/worker/bundler_options.js +1 -1
  31. package/src/worker/bundler_options.spec.js +1 -1
  32. package/src/worker/catalog_workflow/catalog_job.js +148 -0
  33. package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
  34. package/src/worker/check.js +24 -0
  35. package/src/worker/connection_monitor.js +112 -0
  36. package/src/worker/connection_monitor.spec.js +199 -0
  37. package/src/worker/index.js +146 -41
  38. package/src/worker/index.spec.js +281 -109
  39. package/src/worker/interceptors/activity.js +7 -24
  40. package/src/worker/interceptors/activity.spec.js +97 -66
  41. package/src/worker/interceptors/index.js +4 -7
  42. package/src/worker/interceptors/modules.js +15 -0
  43. package/src/worker/interceptors/workflow.js +6 -8
  44. package/src/worker/interceptors/workflow.spec.js +49 -42
  45. package/src/worker/interruption.js +33 -0
  46. package/src/worker/interruption.spec.js +98 -0
  47. package/src/worker/loader/activities.js +75 -0
  48. package/src/worker/loader/activities.spec.js +213 -0
  49. package/src/worker/loader/hooks.js +28 -0
  50. package/src/worker/loader/hooks.spec.js +64 -0
  51. package/src/worker/loader/matchers.js +46 -0
  52. package/src/worker/loader/matchers.spec.js +140 -0
  53. package/src/worker/{loader_tools.js → loader/tools.js} +19 -67
  54. package/src/worker/{loader_tools.spec.js → loader/tools.spec.js} +53 -85
  55. package/src/worker/loader/workflows.js +82 -0
  56. package/src/worker/loader/workflows.spec.js +256 -0
  57. package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
  58. package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
  59. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
  60. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
  61. package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
  62. package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
  63. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
  64. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
  65. package/src/interface/validations/runtime.js +0 -20
  66. package/src/interface/validations/runtime.spec.js +0 -29
  67. package/src/interface/validations/schema_utils.js +0 -8
  68. package/src/interface/validations/schema_utils.spec.js +0 -67
  69. package/src/interface/validations/static.js +0 -137
  70. package/src/interface/validations/static.spec.js +0 -397
  71. package/src/interface/workflow.replay_compatibility.spec.js +0 -254
  72. package/src/worker/loader.js +0 -202
  73. package/src/worker/loader.spec.js +0 -498
  74. package/src/worker/shutdown.js +0 -26
  75. package/src/worker/shutdown.spec.js +0 -82
  76. package/src/worker/start_catalog.js +0 -96
  77. package/src/worker/start_catalog.spec.js +0 -179
@@ -0,0 +1,199 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { TemporalConnectionMonitor } from './connection_monitor.js';
3
+
4
+ const SERVING = 1;
5
+ const NOT_SERVING = 2;
6
+ const CHECK_TIMEOUT_MS = 50;
7
+ const CHECK_INTERVAL_MS = 100;
8
+
9
+ const { scheduledDelays, delayMock, mockLogger } = vi.hoisted( () => {
10
+ const scheduledDelays = [];
11
+ const delayMock = vi.fn( ( ms, value, options ) => new Promise( resolve => {
12
+ scheduledDelays.push( { ms, value, options, resolve } );
13
+ } ) );
14
+
15
+ return {
16
+ scheduledDelays,
17
+ delayMock,
18
+ mockLogger: {
19
+ info: vi.fn(),
20
+ warn: vi.fn()
21
+ }
22
+ };
23
+ } );
24
+
25
+ vi.mock( 'node:timers/promises', () => ( { setTimeout: delayMock } ) );
26
+ vi.mock( '#logger', () => ( { createChildLogger: vi.fn( () => mockLogger ) } ) );
27
+
28
+ const createConnection = check => ( {
29
+ healthService: { check }
30
+ } );
31
+
32
+ const createMonitor = ( check, overrides = {} ) => new TemporalConnectionMonitor( createConnection( check ), {
33
+ checkIntervalMs: CHECK_INTERVAL_MS,
34
+ checkTimeoutMs: CHECK_TIMEOUT_MS,
35
+ ...overrides
36
+ } );
37
+
38
+ const flushPromises = async () => Array
39
+ .from( { length: 10 } )
40
+ .reduce( promise => promise.then( () => Promise.resolve() ), Promise.resolve() );
41
+
42
+ const resolveNextDelay = ms => {
43
+ const index = scheduledDelays.findIndex( delay => delay.ms === ms );
44
+ expect( index ).not.toBe( -1 );
45
+ const [ scheduled ] = scheduledDelays.splice( index, 1 );
46
+ scheduled.resolve( scheduled.value );
47
+ };
48
+
49
+ describe( 'TemporalConnectionMonitor', () => {
50
+ beforeEach( () => {
51
+ vi.clearAllMocks();
52
+ scheduledDelays.length = 0;
53
+ } );
54
+
55
+ it( 'logs healthy when the connection is serving', async () => {
56
+ const check = vi.fn().mockResolvedValue( { status: SERVING } );
57
+ const monitor = createMonitor( check );
58
+
59
+ const run = monitor.start();
60
+ await flushPromises();
61
+
62
+ expect( check ).toHaveBeenCalledWith( {} );
63
+ expect( mockLogger.info ).toHaveBeenCalledWith( 'Healthy' );
64
+ expect( delayMock ).toHaveBeenCalledWith( CHECK_TIMEOUT_MS, 0, { ref: false } );
65
+ expect( delayMock ).toHaveBeenCalledWith( CHECK_INTERVAL_MS, 0, { ref: false } );
66
+ expect( monitor.running ).toBe( true );
67
+
68
+ await monitor.stop();
69
+ await run;
70
+
71
+ expect( monitor.running ).toBe( false );
72
+ } );
73
+
74
+ it( 'logs transient timeout failures before retrying', async () => {
75
+ const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
76
+ const monitor = createMonitor( check );
77
+
78
+ monitor.start();
79
+ resolveNextDelay( CHECK_TIMEOUT_MS );
80
+ await flushPromises();
81
+
82
+ expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
83
+ error: 'Connection health check timed out',
84
+ failures: 1
85
+ } );
86
+
87
+ await monitor.stop();
88
+ } );
89
+
90
+ it( 'logs recovered after a transient failure succeeds', async () => {
91
+ const check = vi.fn()
92
+ .mockRejectedValueOnce( new Error( 'temporary outage' ) )
93
+ .mockResolvedValueOnce( { status: SERVING } );
94
+ const monitor = createMonitor( check );
95
+
96
+ monitor.start();
97
+ await flushPromises();
98
+
99
+ expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
100
+ error: 'temporary outage',
101
+ failures: 1
102
+ } );
103
+
104
+ resolveNextDelay( CHECK_INTERVAL_MS );
105
+ await flushPromises();
106
+
107
+ expect( mockLogger.info ).toHaveBeenCalledWith( 'Recovered' );
108
+
109
+ await monitor.stop();
110
+ } );
111
+
112
+ it( 'stores connection loss error and calls callback after max consecutive failures', async () => {
113
+ const error = new Error( 'connection refused' );
114
+ const check = vi.fn().mockRejectedValue( error );
115
+ const connectionLost = vi.fn();
116
+ const monitor = createMonitor( check );
117
+
118
+ monitor.onConnectionLost( connectionLost );
119
+ monitor.start();
120
+
121
+ await flushPromises();
122
+ resolveNextDelay( CHECK_INTERVAL_MS );
123
+ await flushPromises();
124
+ resolveNextDelay( CHECK_INTERVAL_MS );
125
+ await flushPromises();
126
+
127
+ expect( mockLogger.warn ).toHaveBeenCalledTimes( 3 );
128
+ expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection lost', {
129
+ error: 'connection refused',
130
+ failures: 3
131
+ } );
132
+ expect( connectionLost ).toHaveBeenCalledWith( error );
133
+ expect( monitor.connectionLossError ).toBe( error );
134
+ expect( monitor.running ).toBe( false );
135
+ } );
136
+
137
+ it( 'treats non-serving health status as a failure', async () => {
138
+ const check = vi.fn().mockResolvedValue( { status: NOT_SERVING } );
139
+ const monitor = createMonitor( check );
140
+
141
+ monitor.start();
142
+ await flushPromises();
143
+
144
+ expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
145
+ error: `Connection not serving (status ${NOT_SERVING})`,
146
+ failures: 1
147
+ } );
148
+
149
+ await monitor.stop();
150
+ } );
151
+
152
+ it( 'returns the same lifecycle promise when started more than once', async () => {
153
+ const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
154
+ const monitor = createMonitor( check );
155
+
156
+ const firstRun = monitor.start();
157
+ const secondRun = monitor.start();
158
+
159
+ expect( secondRun ).toBe( firstRun );
160
+ expect( check ).toHaveBeenCalledOnce();
161
+
162
+ await monitor.stop();
163
+ } );
164
+
165
+ it( 'stops without calling connection lost callback for in-flight health checks', async () => {
166
+ const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
167
+ const connectionLost = vi.fn();
168
+ const monitor = createMonitor( check, { maxFailures: 1 } );
169
+
170
+ monitor.onConnectionLost( connectionLost );
171
+ monitor.start();
172
+
173
+ expect( monitor.running ).toBe( true );
174
+
175
+ await monitor.stop();
176
+
177
+ expect( connectionLost ).not.toHaveBeenCalled();
178
+ expect( monitor.connectionLossError ).toBeNull();
179
+ expect( monitor.running ).toBe( false );
180
+ } );
181
+
182
+ it( 'applies timing and failure threshold overrides', async () => {
183
+ const error = new Error( 'fast failure' );
184
+ const check = vi.fn().mockRejectedValue( error );
185
+ const connectionLost = vi.fn();
186
+ const monitor = createMonitor( check, {
187
+ maxFailures: 1,
188
+ checkIntervalMs: 7,
189
+ checkTimeoutMs: 3
190
+ } );
191
+
192
+ monitor.onConnectionLost( connectionLost );
193
+ await monitor.start();
194
+
195
+ expect( delayMock ).toHaveBeenCalledWith( 3, 0, { ref: false } );
196
+ expect( delayMock ).not.toHaveBeenCalledWith( 7, 0, { ref: false } );
197
+ expect( connectionLost ).toHaveBeenCalledWith( error );
198
+ } );
199
+ } );
@@ -1,55 +1,64 @@
1
1
  import { Worker, NativeConnection } from '@temporalio/worker';
2
2
  import * as configs from './configs.js';
3
- import { loadActivities, loadHooks, loadWorkflows, createWorkflowsEntryPoint } from './loader.js';
3
+ import { loadActivities } from './loader/activities.js';
4
+ import { loadWorkflows } from './loader/workflows.js';
5
+ import { loadHooks } from './loader/hooks.js';
6
+ import { hashSourceCode } from './loader/tools.js';
4
7
  import { sinks } from './sinks.js';
5
8
  import { createCatalog } from './catalog_workflow/index.js';
6
9
  import { init as initTracing } from '#tracing';
7
10
  import { webpackConfigHook } from './bundler_options.js';
8
11
  import { initInterceptors } from './interceptors/index.js';
9
12
  import { createChildLogger } from '#logger';
10
- import { registerShutdown } from './shutdown.js';
11
- import { startCatalog } from './start_catalog.js';
13
+ import { setupInterruptionHandler } from './interruption.js';
14
+ import { CatalogJob } from './catalog_workflow/catalog_job.js';
12
15
  import { bootstrapFetchProxy } from './proxy.js';
13
16
  import { messageBus } from '#bus';
14
17
  import { BusEventType } from '#consts';
15
- import { hashSourceCode } from './loader_tools.js';
16
- import { setupTelemetry } from './setup_telemetry.js';
18
+ import { setupTelemetry } from './telemetry.js';
19
+ import { TemporalConnectionMonitor } from './connection_monitor.js';
20
+ import { runOnce } from '#utils';
21
+
17
22
  import './log_hooks.js';
18
23
 
19
24
  const log = createChildLogger( 'Worker' );
20
25
 
26
+ const {
27
+ address,
28
+ apiKey,
29
+ namespace,
30
+ taskQueue,
31
+ grpcProxy,
32
+ maxConcurrentWorkflowTaskExecutions,
33
+ maxConcurrentActivityTaskExecutions,
34
+ maxCachedWorkflows,
35
+ maxConcurrentActivityTaskPolls,
36
+ maxConcurrentWorkflowTaskPolls
37
+ } = configs;
38
+
39
+ const state = {
40
+ connection: null,
41
+ connectionMonitor: null,
42
+ catalogJob: null,
43
+ workerError: null
44
+ };
45
+
21
46
  // Get caller directory from command line arguments
22
47
  const callerDir = process.argv[2];
23
48
 
24
- ( async () => {
25
- const {
26
- address,
27
- apiKey,
28
- namespace,
29
- taskQueue,
30
- grpcProxy,
31
- maxConcurrentWorkflowTaskExecutions,
32
- maxConcurrentActivityTaskExecutions,
33
- maxCachedWorkflows,
34
- maxConcurrentActivityTaskPolls,
35
- maxConcurrentWorkflowTaskPolls
36
- } = configs;
37
-
49
+ const execute = async () => {
38
50
  log.info( 'Loading config...', { callerDir } );
39
51
  await loadHooks( callerDir );
40
52
 
41
53
  log.info( 'Loading workflows...', { callerDir } );
42
- const workflows = await loadWorkflows( callerDir );
54
+ const { workflows, entrypoint: workflowsPath } = await loadWorkflows( callerDir );
43
55
 
44
56
  log.info( 'Loading activities...', { callerDir } );
45
- const activities = await loadActivities( callerDir, workflows );
57
+ const { activities } = await loadActivities( callerDir, workflows );
46
58
 
47
59
  messageBus.emit( BusEventType.WORKER_BEFORE_START );
48
60
  bootstrapFetchProxy();
49
61
 
50
- log.info( 'Creating worker entry point...' );
51
- const workflowsPath = createWorkflowsEntryPoint( workflows );
52
-
53
62
  log.info( 'Initializing tracing...' );
54
63
  await initTracing();
55
64
 
@@ -64,17 +73,23 @@ const callerDir = process.argv[2];
64
73
  if ( proxy ) {
65
74
  log.info( 'Using gRPC proxy', { targetHost: grpcProxy } );
66
75
  }
67
- const connection = await NativeConnection.connect( { address, tls: Boolean( apiKey ), apiKey, proxy } );
76
+ state.connection = await NativeConnection.connect( { address, tls: Boolean( apiKey ), apiKey, proxy } );
77
+
78
+ log.info( 'Creating connection monitor...' );
79
+ state.connectionMonitor = new TemporalConnectionMonitor( state.connection );
80
+
81
+ log.info( 'Creating catalog job manager...' );
82
+ state.catalogJob = new CatalogJob( { connection: state.connection, namespace, catalog, catalogHash } );
68
83
 
69
84
  log.info( 'Creating worker...' );
70
85
  const worker = await Worker.create( {
71
- connection,
86
+ connection: state.connection,
72
87
  namespace,
73
88
  taskQueue,
74
89
  workflowsPath,
75
90
  activities,
76
91
  sinks,
77
- interceptors: initInterceptors( { activities, workflows, connection } ),
92
+ interceptors: initInterceptors( { activities, workflows } ),
78
93
  maxConcurrentWorkflowTaskExecutions,
79
94
  maxConcurrentActivityTaskExecutions,
80
95
  maxCachedWorkflows,
@@ -83,22 +98,112 @@ const callerDir = process.argv[2];
83
98
  bundlerOptions: { webpackConfigHook }
84
99
  } );
85
100
 
86
- registerShutdown( { worker, log } );
87
-
101
+ log.info( 'Setting up telemetry...' );
88
102
  setupTelemetry( { worker } );
89
103
 
90
- log.info( 'Running worker...' );
91
- await Promise.all( [ worker.run(), startCatalog( { connection, namespace, catalog, catalogHash } ) ] );
104
+ /**
105
+ * NOTE
106
+ * Temporal worker shutdown is a bit odd.
107
+ * worker.run() is an async job that only resolves when calling worker.shutdown().
108
+ * But worker.shutdown() is not async and returns nothing, so there is no way to await it.
109
+ * All code that needs to run after shutdown needs to be after `await worker.run()`.
110
+ *
111
+ * The following code needs to cover these scenarios:
112
+ * 1. Connection monitor detects connection loss
113
+ * 2. Catalog.run() has a failure
114
+ * 3. Interruption is received
115
+ * 4. Worker throws an error
116
+ *
117
+ * For each scenario all promises in the Promise.all() need to be completed via functions:
118
+ * connectionMonitor.stop(), catalogJob.interrupt(), worker.shutdown()
119
+ */
120
+
121
+ /**
122
+ * Graceful shutdown
123
+ * Triggers the actions that will resolve all promises in the Promise.all(), so the code can resume
124
+ */
125
+ const shutdown = runOnce( () => {
126
+ log.info( 'Shutdown started...' );
127
+ if ( worker.getStatus().runState === 'RUNNING' ) {
128
+ worker.shutdown();
129
+ }
130
+ state.connectionMonitor.stop();
131
+ state.catalogJob.interrupt();
132
+ } );
92
133
 
93
- log.info( 'Closing connection...' );
94
- await connection.close();
134
+ /** When receiving an interruption, call shutdown */
135
+ setupInterruptionHandler( shutdown );
95
136
 
96
- log.info( 'Bye' );
137
+ /** When the connection is lost, call shutdown */
138
+ state.connectionMonitor.onConnectionLost( shutdown );
97
139
 
98
- process.exit( 0 );
99
- } )().catch( error => {
100
- log.error( 'Fatal error', { message: error.message, stack: error.stack } );
101
- messageBus.emit( BusEventType.RUNTIME_ERROR, { error } );
102
- log.info( `Exiting in ${configs.processFailureShutdownDelay}ms` );
103
- setTimeout( () => process.exit( 1 ), configs.processFailureShutdownDelay );
104
- } );
140
+ /** If the catalog job manager fails, call shutdown */
141
+ state.catalogJob.onError( shutdown );
142
+
143
+ /**
144
+ * Runs the worker, connection monitor and catalogJob (ephemeral)
145
+ * None of these will reject in normal conditions. Errors need to be inspected later
146
+ * They will resolve only when calling the actions in shutdown(),
147
+ * except catalogJob, which can resolve by itself given a bit of time
148
+ */
149
+ log.info( 'Running worker...' );
150
+ await Promise.all( [
151
+ // When the worker fails, store the error and call shutdown
152
+ worker.run().catch( error => {
153
+ state.workerError = error;
154
+ shutdown();
155
+ } ),
156
+ state.connectionMonitor.start(),
157
+ state.catalogJob.run()
158
+ ] );
159
+
160
+ log.info( 'Worker terminated' );
161
+
162
+ /** After the Promise.all() is resolved, check which services had an error, since none rejects the promise */
163
+ const error =
164
+ state.connectionMonitor.connectionLossError ??
165
+ state.catalogJob.error ??
166
+ state.workerError;
167
+
168
+ /** If any error is found, throws it, so this process can exit with code=1 (failure) */
169
+ if ( error ) {
170
+ throw error;
171
+ }
172
+ };
173
+
174
+ execute()
175
+ .finally( async () => {
176
+ /**
177
+ * This will make sure that if we had any uncaught failures, everything is tore down.
178
+ * Ignore any errors here in order to not mask actually errors from before and because at this point
179
+ * the code is already shutting down, so no need to throw anyway.
180
+ *
181
+ * worker.shutdown() is not tried here, because it cannot be awaited. By this point is safe to say
182
+ * that worker never started, already crashed or was stopped anyway.
183
+ */
184
+ if ( state.connectionMonitor?.running ) {
185
+ log.info( 'Stopping connection monitor...' );
186
+ await state.connectionMonitor.stop()
187
+ .catch( e => log.warn( 'Connection monitor stop error', { error: e.message } ) );
188
+ }
189
+ if ( state.catalogJob?.running ) {
190
+ log.info( 'Interrupting catalog job...' );
191
+ await state.catalogJob.interrupt()
192
+ .catch( e => log.warn( 'Catalog job interruption error', { error: e.message } ) );
193
+ }
194
+ if ( state.connection ) {
195
+ log.info( 'Closing connection...' );
196
+ await state.connection.close()
197
+ .catch( e => log.warn( 'Connection close error', { error: e.message } ) );
198
+ }
199
+ } )
200
+ .then( () => log.info( 'Bye' ) )
201
+ .catch( error => {
202
+ log.error( 'Fatal error', { error: error.message, stack: error.stack } );
203
+
204
+ messageBus.emit( BusEventType.RUNTIME_ERROR, { error } );
205
+
206
+ const timeToFlushEvent = configs.processFailureShutdownDelay;
207
+ log.info( `Exiting in ${timeToFlushEvent}ms` );
208
+ setTimeout( () => process.exit( 1 ), timeToFlushEvent );
209
+ } );