@output.ai/core 0.5.2 → 0.5.4

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.2",
3
+ "version": "0.5.4",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
package/src/consts.js CHANGED
@@ -15,8 +15,3 @@ export const LifecycleEvent = {
15
15
  END: 'end',
16
16
  ERROR: 'error'
17
17
  };
18
- export const LifecycleEventLogMessage = {
19
- [LifecycleEvent.START]: 'Start',
20
- [LifecycleEvent.END]: 'End',
21
- [LifecycleEvent.ERROR]: 'Error'
22
- };
@@ -80,6 +80,7 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
80
80
  invokeStep: async ( stepName, input, options ) => steps[`${name}#${stepName}`]( input, options ),
81
81
  invokeSharedStep: async ( stepName, input, options ) => steps[`${SHARED_STEP_PREFIX}#${stepName}`]( input, options ),
82
82
  invokeEvaluator: async ( evaluatorName, input, options ) => steps[`${name}#${evaluatorName}`]( input, options ),
83
+ invokeSharedEvaluator: async ( evaluatorName, input, options ) => steps[`${SHARED_STEP_PREFIX}#${evaluatorName}`]( input, options ),
83
84
 
84
85
  /**
85
86
  * Start a child workflow
@@ -23,7 +23,7 @@ const createEntry = id => ( {
23
23
  kind: '',
24
24
  name: '',
25
25
  startedAt: 0,
26
- endedAt: 0,
26
+ endedAt: null,
27
27
  input: undefined,
28
28
  output: undefined,
29
29
  error: undefined,
@@ -75,5 +75,9 @@ export default entries => {
75
75
  if ( !rootNode ) {
76
76
  return null;
77
77
  }
78
+ if ( !rootNode.endedAt ) {
79
+ rootNode.output = '<<Workflow did not finish yet. If this workflows is supposed to have been completed already, \
80
+ this can indicate it timed out or was interrupted.>>';
81
+ }
78
82
  return rootNode;
79
83
  };
@@ -6,6 +6,17 @@ describe( 'build_trace_tree', () => {
6
6
  expect( buildTraceTree( [] ) ).toBeNull();
7
7
  } );
8
8
 
9
+ it( 'sets root output with a fixed message when workflow has no end/error phase yet', () => {
10
+ const entries = [
11
+ { kind: 'workflow', id: 'wf', parentId: undefined, phase: 'start', name: 'wf', details: {}, timestamp: 1000 }
12
+ ];
13
+ const result = buildTraceTree( entries );
14
+ expect( result ).not.toBeNull();
15
+ expect( result.output ).toBe( '<<Workflow did not finish yet. If this workflows is supposed to have been completed already, \
16
+ this can indicate it timed out or was interrupted.>>' );
17
+ expect( result.endedAt ).toBeNull();
18
+ } );
19
+
9
20
  it( 'returns null when there is no root (all entries have parentId)', () => {
10
21
  const entries = [
11
22
  { id: 'a', parentId: 'x', phase: 'start', name: 'a', timestamp: 1 },
@@ -1,4 +1,4 @@
1
- import { defineQuery, setHandler, condition } from '@temporalio/workflow';
1
+ import { defineQuery, setHandler, condition, defineUpdate } from '@temporalio/workflow';
2
2
 
3
3
  /**
4
4
  * This is a special workflow, unique to each worker, which holds the meta information of all other workflows in that worker.
@@ -8,7 +8,13 @@ import { defineQuery, setHandler, condition } from '@temporalio/workflow';
8
8
  * @param {object} catalog - The catalog information
9
9
  */
10
10
  export default async function catalogWorkflow( catalog ) {
11
+ const state = { canEnd: false };
12
+
11
13
  setHandler( defineQuery( 'get' ), () => catalog );
12
- // Wait indefinitely but remain responsive to workflow cancellation
13
- await condition( () => false );
14
+
15
+ // Listen to this update to complete the workflow
16
+ setHandler( defineUpdate( 'complete' ), () => state.canEnd = true );
17
+
18
+ // Wait indefinitely, until the state changes
19
+ await condition( () => state.canEnd );
14
20
  };
