@output.ai/cli 0.7.13 → 0.7.14
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/dist/api/generated/api.d.ts +2 -0
- package/dist/commands/workflow/list.d.ts +1 -0
- package/dist/commands/workflow/list.js +10 -12
- package/dist/commands/workflow/{list.test.js → list.spec.js} +16 -0
- package/dist/commands/workflow/run.d.ts +2 -1
- package/dist/commands/workflow/run.js +10 -5
- package/dist/commands/workflow/{run.test.js → run.spec.js} +6 -1
- package/dist/commands/workflow/start.d.ts +2 -1
- package/dist/commands/workflow/start.js +9 -4
- package/dist/commands/workflow/{start.test.js → start.spec.js} +6 -1
- package/dist/generated/sdk_versions.json +1 -1
- package/dist/services/messages.js +2 -2
- package/dist/templates/project/README.md.template +1 -1
- package/dist/utils/resolve_input.d.ts +1 -0
- package/dist/utils/resolve_input.js +22 -0
- package/dist/utils/scenario_resolver.d.ts +9 -0
- package/dist/utils/scenario_resolver.js +92 -0
- package/dist/utils/scenario_resolver.spec.d.ts +1 -0
- package/dist/utils/scenario_resolver.spec.js +214 -0
- package/package.json +1 -1
- /package/dist/commands/workflow/{list.test.d.ts → list.spec.d.ts} +0 -0
- /package/dist/commands/workflow/{result.test.d.ts → result.spec.d.ts} +0 -0
- /package/dist/commands/workflow/{result.test.js → result.spec.js} +0 -0
- /package/dist/commands/workflow/{run.test.d.ts → run.spec.d.ts} +0 -0
- /package/dist/commands/workflow/{start.test.d.ts → start.spec.d.ts} +0 -0
- /package/dist/commands/workflow/{status.test.d.ts → status.spec.d.ts} +0 -0
- /package/dist/commands/workflow/{status.test.js → status.spec.js} +0 -0
- /package/dist/commands/workflow/{stop.test.d.ts → stop.spec.d.ts} +0 -0
- /package/dist/commands/workflow/{stop.test.js → stop.spec.js} +0 -0
|
@@ -3,6 +3,7 @@ import Table from 'cli-table3';
|
|
|
3
3
|
import { getWorkflowCatalog } from '#api/generated/api.js';
|
|
4
4
|
import { parseWorkflowDefinition, formatParameters } from '#api/parser.js';
|
|
5
5
|
import { handleApiError } from '#utils/error_handler.js';
|
|
6
|
+
import { listScenariosForWorkflow } from '#utils/scenario_resolver.js';
|
|
6
7
|
const OUTPUT_FORMAT = {
|
|
7
8
|
LIST: 'list',
|
|
8
9
|
TABLE: 'table',
|
|
@@ -10,11 +11,13 @@ const OUTPUT_FORMAT = {
|
|
|
10
11
|
};
|
|
11
12
|
export function parseWorkflowForDisplay(workflow) {
|
|
12
13
|
const parsed = parseWorkflowDefinition(workflow);
|
|
14
|
+
const scenarioNames = listScenariosForWorkflow(workflow.name, workflow.path);
|
|
13
15
|
return {
|
|
14
16
|
name: parsed.name,
|
|
15
17
|
description: parsed.description || 'No description',
|
|
16
18
|
inputs: formatParameters(parsed.inputs),
|
|
17
|
-
outputs: formatParameters(parsed.outputs)
|
|
19
|
+
outputs: formatParameters(parsed.outputs),
|
|
20
|
+
scenarios: scenarioNames.length > 0 ? scenarioNames.join(', ') : 'none'
|
|
18
21
|
};
|
|
19
22
|
}
|
|
20
23
|
function caseInsensitiveIncludes(str, filter) {
|
|
@@ -35,8 +38,8 @@ function sortWorkflowsByName(workflows) {
|
|
|
35
38
|
}
|
|
36
39
|
function createWorkflowTable(workflows, detailed) {
|
|
37
40
|
const table = new Table({
|
|
38
|
-
head: ['Name', 'Description', 'Inputs', 'Outputs'],
|
|
39
|
-
colWidths: detailed ? [
|
|
41
|
+
head: ['Name', 'Description', 'Inputs', 'Outputs', 'Scenarios'],
|
|
42
|
+
colWidths: detailed ? [30, 42, 42, 42, 60] : [24, 30, 24, 24, 48],
|
|
40
43
|
wordWrap: true,
|
|
41
44
|
style: {
|
|
42
45
|
head: ['cyan']
|
|
@@ -48,17 +51,11 @@ function createWorkflowTable(workflows, detailed) {
|
|
|
48
51
|
if (detailed) {
|
|
49
52
|
const inputs = display.inputs.split(', ').join('\n');
|
|
50
53
|
const outputs = display.outputs.split(', ').join('\n');
|
|
51
|
-
|
|
54
|
+
const scenarios = display.scenarios.split(', ').join('\n');
|
|
55
|
+
table.push([display.name, display.description, inputs, outputs, scenarios]);
|
|
52
56
|
}
|
|
53
57
|
else {
|
|
54
|
-
|
|
55
|
-
const inputs = display.inputs.length > maxLen ?
|
|
56
|
-
display.inputs.substring(0, maxLen) + '...' :
|
|
57
|
-
display.inputs;
|
|
58
|
-
const outputs = display.outputs.length > maxLen ?
|
|
59
|
-
display.outputs.substring(0, maxLen) + '...' :
|
|
60
|
-
display.outputs;
|
|
61
|
-
table.push([display.name, display.description, inputs, outputs]);
|
|
58
|
+
table.push([display.name, display.description, display.inputs, display.outputs, display.scenarios]);
|
|
62
59
|
}
|
|
63
60
|
});
|
|
64
61
|
return table.toString();
|
|
@@ -77,6 +74,7 @@ function formatWorkflowsAsJson(workflows) {
|
|
|
77
74
|
description: display.description,
|
|
78
75
|
inputs: display.inputs.split(', '),
|
|
79
76
|
outputs: display.outputs.split(', '),
|
|
77
|
+
scenarios: display.scenarios === 'none' ? [] : display.scenarios.split(', '),
|
|
80
78
|
raw: w
|
|
81
79
|
};
|
|
82
80
|
})
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
const mockListScenarios = vi.fn().mockReturnValue([]);
|
|
3
|
+
vi.mock('#utils/scenario_resolver.js', () => ({
|
|
4
|
+
listScenariosForWorkflow: mockListScenarios
|
|
5
|
+
}));
|
|
2
6
|
describe('workflow list command', () => {
|
|
3
7
|
beforeEach(() => {
|
|
4
8
|
vi.clearAllMocks();
|
|
@@ -47,6 +51,7 @@ describe('workflow list parsing', () => {
|
|
|
47
51
|
expect(parsed.inputs).toContain('message: string');
|
|
48
52
|
expect(parsed.inputs).toContain('count: number?');
|
|
49
53
|
expect(parsed.outputs).toContain('result: string?');
|
|
54
|
+
expect(parsed.scenarios).toBe('none');
|
|
50
55
|
});
|
|
51
56
|
it('should handle workflows without schemas', async () => {
|
|
52
57
|
const { parseWorkflowForDisplay } = await import('./list.js');
|
|
@@ -58,6 +63,17 @@ describe('workflow list parsing', () => {
|
|
|
58
63
|
expect(parsed.name).toBe('simple-workflow');
|
|
59
64
|
expect(parsed.inputs).toBe('none');
|
|
60
65
|
expect(parsed.outputs).toBe('none');
|
|
66
|
+
expect(parsed.scenarios).toBe('none');
|
|
67
|
+
});
|
|
68
|
+
it('should include scenario names when scenarios exist', async () => {
|
|
69
|
+
mockListScenarios.mockReturnValueOnce(['basic', 'advanced', 'stress_test']);
|
|
70
|
+
const { parseWorkflowForDisplay } = await import('./list.js');
|
|
71
|
+
const mockWorkflow = {
|
|
72
|
+
name: 'workflow-with-scenarios',
|
|
73
|
+
description: 'Has scenarios'
|
|
74
|
+
};
|
|
75
|
+
const parsed = parseWorkflowForDisplay(mockWorkflow);
|
|
76
|
+
expect(parsed.scenarios).toBe('basic, advanced, stress_test');
|
|
61
77
|
});
|
|
62
78
|
it('should format nested parameters correctly', async () => {
|
|
63
79
|
const { parseWorkflowForDisplay } = await import('./list.js');
|
|
@@ -4,9 +4,10 @@ export default class WorkflowRun extends Command {
|
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static args: {
|
|
6
6
|
workflowName: import("@oclif/core/lib/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
scenario: import("@oclif/core/lib/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
7
8
|
};
|
|
8
9
|
static flags: {
|
|
9
|
-
input: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
10
|
+
input: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
10
11
|
'task-queue': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
11
12
|
format: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
12
13
|
};
|
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
2
|
import { postWorkflowRun } from '#api/generated/api.js';
|
|
3
3
|
import { OUTPUT_FORMAT } from '#utils/constants.js';
|
|
4
|
-
import { parseInputFlag } from '#utils/input_parser.js';
|
|
5
4
|
import { formatOutput } from '#utils/output_formatter.js';
|
|
6
5
|
import { handleApiError } from '#utils/error_handler.js';
|
|
6
|
+
import { resolveInput } from '#utils/resolve_input.js';
|
|
7
7
|
export default class WorkflowRun extends Command {
|
|
8
8
|
static description = 'Execute a workflow synchronously and wait for completion';
|
|
9
9
|
static examples = [
|
|
10
|
+
'<%= config.bin %> <%= command.id %> simple basic_input',
|
|
11
|
+
'<%= config.bin %> <%= command.id %> simple my_scenario --format json',
|
|
10
12
|
'<%= config.bin %> <%= command.id %> simple --input \'{"values":[1,2,3]}\'',
|
|
11
13
|
'<%= config.bin %> <%= command.id %> simple --input input.json',
|
|
12
|
-
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --format json',
|
|
13
14
|
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --task-queue my-queue'
|
|
14
15
|
];
|
|
15
16
|
static args = {
|
|
16
17
|
workflowName: Args.string({
|
|
17
18
|
description: 'Name of the workflow to execute',
|
|
18
19
|
required: true
|
|
20
|
+
}),
|
|
21
|
+
scenario: Args.string({
|
|
22
|
+
description: 'Scenario name (resolved from the workflow\'s scenarios/ directory)',
|
|
23
|
+
required: false
|
|
19
24
|
})
|
|
20
25
|
};
|
|
21
26
|
static flags = {
|
|
22
27
|
input: Flags.string({
|
|
23
28
|
char: 'i',
|
|
24
|
-
description: 'Workflow input as JSON string or file path',
|
|
25
|
-
required:
|
|
29
|
+
description: 'Workflow input as JSON string or file path (overrides scenario)',
|
|
30
|
+
required: false
|
|
26
31
|
}),
|
|
27
32
|
'task-queue': Flags.string({
|
|
28
33
|
char: 'q',
|
|
@@ -37,7 +42,7 @@ export default class WorkflowRun extends Command {
|
|
|
37
42
|
};
|
|
38
43
|
async run() {
|
|
39
44
|
const { args, flags } = await this.parse(WorkflowRun);
|
|
40
|
-
const input =
|
|
45
|
+
const input = await resolveInput(args.workflowName, args.scenario, flags.input, 'run');
|
|
41
46
|
this.log(`Executing workflow: ${args.workflowName}...`);
|
|
42
47
|
const response = await postWorkflowRun({
|
|
43
48
|
workflowName: args.workflowName,
|
|
@@ -20,7 +20,12 @@ describe('workflow run command', () => {
|
|
|
20
20
|
const WorkflowRun = (await import('./run.js')).default;
|
|
21
21
|
expect(WorkflowRun.flags.format.options).toEqual(['json', 'text']);
|
|
22
22
|
expect(WorkflowRun.flags.format.default).toBe('text');
|
|
23
|
-
expect(WorkflowRun.flags.input.required).toBe(
|
|
23
|
+
expect(WorkflowRun.flags.input.required).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it('should have optional scenario argument', async () => {
|
|
26
|
+
const WorkflowRun = (await import('./run.js')).default;
|
|
27
|
+
expect(WorkflowRun.args).toHaveProperty('scenario');
|
|
28
|
+
expect(WorkflowRun.args.scenario.required).toBe(false);
|
|
24
29
|
});
|
|
25
30
|
});
|
|
26
31
|
});
|
|
@@ -4,9 +4,10 @@ export default class WorkflowStart extends Command {
|
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static args: {
|
|
6
6
|
workflowName: import("@oclif/core/lib/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
scenario: import("@oclif/core/lib/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
7
8
|
};
|
|
8
9
|
static flags: {
|
|
9
|
-
input: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
10
|
+
input: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
10
11
|
'task-queue': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
11
12
|
};
|
|
12
13
|
run(): Promise<void>;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
2
|
import { postWorkflowStart } from '#api/generated/api.js';
|
|
3
|
-
import { parseInputFlag } from '#utils/input_parser.js';
|
|
4
3
|
import { handleApiError } from '#utils/error_handler.js';
|
|
4
|
+
import { resolveInput } from '#utils/resolve_input.js';
|
|
5
5
|
export default class WorkflowStart extends Command {
|
|
6
6
|
static description = 'Start a workflow asynchronously without waiting for completion';
|
|
7
7
|
static examples = [
|
|
8
|
+
'<%= config.bin %> <%= command.id %> simple basic_input',
|
|
8
9
|
'<%= config.bin %> <%= command.id %> simple --input \'{"values":[1,2,3]}\'',
|
|
9
10
|
'<%= config.bin %> <%= command.id %> simple --input input.json',
|
|
10
11
|
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --task-queue my-queue'
|
|
@@ -13,13 +14,17 @@ export default class WorkflowStart extends Command {
|
|
|
13
14
|
workflowName: Args.string({
|
|
14
15
|
description: 'Name of the workflow to start',
|
|
15
16
|
required: true
|
|
17
|
+
}),
|
|
18
|
+
scenario: Args.string({
|
|
19
|
+
description: 'Scenario name (resolved from the workflow\'s scenarios/ directory)',
|
|
20
|
+
required: false
|
|
16
21
|
})
|
|
17
22
|
};
|
|
18
23
|
static flags = {
|
|
19
24
|
input: Flags.string({
|
|
20
25
|
char: 'i',
|
|
21
|
-
description: 'Workflow input as JSON string or file path',
|
|
22
|
-
required:
|
|
26
|
+
description: 'Workflow input as JSON string or file path (overrides scenario)',
|
|
27
|
+
required: false
|
|
23
28
|
}),
|
|
24
29
|
'task-queue': Flags.string({
|
|
25
30
|
char: 'q',
|
|
@@ -28,7 +33,7 @@ export default class WorkflowStart extends Command {
|
|
|
28
33
|
};
|
|
29
34
|
async run() {
|
|
30
35
|
const { args, flags } = await this.parse(WorkflowStart);
|
|
31
|
-
const input =
|
|
36
|
+
const input = await resolveInput(args.workflowName, args.scenario, flags.input, 'start');
|
|
32
37
|
this.log(`Starting workflow: ${args.workflowName}...`);
|
|
33
38
|
const response = await postWorkflowStart({
|
|
34
39
|
workflowName: args.workflowName,
|
|
@@ -17,7 +17,12 @@ describe('workflow start command', () => {
|
|
|
17
17
|
});
|
|
18
18
|
it('should have correct flag configuration', async () => {
|
|
19
19
|
const WorkflowStart = (await import('./start.js')).default;
|
|
20
|
-
expect(WorkflowStart.flags.input.required).toBe(
|
|
20
|
+
expect(WorkflowStart.flags.input.required).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
it('should have optional scenario argument', async () => {
|
|
23
|
+
const WorkflowStart = (await import('./start.js')).default;
|
|
24
|
+
expect(WorkflowStart.args).toHaveProperty('scenario');
|
|
25
|
+
expect(WorkflowStart.args.scenario.required).toBe(false);
|
|
21
26
|
});
|
|
22
27
|
});
|
|
23
28
|
});
|
|
@@ -177,7 +177,7 @@ export const getProjectSuccessMessage = (folderName, installSuccess, envConfigur
|
|
|
177
177
|
note: 'Launches Temporal, Redis, PostgreSQL, API, Worker, and UI'
|
|
178
178
|
}, {
|
|
179
179
|
step: 'Run example workflow',
|
|
180
|
-
command: 'npx output workflow run blog_evaluator
|
|
180
|
+
command: 'npx output workflow run blog_evaluator paulgraham_hwh',
|
|
181
181
|
note: 'Execute in a new terminal after services are running'
|
|
182
182
|
}, {
|
|
183
183
|
step: 'Monitor workflows',
|
|
@@ -321,7 +321,7 @@ ${createSectionHeader('RUN A WORKFLOW', '🚀')}
|
|
|
321
321
|
|
|
322
322
|
${ux.colorize('white', 'In a new terminal, execute:')}
|
|
323
323
|
|
|
324
|
-
${formatCommand('npx output workflow run blog_evaluator
|
|
324
|
+
${formatCommand('npx output workflow run blog_evaluator paulgraham_hwh')}
|
|
325
325
|
|
|
326
326
|
${divider}
|
|
327
327
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resolveInput(workflowName: string, scenario: string | undefined, inputFlag: string | undefined, commandName: string): Promise<unknown>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ux } from '@oclif/core';
|
|
2
|
+
import { parseInputFlag } from '#utils/input_parser.js';
|
|
3
|
+
import { resolveScenarioPath, getScenarioNotFoundMessage } from '#utils/scenario_resolver.js';
|
|
4
|
+
export async function resolveInput(workflowName, scenario, inputFlag, commandName) {
|
|
5
|
+
if (inputFlag && scenario) {
|
|
6
|
+
return ux.error('Cannot use both scenario argument and --input flag. Choose one.', { exit: 1 });
|
|
7
|
+
}
|
|
8
|
+
if (inputFlag) {
|
|
9
|
+
return parseInputFlag(inputFlag);
|
|
10
|
+
}
|
|
11
|
+
if (scenario) {
|
|
12
|
+
const resolution = await resolveScenarioPath(workflowName, scenario);
|
|
13
|
+
if (!resolution.found) {
|
|
14
|
+
return ux.error(getScenarioNotFoundMessage(workflowName, scenario, resolution.searchedPaths), { exit: 1 });
|
|
15
|
+
}
|
|
16
|
+
ux.stdout(`Using scenario: ${resolution.path}\n`);
|
|
17
|
+
return parseInputFlag(resolution.path);
|
|
18
|
+
}
|
|
19
|
+
return ux.error('Input required. Provide either:\n' +
|
|
20
|
+
` - A scenario name: output workflow ${commandName} <workflow> <scenario>\n` +
|
|
21
|
+
` - An input flag: output workflow ${commandName} <workflow> --input <json-or-file>`, { exit: 1 });
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ScenarioResolutionResult {
|
|
2
|
+
found: boolean;
|
|
3
|
+
path?: string;
|
|
4
|
+
searchedPaths: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function extractWorkflowRelativePath(path: string): string | null;
|
|
7
|
+
export declare function resolveScenarioPath(workflowName: string, scenarioName: string, basePath?: string): Promise<ScenarioResolutionResult>;
|
|
8
|
+
export declare function listScenariosForWorkflow(workflowName: string, workflowPath?: string, basePath?: string): string[];
|
|
9
|
+
export declare function getScenarioNotFoundMessage(workflowName: string, scenarioName: string, searchedPaths: string[]): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { getWorkflowCatalog } from '#api/generated/api.js';
|
|
4
|
+
const SCENARIOS_DIR = 'scenarios';
|
|
5
|
+
const WORKFLOWS_PATHS = ['src/workflows', 'workflows'];
|
|
6
|
+
export function extractWorkflowRelativePath(path) {
|
|
7
|
+
const match = path.match(/workflows\/(.+)\/workflow\.[jt]s$/);
|
|
8
|
+
return match ? match[1] : null;
|
|
9
|
+
}
|
|
10
|
+
async function fetchWorkflowDirectory(workflowName) {
|
|
11
|
+
try {
|
|
12
|
+
const response = await getWorkflowCatalog();
|
|
13
|
+
const workflows = response?.data?.workflows;
|
|
14
|
+
if (!workflows) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const workflow = workflows.find(w => w.name === workflowName);
|
|
18
|
+
if (!workflow) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const workflowPath = workflow.path;
|
|
22
|
+
if (!workflowPath) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return extractWorkflowRelativePath(workflowPath);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function resolveScenarioFromDirectory(relativeDir, scenarioFileName, basePath) {
|
|
32
|
+
const searchedPaths = [];
|
|
33
|
+
for (const workflowsDir of WORKFLOWS_PATHS) {
|
|
34
|
+
const candidatePath = resolve(basePath, workflowsDir, relativeDir, SCENARIOS_DIR, scenarioFileName);
|
|
35
|
+
searchedPaths.push(candidatePath);
|
|
36
|
+
if (existsSync(candidatePath)) {
|
|
37
|
+
return { found: true, path: candidatePath, searchedPaths };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { found: false, searchedPaths };
|
|
41
|
+
}
|
|
42
|
+
export async function resolveScenarioPath(workflowName, scenarioName, basePath = process.cwd()) {
|
|
43
|
+
const scenarioFileName = scenarioName.endsWith('.json') ?
|
|
44
|
+
scenarioName :
|
|
45
|
+
`${scenarioName}.json`;
|
|
46
|
+
const catalogDir = await fetchWorkflowDirectory(workflowName);
|
|
47
|
+
if (catalogDir) {
|
|
48
|
+
const result = resolveScenarioFromDirectory(catalogDir, scenarioFileName, basePath);
|
|
49
|
+
if (result.found) {
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
// Catalog resolved but scenario not found at that path — still try convention fallback
|
|
53
|
+
// in case the catalog path differs from local source layout
|
|
54
|
+
if (catalogDir !== workflowName) {
|
|
55
|
+
const fallback = resolveScenarioFromDirectory(workflowName, scenarioFileName, basePath);
|
|
56
|
+
return {
|
|
57
|
+
found: fallback.found,
|
|
58
|
+
path: fallback.path,
|
|
59
|
+
searchedPaths: [...result.searchedPaths, ...fallback.searchedPaths]
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
// API unavailable or workflow not in catalog — fall back to convention
|
|
65
|
+
return resolveScenarioFromDirectory(workflowName, scenarioFileName, basePath);
|
|
66
|
+
}
|
|
67
|
+
export function listScenariosForWorkflow(workflowName, workflowPath, basePath = process.cwd()) {
|
|
68
|
+
const relativeDir = (workflowPath && extractWorkflowRelativePath(workflowPath)) || workflowName;
|
|
69
|
+
for (const workflowsDir of WORKFLOWS_PATHS) {
|
|
70
|
+
const scenariosDir = resolve(basePath, workflowsDir, relativeDir, SCENARIOS_DIR);
|
|
71
|
+
if (existsSync(scenariosDir)) {
|
|
72
|
+
return readdirSync(scenariosDir)
|
|
73
|
+
.filter(f => f.endsWith('.json'))
|
|
74
|
+
.map(f => f.replace(/\.json$/, ''));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
export function getScenarioNotFoundMessage(workflowName, scenarioName, searchedPaths) {
|
|
80
|
+
const pathsList = searchedPaths.map(p => ` - ${p}`).join('\n');
|
|
81
|
+
return [
|
|
82
|
+
`Scenario '${scenarioName}' not found for workflow '${workflowName}'.`,
|
|
83
|
+
'',
|
|
84
|
+
'Searched in:',
|
|
85
|
+
pathsList,
|
|
86
|
+
'',
|
|
87
|
+
'Tip: Create a scenario file in your workflow\'s scenarios/ directory.',
|
|
88
|
+
'',
|
|
89
|
+
'Or use --input to specify a custom path:',
|
|
90
|
+
` output workflow run ${workflowName} --input /path/to/input.json`
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolveScenarioPath, getScenarioNotFoundMessage, extractWorkflowRelativePath, listScenariosForWorkflow } from './scenario_resolver.js';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as api from '#api/generated/api.js';
|
|
5
|
+
vi.mock('node:fs', () => ({
|
|
6
|
+
existsSync: vi.fn(),
|
|
7
|
+
readdirSync: vi.fn()
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('#api/generated/api.js', () => ({
|
|
10
|
+
getWorkflowCatalog: vi.fn()
|
|
11
|
+
}));
|
|
12
|
+
function mockCatalog(workflows) {
|
|
13
|
+
vi.mocked(api.getWorkflowCatalog).mockResolvedValue({
|
|
14
|
+
data: { workflows },
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: new Headers()
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function mockCatalogFailure() {
|
|
20
|
+
vi.mocked(api.getWorkflowCatalog).mockRejectedValue(new Error('API unavailable'));
|
|
21
|
+
}
|
|
22
|
+
describe('extractWorkflowRelativePath', () => {
|
|
23
|
+
it('should extract relative path from workflow.js path', () => {
|
|
24
|
+
expect(extractWorkflowRelativePath('/app/dist/workflows/basic_research/workflow.js'))
|
|
25
|
+
.toBe('basic_research');
|
|
26
|
+
});
|
|
27
|
+
it('should extract nested relative path', () => {
|
|
28
|
+
expect(extractWorkflowRelativePath('/app/dist/workflows/viz_examples/01_simple_linear/workflow.js'))
|
|
29
|
+
.toBe('viz_examples/01_simple_linear');
|
|
30
|
+
});
|
|
31
|
+
it('should handle workflow.ts extension', () => {
|
|
32
|
+
expect(extractWorkflowRelativePath('/src/workflows/my_flow/workflow.ts'))
|
|
33
|
+
.toBe('my_flow');
|
|
34
|
+
});
|
|
35
|
+
it('should return null for non-matching paths', () => {
|
|
36
|
+
expect(extractWorkflowRelativePath('/app/dist/other/workflow.js')).toBeNull();
|
|
37
|
+
expect(extractWorkflowRelativePath('/app/dist/workflows/')).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('resolveScenarioPath', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.resetAllMocks();
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
});
|
|
47
|
+
describe('when API returns workflow with path', () => {
|
|
48
|
+
it('should resolve scenario using catalog path', async () => {
|
|
49
|
+
mockCatalog([{ name: 'my_workflow', path: '/app/dist/workflows/my_workflow/workflow.js' }]);
|
|
50
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
51
|
+
return String(path).includes('src/workflows/my_workflow/scenarios/test_scenario.json');
|
|
52
|
+
});
|
|
53
|
+
const result = await resolveScenarioPath('my_workflow', 'test_scenario', '/project');
|
|
54
|
+
expect(result.found).toBe(true);
|
|
55
|
+
expect(result.path).toContain('src/workflows/my_workflow/scenarios/test_scenario.json');
|
|
56
|
+
});
|
|
57
|
+
it('should resolve when workflow name differs from folder name', async () => {
|
|
58
|
+
mockCatalog([{ name: 'simpleLinear', path: '/app/dist/workflows/viz_examples/01_simple_linear/workflow.js' }]);
|
|
59
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
60
|
+
return String(path).includes('src/workflows/viz_examples/01_simple_linear/scenarios/basic.json');
|
|
61
|
+
});
|
|
62
|
+
const result = await resolveScenarioPath('simpleLinear', 'basic', '/project');
|
|
63
|
+
expect(result.found).toBe(true);
|
|
64
|
+
expect(result.path).toContain('src/workflows/viz_examples/01_simple_linear/scenarios/basic.json');
|
|
65
|
+
});
|
|
66
|
+
it('should handle nested workflow directories', async () => {
|
|
67
|
+
mockCatalog([{ name: 'deep_flow', path: '/app/dist/workflows/category/sub/deep_flow/workflow.js' }]);
|
|
68
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
69
|
+
return String(path).includes('src/workflows/category/sub/deep_flow/scenarios/test.json');
|
|
70
|
+
});
|
|
71
|
+
const result = await resolveScenarioPath('deep_flow', 'test', '/project');
|
|
72
|
+
expect(result.found).toBe(true);
|
|
73
|
+
expect(result.path).toContain('src/workflows/category/sub/deep_flow/scenarios/test.json');
|
|
74
|
+
});
|
|
75
|
+
it('should return not found when scenario file does not exist', async () => {
|
|
76
|
+
mockCatalog([{ name: 'my_workflow', path: '/app/dist/workflows/my_workflow/workflow.js' }]);
|
|
77
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
78
|
+
const result = await resolveScenarioPath('my_workflow', 'missing', '/project');
|
|
79
|
+
expect(result.found).toBe(false);
|
|
80
|
+
expect(result.searchedPaths.length).toBeGreaterThanOrEqual(2);
|
|
81
|
+
});
|
|
82
|
+
it('should fall back to convention when catalog path has no scenario but convention does', async () => {
|
|
83
|
+
mockCatalog([{ name: 'renamedFlow', path: '/app/dist/workflows/actual_folder/workflow.js' }]);
|
|
84
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
85
|
+
// Not found at catalog-resolved path, but found at convention path
|
|
86
|
+
return String(path).includes('src/workflows/renamedFlow/scenarios/test.json');
|
|
87
|
+
});
|
|
88
|
+
const result = await resolveScenarioPath('renamedFlow', 'test', '/project');
|
|
89
|
+
expect(result.found).toBe(true);
|
|
90
|
+
expect(result.path).toContain('src/workflows/renamedFlow/scenarios/test.json');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('when API is unavailable', () => {
|
|
94
|
+
it('should fall back to convention-based lookup', async () => {
|
|
95
|
+
mockCatalogFailure();
|
|
96
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
97
|
+
return String(path).includes('src/workflows/my_workflow/scenarios/test_scenario.json');
|
|
98
|
+
});
|
|
99
|
+
const result = await resolveScenarioPath('my_workflow', 'test_scenario', '/project');
|
|
100
|
+
expect(result.found).toBe(true);
|
|
101
|
+
expect(result.path).toContain('src/workflows/my_workflow/scenarios/test_scenario.json');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('when workflow is not in catalog', () => {
|
|
105
|
+
it('should fall back to convention-based lookup', async () => {
|
|
106
|
+
mockCatalog([{ name: 'other_workflow', path: '/app/dist/workflows/other/workflow.js' }]);
|
|
107
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
108
|
+
return String(path).includes('src/workflows/my_workflow/scenarios/test.json');
|
|
109
|
+
});
|
|
110
|
+
const result = await resolveScenarioPath('my_workflow', 'test', '/project');
|
|
111
|
+
expect(result.found).toBe(true);
|
|
112
|
+
expect(result.path).toContain('src/workflows/my_workflow/scenarios/test.json');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe('json extension normalization', () => {
|
|
116
|
+
it('should handle scenario name with .json extension', async () => {
|
|
117
|
+
mockCatalogFailure();
|
|
118
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
119
|
+
return String(path).includes('src/workflows/my_workflow/scenarios/test_scenario.json');
|
|
120
|
+
});
|
|
121
|
+
const result = await resolveScenarioPath('my_workflow', 'test_scenario.json', '/project');
|
|
122
|
+
expect(result.found).toBe(true);
|
|
123
|
+
expect(result.path).toContain('test_scenario.json');
|
|
124
|
+
expect(result.path).not.toContain('test_scenario.json.json');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('workflows fallback directory', () => {
|
|
128
|
+
it('should find scenario in workflows/ fallback path', async () => {
|
|
129
|
+
mockCatalogFailure();
|
|
130
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
131
|
+
return String(path).includes('workflows/my_workflow/scenarios/test_scenario.json') &&
|
|
132
|
+
!String(path).includes('src/workflows');
|
|
133
|
+
});
|
|
134
|
+
const result = await resolveScenarioPath('my_workflow', 'test_scenario', '/project');
|
|
135
|
+
expect(result.found).toBe(true);
|
|
136
|
+
expect(result.path).toContain('workflows/my_workflow/scenarios/test_scenario.json');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe('subdirectory scenarios', () => {
|
|
140
|
+
it('should support subdirectory paths in scenario name', async () => {
|
|
141
|
+
mockCatalogFailure();
|
|
142
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
143
|
+
return String(path).includes('src/workflows/my_workflow/scenarios/complex/deep_test.json');
|
|
144
|
+
});
|
|
145
|
+
const result = await resolveScenarioPath('my_workflow', 'complex/deep_test', '/project');
|
|
146
|
+
expect(result.found).toBe(true);
|
|
147
|
+
expect(result.path).toContain('complex/deep_test.json');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe('listScenariosForWorkflow', () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
vi.resetAllMocks();
|
|
154
|
+
});
|
|
155
|
+
it('should return scenario names from scenarios directory', () => {
|
|
156
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path).includes('src/workflows/my_workflow/scenarios'));
|
|
157
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['basic.json', 'advanced.json', 'README.md']);
|
|
158
|
+
const result = listScenariosForWorkflow('my_workflow', undefined, '/project');
|
|
159
|
+
expect(result).toEqual(['basic', 'advanced']);
|
|
160
|
+
});
|
|
161
|
+
it('should use workflowPath from catalog to derive directory', () => {
|
|
162
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path).includes('src/workflows/viz_examples/01_simple_linear/scenarios'));
|
|
163
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['test.json']);
|
|
164
|
+
const result = listScenariosForWorkflow('simpleLinear', '/app/dist/workflows/viz_examples/01_simple_linear/workflow.js', '/project');
|
|
165
|
+
expect(result).toEqual(['test']);
|
|
166
|
+
});
|
|
167
|
+
it('should fall back to workflowName when workflowPath is undefined', () => {
|
|
168
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path).includes('src/workflows/my_workflow/scenarios'));
|
|
169
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['scenario_a.json']);
|
|
170
|
+
const result = listScenariosForWorkflow('my_workflow', undefined, '/project');
|
|
171
|
+
expect(result).toEqual(['scenario_a']);
|
|
172
|
+
});
|
|
173
|
+
it('should fall back to workflowName when path extraction returns null', () => {
|
|
174
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path).includes('src/workflows/my_workflow/scenarios'));
|
|
175
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['test.json']);
|
|
176
|
+
const result = listScenariosForWorkflow('my_workflow', '/invalid/path.js', '/project');
|
|
177
|
+
expect(result).toEqual(['test']);
|
|
178
|
+
});
|
|
179
|
+
it('should return empty array when no scenarios directory exists', () => {
|
|
180
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
181
|
+
const result = listScenariosForWorkflow('my_workflow', undefined, '/project');
|
|
182
|
+
expect(result).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
it('should try workflows/ fallback when src/workflows/ does not exist', () => {
|
|
185
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
186
|
+
const p = String(path);
|
|
187
|
+
return p.includes('workflows/my_workflow/scenarios') && !p.includes('src/workflows');
|
|
188
|
+
});
|
|
189
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['fallback.json']);
|
|
190
|
+
const result = listScenariosForWorkflow('my_workflow', undefined, '/project');
|
|
191
|
+
expect(result).toEqual(['fallback']);
|
|
192
|
+
});
|
|
193
|
+
it('should return empty array for empty scenarios directory', () => {
|
|
194
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
195
|
+
vi.mocked(fs.readdirSync).mockReturnValue([]);
|
|
196
|
+
const result = listScenariosForWorkflow('my_workflow', undefined, '/project');
|
|
197
|
+
expect(result).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe('getScenarioNotFoundMessage', () => {
|
|
201
|
+
it('should return a helpful error message', () => {
|
|
202
|
+
const searchedPaths = [
|
|
203
|
+
'/project/src/workflows/my_workflow/scenarios/test.json',
|
|
204
|
+
'/project/workflows/my_workflow/scenarios/test.json'
|
|
205
|
+
];
|
|
206
|
+
const message = getScenarioNotFoundMessage('my_workflow', 'test', searchedPaths);
|
|
207
|
+
expect(message).toContain('Scenario \'test\' not found for workflow \'my_workflow\'');
|
|
208
|
+
expect(message).toContain('Searched in:');
|
|
209
|
+
expect(message).toContain(searchedPaths[0]);
|
|
210
|
+
expect(message).toContain(searchedPaths[1]);
|
|
211
|
+
expect(message).toContain('Tip:');
|
|
212
|
+
expect(message).toContain('--input');
|
|
213
|
+
});
|
|
214
|
+
});
|
package/package.json
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|