@outputai/core 0.1.12-next.ecd4dda.0 → 0.1.13-next.6e35f5c.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/copy_assets.js +16 -0
- package/package.json +3 -2
- package/src/interface/validations/static.js +1 -0
- package/src/interface/validations/static.spec.js +31 -0
- package/src/interface/workflow.d.ts +6 -0
- package/src/interface/workflow.js +3 -3
- package/src/interface/workflow.spec.js +1 -1
- package/src/worker/catalog_workflow/catalog.js +8 -1
- package/src/worker/catalog_workflow/index.spec.js +23 -0
- package/src/worker/interceptors/activity.js +14 -2
- package/src/worker/interceptors/activity.spec.js +23 -0
- package/src/worker/loader.js +40 -1
- package/src/worker/loader.spec.js +59 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { copyFileSync, mkdirSync, globSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const srcDir = 'src';
|
|
6
|
+
const destDir = 'dist';
|
|
7
|
+
const matchers = [ '**/*.prompt', '**/*.yml.enc', '**/*.key', '**/*.md' ];
|
|
8
|
+
|
|
9
|
+
for ( const pattern of matchers ) {
|
|
10
|
+
for ( const file of globSync( pattern, { cwd: srcDir } ) ) {
|
|
11
|
+
const src = join( srcDir, file );
|
|
12
|
+
const dest = join( destDir, file );
|
|
13
|
+
mkdirSync( dirname( dest ), { recursive: true } );
|
|
14
|
+
copyFileSync( src, dest );
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13-next.6e35f5c.0",
|
|
4
4
|
"description": "The core module of the output framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"bin": {
|
|
29
29
|
"output-worker": "./bin/worker.sh",
|
|
30
30
|
"outputai": "./bin/worker.sh",
|
|
31
|
-
"output-healthcheck": "./bin/healthcheck.mjs"
|
|
31
|
+
"output-healthcheck": "./bin/healthcheck.mjs",
|
|
32
|
+
"output-copy-assets": "./bin/copy_assets.js"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"@aws-sdk/client-s3": "3.1018.0",
|
|
@@ -64,6 +64,7 @@ const baseSchema = z.strictObject( {
|
|
|
64
64
|
const stepSchema = baseSchema;
|
|
65
65
|
|
|
66
66
|
const workflowSchema = baseSchema.extend( {
|
|
67
|
+
aliases: z.array( z.string().regex( /^[a-z_][a-z0-9_]*$/i ) ).optional().default( [] ),
|
|
67
68
|
options: baseSchema.shape.options.unwrap().extend( {
|
|
68
69
|
disableTrace: z.boolean().optional().default( false )
|
|
69
70
|
} ).optional()
|
|
@@ -189,6 +189,37 @@ describe( 'interface/validator', () => {
|
|
|
189
189
|
it( 'rejects non-boolean options.disableTrace', () => {
|
|
190
190
|
expect( () => validateWorkflow( { ...validArgs, options: { disableTrace: 'yes' } } ) ).toThrow( StaticValidationError );
|
|
191
191
|
} );
|
|
192
|
+
|
|
193
|
+
it( 'passes with valid aliases array', () => {
|
|
194
|
+
expect( () => validateWorkflow( { ...validArgs, aliases: [ 'old_name', 'legacy_v1' ] } ) ).not.toThrow();
|
|
195
|
+
} );
|
|
196
|
+
|
|
197
|
+
it( 'passes with empty aliases array', () => {
|
|
198
|
+
expect( () => validateWorkflow( { ...validArgs, aliases: [] } ) ).not.toThrow();
|
|
199
|
+
} );
|
|
200
|
+
|
|
201
|
+
it( 'passes without aliases (optional)', () => {
|
|
202
|
+
expect( () => validateWorkflow( { ...validArgs } ) ).not.toThrow();
|
|
203
|
+
} );
|
|
204
|
+
|
|
205
|
+
it( 'rejects aliases with invalid name pattern', () => {
|
|
206
|
+
expect( () => validateWorkflow( { ...validArgs, aliases: [ '-bad' ] } ) ).toThrow( StaticValidationError );
|
|
207
|
+
} );
|
|
208
|
+
|
|
209
|
+
it( 'rejects non-array aliases', () => {
|
|
210
|
+
expect( () => validateWorkflow( { ...validArgs, aliases: 'not_array' } ) ).toThrow( StaticValidationError );
|
|
211
|
+
} );
|
|
212
|
+
} );
|
|
213
|
+
|
|
214
|
+
describe( 'aliases rejected on steps and evaluators', () => {
|
|
215
|
+
it( 'validateStep rejects aliases due to strictObject', () => {
|
|
216
|
+
expect( () => validateStep( { ...validArgs, aliases: [ 'alias' ] } ) ).toThrow( StaticValidationError );
|
|
217
|
+
} );
|
|
218
|
+
|
|
219
|
+
it( 'validateEvaluator rejects aliases due to strictObject', () => {
|
|
220
|
+
const { outputSchema, ...evalArgs } = validArgs;
|
|
221
|
+
expect( () => validateEvaluator( { ...evalArgs, aliases: [ 'alias' ] } ) ).toThrow( StaticValidationError );
|
|
222
|
+
} );
|
|
192
223
|
} );
|
|
193
224
|
|
|
194
225
|
describe( 'validateEvaluator', () => {
|
|
@@ -270,4 +270,10 @@ export declare function workflow<
|
|
|
270
270
|
outputSchema?: OutputSchema;
|
|
271
271
|
fn: WorkflowFunction<InputSchema, OutputSchema>;
|
|
272
272
|
options?: WorkflowOptions;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Alternative names that resolve to this workflow. Useful when renaming a workflow
|
|
276
|
+
* while maintaining backward compatibility with existing callers.
|
|
277
|
+
*/
|
|
278
|
+
aliases?: string[];
|
|
273
279
|
} ): WorkflowFunctionWrapper<WorkflowFunction<InputSchema, OutputSchema>>;
|
|
@@ -22,8 +22,8 @@ const defaultOptions = {
|
|
|
22
22
|
disableTrace: false
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
export function workflow( { name, description, inputSchema, outputSchema, fn, options = {} } ) {
|
|
26
|
-
validateWorkflow( { name, description, inputSchema, outputSchema, fn, options } );
|
|
25
|
+
export function workflow( { name, description, inputSchema, outputSchema, fn, options = {}, aliases = [] } ) {
|
|
26
|
+
validateWorkflow( { name, description, inputSchema, outputSchema, fn, options, aliases } );
|
|
27
27
|
|
|
28
28
|
const { disableTrace, activityOptions } = deepMerge( defaultOptions, options );
|
|
29
29
|
const steps = proxyActivities( activityOptions );
|
|
@@ -123,6 +123,6 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
|
|
|
123
123
|
}
|
|
124
124
|
};
|
|
125
125
|
|
|
126
|
-
setMetadata( wrapper, { name, description, inputSchema, outputSchema } );
|
|
126
|
+
setMetadata( wrapper, { name, description, inputSchema, outputSchema, aliases } );
|
|
127
127
|
return wrapper;
|
|
128
128
|
};
|
|
@@ -140,7 +140,7 @@ describe( 'workflow()', () => {
|
|
|
140
140
|
const symbols = Object.getOwnPropertySymbols( wf );
|
|
141
141
|
expect( symbols ).toHaveLength( 1 );
|
|
142
142
|
const meta = wf[symbols[0]];
|
|
143
|
-
expect( meta ).toEqual( { name: 'meta_wf', description: 'Meta workflow', inputSchema, outputSchema } );
|
|
143
|
+
expect( meta ).toEqual( { name: 'meta_wf', description: 'Meta workflow', inputSchema, outputSchema, aliases: [] } );
|
|
144
144
|
} );
|
|
145
145
|
} );
|
|
146
146
|
|
|
@@ -97,6 +97,11 @@ export class CatalogWorkflow extends CatalogEntry {
|
|
|
97
97
|
* @type {Array<CatalogActivity>}
|
|
98
98
|
*/
|
|
99
99
|
activities;
|
|
100
|
+
/**
|
|
101
|
+
* Alternative names that resolve to this workflow.
|
|
102
|
+
* @type {Array<string>}
|
|
103
|
+
*/
|
|
104
|
+
aliases;
|
|
100
105
|
|
|
101
106
|
/**
|
|
102
107
|
* @param {Object} params - Entry parameters.
|
|
@@ -106,9 +111,11 @@ export class CatalogWorkflow extends CatalogEntry {
|
|
|
106
111
|
* @param {object} [params.outputSchema] - JSON schema describing the produced output.
|
|
107
112
|
* @param {string} params.path - Absolute path of the entity in the file system.
|
|
108
113
|
* @param {Array<CatalogActivity>} params.activities - Each activity of this workflow
|
|
114
|
+
* @param {Array<string>} [params.aliases] - Alternative names for this workflow
|
|
109
115
|
*/
|
|
110
|
-
constructor( { activities, ...args } ) {
|
|
116
|
+
constructor( { activities, aliases = [], ...args } ) {
|
|
111
117
|
super( args );
|
|
112
118
|
this.activities = activities;
|
|
119
|
+
this.aliases = aliases;
|
|
113
120
|
};
|
|
114
121
|
};
|
|
@@ -193,4 +193,27 @@ describe( 'createCatalog', () => {
|
|
|
193
193
|
expect( workflows[0].path ).toBe( '/flows/flow1/workflow.js' );
|
|
194
194
|
expect( workflows[1].path ).toBe( '/flows/flow2/workflow.js' );
|
|
195
195
|
} );
|
|
196
|
+
|
|
197
|
+
it( 'includes aliases in catalog workflow entries', async () => {
|
|
198
|
+
const { createCatalog } = await import( './index.js' );
|
|
199
|
+
|
|
200
|
+
const workflows = [
|
|
201
|
+
{
|
|
202
|
+
name: 'flow1',
|
|
203
|
+
path: '/flows/flow1/workflow.js',
|
|
204
|
+
description: 'desc-flow1',
|
|
205
|
+
aliases: [ 'flow1_old', 'flow1_legacy' ]
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'flow2',
|
|
209
|
+
path: '/flows/flow2/workflow.js',
|
|
210
|
+
description: 'desc-flow2'
|
|
211
|
+
}
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const catalog = createCatalog( { workflows, activities: {} } );
|
|
215
|
+
|
|
216
|
+
expect( catalog.workflows[0].aliases ).toEqual( [ 'flow1_old', 'flow1_legacy' ] );
|
|
217
|
+
expect( catalog.workflows[1].aliases ).toEqual( [] );
|
|
218
|
+
} );
|
|
196
219
|
} );
|
|
@@ -25,7 +25,13 @@ import { messageBus } from '#bus';
|
|
|
25
25
|
export class ActivityExecutionInterceptor {
|
|
26
26
|
constructor( { activities, workflows } ) {
|
|
27
27
|
this.activities = activities;
|
|
28
|
-
this.workflowsMap = workflows.reduce( ( map, w ) =>
|
|
28
|
+
this.workflowsMap = workflows.reduce( ( map, w ) => {
|
|
29
|
+
map.set( w.name, w );
|
|
30
|
+
for ( const alias of w.aliases ?? [] ) {
|
|
31
|
+
map.set( alias, w );
|
|
32
|
+
}
|
|
33
|
+
return map;
|
|
34
|
+
}, new Map() );
|
|
29
35
|
};
|
|
30
36
|
|
|
31
37
|
async execute( input, next ) {
|
|
@@ -33,11 +39,17 @@ export class ActivityExecutionInterceptor {
|
|
|
33
39
|
const { workflowExecution: { workflowId }, activityId: id, activityType: name, workflowType: workflowName } = Context.current().info;
|
|
34
40
|
const { executionContext } = headersToObject( input.headers );
|
|
35
41
|
const { type: kind } = this.activities?.[name]?.[METADATA_ACCESS_SYMBOL];
|
|
36
|
-
const workflowFilename = this.workflowsMap.get( workflowName ).path;
|
|
37
42
|
|
|
38
43
|
messageBus.emit( BusEventType.ACTIVITY_START, { id, name, kind, workflowId, workflowName } );
|
|
39
44
|
Tracing.addEventStart( { id, name, kind, parentId: workflowId, details: input.args[0], executionContext } );
|
|
40
45
|
|
|
46
|
+
const workflowEntry = this.workflowsMap.get( workflowName );
|
|
47
|
+
if ( !workflowEntry ) {
|
|
48
|
+
const availableWorkflows = [ ...this.workflowsMap.keys() ].join( ', ' );
|
|
49
|
+
throw new Error( `Activity interceptor: workflow "${workflowName}" not found in workflowsMap. Available: [${availableWorkflows}]` );
|
|
50
|
+
}
|
|
51
|
+
const workflowFilename = workflowEntry.path;
|
|
52
|
+
|
|
41
53
|
const intervals = { heartbeat: null };
|
|
42
54
|
try {
|
|
43
55
|
// Sends heartbeat to communicate that activity is still alive
|
|
@@ -191,6 +191,29 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
191
191
|
expect( heartbeatMock ).not.toHaveBeenCalled();
|
|
192
192
|
} );
|
|
193
193
|
|
|
194
|
+
it( 'resolves workflow alias in workflowsMap', async () => {
|
|
195
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
196
|
+
const workflows = [ { name: 'myWorkflow', path: '/workflows/myWorkflow.js', aliases: [ 'myWorkflowOld' ] } ];
|
|
197
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows } );
|
|
198
|
+
|
|
199
|
+
// Override context to use alias as workflowType
|
|
200
|
+
contextInfoMock.workflowType = 'myWorkflowOld';
|
|
201
|
+
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
202
|
+
|
|
203
|
+
const promise = interceptor.execute( makeInput(), next );
|
|
204
|
+
vi.advanceTimersByTime( 0 );
|
|
205
|
+
await promise;
|
|
206
|
+
|
|
207
|
+
// Should resolve to the correct path despite using the alias
|
|
208
|
+
expect( runWithContextMock ).toHaveBeenCalledWith(
|
|
209
|
+
expect.any( Function ),
|
|
210
|
+
expect.objectContaining( { workflowFilename: '/workflows/myWorkflow.js' } )
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Restore for other tests
|
|
214
|
+
contextInfoMock.workflowType = 'myWorkflow';
|
|
215
|
+
} );
|
|
216
|
+
|
|
194
217
|
it( 'does not heartbeat when OUTPUT_ACTIVITY_HEARTBEAT_ENABLED is false', async () => {
|
|
195
218
|
vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED', 'false' );
|
|
196
219
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
package/src/worker/loader.js
CHANGED
|
@@ -132,6 +132,40 @@ export async function loadHooks( rootDir ) {
|
|
|
132
132
|
}
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Validates that all workflow names and aliases are unique across the project.
|
|
137
|
+
*
|
|
138
|
+
* @param {object[]} workflows
|
|
139
|
+
* @throws {Error} If any alias conflicts with a workflow name or another alias
|
|
140
|
+
*/
|
|
141
|
+
function validateWorkflowNames( workflows ) {
|
|
142
|
+
const allNames = new Map();
|
|
143
|
+
|
|
144
|
+
// Register primary names (case-insensitive to prevent confusing collisions)
|
|
145
|
+
for ( const { name } of workflows ) {
|
|
146
|
+
allNames.set( name.toLowerCase(), `workflow "${name}"` );
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check the reserved catalog name
|
|
150
|
+
allNames.set( WORKFLOW_CATALOG.toLowerCase(), 'system workflow "$catalog"' );
|
|
151
|
+
|
|
152
|
+
// Check aliases against all names
|
|
153
|
+
for ( const { name, aliases = [] } of workflows ) {
|
|
154
|
+
const lowerCaseName = name.toLowerCase();
|
|
155
|
+
for ( const alias of aliases ) {
|
|
156
|
+
const lowerAliasName = alias.toLowerCase();
|
|
157
|
+
if ( lowerAliasName === lowerCaseName ) {
|
|
158
|
+
throw new Error( `Workflow "${name}" has an alias identical to its own name` );
|
|
159
|
+
}
|
|
160
|
+
const conflict = allNames.get( lowerAliasName );
|
|
161
|
+
if ( conflict ) {
|
|
162
|
+
throw new Error( `Alias "${alias}" on workflow "${name}" conflicts with ${conflict}` );
|
|
163
|
+
}
|
|
164
|
+
allNames.set( lowerAliasName, `alias "${alias}" on workflow "${name}"` );
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
/**
|
|
136
170
|
* Creates a temporary index file importing all workflows for Temporal.
|
|
137
171
|
*
|
|
@@ -139,11 +173,16 @@ export async function loadHooks( rootDir ) {
|
|
|
139
173
|
* @returns
|
|
140
174
|
*/
|
|
141
175
|
export function createWorkflowsEntryPoint( workflows ) {
|
|
176
|
+
validateWorkflowNames( workflows );
|
|
177
|
+
|
|
142
178
|
const path = join( __dirname, 'temp', WORKFLOWS_INDEX_FILENAME );
|
|
143
179
|
|
|
144
180
|
// default system catalog workflow
|
|
145
181
|
const catalog = { name: WORKFLOW_CATALOG, path: join( __dirname, './catalog_workflow/workflow.js' ) };
|
|
146
|
-
const
|
|
182
|
+
const aliasExports = workflows.flatMap( ( { aliases = [], path } ) =>
|
|
183
|
+
aliases.map( alias => ( { name: alias, path } ) )
|
|
184
|
+
);
|
|
185
|
+
const content = [ ...workflows, ...aliasExports, catalog ].map( ( { name, path } ) => `export { default as ${name} } from '${path}';` ).join( EOL );
|
|
147
186
|
|
|
148
187
|
mkdirSync( dirname( path ), { recursive: true } );
|
|
149
188
|
writeFileSync( path, content, 'utf-8' );
|
|
@@ -111,6 +111,65 @@ describe( 'worker/loader', () => {
|
|
|
111
111
|
expect( fsMocks.mkdirSync ).toHaveBeenCalledTimes( 1 );
|
|
112
112
|
} );
|
|
113
113
|
|
|
114
|
+
it( 'createWorkflowsEntryPoint generates alias exports', async () => {
|
|
115
|
+
const { createWorkflowsEntryPoint } = await import( './loader.js' );
|
|
116
|
+
|
|
117
|
+
const workflows = [ { name: 'W', path: '/abs/wf.js', aliases: [ 'W_old', 'W_legacy' ] } ];
|
|
118
|
+
createWorkflowsEntryPoint( workflows );
|
|
119
|
+
|
|
120
|
+
const [ , contents ] = fsMocks.writeFileSync.mock.calls[0];
|
|
121
|
+
expect( contents ).toContain( 'export { default as W } from \'/abs/wf.js\';' );
|
|
122
|
+
expect( contents ).toContain( 'export { default as W_old } from \'/abs/wf.js\';' );
|
|
123
|
+
expect( contents ).toContain( 'export { default as W_legacy } from \'/abs/wf.js\';' );
|
|
124
|
+
} );
|
|
125
|
+
|
|
126
|
+
it( 'createWorkflowsEntryPoint throws on alias conflicting with primary name', async () => {
|
|
127
|
+
const { createWorkflowsEntryPoint } = await import( './loader.js' );
|
|
128
|
+
|
|
129
|
+
const workflows = [
|
|
130
|
+
{ name: 'alpha', path: '/a.js', aliases: [] },
|
|
131
|
+
{ name: 'beta', path: '/b.js', aliases: [ 'alpha' ] }
|
|
132
|
+
];
|
|
133
|
+
expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "alpha" on workflow "beta" conflicts with workflow "alpha"/ );
|
|
134
|
+
} );
|
|
135
|
+
|
|
136
|
+
it( 'createWorkflowsEntryPoint throws on alias conflicting with another alias', async () => {
|
|
137
|
+
const { createWorkflowsEntryPoint } = await import( './loader.js' );
|
|
138
|
+
|
|
139
|
+
const workflows = [
|
|
140
|
+
{ name: 'alpha', path: '/a.js', aliases: [ 'shared_alias' ] },
|
|
141
|
+
{ name: 'beta', path: '/b.js', aliases: [ 'shared_alias' ] }
|
|
142
|
+
];
|
|
143
|
+
expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "shared_alias" on workflow "beta" conflicts with/ );
|
|
144
|
+
} );
|
|
145
|
+
|
|
146
|
+
it( 'createWorkflowsEntryPoint throws on alias identical to own name', async () => {
|
|
147
|
+
const { createWorkflowsEntryPoint } = await import( './loader.js' );
|
|
148
|
+
|
|
149
|
+
const workflows = [ { name: 'alpha', path: '/a.js', aliases: [ 'alpha' ] } ];
|
|
150
|
+
expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Workflow "alpha" has an alias identical to its own name/ );
|
|
151
|
+
} );
|
|
152
|
+
|
|
153
|
+
it( 'createWorkflowsEntryPoint catches case-insensitive alias collision with primary name', async () => {
|
|
154
|
+
const { createWorkflowsEntryPoint } = await import( './loader.js' );
|
|
155
|
+
|
|
156
|
+
const workflows = [
|
|
157
|
+
{ name: 'Alpha', path: '/a.js', aliases: [] },
|
|
158
|
+
{ name: 'beta', path: '/b.js', aliases: [ 'alpha' ] }
|
|
159
|
+
];
|
|
160
|
+
expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "alpha" on workflow "beta" conflicts with workflow "Alpha"/ );
|
|
161
|
+
} );
|
|
162
|
+
|
|
163
|
+
it( 'createWorkflowsEntryPoint catches case-insensitive alias-to-alias collision', async () => {
|
|
164
|
+
const { createWorkflowsEntryPoint } = await import( './loader.js' );
|
|
165
|
+
|
|
166
|
+
const workflows = [
|
|
167
|
+
{ name: 'alpha', path: '/a.js', aliases: [ 'Legacy' ] },
|
|
168
|
+
{ name: 'beta', path: '/b.js', aliases: [ 'legacy' ] }
|
|
169
|
+
];
|
|
170
|
+
expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "legacy" on workflow "beta" conflicts with/ );
|
|
171
|
+
} );
|
|
172
|
+
|
|
114
173
|
it( 'loadActivities uses folder-based matchers for steps/evaluators and shared', async () => {
|
|
115
174
|
const { loadActivities } = await import( './loader.js' );
|
|
116
175
|
// First call (workflow dir): no results
|