@outputai/core 0.1.12-next.ecd4dda.0 → 0.1.13-dev.01b8dea.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.
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { copyFileSync, mkdirSync, globSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ const srcDir = 'src';
6
+ const destDir = 'dist';
7
+ const matchers = [ '**/*.prompt', '**/*.yml.enc', '**/*.key', '**/*.md' ];
8
+
9
+ for ( const pattern of matchers ) {
10
+ for ( const file of globSync( pattern, { cwd: srcDir } ) ) {
11
+ const src = join( srcDir, file );
12
+ const dest = join( destDir, file );
13
+ mkdirSync( dirname( dest ), { recursive: true } );
14
+ copyFileSync( src, dest );
15
+ }
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/core",
3
- "version": "0.1.12-next.ecd4dda.0",
3
+ "version": "0.1.13-dev.01b8dea.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,10 +28,11 @@
28
28
  "bin": {
29
29
  "output-worker": "./bin/worker.sh",
30
30
  "outputai": "./bin/worker.sh",
31
- "output-healthcheck": "./bin/healthcheck.mjs"
31
+ "output-healthcheck": "./bin/healthcheck.mjs",
32
+ "output-copy-assets": "./bin/copy_assets.js"
32
33
  },
33
34
  "dependencies": {
34
- "@aws-sdk/client-s3": "3.1018.0",
35
+ "@aws-sdk/client-s3": "3.1024.0",
35
36
  "@babel/generator": "7.29.1",
36
37
  "@babel/parser": "7.29.2",
37
38
  "@babel/traverse": "7.29.0",
@@ -43,7 +44,7 @@
43
44
  "@temporalio/workflow": "1.15.0",
44
45
  "redis": "5.11.0",
45
46
  "stacktrace-parser": "0.1.11",
46
- "undici": "7.24.6",
47
+ "undici": "8.0.2",
47
48
  "winston": "3.19.0",
48
49
  "zod": "4.3.6"
49
50
  },
@@ -64,6 +64,7 @@ const baseSchema = z.strictObject( {
64
64
  const stepSchema = baseSchema;
65
65
 
66
66
  const workflowSchema = baseSchema.extend( {
67
+ aliases: z.array( z.string().regex( /^[a-z_][a-z0-9_]*$/i ) ).optional().default( [] ),
67
68
  options: baseSchema.shape.options.unwrap().extend( {
68
69
  disableTrace: z.boolean().optional().default( false )
69
70
  } ).optional()
@@ -189,6 +189,37 @@ describe( 'interface/validator', () => {
189
189
  it( 'rejects non-boolean options.disableTrace', () => {
190
190
  expect( () => validateWorkflow( { ...validArgs, options: { disableTrace: 'yes' } } ) ).toThrow( StaticValidationError );
191
191
  } );
192
+
193
+ it( 'passes with valid aliases array', () => {
194
+ expect( () => validateWorkflow( { ...validArgs, aliases: [ 'old_name', 'legacy_v1' ] } ) ).not.toThrow();
195
+ } );
196
+
197
+ it( 'passes with empty aliases array', () => {
198
+ expect( () => validateWorkflow( { ...validArgs, aliases: [] } ) ).not.toThrow();
199
+ } );
200
+
201
+ it( 'passes without aliases (optional)', () => {
202
+ expect( () => validateWorkflow( { ...validArgs } ) ).not.toThrow();
203
+ } );
204
+
205
+ it( 'rejects aliases with invalid name pattern', () => {
206
+ expect( () => validateWorkflow( { ...validArgs, aliases: [ '-bad' ] } ) ).toThrow( StaticValidationError );
207
+ } );
208
+
209
+ it( 'rejects non-array aliases', () => {
210
+ expect( () => validateWorkflow( { ...validArgs, aliases: 'not_array' } ) ).toThrow( StaticValidationError );
211
+ } );
212
+ } );
213
+
214
+ describe( 'aliases rejected on steps and evaluators', () => {
215
+ it( 'validateStep rejects aliases due to strictObject', () => {
216
+ expect( () => validateStep( { ...validArgs, aliases: [ 'alias' ] } ) ).toThrow( StaticValidationError );
217
+ } );
218
+
219
+ it( 'validateEvaluator rejects aliases due to strictObject', () => {
220
+ const { outputSchema, ...evalArgs } = validArgs;
221
+ expect( () => validateEvaluator( { ...evalArgs, aliases: [ 'alias' ] } ) ).toThrow( StaticValidationError );
222
+ } );
192
223
  } );
193
224
 
194
225
  describe( 'validateEvaluator', () => {
@@ -270,4 +270,10 @@ export declare function workflow<
270
270
  outputSchema?: OutputSchema;
271
271
  fn: WorkflowFunction<InputSchema, OutputSchema>;
272
272
  options?: WorkflowOptions;
273
+
274
+ /**
275
+ * Alternative names that resolve to this workflow. Useful when renaming a workflow
276
+ * while maintaining backward compatibility with existing callers.
277
+ */
278
+ aliases?: string[];
273
279
  } ): WorkflowFunctionWrapper<WorkflowFunction<InputSchema, OutputSchema>>;
@@ -22,8 +22,8 @@ const defaultOptions = {
22
22
  disableTrace: false
23
23
  };
24
24
 
25
- export function workflow( { name, description, inputSchema, outputSchema, fn, options = {} } ) {
26
- validateWorkflow( { name, description, inputSchema, outputSchema, fn, options } );
25
+ export function workflow( { name, description, inputSchema, outputSchema, fn, options = {}, aliases = [] } ) {
26
+ validateWorkflow( { name, description, inputSchema, outputSchema, fn, options, aliases } );
27
27
 
28
28
  const { disableTrace, activityOptions } = deepMerge( defaultOptions, options );
29
29
  const steps = proxyActivities( activityOptions );
@@ -123,6 +123,6 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
123
123
  }
124
124
  };
125
125
 
126
- setMetadata( wrapper, { name, description, inputSchema, outputSchema } );
126
+ setMetadata( wrapper, { name, description, inputSchema, outputSchema, aliases } );
127
127
  return wrapper;
128
128
  };
@@ -140,7 +140,7 @@ describe( 'workflow()', () => {
140
140
  const symbols = Object.getOwnPropertySymbols( wf );
141
141
  expect( symbols ).toHaveLength( 1 );
142
142
  const meta = wf[symbols[0]];
143
- expect( meta ).toEqual( { name: 'meta_wf', description: 'Meta workflow', inputSchema, outputSchema } );
143
+ expect( meta ).toEqual( { name: 'meta_wf', description: 'Meta workflow', inputSchema, outputSchema, aliases: [] } );
144
144
  } );
145
145
  } );
146
146
 
@@ -97,6 +97,11 @@ export class CatalogWorkflow extends CatalogEntry {
97
97
  * @type {Array<CatalogActivity>}
98
98
  */
99
99
  activities;
100
+ /**
101
+ * Alternative names that resolve to this workflow.
102
+ * @type {Array<string>}
103
+ */
104
+ aliases;
100
105
 
101
106
  /**
102
107
  * @param {Object} params - Entry parameters.
@@ -106,9 +111,11 @@ export class CatalogWorkflow extends CatalogEntry {
106
111
  * @param {object} [params.outputSchema] - JSON schema describing the produced output.
107
112
  * @param {string} params.path - Absolute path of the entity in the file system.
108
113
  * @param {Array<CatalogActivity>} params.activities - Each activity of this workflow
114
+ * @param {Array<string>} [params.aliases] - Alternative names for this workflow
109
115
  */
110
- constructor( { activities, ...args } ) {
116
+ constructor( { activities, aliases = [], ...args } ) {
111
117
  super( args );
112
118
  this.activities = activities;
119
+ this.aliases = aliases;
113
120
  };
114
121
  };
@@ -193,4 +193,27 @@ describe( 'createCatalog', () => {
193
193
  expect( workflows[0].path ).toBe( '/flows/flow1/workflow.js' );
194
194
  expect( workflows[1].path ).toBe( '/flows/flow2/workflow.js' );
195
195
  } );
196
+
197
+ it( 'includes aliases in catalog workflow entries', async () => {
198
+ const { createCatalog } = await import( './index.js' );
199
+
200
+ const workflows = [
201
+ {
202
+ name: 'flow1',
203
+ path: '/flows/flow1/workflow.js',
204
+ description: 'desc-flow1',
205
+ aliases: [ 'flow1_old', 'flow1_legacy' ]
206
+ },
207
+ {
208
+ name: 'flow2',
209
+ path: '/flows/flow2/workflow.js',
210
+ description: 'desc-flow2'
211
+ }
212
+ ];
213
+
214
+ const catalog = createCatalog( { workflows, activities: {} } );
215
+
216
+ expect( catalog.workflows[0].aliases ).toEqual( [ 'flow1_old', 'flow1_legacy' ] );
217
+ expect( catalog.workflows[1].aliases ).toEqual( [] );
218
+ } );
196
219
  } );
@@ -26,7 +26,9 @@ const envVarSchema = z.object( {
26
26
  // Whether to send activity heartbeats (enabled by default)
27
27
  OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) ),
28
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 ) )
29
+ OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 3000 ) ),
30
+ // HTTP CONNECT proxy for Temporal gRPC connections (e.g. "proxy-host:8080")
31
+ TEMPORAL_GRPC_PROXY: z.string().optional()
30
32
  } );
