@outputai/core 0.7.0 → 0.7.1-next.0e958f3.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 (66) 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/hooks/index.d.ts +10 -0
  6. package/src/interface/evaluator.js +7 -20
  7. package/src/interface/evaluator.spec.js +117 -1
  8. package/src/interface/step.js +8 -9
  9. package/src/interface/step.spec.js +124 -0
  10. package/src/interface/validations/index.js +108 -0
  11. package/src/interface/validations/index.spec.js +182 -0
  12. package/src/interface/validations/schemas.js +113 -0
  13. package/src/interface/validations/schemas.spec.js +209 -0
  14. package/src/interface/webhook.js +1 -1
  15. package/src/interface/webhook.spec.js +1 -1
  16. package/src/interface/workflow.d.ts +10 -9
  17. package/src/interface/workflow.js +76 -164
  18. package/src/interface/workflow.spec.js +637 -521
  19. package/src/interface/workflow_activity_options.js +16 -0
  20. package/src/interface/workflow_utils.js +1 -1
  21. package/src/interface/zod_integration.spec.js +2 -2
  22. package/src/internal_utils/aggregations.js +0 -10
  23. package/src/internal_utils/aggregations.spec.js +1 -48
  24. package/src/internal_utils/errors.js +14 -8
  25. package/src/internal_utils/errors.spec.js +73 -27
  26. package/src/utils/index.d.ts +19 -0
  27. package/src/utils/utils.js +46 -0
  28. package/src/utils/utils.spec.js +82 -1
  29. package/src/worker/bundle.js +26 -0
  30. package/src/worker/bundle.spec.js +52 -0
  31. package/src/worker/catalog_workflow/catalog_job.js +148 -0
  32. package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
  33. package/src/worker/check.js +24 -0
  34. package/src/worker/connection_monitor.js +112 -0
  35. package/src/worker/connection_monitor.spec.js +199 -0
  36. package/src/worker/index.js +140 -34
  37. package/src/worker/index.spec.js +280 -108
  38. package/src/worker/interceptors/activity.js +7 -24
  39. package/src/worker/interceptors/activity.spec.js +97 -66
  40. package/src/worker/interceptors/index.js +4 -7
  41. package/src/worker/interceptors/modules.js +15 -0
  42. package/src/worker/interceptors/workflow.js +4 -7
  43. package/src/worker/interceptors/workflow.spec.js +49 -42
  44. package/src/worker/interruption.js +33 -0
  45. package/src/worker/interruption.spec.js +86 -0
  46. package/src/worker/loader_tools.js +1 -1
  47. package/src/worker/loader_tools.spec.js +36 -0
  48. package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
  49. package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
  50. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
  51. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
  52. package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
  53. package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
  54. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
  55. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
  56. package/src/interface/validations/runtime.js +0 -20
  57. package/src/interface/validations/runtime.spec.js +0 -29
  58. package/src/interface/validations/schema_utils.js +0 -8
  59. package/src/interface/validations/schema_utils.spec.js +0 -67
  60. package/src/interface/validations/static.js +0 -137
  61. package/src/interface/validations/static.spec.js +0 -397
  62. package/src/interface/workflow.replay_compatibility.spec.js +0 -254
  63. package/src/worker/shutdown.js +0 -26
  64. package/src/worker/shutdown.spec.js +0 -82
  65. package/src/worker/start_catalog.js +0 -96
  66. package/src/worker/start_catalog.spec.js +0 -179
@@ -0,0 +1,16 @@
1
+ import { FatalError, ValidationError } from '#errors';
2
+
3
+ export const defaultOptions = {
4
+ activityOptions: {
5
+ startToCloseTimeout: '20m',
6
+ heartbeatTimeout: '5m',
7
+ retry: {
8
+ initialInterval: '10s',
9
+ backoffCoefficient: 2.0,
10
+ maximumInterval: '2m',
11
+ maximumAttempts: 3,
12
+ nonRetryableErrorTypes: [ ValidationError.name, FatalError.name ]
13
+ }
14
+ },
15
+ disableTrace: false
16
+ };
@@ -1,5 +1,5 @@
1
1
  // THIS RUNS IN THE TEMPORAL'S SANDBOX ENVIRONMENT
