@output.ai/core 0.2.1 → 0.3.0-dev.pr263-a59dd0e

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.2.1",
3
+ "version": "0.3.0-dev.pr263-a59dd0e",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.d.ts CHANGED
@@ -2,6 +2,18 @@ import type { z } from 'zod';
2
2
  import type { ActivityOptions } from '@temporalio/workflow';
3
3
  import type { SerializedFetchResponse } from './utils/index.d.ts';
4
4
 
5
+ /**
6
+ * Similar to `Partial<T>` but applies to nested properties recursively, creating a deep optional variant of `T`:
7
+ * - Objects: All properties become optional, recursively.
8
+ * - Functions: Preserved as‑is (only the property itself becomes optional).
9
+ * - Primitives: Returned unchanged.
10
+ * Useful for config overrides with strong IntelliSense on nested fields and methods.
11
+ */
12
+ type DeepPartial<T> =
13
+ T extends ( ...args: any[] ) => unknown ? T : // eslint-disable-line @typescript-eslint/no-explicit-any
14
+ T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } :
15
+ T;
16
+
5
17
  /**
6
18
  * Expose z from Zod as a convenience.
7
19
  */
@@ -211,7 +223,7 @@ export declare function step<
211
223
  *
212
224
  * Allows overriding Temporal Activity options for this workflow.
213
225
  */
214
- export type WorkflowInvocationConfiguration = {
226
+ export type WorkflowInvocationConfiguration<Context extends WorkflowContext = WorkflowContext> = {
215
227
 
216
228
  /**
217
229
  * Native Temporal Activity options
@@ -223,6 +235,11 @@ export type WorkflowInvocationConfiguration = {
223
235
  * Detached workflows called without explicitly awaiting the result are "fire-and-forget" and may outlive the parent.
224
236
  */
225
237
  detached?: boolean
238
+
239
+ /**
240
+ * Allow to overwrite properties of the "context" of workflows when called in tests environments.
241
+ */
242
+ context?: DeepPartial<Context>
226
243
  };
227
244
 
228
245
  /**
@@ -265,7 +282,8 @@ export type WorkflowContext<
265
282
  ( input: z.infer<InputSchema> ) => ( OutputSchema extends AnyZodSchema ? z.infer<OutputSchema> : void ) :
266
283
  () => ( OutputSchema extends AnyZodSchema ? z.infer<OutputSchema> : void ),
267
284
 
268
- /** Indicates whether the Temporal runtime suggests continuing this workflow as new.
285
+ /**
286
+ * Indicates whether the Temporal runtime suggests continuing this workflow as new.
269
287
  *
270
288
  * Use this to decide whether to `continueAsNew` before long waits or at loop boundaries.
271
289
  * Prefer returning the `continueAsNew(...)` call immediately when this becomes `true`.
@@ -275,6 +293,18 @@ export type WorkflowContext<
275
293
  * @returns True if a continue-as-new is suggested for the current run; otherwise false.
276
294
  */
277
295
  isContinueAsNewSuggested: () => boolean
296
+ },
297
+
298
+ /**
299
+ * Information about the workflow execution
300
+ */
301
+ info: {
302
+ /**
303
+ * Internal Temporal workflow id.
304
+ *
305
+ * @see {@link https://docs.temporal.io/workflow-execution/workflowid-runid#workflow-id}
306
+ */
307
+ workflowId: string
278
308
  }
279
309
  };
280
310
 
@@ -310,8 +340,10 @@ export type WorkflowFunction<
310
340
  */
311
341
  export type WorkflowFunctionWrapper<WorkflowFunction> =
312
342
  [Parameters<WorkflowFunction>[0]] extends [undefined | null] ?
313
- ( input?: undefined | null, config?: WorkflowInvocationConfiguration ) => ReturnType<WorkflowFunction> :
314
- ( input: Parameters<WorkflowFunction>[0], config?: WorkflowInvocationConfiguration ) => ReturnType<WorkflowFunction>;
343
+ ( input?: undefined | null, config?: WorkflowInvocationConfiguration<Parameters<WorkflowFunction>[1]> ) =>
344
+ ReturnType<WorkflowFunction> :
345
+ ( input: Parameters<WorkflowFunction>[0], config?: WorkflowInvocationConfiguration<Parameters<WorkflowFunction>[1]> ) =>
346
+ ReturnType<WorkflowFunction>;
315
347
 