@@ -1,15 +1,14 @@
1
1
  import { Worker, NativeConnection } from '@temporalio/worker';
2
- import { Client } from '@temporalio/client';
3
- import { WorkflowIdConflictPolicy } from '@temporalio/common';
4
2
  import * as configs from './configs.js';
5
3
  import { loadActivities, loadWorkflows, createWorkflowsEntryPoint } from './loader.js';
6
4
  import { sinks } from './sinks.js';
7
5
  import { createCatalog } from './catalog_workflow/index.js';
8
6
  import { init as initTracing } from '#tracing';
9
- import { WORKFLOW_CATALOG } from '#consts';
10
7
  import { webpackConfigHook } from './bundler_options.js';
11
8
  import { initInterceptors } from './interceptors.js';
12
9
  import { createChildLogger } from '#logger';
10
+ import { registerShutdown } from './shutdown.js';
11
+ import { startCatalog } from './start_catalog.js';
13
12
 
14
13
  const log = createChildLogger( 'Worker' );
15
14
 
@@ -22,7 +21,6 @@ const callerDir = process.argv[2];
22
21
  apiKey,
23
22
  namespace,
24
23
  taskQueue,
25
- catalogId,
26
24
  maxConcurrentWorkflowTaskExecutions,
27
25
  maxConcurrentActivityTaskExecutions,
28
26
  maxCachedWorkflows,
@@ -66,45 +64,15 @@ const callerDir = process.argv[2];
66
64
  bundlerOptions: { webpackConfigHook }
67
65
  } );
68
66
 
69
- log.info( 'Starting catalog workflow...' );
70
- await new Client( { connection, namespace } ).workflow.start( WORKFLOW_CATALOG, {
71
- taskQueue,
72
- workflowId: catalogId, // use the name of the task queue as the catalog name, ensuring uniqueness
73
- workflowIdConflictPolicy: WorkflowIdConflictPolicy.TERMINATE_EXISTING,
74
- args: [ catalog ]
75
- } );
67
+ registerShutdown( { worker, log } );
76
68
 
77
69
  log.info( 'Running worker...' );
70
+ await Promise.all( [ worker.run(), startCatalog( { connection, namespace, catalog } ) ] );
78
71
 
79
- // FORCE_QUIT_GRACE_MS delays the second instance of a shutdown command.
80
- // If running output-worker directly with npx, 2 signals are recieved in
81
- // rapid succession, and users see the force quit message.
82
- const FORCE_QUIT_GRACE_MS = 1000;
83
- const state = { isShuttingDown: false, shutdownStartedAt: null };
84
-
85
- const shutdown = signal => {
86
- if ( state.isShuttingDown ) {
87
- const elapsed = Date.now() - state.shutdownStartedAt;
88
- if ( elapsed < FORCE_QUIT_GRACE_MS ) {
89
- return; // ignore rapid duplicate signals
90
- }
91
- log.warn( 'Force quitting...' );
92
- process.exit( 1 );
93
- }
94
- state.isShuttingDown = true;
95
- state.shutdownStartedAt = Date.now();
96
- log.info( 'Shutting down...', { signal } );
97
- worker.shutdown();
98
- };
99
-
100
- process.on( 'SIGTERM', () => shutdown( 'SIGTERM' ) );
101
- process.on( 'SIGINT', () => shutdown( 'SIGINT' ) );
102
-
103
- await worker.run();
104
- log.info( 'Worker stopped.' );
105
-
72
+ log.info( 'Closing connection...' );
106
73
  await connection.close();
107
- log.info( 'Connection closed.' );
74
+
75
+ log.info( 'Bye' );
108
76
 
109
77
  process.exit( 0 );