2
- import { validateExecuteInParallel } from './validations/static.js';
2
+ import { validateExecuteInParallel } from './validations/index.js';
3
3
 
4
4
  /**
5
5
  * Execute jobs in parallel with optional concurrency limit.
@@ -495,7 +495,7 @@ describe( 'Zod Schema Integration Tests', () => {
495
495
  await errorStep( { age: 16, email: 'invalid' } );
496
496
  expect.fail( 'Should have thrown an error' );
497
497
  } catch ( error ) {
498
- expect( error.message ).toContain( 'Step error_test input validation failed' );
498
+ expect( error.message ).toContain( 'Step "error_test" input validation failed' );
499
499
  }
500
500
  } );
501
501
 
@@ -527,7 +527,7 @@ describe( 'Zod Schema Integration Tests', () => {
527
527
  const metadata = testStep[METADATA_ACCESS_SYMBOL];
528
528
  expect( metadata.inputSchema ).toBe( zodSchema );
529
529
  expect( metadata.inputSchema ).not.toBe( null );
530
- expect( metadata.inputSchema._def ).toBeDefined(); // Zod-specific property
530
+ expect( metadata.inputSchema._zod?.def ).toBeDefined(); // Zod v4-specific property
531
531
  } );
532
532
 
533
533
  it( 'should handle deeply nested Zod schemas', async () => {
@@ -1,4 +1,3 @@
1
- import { deepMergeWithResolver } from '#utils';
2
1
  import { Attribute } from '#trace_attribute';
3
2
  import Decimal from 'decimal.js';
4
3
 
@@ -43,12 +42,3 @@ export const aggregateAttributes = attributes => ( {
43
42
  total: attributes.filter( a => Attribute.HTTPRequestCount.TYPE === a.type ).length
44
43
  }
45
44
  } );
46
-
47
- /**
48
- * Combine two or more aggregation objects into a single Aggregation, adding up the totals and merging keys.
49
- *
50
- * @param {Aggregation} aggregations
51
- * @returns {Aggregation}
52
- */
53
- export const mergeAggregations = ( ...aggregations ) =>
54
- aggregations.reduce( ( final, item ) => deepMergeWithResolver( final, item, ( a, b ) => Decimal( a ?? 0 ).add( b ?? 0 ).toNumber() ), {} );
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { Attribute } from '#trace_attribute';
3
- import { aggregateAttributes, mergeAggregations } from './aggregations.js';
3
+ import { aggregateAttributes } from './aggregations.js';
4
4
 
