@outputai/cli 0.8.0 → 0.8.1-dev.945f2f2.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/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/workflow/dataset/generate.js +3 -3
- package/dist/commands/workflow/test_eval.d.ts +1 -0
- package/dist/commands/workflow/test_eval.js +10 -1
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/datasets.d.ts +2 -2
- package/dist/services/datasets.js +19 -17
- package/dist/services/datasets.test.js +37 -2
- package/dist/utils/eval_diagnostics.d.ts +7 -0
- package/dist/utils/eval_diagnostics.js +35 -0
- package/dist/utils/scenario_resolver.d.ts +1 -2
- package/dist/utils/scenario_resolver.js +3 -33
- package/dist/utils/workflow_dir.d.ts +13 -0
- package/dist/utils/workflow_dir.js +58 -0
- package/dist/utils/workflow_dir.spec.d.ts +1 -0
- package/dist/utils/workflow_dir.spec.js +60 -0
- package/oclif.manifest.json +1 -1
- package/package.json +4 -4
|
@@ -79,7 +79,7 @@ export default class DatasetGenerate extends Command {
|
|
|
79
79
|
const { workflowId, output } = response.data;
|
|
80
80
|
const executionTimeMs = await getExecutionTime(workflowId);
|
|
81
81
|
const dataset = buildDataset(datasetName, resolvedInput, output, executionTimeMs);
|
|
82
|
-
const dir = resolveDefaultDatasetsDir(workflowName);
|
|
82
|
+
const dir = await resolveDefaultDatasetsDir(workflowName);
|
|
83
83
|
const filePath = join(dir, `${datasetName}.yml`);
|
|
84
84
|
await writeDataset(dataset, filePath);
|
|
85
85
|
this.log(`Dataset saved: ${filePath}`);
|
|
@@ -91,7 +91,7 @@ export default class DatasetGenerate extends Command {
|
|
|
91
91
|
const extracted = extractDatasetFromTrace(traceData);
|
|
92
92
|
const datasetName = nameOverride ?? extractDatasetName(tracePath);
|
|
93
93
|
const dataset = buildDataset(datasetName, extracted.input, extracted.output, extracted.executionTimeMs);
|
|
94
|
-
const dir = resolveDefaultDatasetsDir(workflowName);
|
|
94
|
+
const dir = await resolveDefaultDatasetsDir(workflowName);
|
|
95
95
|
const filePath = join(dir, `${datasetName}.yml`);
|
|
96
96
|
await writeDataset(dataset, filePath);
|
|
97
97
|
this.log(`Dataset saved: ${filePath}`);
|
|
@@ -104,7 +104,7 @@ export default class DatasetGenerate extends Command {
|
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
this.log(`Found ${traces.length} trace(s). Downloading...`);
|
|
107
|
-
const dir = resolveDefaultDatasetsDir(workflowName);
|
|
107
|
+
const dir = await resolveDefaultDatasetsDir(workflowName);
|
|
108
108
|
for (const trace of traces) {
|
|
109
109
|
const traceData = await downloadRemoteTrace(trace.key);
|
|
110
110
|
const extracted = extractDatasetFromTrace(traceData);
|
|
@@ -13,6 +13,7 @@ export default class WorkflowTest extends Command {
|
|
|
13
13
|
format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
14
|
};
|
|
15
15
|
run(): Promise<void>;
|
|
16
|
+
private ensureEvalWorkflowRegistered;
|
|
16
17
|
private validateDatasets;
|
|
17
18
|
private runWorkflowForDatasets;
|
|
18
19
|
private saveEvalResults;
|
|
@@ -2,6 +2,8 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { Args, Command, Flags } from '@oclif/core';
|
|
3
3
|
import { postWorkflowRun } from '#api/generated/api.js';
|
|
4
4
|
import { readAllDatasets, writeDataset } from '#services/datasets.js';
|
|
5
|
+
import { fetchWorkflowCatalog } from '#api/workflow_catalog.js';
|
|
6
|
+
import { diagnoseMissingEvalWorkflow } from '#utils/eval_diagnostics.js';
|
|
5
7
|
import { handleApiError } from '#utils/error_handler.js';
|
|
6
8
|
import { getEvalWorkflowName, renderEvalOutput, computeExitCode, EvalOutputSchema } from '@outputai/evals';
|
|
7
9
|
export default class WorkflowTest extends Command {
|
|
@@ -45,6 +47,8 @@ export default class WorkflowTest extends Command {
|
|
|
45
47
|
async run() {
|
|
46
48
|
const { args, flags } = await this.parse(WorkflowTest);
|
|
47
49
|
const filterNames = flags.dataset?.split(',').map(s => s.trim());
|
|
50
|
+
const evalName = getEvalWorkflowName(args.workflowName);
|
|
51
|
+
await this.ensureEvalWorkflowRegistered(args.workflowName, evalName);
|
|
48
52
|
const { datasets, dir } = await readAllDatasets(args.workflowName, filterNames);
|
|
49
53
|
if (datasets.length === 0) {
|
|
50
54
|
this.error(`No datasets found for workflow "${args.workflowName}".\n` +
|
|
@@ -53,7 +57,6 @@ export default class WorkflowTest extends Command {
|
|
|
53
57
|
const preparedDatasets = flags.cached ?
|
|
54
58
|
this.validateDatasets(datasets) :
|
|
55
59
|
await this.runWorkflowForDatasets(args.workflowName, datasets, flags.save, dir);
|
|
56
|
-
const evalName = getEvalWorkflowName(args.workflowName);
|
|
57
60
|
this.log(`Running eval workflow "${evalName}"...\n`);
|
|
58
61
|
const response = await postWorkflowRun({
|
|
59
62
|
workflowName: evalName,
|
|
@@ -80,6 +83,12 @@ export default class WorkflowTest extends Command {
|
|
|
80
83
|
this.exit(exitCode);
|
|
81
84
|
}
|
|
82
85
|
}
|
|
86
|
+
async ensureEvalWorkflowRegistered(workflowName, evalName) {
|
|
87
|
+
const catalog = await fetchWorkflowCatalog().catch(() => null);
|
|
88
|
+
if (catalog && !catalog.some(w => w.name === evalName)) {
|
|
89
|
+
this.error(await diagnoseMissingEvalWorkflow(workflowName), { exit: 1 });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
83
92
|
validateDatasets(datasets) {
|
|
84
93
|
const missing = datasets.filter(d => d.last_output?.output === undefined);
|
|
85
94
|
if (missing.length > 0) {
|
|
@@ -6,8 +6,8 @@ export interface DatasetInfo {
|
|
|
6
6
|
lastOutputDate?: string;
|
|
7
7
|
lastEvalDate?: string;
|
|
8
8
|
}
|
|
9
|
-
export declare function resolveDatasetsDir(workflowName: string, basePath?: string): string | null
|
|
10
|
-
export declare function resolveDefaultDatasetsDir(workflowName: string, basePath?: string): string
|
|
9
|
+
export declare function resolveDatasetsDir(workflowName: string, basePath?: string, workflowPath?: string): Promise<string | null>;
|
|
10
|
+
export declare function resolveDefaultDatasetsDir(workflowName: string, basePath?: string, workflowPath?: string): Promise<string>;
|
|
11
11
|
export declare function readDatasetFile(filePath: string): Promise<Dataset[]>;
|
|
12
12
|
export declare function readAllDatasets(workflowName: string, filterNames?: string[], basePath?: string): Promise<{
|
|
13
13
|
datasets: Dataset[];
|
|
@@ -5,24 +5,26 @@ import yaml from 'js-yaml';
|
|
|
5
5
|
import { DatasetSchema } from '@outputai/evals';
|
|
6
6
|
import { getTrace } from '#services/trace_reader.js';
|
|
7
7
|
import { sanitizeSecrets } from '#utils/secret_sanitizer.js';
|
|
8
|
+
import { resolveWorkflowDir, WORKFLOWS_PATHS } from '#utils/workflow_dir.js';
|
|
9
|
+
import { getWorkflowsBasePath } from '#utils/paths.js';
|
|
8
10
|
const DATASETS_DIR = 'tests/datasets';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (existsSync(candidate)) {
|
|
14
|
-
return candidate;
|
|
15
|
-
}
|
|
11
|
+
export async function resolveDatasetsDir(workflowName, basePath, workflowPath) {
|
|
12
|
+
const workflowDir = await resolveWorkflowDir(workflowName, basePath, workflowPath);
|
|
13
|
+
if (!workflowDir) {
|
|
14
|
+
return null;
|
|
16
15
|
}
|
|
17
|
-
|
|
16
|
+
const datasetsDir = resolve(workflowDir, DATASETS_DIR);
|
|
17
|
+
return existsSync(datasetsDir) ? datasetsDir : null;
|
|
18
18
|
}
|
|
19
|
-
export function resolveDefaultDatasetsDir(workflowName, basePath
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
export async function resolveDefaultDatasetsDir(workflowName, basePath, workflowPath) {
|
|
20
|
+
// Write into the resolved workflow's tests/datasets — even when it doesn't
|
|
21
|
+
// exist yet (first generation) — so nested workflows get the right location.
|
|
22
|
+
const workflowDir = await resolveWorkflowDir(workflowName, basePath, workflowPath);
|
|
23
|
+
if (workflowDir) {
|
|
24
|
+
return resolve(workflowDir, DATASETS_DIR);
|
|
23
25
|
}
|
|
24
|
-
//
|
|
25
|
-
return resolve(basePath, WORKFLOWS_PATHS[0], workflowName, DATASETS_DIR);
|
|
26
|
+
// Workflow not found (unknown name / API unavailable): flat convention.
|
|
27
|
+
return resolve(basePath ?? getWorkflowsBasePath(), WORKFLOWS_PATHS[0], workflowName, DATASETS_DIR);
|
|
26
28
|
}
|
|
27
29
|
export async function readDatasetFile(filePath) {
|
|
28
30
|
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
@@ -39,9 +41,9 @@ export async function readDatasetFile(filePath) {
|
|
|
39
41
|
});
|
|
40
42
|
}
|
|
41
43
|
export async function readAllDatasets(workflowName, filterNames, basePath) {
|
|
42
|
-
const dir = resolveDatasetsDir(workflowName, basePath);
|
|
44
|
+
const dir = await resolveDatasetsDir(workflowName, basePath);
|
|
43
45
|
if (!dir) {
|
|
44
|
-
return { datasets: [], dir: resolveDefaultDatasetsDir(workflowName, basePath) };
|
|
46
|
+
return { datasets: [], dir: await resolveDefaultDatasetsDir(workflowName, basePath) };
|
|
45
47
|
}
|
|
46
48
|
const files = await readdir(dir);
|
|
47
49
|
const ymlFiles = files.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
@@ -76,7 +78,7 @@ export async function writeDataset(dataset, filePath) {
|
|
|
76
78
|
await writeFile(filePath, yaml.dump(fileObj, { lineWidth: 120, noRefs: true, sortKeys: false }), 'utf-8');
|
|
77
79
|
}
|
|
78
80
|
export async function listDatasets(workflowName, basePath) {
|
|
79
|
-
const dir = resolveDatasetsDir(workflowName, basePath);
|
|
81
|
+
const dir = await resolveDatasetsDir(workflowName, basePath);
|
|
80
82
|
if (!dir) {
|
|
81
83
|
return [];
|
|
82
84
|
}
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import yaml from 'js-yaml';
|
|
6
|
-
import { readDatasetFile, readAllDatasets, writeDataset, listDatasets } from './datasets.js';
|
|
6
|
+
import { readDatasetFile, readAllDatasets, writeDataset, listDatasets, resolveDefaultDatasetsDir } from './datasets.js';
|
|
7
|
+
import * as catalog from '#api/workflow_catalog.js';
|
|
8
|
+
vi.mock('#api/workflow_catalog.js', () => ({
|
|
9
|
+
fetchWorkflowCatalog: vi.fn()
|
|
10
|
+
}));
|
|
7
11
|
const ctx = { tmpDir: '' };
|
|
8
12
|
beforeEach(async () => {
|
|
9
13
|
ctx.tmpDir = await mkdtemp(join(tmpdir(), 'output-datasets-test-'));
|
|
14
|
+
// Default: no API. Flat-layout cases resolve offline before this is hit;
|
|
15
|
+
// nested cases override with a resolved catalog.
|
|
16
|
+
vi.mocked(catalog.fetchWorkflowCatalog).mockReset();
|
|
17
|
+
vi.mocked(catalog.fetchWorkflowCatalog).mockRejectedValue(new Error('API unavailable'));
|
|
10
18
|
});
|
|
11
19
|
afterEach(async () => {
|
|
12
20
|
await rm(ctx.tmpDir, { recursive: true, force: true });
|
|
@@ -102,6 +110,17 @@ describe('readAllDatasets', () => {
|
|
|
102
110
|
expect(datasets).toHaveLength(2);
|
|
103
111
|
expect(datasets.map(d => d.name).sort()).toEqual(['case_2', 'case_3']);
|
|
104
112
|
});
|
|
113
|
+
it('resolves a nested workflow folder via the catalog', async () => {
|
|
114
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'a', 'b', 'c', 'tests', 'datasets');
|
|
115
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
116
|
+
await writeYaml(join(datasetsDir, 'cases.yml'), {
|
|
117
|
+
nested_case: { input: { x: 1 } }
|
|
118
|
+
});
|
|
119
|
+
vi.mocked(catalog.fetchWorkflowCatalog).mockResolvedValue([{ name: 'a_b_c', path: '/app/dist/workflows/a/b/c/workflow.js' }]);
|
|
120
|
+
const { datasets, dir } = await readAllDatasets('a_b_c', undefined, ctx.tmpDir);
|
|
121
|
+
expect(datasets.map(d => d.name)).toEqual(['nested_case']);
|
|
122
|
+
expect(dir).toBe(datasetsDir);
|
|
123
|
+
});
|
|
105
124
|
it('returns empty datasets and a default dir when workflow has no datasets dir', async () => {
|
|
106
125
|
const { datasets, dir } = await readAllDatasets('nonexistent_workflow', undefined, ctx.tmpDir);
|
|
107
126
|
expect(datasets).toHaveLength(0);
|
|
@@ -200,3 +219,19 @@ describe('listDatasets', () => {
|
|
|
200
219
|
expect(infos).toHaveLength(0);
|
|
201
220
|
});
|
|
202
221
|
});
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// resolveDefaultDatasetsDir
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
describe('resolveDefaultDatasetsDir', () => {
|
|
226
|
+
it('returns the nested workflow tests/datasets for first-time generation via catalog', async () => {
|
|
227
|
+
// Workflow dir exists; its tests/datasets does not yet (first generation).
|
|
228
|
+
await mkdir(join(ctx.tmpDir, 'src', 'workflows', 'a', 'b', 'c'), { recursive: true });
|
|
229
|
+
vi.mocked(catalog.fetchWorkflowCatalog).mockResolvedValue([{ name: 'a_b_c', path: '/app/dist/workflows/a/b/c/workflow.js' }]);
|
|
230
|
+
const dir = await resolveDefaultDatasetsDir('a_b_c', ctx.tmpDir);
|
|
231
|
+
expect(dir).toBe(join(ctx.tmpDir, 'src', 'workflows', 'a', 'b', 'c', 'tests', 'datasets'));
|
|
232
|
+
});
|
|
233
|
+
it('falls back to the flat convention when the workflow cannot be resolved', async () => {
|
|
234
|
+
const dir = await resolveDefaultDatasetsDir('unknown_flow', ctx.tmpDir);
|
|
235
|
+
expect(dir).toBe(join(ctx.tmpDir, 'src', 'workflows', 'unknown_flow', 'tests', 'datasets'));
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explain why a `<wf>_eval` workflow isn't registered. When the eval source
|
|
3
|
+
* exists on disk but isn't in the catalog, it almost always means tests/evals
|
|
4
|
+
* never compiled to dist (a tsconfig exclude dropped it) — so point at that
|
|
5
|
+
* instead of a bare WorkflowNotFoundError.
|
|
6
|
+
*/
|
|
7
|
+
export declare function diagnoseMissingEvalWorkflow(workflowName: string, basePath?: string): Promise<string>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { getEvalWorkflowName } from '@outputai/evals';
|
|
4
|
+
import { resolveWorkflowDir } from '#utils/workflow_dir.js';
|
|
5
|
+
const EVAL_WORKFLOW_FILES = ['tests/evals/workflow.ts', 'tests/evals/workflow.js'];
|
|
6
|
+
/**
|
|
7
|
+
* Explain why a `<wf>_eval` workflow isn't registered. When the eval source
|
|
8
|
+
* exists on disk but isn't in the catalog, it almost always means tests/evals
|
|
9
|
+
* never compiled to dist (a tsconfig exclude dropped it) — so point at that
|
|
10
|
+
* instead of a bare WorkflowNotFoundError.
|
|
11
|
+
*/
|
|
12
|
+
export async function diagnoseMissingEvalWorkflow(workflowName, basePath) {
|
|
13
|
+
const evalName = getEvalWorkflowName(workflowName);
|
|
14
|
+
const workflowDir = await resolveWorkflowDir(workflowName, basePath);
|
|
15
|
+
const evalSource = workflowDir ?
|
|
16
|
+
EVAL_WORKFLOW_FILES.map(file => resolve(workflowDir, file)).find(existsSync) :
|
|
17
|
+
undefined;
|
|
18
|
+
if (evalSource) {
|
|
19
|
+
return [
|
|
20
|
+
`Eval workflow "${evalName}" is not registered, but its source exists at:`,
|
|
21
|
+
` ${evalSource}`,
|
|
22
|
+
'',
|
|
23
|
+
'This usually means tests/evals did not compile to dist. Check your tsconfig:',
|
|
24
|
+
' - Ensure tests/evals is not excluded (avoid excluding "src/**/tests").',
|
|
25
|
+
' - Prefer excluding "**/*.spec.ts" and "**/*.test.ts" instead.',
|
|
26
|
+
'',
|
|
27
|
+
'Rebuild the worker so dist/.../tests/evals/workflow.js exists, then retry.'
|
|
28
|
+
].join('\n');
|
|
29
|
+
}
|
|
30
|
+
return [
|
|
31
|
+
`No eval workflow defined for "${workflowName}".`,
|
|
32
|
+
'',
|
|
33
|
+
`Create an eval workflow at tests/evals/workflow.ts that registers "${evalName}".`
|
|
34
|
+
].join('\n');
|
|
35
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
export { extractWorkflowRelativePath, findWorkflowDirectoryFromPath } from '#utils/workflow_dir.js';
|
|
1
2
|
export interface ScenarioResolutionResult {
|
|
2
3
|
found: boolean;
|
|
3
4
|
path?: string;
|
|
4
5
|
searchedPaths: string[];
|
|
5
6
|
}
|
|
6
|
-
export declare function extractWorkflowRelativePath(path: string): string | null;
|
|
7
|
-
export declare function findWorkflowDirectoryFromPath(workflowPath: string | undefined, basePath?: string): string | null;
|
|
8
7
|
export declare function resolveScenarioPath(workflowName: string, scenarioName: string, basePath?: string, workflowPath?: string): Promise<ScenarioResolutionResult>;
|
|
9
8
|
export declare function listScenariosForWorkflow(workflowName: string, workflowPath?: string, basePath?: string): string[];
|
|
10
9
|
export declare function getScenarioNotFoundMessage(workflowName: string, scenarioName: string, searchedPaths: string[]): string;
|
|
@@ -1,43 +1,13 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import { fetchWorkflowCatalog } from '#api/workflow_catalog.js';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
4
3
|
import { getWorkflowsBasePath } from '#utils/paths.js';
|
|
4
|
+
import { WORKFLOWS_PATHS, candidateWorkflowDirsFromPath, fetchWorkflowPath } from '#utils/workflow_dir.js';
|
|
5
|
+
export { extractWorkflowRelativePath, findWorkflowDirectoryFromPath } from '#utils/workflow_dir.js';
|
|
5
6
|
const SCENARIOS_DIR = 'scenarios';
|
|
6
|
-
const WORKFLOWS_PATHS = ['src/workflows', 'workflows'];
|
|
7
|
-
export function extractWorkflowRelativePath(path) {
|
|
8
|
-
const match = path.match(/(?:^|\/)workflows\/(.+)\/workflow\.[jt]s$/);
|
|
9
|
-
return match ? match[1] : null;
|
|
10
|
-
}
|
|
11
|
-
function unique(values) {
|
|
12
|
-
return [...new Set(values)];
|
|
13
|
-
}
|
|
14
|
-
function workflowPathSuffixes(workflowPath) {
|
|
15
|
-
const parts = dirname(workflowPath).split(/[/\\]+/).filter(Boolean);
|
|
16
|
-
return parts.map((_, index) => parts.slice(index));
|
|
17
|
-
}
|
|
18
|
-
function candidateWorkflowDirsFromPath(workflowPath, basePath) {
|
|
19
|
-
return unique(workflowPathSuffixes(workflowPath).flatMap(suffix => WORKFLOWS_PATHS.map(workflowsDir => resolve(basePath, workflowsDir, ...suffix))));
|
|
20
|
-
}
|
|
21
7
|
function candidateScenarioDirsFromPath(workflowPath, basePath) {
|
|
22
8
|
return candidateWorkflowDirsFromPath(workflowPath, basePath)
|
|
23
9
|
.map(workflowDir => resolve(workflowDir, SCENARIOS_DIR));
|
|
24
10
|
}
|
|
25
|
-
export function findWorkflowDirectoryFromPath(workflowPath, basePath = getWorkflowsBasePath()) {
|
|
26
|
-
if (!workflowPath) {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
return candidateWorkflowDirsFromPath(workflowPath, basePath).find(existsSync) ?? null;
|
|
30
|
-
}
|
|
31
|
-
async function fetchWorkflowPath(workflowName) {
|
|
32
|
-
try {
|
|
33
|
-
const workflows = await fetchWorkflowCatalog();
|
|
34
|
-
const workflow = workflows.find(w => w.name === workflowName);
|
|
35
|
-
return workflow?.path ?? null;
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
11
|
function resolveScenarioFromDirectory(relativeDir, scenarioFileName, basePath) {
|
|
42
12
|
const searchedPaths = [];
|
|
43
13
|
for (const workflowsDir of WORKFLOWS_PATHS) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const WORKFLOWS_PATHS: string[];
|
|
2
|
+
export declare function extractWorkflowRelativePath(path: string): string | null;
|
|
3
|
+
export declare function candidateWorkflowDirsFromPath(workflowPath: string, basePath: string): string[];
|
|
4
|
+
export declare function findWorkflowDirectoryFromPath(workflowPath: string | undefined, basePath?: string): string | null;
|
|
5
|
+
export declare function fetchWorkflowPath(workflowName: string): Promise<string | null>;
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the on-disk directory of a registered workflow by name.
|
|
8
|
+
*
|
|
9
|
+
* Flat-first so flat-layout projects resolve offline (no catalog round-trip)
|
|
10
|
+
* exactly as before; nested folders fall through to the worker catalog, whose
|
|
11
|
+
* `path` is re-rooted under `src/workflows` / `workflows`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveWorkflowDir(workflowName: string, basePath?: string, workflowPath?: string): Promise<string | null>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fetchWorkflowCatalog } from '#api/workflow_catalog.js';
|
|
4
|
+
import { getWorkflowsBasePath } from '#utils/paths.js';
|
|
5
|
+
export 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
|
+
function unique(values) {
|
|
11
|
+
return [...new Set(values)];
|
|
12
|
+
}
|
|
13
|
+
function workflowPathSuffixes(workflowPath) {
|
|
14
|
+
const parts = dirname(workflowPath).split(/[/\\]+/).filter(Boolean);
|
|
15
|
+
return parts.map((_, index) => parts.slice(index));
|
|
16
|
+
}
|
|
17
|
+
export function candidateWorkflowDirsFromPath(workflowPath, basePath) {
|
|
18
|
+
return unique(workflowPathSuffixes(workflowPath).flatMap(suffix => WORKFLOWS_PATHS.map(workflowsDir => resolve(basePath, workflowsDir, ...suffix))));
|
|
19
|
+
}
|
|
20
|
+
export function findWorkflowDirectoryFromPath(workflowPath, basePath = getWorkflowsBasePath()) {
|
|
21
|
+
if (!workflowPath) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return candidateWorkflowDirsFromPath(workflowPath, basePath).find(existsSync) ?? null;
|
|
25
|
+
}
|
|
26
|
+
export async function fetchWorkflowPath(workflowName) {
|
|
27
|
+
try {
|
|
28
|
+
const workflows = await fetchWorkflowCatalog();
|
|
29
|
+
const workflow = workflows.find(w => w.name === workflowName);
|
|
30
|
+
return workflow?.path ?? null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the on-disk directory of a registered workflow by name.
|
|
38
|
+
*
|
|
39
|
+
* Flat-first so flat-layout projects resolve offline (no catalog round-trip)
|
|
40
|
+
* exactly as before; nested folders fall through to the worker catalog, whose
|
|
41
|
+
* `path` is re-rooted under `src/workflows` / `workflows`.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveWorkflowDir(workflowName, basePath = getWorkflowsBasePath(), workflowPath) {
|
|
44
|
+
for (const workflowsDir of WORKFLOWS_PATHS) {
|
|
45
|
+
const candidate = resolve(basePath, workflowsDir, workflowName);
|
|
46
|
+
if (existsSync(candidate)) {
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (workflowPath) {
|
|
51
|
+
const found = findWorkflowDirectoryFromPath(workflowPath, basePath);
|
|
52
|
+
if (found) {
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const catalogPath = await fetchWorkflowPath(workflowName);
|
|
57
|
+
return findWorkflowDirectoryFromPath(catalogPath ?? undefined, basePath);
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolveWorkflowDir } from './workflow_dir.js';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as catalog from '#api/workflow_catalog.js';
|
|
5
|
+
vi.mock('node:fs', () => ({
|
|
6
|
+
existsSync: vi.fn()
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('#api/workflow_catalog.js', () => ({
|
|
9
|
+
fetchWorkflowCatalog: vi.fn()
|
|
10
|
+
}));
|
|
11
|
+
function mockCatalog(workflows) {
|
|
12
|
+
vi.mocked(catalog.fetchWorkflowCatalog).mockResolvedValue(workflows);
|
|
13
|
+
}
|
|
14
|
+
function mockCatalogFailure() {
|
|
15
|
+
vi.mocked(catalog.fetchWorkflowCatalog).mockRejectedValue(new Error('API unavailable'));
|
|
16
|
+
}
|
|
17
|
+
describe('resolveWorkflowDir', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.resetAllMocks();
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
it('resolves a flat layout without querying the catalog', async () => {
|
|
25
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path) === '/project/src/workflows/simple');
|
|
26
|
+
const result = await resolveWorkflowDir('simple', '/project');
|
|
27
|
+
expect(result).toBe('/project/src/workflows/simple');
|
|
28
|
+
expect(catalog.fetchWorkflowCatalog).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
it('resolves the workflows/ fallback without querying the catalog', async () => {
|
|
31
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path) === '/project/workflows/simple');
|
|
32
|
+
const result = await resolveWorkflowDir('simple', '/project');
|
|
33
|
+
expect(result).toBe('/project/workflows/simple');
|
|
34
|
+
expect(catalog.fetchWorkflowCatalog).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
it('resolves a nested folder via the catalog path', async () => {
|
|
37
|
+
mockCatalog([{ name: 'a_b_c', path: '/app/dist/workflows/a/b/c/workflow.js' }]);
|
|
38
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path) === '/project/src/workflows/a/b/c');
|
|
39
|
+
const result = await resolveWorkflowDir('a_b_c', '/project');
|
|
40
|
+
expect(result).toBe('/project/src/workflows/a/b/c');
|
|
41
|
+
});
|
|
42
|
+
it('returns null when the catalog is unavailable and no flat dir exists', async () => {
|
|
43
|
+
mockCatalogFailure();
|
|
44
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
45
|
+
const result = await resolveWorkflowDir('a_b_c', '/project');
|
|
46
|
+
expect(result).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
it('returns null when the workflow is not in the catalog', async () => {
|
|
49
|
+
mockCatalog([{ name: 'other', path: '/app/dist/workflows/other/workflow.js' }]);
|
|
50
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
51
|
+
const result = await resolveWorkflowDir('a_b_c', '/project');
|
|
52
|
+
expect(result).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
it('uses a provided workflowPath without querying the catalog', async () => {
|
|
55
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path) === '/project/src/workflows/writing/editor');
|
|
56
|
+
const result = await resolveWorkflowDir('writing_editor', '/project', '/app/build-output/writing/editor/workflow.js');
|
|
57
|
+
expect(result).toBe('/project/src/workflows/writing/editor');
|
|
58
|
+
expect(catalog.fetchWorkflowCatalog).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
});
|
package/oclif.manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.8.0",
|
|
3
|
+
"version": "0.8.1-dev.945f2f2.0",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"semver": "7.7.4",
|
|
37
37
|
"undici": "8.1.0",
|
|
38
38
|
"yaml": "^2.8.3",
|
|
39
|
-
"@outputai/
|
|
40
|
-
"@outputai/llm": "0.8.0",
|
|
41
|
-
"@outputai/
|
|
39
|
+
"@outputai/evals": "0.8.1-dev.945f2f2.0",
|
|
40
|
+
"@outputai/llm": "0.8.1-dev.945f2f2.0",
|
|
41
|
+
"@outputai/credentials": "0.8.1-dev.945f2f2.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/cli-progress": "3.11.6",
|