316
348
  /**
317
349
  * Creates a workflow.
@@ -3,9 +3,41 @@ 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 { mergeActivityOptions, resolveInvocationDir, setMetadata } from '#utils';
6
+ import { deepMerge, mergeActivityOptions, resolveInvocationDir, setMetadata } from '#utils';
7
7
  import { FatalError, ValidationError } from '#errors';
8
8
 
9
+ /**
10
+ * Context instance builder
11
+ */
12
+ class Context {
13
+
14
+ /**
15
+ * Builds a new context instance
16
+ * @param {object} options - Arguments to build a new context instance
17
+ * @param {string} workflowId
18
+ * @param {function} continueAsNew
19
+ * @param {function} isContinueAsNewSuggested
20
+ * @returns {object} context
21
+ */
22
+ static build( { workflowId, continueAsNew, isContinueAsNewSuggested } ) {
23
+ return {
24
+ /**
25
+ * Control namespace: This object adds functions to interact with Temporal flow mechanisms
26
+ */
27
+ control: {
28
+ continueAsNew,
29
+ isContinueAsNewSuggested
30
+ },
31
+ /**
32
+ * Info namespace: abstracts workflowInfo()
33
+ */
34
+ info: {
35
+ workflowId
36
+ }
37
+ };
38
+ }
39
+ };
40
+
9
41
  const defaultActivityOptions = {
10
42
  startToCloseTimeout: '20m',
11
43
  retry: {
@@ -24,26 +56,26 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
24
56
  const activityOptions = mergeActivityOptions( defaultActivityOptions, options );
25
57
  const steps = proxyActivities( activityOptions );
26
58
 
27
- const wrapper = async input => {
59
+ /**
60
+ * Wraps the `fn` function of the workflow
61
+ *
62
+ * @param {unknown} input - The input, must match the inputSchema
63
+ * @param {object} extra - Workflow configurations (received directly only in unit tests)
64
+ * @returns {unknown} The result, will match the outputSchema
65
+ */
66
+ const wrapper = async ( input, extra = {} ) => {
28
67
  // this returns a plain function, for example, in unit tests
29
68
  if ( !inWorkflowContext() ) {
30
69
  validateWithSchema( inputSchema, input, `Workflow ${name} input` );
31
- const output = await fn( input );
70
+ const context = Context.build( { workflowId: 'test-workflow', continueAsNew: async () => {}, isContinueAsNewSuggested: () => false } );
71
+ const output = await fn( input, deepMerge( context, extra.context ?? {} ) );
32
72
  validateWithSchema( outputSchema, output, `Workflow ${name} output` );
33
73
  return output;
34
74
  }
35
75
 
36
76
  const { workflowId, memo, startTime } = workflowInfo();
37
77
 
38
- /* Context Object definition
39
- - Control namespace: This object adds functions to interact with Temporal flow mechanisms
40
- */
41
- const context = {
42
- control: {
43
- isContinueAsNewSuggested: () => workflowInfo().continueAsNewSuggested,
44
- continueAsNew
45
- }
46
- };
78
+ const context = Context.build( { workflowId, continueAsNew, isContinueAsNewSuggested: () => workflowInfo().continueAsNewSuggested } );
47
79
 
48
80
  // Root workflows will not have the execution context yet, since it is set here.
49
81
  const isRoot = !memo.executionContext;
@@ -63,18 +63,48 @@ export type SerializedFetchResponse = {
63
63
  };
64
64
 
65
65
  /**
66
- * Consumes and HTTP Response and serialize it to a plain object
66
+ * Consumes an HTTP `Response` and serializes it to a plain object.
67
+ *
68
+ * @param response - The response to serialize.
69
+ * @returns SerializedFetchResponse
67
70
  */
68
71
  export function serializeFetchResponse( response: Response ): SerializedFetchResponse;
69
72
 
70
73
  export type SerializedBodyAndContentType = {
71
- /** The body parsed to string if possible or kept as the types allowed in fetch's POST body */
74
+ /** The body as a string when possible; otherwise the original value */
72
75
  body: string | unknown,
73
- /** The inferred content-type */
76
+ /** The inferred `Content-Type` header value, if any */
74
77
  contentType: string | undefined
75
78
  };
76
79
 
77
80
  /**
78
- * Based on the type of a payload, serialized it to be send as the body of a fetch POST request and also infer its Content Type.
81
+ * Serializes a payload for use as a fetch POST body and infers its `Content-Type`.
82
+ *
83
+ * @param body - The payload to serialize.
84
+ * @returns The serialized body and inferred `Content-Type`.
79
85
  */
80
86
  export function serializeBodyAndInferContentType( body: unknown ): SerializedBodyAndContentType;
87
+
88
+ /**
89
+ * Returns true if the value is a plain object:
90
+ * - `{}`
91
+ * - `new Object()`
92
+ * - `Object.create(null)`
93
+ *
94
+ * @param object - The value to check.
95
+ * @returns Whether the value is a plain object.
96
+ */
97
+ export function isPlainObject( object: unknown ): boolean;
98
+
99
+ /**
100
+ * Creates a new object by merging object `b` onto object `a`, biased toward `b`:
101
+ * - Fields in `b` overwrite fields in `a`.
102
+ * - Fields in `b` that don't exist in `a` are created.
103
+ * - Fields in `a` that don't exist in `b` are left unchanged.
104
+ *
105
+ * @param a - The base object.
106
+ * @param b - The overriding object.
107
+ * @throws {Error} If either `a` or `b` is not a plain object.
108
+ * @returns A new merged object.
109
+ */
110
+ export function deepMerge( a: object, b: object ): object;
@@ -7,6 +7,18 @@ import { METADATA_ACCESS_SYMBOL } from '#consts';
7
7
  */
8
8
  export const clone = v => JSON.parse( JSON.stringify( v ) );
9
9
 
10
+ /**
11
+ * Detect a JS plain object.
12
+ *
13
+ * @param {unknown} v
14
+ * @returns {boolean}
15
+ */
16
+ export const isPlainObject = v =>
17
+ typeof v === 'object' &&
18
+ !Array.isArray( v ) &&
19
+ v !== null &&
20
+ [ Object.prototype, null ].includes( Object.getPrototypeOf( v ) );
21
+
10
22
  /**
11
23
  * Throw given error
12
24
  * @param {Error} e
@@ -149,3 +161,26 @@ export const serializeBodyAndInferContentType = payload => {
149
161
 
150
162
  return { body: String( payload ), contentType: 'text/plain; charset=UTF-8' };
151
163
  };
164
+
165
+ /**
166
+ * Creates a new object merging object "b" onto object "a" biased to "b":
167
+ * - Object "b" will overwrite fields on object "a";
168
+ * - Object "b" fields that don't exist on object "a" will be created;
169
+ * - Object "a" fields that don't exist on object "b" will not be touched;
170
+ *
171
+ *
172
+ * @param {object} a - The base object
173
+ * @param {object} b - The target object
174
+ * @returns {object} A new object
175
+ */
176
+ export const deepMerge = ( a, b ) => {
177
+ if ( !isPlainObject( a ) ) {
178
+ throw new Error( 'Parameter "a" is not an object.' );
179
+ }
180
+ if ( !isPlainObject( b ) ) {
181
+ throw new Error( 'Parameter "b" is not an object.' );
182
+ }
183
+ return Object.entries( b ).reduce( ( obj, [ k, v ] ) =>
184
+ Object.assign( obj, { [k]: isPlainObject( v ) && isPlainObject( a[k] ) ? deepMerge( a[k], v ) : v } )
185
+ , clone( a ) );
186
+ };
@@ -1,7 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { Readable } from 'node:stream';
3
- import { clone, mergeActivityOptions, serializeBodyAndInferContentType, serializeFetchResponse } from './utils.js';
4
- // Response is available globally in Node 18+ (undici)
3
+ import { clone, mergeActivityOptions, serializeBodyAndInferContentType, serializeFetchResponse, deepMerge, isPlainObject } from './utils.js';
5
4
 
6
5
  describe( 'clone', () => {
7
6
  it( 'produces a deep copy without shared references', () => {
@@ -260,3 +259,155 @@ describe( 'mergeActivityOptions', () => {
260
259
  } );
261
260
  } );
262
261
 
262
+ describe( 'deepMerge', () => {
263
+ it( 'Overwrites properties in object "a"', () => {
264
+ const a = {
265
+ a: 1,
266
+ b: {
267
+ c: 2
268
+ }
269
+ };
270
+ const b = {
271
+ a: false,
272
+ b: {
273
+ c: true
274
+ }
275
+ };
276
+ expect( deepMerge( a, b ) ).toEqual( {
277
+ a: false,
278
+ b: {
279
+ c: true
280
+ }
281
+ } );
282
+ } );
283
+
284
+ it( 'Adds properties existing in "b" but absent in "a"', () => {
285
+ const a = {
286
+ a: 1
287
+ };
288
+ const b = {
289
+ a: false,
290
+ b: true
291
+ };
292
+ expect( deepMerge( a, b ) ).toEqual( {
293
+ a: false,
294
+ b: true
295
+ } );
296
+ } );
297
+
298
+ it( 'Keep extra properties in "a"', () => {
299
+ const a = {
300
+ a: 1
301
+ };
302
+ const b = {
303
+ b: true
304
+ };
305
+ expect( deepMerge( a, b ) ).toEqual( {
306
+ a: 1,
307
+ b: true
308
+ } );
309
+ } );
310
+
311
+ it( 'Throw error on non iterable object types', () => {
312
+ expect( () => deepMerge( Function, Function ) ).toThrow( Error );
313
+ expect( () => deepMerge( () => {}, () => {} ) ).toThrow( Error );
314
+ expect( () => deepMerge( 'a', 'a' ) ).toThrow( Error );
315
+ expect( () => deepMerge( true, true ) ).toThrow( Error );
316
+ expect( () => deepMerge( /a/, /a/ ) ).toThrow( Error );
317
+ expect( () => deepMerge( [], [] ) ).toThrow( Error );
318
+ expect( () => deepMerge( class Foo {}, class Foo {} ) ).toThrow( Error );
319
+ expect( () => deepMerge( Number.constructor, Number.constructor ) ).toThrow( Error );
320
+ expect( () => deepMerge( Number.constructor.prototype, Number.constructor.prototype ) ).toThrow( Error );
321
+ } );
322
+ } );
323
+
324
+ describe( 'isPlainObject', () => {
325
+ it( 'Detects plain objects', () => {
326
+ expect( isPlainObject( {} ) ).toBe( true );
327
+ expect( isPlainObject( { a: 1 } ) ).toBe( true );
328
+ expect( isPlainObject( new Object() ) ).toBe( true );
329
+ expect( isPlainObject( new Object( { foo: 'bar' } ) ) ).toBe( true );
330
+ expect( isPlainObject( Object.create( {}.constructor.prototype ) ) ).toBe( true );
331
+ expect( isPlainObject( Object.create( Object.prototype ) ) ).toBe( true );
332
+ } );
333
+
334
+ it( 'Detects plain objects with different prototypes than Object.prototype', () => {
335
+ // Object with null prototype
336
+ expect( isPlainObject( Object.create( null ) ) ).toBe( true );
337
+ } );
338
+
339
+ it( 'Detects non plain objects that had their __proto__ mutated to Object.prototype or null', () => {
340
+ class Foo {}
341
+ const x = new Foo();
342
+ x.__proto__ = Object.prototype;
343
+ expect( isPlainObject( x ) ).toBe( true );
344
+
345
+ const y = new Foo();
346
+ y.__proto__ = null;
347
+ expect( isPlainObject( y ) ).toBe( true );
348
+ } );
349
+
350
+ it( 'Returns false for object which the prototype is not Object.prototype or null', () => {
351
+ // Object which the prototype is a plain {}
352
+ expect( isPlainObject( Object.create( {} ) ) ).toBe( false );
353
+ // Object which prototype is a another object with null prototype
354
+ expect( isPlainObject( Object.create( Object.create( null ) ) ) ).toBe( false );
355
+ } );
356
+
357
+ it( 'Returns false for functions', () => {
358
+ expect( isPlainObject( Function ) ).toBe( false );
359
+ expect( isPlainObject( () => {} ) ).toBe( false );
360
+ expect( isPlainObject( class Foo {} ) ).toBe( false );
361
+ expect( isPlainObject( Number.constructor ) ).toBe( false );
362
+ expect( isPlainObject( Number.constructor.prototype ) ).toBe( false );
363
+ } );
364
+
365
+ it( 'Returns false for arrays', () => {
366
+ expect( isPlainObject( [ 1, 2, 3 ] ) ).toBe( false );
367
+ expect( isPlainObject( [] ) ).toBe( false );
368
+ expect( isPlainObject( Array( 3 ) ) ).toBe( false );
369
+ } );
370
+
371
+ it( 'Returns false for primitives', () => {
372
+ expect( isPlainObject( false ) ).toBe( false );
373
+ expect( isPlainObject( true ) ).toBe( false );
374
+ expect( isPlainObject( 1 ) ).toBe( false );
375
+ expect( isPlainObject( 0 ) ).toBe( false );
376
+ expect( isPlainObject( '' ) ).toBe( false );
377
+ expect( isPlainObject( 'foo' ) ).toBe( false );
378
+ expect( isPlainObject( Symbol( 'foo' ) ) ).toBe( false );
379
+ expect( isPlainObject( Symbol.for( 'foo' ) ) ).toBe( false );
380
+ } );
381
+
382
+ it( 'Returns true for built in objects', () => {
383
+ expect( isPlainObject( Math ) ).toBe( true );
384
+ expect( isPlainObject( JSON ) ).toBe( true );
385
+ } );
386
+
387
+ it( 'Returns false for built in types', () => {
388
+ expect( isPlainObject( String ) ).toBe( false );
389
+ expect( isPlainObject( Number ) ).toBe( false );
390
+ expect( isPlainObject( Date ) ).toBe( false );
391
+ } );
392
+
393
+ it( 'Returns false for other instance where prototype is not object or null', () => {
394
+ expect( isPlainObject( /foo/ ) ).toBe( false );
395
+ expect( isPlainObject( new RegExp( 'foo' ) ) ).toBe( false );
396
+ expect( isPlainObject( new Date() ) ).toBe( false );
397
+ class Foo {}
398
+ expect( isPlainObject( new Foo() ) ).toBe( false );
399
+ expect( isPlainObject( Object.create( ( class Foo {} ).prototype ) ) ).toBe( false );
400
+ } );
401
+
402
+ it( 'Returns false if tries to change the prototype to simulate an object', () => {
403
+ function Bar() {}
404
+ Bar.prototype = Object.create( null );
405
+ expect( isPlainObject( new Bar() ) ).toBe( false );
406
+ } );
407
+
408
+ it( 'Returns false if object proto was mutated to anything else than object or null', () => {
409
+ const zum = {};
410
+ zum.__proto__ = Number.prototype;
411
+ expect( isPlainObject( zum ) ).toBe( false );
412
+ } );
413
+ } );
@@ -1,4 +1,4 @@
1
- import { basename, dirname, join } from 'node:path';
1
+ import { dirname, join } from 'node:path';
2
2
  import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { EOL } from 'node:os';
4
4
  import { fileURLToPath } from 'url';
@@ -35,8 +35,7 @@ const writeActivityOptionsFile = map => {
35
35
  export async function loadActivities( target ) {
36
36
  const activities = {};
37
37
  const activityOptionsMap = {};
38
- for await ( const { fn, metadata, path } of importComponents( target, [ 'steps.js', 'evaluators.js', 'shared_steps.js' ] ) ) {
39
- const isShared = basename( path ) === 'shared_steps.js';
38
+ for await ( const { fn, metadata, path, isShared } of importComponents( target, [ 'steps.js', 'evaluators.js' ] ) ) {
40
39
  const prefix = isShared ? SHARED_STEP_PREFIX : dirname( path );
41
40
 
42
41
  console.log( '[Core.Scanner]', 'Component loaded:', metadata.type, metadata.name, 'at', path );
@@ -53,6 +53,27 @@ describe( 'worker/loader', () => {
53
53
  expect( mkdirSyncMock ).toHaveBeenCalled();
54
54
  } );
55
55
 
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
+
56
77
  it( 'loadWorkflows returns array of workflows with metadata', async () => {
57
78
  const { loadWorkflows } = await import( './loader.js' );
58
79
 
@@ -7,14 +7,51 @@ 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
10
11
  */
11
12
  /**
12
13
  * @typedef {object} Component
13
14
  * @property {Function} fn - The loaded component function
14
15
  * @property {object} metadata - Associated metadata with the component
15
- * @property {string} path - Associated metadata with the component
16
+ * @property {string} path - The file path of the component
17
+ * @property {boolean} [isShared] - Whether this component is from a shared/steps/ directory
16
18
  */
17
19
 
20
+ /**
21
+ * Check if the search is targeting step or evaluator files.
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
+
18
55
  /**
19
56
  * Recursive traverse directories looking for files with given name.
20
57
  *
@@ -30,7 +67,11 @@ const findByNameRecursively = ( parentPath, filenames, collection = [], ignoreDi
30
67
 
31
68
  const path = resolve( parentPath, entry.name );
32
69
  if ( entry.isDirectory() ) {
33
- findByNameRecursively( path, filenames, collection );
70
+ if ( isSharedStepsDirectory( entry.name, parentPath ) && isSearchingForStepFiles( filenames ) ) {
71
+ collectSharedStepsFiles( path, collection );
72
+ } else {
73
+ findByNameRecursively( path, filenames, collection, ignoreDirNames );
74
+ }
34
75
  } else if ( filenames.includes( entry.name ) ) {
35
76
  collection.push( { path, url: pathToFileURL( path ).href } );
36
77
  }
@@ -50,14 +91,14 @@ const findByNameRecursively = ( parentPath, filenames, collection = [], ignoreDi
50
91
  * @yields {Component}
51
92
  */
52
93
  export async function *importComponents( target, filenames ) {
53
- for ( const { url, path } of findByNameRecursively( target, filenames ) ) {
94
+ for ( const { url, path, isShared } of findByNameRecursively( target, filenames ) ) {
54
95
  const imported = await import( url );
55
96
  for ( const fn of Object.values( imported ) ) {
56
97
  const metadata = fn[METADATA_ACCESS_SYMBOL];
57
98
  if ( !metadata ) {
58
99
  continue;
59
100
  }
60
- yield { fn, metadata, path };
101
+ yield { fn, metadata, path, isShared };
61
102
  }
62
103
  }
63
104
  };
@@ -86,4 +86,84 @@ describe( '.importComponents', () => {
86
86
 
87
87
  rmSync( root, { recursive: true, force: true } );
88
88
  } );
89
+
90
+ it( 'collects all .js files from shared/steps/ directory with isShared flag', async () => {
91
+ const root = join( process.cwd(), 'sdk/core/temp_test_modules', `meta-${Date.now()}-shared` );
92
+ const sharedStepsDir = join( root, 'shared', 'steps' );
93
+ const workflowDir = join( root, 'workflows', 'example' );
94
+ mkdirSync( sharedStepsDir, { recursive: true } );
95
+ mkdirSync( workflowDir, { recursive: true } );
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' );
143
+
144
+ const sharedFileContents = [
145
+ 'import { METADATA_ACCESS_SYMBOL } from "#consts";',
146
+ 'export const SharedStep = () => {};',
147
+ 'SharedStep[METADATA_ACCESS_SYMBOL] = { kind: "step", name: "sharedStep" };'
148
+ ].join( '\n' );
149
+ const workflowFileContents = [
150
+ 'import { METADATA_ACCESS_SYMBOL } from "#consts";',
151
+ 'export default function myWorkflow() {};',
152
+ 'myWorkflow[METADATA_ACCESS_SYMBOL] = { kind: "workflow", name: "myWorkflow" };'
153
+ ].join( '\n' );
154
+ writeFileSync( sharedToolsFile, sharedFileContents );
155
+ writeFileSync( workflowFile, workflowFileContents );
156
+
157
+ const collected = [];
158
+ for await ( const m of importComponents( root, [ 'workflow.js' ] ) ) {
159
+ collected.push( m );
160
+ }
161
+
162
+ expect( collected.length ).toBe( 1 );
163
+ expect( collected[0].path ).toBe( workflowFile );
164
+ expect( collected[0].metadata.kind ).toBe( 'workflow' );
165
+ expect( collected[0].isShared ).toBeUndefined();
166
+
167
+ rmSync( root, { recursive: true, force: true } );
168
+ } );
89
169
  } );
@@ -5,23 +5,9 @@ export const NodeType = {
5
5
  export const ComponentFile = {
6
6
  EVALUATORS: 'evaluators',
7
7
  STEPS: 'steps',
8
- SHARED_STEPS: 'shared_steps',
9
8
  WORKFLOW: 'workflow'
10
9
  };
11
10
 
12
- export const EXTRANEOUS_FILE = 'extraneous';
13
- export const ExtraneousFileList = [
14
- 'types',
15
- 'consts',
16
- 'constants',
17
- 'vars',
18
- 'variables',
19
- 'utils',
20
- 'tools',
21
- 'functions',
22
- 'shared'
23
- ];
24
-
25
11
  export const CoreModule = {
26
12
  LOCAL: 'local_core',
27
13
  NPM: '@output.ai/core'