110
78
  } )().catch( error => {
@@ -40,6 +40,12 @@ vi.mock( './bundler_options.js', () => ( { webpackConfigHook: vi.fn() } ) );
40
40
  const initInterceptorsMock = vi.fn().mockReturnValue( [] );
41
41
  vi.mock( './interceptors.js', () => ( { initInterceptors: initInterceptorsMock } ) );
42
42
 
43
+ const startCatalogMock = vi.fn().mockResolvedValue( undefined );
44
+ vi.mock( './start_catalog.js', () => ( { startCatalog: startCatalogMock } ) );
45
+
46
+ const registerShutdownMock = vi.fn();
47
+ vi.mock( './shutdown.js', () => ( { registerShutdown: registerShutdownMock } ) );
48
+
43
49
  const runState = { resolve: null };
44
50
  const runPromise = new Promise( r => {
45
51
  runState.resolve = r;
@@ -53,22 +59,10 @@ vi.mock( '@temporalio/worker', () => ( {
53
59
  NativeConnection: { connect: vi.fn().mockResolvedValue( mockConnection ) }
54
60
  } ) );
55
61
 
56
- const workflowStartMock = vi.fn().mockResolvedValue( undefined );
57
- vi.mock( '@temporalio/client', () => ( {
58
- Client: vi.fn().mockImplementation( () => ( {
59
- workflow: { start: workflowStartMock }
60
- } ) )
61
- } ) );
62
-
63
- vi.mock( '@temporalio/common', () => ( {
64
- WorkflowIdConflictPolicy: { TERMINATE_EXISTING: 'TERMINATE_EXISTING' }
65
- } ) );
66
-
67
62
  describe( 'worker/index', () => {
68
63
  const exitMock = vi.fn();
69
64
  const originalArgv = process.argv;
70
65
  const originalExit = process.exit;
71
- const originalOn = process.on;
72
66
 
73
67
  beforeEach( () => {
74
68
  vi.clearAllMocks();
@@ -79,13 +73,11 @@ describe( 'worker/index', () => {
79
73
  afterEach( () => {
80
74
  process.argv = originalArgv;
81
75
  process.exit = originalExit;
82
- process.on = originalOn;
83
76
  configValues.apiKey = undefined;
84
77
  } );
85
78
 
86
79
  it( 'loads configs, workflows, activities and creates worker with correct options', async () => {
87
80
  const { Worker, NativeConnection } = await import( '@temporalio/worker' );
88
- const { Client } = await import( '@temporalio/client' );
89
81
  const { init: initTracing } = await import( '#tracing' );
90
82
 
91
83
  import( './index.js' );
@@ -114,12 +106,11 @@ describe( 'worker/index', () => {
114
106
  maxConcurrentWorkflowTaskPolls: configValues.maxConcurrentWorkflowTaskPolls
115
107
  } ) );
116
108
  expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {} } );
117
- expect( Client ).toHaveBeenCalledWith( { connection: mockConnection, namespace: configValues.namespace } );
118
- expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
119
- taskQueue: configValues.taskQueue,
120
- workflowId: configValues.catalogId,
121
- workflowIdConflictPolicy: 'TERMINATE_EXISTING',
122
- args: [ { workflows: [], activities: {} } ]
109
+ expect( registerShutdownMock ).toHaveBeenCalledWith( { worker: mockWorker, log: mockLog } );
110
+ expect( startCatalogMock ).toHaveBeenCalledWith( {
111
+ connection: mockConnection,
112
+ namespace: configValues.namespace,
113
+ catalog: { workflows: [], activities: {} }
123
114
  } );
124
115
 
125
116
  runState.resolve();
@@ -145,23 +136,14 @@ describe( 'worker/index', () => {
145
136
  await vi.waitFor( () => expect( exitMock ).toHaveBeenCalled() );
146
137
  } );
147
138
 
148
- it( 'registers SIGTERM and SIGINT handlers that shut down worker', async () => {
149
- const onMock = vi.fn();
150
- process.on = onMock;
139
+ it( 'calls registerShutdown with worker and log', async () => {
151
140
  vi.resetModules();
152
141
 
153
142
  import( './index.js' );
154
143
 
155
144
  await vi.waitFor( () => {
156
- expect( onMock ).toHaveBeenCalledWith( 'SIGTERM', expect.any( Function ) );
157
- expect( onMock ).toHaveBeenCalledWith( 'SIGINT', expect.any( Function ) );
145
+ expect( registerShutdownMock ).toHaveBeenCalledWith( { worker: mockWorker, log: mockLog } );
158
146
  } );
159
-
160
- const sigtermHandler = onMock.mock.calls.find( c => c[0] === 'SIGTERM' )?.[1];
161
- expect( sigtermHandler ).toBeDefined();
162
- sigtermHandler();
163
- expect( shutdownMock ).toHaveBeenCalled();
164
-
165
147
  runState.resolve();
166
148
  await vi.waitFor( () => expect( exitMock ).toHaveBeenCalled() );
167
149
  } );
@@ -2,7 +2,7 @@ import { Context } from '@temporalio/activity';
2
2
  import { Storage } from '#async_storage';
3
3
  import { addEventStart, addEventEnd, addEventError } from '#tracing';
4
4
  import { headersToObject } from '../sandboxed_utils.js';
5
- import { LifecycleEventLogMessage, LifecycleEvent, METADATA_ACCESS_SYMBOL } from '#consts';
5
+ import { LifecycleEvent, METADATA_ACCESS_SYMBOL } from '#consts';
6
6
  import { activityHeartbeatEnabled, activityHeartbeatIntervalMs } from '../configs.js';
7
7
  import { createChildLogger } from '#logger';
8
8
 
@@ -37,7 +37,7 @@ export class ActivityExecutionInterceptor {
37
37
  const logContext = { workflowName, workflowId, stepId: activityId, stepName: activityType };
38
38
  const traceArguments = { kind, id: activityId, parentId: workflowId, name: activityType, executionContext };
39
39
 
40
- log.info( LifecycleEventLogMessage[LifecycleEvent.START], logContext );
40
+ log.info( `Started ${activityType} ${kind}`, { event: LifecycleEvent.START, kind, ...logContext } );
41
41
  addEventStart( { details: input.args[0], ...traceArguments } );
42
42
 
43
43
  const intervals = { heartbeat: null };
@@ -47,12 +47,14 @@ export class ActivityExecutionInterceptor {
47
47
 
48
48
  const output = await Storage.runWithContext( async _ => next( input ), { parentId: activityId, executionContext } );
49
49
 
50
- log.info( LifecycleEventLogMessage[LifecycleEvent.END], { ...logContext, durationMs: Date.now() - startDate } );
50
+ log.info( `Ended ${activityType} ${kind}`, { event: LifecycleEvent.END, kind, ...logContext, durationMs: Date.now() - startDate } );
51
51
  addEventEnd( { details: output, ...traceArguments } );
52
52
  return output;
53
53
 
54
54
  } catch ( error ) {
55
- log.error( LifecycleEventLogMessage[LifecycleEvent.ERROR], { ...logContext, durationMs: Date.now() - startDate, error: error.message } );
55
+ log.error( `Error ${activityType} ${kind}: ${error.message}`, {
56
+ event: LifecycleEvent.ERROR, kind, ...logContext, durationMs: Date.now() - startDate, error: error.message
57
+ } );
56
58
  addEventError( { details: error, ...traceArguments } );
57
59
  throw error;
58
60
 
@@ -49,7 +49,7 @@ class WorkflowExecutionInterceptor {
49
49
  }
50
50
 
51
51
  sinks.trace.addWorkflowEventError( error );
52
- const failure = new ApplicationFailure( error.message, error.constructor.name );
52
+ const failure = new ApplicationFailure( error.message, error.constructor.name, undefined, undefined, error );
53
53
 
54
54
  /*
55
55
  * If intercepted error has metadata, set it to .details property of Temporal's ApplicationFailure instance.
@@ -0,0 +1,26 @@
1
+ const FORCE_QUIT_GRACE_MS = 1000;
2
+
3
+ export const registerShutdown = ( { worker, log } ) => {
4
+ const state = { isShuttingDown: false, shutdownStartedAt: null };
5
+
6
+ const shutdown = signal => {
7
+ if ( state.isShuttingDown ) {
8
+ const elapsed = Date.now() - state.shutdownStartedAt;
9
+
10
+ // If running with npx, 2 kill signals are received in rapid succession,
11
+ // this ignores the second interruption when it is right after the first.
12
+ if ( elapsed < FORCE_QUIT_GRACE_MS ) {
13
+ return;
14
+ }
15
+ log.warn( 'Force quitting...' );
16
+ process.exit( 1 );
17
+ }
18
+ state.isShuttingDown = true;
19
+ state.shutdownStartedAt = Date.now();
20
+ log.info( 'Shutting down...', { signal } );
21
+ worker.shutdown();
22
+ };
23
+
24
+ process.on( 'SIGTERM', () => shutdown( 'SIGTERM' ) );
25
+ process.on( 'SIGINT', () => shutdown( 'SIGINT' ) );
26
+ };
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { registerShutdown } from './shutdown.js';
3
+
4
+ describe( 'worker/shutdown', () => {
5
+ const mockLog = { info: vi.fn(), warn: vi.fn() };
6
+ const shutdownMock = vi.fn();
7
+ const mockWorker = { shutdown: shutdownMock };
8
+ const onHandlers = {};
9
+ const exitMock = vi.fn();
10
+ const originalOn = process.on;
11
+ const originalExit = process.exit;
12
+
13
+ beforeEach( () => {
14
+ vi.clearAllMocks();
15
+ Object.keys( onHandlers ).forEach( k => delete onHandlers[k] );
16
+ process.on = vi.fn( ( event, handler ) => {
17
+ onHandlers[event] = handler;
18
+ } );
19
+ process.exit = exitMock;
20
+ } );
21
+
22
+ afterEach( () => {
23
+ process.on = originalOn;
24
+ process.exit = originalExit;
25
+ } );
26
+
27
+ it( 'registers SIGTERM and SIGINT handlers', () => {
28
+ registerShutdown( { worker: mockWorker, log: mockLog } );
29
+
30
+ expect( process.on ).toHaveBeenCalledWith( 'SIGTERM', expect.any( Function ) );
31
+ expect( process.on ).toHaveBeenCalledWith( 'SIGINT', expect.any( Function ) );
32
+ } );
33
+
34
+ it( 'on first signal: logs, calls worker.shutdown(), does not exit', () => {
35
+ registerShutdown( { worker: mockWorker, log: mockLog } );
36
+
37
+ onHandlers.SIGTERM();
38
+
39
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Shutting down...', { signal: 'SIGTERM' } );
40
+ expect( shutdownMock ).toHaveBeenCalledTimes( 1 );
41
+ expect( mockLog.warn ).not.toHaveBeenCalled();
42
+ expect( exitMock ).not.toHaveBeenCalled();
43
+ } );
44
+
45
+ it( 'on first SIGINT: logs with SIGINT', () => {
46
+ registerShutdown( { worker: mockWorker, log: mockLog } );
47
+
48
+ onHandlers.SIGINT();
49
+
50
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Shutting down...', { signal: 'SIGINT' } );
51
+ expect( shutdownMock ).toHaveBeenCalledTimes( 1 );
52
+ } );
53
+
54
+ it( 'on second signal within grace period: ignores (no force quit)', () => {
55
+ vi.useFakeTimers();
56
+ registerShutdown( { worker: mockWorker, log: mockLog } );
57
+
58
+ onHandlers.SIGTERM();
59
+ onHandlers.SIGINT();
60
+
61
+ expect( mockLog.info ).toHaveBeenCalledTimes( 1 );
62
+ expect( shutdownMock ).toHaveBeenCalledTimes( 1 );
63
+ expect( mockLog.warn ).not.toHaveBeenCalled();
64
+ expect( exitMock ).not.toHaveBeenCalled();
65
+
66
+ vi.useRealTimers();
67
+ } );
68
+
69
+ it( 'on second signal after grace period: logs force quit and exits with 1', () => {
70
+ vi.useFakeTimers();
71
+ registerShutdown( { worker: mockWorker, log: mockLog } );
72
+
73
+ onHandlers.SIGTERM();
74
+ vi.advanceTimersByTime( 1001 );
75
+ onHandlers.SIGINT();
76
+
77
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Force quitting...' );
78
+ expect( exitMock ).toHaveBeenCalledWith( 1 );
79
+
80
+ vi.useRealTimers();
81
+ } );
82
+ } );
@@ -1,4 +1,4 @@
1
- import { LifecycleEvent, LifecycleEventLogMessage, WORKFLOW_CATALOG } from '#consts';
1
+ import { LifecycleEvent, WORKFLOW_CATALOG } from '#consts';
2
2
  import { addEventStart, addEventEnd, addEventError } from '#tracing';
3
3
  import { createChildLogger } from '#logger';
4
4
 
@@ -34,11 +34,12 @@ const logWorkflowEvent = ( event, workflowInfo, error ) => {
34
34
  }
35
35
 
36
36
  if ( event === LifecycleEvent.START ) {
37
- log.info( LifecycleEventLogMessage[event], { workflowName, workflowId } );
37
+ log.info( `Started ${workflowName} workflow`, { event, workflowName, workflowId } );
38
38
  } else if ( event === LifecycleEvent.END ) {
39
- log.info( LifecycleEventLogMessage[event], { workflowName, workflowId, durationMs: Date.now() - startTime.getTime() } );
39
+ log.info( `Ended ${workflowName} workflow`, { event, workflowName, workflowId, durationMs: Date.now() - startTime.getTime() } );
40
40
  } else if ( event === LifecycleEvent.ERROR ) {
41
- log.info( LifecycleEventLogMessage[event], {
41
+ log.error( `Error ${workflowName} workflow: ${error.message}`, {
42
+ event,
42
43
  workflowName,
43
44
  workflowId,
44
45
  durationMs: Date.now() - startTime.getTime(),
@@ -0,0 +1,36 @@
1
+ import { Client, WorkflowNotFoundError } from '@temporalio/client';
2
+ import { WorkflowIdConflictPolicy } from '@temporalio/common';
3
+ import { WORKFLOW_CATALOG } from '#consts';
4
+ import { catalogId, taskQueue } from './configs.js';
5
+ import { createChildLogger } from '#logger';
6
+
7
+ const log = createChildLogger( 'Catalog' );
8
+
9
+ export const startCatalog = async ( { connection, namespace, catalog } ) => {
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
+
29
+ log.info( 'Starting catalog workflow...' );
30
+ await client.workflow.start( WORKFLOW_CATALOG, {
31
+ taskQueue,
32
+ workflowId: catalogId, // use the name of the task queue as the catalog name, ensuring uniqueness
33
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
34
+ args: [ catalog ]
35
+ } );
36
+ };
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { WorkflowNotFoundError } from '@temporalio/client';
3
+
4
+ const mockLog = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
5
+ vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
6
+
7
+ vi.mock( '#consts', () => ( { WORKFLOW_CATALOG: 'catalog' } ) );
8
+
9
+ const catalogId = 'test-catalog';
10
+ const taskQueue = 'test-queue';
11
+ vi.mock( './configs.js', () => ( { catalogId, taskQueue } ) );
12
+
13
+ const describeMock = vi.fn();
14
+ const executeUpdateMock = vi.fn();
15
+ const workflowStartMock = vi.fn().mockResolvedValue( undefined );
16
+ vi.mock( '@temporalio/client', async importOriginal => {
17
+ const actual = await importOriginal();
18
+ return {
19
+ ...actual,
20
+ Client: vi.fn().mockImplementation( () => ( {
21
+ workflow: {
22
+ start: workflowStartMock,
23
+ getHandle: () => ( { describe: describeMock, executeUpdate: executeUpdateMock } )
24
+ }
25
+ } ) )
26
+ };
27
+ } );
28
+
29
+ vi.mock( '@temporalio/common', () => ( { WorkflowIdConflictPolicy: { FAIL: 'FAIL' } } ) );
30
+
31
+ describe( 'worker/start_catalog', () => {
32
+ const mockConnection = {};
33
+ const namespace = 'default';
34
+ const catalog = { workflows: [], activities: {} };
35
+
36
+ beforeEach( () => {
37
+ vi.clearAllMocks();
38
+ workflowStartMock.mockResolvedValue( undefined );
39
+ } );
40
+
41
+ it( 'when previous catalog still running: completes it then starts catalog workflow', async () => {
42
+ describeMock.mockResolvedValue( { closeTime: undefined } );
43
+ executeUpdateMock.mockResolvedValue( undefined );
44
+
45
+ const { startCatalog } = await import( './start_catalog.js' );
46
+ await startCatalog( { connection: mockConnection, namespace, catalog } );
47
+
48
+ expect( describeMock ).toHaveBeenCalled();
49
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Completing previous catalog workflow...' );
50
+ expect( executeUpdateMock ).toHaveBeenCalledWith( 'complete', { args: [] } );
51
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
52
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
53
+ taskQueue,
54
+ workflowId: catalogId,
55
+ workflowIdConflictPolicy: 'FAIL',
56
+ args: [ catalog ]
57
+ } );
58
+ } );
59
+
60
+ it( 'when no previous catalog: ignores and starts catalog workflow', async () => {
61
+ describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
62
+
63
+ const { startCatalog } = await import( './start_catalog.js' );
64
+ await startCatalog( { connection: mockConnection, namespace, catalog } );
65
+
66
+ expect( describeMock ).toHaveBeenCalled();
67
+ expect( mockLog.warn ).not.toHaveBeenCalled();
68
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
69
+ expect( executeUpdateMock ).not.toHaveBeenCalled();
70
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
71
+ taskQueue,
72
+ workflowId: catalogId,
73
+ workflowIdConflictPolicy: 'FAIL',
74
+ args: [ catalog ]
75
+ } );
76
+ } );
77
+
78
+ it( 'when previous catalog already closed: skips complete and starts catalog workflow', async () => {
79
+ describeMock.mockResolvedValue( { closeTime: '2024-01-01T00:00:00Z' } );
80
+
81
+ const { startCatalog } = await import( './start_catalog.js' );
82
+ await startCatalog( { connection: mockConnection, namespace, catalog } );
83
+
84
+ expect( describeMock ).toHaveBeenCalled();
85
+ expect( mockLog.info ).not.toHaveBeenCalledWith( 'Completing previous catalog workflow...' );
86
+ expect( executeUpdateMock ).not.toHaveBeenCalled();
87
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
88
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
89
+ taskQueue,
90
+ workflowId: catalogId,
91
+ workflowIdConflictPolicy: 'FAIL',
92
+ args: [ catalog ]
93
+ } );
94
+ } );
95
+
96
+ it( 'when describe or complete fails with other error: logs warn and still starts catalog workflow', async () => {
97
+ describeMock.mockResolvedValue( { closeTime: undefined } );
98
+ executeUpdateMock.mockRejectedValue( new Error( 'Connection refused' ) );
99
+
100
+ const { startCatalog } = await import( './start_catalog.js' );
101
+ await startCatalog( { connection: mockConnection, namespace, catalog } );
102
+
103
+ expect( describeMock ).toHaveBeenCalled();
104
+ expect( executeUpdateMock ).toHaveBeenCalledWith( 'complete', { args: [] } );
105
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Error interacting with previous catalog workflow', {
106
+ error: expect.any( Error )
107
+ } );
108
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
109
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
110
+ taskQueue,
111
+ workflowId: catalogId,
112
+ workflowIdConflictPolicy: 'FAIL',
113
+ args: [ catalog ]
114
+ } );
115
+ } );
116
+ } );
@@ -156,8 +156,13 @@ export const isAnyStepsPath = value =>
156
156
  * @param {string} value - Module path or request string.
157
157
  * @returns {boolean} True if it matches an evaluators path.
158
158
  */
159
- export const isEvaluatorsPath = value =>
160
- EVALUATORS_FILE_REGEX.test( value ) || EVALUATORS_FOLDER_REGEX.test( value );
159
+ export const isEvaluatorsPath = value => {
160
+ // Exclude shared evaluators (paths with ../ or containing /shared/)
161
+ if ( PATH_TRAVERSAL_REGEX.test( value ) || SHARED_PATH_REGEX.test( value ) ) {
162
+ return false;
163
+ }
164
+ return EVALUATORS_FILE_REGEX.test( value ) || EVALUATORS_FOLDER_REGEX.test( value );
165
+ };
161
166
 
162
167
  /**
163
168
  * Check if a module specifier or request string points to shared evaluators.
@@ -174,6 +179,15 @@ export const isSharedEvaluatorsPath = value => {
174
179
  return PATH_TRAVERSAL_REGEX.test( value ) || SHARED_PATH_REGEX.test( value );
175
180
  };
176
181
 
182
+ /**
183
+ * Check if a path matches any evaluators pattern (local or shared).
184
+ * Used for validation purposes.
185
+ * @param {string} value - Module path or request string.
186
+ * @returns {boolean} True if it matches any evaluators path pattern.
187
+ */
188
+ export const isAnyEvaluatorsPath = value =>
189
+ EVALUATORS_FILE_REGEX.test( value ) || EVALUATORS_FOLDER_REGEX.test( value );
190
+
177
191
  /**
178
192
  * Check if a module specifier or request string points to workflow.js.
179
193
  * @param {string} value - Module path or request string.
@@ -187,7 +201,7 @@ export const isWorkflowPath = value => /(^|\/)workflow\.js$/.test( value );
187
201
  * @returns {boolean} True if it matches any component file path.
188
202
  */
189
203
  export const isComponentFile = value =>
190
- isAnyStepsPath( value ) || isEvaluatorsPath( value ) || isWorkflowPath( value );
204
+ isAnyStepsPath( value ) || isAnyEvaluatorsPath( value ) || isWorkflowPath( value );
191
205
 
192
206
  /**
193
207
  * Determine file kind based on its path.
@@ -199,7 +213,7 @@ export const getFileKind = path => {
199
213
  if ( isAnyStepsPath( path ) ) {
200
214
  return ComponentFile.STEPS;
201
215
  }
202
- if ( isEvaluatorsPath( path ) ) {
216
+ if ( isAnyEvaluatorsPath( path ) ) {
203
217
  return ComponentFile.EVALUATORS;
204
218
  }
205
219
  if ( isWorkflowPath( path ) ) {
@@ -357,6 +371,22 @@ export const buildEvaluatorsNameMap = ( path, cache ) => buildComponentNameMap(
357
371
  invalidMessagePrefix: 'Invalid evaluator name in'
358
372
  } );
359
373
 
374
+ /**
375
+ * Build a map from exported shared evaluator identifier to declared evaluator name.
376
+ * Same as buildEvaluatorsNameMap but for shared evaluators.
377
+ *
378
+ * @param {string} path - Absolute path to the shared evaluators module file.
379
+ * @param {Map<string, Map<string,string>>} cache - Cache of computed evaluator name maps.
380
+ * @returns {Map<string,string>} Exported identifier -> evaluator name.
381
+ * @throws {Error} When an evaluator name is invalid (non-static or missing).
382
+ */
383
+ export const buildSharedEvaluatorsNameMap = ( path, cache ) => buildComponentNameMap( {
384
+ path,
385
+ cache,
386
+ calleeName: 'evaluator',
387
+ invalidMessagePrefix: 'Invalid shared evaluator name in'
388
+ } );
389
+
360
390
  /**
361
391
  * Build a structure with default and named workflow names from a workflow module.
362
392
  * Extracts names from `workflow({ name: '...' })` calls.