@outputai/cli 0.3.3-next.b4a190e.0 → 0.3.3-next.e8eff63.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 +24 -4
- package/dist/commands/dev/index.spec.js +96 -10
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/docker.d.ts +9 -3
- package/dist/services/docker.js +27 -4
- package/dist/services/docker.spec.js +62 -3
- package/dist/utils/scenario_resolver.d.ts +2 -1
- package/dist/utils/scenario_resolver.js +55 -24
- package/dist/utils/scenario_resolver.spec.js +30 -1
- package/dist/views/dev/dev_app.js +1 -1
- package/dist/views/dev/modals/run_modal.d.ts +1 -0
- package/dist/views/dev/modals/run_modal.js +5 -5
- package/dist/views/dev/panels/workflows_panel.js +1 -1
- package/dist/views/dev/services/scenario_io.d.ts +2 -2
- package/dist/views/dev/services/scenario_io.js +10 -6
- package/dist/views/dev/state/ui_state.d.ts +2 -1
- package/dist/views/dev/state/ui_state.js +1 -1
- package/package.json +4 -4
|
@@ -64,7 +64,11 @@ export default class Dev extends Command {
|
|
|
64
64
|
this.log('✅ Services started. Run `output dev` without --detached to monitor status.\n');
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
|
+
const state = {
|
|
68
|
+
cleaningUp: false
|
|
69
|
+
};
|
|
67
70
|
const cleanup = async () => {
|
|
71
|
+
state.cleaningUp = true;
|
|
68
72
|
this.log('\n');
|
|
69
73
|
if (this.dockerProcess) {
|
|
70
74
|
this.dockerProcess.kill('SIGTERM');
|
|
@@ -115,18 +119,34 @@ export default class Dev extends Command {
|
|
|
115
119
|
process.on('SIGINT', handleSignal);
|
|
116
120
|
process.on('SIGTERM', handleSignal);
|
|
117
121
|
try {
|
|
118
|
-
const { process: dockerProc } = await startDockerCompose(dockerComposePath, pullPolicy);
|
|
119
|
-
this.dockerProcess = dockerProc;
|
|
120
122
|
enterAltScreen();
|
|
121
123
|
const instance = render(React.createElement(DevApp, { dockerComposePath, onCleanup: cleanup }), { exitOnCtrlC: false });
|
|
122
124
|
instanceRef.current = instance;
|
|
123
|
-
dockerProc
|
|
124
|
-
|
|
125
|
+
const dockerProc = await startDockerCompose({
|
|
126
|
+
dockerComposePath,
|
|
127
|
+
pullPolicy,
|
|
128
|
+
onError: error => {
|
|
129
|
+
instance.unmount(new Error(`Docker process error: ${getErrorMessage(error)}`));
|
|
130
|
+
},
|
|
131
|
+
onExit: (code, signal, output) => {
|
|
132
|
+
if (state.cleaningUp) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (code === 0) {
|
|
136
|
+
instance.unmount();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const exitReason = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
|
|
140
|
+
const detail = output ? `\n\nRecent Docker output:\n${output}` : '';
|
|
141
|
+
instance.unmount(new Error(`Docker compose exited with ${exitReason}.${detail}`));
|
|
142
|
+
}
|
|
125
143
|
});
|
|
144
|
+
this.dockerProcess = dockerProc;
|
|
126
145
|
await instance.waitUntilExit();
|
|
127
146
|
exitAltScreenOnce();
|
|
128
147
|
}
|
|
129
148
|
catch (error) {
|
|
149
|
+
instanceRef.current?.unmount();
|
|
130
150
|
exitAltScreenOnce();
|
|
131
151
|
this.error(getErrorMessage(error), { exit: 1 });
|
|
132
152
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import React from 'react';
|
|
2
3
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
4
|
import fs from 'node:fs/promises';
|
|
5
|
+
import { render } from 'ink';
|
|
4
6
|
import * as dockerService from '#services/docker.js';
|
|
5
7
|
import * as codingAgentsService from '#services/coding_agents.js';
|
|
6
8
|
import Dev from './index.js';
|
|
@@ -45,13 +47,36 @@ vi.mock('#views/dev/dev_app.js', () => ({
|
|
|
45
47
|
DevApp: () => null
|
|
46
48
|
}));
|
|
47
49
|
const createMockDockerProcess = () => ({
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
stderr: { on: vi.fn() }
|
|
53
|
-
}
|
|
50
|
+
on: vi.fn(),
|
|
51
|
+
kill: vi.fn(),
|
|
52
|
+
stdout: { on: vi.fn() },
|
|
53
|
+
stderr: { on: vi.fn() }
|
|
54
54
|
});
|
|
55
|
+
const getStartDockerComposeOptions = () => {
|
|
56
|
+
const options = vi.mocked(dockerService.startDockerCompose).mock.calls.at(-1)?.[0];
|
|
57
|
+
if (!options) {
|
|
58
|
+
throw new Error('Expected startDockerCompose to receive options');
|
|
59
|
+
}
|
|
60
|
+
return options;
|
|
61
|
+
};
|
|
62
|
+
const createControllableInkInstance = () => {
|
|
63
|
+
const deferred = {};
|
|
64
|
+
deferred.promise = new Promise((resolve, reject) => {
|
|
65
|
+
deferred.resolve = resolve;
|
|
66
|
+
deferred.reject = reject;
|
|
67
|
+
});
|
|
68
|
+
const instance = {
|
|
69
|
+
waitUntilExit: vi.fn(() => deferred.promise),
|
|
70
|
+
unmount: vi.fn((error) => {
|
|
71
|
+
if (error) {
|
|
72
|
+
deferred.reject(error);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
deferred.resolve();
|
|
76
|
+
})
|
|
77
|
+
};
|
|
78
|
+
return instance;
|
|
79
|
+
};
|
|
55
80
|
describe('dev command', () => {
|
|
56
81
|
beforeEach(() => {
|
|
57
82
|
vi.clearAllMocks();
|
|
@@ -182,8 +207,12 @@ describe('dev command', () => {
|
|
|
182
207
|
const runPromise = cmd.run();
|
|
183
208
|
// Wait a tick for startDockerCompose to be called
|
|
184
209
|
await new Promise(resolve => setImmediate(resolve));
|
|
185
|
-
expect(dockerService.startDockerCompose).toHaveBeenCalledWith(
|
|
186
|
-
|
|
210
|
+
expect(dockerService.startDockerCompose).toHaveBeenCalledWith(expect.objectContaining({
|
|
211
|
+
dockerComposePath: '/path/to/docker-compose-dev.yml',
|
|
212
|
+
pullPolicy: 'always',
|
|
213
|
+
onError: expect.any(Function),
|
|
214
|
+
onExit: expect.any(Function)
|
|
215
|
+
}));
|
|
187
216
|
// Cancel the promise (it will be rejected but we don't care)
|
|
188
217
|
runPromise.catch(() => { });
|
|
189
218
|
});
|
|
@@ -207,6 +236,53 @@ describe('dev command', () => {
|
|
|
207
236
|
await cmd.run();
|
|
208
237
|
expect(cmd.error).toHaveBeenCalledWith('Docker error', { exit: 1 });
|
|
209
238
|
});
|
|
239
|
+
it('should surface docker compose exit failures with recent output', async () => {
|
|
240
|
+
const dockerProcess = createMockDockerProcess();
|
|
241
|
+
vi.mocked(dockerService.startDockerCompose).mockResolvedValue(dockerProcess);
|
|
242
|
+
const inkInstance = createControllableInkInstance();
|
|
243
|
+
vi.mocked(render).mockReturnValue(inkInstance);
|
|
244
|
+
const cmd = new Dev([], {});
|
|
245
|
+
cmd.log = vi.fn();
|
|
246
|
+
cmd.error = vi.fn();
|
|
247
|
+
Object.defineProperty(cmd, 'parse', {
|
|
248
|
+
value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
|
|
249
|
+
configurable: true
|
|
250
|
+
});
|
|
251
|
+
const runPromise = cmd.run();
|
|
252
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
253
|
+
getStartDockerComposeOptions().onExit?.(1, null, 'failed to bind host port');
|
|
254
|
+
await runPromise;
|
|
255
|
+
expect(inkInstance.unmount).toHaveBeenCalledWith(expect.objectContaining({
|
|
256
|
+
message: expect.stringContaining('Docker compose exited with code 1')
|
|
257
|
+
}));
|
|
258
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Recent Docker output:\nfailed to bind host port'), { exit: 1 });
|
|
259
|
+
});
|
|
260
|
+
it('should ignore docker compose exits triggered by cleanup', async () => {
|
|
261
|
+
const dockerProcess = createMockDockerProcess();
|
|
262
|
+
vi.mocked(dockerService.startDockerCompose).mockResolvedValue(dockerProcess);
|
|
263
|
+
const inkInstance = createControllableInkInstance();
|
|
264
|
+
vi.mocked(render).mockReturnValue(inkInstance);
|
|
265
|
+
const cmd = new Dev([], {});
|
|
266
|
+
cmd.log = vi.fn();
|
|
267
|
+
cmd.error = vi.fn();
|
|
268
|
+
Object.defineProperty(cmd, 'parse', {
|
|
269
|
+
value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
|
|
270
|
+
configurable: true
|
|
271
|
+
});
|
|
272
|
+
const runPromise = cmd.run();
|
|
273
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
274
|
+
const appElement = vi.mocked(render).mock.calls[0]?.[0];
|
|
275
|
+
if (!React.isValidElement(appElement)) {
|
|
276
|
+
throw new Error('Expected render to receive a React element');
|
|
277
|
+
}
|
|
278
|
+
const appProps = appElement.props;
|
|
279
|
+
await appProps.onCleanup();
|
|
280
|
+
getStartDockerComposeOptions().onExit?.(null, 'SIGTERM', 'compose stopped');
|
|
281
|
+
inkInstance.unmount();
|
|
282
|
+
await runPromise;
|
|
283
|
+
expect(inkInstance.unmount).not.toHaveBeenCalledWith(expect.any(Error));
|
|
284
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
210
286
|
});
|
|
211
287
|
describe('image pull policy', () => {
|
|
212
288
|
it('should pass pull policy to startDockerCompose', async () => {
|
|
@@ -222,7 +298,12 @@ describe('dev command', () => {
|
|
|
222
298
|
const runPromise = cmd.run();
|
|
223
299
|
// Wait a tick for startDockerCompose to be called
|
|
224
300
|
await new Promise(resolve => setImmediate(resolve));
|
|
225
|
-
expect(dockerService.startDockerCompose).toHaveBeenCalledWith(
|
|
301
|
+
expect(dockerService.startDockerCompose).toHaveBeenCalledWith(expect.objectContaining({
|
|
302
|
+
dockerComposePath: '/path/to/docker-compose-dev.yml',
|
|
303
|
+
pullPolicy: 'missing',
|
|
304
|
+
onError: expect.any(Function),
|
|
305
|
+
onExit: expect.any(Function)
|
|
306
|
+
}));
|
|
226
307
|
// Cancel the promise (it will be rejected but we don't care)
|
|
227
308
|
runPromise.catch(() => { });
|
|
228
309
|
});
|
|
@@ -239,7 +320,12 @@ describe('dev command', () => {
|
|
|
239
320
|
const runPromise = cmd.run();
|
|
240
321
|
// Wait a tick for startDockerCompose to be called
|
|
241
322
|
await new Promise(resolve => setImmediate(resolve));
|
|
242
|
-
expect(dockerService.startDockerCompose).toHaveBeenCalledWith(
|
|
323
|
+
expect(dockerService.startDockerCompose).toHaveBeenCalledWith(expect.objectContaining({
|
|
324
|
+
dockerComposePath: '/path/to/docker-compose-dev.yml',
|
|
325
|
+
pullPolicy: 'never',
|
|
326
|
+
onError: expect.any(Function),
|
|
327
|
+
onExit: expect.any(Function)
|
|
328
|
+
}));
|
|
243
329
|
// Cancel the promise (it will be rejected but we don't care)
|
|
244
330
|
runPromise.catch(() => { });
|
|
245
331
|
});
|
|
@@ -7,6 +7,7 @@ export declare const SERVICE_HEALTH: {
|
|
|
7
7
|
};
|
|
8
8
|
export declare const SERVICE_STATE: {
|
|
9
9
|
readonly RUNNING: "running";
|
|
10
|
+
readonly CREATED: "created";
|
|
10
11
|
readonly EXITED: "exited";
|
|
11
12
|
};
|
|
12
13
|
declare class DockerValidationError extends Error {
|
|
@@ -28,11 +29,16 @@ export declare function getServiceStatus(dockerComposePath: string): Promise<Ser
|
|
|
28
29
|
export declare function isServiceHealthy(service: ServiceStatus): boolean;
|
|
29
30
|
export declare function isServiceFailed(service: ServiceStatus): boolean;
|
|
30
31
|
export declare function waitForServicesHealthy(dockerComposePath: string, timeoutMs?: number, pollIntervalMs?: number): Promise<void>;
|
|
31
|
-
export interface
|
|
32
|
-
|
|
32
|
+
export interface DockerComposeHandlers {
|
|
33
|
+
onError?: (error: Error, output: string) => void;
|
|
34
|
+
onExit?: (code: number | null, signal: NodeJS.Signals | null, output: string) => void;
|
|
33
35
|
}
|
|
34
36
|
export type PullPolicy = 'always' | 'missing' | 'never';
|
|
35
|
-
export
|
|
37
|
+
export interface StartDockerComposeOptions extends DockerComposeHandlers {
|
|
38
|
+
dockerComposePath: string;
|
|
39
|
+
pullPolicy?: PullPolicy;
|
|
40
|
+
}
|
|
41
|
+
export declare function startDockerCompose({ dockerComposePath, pullPolicy, onError, onExit }: StartDockerComposeOptions): Promise<ChildProcess>;
|
|
36
42
|
export declare function startDockerComposeDetached(dockerComposePath: string, pullPolicy?: PullPolicy): void;
|
|
37
43
|
export declare function stopDockerCompose(dockerComposePath: string): Promise<void>;
|
|
38
44
|
export { isDockerInstalled, DockerValidationError };
|
package/dist/services/docker.js
CHANGED
|
@@ -13,6 +13,7 @@ export const SERVICE_HEALTH = {
|
|
|
13
13
|
};
|
|
14
14
|
export const SERVICE_STATE = {
|
|
15
15
|
RUNNING: 'running',
|
|
16
|
+
CREATED: 'created',
|
|
16
17
|
EXITED: 'exited'
|
|
17
18
|
};
|
|
18
19
|
class DockerValidationError extends Error {
|
|
@@ -101,11 +102,17 @@ export function parseServiceStatus(jsonOutput) {
|
|
|
101
102
|
});
|
|
102
103
|
}
|
|
103
104
|
export async function getServiceStatus(dockerComposePath) {
|
|
104
|
-
const result = execFileSync('docker', [
|
|
105
|
+
const result = execFileSync('docker', [
|
|
106
|
+
'compose',
|
|
107
|
+
'-f', dockerComposePath,
|
|
108
|
+
'--project-directory', process.cwd(),
|
|
109
|
+
'--project-name', config.dockerServiceName,
|
|
110
|
+
'ps', '--all', '--format', 'json'
|
|
111
|
+
], { encoding: 'utf-8', cwd: process.cwd() });
|
|
105
112
|
return parseServiceStatus(result);
|
|
106
113
|
}
|
|
107
114
|
export function isServiceHealthy(service) {
|
|
108
|
-
return service.state
|
|
115
|
+
return service.state === SERVICE_STATE.RUNNING &&
|
|
109
116
|
(service.health === SERVICE_HEALTH.HEALTHY || service.health === SERVICE_HEALTH.NONE);
|
|
110
117
|
}
|
|
111
118
|
export function isServiceFailed(service) {
|
|
@@ -122,7 +129,7 @@ export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 1200
|
|
|
122
129
|
}
|
|
123
130
|
throw new Error('Timeout waiting for services to become healthy');
|
|
124
131
|
}
|
|
125
|
-
export async function startDockerCompose(dockerComposePath, pullPolicy) {
|
|
132
|
+
export async function startDockerCompose({ dockerComposePath, pullPolicy, onError, onExit }) {
|
|
126
133
|
const args = [
|
|
127
134
|
'compose',
|
|
128
135
|
'-f', dockerComposePath,
|
|
@@ -133,11 +140,27 @@ export async function startDockerCompose(dockerComposePath, pullPolicy) {
|
|
|
133
140
|
if (pullPolicy) {
|
|
134
141
|
args.push('--pull', pullPolicy);
|
|
135
142
|
}
|
|
143
|
+
const output = {
|
|
144
|
+
value: ''
|
|
145
|
+
};
|
|
146
|
+
const appendOutput = (chunk) => {
|
|
147
|
+
output.value = `${output.value}${chunk.toString()}`.slice(-20000).trimStart();
|
|
148
|
+
};
|
|
136
149
|
const dockerProcess = spawn('docker', args, {
|
|
137
150
|
cwd: process.cwd(),
|
|
151
|
+
// The Ink dev UI owns the terminal. Drain compose output so Docker cannot
|
|
152
|
+
// block on a full pipe, while keeping recent output for startup failures.
|
|
138
153
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
139
154
|
});
|
|
140
|
-
|
|
155
|
+
dockerProcess.stdout?.on('data', appendOutput);
|
|
156
|
+
dockerProcess.stderr?.on('data', appendOutput);
|
|
157
|
+
if (onError) {
|
|
158
|
+
dockerProcess.on('error', error => onError(error, output.value.trimEnd()));
|
|
159
|
+
}
|
|
160
|
+
if (onExit) {
|
|
161
|
+
dockerProcess.on('exit', (code, signal) => onExit(code, signal, output.value.trimEnd()));
|
|
162
|
+
}
|
|
163
|
+
return dockerProcess;
|
|
141
164
|
}
|
|
142
165
|
export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
|
|
143
166
|
const args = [
|
|
@@ -6,6 +6,7 @@ vi.mock('node:child_process', () => ({
|
|
|
6
6
|
execFileSync: vi.fn(),
|
|
7
7
|
spawn: vi.fn()
|
|
8
8
|
}));
|
|
9
|
+
const mockChildProcess = (process) => process;
|
|
9
10
|
vi.mock('log-update', () => {
|
|
10
11
|
const fn = vi.fn();
|
|
11
12
|
fn.done = vi.fn();
|
|
@@ -14,6 +15,11 @@ vi.mock('log-update', () => {
|
|
|
14
15
|
describe('docker service', () => {
|
|
15
16
|
beforeEach(() => {
|
|
16
17
|
vi.clearAllMocks();
|
|
18
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess({
|
|
19
|
+
on: vi.fn(),
|
|
20
|
+
stdout: { on: vi.fn() },
|
|
21
|
+
stderr: { on: vi.fn() }
|
|
22
|
+
}));
|
|
17
23
|
});
|
|
18
24
|
afterEach(() => {
|
|
19
25
|
vi.restoreAllMocks();
|
|
@@ -73,7 +79,12 @@ describe('docker service', () => {
|
|
|
73
79
|
const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}';
|
|
74
80
|
vi.mocked(execFileSync).mockReturnValue(mockOutput);
|
|
75
81
|
await getServiceStatus('/path/to/docker-compose.yml');
|
|
76
|
-
expect(execFileSync).toHaveBeenCalledWith('docker', [
|
|
82
|
+
expect(execFileSync).toHaveBeenCalledWith('docker', [
|
|
83
|
+
'compose', '-f', '/path/to/docker-compose.yml',
|
|
84
|
+
'--project-directory', process.cwd(),
|
|
85
|
+
'--project-name', 'output-sdk',
|
|
86
|
+
'ps', '--all', '--format', 'json'
|
|
87
|
+
], expect.objectContaining({ encoding: 'utf-8' }));
|
|
77
88
|
});
|
|
78
89
|
it('should return parsed service status', async () => {
|
|
79
90
|
const mockOutput = '{"Service":"redis","State":"running","Health":"healthy","Publishers":[{"PublishedPort":6379,"TargetPort":6379}]}';
|
|
@@ -91,7 +102,7 @@ describe('docker service', () => {
|
|
|
91
102
|
});
|
|
92
103
|
describe('startDockerCompose', () => {
|
|
93
104
|
it('should pass --project-name to docker compose up', async () => {
|
|
94
|
-
await startDockerCompose('/path/to/docker-compose.yml');
|
|
105
|
+
await startDockerCompose({ dockerComposePath: '/path/to/docker-compose.yml' });
|
|
95
106
|
expect(spawn).toHaveBeenCalledWith('docker', [
|
|
96
107
|
'compose', '-f', '/path/to/docker-compose.yml',
|
|
97
108
|
'--project-directory', process.cwd(),
|
|
@@ -100,7 +111,7 @@ describe('docker service', () => {
|
|
|
100
111
|
], expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], cwd: process.cwd() }));
|
|
101
112
|
});
|
|
102
113
|
it('should append --pull when pullPolicy is provided', async () => {
|
|
103
|
-
await startDockerCompose('/path/to/docker-compose.yml', 'always');
|
|
114
|
+
await startDockerCompose({ dockerComposePath: '/path/to/docker-compose.yml', pullPolicy: 'always' });
|
|
104
115
|
expect(spawn).toHaveBeenCalledWith('docker', [
|
|
105
116
|
'compose', '-f', '/path/to/docker-compose.yml',
|
|
106
117
|
'--project-directory', process.cwd(),
|
|
@@ -108,6 +119,51 @@ describe('docker service', () => {
|
|
|
108
119
|
'up', '--pull', 'always'
|
|
109
120
|
], expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], cwd: process.cwd() }));
|
|
110
121
|
});
|
|
122
|
+
it('should attach docker process handlers and pass captured output', async () => {
|
|
123
|
+
const onError = vi.fn();
|
|
124
|
+
const onExit = vi.fn();
|
|
125
|
+
const processHandlers = {};
|
|
126
|
+
const streamHandlers = {};
|
|
127
|
+
const process = {
|
|
128
|
+
on: vi.fn((event, handler) => {
|
|
129
|
+
if (event === 'error') {
|
|
130
|
+
processHandlers.error = handler;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
processHandlers.exit = handler;
|
|
134
|
+
}
|
|
135
|
+
return process;
|
|
136
|
+
}),
|
|
137
|
+
stdout: {
|
|
138
|
+
on: vi.fn((event, handler) => {
|
|
139
|
+
streamHandlers.stdout = handler;
|
|
140
|
+
return process.stdout;
|
|
141
|
+
})
|
|
142
|
+
},
|
|
143
|
+
stderr: {
|
|
144
|
+
on: vi.fn((event, handler) => {
|
|
145
|
+
streamHandlers.stderr = handler;
|
|
146
|
+
return process.stderr;
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess(process));
|
|
151
|
+
const dockerProcess = await startDockerCompose({
|
|
152
|
+
dockerComposePath: '/path/to/docker-compose.yml',
|
|
153
|
+
pullPolicy: 'always',
|
|
154
|
+
onError,
|
|
155
|
+
onExit
|
|
156
|
+
});
|
|
157
|
+
expect(dockerProcess.on).toHaveBeenCalledWith('error', expect.any(Function));
|
|
158
|
+
expect(dockerProcess.on).toHaveBeenCalledWith('exit', expect.any(Function));
|
|
159
|
+
streamHandlers.stdout?.(Buffer.from('starting services\n'));
|
|
160
|
+
streamHandlers.stderr?.(Buffer.from('compose failed\n'));
|
|
161
|
+
const error = new Error('Docker failed');
|
|
162
|
+
processHandlers.error?.(error);
|
|
163
|
+
processHandlers.exit?.(1, null);
|
|
164
|
+
expect(onError).toHaveBeenCalledWith(error, 'starting services\ncompose failed');
|
|
165
|
+
expect(onExit).toHaveBeenCalledWith(1, null, 'starting services\ncompose failed');
|
|
166
|
+
});
|
|
111
167
|
});
|
|
112
168
|
describe('startDockerComposeDetached', () => {
|
|
113
169
|
it('should pass --project-name and -d to docker compose up', () => {
|
|
@@ -176,6 +232,9 @@ describe('docker service', () => {
|
|
|
176
232
|
it('should return false for an exited service with health: unhealthy', () => {
|
|
177
233
|
expect(isServiceHealthy({ name: 'worker', state: 'exited', health: 'unhealthy', ports: [] })).toBe(false);
|
|
178
234
|
});
|
|
235
|
+
it('should return false for a created service with no health check', () => {
|
|
236
|
+
expect(isServiceHealthy({ name: 'api', state: 'created', health: 'none', ports: [] })).toBe(false);
|
|
237
|
+
});
|
|
179
238
|
it('should return false for a service with health: starting', () => {
|
|
180
239
|
expect(isServiceHealthy({ name: 'temporal', state: 'running', health: 'starting', ports: [] })).toBe(false);
|
|
181
240
|
});
|
|
@@ -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,14 +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
4
|
import { getWorkflowsBasePath } from '#utils/paths.js';
|
|
5
5
|
const SCENARIOS_DIR = 'scenarios';
|
|
6
6
|
const WORKFLOWS_PATHS = ['src/workflows', 'workflows'];
|
|
7
7
|
export function extractWorkflowRelativePath(path) {
|
|
8
|
-
const match = path.match(/workflows\/(.+)\/workflow\.[jt]s$/);
|
|
8
|
+
const match = path.match(/(?:^|\/)workflows\/(.+)\/workflow\.[jt]s$/);
|
|
9
9
|
return match ? match[1] : null;
|
|
10
10
|
}
|
|
11
|
-
|
|
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) {
|
|
12
32
|
try {
|
|
13
33
|
const response = await getWorkflowCatalog();
|
|
14
34
|
const data = response?.data;
|
|
@@ -20,11 +40,7 @@ async function fetchWorkflowDirectory(workflowName) {
|
|
|
20
40
|
if (!workflow) {
|
|
21
41
|
return null;
|
|
22
42
|
}
|
|
23
|
-
|
|
24
|
-
if (!workflowPath) {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
return extractWorkflowRelativePath(workflowPath);
|
|
43
|
+
return workflow.path ?? null;
|
|
28
44
|
}
|
|
29
45
|
catch {
|
|
30
46
|
return null;
|
|
@@ -41,33 +57,48 @@ function resolveScenarioFromDirectory(relativeDir, scenarioFileName, basePath) {
|
|
|
41
57
|
}
|
|
42
58
|
return { found: false, searchedPaths };
|
|
43
59
|
}
|
|
44
|
-
|
|
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) {
|
|
45
68
|
const scenarioFileName = scenarioName.endsWith('.json') ?
|
|
46
69
|
scenarioName :
|
|
47
70
|
`${scenarioName}.json`;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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);
|
|
51
80
|
if (result.found) {
|
|
52
81
|
return result;
|
|
53
82
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
path: fallback.path,
|
|
61
|
-
searchedPaths: [...result.searchedPaths, ...fallback.searchedPaths]
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
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
|
+
};
|
|
65
89
|
}
|
|
66
90
|
// API unavailable or workflow not in catalog — fall back to convention
|
|
67
91
|
return resolveScenarioFromDirectory(workflowName, scenarioFileName, basePath);
|
|
68
92
|
}
|
|
69
93
|
export function listScenariosForWorkflow(workflowName, workflowPath, basePath = getWorkflowsBasePath()) {
|
|
70
|
-
const
|
|
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;
|
|
71
102
|
for (const workflowsDir of WORKFLOWS_PATHS) {
|
|
72
103
|
const scenariosDir = resolve(basePath, workflowsDir, relativeDir, SCENARIOS_DIR);
|
|
73
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 = [
|
|
@@ -141,6 +141,6 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
|
141
141
|
if (ui.expandedJson.open) {
|
|
142
142
|
return (_jsx(Box, { flexDirection: "column", height: rows, paddingX: 2, paddingTop: 1, children: _jsx(ExpandedJsonModal, {}) }));
|
|
143
143
|
}
|
|
144
|
-
return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 2, paddingTop: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab }), _jsx(HorizontalRule, {}), _jsx(SearchBar, { active: ui.search.open }), _jsx(Toasts, {}), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [ui.tab === 'workflows' && !ui.runModal.open && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && !ui.runModal.open && _jsx(RunsPanel, { runs: runs }), ui.tab === 'services' && !ui.runModal.open && (_jsx(ServicesPanel, { phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && !ui.runModal.open && _jsx(HelpPanel, {}), ui.runModal.open && _jsx(RunModal, { workflowName: ui.runModal.workflowName })] })] }));
|
|
144
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 2, paddingTop: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab }), _jsx(HorizontalRule, {}), _jsx(SearchBar, { active: ui.search.open }), _jsx(Toasts, {}), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [ui.tab === 'workflows' && !ui.runModal.open && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && !ui.runModal.open && _jsx(RunsPanel, { runs: runs }), ui.tab === 'services' && !ui.runModal.open && (_jsx(ServicesPanel, { phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && !ui.runModal.open && _jsx(HelpPanel, {}), ui.runModal.open && _jsx(RunModal, { workflowName: ui.runModal.workflowName, workflowPath: ui.runModal.workflowPath })] })] }));
|
|
145
145
|
};
|
|
146
146
|
export const DevApp = ({ dockerComposePath, onCleanup }) => (_jsx(UiStateProvider, { children: _jsx(Shell, { dockerComposePath: dockerComposePath, onCleanup: onCleanup }) }));
|
|
@@ -21,9 +21,9 @@ const buildEntries = (scenarios) => {
|
|
|
21
21
|
};
|
|
22
22
|
const Frame = ({ title, children }) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", paddingX: 1, paddingY: 0, children: [_jsx(Text, { bold: true, children: title }), children] }));
|
|
23
23
|
const TextPrompt = ({ label, value }) => (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { children: [label, " "] }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: ' ' })] }));
|
|
24
|
-
export const RunModal = ({ workflowName }) => {
|
|
24
|
+
export const RunModal = ({ workflowName, workflowPath }) => {
|
|
25
25
|
const ui = useUiState();
|
|
26
|
-
const scenarios = useMemo(() => listScenariosForWorkflow(workflowName), [workflowName]);
|
|
26
|
+
const scenarios = useMemo(() => listScenariosForWorkflow(workflowName, workflowPath), [workflowName, workflowPath]);
|
|
27
27
|
const entries = useMemo(() => buildEntries(scenarios), [scenarios]);
|
|
28
28
|
const [mode, setMode] = useState('select');
|
|
29
29
|
const [index, setIndex] = useState(0);
|
|
@@ -54,7 +54,7 @@ export const RunModal = ({ workflowName }) => {
|
|
|
54
54
|
};
|
|
55
55
|
const runScenario = async (scenarioName) => {
|
|
56
56
|
try {
|
|
57
|
-
const input = await readScenario(workflowName, scenarioName);
|
|
57
|
+
const input = await readScenario(workflowName, scenarioName, workflowPath);
|
|
58
58
|
await submit(input, scenarioName);
|
|
59
59
|
}
|
|
60
60
|
catch (err) {
|
|
@@ -64,7 +64,7 @@ export const RunModal = ({ workflowName }) => {
|
|
|
64
64
|
};
|
|
65
65
|
const startDuplicate = async (scenarioName) => {
|
|
66
66
|
try {
|
|
67
|
-
const sourceContent = await readScenario(workflowName, scenarioName);
|
|
67
|
+
const sourceContent = await readScenario(workflowName, scenarioName, workflowPath);
|
|
68
68
|
setEditName(`${scenarioName}_copy`);
|
|
69
69
|
setEditSeed(sourceContent);
|
|
70
70
|
setEditFrameTitle(`Duplicate '${scenarioName}'`);
|
|
@@ -106,7 +106,7 @@ export const RunModal = ({ workflowName }) => {
|
|
|
106
106
|
}
|
|
107
107
|
setMode('submitting');
|
|
108
108
|
try {
|
|
109
|
-
const writtenPath = await writeScenario(workflowName, name, value);
|
|
109
|
+
const writtenPath = await writeScenario(workflowName, name, value, workflowPath);
|
|
110
110
|
ui.pushToast(`Saved scenario at ${writtenPath}`, 'info');
|
|
111
111
|
await submit(value, name);
|
|
112
112
|
}
|
|
@@ -98,7 +98,7 @@ export const WorkflowsPanel = ({ workflows, runs }) => {
|
|
|
98
98
|
ui.setTab('runs');
|
|
99
99
|
}
|
|
100
100
|
else if (input === 'r' && selectedWorkflow?.name) {
|
|
101
|
-
ui.openRunModal(selectedWorkflow.name);
|
|
101
|
+
ui.openRunModal(selectedWorkflow.name, selectedWorkflow.path);
|
|
102
102
|
}
|
|
103
103
|
}, { isActive });
|
|
104
104
|
if (workflows.length === 0) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const readScenario: (workflowName: string, scenarioName: string) => Promise<unknown>;
|
|
2
|
-
export declare const writeScenario: (workflowName: string, scenarioName: string, content: unknown) => Promise<string>;
|
|
1
|
+
export declare const readScenario: (workflowName: string, scenarioName: string, workflowPath?: string) => Promise<unknown>;
|
|
2
|
+
export declare const writeScenario: (workflowName: string, scenarioName: string, content: unknown, workflowPath?: string) => Promise<string>;
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { resolve, dirname } from 'node:path';
|
|
4
|
-
import { resolveScenarioPath } from '#utils/scenario_resolver.js';
|
|
4
|
+
import { findWorkflowDirectoryFromPath, resolveScenarioPath } from '#utils/scenario_resolver.js';
|
|
5
5
|
import { getWorkflowsBasePath } from '#utils/paths.js';
|
|
6
6
|
const WORKFLOWS_PATHS = ['src/workflows', 'workflows'];
|
|
7
|
-
export const readScenario = async (workflowName, scenarioName) => {
|
|
8
|
-
const resolution = await resolveScenarioPath(workflowName, scenarioName);
|
|
7
|
+
export const readScenario = async (workflowName, scenarioName, workflowPath) => {
|
|
8
|
+
const resolution = await resolveScenarioPath(workflowName, scenarioName, getWorkflowsBasePath(), workflowPath);
|
|
9
9
|
if (!resolution.found || !resolution.path) {
|
|
10
10
|
throw new Error(`Scenario '${scenarioName}' not found for workflow '${workflowName}'.`);
|
|
11
11
|
}
|
|
12
12
|
const content = await readFile(resolution.path, 'utf-8');
|
|
13
13
|
return JSON.parse(content);
|
|
14
14
|
};
|
|
15
|
-
const findWorkflowDirectory = (workflowName) => {
|
|
15
|
+
const findWorkflowDirectory = (workflowName, workflowPath) => {
|
|
16
16
|
const basePath = getWorkflowsBasePath();
|
|
17
|
+
const pathDir = findWorkflowDirectoryFromPath(workflowPath, basePath);
|
|
18
|
+
if (pathDir) {
|
|
19
|
+
return pathDir;
|
|
20
|
+
}
|
|
17
21
|
for (const wfDir of WORKFLOWS_PATHS) {
|
|
18
22
|
const candidate = resolve(basePath, wfDir, workflowName);
|
|
19
23
|
if (existsSync(candidate)) {
|
|
@@ -22,8 +26,8 @@ const findWorkflowDirectory = (workflowName) => {
|
|
|
22
26
|
}
|
|
23
27
|
return null;
|
|
24
28
|
};
|
|
25
|
-
export const writeScenario = async (workflowName, scenarioName, content) => {
|
|
26
|
-
const dir = findWorkflowDirectory(workflowName);
|
|
29
|
+
export const writeScenario = async (workflowName, scenarioName, content, workflowPath) => {
|
|
30
|
+
const dir = findWorkflowDirectory(workflowName, workflowPath);
|
|
27
31
|
if (!dir) {
|
|
28
32
|
throw new Error(`Workflow directory for '${workflowName}' not found locally.`);
|
|
29
33
|
}
|
|
@@ -17,6 +17,7 @@ export interface SearchState {
|
|
|
17
17
|
export interface RunModalState {
|
|
18
18
|
open: boolean;
|
|
19
19
|
workflowName: string;
|
|
20
|
+
workflowPath?: string;
|
|
20
21
|
}
|
|
21
22
|
export interface ExpandedJsonState {
|
|
22
23
|
open: boolean;
|
|
@@ -47,7 +48,7 @@ export interface UiState {
|
|
|
47
48
|
setSelection: (selection: Selection) => void;
|
|
48
49
|
setRightPaneTab: (tab: RightPaneTab) => void;
|
|
49
50
|
setRunsView: (view: RunsView) => void;
|
|
50
|
-
openRunModal: (workflowName: string) => void;
|
|
51
|
+
openRunModal: (workflowName: string, workflowPath?: string) => void;
|
|
51
52
|
closeRunModal: () => void;
|
|
52
53
|
openExpandedJson: (value: unknown, title: string) => void;
|
|
53
54
|
closeExpandedJson: () => void;
|
|
@@ -43,7 +43,7 @@ export const UiStateProvider = ({ children }) => {
|
|
|
43
43
|
setSelection,
|
|
44
44
|
setRightPaneTab,
|
|
45
45
|
setRunsView,
|
|
46
|
-
openRunModal: (workflowName) => setRunModal({ open: true, workflowName }),
|
|
46
|
+
openRunModal: (workflowName, workflowPath) => setRunModal({ open: true, workflowName, workflowPath }),
|
|
47
47
|
closeRunModal: () => setRunModal({ open: false, workflowName: '' }),
|
|
48
48
|
openExpandedJson: (value, title) => setExpandedJson({ open: true, value, title }),
|
|
49
49
|
closeExpandedJson: () => setExpandedJson({ open: false, value: null, title: '' }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.3.3-next.
|
|
3
|
+
"version": "0.3.3-next.e8eff63.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/credentials": "0.3.3-next.
|
|
40
|
-
"@outputai/evals": "0.3.3-next.
|
|
41
|
-
"@outputai/llm": "0.3.3-next.
|
|
39
|
+
"@outputai/credentials": "0.3.3-next.e8eff63.0",
|
|
40
|
+
"@outputai/evals": "0.3.3-next.e8eff63.0",
|
|
41
|
+
"@outputai/llm": "0.3.3-next.e8eff63.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/cli-progress": "3.11.6",
|