@outputai/cli 0.3.2-next.5e221e8.0 β 0.3.3-dev.2650161.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/dev/index.js +48 -13
- package/dist/commands/dev/index.spec.js +1 -2
- package/dist/components/status_icon.js +1 -1
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/docker.js +0 -1
- package/dist/templates/project/src/workflows/blog_evaluator/prompts/signal_noise@v1.prompt.template +2 -1
- package/dist/templates/workflow/README.md.template +2 -1
- package/dist/templates/workflow/prompts/example@v1.prompt.template +2 -1
- package/dist/utils/paths.d.ts +11 -0
- package/dist/utils/paths.js +14 -0
- package/dist/utils/scenario_resolver.d.ts +2 -1
- package/dist/utils/scenario_resolver.js +57 -25
- package/dist/utils/scenario_resolver.spec.js +30 -1
- package/dist/views/dev/chrome/divider.d.ts +8 -0
- package/dist/views/dev/chrome/divider.js +16 -0
- package/dist/views/dev/chrome/footer.d.ts +11 -0
- package/dist/views/dev/chrome/footer.js +10 -0
- package/dist/views/dev/chrome/header.d.ts +21 -0
- package/dist/views/dev/chrome/header.js +74 -0
- package/dist/views/dev/chrome/header.spec.d.ts +1 -0
- package/dist/views/dev/chrome/header.spec.js +50 -0
- package/dist/views/dev/chrome/loading_spinner.d.ts +9 -0
- package/dist/views/dev/chrome/loading_spinner.js +9 -0
- package/dist/views/dev/chrome/palette.d.ts +16 -0
- package/dist/views/dev/chrome/palette.js +16 -0
- package/dist/views/dev/chrome/search_bar.d.ts +5 -0
- package/dist/views/dev/chrome/search_bar.js +35 -0
- package/dist/views/dev/chrome/selection_indicator.d.ts +14 -0
- package/dist/views/dev/chrome/selection_indicator.js +13 -0
- package/dist/views/dev/chrome/tab_bar.d.ts +5 -0
- package/dist/views/dev/chrome/tab_bar.js +4 -0
- package/dist/views/dev/chrome/toasts.d.ts +2 -0
- package/dist/views/dev/chrome/toasts.js +40 -0
- package/dist/views/dev/components/master_detail_panel.d.ts +21 -0
- package/dist/views/dev/components/master_detail_panel.js +18 -0
- package/dist/views/{dev.d.ts β dev/dev_app.d.ts} +1 -0
- package/dist/views/dev/dev_app.js +146 -0
- package/dist/views/dev/hooks/use_docker_logs.d.ts +7 -0
- package/dist/views/dev/hooks/use_docker_logs.js +69 -0
- package/dist/views/dev/hooks/use_poll.d.ts +16 -0
- package/dist/views/dev/hooks/use_poll.js +95 -0
- package/dist/views/dev/hooks/use_run_detail.d.ts +21 -0
- package/dist/views/dev/hooks/use_run_detail.js +153 -0
- package/dist/views/dev/hooks/use_run_detail.spec.d.ts +1 -0
- package/dist/views/dev/hooks/use_run_detail.spec.js +86 -0
- package/dist/views/dev/hooks/use_workflow_catalog.d.ts +2 -0
- package/dist/views/dev/hooks/use_workflow_catalog.js +21 -0
- package/dist/views/dev/modals/expanded_json_modal.d.ts +2 -0
- package/dist/views/dev/modals/expanded_json_modal.js +44 -0
- package/dist/views/dev/modals/run_modal.d.ts +5 -0
- package/dist/views/dev/modals/run_modal.js +213 -0
- package/dist/views/dev/panels/help_panel.d.ts +2 -0
- package/dist/views/dev/panels/help_panel.js +53 -0
- package/dist/views/dev/panels/run_detail_view.d.ts +5 -0
- package/dist/views/dev/panels/run_detail_view.js +112 -0
- package/dist/views/dev/panels/runs_panel.d.ts +8 -0
- package/dist/views/dev/panels/runs_panel.js +204 -0
- package/dist/views/dev/panels/runs_panel.spec.d.ts +1 -0
- package/dist/views/dev/panels/runs_panel.spec.js +82 -0
- package/dist/views/dev/panels/services_panel.d.ts +14 -0
- package/dist/views/dev/panels/services_panel.js +155 -0
- package/dist/views/dev/panels/services_panel.spec.d.ts +1 -0
- package/dist/views/dev/panels/services_panel.spec.js +28 -0
- package/dist/views/dev/panels/workflows_panel.d.ts +7 -0
- package/dist/views/dev/panels/workflows_panel.js +111 -0
- package/dist/views/dev/services/docker_control.d.ts +5 -0
- package/dist/views/dev/services/docker_control.js +25 -0
- package/dist/views/dev/services/run_workflow.d.ts +10 -0
- package/dist/views/dev/services/run_workflow.js +14 -0
- package/dist/views/dev/services/scenario_io.d.ts +2 -0
- package/dist/views/dev/services/scenario_io.js +41 -0
- package/dist/views/dev/state/ui_state.d.ts +61 -0
- package/dist/views/dev/state/ui_state.js +64 -0
- package/dist/views/dev/utils/constants.d.ts +17 -0
- package/dist/views/dev/utils/constants.js +17 -0
- package/dist/views/dev/utils/json_editor.d.ts +21 -0
- package/dist/views/dev/utils/json_editor.js +117 -0
- package/dist/views/dev/utils/json_editor.spec.d.ts +1 -0
- package/dist/views/dev/utils/json_editor.spec.js +57 -0
- package/dist/views/dev/utils/json_render.d.ts +15 -0
- package/dist/views/dev/utils/json_render.js +77 -0
- package/dist/views/dev/utils/json_render.spec.d.ts +1 -0
- package/dist/views/dev/utils/json_render.spec.js +65 -0
- package/dist/views/dev/utils/panel_helpers.d.ts +16 -0
- package/dist/views/dev/utils/panel_helpers.js +32 -0
- package/dist/views/dev/utils/panel_helpers.spec.d.ts +1 -0
- package/dist/views/dev/utils/panel_helpers.spec.js +47 -0
- package/package.json +5 -5
- package/dist/components/command_footer.d.ts +0 -8
- package/dist/components/command_footer.js +0 -4
- package/dist/components/workflow_summary.d.ts +0 -10
- package/dist/components/workflow_summary.js +0 -4
- package/dist/views/dev.js +0 -187
- package/dist/views/workflow/list.d.ts +0 -6
- package/dist/views/workflow/list.js +0 -129
|
@@ -6,7 +6,7 @@ import React from 'react';
|
|
|
6
6
|
import { validateDockerEnvironment, startDockerCompose, startDockerComposeDetached, stopDockerCompose, DockerComposeConfigNotFoundError, getDefaultDockerComposePath } from '#services/docker.js';
|
|
7
7
|
import { getErrorMessage } from '#utils/error_utils.js';
|
|
8
8
|
import { ensureClaudePlugin } from '#services/coding_agents.js';
|
|
9
|
-
import { DevApp } from '#views/dev.js';
|
|
9
|
+
import { DevApp } from '#views/dev/dev_app.js';
|
|
10
10
|
import { config } from '#config.js';
|
|
11
11
|
export default class Dev extends Command {
|
|
12
12
|
static description = [
|
|
@@ -57,11 +57,6 @@ export default class Dev extends Command {
|
|
|
57
57
|
catch {
|
|
58
58
|
throw new DockerComposeConfigNotFoundError(dockerComposePath);
|
|
59
59
|
}
|
|
60
|
-
this.log('\nπ Starting Output development services...\n');
|
|
61
|
-
this.log(`Docker project name: ${config.dockerServiceName}\n`);
|
|
62
|
-
if (flags['compose-file']) {
|
|
63
|
-
this.log(`Using custom docker-compose file: ${flags['compose-file']}\n`);
|
|
64
|
-
}
|
|
65
60
|
const pullPolicy = flags['image-pull-policy'];
|
|
66
61
|
if (flags.detached) {
|
|
67
62
|
this.log('π³ Starting services in detached mode...\n');
|
|
@@ -69,7 +64,6 @@ export default class Dev extends Command {
|
|
|
69
64
|
this.log('β
Services started. Run `output dev` without --detached to monitor status.\n');
|
|
70
65
|
return;
|
|
71
66
|
}
|
|
72
|
-
this.log('File watching enabled - worker will restart automatically on changes\n');
|
|
73
67
|
const cleanup = async () => {
|
|
74
68
|
this.log('\n');
|
|
75
69
|
if (this.dockerProcess) {
|
|
@@ -77,22 +71,63 @@ export default class Dev extends Command {
|
|
|
77
71
|
}
|
|
78
72
|
await stopDockerCompose(dockerComposePath);
|
|
79
73
|
};
|
|
74
|
+
// INK paints onto the alternate screen buffer so log-update has a
|
|
75
|
+
// fixed-height canvas and doesn't scroll old frames into the user's
|
|
76
|
+
// scrollback when the rendered tree exceeds the visible terminal rows.
|
|
77
|
+
const enterAltScreen = () => {
|
|
78
|
+
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H');
|
|
79
|
+
};
|
|
80
|
+
const exitAltScreen = () => {
|
|
81
|
+
process.stdout.write('\x1b[?1049l');
|
|
82
|
+
};
|
|
83
|
+
// Idempotent so repeated SIGINTs / process.exit don't re-emit the leave
|
|
84
|
+
// sequence (which produces visible garbage in some terminals).
|
|
85
|
+
const exitAltScreenOnce = (() => {
|
|
86
|
+
const state = { fired: false };
|
|
87
|
+
return () => {
|
|
88
|
+
if (state.fired) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
state.fired = true;
|
|
92
|
+
exitAltScreen();
|
|
93
|
+
};
|
|
94
|
+
})();
|
|
95
|
+
// Register cleanup before anything that can throw or get signaled. The
|
|
96
|
+
// `instance` ref is filled in once `render()` returns; until then,
|
|
97
|
+
// signal handlers just stop docker and exit.
|
|
98
|
+
const instanceRef = { current: null };
|
|
99
|
+
process.on('exit', exitAltScreenOnce);
|
|
100
|
+
// `process.on` doesn't await the handler, so the cleanup promise would
|
|
101
|
+
// float and any rejection would surface as an unhandled rejection.
|
|
102
|
+
// Wrap the async work in a sync registration that explicitly logs
|
|
103
|
+
// failures and always unmounts Ink afterwards. Exit the alt-screen
|
|
104
|
+
// first inside the catch β Ink still owns the alt-buffer until
|
|
105
|
+
// `unmount()` runs, so a bare `console.error` would paint into a
|
|
106
|
+
// buffer the user never sees.
|
|
107
|
+
const handleSignal = () => {
|
|
108
|
+
cleanup()
|
|
109
|
+
.catch(err => {
|
|
110
|
+
exitAltScreenOnce();
|
|
111
|
+
console.error('Cleanup failed:', getErrorMessage(err));
|
|
112
|
+
})
|
|
113
|
+
.finally(() => instanceRef.current?.unmount());
|
|
114
|
+
};
|
|
115
|
+
process.on('SIGINT', handleSignal);
|
|
116
|
+
process.on('SIGTERM', handleSignal);
|
|
80
117
|
try {
|
|
81
118
|
const { process: dockerProc } = await startDockerCompose(dockerComposePath, pullPolicy);
|
|
82
119
|
this.dockerProcess = dockerProc;
|
|
120
|
+
enterAltScreen();
|
|
83
121
|
const instance = render(React.createElement(DevApp, { dockerComposePath, onCleanup: cleanup }), { exitOnCtrlC: false });
|
|
122
|
+
instanceRef.current = instance;
|
|
84
123
|
dockerProc.on('error', error => {
|
|
85
124
|
instance.unmount(new Error(`Docker process error: ${getErrorMessage(error)}`));
|
|
86
125
|
});
|
|
87
|
-
const handleSignal = async () => {
|
|
88
|
-
await cleanup();
|
|
89
|
-
instance.unmount();
|
|
90
|
-
};
|
|
91
|
-
process.on('SIGINT', handleSignal);
|
|
92
|
-
process.on('SIGTERM', handleSignal);
|
|
93
126
|
await instance.waitUntilExit();
|
|
127
|
+
exitAltScreenOnce();
|
|
94
128
|
}
|
|
95
129
|
catch (error) {
|
|
130
|
+
exitAltScreenOnce();
|
|
96
131
|
this.error(getErrorMessage(error), { exit: 1 });
|
|
97
132
|
}
|
|
98
133
|
}
|
|
@@ -41,7 +41,7 @@ vi.mock('ink', () => ({
|
|
|
41
41
|
unmount: vi.fn()
|
|
42
42
|
})
|
|
43
43
|
}));
|
|
44
|
-
vi.mock('#views/dev.js', () => ({
|
|
44
|
+
vi.mock('#views/dev/dev_app.js', () => ({
|
|
45
45
|
DevApp: () => null
|
|
46
46
|
}));
|
|
47
47
|
const createMockDockerProcess = () => ({
|
|
@@ -184,7 +184,6 @@ describe('dev command', () => {
|
|
|
184
184
|
await new Promise(resolve => setImmediate(resolve));
|
|
185
185
|
expect(dockerService.startDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', 'always' // default pull policy
|
|
186
186
|
);
|
|
187
|
-
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('File watching enabled'));
|
|
188
187
|
// Cancel the promise (it will be rejected but we don't care)
|
|
189
188
|
runPromise.catch(() => { });
|
|
190
189
|
});
|
|
@@ -8,7 +8,7 @@ const STATUS_MAP = {
|
|
|
8
8
|
none: { icon: 'β', color: 'blue' },
|
|
9
9
|
exited: { icon: 'β', color: 'red' },
|
|
10
10
|
// Workflow run status
|
|
11
|
-
running: { icon: 'β', color: '
|
|
11
|
+
running: { icon: 'β', color: 'green' },
|
|
12
12
|
completed: { icon: 'β', color: 'green' },
|
|
13
13
|
failed: { icon: 'β', color: 'red' },
|
|
14
14
|
canceled: { icon: 'β', color: 'gray' },
|
package/dist/services/docker.js
CHANGED
|
@@ -133,7 +133,6 @@ export async function startDockerCompose(dockerComposePath, pullPolicy) {
|
|
|
133
133
|
if (pullPolicy) {
|
|
134
134
|
args.push('--pull', pullPolicy);
|
|
135
135
|
}
|
|
136
|
-
ux.stdout('π³ Starting Docker services...\n');
|
|
137
136
|
const dockerProcess = spawn('docker', args, {
|
|
138
137
|
cwd: process.cwd(),
|
|
139
138
|
stdio: ['ignore', 'pipe', 'pipe']
|
package/dist/utils/paths.d.ts
CHANGED
|
@@ -15,6 +15,17 @@ export declare const DEFAULT_OUTPUT_DIRS: {
|
|
|
15
15
|
* Resolve the output directory path
|
|
16
16
|
*/
|
|
17
17
|
export declare function resolveOutputDir(outputDir: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the project root that holds `src/workflows` / `workflows`.
|
|
20
|
+
*
|
|
21
|
+
* When the CLI is invoked from inside the workflow project (the common
|
|
22
|
+
* case for `output workflow run` and friends) `process.cwd()` already
|
|
23
|
+
* points at the right place. When it's invoked from the repo root with
|
|
24
|
+
* `OUTPUT_WORKFLOWS_DIR=test_workflows` (how `npm run dev` drives
|
|
25
|
+
* `output dev` in this repo), we need to descend into that subdirectory
|
|
26
|
+
* before searching for scenarios.
|
|
27
|
+
*/
|
|
28
|
+
export declare function getWorkflowsBasePath(): string;
|
|
18
29
|
/**
|
|
19
30
|
* Create target directory path for a workflow
|
|
20
31
|
*/
|
package/dist/utils/paths.js
CHANGED
|
@@ -21,6 +21,20 @@ export const DEFAULT_OUTPUT_DIRS = {
|
|
|
21
21
|
export function resolveOutputDir(outputDir) {
|
|
22
22
|
return path.resolve(process.cwd(), outputDir);
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the project root that holds `src/workflows` / `workflows`.
|
|
26
|
+
*
|
|
27
|
+
* When the CLI is invoked from inside the workflow project (the common
|
|
28
|
+
* case for `output workflow run` and friends) `process.cwd()` already
|
|
29
|
+
* points at the right place. When it's invoked from the repo root with
|
|
30
|
+
* `OUTPUT_WORKFLOWS_DIR=test_workflows` (how `npm run dev` drives
|
|
31
|
+
* `output dev` in this repo), we need to descend into that subdirectory
|
|
32
|
+
* before searching for scenarios.
|
|
33
|
+
*/
|
|
34
|
+
export function getWorkflowsBasePath() {
|
|
35
|
+
const subdir = process.env.OUTPUT_WORKFLOWS_DIR;
|
|
36
|
+
return subdir ? path.resolve(process.cwd(), subdir) : process.cwd();
|
|
37
|
+
}
|
|
24
38
|
/**
|
|
25
39
|
* Create target directory path for a workflow
|
|
26
40
|
*/
|
|
@@ -4,6 +4,7 @@ export interface ScenarioResolutionResult {
|
|
|
4
4
|
searchedPaths: string[];
|
|
5
5
|
}
|
|
6
6
|
export declare function extractWorkflowRelativePath(path: string): string | null;
|
|
7
|
-
export declare function
|
|
7
|
+
export declare function findWorkflowDirectoryFromPath(workflowPath: string | undefined, basePath?: string): string | null;
|
|
8
|
+
export declare function resolveScenarioPath(workflowName: string, scenarioName: string, basePath?: string, workflowPath?: string): Promise<ScenarioResolutionResult>;
|
|
8
9
|
export declare function listScenariosForWorkflow(workflowName: string, workflowPath?: string, basePath?: string): string[];
|
|
9
10
|
export declare function getScenarioNotFoundMessage(workflowName: string, scenarioName: string, searchedPaths: string[]): string;
|
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import { getWorkflowCatalog } from '#api/generated/api.js';
|
|
4
|
+
import { getWorkflowsBasePath } from '#utils/paths.js';
|
|
4
5
|
const SCENARIOS_DIR = 'scenarios';
|
|
5
6
|
const WORKFLOWS_PATHS = ['src/workflows', 'workflows'];
|
|
6
7
|
export function extractWorkflowRelativePath(path) {
|
|
7
|
-
const match = path.match(/workflows\/(.+)\/workflow\.[jt]s$/);
|
|
8
|
+
const match = path.match(/(?:^|\/)workflows\/(.+)\/workflow\.[jt]s$/);
|
|
8
9
|
return match ? match[1] : null;
|
|
9
10
|
}
|
|
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
|
+
function candidateScenarioDirsFromPath(workflowPath, basePath) {
|
|
22
|
+
return candidateWorkflowDirsFromPath(workflowPath, basePath)
|
|
23
|
+
.map(workflowDir => resolve(workflowDir, SCENARIOS_DIR));
|
|
24
|
+
}
|
|
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) {
|
|
11
32
|
try {
|
|
12
33
|
const response = await getWorkflowCatalog();
|
|
13
34
|
const data = response?.data;
|
|
@@ -19,11 +40,7 @@ async function fetchWorkflowDirectory(workflowName) {
|
|
|
19
40
|
if (!workflow) {
|
|
20
41
|
return null;
|
|
21
42
|
}
|
|
22
|
-
|
|
23
|
-
if (!workflowPath) {
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
return extractWorkflowRelativePath(workflowPath);
|
|
43
|
+
return workflow.path ?? null;
|
|
27
44
|
}
|
|
28
45
|
catch {
|
|
29
46
|
return null;
|
|
@@ -40,33 +57,48 @@ function resolveScenarioFromDirectory(relativeDir, scenarioFileName, basePath) {
|
|
|
40
57
|
}
|
|
41
58
|
return { found: false, searchedPaths };
|
|
42
59
|
}
|
|
43
|
-
|
|
60
|
+
function resolveScenarioFromScenarioDirs(scenariosDirs, scenarioFileName) {
|
|
61
|
+
const searchedPaths = scenariosDirs.map(dir => resolve(dir, scenarioFileName));
|
|
62
|
+
const path = searchedPaths.find(existsSync);
|
|
63
|
+
return path ?
|
|
64
|
+
{ found: true, path, searchedPaths } :
|
|
65
|
+
{ found: false, searchedPaths };
|
|
66
|
+
}
|
|
67
|
+
export async function resolveScenarioPath(workflowName, scenarioName, basePath = getWorkflowsBasePath(), workflowPath) {
|
|
44
68
|
const scenarioFileName = scenarioName.endsWith('.json') ?
|
|
45
69
|
scenarioName :
|
|
46
70
|
`${scenarioName}.json`;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
71
|
+
if (workflowPath) {
|
|
72
|
+
const pathResult = resolveScenarioFromScenarioDirs(candidateScenarioDirsFromPath(workflowPath, basePath), scenarioFileName);
|
|
73
|
+
if (pathResult.found) {
|
|
74
|
+
return pathResult;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const catalogPath = workflowPath ? null : await fetchWorkflowPath(workflowName);
|
|
78
|
+
if (catalogPath) {
|
|
79
|
+
const result = resolveScenarioFromScenarioDirs(candidateScenarioDirsFromPath(catalogPath, basePath), scenarioFileName);
|
|
50
80
|
if (result.found) {
|
|
51
81
|
return result;
|
|
52
82
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
path: fallback.path,
|
|
60
|
-
searchedPaths: [...result.searchedPaths, ...fallback.searchedPaths]
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
return result;
|
|
83
|
+
const fallback = resolveScenarioFromDirectory(workflowName, scenarioFileName, basePath);
|
|
84
|
+
return {
|
|
85
|
+
found: fallback.found,
|
|
86
|
+
path: fallback.path,
|
|
87
|
+
searchedPaths: [...result.searchedPaths, ...fallback.searchedPaths]
|
|
88
|
+
};
|
|
64
89
|
}
|
|
65
90
|
// API unavailable or workflow not in catalog β fall back to convention
|
|
66
91
|
return resolveScenarioFromDirectory(workflowName, scenarioFileName, basePath);
|
|
67
92
|
}
|
|
68
|
-
export function listScenariosForWorkflow(workflowName, workflowPath, basePath =
|
|
69
|
-
const
|
|
93
|
+
export function listScenariosForWorkflow(workflowName, workflowPath, basePath = getWorkflowsBasePath()) {
|
|
94
|
+
const scenariosDirs = workflowPath ? candidateScenarioDirsFromPath(workflowPath, basePath) : [];
|
|
95
|
+
const scenariosDir = scenariosDirs.find(existsSync);
|
|
96
|
+
if (scenariosDir) {
|
|
97
|
+
return readdirSync(scenariosDir)
|
|
98
|
+
.filter(f => f.endsWith('.json'))
|
|
99
|
+
.map(f => f.replace(/\.json$/, ''));
|
|
100
|
+
}
|
|
101
|
+
const relativeDir = workflowName;
|
|
70
102
|
for (const workflowsDir of WORKFLOWS_PATHS) {
|
|
71
103
|
const scenariosDir = resolve(basePath, workflowsDir, relativeDir, SCENARIOS_DIR);
|
|
72
104
|
if (existsSync(scenariosDir)) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { resolveScenarioPath, getScenarioNotFoundMessage, extractWorkflowRelativePath, listScenariosForWorkflow } from './scenario_resolver.js';
|
|
2
|
+
import { resolveScenarioPath, getScenarioNotFoundMessage, extractWorkflowRelativePath, findWorkflowDirectoryFromPath, listScenariosForWorkflow } from './scenario_resolver.js';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import * as api from '#api/generated/api.js';
|
|
5
5
|
vi.mock('node:fs', () => ({
|
|
@@ -32,6 +32,10 @@ describe('extractWorkflowRelativePath', () => {
|
|
|
32
32
|
expect(extractWorkflowRelativePath('/src/workflows/my_flow/workflow.ts'))
|
|
33
33
|
.toBe('my_flow');
|
|
34
34
|
});
|
|
35
|
+
it('should not match workflows inside a parent directory name', () => {
|
|
36
|
+
expect(extractWorkflowRelativePath('/app/test_workflows/dist/workflows/simple_sleep/workflow.js'))
|
|
37
|
+
.toBe('simple_sleep');
|
|
38
|
+
});
|
|
35
39
|
it('should return null for non-matching paths', () => {
|
|
36
40
|
expect(extractWorkflowRelativePath('/app/dist/other/workflow.js')).toBeNull();
|
|
37
41
|
expect(extractWorkflowRelativePath('/app/dist/workflows/')).toBeNull();
|
|
@@ -100,6 +104,15 @@ describe('resolveScenarioPath', () => {
|
|
|
100
104
|
expect(result.found).toBe(true);
|
|
101
105
|
expect(result.path).toContain('src/workflows/my_workflow/scenarios/test_scenario.json');
|
|
102
106
|
});
|
|
107
|
+
it('should resolve using a provided workflow path without requiring a workflows segment', async () => {
|
|
108
|
+
mockCatalogFailure();
|
|
109
|
+
vi.mocked(fs.existsSync).mockImplementation(path => {
|
|
110
|
+
return String(path) === '/project/src/workflows/writing/editor/scenarios/basic.json';
|
|
111
|
+
});
|
|
112
|
+
const result = await resolveScenarioPath('writing_editor', 'basic', '/project', '/app/build-output/writing/editor/workflow.js');
|
|
113
|
+
expect(result.found).toBe(true);
|
|
114
|
+
expect(result.path).toBe('/project/src/workflows/writing/editor/scenarios/basic.json');
|
|
115
|
+
});
|
|
103
116
|
});
|
|
104
117
|
describe('when workflow is not in catalog', () => {
|
|
105
118
|
it('should fall back to convention-based lookup', async () => {
|
|
@@ -158,6 +171,12 @@ describe('listScenariosForWorkflow', () => {
|
|
|
158
171
|
const result = listScenariosForWorkflow('my_workflow', undefined, '/project');
|
|
159
172
|
expect(result).toEqual(['basic', 'advanced']);
|
|
160
173
|
});
|
|
174
|
+
it('should list scenarios from a workflow path without requiring a workflows segment', () => {
|
|
175
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path) === '/project/src/workflows/writing/editor/scenarios');
|
|
176
|
+
vi.mocked(fs.readdirSync).mockReturnValue(['basic.json', 'README.md']);
|
|
177
|
+
const result = listScenariosForWorkflow('writing_editor', '/app/build-output/writing/editor/workflow.js', '/project');
|
|
178
|
+
expect(result).toEqual(['basic']);
|
|
179
|
+
});
|
|
161
180
|
it('should use workflowPath from catalog to derive directory', () => {
|
|
162
181
|
vi.mocked(fs.existsSync).mockImplementation(path => String(path).includes('src/workflows/viz_examples/01_simple_linear/scenarios'));
|
|
163
182
|
vi.mocked(fs.readdirSync).mockReturnValue(['test.json']);
|
|
@@ -197,6 +216,16 @@ describe('listScenariosForWorkflow', () => {
|
|
|
197
216
|
expect(result).toEqual([]);
|
|
198
217
|
});
|
|
199
218
|
});
|
|
219
|
+
describe('findWorkflowDirectoryFromPath', () => {
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
vi.resetAllMocks();
|
|
222
|
+
});
|
|
223
|
+
it('should find the local workflow directory from a loaded workflow path suffix', () => {
|
|
224
|
+
vi.mocked(fs.existsSync).mockImplementation(path => String(path) === '/project/src/workflows/writing/editor');
|
|
225
|
+
const result = findWorkflowDirectoryFromPath('/app/build-output/writing/editor/workflow.js', '/project');
|
|
226
|
+
expect(result).toBe('/project/src/workflows/writing/editor');
|
|
227
|
+
});
|
|
228
|
+
});
|
|
200
229
|
describe('getScenarioNotFoundMessage', () => {
|
|
201
230
|
it('should return a helpful error message', () => {
|
|
202
231
|
const searchedPaths = [
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import { RULE_PURPLE } from '#views/dev/chrome/palette.js';
|
|
4
|
+
// `RULE_PURPLE` is part of the OUTPUT brand chrome β kept as the default
|
|
5
|
+
// for horizontal rules. Vertical rules pass through whatever the caller
|
|
6
|
+
// supplies; default is undefined, which Ink renders in the terminal's
|
|
7
|
+
// default foreground colour (theme-agnostic).
|
|
8
|
+
const DEFAULT_RULE_COLOR = RULE_PURPLE;
|
|
9
|
+
// dev_app.tsx Shell uses paddingX={2}, so 4 cols are eaten by horizontal padding.
|
|
10
|
+
const SHELL_HORIZONTAL_PADDING = 4;
|
|
11
|
+
export const HorizontalRule = ({ color = DEFAULT_RULE_COLOR, widthOffset = SHELL_HORIZONTAL_PADDING }) => {
|
|
12
|
+
const { stdout } = useStdout();
|
|
13
|
+
const cols = stdout?.columns ?? 80;
|
|
14
|
+
return _jsx(Text, { color: color, children: 'β'.repeat(Math.max(1, cols - widthOffset)) });
|
|
15
|
+
};
|
|
16
|
+
export const VerticalRule = ({ color }) => (_jsx(Box, { borderStyle: "single", borderColor: color, borderTop: false, borderBottom: false, borderRight: false, flexDirection: "column" }));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface CommandHint {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const Footer: React.FC<{
|
|
7
|
+
hints: CommandHint[];
|
|
8
|
+
itemCount?: number;
|
|
9
|
+
itemLabel?: string;
|
|
10
|
+
}>;
|
|
11
|
+
export declare const GLOBAL_HINTS: CommandHint[];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
export const Footer = ({ hints, itemCount, itemLabel = 'items' }) => (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", marginTop: 1, children: [_jsx(Box, { flexDirection: "row", children: hints.map((hint, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { bold: true, children: hint.key }), _jsx(Text, { dimColor: true, children: ` ${hint.label}` })] }, hint.key))) }), typeof itemCount === 'number' && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [itemCount, " ", itemLabel] }) }))] }));
|
|
5
|
+
export const GLOBAL_HINTS = [
|
|
6
|
+
{ key: 'tab', label: 'next tab' },
|
|
7
|
+
{ key: '/', label: 'search' },
|
|
8
|
+
{ key: '?', label: 'help' },
|
|
9
|
+
{ key: 'ctrl+c', label: 'quit' }
|
|
10
|
+
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface WorkflowSummary {
|
|
3
|
+
running: number;
|
|
4
|
+
completed: number;
|
|
5
|
+
failed: number;
|
|
6
|
+
total: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const compressPixels: (rows: string[]) => string[];
|
|
9
|
+
export type ServiceBadge = 'healthy' | 'starting' | 'failed';
|
|
10
|
+
export interface HeaderCounters {
|
|
11
|
+
running: number;
|
|
12
|
+
failed: number;
|
|
13
|
+
totalWorkflows: number;
|
|
14
|
+
totalRuns: number;
|
|
15
|
+
serviceBadge: ServiceBadge;
|
|
16
|
+
failingServices: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const buildSummaryCounters: (summary: WorkflowSummary | null, totalWorkflows: number, serviceBadge?: ServiceBadge, failingServices?: number) => HeaderCounters;
|
|
19
|
+
export declare const Header: React.FC<{
|
|
20
|
+
counters: HeaderCounters;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { LOGO_GRADIENT, PURPLE_100 } from '#views/dev/chrome/palette.js';
|
|
5
|
+
const LOGO_PIXELS = [
|
|
6
|
+
' ββββββ ββ ββ ββββββββ ββββββ ββ ββ ββββββββ',
|
|
7
|
+
'ββ ββ ββ ββ ββ ββ ββ ββ ββ ββ ',
|
|
8
|
+
'ββ ββ ββ ββ ββ ββββββ ββ ββ ββ ',
|
|
9
|
+
'ββ ββ ββ ββ ββ ββ ββ ββ ββ ',
|
|
10
|
+
' ββββββ ββββββ ββ ββ ββββββ ββ '
|
|
11
|
+
];
|
|
12
|
+
const QUADRANT_CHARS = [
|
|
13
|
+
' ', 'β', 'β', 'β',
|
|
14
|
+
'β', 'β', 'β', 'β',
|
|
15
|
+
'β', 'β', 'β', 'β',
|
|
16
|
+
'β', 'β', 'β', 'β'
|
|
17
|
+
];
|
|
18
|
+
export const compressPixels = (rows) => {
|
|
19
|
+
const maxCol = Math.max(...rows.map(r => r.length));
|
|
20
|
+
const evenCol = maxCol + (maxCol % 2);
|
|
21
|
+
const padded = rows.map(r => r.padEnd(evenCol, ' '));
|
|
22
|
+
const fullPadded = padded.length % 2 === 1 ?
|
|
23
|
+
[...padded, ' '.repeat(evenCol)] :
|
|
24
|
+
padded;
|
|
25
|
+
const rowPairs = fullPadded.reduce((acc, row, i) => {
|
|
26
|
+
if (i % 2 === 0) {
|
|
27
|
+
acc.push([row, fullPadded[i + 1]]);
|
|
28
|
+
}
|
|
29
|
+
return acc;
|
|
30
|
+
}, []);
|
|
31
|
+
const colCount = Math.floor(evenCol / 2);
|
|
32
|
+
return rowPairs.map(([top, bot]) => {
|
|
33
|
+
const chars = Array.from({ length: colCount }, (_, k) => {
|
|
34
|
+
const j = k * 2;
|
|
35
|
+
const tl = top[j] === 'β' ? 8 : 0;
|
|
36
|
+
const tr = top[j + 1] === 'β' ? 4 : 0;
|
|
37
|
+
const bl = bot[j] === 'β' ? 2 : 0;
|
|
38
|
+
const br = bot[j + 1] === 'β' ? 1 : 0;
|
|
39
|
+
return QUADRANT_CHARS[tl + tr + bl + br];
|
|
40
|
+
});
|
|
41
|
+
return chars.join('');
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
const LOGO_COMPRESSED = compressPixels(LOGO_PIXELS);
|
|
45
|
+
const FULL_WIDTH_THRESHOLD = 60;
|
|
46
|
+
const ServicesBadge = ({ badge, failingCount }) => {
|
|
47
|
+
if (badge === 'failed') {
|
|
48
|
+
return (_jsxs(Text, { color: "red", bold: true, children: ["\u26A0 ", failingCount, " service", failingCount === 1 ? '' : 's', " down"] }));
|
|
49
|
+
}
|
|
50
|
+
if (badge === 'starting') {
|
|
51
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " services" })] }));
|
|
52
|
+
}
|
|
53
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", children: "\u25CF " }), _jsx(Text, { children: "services" })] }));
|
|
54
|
+
};
|
|
55
|
+
const Counters = ({ counters }) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { marginRight: 3, children: _jsx(ServicesBadge, { badge: counters.serviceBadge, failingCount: counters.failingServices }) }), counters.running > 0 && (_jsxs(Box, { marginRight: 3, children: [_jsx(Text, { color: "blue", children: "\u25CF " }), _jsxs(Text, { bold: true, children: [counters.running, " "] }), _jsx(Text, { children: "running" })] })), counters.failed > 0 && (_jsxs(Box, { marginRight: 3, children: [_jsx(Text, { color: "red", children: "\u2717 " }), _jsxs(Text, { color: "red", bold: true, children: [counters.failed, " "] }), _jsx(Text, { color: "red", children: "failed" })] })), _jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [counters.totalWorkflows, " workflows \u00B7 ", counters.totalRuns, " runs"] }) })] }));
|
|
56
|
+
const Logo = ({ cols }) => {
|
|
57
|
+
if (cols < FULL_WIDTH_THRESHOLD) {
|
|
58
|
+
return _jsx(Text, { color: PURPLE_100, bold: true, children: "OUTPUT" });
|
|
59
|
+
}
|
|
60
|
+
return (_jsx(Box, { flexDirection: "column", children: LOGO_COMPRESSED.map((line, i) => (_jsx(Text, { color: LOGO_GRADIENT[i] ?? PURPLE_100, bold: true, children: line }, i))) }));
|
|
61
|
+
};
|
|
62
|
+
export const buildSummaryCounters = (summary, totalWorkflows, serviceBadge = 'starting', failingServices = 0) => ({
|
|
63
|
+
running: summary?.running ?? 0,
|
|
64
|
+
failed: summary?.failed ?? 0,
|
|
65
|
+
totalWorkflows,
|
|
66
|
+
totalRuns: summary?.total ?? 0,
|
|
67
|
+
serviceBadge,
|
|
68
|
+
failingServices
|
|
69
|
+
});
|
|
70
|
+
export const Header = ({ counters }) => {
|
|
71
|
+
const { stdout } = useStdout();
|
|
72
|
+
const cols = stdout?.columns ?? 120;
|
|
73
|
+
return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", children: [_jsx(Logo, { cols: cols }), _jsx(Box, { flexDirection: "column", paddingTop: 1, children: _jsx(Counters, { counters: counters }) })] }));
|
|
74
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { compressPixels } from './header.js';
|
|
3
|
+
describe('compressPixels', () => {
|
|
4
|
+
it('returns an empty array for empty input', () => {
|
|
5
|
+
expect(compressPixels([])).toEqual([]);
|
|
6
|
+
});
|
|
7
|
+
it('compresses a 2x2 full block to a single full block', () => {
|
|
8
|
+
expect(compressPixels(['ββ', 'ββ'])).toEqual(['β']);
|
|
9
|
+
});
|
|
10
|
+
it('maps each quadrant character correctly', () => {
|
|
11
|
+
// Each row pair decodes to: top-left, top-right, bottom-left, bottom-right
|
|
12
|
+
const cases = [
|
|
13
|
+
[[' ', ' '], ' '], // 0000
|
|
14
|
+
[[' ', ' β'], 'β'], // 0001 bottom-right
|
|
15
|
+
[[' ', 'β '], 'β'], // 0010 bottom-left
|
|
16
|
+
[[' ', 'ββ'], 'β'], // 0011 bottom half
|
|
17
|
+
[[' β', ' '], 'β'], // 0100 top-right
|
|
18
|
+
[[' β', ' β'], 'β'], // 0101 right half
|
|
19
|
+
[[' β', 'β '], 'β'], // 0110 anti-diagonal
|
|
20
|
+
[[' β', 'ββ'], 'β'], // 0111
|
|
21
|
+
[['β ', ' '], 'β'], // 1000 top-left
|
|
22
|
+
[['β ', ' β'], 'β'], // 1001 diagonal
|
|
23
|
+
[['β ', 'β '], 'β'], // 1010 left half
|
|
24
|
+
[['β ', 'ββ'], 'β'], // 1011
|
|
25
|
+
[['ββ', ' '], 'β'], // 1100 top half
|
|
26
|
+
[['ββ', ' β'], 'β'], // 1101
|
|
27
|
+
[['ββ', 'β '], 'β'], // 1110
|
|
28
|
+
[['ββ', 'ββ'], 'β'] // 1111 full
|
|
29
|
+
];
|
|
30
|
+
for (const [input, expected] of cases) {
|
|
31
|
+
expect(compressPixels(input)).toEqual([expected]);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
it('pads odd-length rows with an empty bottom row', () => {
|
|
35
|
+
// Single row of 4 cols compresses to 1 row of 2 cols
|
|
36
|
+
expect(compressPixels(['ββββ'])).toEqual(['ββ']);
|
|
37
|
+
});
|
|
38
|
+
it('pads odd-width columns with a trailing space', () => {
|
|
39
|
+
// 3 cols compresses to 2 cols (even-padded)
|
|
40
|
+
expect(compressPixels(['βββ', 'βββ'])).toEqual(['ββ']);
|
|
41
|
+
});
|
|
42
|
+
it('compresses 5-row input to 3 rows', () => {
|
|
43
|
+
const five = ['ββ', 'ββ', 'ββ', 'ββ', 'ββ'];
|
|
44
|
+
const result = compressPixels(five);
|
|
45
|
+
expect(result.length).toBe(3);
|
|
46
|
+
expect(result[0]).toBe('β');
|
|
47
|
+
expect(result[1]).toBe('β');
|
|
48
|
+
expect(result[2]).toBe('β');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The dim "<spinner> loadingβ¦" row used by every panel and modal that
|
|
4
|
+
* waits on async data. Single source of truth so the visual stays
|
|
5
|
+
* uniform.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LoadingSpinner: React.FC<{
|
|
8
|
+
label?: string;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
/**
|
|
5
|
+
* The dim "<spinner> loadingβ¦" row used by every panel and modal that
|
|
6
|
+
* waits on async data. Single source of truth so the visual stays
|
|
7
|
+
* uniform.
|
|
8
|
+
*/
|
|
9
|
+
export const LoadingSpinner = ({ label = 'loadingβ¦' }) => (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { dimColor: true, children: [" ", label] })] }));
|