31
33
 
32
34
  const { data: envVars, error } = envVarSchema.safeParse( process.env );
@@ -47,3 +49,4 @@ export const catalogId = envVars.OUTPUT_CATALOG_ID;
47
49
  export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS;
48
50
  export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
49
51
  export const processFailureShutdownDelay = envVars.OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY;
52
+ export const grpcProxy = envVars.TEMPORAL_GRPC_PROXY;
@@ -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 { bootstrapFetchProxy } from './proxy.js';
12
13
  import { messageBus } from '#bus';
13
14
  import './log_hooks.js';
14
15
  import { BusEventType } from '#consts';
@@ -24,6 +25,7 @@ const callerDir = process.argv[2];
24
25
  apiKey,
25
26
  namespace,
26
27
  taskQueue,
28
+ grpcProxy,
27
29
  maxConcurrentWorkflowTaskExecutions,
28
30
  maxConcurrentActivityTaskExecutions,
29
31
  maxCachedWorkflows,
@@ -41,6 +43,7 @@ const callerDir = process.argv[2];
41
43
  const activities = await loadActivities( callerDir, workflows );
42
44
 
43
45
  messageBus.emit( BusEventType.WORKER_BEFORE_START );
46
+ bootstrapFetchProxy();
44
47
 
45
48
  log.info( 'Creating worker entry point...' );
