@outputai/cli 0.10.1-next.fc0a41f.0 → 0.11.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/api/generated/api.d.ts +81 -26
- package/dist/api/generated/api.js +7 -4
- package/dist/assets/docker/docker-compose-dev.yml +2 -2
- package/dist/commands/workflow/monitor.d.ts +5 -20
- package/dist/commands/workflow/monitor.js +20 -182
- package/dist/commands/workflow/monitor.spec.js +82 -3
- package/dist/commands/workflow/result.js +2 -2
- package/dist/commands/workflow/result.spec.js +65 -1
- package/dist/commands/workflow/run.js +2 -2
- package/dist/commands/workflow/run.spec.js +30 -3
- package/dist/commands/workflow/start.d.ts +4 -0
- package/dist/commands/workflow/start.js +95 -10
- package/dist/commands/workflow/start.spec.js +252 -0
- package/dist/commands/workflow/status.spec.js +1 -1
- package/dist/commands/workflow/{test_eval.d.ts → test.d.ts} +0 -1
- package/dist/commands/workflow/{test_eval.js → test.js} +0 -1
- package/dist/commands/workflow/{test_eval.spec.js → test.spec.js} +4 -4
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/monitor_stream.d.ts +62 -0
- package/dist/services/monitor_stream.js +285 -0
- package/dist/services/monitor_stream.spec.d.ts +1 -0
- package/dist/services/monitor_stream.spec.js +285 -0
- package/dist/services/workflow_history.js +2 -2
- package/dist/templates/agent_instructions/CLAUDE.md.template +4 -2
- package/dist/templates/project/README.md.template +3 -1
- package/dist/templates/project/package.json.template +2 -2
- package/dist/utils/env_loader.js +6 -2
- package/dist/utils/env_loader.spec.js +61 -32
- package/dist/utils/error_handler.d.ts +10 -0
- package/dist/utils/error_handler.js +14 -0
- package/dist/utils/error_handler.spec.d.ts +1 -0
- package/dist/utils/error_handler.spec.js +62 -0
- package/dist/utils/format_workflow_result.d.ts +15 -3
- package/dist/utils/format_workflow_result.js +39 -6
- package/dist/utils/format_workflow_result.spec.js +39 -6
- package/dist/utils/monitor_flags.d.ts +35 -0
- package/dist/utils/monitor_flags.js +76 -0
- package/dist/utils/normalize_workflow_status.d.ts +4 -3
- package/dist/utils/normalize_workflow_status.js +12 -3
- package/dist/utils/normalize_workflow_status.spec.js +3 -0
- package/dist/views/dev/components/workflow_status.js +1 -1
- package/dist/views/dev/hooks/use_run_detail.js +4 -4
- package/dist/views/dev/hooks/use_run_detail.spec.js +1 -1
- package/dist/views/dev/panels/runs_panel.js +2 -2
- package/oclif.manifest.json +44 -7
- package/package.json +6 -8
- /package/dist/commands/workflow/{test_eval.spec.d.ts → test.spec.d.ts} +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
1
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
vi.mock('
|
|
3
|
+
vi.mock('#api/generated/api.js', () => ({
|
|
3
4
|
getWorkflowIdResult: vi.fn()
|
|
4
5
|
}));
|
|
5
6
|
describe('workflow result command', () => {
|
|
6
7
|
beforeEach(() => {
|
|
7
8
|
vi.clearAllMocks();
|
|
9
|
+
process.exitCode = undefined;
|
|
8
10
|
});
|
|
9
11
|
describe('command definition', () => {
|
|
10
12
|
it('should export a valid OCLIF command', async () => {
|
|
@@ -18,4 +20,66 @@ describe('workflow result command', () => {
|
|
|
18
20
|
expect(WorkflowResult.enableJsonFlag).toBe(true);
|
|
19
21
|
});
|
|
20
22
|
});
|
|
23
|
+
describe('run()', () => {
|
|
24
|
+
const runCommand = async (data) => {
|
|
25
|
+
const WorkflowResult = (await import('./result.js')).default;
|
|
26
|
+
const { getWorkflowIdResult } = await import('#api/generated/api.js');
|
|
27
|
+
const cmd = new WorkflowResult(['wf-1'], {});
|
|
28
|
+
cmd.log = vi.fn();
|
|
29
|
+
cmd.parse = vi.fn().mockResolvedValue({ args: { workflowId: 'wf-1' } });
|
|
30
|
+
vi.mocked(getWorkflowIdResult).mockResolvedValue({
|
|
31
|
+
data,
|
|
32
|
+
status: 200,
|
|
33
|
+
headers: new Headers()
|
|
34
|
+
});
|
|
35
|
+
const result = await cmd.run();
|
|
36
|
+
return { cmd, result, getWorkflowIdResult };
|
|
37
|
+
};
|
|
38
|
+
it('returns and formats a legacy result', async () => {
|
|
39
|
+
const data = {
|
|
40
|
+
workflowId: 'wf-1',
|
|
41
|
+
runId: 'run-1',
|
|
42
|
+
status: 'failed',
|
|
43
|
+
input: {},
|
|
44
|
+
output: null,
|
|
45
|
+
trace: null,
|
|
46
|
+
error: 'Legacy failure',
|
|
47
|
+
errorDetails: null
|
|
48
|
+
};
|
|
49
|
+
const { cmd, result, getWorkflowIdResult } = await runCommand(data);
|
|
50
|
+
expect(getWorkflowIdResult).toHaveBeenCalledWith('wf-1');
|
|
51
|
+
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('Error: Legacy failure'));
|
|
52
|
+
expect(result).toEqual(data);
|
|
53
|
+
expect(process.exitCode).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
it('returns and formats a current result', async () => {
|
|
56
|
+
const data = {
|
|
57
|
+
v: '2',
|
|
58
|
+
workflowId: 'wf-1',
|
|
59
|
+
runId: 'run-1',
|
|
60
|
+
status: 'failed',
|
|
61
|
+
input: {},
|
|
62
|
+
output: null,
|
|
63
|
+
trace: null,
|
|
64
|
+
error: { name: 'ValidationError', message: 'Invalid input' }
|
|
65
|
+
};
|
|
66
|
+
const { result } = await runCommand(data);
|
|
67
|
+
expect(result).toEqual(data);
|
|
68
|
+
expect(process.exitCode).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
it('sets a failure exit code for the previous canceled spelling', async () => {
|
|
71
|
+
const data = {
|
|
72
|
+
workflowId: 'wf-1',
|
|
73
|
+
runId: 'run-1',
|
|
74
|
+
status: 'canceled',
|
|
75
|
+
input: {},
|
|
76
|
+
output: null,
|
|
77
|
+
trace: null,
|
|
78
|
+
error: 'Workflow was canceled',
|
|
79
|
+
errorDetails: null
|
|
80
|
+
};
|
|
81
|
+
await runCommand(data);
|
|
82
|
+
expect(process.exitCode).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
21
85
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
2
|
import { postWorkflowRun } from '#api/generated/api.js';
|
|
3
|
-
import { formatWorkflowResult,
|
|
3
|
+
import { formatWorkflowResult, isErrorStatus } from '#utils/format_workflow_result.js';
|
|
4
4
|
import { handleApiError } from '#utils/error_handler.js';
|
|
5
5
|
import { resolveInput } from '#utils/resolve_input.js';
|
|
6
6
|
import { getRetryDelayFromResponse } from '#utils/header_utils.js';
|
|
@@ -86,7 +86,7 @@ export default class WorkflowRun extends Command {
|
|
|
86
86
|
}
|
|
87
87
|
const data = response.data;
|
|
88
88
|
this.log(`\n${formatWorkflowResult(data)}`);
|
|
89
|
-
if (
|
|
89
|
+
if (isErrorStatus(data.status)) {
|
|
90
90
|
process.exitCode = 1;
|
|
91
91
|
}
|
|
92
92
|
return data;
|
|
@@ -59,7 +59,16 @@ describe('workflow run command', () => {
|
|
|
59
59
|
const { cmd, postWorkflowRun, resolveInput } = await createCommand();
|
|
60
60
|
resolveInput.mockResolvedValue({ key: 'value' });
|
|
61
61
|
postWorkflowRun.mockResolvedValue({
|
|
62
|
-
data: {
|
|
62
|
+
data: {
|
|
63
|
+
v: '2',
|
|
64
|
+
workflowId: 'wf-1',
|
|
65
|
+
runId: 'run-1',
|
|
66
|
+
status: 'completed',
|
|
67
|
+
input: { key: 'value' },
|
|
68
|
+
output: 'ok',
|
|
69
|
+
trace: null,
|
|
70
|
+
error: null
|
|
71
|
+
},
|
|
63
72
|
status: 200,
|
|
64
73
|
headers: new Headers()
|
|
65
74
|
});
|
|
@@ -83,7 +92,16 @@ describe('workflow run command', () => {
|
|
|
83
92
|
});
|
|
84
93
|
resolveInput.mockResolvedValue({ key: 'value' });
|
|
85
94
|
postWorkflowRun.mockResolvedValue({
|
|
86
|
-
data: {
|
|
95
|
+
data: {
|
|
96
|
+
v: '2',
|
|
97
|
+
workflowId: 'wf-1',
|
|
98
|
+
runId: 'run-1',
|
|
99
|
+
status: 'completed',
|
|
100
|
+
input: { key: 'value' },
|
|
101
|
+
output: {},
|
|
102
|
+
trace: null,
|
|
103
|
+
error: null
|
|
104
|
+
},
|
|
87
105
|
status: 200,
|
|
88
106
|
headers: new Headers()
|
|
89
107
|
});
|
|
@@ -103,7 +121,16 @@ describe('workflow run command', () => {
|
|
|
103
121
|
postWorkflowRun
|
|
104
122
|
.mockRejectedValueOnce(new HttpError('Unavailable', { status: 503, headers }))
|
|
105
123
|
.mockResolvedValueOnce({
|
|
106
|
-
data: {
|
|
124
|
+
data: {
|
|
125
|
+
v: '2',
|
|
126
|
+
workflowId: 'wf-1',
|
|
127
|
+
runId: 'run-1',
|
|
128
|
+
status: 'completed',
|
|
129
|
+
input: {},
|
|
130
|
+
output: {},
|
|
131
|
+
trace: null,
|
|
132
|
+
error: null
|
|
133
|
+
},
|
|
107
134
|
status: 200,
|
|
108
135
|
headers: new Headers()
|
|
109
136
|
});
|
|
@@ -9,8 +9,12 @@ export default class WorkflowStart extends Command {
|
|
|
9
9
|
scenario: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
10
10
|
};
|
|
11
11
|
static flags: {
|
|
12
|
+
'include-payloads': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
interval: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
color: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
15
|
input: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
16
|
catalog: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
monitor: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
18
|
};
|
|
15
19
|
run(): Promise<PostWorkflowStart200>;
|
|
16
20
|
catch(error: Error): Promise<void>;
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
2
|
import { postWorkflowStart } from '#api/generated/api.js';
|
|
3
|
-
import {
|
|
3
|
+
import { commandStreamIo, monitorErrorOverrides, streamWorkflowUpdates } from '#services/monitor_stream.js';
|
|
4
|
+
import { handleApiError, handleCommandError } from '#utils/error_handler.js';
|
|
5
|
+
import { isErrorStatus } from '#utils/format_workflow_result.js';
|
|
6
|
+
import { gatedMonitorStreamFlags, MONITOR_DEFAULTS } from '#utils/monitor_flags.js';
|
|
4
7
|
import { resolveInput } from '#utils/resolve_input.js';
|
|
8
|
+
/**
|
|
9
|
+
* Distinct from 1 (the workflow itself failed) and 2 (usage): the workflow was
|
|
10
|
+
* started and is still running, only the attached stream gave up. A caller that
|
|
11
|
+
* retries on exit 1 would otherwise re-submit a workflow that is already running.
|
|
12
|
+
*/
|
|
13
|
+
const MONITOR_FAILED_EXIT_CODE = 3;
|
|
5
14
|
export default class WorkflowStart extends Command {
|
|
6
15
|
static description = 'Start a workflow asynchronously without waiting for completion';
|
|
7
16
|
static enableJsonFlag = true;
|
|
@@ -9,6 +18,7 @@ export default class WorkflowStart extends Command {
|
|
|
9
18
|
'<%= config.bin %> <%= command.id %> simple basic_input',
|
|
10
19
|
'<%= config.bin %> <%= command.id %> simple --input \'{"values":[1,2,3]}\'',
|
|
11
20
|
'<%= config.bin %> <%= command.id %> simple --input input.json',
|
|
21
|
+
'<%= config.bin %> <%= command.id %> simple --input input.json --monitor',
|
|
12
22
|
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --catalog my-catalog',
|
|
13
23
|
'<%= config.bin %> <%= command.id %> simple --json'
|
|
14
24
|
];
|
|
@@ -35,10 +45,37 @@ export default class WorkflowStart extends Command {
|
|
|
35
45
|
deprecateAliases: true,
|
|
36
46
|
description: 'Catalog name for workflow execution (defaults to OUTPUT_CATALOG_ID)',
|
|
37
47
|
env: 'OUTPUT_CATALOG_ID'
|
|
38
|
-
})
|
|
48
|
+
}),
|
|
49
|
+
// No `default: false`: a defaulted flag counts as present, so it would
|
|
50
|
+
// satisfy the `dependsOn` guard the three flags in `gatedMonitorStreamFlags`
|
|
51
|
+
// point at, letting `--interval` and friends be accepted (and then ignored)
|
|
52
|
+
// on a plain `workflow start`. Those three omit their own defaults for a
|
|
53
|
+
// different reason — see `gatedMonitorStreamFlags`.
|
|
54
|
+
//
|
|
55
|
+
// No `exclusive: [ 'json' ]` either: oclif's own rejection would fire first
|
|
56
|
+
// and print a bare "--json=true cannot also be provided", pre-empting the
|
|
57
|
+
// guard in `run()` that explains what to use instead. That guard covers both
|
|
58
|
+
// triggers (`--json` on argv, and `CONTENT_TYPE=json`) with one message.
|
|
59
|
+
monitor: Flags.boolean({
|
|
60
|
+
char: 'm',
|
|
61
|
+
description: 'After starting, attach and stream status updates until the workflow ends ' +
|
|
62
|
+
'(Ctrl+C detaches; the workflow keeps running). Cannot be combined with --json'
|
|
63
|
+
}),
|
|
64
|
+
...gatedMonitorStreamFlags('monitor')
|
|
39
65
|
};
|
|
40
66
|
async run() {
|
|
41
67
|
const { args, flags } = await this.parse(WorkflowStart);
|
|
68
|
+
// The built-in `--json` flag is injected by `enableJsonFlag`, and
|
|
69
|
+
// `CONTENT_TYPE=json` turns it on without it appearing on argv at all, so
|
|
70
|
+
// this runtime check — not an oclif flag relationship — is what catches
|
|
71
|
+
// every route into json mode. Streaming under `--json` is worse than
|
|
72
|
+
// useless: `Command.log()` is a no-op while json is enabled, so every update
|
|
73
|
+
// would be swallowed and the command would simply hang until the workflow
|
|
74
|
+
// ended.
|
|
75
|
+
if (flags.monitor && this.jsonEnabled()) {
|
|
76
|
+
this.error('Cannot combine --monitor with --json. Use "workflow run --json" to wait for the result, ' +
|
|
77
|
+
'or "workflow monitor <id> --format json" to stream newline-delimited JSON.', { exit: 2 });
|
|
78
|
+
}
|
|
42
79
|
const input = await resolveInput({
|
|
43
80
|
workflowName: args.workflowName,
|
|
44
81
|
scenario: args.scenario,
|
|
@@ -57,19 +94,67 @@ export default class WorkflowStart extends Command {
|
|
|
57
94
|
this.error('API returned invalid response', { exit: 1 });
|
|
58
95
|
}
|
|
59
96
|
const result = response.data;
|
|
60
|
-
const
|
|
97
|
+
const started = [
|
|
61
98
|
'Workflow started successfully',
|
|
62
99
|
'',
|
|
63
|
-
`Workflow ID: ${result.workflowId || 'unknown'}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
`Workflow ID: ${result.workflowId || 'unknown'}`
|
|
101
|
+
];
|
|
102
|
+
if (!flags.monitor) {
|
|
103
|
+
this.log(`\n${[
|
|
104
|
+
...started,
|
|
105
|
+
'',
|
|
106
|
+
`Use "workflow status ${result.workflowId || '<workflow-id>'}" to check the workflow status`,
|
|
107
|
+
`Use "workflow result ${result.workflowId || '<workflow-id>'}" to get the workflow result when complete`
|
|
108
|
+
].join('\n')}`);
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
// Checked before the banner prints: "Workflow started successfully" followed
|
|
112
|
+
// immediately by a failure contradicts itself, and the `unknown` placeholder
|
|
113
|
+
// id it would show is not something the user can act on. Exit 3, not 1 — the
|
|
114
|
+
// start itself succeeded, so this is the "started but unmonitorable" case.
|
|
115
|
+
if (!result.workflowId) {
|
|
116
|
+
this.error('The workflow was started, but the API did not return a workflow ID, so it cannot be monitored. ' +
|
|
117
|
+
'Use "workflow runs list" to find it.', { exit: MONITOR_FAILED_EXIT_CODE });
|
|
118
|
+
}
|
|
119
|
+
this.log(`\n${started.join('\n')}`);
|
|
120
|
+
this.log('');
|
|
121
|
+
try {
|
|
122
|
+
const status = await streamWorkflowUpdates({
|
|
123
|
+
workflowId: result.workflowId,
|
|
124
|
+
// Pin to the run just started rather than letting the monitor resolve
|
|
125
|
+
// "latest run" — with a retry or a rapid re-start those can differ.
|
|
126
|
+
runId: result.runId ?? undefined,
|
|
127
|
+
includePayloads: flags['include-payloads'] ?? MONITOR_DEFAULTS.includePayloads,
|
|
128
|
+
interval: flags.interval ?? MONITOR_DEFAULTS.interval,
|
|
129
|
+
// Always text: monitoring under json mode is rejected above, so the
|
|
130
|
+
// NDJSON path is `workflow monitor --format json`.
|
|
131
|
+
json: false,
|
|
132
|
+
color: flags.color ?? MONITOR_DEFAULTS.color
|
|
133
|
+
}, commandStreamIo(this));
|
|
134
|
+
// Monitoring reports the workflow's *progress*; the return value still has
|
|
135
|
+
// to be fetched separately, so name the command that does it. A failed run
|
|
136
|
+
// has no result to fetch, so point at the one that explains the failure.
|
|
137
|
+
if (status) {
|
|
138
|
+
this.log(isErrorStatus(status) ?
|
|
139
|
+
`\nUse "workflow debug ${result.workflowId}" to inspect the failure` :
|
|
140
|
+
`\nUse "workflow result ${result.workflowId}" to get the workflow result`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
// The workflow was started and is still running — only the stream gave up.
|
|
145
|
+
// Say so explicitly and exit on a code of its own, so this can't be read
|
|
146
|
+
// (by a human or by a CI job retrying on exit 1) as a failed start.
|
|
147
|
+
//
|
|
148
|
+
// `handleApiError`, not `handleCommandError`: an error the stream raised
|
|
149
|
+
// through `io.error` is already a CLIError, and passing it through would
|
|
150
|
+
// report it as a plain exit-1 failure rather than a live workflow.
|
|
151
|
+
handleApiError(error, message => this.error(`Workflow ${result.workflowId} started, but monitoring stopped:\n${message}\n` +
|
|
152
|
+
`The workflow is still running. Use "workflow status ${result.workflowId}" to check on it.`, { exit: MONITOR_FAILED_EXIT_CODE }), monitorErrorOverrides(error));
|
|
153
|
+
}
|
|
69
154
|
return result;
|
|
70
155
|
}
|
|
71
156
|
async catch(error) {
|
|
72
|
-
return
|
|
157
|
+
return handleCommandError(error, (...args) => this.error(...args), {
|
|
73
158
|
404: 'Workflow not found. Check the workflow name.'
|
|
74
159
|
});
|
|
75
160
|
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { Parser } from '@oclif/core';
|
|
4
|
+
import { CLIError } from '@oclif/core/errors';
|
|
3
5
|
vi.mock('#api/generated/api.js', () => ({
|
|
4
6
|
postWorkflowStart: vi.fn()
|
|
5
7
|
}));
|
|
6
8
|
vi.mock('#utils/resolve_input.js', () => ({
|
|
7
9
|
resolveInput: vi.fn()
|
|
8
10
|
}));
|
|
11
|
+
// Only the streaming loop is stubbed; monitorErrorOverrides and commandStreamIo
|
|
12
|
+
// stay real so the default-application and error-mapping branches are exercised
|
|
13
|
+
// against the values `workflow monitor` actually uses.
|
|
14
|
+
vi.mock('#services/monitor_stream.js', async (importOriginal) => ({
|
|
15
|
+
...await importOriginal(),
|
|
16
|
+
streamWorkflowUpdates: vi.fn()
|
|
17
|
+
}));
|
|
9
18
|
describe('workflow start command', () => {
|
|
10
19
|
beforeEach(async () => {
|
|
11
20
|
vi.clearAllMocks();
|
|
@@ -40,6 +49,61 @@ describe('workflow start command', () => {
|
|
|
40
49
|
const WorkflowStart = (await import('./start.js')).default;
|
|
41
50
|
expect(WorkflowStart.enableJsonFlag).toBe(true);
|
|
42
51
|
});
|
|
52
|
+
it('exposes --monitor with no default so dependsOn stays enforceable', async () => {
|
|
53
|
+
const WorkflowStart = (await import('./start.js')).default;
|
|
54
|
+
expect(WorkflowStart.flags.monitor.char).toBe('m');
|
|
55
|
+
// A defaulted flag counts as present, so a default here would satisfy the
|
|
56
|
+
// dependsOn guards below and let --interval through without --monitor.
|
|
57
|
+
expect(WorkflowStart.flags.monitor.default).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
it('leaves the --monitor/--json conflict to the runtime guard', async () => {
|
|
60
|
+
const WorkflowStart = (await import('./start.js')).default;
|
|
61
|
+
// An `exclusive: [ 'json' ]` relationship would fire first and print a bare
|
|
62
|
+
// "--json=true cannot also be provided", pre-empting the guard in run()
|
|
63
|
+
// that explains what to use instead — and it still wouldn't catch
|
|
64
|
+
// CONTENT_TYPE=json, which never reaches argv.
|
|
65
|
+
expect(WorkflowStart.flags.monitor.exclusive).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
it('gates every monitor passthrough flag behind --monitor and leaves them undefaulted', async () => {
|
|
68
|
+
const WorkflowStart = (await import('./start.js')).default;
|
|
69
|
+
for (const name of ['interval', 'include-payloads', 'color']) {
|
|
70
|
+
expect(WorkflowStart.flags[name].dependsOn).toEqual(['monitor']);
|
|
71
|
+
// A default would count as present and trigger this flag's own dependsOn
|
|
72
|
+
// check, failing every invocation that omits --monitor.
|
|
73
|
+
expect(WorkflowStart.flags[name].default).toBeUndefined();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
// The properties asserted above are only the inputs; what matters is what
|
|
77
|
+
// oclif does with them. Parser.parse needs no oclif Config, so the actual
|
|
78
|
+
// gating is cheap to pin — worth doing because the no-default design is
|
|
79
|
+
// subtle enough that someone will try to "fix" it by adding one.
|
|
80
|
+
describe('flag parsing', () => {
|
|
81
|
+
const parse = async (argv) => {
|
|
82
|
+
const WorkflowStart = (await import('./start.js')).default;
|
|
83
|
+
return Parser.parse(argv, { flags: WorkflowStart.flags, strict: false });
|
|
84
|
+
};
|
|
85
|
+
it('accepts a plain start with none of the monitor flags', async () => {
|
|
86
|
+
await expect(parse(['my_workflow'])).resolves.toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
it('accepts the passthrough flags once --monitor is given', async () => {
|
|
89
|
+
const { flags } = await parse(['my_workflow', '--monitor', '--interval', '500', '--include-payloads']);
|
|
90
|
+
expect(flags).toMatchObject({ monitor: true, interval: 500, 'include-payloads': true });
|
|
91
|
+
});
|
|
92
|
+
it('rejects a passthrough flag without --monitor', async () => {
|
|
93
|
+
await expect(parse(['my_workflow', '--interval', '500'])).rejects.toThrow(/--monitor/);
|
|
94
|
+
});
|
|
95
|
+
it('gates --no-color behind --monitor as well', async () => {
|
|
96
|
+
// A consequence of gating --color, not an independent decision: --no-color
|
|
97
|
+
// is reflexive enough that this is worth stating outright rather than
|
|
98
|
+
// leaving as a surprise.
|
|
99
|
+
await expect(parse(['my_workflow', '--no-color'])).rejects.toThrow(/--monitor/);
|
|
100
|
+
await expect(parse(['my_workflow', '--monitor', '--no-color']))
|
|
101
|
+
.resolves.toMatchObject({ flags: { color: false } });
|
|
102
|
+
});
|
|
103
|
+
it('rejects an interval below the minimum', async () => {
|
|
104
|
+
await expect(parse(['my_workflow', '--monitor', '--interval', '0'])).rejects.toThrow();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
43
107
|
});
|
|
44
108
|
describe('run()', () => {
|
|
45
109
|
const createCommand = async (flagOverrides = {}, argv = ['my_workflow']) => {
|
|
@@ -57,6 +121,7 @@ describe('workflow start command', () => {
|
|
|
57
121
|
});
|
|
58
122
|
return { cmd, postWorkflowStart: vi.mocked(postWorkflowStart), resolveInput: vi.mocked(resolveInput) };
|
|
59
123
|
};
|
|
124
|
+
const logged = (cmd) => cmd.log.mock.calls.map(([line]) => line);
|
|
60
125
|
it('threads the resolved catalog to resolveInput and postWorkflowStart', async () => {
|
|
61
126
|
const { cmd, postWorkflowStart, resolveInput } = await createCommand({ catalog: 'my-catalog' });
|
|
62
127
|
resolveInput.mockResolvedValue({ key: 'value' });
|
|
@@ -101,5 +166,192 @@ describe('workflow start command', () => {
|
|
|
101
166
|
await cmd.run();
|
|
102
167
|
expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({ json: true }));
|
|
103
168
|
});
|
|
169
|
+
describe('--monitor', () => {
|
|
170
|
+
const startResponse = (data) => ({
|
|
171
|
+
data, status: 200, headers: new Headers()
|
|
172
|
+
});
|
|
173
|
+
it('does not attach when the flag is absent, and keeps the follow-up hints', async () => {
|
|
174
|
+
const { cmd, postWorkflowStart } = await createCommand();
|
|
175
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
176
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123', runId: 'run-1' }));
|
|
177
|
+
await cmd.run();
|
|
178
|
+
expect(streamWorkflowUpdates).not.toHaveBeenCalled();
|
|
179
|
+
const printed = logged(cmd).join('\n');
|
|
180
|
+
expect(printed).toContain('workflow status wf-123');
|
|
181
|
+
expect(printed).toContain('workflow result wf-123');
|
|
182
|
+
});
|
|
183
|
+
it('streams updates pinned to the run it just started', async () => {
|
|
184
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
185
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
186
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123', runId: 'run-1' }));
|
|
187
|
+
await cmd.run();
|
|
188
|
+
expect(streamWorkflowUpdates).toHaveBeenCalledWith(
|
|
189
|
+
// runId is pinned rather than left undefined so a rapid re-start can't
|
|
190
|
+
// make the monitor resolve "latest run" to a different execution.
|
|
191
|
+
expect.objectContaining({ workflowId: 'wf-123', runId: 'run-1', json: false }), expect.objectContaining({ log: expect.any(Function), error: expect.any(Function) }));
|
|
192
|
+
});
|
|
193
|
+
it('still returns the start result after monitoring finishes', async () => {
|
|
194
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
195
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123', runId: 'run-1' }));
|
|
196
|
+
await expect(cmd.run()).resolves.toEqual({ workflowId: 'wf-123', runId: 'run-1' });
|
|
197
|
+
});
|
|
198
|
+
it('drops the up-front status hint that duplicates what monitoring already does', async () => {
|
|
199
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
200
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123', runId: 'run-1' }));
|
|
201
|
+
await cmd.run();
|
|
202
|
+
const printed = logged(cmd).join('\n');
|
|
203
|
+
expect(printed).toContain('Workflow ID: wf-123');
|
|
204
|
+
expect(printed).not.toContain('workflow status wf-123');
|
|
205
|
+
});
|
|
206
|
+
it('points at "workflow result" once monitoring finishes', async () => {
|
|
207
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
208
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
209
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123', runId: 'run-1' }));
|
|
210
|
+
vi.mocked(streamWorkflowUpdates).mockResolvedValue('completed');
|
|
211
|
+
await cmd.run();
|
|
212
|
+
// The stream reports progress, never the return value, so the command
|
|
213
|
+
// that fetches it has to be named somewhere.
|
|
214
|
+
const printed = logged(cmd);
|
|
215
|
+
expect(printed.at(-1)).toContain('workflow result wf-123');
|
|
216
|
+
});
|
|
217
|
+
it('points at "workflow debug" instead when the workflow failed', async () => {
|
|
218
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
219
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
220
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123', runId: 'run-1' }));
|
|
221
|
+
vi.mocked(streamWorkflowUpdates).mockResolvedValue('failed');
|
|
222
|
+
await cmd.run();
|
|
223
|
+
// A failed run has no result to fetch.
|
|
224
|
+
const printed = logged(cmd);
|
|
225
|
+
expect(printed.at(-1)).toContain('workflow debug wf-123');
|
|
226
|
+
expect(printed.at(-1)).not.toContain('workflow result');
|
|
227
|
+
});
|
|
228
|
+
it('adds no follow-up hint when the user detached mid-run', async () => {
|
|
229
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
230
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
231
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123', runId: 'run-1' }));
|
|
232
|
+
// Detaching returns no terminal status; the detach message carries its
|
|
233
|
+
// own hints, so a second one guessing at the outcome would be wrong.
|
|
234
|
+
vi.mocked(streamWorkflowUpdates).mockResolvedValue(undefined);
|
|
235
|
+
await cmd.run();
|
|
236
|
+
const printed = logged(cmd).join('\n');
|
|
237
|
+
expect(printed).not.toContain('workflow result wf-123');
|
|
238
|
+
expect(printed).not.toContain('workflow debug wf-123');
|
|
239
|
+
});
|
|
240
|
+
it('applies monitor defaults for the passthrough flags left unset', async () => {
|
|
241
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
242
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
243
|
+
const { MONITOR_DEFAULTS } = await import('#utils/monitor_flags.js');
|
|
244
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123' }));
|
|
245
|
+
await cmd.run();
|
|
246
|
+
// These carry no oclif default (that would defeat dependsOn), so run() must
|
|
247
|
+
// supply them — asserted against the shared source rather than re-stating
|
|
248
|
+
// the literals, which is exactly how the two commands would drift apart.
|
|
249
|
+
expect(streamWorkflowUpdates).toHaveBeenCalledWith(expect.objectContaining({
|
|
250
|
+
interval: MONITOR_DEFAULTS.interval,
|
|
251
|
+
color: MONITOR_DEFAULTS.color,
|
|
252
|
+
includePayloads: MONITOR_DEFAULTS.includePayloads
|
|
253
|
+
}), expect.anything());
|
|
254
|
+
});
|
|
255
|
+
it('forwards explicit passthrough flag values', async () => {
|
|
256
|
+
const { cmd, postWorkflowStart } = await createCommand({
|
|
257
|
+
monitor: true, interval: 500, color: false, 'include-payloads': true
|
|
258
|
+
});
|
|
259
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
260
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123' }));
|
|
261
|
+
await cmd.run();
|
|
262
|
+
expect(streamWorkflowUpdates).toHaveBeenCalledWith(expect.objectContaining({ interval: 500, color: false, includePayloads: true }), expect.anything());
|
|
263
|
+
});
|
|
264
|
+
it('leaves runId undefined when the API omits it, falling back to the latest run', async () => {
|
|
265
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
266
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
267
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123', runId: null }));
|
|
268
|
+
await cmd.run();
|
|
269
|
+
expect(streamWorkflowUpdates).toHaveBeenCalledWith(expect.objectContaining({ runId: undefined }), expect.anything());
|
|
270
|
+
});
|
|
271
|
+
it('errors instead of monitoring when the API returns no workflow ID', async () => {
|
|
272
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
273
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
274
|
+
postWorkflowStart.mockResolvedValue(startResponse({ runId: 'run-1' }));
|
|
275
|
+
await expect(cmd.run()).rejects.toThrow();
|
|
276
|
+
expect(streamWorkflowUpdates).not.toHaveBeenCalled();
|
|
277
|
+
const [message, options] = cmd.error.mock.calls.at(-1);
|
|
278
|
+
// The start succeeded — only monitoring is impossible — so this is the
|
|
279
|
+
// exit-3 case. Exit 1 here would tell a CI job retrying a failed workflow
|
|
280
|
+
// to re-submit one that is already running.
|
|
281
|
+
expect(message).toContain('started');
|
|
282
|
+
expect(message).toContain('cannot be monitored');
|
|
283
|
+
expect(options).toEqual(expect.objectContaining({ exit: 3 }));
|
|
284
|
+
// Claiming success and then failing on the next line contradicts itself,
|
|
285
|
+
// and the "unknown" placeholder id isn't something the user can act on.
|
|
286
|
+
const printed = logged(cmd).join('\n');
|
|
287
|
+
expect(printed).not.toContain('Workflow started successfully');
|
|
288
|
+
expect(printed).not.toContain('unknown');
|
|
289
|
+
});
|
|
290
|
+
it('refuses to monitor under --json instead of silently swallowing the stream', async () => {
|
|
291
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
292
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
293
|
+
// CONTENT_TYPE=json enables json mode without --json ever reaching argv,
|
|
294
|
+
// so oclif's `exclusive` check has nothing to reject — this guard catches it.
|
|
295
|
+
vi.spyOn(cmd, 'jsonEnabled').mockReturnValue(true);
|
|
296
|
+
await expect(cmd.run()).rejects.toThrow();
|
|
297
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Cannot combine --monitor with --json'), expect.objectContaining({ exit: 2 }));
|
|
298
|
+
expect(postWorkflowStart).not.toHaveBeenCalled();
|
|
299
|
+
expect(streamWorkflowUpdates).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
// Shaped like the API's real 404 body so the two 404 paths below differ the
|
|
302
|
+
// way they do in practice: `catch()`'s override replaces the body, while
|
|
303
|
+
// monitoring has no override and lets the body through.
|
|
304
|
+
const notFound = () => Object.assign(new Error('not found'), {
|
|
305
|
+
response: { status: 404, data: { error: 'WorkflowNotFoundError', message: 'Workflow "wf-123" not found' } }
|
|
306
|
+
});
|
|
307
|
+
it('blames the workflow name for a 404 raised before monitoring begins', async () => {
|
|
308
|
+
const { cmd } = await createCommand();
|
|
309
|
+
await expect(cmd.catch(notFound())).rejects.toThrow();
|
|
310
|
+
expect(cmd.error).toHaveBeenCalledWith('Workflow not found. Check the workflow name.', expect.objectContaining({ exit: 1 }));
|
|
311
|
+
});
|
|
312
|
+
it('reports a monitoring failure as a live workflow, not a failed start', async () => {
|
|
313
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
314
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
315
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123' }));
|
|
316
|
+
vi.mocked(streamWorkflowUpdates).mockRejectedValue(notFound());
|
|
317
|
+
await expect(cmd.run()).rejects.toThrow();
|
|
318
|
+
const [message, options] = cmd.error.mock.calls.at(-1);
|
|
319
|
+
// The workflow started fine, so a 404 here is about the run being polled.
|
|
320
|
+
// The server's own message surfaces; `workflow monitor`'s "Check the
|
|
321
|
+
// workflow ID" must not, since this id came back from postWorkflowStart —
|
|
322
|
+
// it would both misdirect the user and contradict the "still running" line.
|
|
323
|
+
expect(message).toContain('WorkflowNotFoundError');
|
|
324
|
+
expect(message).not.toContain('Check the workflow ID');
|
|
325
|
+
expect(message).toContain('wf-123 started, but monitoring stopped');
|
|
326
|
+
expect(message).toContain('workflow status wf-123');
|
|
327
|
+
// Exit 3, not 1: a caller retrying on a failed workflow must not
|
|
328
|
+
// re-submit one that is already running.
|
|
329
|
+
expect(options).toEqual(expect.objectContaining({ exit: 3 }));
|
|
330
|
+
});
|
|
331
|
+
it('still reports a live workflow when the stream raises its own CLIError', async () => {
|
|
332
|
+
const { cmd, postWorkflowStart } = await createCommand({ monitor: true });
|
|
333
|
+
const { streamWorkflowUpdates } = await import('#services/monitor_stream.js');
|
|
334
|
+
postWorkflowStart.mockResolvedValue(startResponse({ workflowId: 'wf-123' }));
|
|
335
|
+
// What `io.error` produces — e.g. the continue-as-new branch. run() must
|
|
336
|
+
// use handleApiError here, not handleCommandError: the latter rethrows a
|
|
337
|
+
// CLIError untouched, which would surface this as a bare exit 1 and lose
|
|
338
|
+
// the "still running" message entirely.
|
|
339
|
+
vi.mocked(streamWorkflowUpdates).mockRejectedValue(new CLIError('Workflow continued as a new run, but the new run ID could not be determined.'));
|
|
340
|
+
await expect(cmd.run()).rejects.toThrow();
|
|
341
|
+
const [message, options] = cmd.error.mock.calls.at(-1);
|
|
342
|
+
expect(message).toContain('wf-123 started, but monitoring stopped');
|
|
343
|
+
expect(message).toContain('The workflow is still running');
|
|
344
|
+
expect(options).toEqual(expect.objectContaining({ exit: 3 }));
|
|
345
|
+
});
|
|
346
|
+
it('rethrows oclif errors instead of flattening them to exit 1', async () => {
|
|
347
|
+
const { cmd } = await createCommand();
|
|
348
|
+
// `catch` re-raising a CLIError through handleApiError would discard both
|
|
349
|
+
// its exit code (2 for usage, 3 for a dropped stream) and oclif's own
|
|
350
|
+
// formatted flag-validation output.
|
|
351
|
+
const usageError = new CLIError('Cannot combine --monitor with --json.', { exit: 2 });
|
|
352
|
+
await expect(cmd.catch(usageError)).rejects.toBe(usageError);
|
|
353
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
354
|
+
});
|
|
355
|
+
});
|
|
104
356
|
});
|
|
105
357
|
});
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
2
2
|
vi.mock('../../api/generated/api.js', () => ({
|
|
3
3
|
getWorkflowIdStatus: vi.fn(),
|
|
4
4
|
GetWorkflowIdStatus200Status: {
|
|
5
|
-
|
|
5
|
+
cancelled: 'cancelled',
|
|
6
6
|
completed: 'completed',
|
|
7
7
|
continued_as_new: 'continued_as_new',
|
|
8
8
|
failed: 'failed',
|
|
@@ -7,7 +7,6 @@ import { diagnoseMissingEvalWorkflow } from '#utils/eval_diagnostics.js';
|
|
|
7
7
|
import { handleApiError } from '#utils/error_handler.js';
|
|
8
8
|
import { getEvalWorkflowName, renderEvalOutput, computeExitCode, EvalOutputSchema } from '@outputai/evals';
|
|
9
9
|
export default class WorkflowTest extends Command {
|
|
10
|
-
static aliases = ['workflow:test'];
|
|
11
10
|
static description = 'Run evaluations against a workflow using its datasets';
|
|
12
11
|
static enableJsonFlag = true;
|
|
13
12
|
static examples = [
|