5
5
  describe( 'aggregateAttributes', () => {
6
6
  it( 'returns zeroed aggregations when there are no attributes', () => {
@@ -90,50 +90,3 @@ describe( 'aggregateAttributes', () => {
90
90
  } );
91
91
  } );
92
92
 
93
- describe( 'mergeAggregations', () => {
94
- it( 'returns an empty object when no aggregations are provided', () => {
95
- expect( mergeAggregations() ).toEqual( {} );
96
- } );
97
-
98
- it( 'sums nested aggregation values', () => {
99
- expect( mergeAggregations(
100
- {
101
- cost: { total: 1.2 },
102
- tokens: { total: 10, input: 6 },
103
- httpRequests: { total: 2 }
104
- },
105
- {
106
- cost: { total: 0.3 },
107
- tokens: { total: 5, input: 2, output: 3 },
108
- httpRequests: { total: 1 }
109
- }
110
- ) ).toEqual( {
111
- cost: { total: 1.5 },
112
- tokens: { total: 15, input: 8, output: 3 },
113
- httpRequests: { total: 3 }
114
- } );
115
- } );
116
-
117
- it( 'handles undefined or partial aggregation objects', () => {
118
- expect( mergeAggregations(
119
- undefined,
120
- { cost: { total: 2 } },
121
- { tokens: { total: 4, reasoning: 1 } },
122
- { httpRequests: { total: 3 } }
123
- ) ).toEqual( {
124
- cost: { total: 2 },
125
- tokens: { total: 4, reasoning: 1 },
126
- httpRequests: { total: 3 }
127
- } );
128
- } );
129
-
130
- it( 'does not mutate source aggregation objects', () => {
131
- const first = { cost: { total: 1 }, tokens: { total: 2 }, httpRequests: { total: 3 } };
132
- const second = { cost: { total: 4 }, tokens: { total: 5 }, httpRequests: { total: 6 } };
133
-
134
- mergeAggregations( first, second );
135
-
136
- expect( first ).toEqual( { cost: { total: 1 }, tokens: { total: 2 }, httpRequests: { total: 3 } } );
137
- expect( second ).toEqual( { cost: { total: 4 }, tokens: { total: 5 }, httpRequests: { total: 6 } } );
138
- } );
139
- } );
@@ -1,10 +1,16 @@
1
+ import { ApplicationFailure } from '@temporalio/common';
2
+
1
3
  /**
2
- * Extract a property from the error .details.
3
- * If error does not have details, navigate up the .cause chain.
4
- *
5
- * @param {Error} e
6
- * @param {string} key
7
- * @returns {any} The value of the property
4
+ * Builds a Temporal ApplicationFailure based on an error attaching info to its details
5
+ * @param {Error} error
6
+ * @param {unknown} info
7
+ * @returns {ApplicationFailure}
8
8
  */
9
- export const extractErrorDetail = ( e, key ) =>
10
- e ? ( e.details?.find?.( d => d[key] )?.[key] ?? extractErrorDetail( e.cause, key ) ) : null;
9
+ export const buildApplicationFailureWithDetails = ( error, info ) =>
10
+ ApplicationFailure.create( {
11
+ message: error.message,
12
+ type: error.type ?? error.constructor?.name ?? error.name,
13
+ nonRetryable: error.nonRetryable,
14
+ details: ( Array.isArray( error.details ) ? error.details : [] ).concat( info ),
15
+ cause: error
16
+ } );
@@ -1,43 +1,89 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { extractErrorDetail } from './errors.js';
2
+ import { ApplicationFailure } from '@temporalio/common';
3
+ import { buildApplicationFailureWithDetails } from './errors.js';
3
4
 
4
- describe( 'extractErrorDetail', () => {
5
- it( 'returns a matching value from error details', () => {
6
- const error = new Error( 'failed' );
7
- error.details = [
8
- { requestId: 'req-1' },
9
- { workflowId: 'workflow-1' }
10
- ];
5
+ class CustomFailure extends Error {}
11
6
 
12
- expect( extractErrorDetail( error, 'workflowId' ) ).toBe( 'workflow-1' );
7
+ describe( 'buildApplicationFailureWithDetails', () => {
8
+ it( 'wraps a regular error in an ApplicationFailure with appended details', () => {
9
+ const error = new Error( 'step failed' );
10
+ const info = { aggregations: { cost: { total: 1 } } };
11
+
12
+ const failure = buildApplicationFailureWithDetails( error, info );
13
+
14
+ expect( failure ).toBeInstanceOf( ApplicationFailure );
15
+ expect( failure ).toMatchObject( {
16
+ message: 'step failed',
17
+ type: 'Error',
18
+ nonRetryable: false,
19
+ details: [ info ],
20
+ cause: error
21
+ } );
13
22
  } );
14
23
 
15
- it( 'walks the cause chain until it finds matching details', () => {
16
- const root = new Error( 'root' );
17
- root.details = [ { traceId: 'trace-1' } ];
18
- const wrapped = new Error( 'wrapped', { cause: root } );
24
+ it( 'uses the original constructor name for custom errors', () => {
25
+ const error = new CustomFailure( 'custom failed' );
26
+ const info = { trace: { destinations: { local: '/tmp/trace' } } };
19
27
 
20
- expect( extractErrorDetail( wrapped, 'traceId' ) ).toBe( 'trace-1' );
28
+ const failure = buildApplicationFailureWithDetails( error, info );
29
+
30
+ expect( failure ).toMatchObject( {
31
+ message: 'custom failed',
32
+ type: 'CustomFailure',
33
+ details: [ info ],
34
+ cause: error
35
+ } );
21
36
  } );
22
37
 
23
- it( 'prefers details from the current error over causes', () => {
24
- const root = new Error( 'root' );
25
- root.details = [ { traceId: 'root-trace' } ];
26
- const wrapped = new Error( 'wrapped', { cause: root } );
27
- wrapped.details = [ { traceId: 'wrapped-trace' } ];
38
+ it( 'preserves existing details and appends new info without mutating the original error', () => {
39
+ const existingDetails = [ { domain: { reason: 'bad-input' } } ];
40
+ const error = new Error( 'step failed' );
41
+ error.details = existingDetails;
42
+ const info = { aggregations: { httpRequests: { total: 1 } } };
43
+
44
+ const failure = buildApplicationFailureWithDetails( error, info );
28
45
 
29
- expect( extractErrorDetail( wrapped, 'traceId' ) ).toBe( 'wrapped-trace' );
46
+ expect( failure.details ).toEqual( [
47
+ { domain: { reason: 'bad-input' } },
48
+ info
49
+ ] );
50
+ expect( error.details ).toBe( existingDetails );
51
+ expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
30
52
  } );
31
53
 
32
- it( 'returns null when the key is not found', () => {
33
- const root = new Error( 'root' );
34
- root.details = [ { traceId: 'trace-1' } ];
35
- const wrapped = new Error( 'wrapped', { cause: root } );
54
+ it( 'ignores non-array details on the original error', () => {
55
+ const error = new Error( 'step failed' );
56
+ error.details = { domain: { reason: 'bad-input' } };
57
+ const info = { aggregations: { tokens: { total: 3 } } };
36
58
 
37
- expect( extractErrorDetail( wrapped, 'missing' ) ).toBeNull();
59
+ const failure = buildApplicationFailureWithDetails( error, info );
60
+
61
+ expect( failure.details ).toEqual( [ info ] );
38
62
  } );
39
63
 
40
- it( 'returns null for empty errors', () => {
41
- expect( extractErrorDetail( null, 'traceId' ) ).toBeNull();
64
+ it( 'preserves ApplicationFailure type, nonRetryable flag, and details while avoiding self-cause', () => {
65
+ const original = ApplicationFailure.create( {
66
+ message: 'application failed',
67
+ type: 'DomainFailure',
68
+ nonRetryable: true,
69
+ details: [ { domain: { reason: 'bad-input' } } ]
70
+ } );
71
+ const info = { aggregations: { cost: { total: 2 } } };
72
+
73
+ const failure = buildApplicationFailureWithDetails( original, info );
74
+
75
+ expect( failure ).toBeInstanceOf( ApplicationFailure );
76
+ expect( failure ).not.toBe( original );
77
+ expect( failure.cause ).toBe( original );
78
+ expect( failure.cause ).not.toBe( failure );
79
+ expect( failure ).toMatchObject( {
80
+ message: 'application failed',
81
+ type: 'DomainFailure',
82
+ nonRetryable: true,
83
+ details: [
84
+ { domain: { reason: 'bad-input' } },
85
+ info
86
+ ]
87
+ } );
42
88
  } );
43
89
  } );
@@ -159,3 +159,22 @@ export function allSettledWithTimeout<T>(
159
159
  promises: Array<T | PromiseLike<T>>,
160
160
  timeoutMs: number
161
161
  ): Promise<PromiseSettledResult<Awaited<T>>[]>;
162
+
163
+ /**
164
+ * Promise wrapper that can be resolved externally.
165
+ */
166
+ export class CancellablePromise {
167
+ /** The internal promise */
168
+ readonly promise: Promise<void>;
169
+ /** Whether the promise is already resolved or not */
170
+ readonly completed: boolean;
171
+ /** Resolves the promise */
172
+ complete(): void;
173
+ }
174
+
175
+ /**
176
+ * Returns a function that invokes the wrapped function once.
177
+ */
178
+ export function runOnce<Args extends unknown[], Return>(
179
+ fn: ( ...args: Args ) => Return
180
+ ): ( ...args: Args ) => Return;
@@ -279,3 +279,49 @@ export const allSettledWithTimeout = ( () => {
279
279
  }
280
280
  };
281
281
  } )();
282
+
283
+ /**
284
+ * Builds a promise that can be resolved from the outside.
285
+ */
286
+ export class CancellablePromise {
287
+ #promise = null;
288
+ #complete = null;
289
+ #completed = false;
290
+
291
+ constructor() {
292
+ this.#promise = new Promise( resolve => {
293
+ this.#complete = () => {
294
+ resolve();
295
+ this.#completed = true;
296
+ };
297
+ } );
298
+ }
299
+ /** Retrieves the promise */
300
+ get promise() {
301
+ return this.#promise;
302
+ }
303
+ /** Returns whether the promise is resolved or not */
304
+ get completed() {
305
+ return this.#completed;
306
+ }
307
+ /** Resolves the promise */
308
+ complete() {
309
+ this.#complete();
310
+ }
311
+ };
312
+
313
+ /**
314
+ * Returns a function that invokes the fn argument when called once, further calls do nothing.
315
+ * @param {Function} fn
316
+ * @returns {Function}
317
+ */
318
+ export const runOnce = fn => {
319
+ const state = { executed: false, result: undefined };
320
+ return ( ...args ) => {
321
+ if ( !state.executed ) {
322
+ state.executed = true;
323
+ return state.result = fn( ...args );
324
+ }
325
+ return state.result;
326
+ };
327
+ };
@@ -8,7 +8,9 @@ import {
8
8
  deepMergeWithResolver,
9
9
  isPlainObject,
10
10
  toUrlSafeBase64,
11
- allSettledWithTimeout
11
+ allSettledWithTimeout,
12
+ CancellablePromise,
13
+ runOnce
12
14
  } from './utils.js';
13
15
 
14
16
  describe( 'clone', () => {
@@ -142,6 +144,85 @@ describe( 'allSettledWithTimeout', () => {
142
144
  } );
143
145
  } );
144
146
 
147
+ describe( 'CancellablePromise', () => {
148
+ it( 'exposes a pending promise until it is completed', async () => {
149
+ const cancellable = new CancellablePromise();
150
+ const onComplete = vi.fn();
151
+
152
+ cancellable.promise.then( onComplete );
153
+ await Promise.resolve();
154
+
155
+ expect( cancellable.completed ).toBe( false );
156
+ expect( onComplete ).not.toHaveBeenCalled();
157
+
158
+ cancellable.complete();
159
+ await cancellable.promise;
160
+
161
+ expect( cancellable.completed ).toBe( true );
162
+ expect( onComplete ).toHaveBeenCalledOnce();
163
+ } );
164
+
165
+ it( 'can be completed multiple times without resolving again', async () => {
166
+ const cancellable = new CancellablePromise();
167
+ const onComplete = vi.fn();
168
+
169
+ cancellable.promise.then( onComplete );
170
+ cancellable.complete();
171
+ cancellable.complete();
172
+ await cancellable.promise;
173
+ await Promise.resolve();
174
+
175
+ expect( cancellable.completed ).toBe( true );
176
+ expect( onComplete ).toHaveBeenCalledOnce();
177
+ } );
178
+ } );
179
+
180
+ describe( 'runOnce', () => {
181
+ it( 'calls the wrapped function only once', () => {
182
+ const fn = vi.fn();
183
+ const once = runOnce( fn );
184
+
185
+ once();
186
+ once();
187
+ once();
188
+
189
+ expect( fn ).toHaveBeenCalledOnce();
190
+ } );
191
+
192
+ it( 'passes arguments and replays the first call result', () => {
193
+ const fn = vi.fn( ( a, b ) => a + b );
194
+ const once = runOnce( fn );
195
+
196
+ expect( once( 2, 3 ) ).toBe( 5 );
197
+ expect( once( 4, 5 ) ).toBe( 5 );
198
+ expect( once( 6, 7 ) ).toBe( 5 );
199
+ expect( fn ).toHaveBeenCalledWith( 2, 3 );
200
+ expect( fn ).toHaveBeenCalledOnce();
201
+ } );
202
+
203
+ it( 'replays the first returned promise', () => {
204
+ const result = Promise.resolve( 'done' );
205
+ const fn = vi.fn( () => result );
206
+ const once = runOnce( fn );
207
+
208
+ expect( once() ).toBe( result );
209
+ expect( once() ).toBe( result );
210
+ expect( fn ).toHaveBeenCalledOnce();
211
+ } );
212
+
213
+ it( 'does not retry when the first call throws', () => {
214
+ const error = new Error( 'boom' );
215
+ const fn = vi.fn( () => {
216
+ throw error;
217
+ } );
218
+ const once = runOnce( fn );
219
+
220
+ expect( () => once() ).toThrow( error );
221
+ expect( once() ).toBeUndefined();
222
+ expect( fn ).toHaveBeenCalledOnce();
223
+ } );
224
+ } );
225
+
145
226
  describe( 'serializeFetchResponse', () => {
146
227
  it( 'serializes JSON response body and flattens headers', async () => {
147
228
  const payload = { a: 1, b: 'two' };
@@ -0,0 +1,26 @@
1
+ import { bundleWorkflowCode } from '@temporalio/worker';
2
+ import { loadWorkflows, loadActivities, createWorkflowsEntryPoint } from './loader.js';
3
+ import { webpackConfigHook } from './bundler_options.js';
4
+ import { workflowInterceptorModules } from './interceptors/modules.js';
5
+
6
+ /**
7
+ * Bundle a project's workflows exactly as the worker does, without a Temporal server.
8
+ *
9
+ * Mirrors the worker's startup prep (`loadWorkflows` -> `loadActivities` ->
10
+ * `createWorkflowsEntryPoint`) and then runs the same bundler (`bundleWorkflowCode`)
11
+ * with the same inputs `Worker.create` derives — `webpackConfigHook` and
12
+ * `workflowInterceptorModules` — so it stays in parity with worker startup. Rejects if
13
+ * the Temporal webpack bundler fails, e.g. a `node:` built-in in the workflow's
14
+ * transitive import graph.
15
+ *
16
+ * @param {string} rootDir directory to discover workflows in
17
+ * @returns {Promise<import('@temporalio/worker').WorkflowBundleWithSourceMap>}
18
+ */
19
+ export async function bundleWorkflows( rootDir ) {
20
+ const workflows = await loadWorkflows( rootDir );
21
+ // Writes worker/temp/__activity_options.js, which the workflow interceptor module
22
+ // imports — the worker generates it via loadActivities() before Worker.create bundles.
23
+ await loadActivities( rootDir, workflows );
24
+ const workflowsPath = createWorkflowsEntryPoint( workflows );
25
+ return bundleWorkflowCode( { workflowsPath, workflowInterceptorModules, webpackConfigHook } );
26
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Stub the activity interceptor so the real interceptors/index.js imports cleanly.
4
+ vi.mock( './interceptors/activity.js', () => ( { ActivityExecutionInterceptor: class {} } ) );
5
+
6
+ vi.mock( '@temporalio/worker', () => ( {
7
+ bundleWorkflowCode: vi.fn().mockResolvedValue( { code: '', sourceMap: '' } )
8
+ } ) );
9
+
10
+ vi.mock( './loader.js', () => ( {
11
+ loadWorkflows: vi.fn().mockResolvedValue( [] ),
12
+ loadActivities: vi.fn().mockResolvedValue( {} ),
13
+ createWorkflowsEntryPoint: vi.fn().mockReturnValue( '/fake/workflows/entrypoint.js' )
14
+ } ) );
15
+
16
+ vi.mock( './bundler_options.js', () => ( { webpackConfigHook: vi.fn() } ) );
17
+
18
+ import { bundleWorkflowCode } from '@temporalio/worker';
19
+ import { loadWorkflows, loadActivities, createWorkflowsEntryPoint } from './loader.js';
20
+ import { webpackConfigHook } from './bundler_options.js';
21
+ import { initInterceptors } from './interceptors/index.js';
22
+ import { workflowInterceptorModules } from './interceptors/modules.js';
23
+ import { bundleWorkflows } from './bundle.js';
24
+
25
+ describe( 'output-worker --check parity', () => {
26
+ beforeEach( () => {
27
+ vi.clearAllMocks();
28
+ loadWorkflows.mockResolvedValue( [] );
29
+ loadActivities.mockResolvedValue( {} );
30
+ createWorkflowsEntryPoint.mockReturnValue( '/fake/workflows/entrypoint.js' );
31
+ bundleWorkflowCode.mockResolvedValue( { code: '', sourceMap: '' } );
32
+ } );
33
+
34
+ it( 'worker registers the shared workflow interceptor modules', () => {
35
+ const { workflowModules } = initInterceptors( { activities: {}, workflows: [], connection: {} } );
36
+ // The check (bundleWorkflows) and the worker must register the very same modules.
37
+ expect( workflowModules ).toBe( workflowInterceptorModules );
38
+ } );
39
+
40
+ it( 'check bundles with the same inputs Worker.create derives', async () => {
41
+ await bundleWorkflows( '/project' );
42
+
43
+ expect( loadWorkflows ).toHaveBeenCalledWith( '/project' );
44
+ expect( loadActivities ).toHaveBeenCalledWith( '/project', [] );
45
+ expect( createWorkflowsEntryPoint ).toHaveBeenCalledWith( [] );
46
+ expect( bundleWorkflowCode ).toHaveBeenCalledWith( {
47
+ workflowsPath: '/fake/workflows/entrypoint.js',
48
+ workflowInterceptorModules,
49
+ webpackConfigHook
50
+ } );
51
+ } );
52
+ } );
@@ -0,0 +1,148 @@
1
+ import { Client, WorkflowNotFoundError } from '@temporalio/client';
2
+ import { WorkflowExecutionAlreadyStartedError, WorkflowIdConflictPolicy } from '@temporalio/common';
3
+ import { WORKFLOW_CATALOG } from '#consts';
4
+ import { catalogId, taskQueue } from '../configs.js';
5
+ import { createChildLogger } from '#logger';
6
+ import { CancellablePromise } from '#utils';
7
+
8
+ const log = createChildLogger( 'Catalog' );
9
+
10
+ class CancellationError extends Error {};
11
+
12
+ // Note, functions don't log on "WorkflowNotFound" errors,
13
+ // because they happen when the catalog is not running at all.
14
+
15
+ /** Make sure the latest version of the catalog workflow is running. Stateful. */
16
+ export class CatalogJob {
17
+ #cancellation = new CancellablePromise();
18
+ #connection = null;
19
+ #namespace = null;
20
+ #catalog = null;
21
+ #catalogHash = null;
22
+
23
+ #running = false;
24
+ #executePromise = null;
25
+ #onErrorCb = null;
26
+ #error = null;
27
+
28
+ #runCancellable = promise => Promise
29
+ .race( [ promise, this.#cancellation.promise.then( () => {
30
+ throw new CancellationError();
31
+ } ) ] );
32
+
33
+ /** Check if the currently running catalog has the same hash as instance. */
34
+ async #checkCatalogIsTheSame( handle ) {
35
+ log.info( 'Checking running catalog hash against worker hash...' );
36
+ return this.#runCancellable( handle.query( 'get_hash' ) ).then( h => h === this.#catalogHash ).catch( e => {
37
+ if ( e instanceof CancellationError ) {
38
+ throw e;
39
+ }
40
+ if ( !( e instanceof WorkflowNotFoundError ) ) {
41
+ log.warn( 'Error retrieving catalog hash', { error: e } );
42
+ }
43
+ return false;
44
+ } );
45
+ };
46
+
47
+ /** Check if the catalog workflow is running. */
48
+ async #checkCatalogRunning( handle ) {
49
+ log.info( 'Checking if the catalog workflow is running...' );
50
+ return this.#runCancellable( handle.describe() ).then( d => !d.closeTime ).catch( e => {
51
+ if ( e instanceof CancellationError ) {
52
+ throw e;
53
+ }
54
+ if ( !( e instanceof WorkflowNotFoundError ) ) {
55
+ log.warn( 'Error describing catalog workflow', { error: e } );
56
+ }
57
+ return false;
58
+ } );
59
+ };
60
+
61
+ /** Complete previous running catalog workflow. */
62
+ async #completePreviousCatalog( handle ) {
63
+ log.info( 'Completing previous catalog workflow...' );
64
+ return this.#runCancellable( handle.executeUpdate( 'complete', { args: [] } ) ).catch( e => {
65
+ if ( e instanceof CancellationError ) {
66
+ throw e;
67
+ }
68
+ if ( !( e instanceof WorkflowNotFoundError ) ) {
69
+ log.warn( 'Error completing previous catalog workflow', { error: e } );
70
+ }
71
+ } );
72
+ };
73
+
74
+ /** Run the sequence to start the catalog */
75
+ async #execute() {
76
+ const client = new Client( { connection: this.#connection, namespace: this.#namespace } );
77
+ const handle = client.workflow.getHandle( catalogId );
78
+
79
+ if ( await this.#checkCatalogRunning( handle ) ) {
80
+ if ( await this.#checkCatalogIsTheSame( handle ) ) {
81
+ log.info( 'Current catalog workflow hash matches worker, restart skipped' );
82
+ return;
83
+ }
84
+ await this.#completePreviousCatalog( handle );
85
+ }
86
+
87
+ const startArguments = {
88
+ taskQueue,
89
+ workflowId: catalogId,
90
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
91
+ args: [ this.#catalog, this.#catalogHash ]
92
+ };
93
+
94
+ log.info( 'Starting catalog workflow...' );
95
+ try {
96
+ await this.#runCancellable( client.workflow.start( WORKFLOW_CATALOG, startArguments ) );
97
+ } catch ( error ) {
98
+ // if the error was caused by the catalog existing and its hash is the same as the one from the worker, just ignore the error
99
+ if ( error instanceof WorkflowExecutionAlreadyStartedError && await this.#checkCatalogIsTheSame( handle ) ) {
100
+ log.info( 'Ignoring start error: it failed because execution already started but catalog hash matches worker' );
101
+ } else {
102
+ throw error;
103
+ }
104
+ }
105
+ log.info( 'Startup completed' );
106
+ }
107
+
108
+ constructor( { connection, namespace, catalog, catalogHash } ) {
109
+ this.#connection = connection;
110
+ this.#namespace = namespace;
111
+ this.#catalog = catalog;
112
+ this.#catalogHash = catalogHash;
113
+ }
114
+
115
+ onError( cb ) {
116
+ this.#onErrorCb = cb;
117
+ }
118
+
119
+ get running() {
120
+ return this.#running;
121
+ }
122
+
123
+ interrupt() {
124
+ if ( this.#running ) {
125
+ this.#cancellation.complete();
126
+ }
127
+ return this.#executePromise ?? Promise.resolve();
128
+ }
129
+
130
+ get error() {
131
+ return this.#error;
132
+ }
133
+
134
+ async run() {
135
+ this.#running = true;
136
+ this.#executePromise = this.#execute()
137
+ .catch( error => {
138
+ if ( !( error instanceof CancellationError ) ) {
139
+ this.#error = error;
140
+ this.#onErrorCb?.( error );
141
+ }
142
+ } )
143
+ .finally( () => {
144
+ this.#running = false;
145
+ } );
146
+ return this.#executePromise;
147
+ }
148
+ };