46
49
  const workflowsPath = createWorkflowsEntryPoint( workflows );
@@ -52,8 +55,8 @@ const callerDir = process.argv[2];
52
55
  const catalog = createCatalog( { workflows, activities } );
53
56
 
54
57
  log.info( 'Connecting Temporal...' );
55
- // Enable TLS when connecting to remote Temporal (API key present)
56
- const connection = await NativeConnection.connect( { address, tls: Boolean( apiKey ), apiKey } );
58
+ const proxy = grpcProxy ? { type: 'http-connect', targetHost: grpcProxy } : undefined;
59
+ const connection = await NativeConnection.connect( { address, tls: Boolean( apiKey ), apiKey, proxy } );
57
60
 
58
61
  log.info( 'Creating worker...' );
59
62
  const worker = await Worker.create( {
@@ -16,6 +16,7 @@ const configValues = {
16
16
  namespace: 'default',
17
17
  taskQueue: 'test-queue',
18
18
  catalogId: 'test-catalog',
19
+ grpcProxy: undefined,
19
20
  maxConcurrentWorkflowTaskExecutions: 200,
20
21
  maxConcurrentActivityTaskExecutions: 40,
21
22
  maxCachedWorkflows: 1000,
@@ -52,6 +53,9 @@ vi.mock( './interceptors.js', () => ( { initInterceptors: initInterceptorsMock }
52
53
  const startCatalogMock = vi.fn().mockResolvedValue( undefined );
53
54
  vi.mock( './start_catalog.js', () => ( { startCatalog: startCatalogMock } ) );
54
55
 
56
+ const bootstrapFetchProxyMock = vi.fn();
57
+ vi.mock( './proxy.js', () => ( { bootstrapFetchProxy: bootstrapFetchProxyMock } ) );
58
+
55
59
  const registerShutdownMock = vi.fn();
56
60
  vi.mock( './shutdown.js', () => ( { registerShutdown: registerShutdownMock } ) );
57
61
 
@@ -104,7 +108,8 @@ describe( 'worker/index', () => {
104
108
  expect( NativeConnection.connect ).toHaveBeenCalledWith( {
105
109
  address: configValues.address,
106
110
  tls: false,
107
- apiKey: undefined
111
+ apiKey: undefined,
112
+ proxy: undefined
108
113
  } );
109
114
  expect( Worker.create ).toHaveBeenCalledWith( expect.objectContaining( {
110
115
  namespace: configValues.namespace,
@@ -25,7 +25,13 @@ import { messageBus } from '#bus';
25
25
  export class ActivityExecutionInterceptor {
26
26
  constructor( { activities, workflows } ) {
27
27
  this.activities = activities;
28
- this.workflowsMap = workflows.reduce( ( map, w ) => map.set( w.name, w ), new Map() );
28
+ this.workflowsMap = workflows.reduce( ( map, w ) => {
29
+ map.set( w.name, w );
30
+ for ( const alias of w.aliases ?? [] ) {
31
+ map.set( alias, w );
32
+ }
33
+ return map;
34
+ }, new Map() );
29
35
  };
30
36
 
31
37
  async execute( input, next ) {
@@ -33,11 +39,17 @@ export class ActivityExecutionInterceptor {
33
39
  const { workflowExecution: { workflowId }, activityId: id, activityType: name, workflowType: workflowName } = Context.current().info;
34
40
  const { executionContext } = headersToObject( input.headers );
35
41
  const { type: kind } = this.activities?.[name]?.[METADATA_ACCESS_SYMBOL];
36
- const workflowFilename = this.workflowsMap.get( workflowName ).path;
37
42
 
38
43
  messageBus.emit( BusEventType.ACTIVITY_START, { id, name, kind, workflowId, workflowName } );
39
44
  Tracing.addEventStart( { id, name, kind, parentId: workflowId, details: input.args[0], executionContext } );
40
45
 
46
+ const workflowEntry = this.workflowsMap.get( workflowName );
47
+ if ( !workflowEntry ) {
48
+ const availableWorkflows = [ ...this.workflowsMap.keys() ].join( ', ' );
49
+ throw new Error( `Activity interceptor: workflow "${workflowName}" not found in workflowsMap. Available: [${availableWorkflows}]` );
50
+ }
51
+ const workflowFilename = workflowEntry.path;
52
+
41
53
  const intervals = { heartbeat: null };
42
54
  try {
43
55
  // Sends heartbeat to communicate that activity is still alive
@@ -191,6 +191,29 @@ describe( 'ActivityExecutionInterceptor', () => {
191
191
  expect( heartbeatMock ).not.toHaveBeenCalled();
192
192
  } );
193
193
 
194
+ it( 'resolves workflow alias in workflowsMap', async () => {
195
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
196
+ const workflows = [ { name: 'myWorkflow', path: '/workflows/myWorkflow.js', aliases: [ 'myWorkflowOld' ] } ];
197
+ const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows } );
198
+
199
+ // Override context to use alias as workflowType
200
+ contextInfoMock.workflowType = 'myWorkflowOld';
201
+ const next = vi.fn().mockResolvedValue( { result: 'ok' } );
202
+
203
+ const promise = interceptor.execute( makeInput(), next );
204
+ vi.advanceTimersByTime( 0 );
205
+ await promise;
206
+
207
+ // Should resolve to the correct path despite using the alias
208
+ expect( runWithContextMock ).toHaveBeenCalledWith(
209
+ expect.any( Function ),
210
+ expect.objectContaining( { workflowFilename: '/workflows/myWorkflow.js' } )
211
+ );
212
+
213
+ // Restore for other tests
214
+ contextInfoMock.workflowType = 'myWorkflow';
215
+ } );
216
+
194
217
  it( 'does not heartbeat when OUTPUT_ACTIVITY_HEARTBEAT_ENABLED is false', async () => {
195
218
  vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED', 'false' );
196
219
  const { ActivityExecutionInterceptor } = await import( './activity.js' );
@@ -132,6 +132,40 @@ export async function loadHooks( rootDir ) {
132
132
  }
133
133
  };
134
134
 
135
+ /**
136
+ * Validates that all workflow names and aliases are unique across the project.
137
+ *
138
+ * @param {object[]} workflows
139
+ * @throws {Error} If any alias conflicts with a workflow name or another alias
140
+ */
141
+ function validateWorkflowNames( workflows ) {
142
+ const allNames = new Map();
143
+
144
+ // Register primary names (case-insensitive to prevent confusing collisions)
145
+ for ( const { name } of workflows ) {
146
+ allNames.set( name.toLowerCase(), `workflow "${name}"` );
147
+ }
148
+
149
+ // Check the reserved catalog name
150
+ allNames.set( WORKFLOW_CATALOG.toLowerCase(), 'system workflow "$catalog"' );
151
+
152
+ // Check aliases against all names
153
+ for ( const { name, aliases = [] } of workflows ) {
154
+ const lowerCaseName = name.toLowerCase();
155
+ for ( const alias of aliases ) {
156
+ const lowerAliasName = alias.toLowerCase();
157
+ if ( lowerAliasName === lowerCaseName ) {
158
+ throw new Error( `Workflow "${name}" has an alias identical to its own name` );
159
+ }
160
+ const conflict = allNames.get( lowerAliasName );
161
+ if ( conflict ) {
162
+ throw new Error( `Alias "${alias}" on workflow "${name}" conflicts with ${conflict}` );
163
+ }
164
+ allNames.set( lowerAliasName, `alias "${alias}" on workflow "${name}"` );
165
+ }
166
+ }
167
+ }
168
+
135
169
  /**
136
170
  * Creates a temporary index file importing all workflows for Temporal.
137
171
  *
@@ -139,11 +173,16 @@ export async function loadHooks( rootDir ) {
139
173
  * @returns
140
174
  */
141
175
  export function createWorkflowsEntryPoint( workflows ) {
176
+ validateWorkflowNames( workflows );
177
+
142
178
  const path = join( __dirname, 'temp', WORKFLOWS_INDEX_FILENAME );
143
179
 
144
180
  // default system catalog workflow
145
181
  const catalog = { name: WORKFLOW_CATALOG, path: join( __dirname, './catalog_workflow/workflow.js' ) };
146
- const content = [ ... workflows, catalog ].map( ( { name, path } ) => `export { default as ${name} } from '${path}';` ).join( EOL );
182
+ const aliasExports = workflows.flatMap( ( { aliases = [], path } ) =>
183
+ aliases.map( alias => ( { name: alias, path } ) )
184
+ );
185
+ const content = [ ...workflows, ...aliasExports, catalog ].map( ( { name, path } ) => `export { default as ${name} } from '${path}';` ).join( EOL );
147
186
 
148
187
  mkdirSync( dirname( path ), { recursive: true } );
149
188
  writeFileSync( path, content, 'utf-8' );
@@ -111,6 +111,65 @@ describe( 'worker/loader', () => {
111
111
  expect( fsMocks.mkdirSync ).toHaveBeenCalledTimes( 1 );
112
112
  } );
113
113
 
114
+ it( 'createWorkflowsEntryPoint generates alias exports', async () => {
115
+ const { createWorkflowsEntryPoint } = await import( './loader.js' );
116
+
117
+ const workflows = [ { name: 'W', path: '/abs/wf.js', aliases: [ 'W_old', 'W_legacy' ] } ];
118
+ createWorkflowsEntryPoint( workflows );
119
+
120
+ const [ , contents ] = fsMocks.writeFileSync.mock.calls[0];
121
+ expect( contents ).toContain( 'export { default as W } from \'/abs/wf.js\';' );
122
+ expect( contents ).toContain( 'export { default as W_old } from \'/abs/wf.js\';' );
123
+ expect( contents ).toContain( 'export { default as W_legacy } from \'/abs/wf.js\';' );
124
+ } );
125
+
126
+ it( 'createWorkflowsEntryPoint throws on alias conflicting with primary name', async () => {
127
+ const { createWorkflowsEntryPoint } = await import( './loader.js' );
128
+
129
+ const workflows = [
130
+ { name: 'alpha', path: '/a.js', aliases: [] },
131
+ { name: 'beta', path: '/b.js', aliases: [ 'alpha' ] }
132
+ ];
133
+ expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "alpha" on workflow "beta" conflicts with workflow "alpha"/ );
134
+ } );
135
+
136
+ it( 'createWorkflowsEntryPoint throws on alias conflicting with another alias', async () => {
137
+ const { createWorkflowsEntryPoint } = await import( './loader.js' );
138
+
139
+ const workflows = [
140
+ { name: 'alpha', path: '/a.js', aliases: [ 'shared_alias' ] },
141
+ { name: 'beta', path: '/b.js', aliases: [ 'shared_alias' ] }
142
+ ];
143
+ expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "shared_alias" on workflow "beta" conflicts with/ );
144
+ } );
145
+
146
+ it( 'createWorkflowsEntryPoint throws on alias identical to own name', async () => {
147
+ const { createWorkflowsEntryPoint } = await import( './loader.js' );
148
+
149
+ const workflows = [ { name: 'alpha', path: '/a.js', aliases: [ 'alpha' ] } ];
150
+ expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Workflow "alpha" has an alias identical to its own name/ );
151
+ } );
152
+
153
+ it( 'createWorkflowsEntryPoint catches case-insensitive alias collision with primary name', async () => {
154
+ const { createWorkflowsEntryPoint } = await import( './loader.js' );
155
+
156
+ const workflows = [
157
+ { name: 'Alpha', path: '/a.js', aliases: [] },
158
+ { name: 'beta', path: '/b.js', aliases: [ 'alpha' ] }
159
+ ];
160
+ expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "alpha" on workflow "beta" conflicts with workflow "Alpha"/ );
161
+ } );
162
+
163
+ it( 'createWorkflowsEntryPoint catches case-insensitive alias-to-alias collision', async () => {
164
+ const { createWorkflowsEntryPoint } = await import( './loader.js' );
165
+
166
+ const workflows = [
167
+ { name: 'alpha', path: '/a.js', aliases: [ 'Legacy' ] },
168
+ { name: 'beta', path: '/b.js', aliases: [ 'legacy' ] }
169
+ ];
170
+ expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "legacy" on workflow "beta" conflicts with/ );
171
+ } );
172
+
114
173
  it( 'loadActivities uses folder-based matchers for steps/evaluators and shared', async () => {
115
174
  const { loadActivities } = await import( './loader.js' );
116
175
  // First call (workflow dir): no results
@@ -0,0 +1,16 @@
1
+ import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
2
+ import { createChildLogger } from '#logger';
3
+
4
+ const log = createChildLogger( 'Proxy' );
5
+
6
+ export const bootstrapFetchProxy = () => {
7
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
8
+ process.env.HTTP_PROXY || process.env.http_proxy;
9
+
10
+ if ( !proxyUrl ) {
11
+ return;
12
+ }
13
+
14
+ log.info( 'Routing fetch() through HTTP proxy', { proxyUrl } );
15
+ setGlobalDispatcher( new EnvHttpProxyAgent() );
16
+ };
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const mockSetGlobalDispatcher = vi.fn();
4
+ const MockEnvHttpProxyAgent = vi.fn();
5
+
6
+ vi.mock( 'undici', () => ( {
7
+ EnvHttpProxyAgent: MockEnvHttpProxyAgent,
8
+ setGlobalDispatcher: mockSetGlobalDispatcher
9
+ } ) );
10
+
11
+ vi.mock( '#logger', () => ( {
12
+ createChildLogger: () => ( { info: vi.fn(), warn: vi.fn(), error: vi.fn() } )
13
+ } ) );
14
+
15
+ describe( 'worker/proxy', () => {
16
+ const originalEnv = { ...process.env };
17
+
18
+ beforeEach( () => {
19
+ vi.clearAllMocks();
20
+ delete process.env.HTTPS_PROXY;
21
+ delete process.env.https_proxy;
22
+ delete process.env.HTTP_PROXY;
23
+ delete process.env.http_proxy;
24
+ } );
25
+
26
+ afterEach( () => {
27
+ process.env = { ...originalEnv };
28
+ } );
29
+
30
+ it( 'does nothing when no proxy env vars are set', async () => {
31
+ const { bootstrapFetchProxy } = await import( './proxy.js' );
32
+ bootstrapFetchProxy();
33
+
34
+ expect( mockSetGlobalDispatcher ).not.toHaveBeenCalled();
35
+ } );
36
+
37
+ it( 'sets global dispatcher when HTTPS_PROXY is set', async () => {
38
+ process.env.HTTPS_PROXY = 'http://proxy:8080';
39
+ const { bootstrapFetchProxy } = await import( './proxy.js' );
40
+ bootstrapFetchProxy();
41
+
42
+ expect( MockEnvHttpProxyAgent ).toHaveBeenCalled();
43
+ expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
44
+ } );
45
+
46
+ it( 'sets global dispatcher when HTTP_PROXY is set', async () => {
47
+ process.env.HTTP_PROXY = 'http://proxy:8080';
48
+ const { bootstrapFetchProxy } = await import( './proxy.js' );
49
+ bootstrapFetchProxy();
50
+
51
+ expect( MockEnvHttpProxyAgent ).toHaveBeenCalled();
52
+ expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
53
+ } );
54
+
55
+ it( 'prefers HTTPS_PROXY over HTTP_PROXY for detection', async () => {
56
+ process.env.HTTPS_PROXY = 'http://secure-proxy:8080';
57
+ process.env.HTTP_PROXY = 'http://plain-proxy:8080';
58
+ const { bootstrapFetchProxy } = await import( './proxy.js' );
59
+ bootstrapFetchProxy();
60
+
61
+ expect( mockSetGlobalDispatcher ).toHaveBeenCalledTimes( 1 );
62
+ } );
63
+ } );
@@ -1,4 +1,4 @@
1
- import { Client, WorkflowNotFoundError } from '@temporalio/client';
1
+ import { Client } from '@temporalio/client';
2
2
  import { WorkflowIdConflictPolicy } from '@temporalio/common';
3
3
  import { WORKFLOW_CATALOG } from '#consts';
4
4
  import { catalogId, taskQueue } from './configs.js';
@@ -8,29 +8,12 @@ const log = createChildLogger( 'Catalog' );
8
8
 
9
9
  export const startCatalog = async ( { connection, namespace, catalog } ) => {
10
10
  const client = new Client( { connection, namespace } );
11
- const catalogWorkflowHandle = client.workflow.getHandle( catalogId );
12
-
13
- try {
14
- const catalogWorkflowDescription = await catalogWorkflowHandle.describe();
15
- if ( !catalogWorkflowDescription.closeTime ) {
16
- log.info( 'Completing previous catalog workflow...' );
17
- await catalogWorkflowHandle.executeUpdate( 'complete', { args: [] } );
18
- }
19
- } catch ( error ) {
20
- // When "not found", it's either a cold start or the catalog was already stopped/terminated, ignore it.
21
- // Otherwise, create a log and try the next operation:
22
- // A. If the workflow is still running, the start() will fail and throw;
23
- // B. If the workflow is no running, the start() will succeed, and the error was transient;
24
- if ( !( error instanceof WorkflowNotFoundError ) ) {
25
- log.warn( 'Error interacting with previous catalog workflow', { error } );
26
- }
27
- }
28
11
 
29
12
  log.info( 'Starting catalog workflow...' );
30
13
  await client.workflow.start( WORKFLOW_CATALOG, {
31
14
  taskQueue,
32
- workflowId: catalogId, // use the name of the task queue as the catalog name, ensuring uniqueness
33
- workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
15
+ workflowId: catalogId,
16
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.TERMINATE_EXISTING,
34
17
  args: [ catalog ]
35
18
  } );
36
19
  };
@@ -1,5 +1,4 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { WorkflowNotFoundError } from '@temporalio/client';
3
2
 
4
3
  const mockLog = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
5
4
  vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
@@ -10,25 +9,18 @@ const catalogId = 'test-catalog';
10
9
  const taskQueue = 'test-queue';
11
10
  vi.mock( './configs.js', () => ( { catalogId, taskQueue } ) );
12
11
 
13
- const describeMock = vi.fn();
14
- const executeUpdateMock = vi.fn();
15
12
  const workflowStartMock = vi.fn().mockResolvedValue( undefined );
16
13
  vi.mock( '@temporalio/client', async importOriginal => {
17
14
  const actual = await importOriginal();
18
15
  return {
19
16
  ...actual,
20
17
  Client: vi.fn().mockImplementation( function () {
21
- return {
22
- workflow: {
23
- start: workflowStartMock,
24
- getHandle: () => ( { describe: describeMock, executeUpdate: executeUpdateMock } )
25
- }
26
- };
18
+ return { workflow: { start: workflowStartMock } };
27
19
  } )
28
20
  };
29
21
  } );
30
22
 
31
- vi.mock( '@temporalio/common', () => ( { WorkflowIdConflictPolicy: { FAIL: 'FAIL' } } ) );
23
+ vi.mock( '@temporalio/common', () => ( { WorkflowIdConflictPolicy: { TERMINATE_EXISTING: 'TERMINATE_EXISTING' } } ) );
32
24
 
33
25
  describe( 'worker/start_catalog', () => {
34
26
  const mockConnection = {};
@@ -40,79 +32,24 @@ describe( 'worker/start_catalog', () => {
40
32
  workflowStartMock.mockResolvedValue( undefined );
41
33
  } );
42
34
 
43
- it( 'when previous catalog still running: completes it then starts catalog workflow', async () => {
44
- describeMock.mockResolvedValue( { closeTime: undefined } );
45
- executeUpdateMock.mockResolvedValue( undefined );
46
-
47
- const { startCatalog } = await import( './start_catalog.js' );
48
- await startCatalog( { connection: mockConnection, namespace, catalog } );
49
-
50
- expect( describeMock ).toHaveBeenCalled();
51
- expect( mockLog.info ).toHaveBeenCalledWith( 'Completing previous catalog workflow...' );
52
- expect( executeUpdateMock ).toHaveBeenCalledWith( 'complete', { args: [] } );
53
- expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
54
- expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
55
- taskQueue,
56
- workflowId: catalogId,
57
- workflowIdConflictPolicy: 'FAIL',
58
- args: [ catalog ]
59
- } );
60
- } );
61
-
62
- it( 'when no previous catalog: ignores and starts catalog workflow', async () => {
63
- describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
64
-
65
- const { startCatalog } = await import( './start_catalog.js' );
66
- await startCatalog( { connection: mockConnection, namespace, catalog } );
67
-
68
- expect( describeMock ).toHaveBeenCalled();
69
- expect( mockLog.warn ).not.toHaveBeenCalled();
70
- expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
71
- expect( executeUpdateMock ).not.toHaveBeenCalled();
72
- expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
73
- taskQueue,
74
- workflowId: catalogId,
75
- workflowIdConflictPolicy: 'FAIL',
76
- args: [ catalog ]
77
- } );
78
- } );
79
-
80
- it( 'when previous catalog already closed: skips complete and starts catalog workflow', async () => {
81
- describeMock.mockResolvedValue( { closeTime: '2024-01-01T00:00:00Z' } );
82
-
35
+ it( 'starts catalog workflow with TERMINATE_EXISTING policy', async () => {
83
36
  const { startCatalog } = await import( './start_catalog.js' );
84
37
  await startCatalog( { connection: mockConnection, namespace, catalog } );
85
38
 
86
- expect( describeMock ).toHaveBeenCalled();
87
- expect( mockLog.info ).not.toHaveBeenCalledWith( 'Completing previous catalog workflow...' );
88
- expect( executeUpdateMock ).not.toHaveBeenCalled();
89
39
  expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
90
40
  expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
91
41
  taskQueue,
92
42
  workflowId: catalogId,
93
- workflowIdConflictPolicy: 'FAIL',
43
+ workflowIdConflictPolicy: 'TERMINATE_EXISTING',
94
44
  args: [ catalog ]
95
45
  } );
96
46
  } );
97
47
 
98
- it( 'when describe or complete fails with other error: logs warn and still starts catalog workflow', async () => {
99
- describeMock.mockResolvedValue( { closeTime: undefined } );
100
- executeUpdateMock.mockRejectedValue( new Error( 'Connection refused' ) );
48
+ it( 'propagates errors from workflow.start', async () => {
49
+ workflowStartMock.mockRejectedValue( new Error( 'Connection refused' ) );
101
50
 
102
51
  const { startCatalog } = await import( './start_catalog.js' );
103
- await startCatalog( { connection: mockConnection, namespace, catalog } );
104
-
105
- expect( describeMock ).toHaveBeenCalled();
106
- expect( executeUpdateMock ).toHaveBeenCalledWith( 'complete', { args: [] } );
107
- expect( mockLog.warn ).toHaveBeenCalledWith( 'Error interacting with previous catalog workflow', {
108
- error: expect.any( Error )
109
- } );
110
- expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
111
- expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
112
- taskQueue,
113
- workflowId: catalogId,
114
- workflowIdConflictPolicy: 'FAIL',
115
- args: [ catalog ]
116
- } );
52
+ await expect( startCatalog( { connection: mockConnection, namespace, catalog } ) )
53
+ .rejects.toThrow( 'Connection refused' );
117
54
  } );
118
55
  } );