@output.ai/core 0.3.0-dev.pr263-a59dd0e → 0.3.0-dev.pr263-7879ff1
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/bin/worker.sh +1 -1
- package/package.json +1 -1
- package/src/consts.js +1 -1
- package/src/interface/evaluator.js +3 -0
- package/src/interface/workflow.js +3 -4
- package/src/worker/catalog_workflow/workflow.js +3 -2
- package/src/worker/index.js +37 -3
- package/src/worker/loader.js +57 -15
- package/src/worker/loader.spec.js +106 -26
- package/src/worker/loader_tools.js +82 -54
- package/src/worker/loader_tools.spec.js +21 -72
- package/src/worker/webpack_loaders/workflow_validator/index.mjs +13 -12
- package/src/worker/webpack_loaders/workflow_validator/index.spec.js +71 -46
package/bin/worker.sh
CHANGED
package/package.json
CHANGED
package/src/consts.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const ACTIVITY_SEND_HTTP_REQUEST = '__internal#sendHttpRequest';
|
|
2
2
|
export const ACTIVITY_GET_TRACE_DESTINATIONS = '__internal#getTraceDestinations';
|
|
3
3
|
export const METADATA_ACCESS_SYMBOL = Symbol( '__metadata' );
|
|
4
|
-
export const SHARED_STEP_PREFIX = '
|
|
4
|
+
export const SHARED_STEP_PREFIX = '$shared';
|
|
5
5
|
export const WORKFLOWS_INDEX_FILENAME = '__workflows_entrypoint.js';
|
|
6
6
|
export const ACTIVITY_OPTIONS_FILENAME = '__activity_options.js';
|
|
7
7
|
export const WORKFLOW_CATALOG = '$catalog';
|
|
@@ -81,6 +81,7 @@ export class EvaluationStringResult extends EvaluationResult {
|
|
|
81
81
|
* @param {number} args.confidence - The confidence on the evaluation
|
|
82
82
|
* @param {string} [args.reasoning] - The reasoning behind the result
|
|
83
83
|
*/
|
|
84
|
+
// eslint-disable-next-line no-useless-constructor
|
|
84
85
|
constructor( args ) {
|
|
85
86
|
super( args );
|
|
86
87
|
}
|
|
@@ -103,6 +104,7 @@ export class EvaluationBooleanResult extends EvaluationResult {
|
|
|
103
104
|
* @param {number} args.confidence - The confidence on the evaluation
|
|
104
105
|
* @param {string} [args.reasoning] - The reasoning behind the result
|
|
105
106
|
*/
|
|
107
|
+
// eslint-disable-next-line no-useless-constructor
|
|
106
108
|
constructor( args ) {
|
|
107
109
|
super( args );
|
|
108
110
|
}
|
|
@@ -125,6 +127,7 @@ export class EvaluationNumberResult extends EvaluationResult {
|
|
|
125
127
|
* @param {number} args.confidence - The confidence on the evaluation
|
|
126
128
|
* @param {string} [args.reasoning] - The reasoning behind the result
|
|
127
129
|
*/
|
|
130
|
+
// eslint-disable-next-line no-useless-constructor
|
|
128
131
|
constructor( args ) {
|
|
129
132
|
super( args );
|
|
130
133
|
}
|
|
@@ -3,7 +3,7 @@ import { proxyActivities, inWorkflowContext, executeChild, workflowInfo, uuid4,
|
|
|
3
3
|
import { validateWorkflow } from './validations/static.js';
|
|
4
4
|
import { validateWithSchema } from './validations/runtime.js';
|
|
5
5
|
import { SHARED_STEP_PREFIX, ACTIVITY_GET_TRACE_DESTINATIONS } from '#consts';
|
|
6
|
-
import { deepMerge, mergeActivityOptions,
|
|
6
|
+
import { deepMerge, mergeActivityOptions, setMetadata } from '#utils';
|
|
7
7
|
import { FatalError, ValidationError } from '#errors';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -51,7 +51,6 @@ const defaultActivityOptions = {
|
|
|
51
51
|
|
|
52
52
|
export function workflow( { name, description, inputSchema, outputSchema, fn, options } ) {
|
|
53
53
|
validateWorkflow( { name, description, inputSchema, outputSchema, fn, options } );
|
|
54
|
-
const workflowPath = resolveInvocationDir();
|
|
55
54
|
|
|
56
55
|
const activityOptions = mergeActivityOptions( defaultActivityOptions, options );
|
|
57
56
|
const steps = proxyActivities( activityOptions );
|
|
@@ -99,9 +98,9 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
|
|
|
99
98
|
|
|
100
99
|
// binds the methods called in the code that Webpack loader will add, they will exposed via "this"
|
|
101
100
|
const output = await fn.call( {
|
|
102
|
-
invokeStep: async ( stepName, input, options ) => steps[`${
|
|
101
|
+
invokeStep: async ( stepName, input, options ) => steps[`${name}#${stepName}`]( input, options ),
|
|
103
102
|
invokeSharedStep: async ( stepName, input, options ) => steps[`${SHARED_STEP_PREFIX}#${stepName}`]( input, options ),
|
|
104
|
-
invokeEvaluator: async ( evaluatorName, input, options ) => steps[`${
|
|
103
|
+
invokeEvaluator: async ( evaluatorName, input, options ) => steps[`${name}#${evaluatorName}`]( input, options ),
|
|
105
104
|
|
|
106
105
|
/**
|
|
107
106
|
* Start a child workflow
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineQuery, setHandler } from '@temporalio/workflow';
|
|
1
|
+
import { defineQuery, setHandler, condition } 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.
|
|
@@ -9,5 +9,6 @@ import { defineQuery, setHandler } from '@temporalio/workflow';
|
|
|
9
9
|
*/
|
|
10
10
|
export default async function catalogWorkflow( catalog ) {
|
|
11
11
|
setHandler( defineQuery( 'get' ), () => catalog );
|
|
12
|
-
|
|
12
|
+
// Wait indefinitely but remain responsive to workflow cancellation
|
|
13
|
+
await condition( () => false );
|
|
13
14
|
};
|
package/src/worker/index.js
CHANGED
|
@@ -18,7 +18,7 @@ const callerDir = process.argv[2];
|
|
|
18
18
|
const workflows = await loadWorkflows( callerDir );
|
|
19
19
|
|
|
20
20
|
console.log( '[Core]', 'Loading activities...', { callerDir } );
|
|
21
|
-
const activities = await loadActivities( callerDir );
|
|
21
|
+
const activities = await loadActivities( callerDir, workflows );
|
|
22
22
|
|
|
23
23
|
console.log( '[Core]', 'Creating worker entry point...' );
|
|
24
24
|
const workflowsPath = createWorkflowsEntryPoint( workflows );
|
|
@@ -56,5 +56,39 @@ const callerDir = process.argv[2];
|
|
|
56
56
|
} );
|
|
57
57
|
|
|
58
58
|
console.log( '[Core]', 'Running worker...' );
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
|
|
60
|
+
// FORCE_QUIT_GRACE_MS delays the second instance of a shutdown command.
|
|
61
|
+
// If running output-worker directly with npx, 2 signals are recieved in
|
|
62
|
+
// rapid succession, and users see the force quit message.
|
|
63
|
+
const FORCE_QUIT_GRACE_MS = 1000;
|
|
64
|
+
const state = { isShuttingDown: false, shutdownStartedAt: null };
|
|
65
|
+
|
|
66
|
+
const shutdown = signal => {
|
|
67
|
+
if ( state.isShuttingDown ) {
|
|
68
|
+
const elapsed = Date.now() - state.shutdownStartedAt;
|
|
69
|
+
if ( elapsed < FORCE_QUIT_GRACE_MS ) {
|
|
70
|
+
return; // ignore rapid duplicate signals
|
|
71
|
+
}
|
|
72
|
+
process.stderr.write( '[Core] Force quitting...\n' );
|
|
73
|
+
process.exit( 1 );
|
|
74
|
+
}
|
|
75
|
+
state.isShuttingDown = true;
|
|
76
|
+
state.shutdownStartedAt = Date.now();
|
|
77
|
+
process.stderr.write( `[Core] Received ${signal}, shutting down...\n` );
|
|
78
|
+
worker.shutdown();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
process.on( 'SIGTERM', () => shutdown( 'SIGTERM' ) );
|
|
82
|
+
process.on( 'SIGINT', () => shutdown( 'SIGINT' ) );
|
|
83
|
+
|
|
84
|
+
await worker.run();
|
|
85
|
+
process.stderr.write( '[Core] Worker stopped.\n' );
|
|
86
|
+
|
|
87
|
+
await connection.close();
|
|
88
|
+
process.stderr.write( '[Core] Connection closed.\n' );
|
|
89
|
+
|
|
90
|
+
process.exit( 0 );
|
|
91
|
+
} )().catch( error => {
|
|
92
|
+
process.stderr.write( `[Core] Fatal error: ${error.message}\n` );
|
|
93
|
+
process.exit( 1 );
|
|
94
|
+
} );
|
package/src/worker/loader.js
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { EOL } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { getTraceDestinations, sendHttpRequest } from '#internal_activities';
|
|
6
|
-
import { importComponents } from './loader_tools.js';
|
|
6
|
+
import { importComponents, staticMatchers, activityMatchersBuilder } from './loader_tools.js';
|
|
7
7
|
import {
|
|
8
8
|
ACTIVITY_SEND_HTTP_REQUEST,
|
|
9
9
|
ACTIVITY_OPTIONS_FILENAME,
|
|
@@ -27,24 +27,61 @@ const writeActivityOptionsFile = map => {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* Creates the activity key that will identify it on Temporal.
|
|
31
31
|
*
|
|
32
|
-
*
|
|
32
|
+
* It composes it using a namespace and the name of the activity.
|
|
33
|
+
*
|
|
34
|
+
* No two activities with the same name can exist on the same namespace.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} options
|
|
37
|
+
* @param {string} namespace
|
|
38
|
+
* @param {string} activityName
|
|
39
|
+
* @returns {string} key
|
|
40
|
+
*/
|
|
41
|
+
const generateActivityKey = ( { namespace, activityName } ) => `${namespace}#${activityName}`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load activities:
|
|
45
|
+
*
|
|
46
|
+
* - Scans activities based on workflows, using each workflow folder as a point to lookup for steps, evaluators files;
|
|
47
|
+
* - Scans shared activities in the rootDir;
|
|
48
|
+
* - Loads internal activities as well;
|
|
49
|
+
*
|
|
50
|
+
* Builds a map of activities, where they is generated according to the type of activity and the value is the function itself and return it.
|
|
51
|
+
* - Shared activity keys have a common prefix followed by the activity name;
|
|
52
|
+
* - Internal activities are registered with a fixed key;
|
|
53
|
+
* - Workflow activities keys are composed using the workflow name and the activity name;
|
|
54
|
+
*
|
|
55
|
+
* @param {string} rootDir
|
|
56
|
+
* @param {object[]} workflows
|
|
33
57
|
* @returns {object}
|
|
34
58
|
*/
|
|
35
|
-
export async function loadActivities(
|
|
59
|
+
export async function loadActivities( rootDir, workflows ) {
|
|
36
60
|
const activities = {};
|
|
37
61
|
const activityOptionsMap = {};
|
|
38
|
-
for await ( const { fn, metadata, path, isShared } of importComponents( target, [ 'steps.js', 'evaluators.js' ] ) ) {
|
|
39
|
-
const prefix = isShared ? SHARED_STEP_PREFIX : dirname( path );
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
// Load workflow based activities
|
|
64
|
+
for ( const { path: workflowPath, name: workflowName } of workflows ) {
|
|
65
|
+
const dir = dirname( workflowPath );
|
|
66
|
+
for await ( const { fn, metadata, path } of importComponents( dir, Object.values( activityMatchersBuilder( dir ) ) ) ) {
|
|
67
|
+
console.log( '[Core.Scanner]', 'Component loaded:', metadata.type, metadata.name, 'at', path );
|
|
68
|
+
// Activities loaded from a workflow path will use the workflow name as a namespace, which is unique across the platform, avoiding collision
|
|
69
|
+
const activityKey = generateActivityKey( { namespace: workflowName, activityName: metadata.name } );
|
|
70
|
+
activities[activityKey] = fn;
|
|
71
|
+
// propagate the custom options set on the step()/evaluator() constructor
|
|
72
|
+
activityOptionsMap[activityKey] = metadata.options ?? undefined;
|
|
45
73
|
}
|
|
46
74
|
}
|
|
47
75
|
|
|
76
|
+
// Load shared activities/evaluators
|
|
77
|
+
for await ( const { fn, metadata, path } of importComponents( rootDir, [ staticMatchers.sharedStepsDir, staticMatchers.sharedEvaluatorsDir ] ) ) {
|
|
78
|
+
console.log( '[Core.Scanner]', 'Component loaded: shared ', metadata.type, metadata.name, 'at', path );
|
|
79
|
+
// The namespace for shared activities is fixed
|
|
80
|
+
const activityKey = generateActivityKey( { namespace: SHARED_STEP_PREFIX, activityName: metadata.name } );
|
|
81
|
+
activities[activityKey] = fn;
|
|
82
|
+
activityOptionsMap[activityKey] = metadata.options ?? undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
48
85
|
// writes down the activity option overrides
|
|
49
86
|
writeActivityOptionsFile( activityOptionsMap );
|
|
50
87
|
|
|
@@ -55,14 +92,19 @@ export async function loadActivities( target ) {
|
|
|
55
92
|
};
|
|
56
93
|
|
|
57
94
|
/**
|
|
58
|
-
*
|
|
95
|
+
* Scan and find workflow.js files and import them.
|
|
96
|
+
*
|
|
97
|
+
* Creates an array containing their metadata and path and return it.
|
|
59
98
|
*
|
|
60
|
-
* @param {string}
|
|
99
|
+
* @param {string} rootDir
|
|
61
100
|
* @returns {object[]}
|
|
62
101
|
*/
|
|
63
|
-
export async function loadWorkflows(
|
|
102
|
+
export async function loadWorkflows( rootDir ) {
|
|
64
103
|
const workflows = [];
|
|
65
|
-
for await ( const { metadata, path } of importComponents(
|
|
104
|
+
for await ( const { metadata, path } of importComponents( rootDir, [ staticMatchers.workflowFile ] ) ) {
|
|
105
|
+
if ( staticMatchers.workflowPathHasShared( path ) ) {
|
|
106
|
+
throw new Error( 'Workflow directory can\'t be named "shared"' );
|
|
107
|
+
}
|
|
66
108
|
console.log( '[Core.Scanner]', 'Workflow loaded:', metadata.name, 'at', path );
|
|
67
109
|
workflows.push( { ...metadata, path } );
|
|
68
110
|
}
|
|
@@ -70,7 +112,7 @@ export async function loadWorkflows( target ) {
|
|
|
70
112
|
};
|
|
71
113
|
|
|
72
114
|
/**
|
|
73
|
-
* Creates a temporary index file importing all workflows
|
|
115
|
+
* Creates a temporary index file importing all workflows for Temporal.
|
|
74
116
|
*
|
|
75
117
|
* @param {object[]} workflows
|
|
76
118
|
* @returns
|
|
@@ -6,7 +6,7 @@ vi.mock( '#consts', () => ( {
|
|
|
6
6
|
WORKFLOWS_INDEX_FILENAME: '__workflows_entrypoint.js',
|
|
7
7
|
WORKFLOW_CATALOG: 'catalog',
|
|
8
8
|
ACTIVITY_OPTIONS_FILENAME: '__activity_options.js',
|
|
9
|
-
SHARED_STEP_PREFIX: '
|
|
9
|
+
SHARED_STEP_PREFIX: '$shared'
|
|
10
10
|
} ) );
|
|
11
11
|
|
|
12
12
|
const sendHttpRequestMock = vi.fn();
|
|
@@ -17,7 +17,10 @@ vi.mock( '#internal_activities', () => ( {
|
|
|
17
17
|
} ) );
|
|
18
18
|
|
|
19
19
|
const importComponentsMock = vi.fn();
|
|
20
|
-
vi.mock( './loader_tools.js',
|
|
20
|
+
vi.mock( './loader_tools.js', async importOriginal => {
|
|
21
|
+
const actual = await importOriginal();
|
|
22
|
+
return { ...actual, importComponents: importComponentsMock };
|
|
23
|
+
} );
|
|
21
24
|
|
|
22
25
|
const mkdirSyncMock = vi.fn();
|
|
23
26
|
const writeFileSyncMock = vi.fn();
|
|
@@ -34,12 +37,16 @@ describe( 'worker/loader', () => {
|
|
|
34
37
|
it( 'loadActivities returns map including system activity and writes options file', async () => {
|
|
35
38
|
const { loadActivities } = await import( './loader.js' );
|
|
36
39
|
|
|
40
|
+
// First call: workflow directory scan
|
|
37
41
|
importComponentsMock.mockImplementationOnce( async function *() {
|
|
38
42
|
yield { fn: () => {}, metadata: { name: 'Act1', options: { retry: { maximumAttempts: 3 } } }, path: '/a/steps.js' };
|
|
39
43
|
} );
|
|
44
|
+
// Second call: shared activities scan (no results)
|
|
45
|
+
importComponentsMock.mockImplementationOnce( async function *() {} );
|
|
40
46
|
|
|
41
|
-
const
|
|
42
|
-
|
|
47
|
+
const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
|
|
48
|
+
const activities = await loadActivities( '/root', workflows );
|
|
49
|
+
expect( activities['A#Act1'] ).toBeTypeOf( 'function' );
|
|
43
50
|
expect( activities['__internal#sendHttpRequest'] ).toBe( sendHttpRequestMock );
|
|
44
51
|
|
|
45
52
|
// options file written with the collected map
|
|
@@ -48,32 +55,11 @@ describe( 'worker/loader', () => {
|
|
|
48
55
|
expect( writtenPath ).toMatch( /temp\/__activity_options\.js$/ );
|
|
49
56
|
expect( contents ).toContain( 'export default' );
|
|
50
57
|
expect( JSON.parse( contents.replace( /^export default\s*/, '' ).replace( /;\s*$/, '' ) ) ).toEqual( {
|
|
51
|
-
'
|
|
58
|
+
'A#Act1': { retry: { maximumAttempts: 3 } }
|
|
52
59
|
} );
|
|
53
60
|
expect( mkdirSyncMock ).toHaveBeenCalled();
|
|
54
61
|
} );
|
|
55
62
|
|
|
56
|
-
it( 'loadActivities uses SHARED_STEP_PREFIX for components with isShared flag', async () => {
|
|
57
|
-
const { loadActivities } = await import( './loader.js' );
|
|
58
|
-
|
|
59
|
-
importComponentsMock.mockImplementationOnce( async function *() {
|
|
60
|
-
yield { fn: () => 'local', metadata: { name: 'LocalStep' }, path: '/workflows/example/steps.js', isShared: false };
|
|
61
|
-
yield { fn: () => 'shared', metadata: { name: 'SharedStep' }, path: '/shared/steps/tools.js', isShared: true };
|
|
62
|
-
} );
|
|
63
|
-
|
|
64
|
-
const activities = await loadActivities( '/root' );
|
|
65
|
-
|
|
66
|
-
// Local step uses dirname as prefix
|
|
67
|
-
expect( activities['/workflows/example#LocalStep'] ).toBeTypeOf( 'function' );
|
|
68
|
-
|
|
69
|
-
// Shared step uses SHARED_STEP_PREFIX ('/shared' from mock)
|
|
70
|
-
expect( activities['/shared#SharedStep'] ).toBeTypeOf( 'function' );
|
|
71
|
-
|
|
72
|
-
// Verify they're different functions
|
|
73
|
-
expect( activities['/workflows/example#LocalStep']() ).toBe( 'local' );
|
|
74
|
-
expect( activities['/shared#SharedStep']() ).toBe( 'shared' );
|
|
75
|
-
} );
|
|
76
|
-
|
|
77
63
|
it( 'loadWorkflows returns array of workflows with metadata', async () => {
|
|
78
64
|
const { loadWorkflows } = await import( './loader.js' );
|
|
79
65
|
|
|
@@ -98,4 +84,98 @@ describe( 'worker/loader', () => {
|
|
|
98
84
|
expect( contents ).toContain( 'export { default as catalog }' );
|
|
99
85
|
expect( mkdirSyncMock ).toHaveBeenCalledTimes( 1 );
|
|
100
86
|
} );
|
|
87
|
+
|
|
88
|
+
it( 'loadActivities uses folder-based matchers for steps/evaluators and shared', async () => {
|
|
89
|
+
const { loadActivities } = await import( './loader.js' );
|
|
90
|
+
// First call (workflow dir): no results
|
|
91
|
+
importComponentsMock.mockImplementationOnce( async function *() {} );
|
|
92
|
+
// Second call (shared): no results
|
|
93
|
+
importComponentsMock.mockImplementationOnce( async function *() {} );
|
|
94
|
+
|
|
95
|
+
const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
|
|
96
|
+
await loadActivities( '/root', workflows );
|
|
97
|
+
|
|
98
|
+
// First invocation should target the workflow directory with folder/file matchers
|
|
99
|
+
expect( importComponentsMock ).toHaveBeenCalledTimes( 2 );
|
|
100
|
+
const [ firstDir, firstMatchers ] = importComponentsMock.mock.calls[0];
|
|
101
|
+
expect( firstDir ).toBe( '/a' );
|
|
102
|
+
expect( Array.isArray( firstMatchers ) ).toBe( true );
|
|
103
|
+
// Should match folder-based steps and evaluators files
|
|
104
|
+
expect( firstMatchers.some( fn => fn( '/a/steps/foo.js' ) ) ).toBe( true );
|
|
105
|
+
expect( firstMatchers.some( fn => fn( '/a/evaluators/bar.js' ) ) ).toBe( true );
|
|
106
|
+
// And also direct file names
|
|
107
|
+
expect( firstMatchers.some( fn => fn( '/a/steps.js' ) ) ).toBe( true );
|
|
108
|
+
expect( firstMatchers.some( fn => fn( '/a/evaluators.js' ) ) ).toBe( true );
|
|
109
|
+
|
|
110
|
+
// Second invocation should target root with shared matchers
|
|
111
|
+
const [ secondDir, secondMatchers ] = importComponentsMock.mock.calls[1];
|
|
112
|
+
expect( secondDir ).toBe( '/root' );
|
|
113
|
+
expect( secondMatchers.some( fn => fn( '/root/shared/steps/baz.js' ) ) ).toBe( true );
|
|
114
|
+
expect( secondMatchers.some( fn => fn( '/root/shared/evaluators/qux.js' ) ) ).toBe( true );
|
|
115
|
+
} );
|
|
116
|
+
|
|
117
|
+
it( 'loadActivities includes nested workflow steps and shared evaluators', async () => {
|
|
118
|
+
const { loadActivities } = await import( './loader.js' );
|
|
119
|
+
// Workflow dir scan returns a nested step
|
|
120
|
+
importComponentsMock.mockImplementationOnce( async function *() {
|
|
121
|
+
yield { fn: () => {}, metadata: { name: 'ActNested' }, path: '/a/steps/foo.js' };
|
|
122
|
+
} );
|
|
123
|
+
// Shared scan returns a shared evaluator
|
|
124
|
+
importComponentsMock.mockImplementationOnce( async function *() {
|
|
125
|
+
yield { fn: () => {}, metadata: { name: 'SharedEval' }, path: '/root/shared/evaluators/bar.js' };
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
|
|
129
|
+
const activities = await loadActivities( '/root', workflows );
|
|
130
|
+
expect( activities['A#ActNested'] ).toBeTypeOf( 'function' );
|
|
131
|
+
expect( activities['$shared#SharedEval'] ).toBeTypeOf( 'function' );
|
|
132
|
+
} );
|
|
133
|
+
|
|
134
|
+
it( 'loadWorkflows throws when workflow is under shared directory', async () => {
|
|
135
|
+
const { loadWorkflows } = await import( './loader.js' );
|
|
136
|
+
importComponentsMock.mockImplementationOnce( async function *() {
|
|
137
|
+
yield { metadata: { name: 'Invalid' }, path: '/root/shared/workflow.js' };
|
|
138
|
+
} );
|
|
139
|
+
await expect( loadWorkflows( '/root' ) ).rejects.toThrow( 'Workflow directory can\'t be named \"shared\"' );
|
|
140
|
+
} );
|
|
141
|
+
|
|
142
|
+
it( 'collects workflow nested steps and evaluators across multiple subfolders', async () => {
|
|
143
|
+
const { loadActivities } = await import( './loader.js' );
|
|
144
|
+
// Workflow dir scan returns nested steps and evaluators
|
|
145
|
+
importComponentsMock.mockImplementationOnce( async function *() {
|
|
146
|
+
yield { fn: () => {}, metadata: { name: 'StepPrimary' }, path: '/a/steps/primary/foo.js' };
|
|
147
|
+
yield { fn: () => {}, metadata: { name: 'StepSecondary' }, path: '/a/steps/secondary/bar.js' };
|
|
148
|
+
yield { fn: () => {}, metadata: { name: 'EvalPrimary' }, path: '/a/evaluators/primary/baz.js' };
|
|
149
|
+
yield { fn: () => {}, metadata: { name: 'EvalSecondary' }, path: '/a/evaluators/secondary/qux.js' };
|
|
150
|
+
} );
|
|
151
|
+
// Shared scan returns nothing for this test
|
|
152
|
+
importComponentsMock.mockImplementationOnce( async function *() {} );
|
|
153
|
+
|
|
154
|
+
const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
|
|
155
|
+
const activities = await loadActivities( '/root', workflows );
|
|
156
|
+
expect( activities['A#StepPrimary'] ).toBeTypeOf( 'function' );
|
|
157
|
+
expect( activities['A#StepSecondary'] ).toBeTypeOf( 'function' );
|
|
158
|
+
expect( activities['A#EvalPrimary'] ).toBeTypeOf( 'function' );
|
|
159
|
+
expect( activities['A#EvalSecondary'] ).toBeTypeOf( 'function' );
|
|
160
|
+
} );
|
|
161
|
+
|
|
162
|
+
it( 'collects shared nested steps and evaluators across multiple subfolders', async () => {
|
|
163
|
+
const { loadActivities } = await import( './loader.js' );
|
|
164
|
+
// Workflow dir scan returns nothing for this test
|
|
165
|
+
importComponentsMock.mockImplementationOnce( async function *() {} );
|
|
166
|
+
// Shared scan returns nested steps and evaluators
|
|
167
|
+
importComponentsMock.mockImplementationOnce( async function *() {
|
|
168
|
+
yield { fn: () => {}, metadata: { name: 'SharedStepPrimary' }, path: '/root/shared/steps/primary/a.js' };
|
|
169
|
+
yield { fn: () => {}, metadata: { name: 'SharedStepSecondary' }, path: '/root/shared/steps/secondary/b.js' };
|
|
170
|
+
yield { fn: () => {}, metadata: { name: 'SharedEvalPrimary' }, path: '/root/shared/evaluators/primary/c.js' };
|
|
171
|
+
yield { fn: () => {}, metadata: { name: 'SharedEvalSecondary' }, path: '/root/shared/evaluators/secondary/d.js' };
|
|
172
|
+
} );
|
|
173
|
+
|
|
174
|
+
const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
|
|
175
|
+
const activities = await loadActivities( '/root', workflows );
|
|
176
|
+
expect( activities['$shared#SharedStepPrimary'] ).toBeTypeOf( 'function' );
|
|
177
|
+
expect( activities['$shared#SharedStepSecondary'] ).toBeTypeOf( 'function' );
|
|
178
|
+
expect( activities['$shared#SharedEvalPrimary'] ).toBeTypeOf( 'function' );
|
|
179
|
+
expect( activities['$shared#SharedEvalSecondary'] ).toBeTypeOf( 'function' );
|
|
180
|
+
} );
|
|
101
181
|
} );
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve } from 'path';
|
|
1
|
+
import { resolve, sep } from 'path';
|
|
2
2
|
import { pathToFileURL } from 'url';
|
|
3
3
|
import { METADATA_ACCESS_SYMBOL } from '#consts';
|
|
4
4
|
import { readdirSync } from 'fs';
|
|
@@ -7,59 +7,23 @@ import { readdirSync } from 'fs';
|
|
|
7
7
|
* @typedef {object} CollectedFile
|
|
8
8
|
* @property {string} path - The file path
|
|
9
9
|
* @property {string} url - The resolved url of the file, ready to be imported
|
|
10
|
-
* @property {boolean} [isShared] - Whether this file is in a shared/steps/ directory
|
|
11
10
|
*/
|
|
12
11
|
/**
|
|
13
12
|
* @typedef {object} Component
|
|
14
13
|
* @property {Function} fn - The loaded component function
|
|
15
14
|
* @property {object} metadata - Associated metadata with the component
|
|
16
|
-
* @property {string} path -
|
|
17
|
-
* @property {boolean} [isShared] - Whether this component is from a shared/steps/ directory
|
|
15
|
+
* @property {string} path - Associated metadata with the component
|
|
18
16
|
*/
|
|
19
17
|
|
|
20
18
|
/**
|
|
21
|
-
*
|
|
22
|
-
* @param {string[]} filenames - The filenames being searched for.
|
|
23
|
-
* @returns {boolean} True if searching for steps.js or evaluators.js.
|
|
24
|
-
*/
|
|
25
|
-
const isSearchingForStepFiles = filenames =>
|
|
26
|
-
filenames.includes( 'steps.js' ) || filenames.includes( 'evaluators.js' );
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Check if a directory is a shared steps directory.
|
|
30
|
-
* @param {string} dirName - The directory name.
|
|
31
|
-
* @param {string} parentPath - The parent path.
|
|
32
|
-
* @returns {boolean} True if this is a shared/steps directory.
|
|
33
|
-
*/
|
|
34
|
-
const isSharedStepsDirectory = ( dirName, parentPath ) =>
|
|
35
|
-
dirName === 'steps' && parentPath.endsWith( '/shared' );
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Collect all .js files from a shared steps directory.
|
|
39
|
-
* @param {string} dirPath - The directory path.
|
|
40
|
-
* @param {CollectedFile[]} collection - The collection to add files to.
|
|
41
|
-
*/
|
|
42
|
-
const collectSharedStepsFiles = ( dirPath, collection ) => {
|
|
43
|
-
for ( const entry of readdirSync( dirPath, { withFileTypes: true } ) ) {
|
|
44
|
-
if ( entry.isFile() && entry.name.endsWith( '.js' ) ) {
|
|
45
|
-
const filePath = resolve( dirPath, entry.name );
|
|
46
|
-
collection.push( {
|
|
47
|
-
path: filePath,
|
|
48
|
-
url: pathToFileURL( filePath ).href,
|
|
49
|
-
isShared: true
|
|
50
|
-
} );
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Recursive traverse directories looking for files with given name.
|
|
19
|
+
* Recursive traverse directories collection files with paths that match one of the given matches.
|
|
57
20
|
*
|
|
58
21
|
* @param {string} path - The path to scan
|
|
59
|
-
* @param {
|
|
22
|
+
* @param {function[]} matchers - Boolean functions to match files to add to collection
|
|
60
23
|
* @returns {CollectedFile[]} An array containing the collected files
|
|
61
|
-
|
|
62
|
-
const findByNameRecursively = ( parentPath,
|
|
24
|
+
*/
|
|
25
|
+
const findByNameRecursively = ( parentPath, matchers, ignoreDirNames = [ 'vendor', 'node_modules' ] ) => {
|
|
26
|
+
const collection = [];
|
|
63
27
|
for ( const entry of readdirSync( parentPath, { withFileTypes: true } ) ) {
|
|
64
28
|
if ( ignoreDirNames.includes( entry.name ) ) {
|
|
65
29
|
continue;
|
|
@@ -67,12 +31,8 @@ const findByNameRecursively = ( parentPath, filenames, collection = [], ignoreDi
|
|
|
67
31
|
|
|
68
32
|
const path = resolve( parentPath, entry.name );
|
|
69
33
|
if ( entry.isDirectory() ) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
} else {
|
|
73
|
-
findByNameRecursively( path, filenames, collection, ignoreDirNames );
|
|
74
|
-
}
|
|
75
|
-
} else if ( filenames.includes( entry.name ) ) {
|
|
34
|
+
collection.push( ...findByNameRecursively( path, matchers ) );
|
|
35
|
+
} else if ( matchers.some( m => m( path ) ) ) {
|
|
76
36
|
collection.push( { path, url: pathToFileURL( path ).href } );
|
|
77
37
|
}
|
|
78
38
|
}
|
|
@@ -81,24 +41,92 @@ const findByNameRecursively = ( parentPath, filenames, collection = [], ignoreDi
|
|
|
81
41
|
};
|
|
82
42
|
|
|
83
43
|
/**
|
|
84
|
-
*
|
|
44
|
+
* Scan a path for files testing each path against a matching function.
|
|
45
|
+
*
|
|
46
|
+
* For each file found, dynamic import it and for each exports on that file, yields it.
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
49
|
+
* - Only yields exports that have the METADATA_ACCESS_SYMBOL, as they are output components (steps, evaluators, etc).
|
|
85
50
|
*
|
|
86
51
|
* @generator
|
|
87
52
|
* @async
|
|
88
53
|
* @function importComponents
|
|
89
54
|
* @param {string} target - Place to look for files
|
|
90
|
-
* @param {
|
|
55
|
+
* @param {function[]} matchers - Boolean functions to match files
|
|
91
56
|
* @yields {Component}
|
|
92
57
|
*/
|
|
93
|
-
export async function *importComponents( target,
|
|
94
|
-
for ( const { url, path
|
|
58
|
+
export async function *importComponents( target, matchers ) {
|
|
59
|
+
for ( const { url, path } of findByNameRecursively( target, matchers ) ) {
|
|
95
60
|
const imported = await import( url );
|
|
96
61
|
for ( const fn of Object.values( imported ) ) {
|
|
97
62
|
const metadata = fn[METADATA_ACCESS_SYMBOL];
|
|
98
63
|
if ( !metadata ) {
|
|
99
64
|
continue;
|
|
100
65
|
}
|
|
101
|
-
yield { fn, metadata, path
|
|
66
|
+
yield { fn, metadata, path };
|
|
102
67
|
}
|
|
103
68
|
}
|
|
104
69
|
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns matchers that need to be built using a relative path
|
|
73
|
+
*
|
|
74
|
+
* @param {string} path
|
|
75
|
+
* @returns {object} The object containing the matchers
|
|
76
|
+
*/
|
|
77
|
+
export const activityMatchersBuilder = path => ( {
|
|
78
|
+
/**
|
|
79
|
+
* Matches a file called steps.js, located at the path
|
|
80
|
+
* @param {string} path - Path to test
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
stepsFile: v => v === `${path}${sep}steps.js`,
|
|
84
|
+
/**
|
|
85
|
+
* Matches a file called evaluators.js, located at the path
|
|
86
|
+
* @param {string} path - Path to test
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
evaluatorsFile: v => v === `${path}${sep}evaluators.js`,
|
|
90
|
+
/**
|
|
91
|
+
* Matches all files on any levels inside a folder called steps/, located at the path
|
|
92
|
+
* @param {string} path - Path to test
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
stepsDir: v => v.startsWith( `${path}${sep}steps${sep}` ),
|
|
96
|
+
/**
|
|
97
|
+
* Matches all files on any levels inside a folder called evaluators/, located at the path
|
|
98
|
+
* @param {string} path - Path to test
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
evaluatorsDir: v => v.startsWith( `${path}${sep}evaluators${sep}` )
|
|
102
|
+
} );
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Matchers that can be used to access conditions without initializing them
|
|
106
|
+
*/
|
|
107
|
+
export const staticMatchers = {
|
|
108
|
+
/**
|
|
109
|
+
* Matches a workflow.js file
|
|
110
|
+
* @param {string} path - Path to test
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
workflowFile: v => v.endsWith( `${sep}workflow.js` ),
|
|
114
|
+
/**
|
|
115
|
+
* Matches a workflow.js that is inside a shared folder: eg foo/shared/workflow.js
|
|
116
|
+
* @param {string} path - Path to test
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
workflowPathHasShared: v => v.endsWith( `${sep}shared${sep}workflow.js` ),
|
|
120
|
+
/**
|
|
121
|
+
* Matches the shared folder for steps src/shared/steps/../step_file.js
|
|
122
|
+
* @param {string} path - Path to test
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
sharedStepsDir: v => v.includes( `${sep}shared${sep}steps${sep}` ),
|
|
126
|
+
/**
|
|
127
|
+
* Matches the shared folder for evaluators src/shared/evaluators/../evaluator_file.js
|
|
128
|
+
* @param {string} path - Path to test
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
sharedEvaluatorsDir: v => v.includes( `${sep}shared${sep}evaluators${sep}` )
|
|
132
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
2
|
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, sep } from 'node:path';
|
|
4
4
|
import { importComponents } from './loader_tools.js';
|
|
5
5
|
|
|
6
6
|
describe( '.importComponents', () => {
|
|
@@ -21,7 +21,7 @@ describe( '.importComponents', () => {
|
|
|
21
21
|
].join( '\n' ) );
|
|
22
22
|
|
|
23
23
|
const collected = [];
|
|
24
|
-
for await ( const m of importComponents( root, [ 'meta.module.js' ] ) ) {
|
|
24
|
+
for await ( const m of importComponents( root, [ v => v.endsWith( 'meta.module.js' ) ] ) ) {
|
|
25
25
|
collected.push( m );
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -46,7 +46,7 @@ describe( '.importComponents', () => {
|
|
|
46
46
|
].join( '\n' ) );
|
|
47
47
|
|
|
48
48
|
const collected = [];
|
|
49
|
-
for await ( const m of importComponents( root, [ 'meta.module.js' ] ) ) {
|
|
49
|
+
for await ( const m of importComponents( root, [ v => v.endsWith( 'meta.module.js' ) ] ) ) {
|
|
50
50
|
collected.push( m );
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -77,7 +77,7 @@ describe( '.importComponents', () => {
|
|
|
77
77
|
writeFileSync( vendorFile, fileContents );
|
|
78
78
|
|
|
79
79
|
const collected = [];
|
|
80
|
-
for await ( const m of importComponents( root, [ 'meta.module.js' ] ) ) {
|
|
80
|
+
for await ( const m of importComponents( root, [ v => v.endsWith( 'meta.module.js' ) ] ) ) {
|
|
81
81
|
collected.push( m );
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -87,82 +87,31 @@ describe( '.importComponents', () => {
|
|
|
87
87
|
rmSync( root, { recursive: true, force: true } );
|
|
88
88
|
} );
|
|
89
89
|
|
|
90
|
-
it( '
|
|
91
|
-
const root = join( process.cwd(), 'sdk/core/temp_test_modules', `meta-${Date.now()}-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
mkdirSync(
|
|
95
|
-
mkdirSync(
|
|
96
|
-
|
|
97
|
-
const sharedToolsFile = join( sharedStepsDir, 'tools.js' );
|
|
98
|
-
const sharedUtilsFile = join( sharedStepsDir, 'utils.js' );
|
|
99
|
-
const workflowStepsFile = join( workflowDir, 'steps.js' );
|
|
100
|
-
|
|
101
|
-
const sharedFileContents = [
|
|
102
|
-
'import { METADATA_ACCESS_SYMBOL } from "#consts";',
|
|
103
|
-
'export const SharedStep = () => {};',
|
|
104
|
-
'SharedStep[METADATA_ACCESS_SYMBOL] = { kind: "step", name: "sharedStep" };'
|
|
105
|
-
].join( '\n' );
|
|
106
|
-
const workflowFileContents = [
|
|
107
|
-
'import { METADATA_ACCESS_SYMBOL } from "#consts";',
|
|
108
|
-
'export const LocalStep = () => {};',
|
|
109
|
-
'LocalStep[METADATA_ACCESS_SYMBOL] = { kind: "step", name: "localStep" };'
|
|
110
|
-
].join( '\n' );
|
|
111
|
-
writeFileSync( sharedToolsFile, sharedFileContents );
|
|
112
|
-
writeFileSync( sharedUtilsFile, sharedFileContents );
|
|
113
|
-
writeFileSync( workflowStepsFile, workflowFileContents );
|
|
114
|
-
|
|
115
|
-
const collected = [];
|
|
116
|
-
for await ( const m of importComponents( root, [ 'steps.js' ] ) ) {
|
|
117
|
-
collected.push( m );
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
expect( collected.length ).toBe( 3 );
|
|
121
|
-
|
|
122
|
-
const sharedComponents = collected.filter( m => m.isShared === true );
|
|
123
|
-
const localComponents = collected.filter( m => !m.isShared );
|
|
124
|
-
|
|
125
|
-
expect( sharedComponents.length ).toBe( 2 );
|
|
126
|
-
expect( localComponents.length ).toBe( 1 );
|
|
127
|
-
|
|
128
|
-
expect( sharedComponents.map( m => m.path ).sort() ).toEqual( [ sharedToolsFile, sharedUtilsFile ].sort() );
|
|
129
|
-
expect( localComponents[0].path ).toBe( workflowStepsFile );
|
|
130
|
-
|
|
131
|
-
rmSync( root, { recursive: true, force: true } );
|
|
132
|
-
} );
|
|
133
|
-
|
|
134
|
-
it( 'does not collect shared/steps/ files when searching for workflow.js', async () => {
|
|
135
|
-
const root = join( process.cwd(), 'sdk/core/temp_test_modules', `meta-${Date.now()}-workflow-exclude` );
|
|
136
|
-
const sharedStepsDir = join( root, 'shared', 'steps' );
|
|
137
|
-
const workflowDir = join( root, 'workflows', 'example' );
|
|
138
|
-
mkdirSync( sharedStepsDir, { recursive: true } );
|
|
139
|
-
mkdirSync( workflowDir, { recursive: true } );
|
|
140
|
-
|
|
141
|
-
const sharedToolsFile = join( sharedStepsDir, 'tools.js' );
|
|
142
|
-
const workflowFile = join( workflowDir, 'workflow.js' );
|
|
90
|
+
it( 'supports partial matching by folder name', async () => {
|
|
91
|
+
const root = join( process.cwd(), 'sdk/core/temp_test_modules', `meta-${Date.now()}-foldermatch` );
|
|
92
|
+
const okDir = join( root, 'features', 'ok' );
|
|
93
|
+
const otherDir = join( root, 'features', 'other' );
|
|
94
|
+
mkdirSync( okDir, { recursive: true } );
|
|
95
|
+
mkdirSync( otherDir, { recursive: true } );
|
|
143
96
|
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
'SharedStep[METADATA_ACCESS_SYMBOL] = { kind: "step", name: "sharedStep" };'
|
|
148
|
-
].join( '\n' );
|
|
149
|
-
const workflowFileContents = [
|
|
97
|
+
const okFile = join( okDir, 'alpha.js' );
|
|
98
|
+
const otherFile = join( otherDir, 'beta.js' );
|
|
99
|
+
const src = [
|
|
150
100
|
'import { METADATA_ACCESS_SYMBOL } from "#consts";',
|
|
151
|
-
'export
|
|
152
|
-
'
|
|
101
|
+
'export const X = () => {};',
|
|
102
|
+
'X[METADATA_ACCESS_SYMBOL] = { kind: "step", name: "x" };'
|
|
153
103
|
].join( '\n' );
|
|
154
|
-
writeFileSync(
|
|
155
|
-
writeFileSync(
|
|
104
|
+
writeFileSync( okFile, src );
|
|
105
|
+
writeFileSync( otherFile, src );
|
|
156
106
|
|
|
107
|
+
// Match any JS under a folder named "ok"
|
|
108
|
+
const matcher = v => v.includes( `${join( 'features', 'ok' )}${sep}` );
|
|
157
109
|
const collected = [];
|
|
158
|
-
for await ( const m of importComponents( root, [
|
|
110
|
+
for await ( const m of importComponents( root, [ matcher ] ) ) {
|
|
159
111
|
collected.push( m );
|
|
160
112
|
}
|
|
161
|
-
|
|
162
113
|
expect( collected.length ).toBe( 1 );
|
|
163
|
-
expect( collected[0].path ).toBe(
|
|
164
|
-
expect( collected[0].metadata.kind ).toBe( 'workflow' );
|
|
165
|
-
expect( collected[0].isShared ).toBeUndefined();
|
|
114
|
+
expect( collected[0].path ).toBe( okFile );
|
|
166
115
|
|
|
167
116
|
rmSync( root, { recursive: true, force: true } );
|
|
168
117
|
} );
|
|
@@ -43,15 +43,15 @@ const getFileKindLabel = filename => {
|
|
|
43
43
|
* - Core modules (@output.ai/core, local_core)
|
|
44
44
|
* - ANY file that is NOT a component file (flexible utility imports)
|
|
45
45
|
*/
|
|
46
|
-
const validateWorkflowImports = ( { specifier, filename } ) => {
|
|
46
|
+
const validateWorkflowImports = ( { specifier, filename, emitWarning } ) => {
|
|
47
47
|
const isCore = Object.values( CoreModule ).includes( specifier );
|
|
48
48
|
const fileKind = getFileKind( specifier );
|
|
49
49
|
const isComponent = Object.values( ComponentFile ).includes( fileKind );
|
|
50
50
|
const isNonComponentFile = fileKind === null;
|
|
51
51
|
|
|
52
52
|
if ( !isCore && !isComponent && !isNonComponentFile ) {
|
|
53
|
-
|
|
54
|
-
Only components (${Object.values( ComponentFile ) } ), @output.ai/core, or non-component files are allowed in ${filename}` );
|
|
53
|
+
emitWarning( new Error( `Invalid dependency in workflow.js: '${specifier}'. \
|
|
54
|
+
Only components (${Object.values( ComponentFile ) } ), @output.ai/core, or non-component files are allowed in ${filename}` ) );
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
|
|
@@ -65,25 +65,25 @@ Only components (${Object.values( ComponentFile ) } ), @output.ai/core, or non-c
|
|
|
65
65
|
* Steps and evaluators CAN import:
|
|
66
66
|
* - ANY file that is NOT a component file (flexible utility imports)
|
|
67
67
|
*/
|
|
68
|
-
const validateStepEvaluatorImports = ( { specifier, filename } ) => {
|
|
68
|
+
const validateStepEvaluatorImports = ( { specifier, filename, emitWarning } ) => {
|
|
69
69
|
const importedFileKind = getFileKind( specifier );
|
|
70
70
|
|
|
71
71
|
// Activity isolation: steps/evaluators cannot import other steps, evaluators, or workflows
|
|
72
72
|
if ( Object.values( ComponentFile ).includes( importedFileKind ) ) {
|
|
73
73
|
const fileLabel = getFileKindLabel( filename );
|
|
74
|
-
|
|
75
|
-
Steps, evaluators or workflows are not allowed dependencies in ${filename}` );
|
|
74
|
+
emitWarning( new Error( `Invalid dependency in ${fileLabel}: '${specifier}'. \
|
|
75
|
+
Steps, evaluators or workflows are not allowed dependencies in ${filename}` ) );
|
|
76
76
|
}
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Validate import for evaluators, steps, workflow
|
|
81
81
|
*/
|
|
82
|
-
const executeImportValidations = ( { fileKind, specifier, filename } ) => {
|
|
82
|
+
const executeImportValidations = ( { fileKind, specifier, filename, emitWarning } ) => {
|
|
83
83
|
if ( fileKind === ComponentFile.WORKFLOW ) {
|
|
84
|
-
validateWorkflowImports( { specifier, filename } );
|
|
84
|
+
validateWorkflowImports( { specifier, filename, emitWarning } );
|
|
85
85
|
} else if ( Object.values( ComponentFile ).includes( fileKind ) ) {
|
|
86
|
-
validateStepEvaluatorImports( { specifier, filename } );
|
|
86
|
+
validateStepEvaluatorImports( { specifier, filename, emitWarning } );
|
|
87
87
|
}
|
|
88
88
|
};
|
|
89
89
|
|
|
@@ -127,6 +127,7 @@ const validateInstantiationLocation = ( calleeName, filename ) => {
|
|
|
127
127
|
export default function workflowValidatorLoader( source, inputMap ) {
|
|
128
128
|
this.cacheable?.( true );
|
|
129
129
|
const callback = this.async?.() ?? this.callback;
|
|
130
|
+
const emitWarning = this.emitWarning?.bind( this ) ?? ( () => {} );
|
|
130
131
|
|
|
131
132
|
try {
|
|
132
133
|
const filename = this.resourcePath;
|
|
@@ -147,7 +148,7 @@ export default function workflowValidatorLoader( source, inputMap ) {
|
|
|
147
148
|
ImportDeclaration: path => {
|
|
148
149
|
const specifier = path.node.source.value;
|
|
149
150
|
|
|
150
|
-
executeImportValidations( { fileKind, specifier, filename } );
|
|
151
|
+
executeImportValidations( { fileKind, specifier, filename, emitWarning } );
|
|
151
152
|
|
|
152
153
|
// Collect imported identifiers for later call checks
|
|
153
154
|
const importedKind = getFileKind( specifier );
|
|
@@ -194,7 +195,7 @@ export default function workflowValidatorLoader( source, inputMap ) {
|
|
|
194
195
|
return;
|
|
195
196
|
}
|
|
196
197
|
const req = firstArg.value;
|
|
197
|
-
executeImportValidations( { fileKind, specifier: req, filename } );
|
|
198
|
+
executeImportValidations( { fileKind, specifier: req, filename, emitWarning } );
|
|
198
199
|
|
|
199
200
|
// Collect imported identifiers from require patterns
|
|
200
201
|
if ( isStringLiteral( firstArg ) ) {
|
|
@@ -246,7 +247,7 @@ export default function workflowValidatorLoader( source, inputMap ) {
|
|
|
246
247
|
].find( v => v[1] )?.[0];
|
|
247
248
|
|
|
248
249
|
if ( violation ) {
|
|
249
|
-
|
|
250
|
+
emitWarning( new Error( `Invalid call in ${fileLabel} fn: calling a ${violation} ('${name}') is not allowed in ${filename}` ) );
|
|
250
251
|
}
|
|
251
252
|
}
|
|
252
253
|
}
|
|
@@ -6,11 +6,13 @@ import validatorLoader from './index.mjs';
|
|
|
6
6
|
|
|
7
7
|
function runLoader( filename, source ) {
|
|
8
8
|
return new Promise( ( resolve, reject ) => {
|
|
9
|
+
const warnings = [];
|
|
9
10
|
const ctx = {
|
|
10
11
|
resourcePath: filename,
|
|
11
12
|
cacheable: () => {},
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
emitWarning: err => warnings.push( err ),
|
|
14
|
+
async: () => ( err, code, map ) => ( err ? reject( err ) : resolve( { code, map, warnings } ) ),
|
|
15
|
+
callback: ( err, code, map ) => ( err ? reject( err ) : resolve( { code, map, warnings } ) )
|
|
14
16
|
};
|
|
15
17
|
validatorLoader.call( ctx, source, null );
|
|
16
18
|
} );
|
|
@@ -52,10 +54,12 @@ describe( 'workflow_validator loader', () => {
|
|
|
52
54
|
rmSync( dir, { recursive: true, force: true } );
|
|
53
55
|
} );
|
|
54
56
|
|
|
55
|
-
it( 'steps.js:
|
|
57
|
+
it( 'steps.js: warns when importing steps/evaluators/workflow', async () => {
|
|
56
58
|
const dir = mkdtempSync( join( tmpdir(), 'steps-reject-' ) );
|
|
57
59
|
const src = 'import { S } from "./steps.js";';
|
|
58
|
-
await
|
|
60
|
+
const result = await runLoader( join( dir, 'steps.js' ), src );
|
|
61
|
+
expect( result.warnings ).toHaveLength( 1 );
|
|
62
|
+
expect( result.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in steps\.js/ );
|
|
59
63
|
rmSync( dir, { recursive: true, force: true } );
|
|
60
64
|
} );
|
|
61
65
|
|
|
@@ -66,14 +70,16 @@ describe( 'workflow_validator loader', () => {
|
|
|
66
70
|
rmSync( dir, { recursive: true, force: true } );
|
|
67
71
|
} );
|
|
68
72
|
|
|
69
|
-
it( 'evaluators.js:
|
|
73
|
+
it( 'evaluators.js: warns when importing evaluators/steps/workflow', async () => {
|
|
70
74
|
const dir = mkdtempSync( join( tmpdir(), 'evals-reject-' ) );
|
|
71
75
|
const src = 'import { E } from "./evaluators.js";';
|
|
72
|
-
await
|
|
76
|
+
const result = await runLoader( join( dir, 'evaluators.js' ), src );
|
|
77
|
+
expect( result.warnings ).toHaveLength( 1 );
|
|
78
|
+
expect( result.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in evaluators\.js/ );
|
|
73
79
|
rmSync( dir, { recursive: true, force: true } );
|
|
74
80
|
} );
|
|
75
81
|
|
|
76
|
-
it( 'steps.js:
|
|
82
|
+
it( 'steps.js: warns when calling another step inside fn', async () => {
|
|
77
83
|
const dir = mkdtempSync( join( tmpdir(), 'steps-call-reject-' ) );
|
|
78
84
|
// Can only test same-type components since cross-type declarations are now blocked by instantiation validation
|
|
79
85
|
const src = [
|
|
@@ -81,11 +87,13 @@ describe( 'workflow_validator loader', () => {
|
|
|
81
87
|
'const B = step({ name: "b" });',
|
|
82
88
|
'const obj = { fn: function() { B(); } };'
|
|
83
89
|
].join( '\n' );
|
|
84
|
-
await
|
|
90
|
+
const result = await runLoader( join( dir, 'steps.js' ), src );
|
|
91
|
+
expect( result.warnings ).toHaveLength( 1 );
|
|
92
|
+
expect( result.warnings[0].message ).toMatch( /Invalid call in .*\.js fn/ );
|
|
85
93
|
rmSync( dir, { recursive: true, force: true } );
|
|
86
94
|
} );
|
|
87
95
|
|
|
88
|
-
it( 'evaluators.js:
|
|
96
|
+
it( 'evaluators.js: warns when calling another evaluator inside fn', async () => {
|
|
89
97
|
const dir = mkdtempSync( join( tmpdir(), 'evals-call-reject-' ) );
|
|
90
98
|
// Can only test same-type components since cross-type declarations are now blocked by instantiation validation
|
|
91
99
|
const src = [
|
|
@@ -93,7 +101,9 @@ describe( 'workflow_validator loader', () => {
|
|
|
93
101
|
'const E2 = evaluator({ name: "e2" });',
|
|
94
102
|
'const obj = { fn: function() { E2(); } };'
|
|
95
103
|
].join( '\n' );
|
|
96
|
-
await
|
|
104
|
+
const result = await runLoader( join( dir, 'evaluators.js' ), src );
|
|
105
|
+
expect( result.warnings ).toHaveLength( 1 );
|
|
106
|
+
expect( result.warnings[0].message ).toMatch( /Invalid call in .*\.js fn/ );
|
|
97
107
|
rmSync( dir, { recursive: true, force: true } );
|
|
98
108
|
} );
|
|
99
109
|
|
|
@@ -130,21 +140,25 @@ describe( 'workflow_validator loader', () => {
|
|
|
130
140
|
rmSync( dir, { recursive: true, force: true } );
|
|
131
141
|
} );
|
|
132
142
|
|
|
133
|
-
it( 'steps.js:
|
|
143
|
+
it( 'steps.js: warns when importing evaluators/workflow variants', async () => {
|
|
134
144
|
const dir = mkdtempSync( join( tmpdir(), 'steps-reject2-' ) );
|
|
135
|
-
await
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
const result1 = await runLoader( join( dir, 'steps.js' ), 'import { E } from "./evaluators.js";' );
|
|
146
|
+
expect( result1.warnings ).toHaveLength( 1 );
|
|
147
|
+
expect( result1.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in steps\.js/ );
|
|
148
|
+
const result2 = await runLoader( join( dir, 'steps.js' ), 'import WF from "./workflow.js";' );
|
|
149
|
+
expect( result2.warnings ).toHaveLength( 1 );
|
|
150
|
+
expect( result2.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in steps\.js/ );
|
|
139
151
|
rmSync( dir, { recursive: true, force: true } );
|
|
140
152
|
} );
|
|
141
153
|
|
|
142
|
-
it( 'evaluators.js:
|
|
154
|
+
it( 'evaluators.js: warns when importing steps/workflow variants', async () => {
|
|
143
155
|
const dir = mkdtempSync( join( tmpdir(), 'evals-reject2-' ) );
|
|
144
|
-
await
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
const result1 = await runLoader( join( dir, 'evaluators.js' ), 'import { S } from "./steps.js";' );
|
|
157
|
+
expect( result1.warnings ).toHaveLength( 1 );
|
|
158
|
+
expect( result1.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in evaluators\.js/ );
|
|
159
|
+
const result2 = await runLoader( join( dir, 'evaluators.js' ), 'import WF from "./workflow.js";' );
|
|
160
|
+
expect( result2.warnings ).toHaveLength( 1 );
|
|
161
|
+
expect( result2.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in evaluators\.js/ );
|
|
148
162
|
rmSync( dir, { recursive: true, force: true } );
|
|
149
163
|
} );
|
|
150
164
|
|
|
@@ -176,27 +190,34 @@ describe( 'workflow_validator loader', () => {
|
|
|
176
190
|
rmSync( dir, { recursive: true, force: true } );
|
|
177
191
|
} );
|
|
178
192
|
|
|
179
|
-
it( 'steps.js:
|
|
193
|
+
it( 'steps.js: warns on require of steps/evaluators/workflow; allows other require', async () => {
|
|
180
194
|
const dir = mkdtempSync( join( tmpdir(), 'steps-require-' ) );
|
|
181
|
-
await
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
195
|
+
const result1 = await runLoader( join( dir, 'steps.js' ), 'const { S } = require("./steps.js");' );
|
|
196
|
+
expect( result1.warnings ).toHaveLength( 1 );
|
|
197
|
+
expect( result1.warnings[0].message ).toMatch( /Invalid (require|dependency) in steps\.js/ );
|
|
198
|
+
const result2 = await runLoader( join( dir, 'steps.js' ), 'const { E } = require("./evaluators.js");' );
|
|
199
|
+
expect( result2.warnings ).toHaveLength( 1 );
|
|
200
|
+
expect( result2.warnings[0].message ).toMatch( /Invalid (require|dependency) in steps\.js/ );
|
|
201
|
+
const result3 = await runLoader( join( dir, 'steps.js' ), 'const W = require("./workflow.js");' );
|
|
202
|
+
expect( result3.warnings ).toHaveLength( 1 );
|
|
203
|
+
expect( result3.warnings[0].message ).toMatch( /Invalid (require|dependency) in steps\.js/ );
|
|
187
204
|
const ok = 'const util = require("./util.js");';
|
|
188
|
-
await
|
|
205
|
+
const resultOk = await runLoader( join( dir, 'steps.js' ), ok );
|
|
206
|
+
expect( resultOk.warnings ).toHaveLength( 0 );
|
|
189
207
|
rmSync( dir, { recursive: true, force: true } );
|
|
190
208
|
} );
|
|
191
209
|
|
|
192
|
-
it( 'evaluators.js:
|
|
210
|
+
it( 'evaluators.js: warns on require of steps/workflow; allows other require', async () => {
|
|
193
211
|
const dir = mkdtempSync( join( tmpdir(), 'evals-require-' ) );
|
|
194
|
-
await
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
212
|
+
const result1 = await runLoader( join( dir, 'evaluators.js' ), 'const { S } = require("./steps.js");' );
|
|
213
|
+
expect( result1.warnings ).toHaveLength( 1 );
|
|
214
|
+
expect( result1.warnings[0].message ).toMatch( /Invalid (require|dependency) in evaluators\.js/ );
|
|
215
|
+
const result2 = await runLoader( join( dir, 'evaluators.js' ), 'const W = require("./workflow.js");' );
|
|
216
|
+
expect( result2.warnings ).toHaveLength( 1 );
|
|
217
|
+
expect( result2.warnings[0].message ).toMatch( /Invalid (require|dependency) in evaluators\.js/ );
|
|
198
218
|
const ok = 'const util = require("./util.js");';
|
|
199
|
-
await
|
|
219
|
+
const resultOk = await runLoader( join( dir, 'evaluators.js' ), ok );
|
|
220
|
+
expect( resultOk.warnings ).toHaveLength( 0 );
|
|
200
221
|
rmSync( dir, { recursive: true, force: true } );
|
|
201
222
|
} );
|
|
202
223
|
|
|
@@ -317,47 +338,51 @@ describe( 'workflow_validator loader', () => {
|
|
|
317
338
|
} );
|
|
318
339
|
|
|
319
340
|
describe( 'activity isolation - shared imports', () => {
|
|
320
|
-
it( 'steps.ts:
|
|
341
|
+
it( 'steps.ts: warns on imports from ../../shared/steps/common.js (activity isolation)', async () => {
|
|
321
342
|
const dir = mkdtempSync( join( tmpdir(), 'steps-shared-steps-reject-' ) );
|
|
322
343
|
mkdirSync( join( dir, 'workflows', 'my_workflow' ), { recursive: true } );
|
|
323
344
|
mkdirSync( join( dir, 'shared', 'steps' ), { recursive: true } );
|
|
324
345
|
writeFileSync( join( dir, 'shared', 'steps', 'common.js' ), 'export const commonStep = step({ name: "common" });\n' );
|
|
325
346
|
const src = 'import { commonStep } from "../../shared/steps/common.js";';
|
|
326
|
-
await
|
|
327
|
-
|
|
347
|
+
const result = await runLoader( join( dir, 'workflows', 'my_workflow', 'steps.js' ), src );
|
|
348
|
+
expect( result.warnings ).toHaveLength( 1 );
|
|
349
|
+
expect( result.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in steps\.js/ );
|
|
328
350
|
rmSync( dir, { recursive: true, force: true } );
|
|
329
351
|
} );
|
|
330
352
|
|
|
331
|
-
it( 'steps.ts:
|
|
353
|
+
it( 'steps.ts: warns on imports from ../../shared/evaluators/quality.js (activity isolation)', async () => {
|
|
332
354
|
const dir = mkdtempSync( join( tmpdir(), 'steps-shared-evals-reject-' ) );
|
|
333
355
|
mkdirSync( join( dir, 'workflows', 'my_workflow' ), { recursive: true } );
|
|
334
356
|
mkdirSync( join( dir, 'shared', 'evaluators' ), { recursive: true } );
|
|
335
357
|
writeFileSync( join( dir, 'shared', 'evaluators', 'quality.js' ), 'export const qualityEval = evaluator({ name: "quality" });\n' );
|
|
336
358
|
const src = 'import { qualityEval } from "../../shared/evaluators/quality.js";';
|
|
337
|
-
await
|
|
338
|
-
|
|
359
|
+
const result = await runLoader( join( dir, 'workflows', 'my_workflow', 'steps.js' ), src );
|
|
360
|
+
expect( result.warnings ).toHaveLength( 1 );
|
|
361
|
+
expect( result.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in steps\.js/ );
|
|
339
362
|
rmSync( dir, { recursive: true, force: true } );
|
|
340
363
|
} );
|
|
341
364
|
|
|
342
|
-
it( 'evaluators.ts:
|
|
365
|
+
it( 'evaluators.ts: warns on imports from ../../shared/steps/common.js (activity isolation)', async () => {
|
|
343
366
|
const dir = mkdtempSync( join( tmpdir(), 'evals-shared-steps-reject-' ) );
|
|
344
367
|
mkdirSync( join( dir, 'workflows', 'my_workflow' ), { recursive: true } );
|
|
345
368
|
mkdirSync( join( dir, 'shared', 'steps' ), { recursive: true } );
|
|
346
369
|
writeFileSync( join( dir, 'shared', 'steps', 'common.js' ), 'export const commonStep = step({ name: "common" });\n' );
|
|
347
370
|
const src = 'import { commonStep } from "../../shared/steps/common.js";';
|
|
348
|
-
await
|
|
349
|
-
|
|
371
|
+
const result = await runLoader( join( dir, 'workflows', 'my_workflow', 'evaluators.js' ), src );
|
|
372
|
+
expect( result.warnings ).toHaveLength( 1 );
|
|
373
|
+
expect( result.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in evaluators\.js/ );
|
|
350
374
|
rmSync( dir, { recursive: true, force: true } );
|
|
351
375
|
} );
|
|
352
376
|
|
|
353
|
-
it( 'evaluators.ts:
|
|
377
|
+
it( 'evaluators.ts: warns on imports from ../../shared/evaluators/other.js (activity isolation)', async () => {
|
|
354
378
|
const dir = mkdtempSync( join( tmpdir(), 'evals-shared-evals-reject-' ) );
|
|
355
379
|
mkdirSync( join( dir, 'workflows', 'my_workflow' ), { recursive: true } );
|
|
356
380
|
mkdirSync( join( dir, 'shared', 'evaluators' ), { recursive: true } );
|
|
357
381
|
writeFileSync( join( dir, 'shared', 'evaluators', 'other.js' ), 'export const otherEval = evaluator({ name: "other" });\n' );
|
|
358
382
|
const src = 'import { otherEval } from "../../shared/evaluators/other.js";';
|
|
359
|
-
await
|
|
360
|
-
|
|
383
|
+
const result = await runLoader( join( dir, 'workflows', 'my_workflow', 'evaluators.js' ), src );
|
|
384
|
+
expect( result.warnings ).toHaveLength( 1 );
|
|
385
|
+
expect( result.warnings[0].message ).toMatch( /Invalid (import|imports|dependency) in evaluators\.js/ );
|
|
361
386
|
rmSync( dir, { recursive: true, force: true } );
|
|
362
387
|
} );
|
|
363
388
|
} );
|