@outputai/cli 0.10.1-next.f6a7c1a.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/api/http_client.js +2 -2
- package/dist/assets/docker/docker-compose-dev.yml +2 -2
- package/dist/commands/dev/down.d.ts +10 -0
- package/dist/commands/dev/down.js +34 -0
- package/dist/commands/dev/down.spec.js +71 -0
- package/dist/commands/dev/index.d.ts +4 -0
- package/dist/commands/dev/index.js +200 -53
- package/dist/commands/dev/index.spec.js +390 -42
- 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 +10 -3
- package/dist/commands/workflow/run.spec.js +42 -5
- package/dist/commands/workflow/start.d.ts +7 -1
- package/dist/commands/workflow/start.js +107 -12
- package/dist/commands/workflow/start.spec.js +282 -5
- 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.spec.d.ts +1 -0
- package/dist/commands/workflow/{test_eval.spec.js → test.spec.js} +4 -4
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/docker.d.ts +28 -1
- package/dist/services/docker.js +106 -12
- package/dist/services/docker.spec.js +144 -14
- 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 +5 -3
- package/dist/templates/project/README.md.template +3 -1
- package/dist/templates/project/package.json.template +2 -2
- package/dist/templates/project/src/clients/jina.ts.template +4 -4
- 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/utils/port_collision.d.ts +22 -7
- package/dist/utils/port_collision.js +39 -14
- package/dist/utils/port_collision.spec.js +40 -1
- package/dist/utils/resolve_input.d.ts +9 -1
- package/dist/utils/resolve_input.js +8 -2
- package/dist/utils/resolve_input.spec.d.ts +1 -0
- package/dist/utils/resolve_input.spec.js +75 -0
- package/dist/views/dev/chrome/footer.d.ts +2 -0
- package/dist/views/dev/chrome/footer.js +4 -4
- package/dist/views/dev/components/workflow_status.js +1 -1
- package/dist/views/dev/dev_app.d.ts +1 -0
- package/dist/views/dev/dev_app.js +13 -4
- 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 +91 -10
- package/package.json +7 -9
- /package/dist/commands/{workflow/test_eval.spec.d.ts → dev/down.spec.d.ts} +0 -0
package/dist/utils/env_loader.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
|
-
import * as dotenv from 'dotenv';
|
|
9
8
|
import debugFactory from 'debug';
|
|
10
9
|
const debug = debugFactory('output-cli:env-loader');
|
|
11
10
|
export function loadEnvironment() {
|
|
@@ -17,5 +16,10 @@ export function loadEnvironment() {
|
|
|
17
16
|
return;
|
|
18
17
|
}
|
|
19
18
|
debug(`Loading env from: ${envPath}`);
|
|
20
|
-
|
|
19
|
+
try {
|
|
20
|
+
process.loadEnvFile(envPath);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
debug(`Warning: Failed to load env file ${envPath}: ${err}`);
|
|
24
|
+
}
|
|
21
25
|
}
|
|
@@ -1,43 +1,72 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { resolve } from 'node:path';
|
|
7
|
-
import * as dotenv from 'dotenv';
|
|
8
|
-
vi.mock('node:fs');
|
|
9
|
-
vi.mock('dotenv');
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { loadEnvironment } from './env_loader.js';
|
|
10
6
|
describe('loadEnvironment', () => {
|
|
11
|
-
const
|
|
12
|
-
const mockCwd = '/mock/project';
|
|
7
|
+
const mockCwd = mkdtempSync(join(tmpdir(), 'output-env-loader-'));
|
|
13
8
|
beforeEach(() => {
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
for (const name of readdirSync(mockCwd)) {
|
|
10
|
+
rmSync(join(mockCwd, name), { recursive: true, force: true });
|
|
11
|
+
}
|
|
16
12
|
vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
|
|
17
|
-
vi.
|
|
18
|
-
vi.
|
|
13
|
+
vi.stubEnv('OUTPUT_CLI_ENV', undefined);
|
|
14
|
+
vi.stubEnv('OUTPUT_API_URL', undefined);
|
|
15
|
+
vi.stubEnv('OUTPUT_API_TOKEN', undefined);
|
|
19
16
|
});
|
|
20
17
|
afterEach(() => {
|
|
21
|
-
process.env = { ...originalEnv };
|
|
22
18
|
vi.restoreAllMocks();
|
|
19
|
+
vi.unstubAllEnvs();
|
|
23
20
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
rmSync(mockCwd, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
it('loads variables from OUTPUT_CLI_ENV', () => {
|
|
25
|
+
writeFileSync(join(mockCwd, '.env'), [
|
|
26
|
+
'OUTPUT_API_URL=https://default.api.com',
|
|
27
|
+
'OUTPUT_API_TOKEN=default-token'
|
|
28
|
+
].join('\n'));
|
|
29
|
+
writeFileSync(join(mockCwd, '.env.mock'), [
|
|
30
|
+
'OUTPUT_API_URL=https://mock.api.com',
|
|
31
|
+
'OUTPUT_API_TOKEN=mock-token'
|
|
32
|
+
].join('\n'));
|
|
33
|
+
process.env.OUTPUT_CLI_ENV = '.env.mock';
|
|
34
|
+
loadEnvironment();
|
|
35
|
+
expect(process.env.OUTPUT_API_URL).toBe('https://mock.api.com');
|
|
36
|
+
expect(process.env.OUTPUT_API_TOKEN).toBe('mock-token');
|
|
37
|
+
});
|
|
38
|
+
it('loads variables from .env by default', () => {
|
|
39
|
+
writeFileSync(join(mockCwd, '.env'), [
|
|
40
|
+
'OUTPUT_API_URL=https://default.api.com',
|
|
41
|
+
'OUTPUT_API_TOKEN=default-token'
|
|
42
|
+
].join('\n'));
|
|
43
|
+
writeFileSync(join(mockCwd, '.env.mock'), [
|
|
44
|
+
'OUTPUT_API_URL=https://mock.api.com',
|
|
45
|
+
'OUTPUT_API_TOKEN=mock-token'
|
|
46
|
+
].join('\n'));
|
|
30
47
|
loadEnvironment();
|
|
31
|
-
expect(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
expect(process.env.OUTPUT_API_URL).toBe('https://default.api.com');
|
|
49
|
+
expect(process.env.OUTPUT_API_TOKEN).toBe('default-token');
|
|
50
|
+
});
|
|
51
|
+
it('does nothing when the env file is missing', () => {
|
|
52
|
+
expect(() => loadEnvironment()).not.toThrow();
|
|
53
|
+
expect(process.env.OUTPUT_API_URL).toBeUndefined();
|
|
54
|
+
expect(process.env.OUTPUT_API_TOKEN).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
it('does not throw when the env path is not a readable file', () => {
|
|
57
|
+
mkdirSync(join(mockCwd, 'not-a-file.env'));
|
|
58
|
+
process.env.OUTPUT_CLI_ENV = 'not-a-file.env';
|
|
59
|
+
expect(() => loadEnvironment()).not.toThrow();
|
|
60
|
+
expect(process.env.OUTPUT_API_URL).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
it('does not overwrite already-set process.env values', () => {
|
|
63
|
+
vi.stubEnv('OUTPUT_API_URL', 'https://ambient.api.com');
|
|
64
|
+
writeFileSync(join(mockCwd, '.env'), [
|
|
65
|
+
'OUTPUT_API_URL=https://file.api.com',
|
|
66
|
+
'OUTPUT_API_TOKEN=file-token'
|
|
67
|
+
].join('\n'));
|
|
39
68
|
loadEnvironment();
|
|
40
|
-
expect(
|
|
41
|
-
expect(
|
|
69
|
+
expect(process.env.OUTPUT_API_URL).toBe('https://ambient.api.com');
|
|
70
|
+
expect(process.env.OUTPUT_API_TOKEN).toBe('file-token');
|
|
42
71
|
});
|
|
43
72
|
});
|
|
@@ -5,4 +5,14 @@ type ErrorOverrides = {
|
|
|
5
5
|
export declare function handleApiError(error: unknown, errorFn: (...args: [message: string, options: {
|
|
6
6
|
exit: number;
|
|
7
7
|
}]) => never, overrides?: ErrorOverrides): never;
|
|
8
|
+
/**
|
|
9
|
+
* `catch()` handling for a command that raises oclif errors of its own. Flag
|
|
10
|
+
* relationship failures and every `this.error( ..., { exit } )` already carry an
|
|
11
|
+
* exit code and formatted output, and `handleApiError` would flatten all of it
|
|
12
|
+
* to a bare exit 1 — so pass those straight through and only map what actually
|
|
13
|
+
* came back from the API.
|
|
14
|
+
*/
|
|
15
|
+
export declare function handleCommandError(error: Error, errorFn: (...args: [message: string, options: {
|
|
16
|
+
exit: number;
|
|
17
|
+
}]) => never, overrides?: ErrorOverrides): never;
|
|
8
18
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLIError } from '@oclif/core/errors';
|
|
1
2
|
import { config } from '#config.js';
|
|
2
3
|
function getDefaultMessages() {
|
|
3
4
|
return {
|
|
@@ -79,3 +80,16 @@ export function handleApiError(error, errorFn, overrides = {}) {
|
|
|
79
80
|
const detailedMessage = getDetailedErrorMessage(error);
|
|
80
81
|
errorFn(detailedMessage, { exit: 1 });
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* `catch()` handling for a command that raises oclif errors of its own. Flag
|
|
85
|
+
* relationship failures and every `this.error( ..., { exit } )` already carry an
|
|
86
|
+
* exit code and formatted output, and `handleApiError` would flatten all of it
|
|
87
|
+
* to a bare exit 1 — so pass those straight through and only map what actually
|
|
88
|
+
* came back from the API.
|
|
89
|
+
*/
|
|
90
|
+
export function handleCommandError(error, errorFn, overrides = {}) {
|
|
91
|
+
if (error instanceof CLIError) {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
return handleApiError(error, errorFn, overrides);
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { CLIError } from '@oclif/core/errors';
|
|
4
|
+
import { handleApiError, handleCommandError } from './error_handler.js';
|
|
5
|
+
const errorFn = () => vi.fn((message) => {
|
|
6
|
+
throw new Error(message);
|
|
7
|
+
});
|
|
8
|
+
const apiError = (status, data) => Object.assign(new Error('request failed'), { response: { status, data } });
|
|
9
|
+
describe('handleApiError()', () => {
|
|
10
|
+
it('prefers a caller override over the server body for the same status', () => {
|
|
11
|
+
const fn = errorFn();
|
|
12
|
+
expect(() => handleApiError(apiError(404, { error: 'WorkflowNotFoundError', message: 'Workflow "wf-1" not found' }), fn, { 404: 'Workflow not found. Check the workflow ID.' })).toThrow('Workflow not found. Check the workflow ID.');
|
|
13
|
+
});
|
|
14
|
+
it('surfaces the server body when no override covers the status', () => {
|
|
15
|
+
const fn = errorFn();
|
|
16
|
+
// What `start --monitor` relies on now that it passes no 404 override: the
|
|
17
|
+
// API's own words, rather than advice about an id the user never typed.
|
|
18
|
+
expect(() => handleApiError(apiError(404, { error: 'WorkflowNotFoundError', message: 'Workflow "wf-1" not found' }), fn)).toThrow('WorkflowNotFoundError: Workflow "wf-1" not found.');
|
|
19
|
+
});
|
|
20
|
+
it('appends a root cause to the server message when the API reports one', () => {
|
|
21
|
+
const fn = errorFn();
|
|
22
|
+
expect(() => handleApiError(apiError(500, { error: 'StepFailure', message: 'Step failed', rootCause: { error: 'TypeError', message: 'x is not a function' } }), fn)).toThrow(/TypeError: x is not a function/);
|
|
23
|
+
});
|
|
24
|
+
it('falls back to the status default when the body carries no detail', () => {
|
|
25
|
+
const fn = errorFn();
|
|
26
|
+
expect(() => handleApiError(apiError(401), fn)).toThrow(/OUTPUT_API_AUTH_TOKEN/);
|
|
27
|
+
});
|
|
28
|
+
it.each([
|
|
29
|
+
['a top-level code', Object.assign(new Error('connect'), { code: 'ECONNREFUSED' })],
|
|
30
|
+
['a nested cause', Object.assign(new Error('fetch failed'), { cause: { code: 'ECONNREFUSED' } })]
|
|
31
|
+
])('reports a refused connection from %s before looking at any status', (_label, error) => {
|
|
32
|
+
const fn = errorFn();
|
|
33
|
+
expect(() => handleApiError(error, fn)).toThrow(/Is the API server running\?/);
|
|
34
|
+
});
|
|
35
|
+
it('assembles a detailed message when there is no response at all', () => {
|
|
36
|
+
const fn = errorFn();
|
|
37
|
+
const error = Object.assign(new Error('fetch failed'), {
|
|
38
|
+
cause: Object.assign(new Error('getaddrinfo EAI_AGAIN api'), { code: 'EAI_AGAIN', hostname: 'api', port: 3001 })
|
|
39
|
+
});
|
|
40
|
+
expect(() => handleApiError(error, fn)).toThrow(/fetch failed \| Cause: getaddrinfo EAI_AGAIN api \| Code: EAI_AGAIN \| Host: api:3001/);
|
|
41
|
+
});
|
|
42
|
+
it('always exits 1, leaving a command\'s own exit codes to the command', () => {
|
|
43
|
+
const fn = errorFn();
|
|
44
|
+
expect(() => handleApiError(apiError(500), fn)).toThrow();
|
|
45
|
+
expect(fn).toHaveBeenCalledWith(expect.any(String), { exit: 1 });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('handleCommandError()', () => {
|
|
49
|
+
it('rethrows an oclif error untouched so its exit code and formatting survive', () => {
|
|
50
|
+
const fn = errorFn();
|
|
51
|
+
// `workflow start --monitor` raises exit 2 (bad flag combination) and exit 3
|
|
52
|
+
// (started but unmonitorable); flattening those to exit 1 would let a CI job
|
|
53
|
+
// retrying on a failed workflow re-submit one that is already running.
|
|
54
|
+
const cliError = new CLIError('Cannot combine --monitor with --json', { exit: 2 });
|
|
55
|
+
expect(() => handleCommandError(cliError, fn)).toThrow(cliError);
|
|
56
|
+
expect(fn).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
it('maps anything that came back from the API the same way handleApiError does', () => {
|
|
59
|
+
const fn = errorFn();
|
|
60
|
+
expect(() => handleCommandError(apiError(404), fn, { 404: 'Workflow not found. Check the workflow name.' })).toThrow('Workflow not found. Check the workflow name.');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import type { WorkflowResultResponse
|
|
1
|
+
import type { WorkflowResultResponse } from '../api/generated/api.js';
|
|
2
2
|
type WorkflowResult = Pick<WorkflowResultResponse, 'workflowId' | 'output' | 'status' | 'error'>;
|
|
3
|
-
|
|
4
|
-
export
|
|
3
|
+
declare const ERROR_STATUS_VALUES: readonly ["failed", "cancelled", "terminated", "timed_out"];
|
|
4
|
+
export type ErrorStatus = typeof ERROR_STATUS_VALUES[number];
|
|
5
|
+
export type TerminalStatus = 'completed' | ErrorStatus;
|
|
6
|
+
/**
|
|
7
|
+
* Maps a raw status to a canonical error status, or `undefined` if it is not one.
|
|
8
|
+
* Legacy spellings (`canceled`) are normalized so callers only ever see the
|
|
9
|
+
* current API vocabulary — until `normalizeWorkflowStatus` is removed.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isErrorStatus(status: string | null | undefined): ErrorStatus | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Maps a raw status to a canonical terminal status, or `undefined` if it is not
|
|
14
|
+
* one. Same normalization contract as `isErrorStatus`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isTerminalStatus(status: string | null | undefined): TerminalStatus | undefined;
|
|
5
17
|
export declare function formatWorkflowResult(result: WorkflowResult): string;
|
|
6
18
|
export {};
|
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { normalizeWorkflowStatus } from './normalize_workflow_status.js';
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// `satisfies` rather than a plain annotation: it pins these to the generated API
|
|
3
|
+
// union without widening them, so regenerating the API with a renamed status
|
|
4
|
+
// breaks the build instead of silently making that status non-terminal.
|
|
5
|
+
const ERROR_STATUS_VALUES = [
|
|
6
|
+
'failed', 'cancelled', 'terminated', 'timed_out'
|
|
7
|
+
];
|
|
8
|
+
const ERROR_STATUSES = new Set(ERROR_STATUS_VALUES);
|
|
9
|
+
const TERMINAL_STATUSES = new Set(['completed', ...ERROR_STATUS_VALUES]);
|
|
10
|
+
/**
|
|
11
|
+
* Maps a raw status to a canonical error status, or `undefined` if it is not one.
|
|
12
|
+
* Legacy spellings (`canceled`) are normalized so callers only ever see the
|
|
13
|
+
* current API vocabulary — until `normalizeWorkflowStatus` is removed.
|
|
14
|
+
*/
|
|
15
|
+
/* eslint-disable consistent-return -- returns ErrorStatus | undefined via early exit */
|
|
16
|
+
export function isErrorStatus(status) {
|
|
17
|
+
if (typeof status !== 'string') {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const normalized = normalizeWorkflowStatus(status);
|
|
21
|
+
return ERROR_STATUSES.has(normalized) ? normalized : undefined;
|
|
22
|
+
}
|
|
23
|
+
/* eslint-enable consistent-return */
|
|
24
|
+
/**
|
|
25
|
+
* Maps a raw status to a canonical terminal status, or `undefined` if it is not
|
|
26
|
+
* one. Same normalization contract as `isErrorStatus`.
|
|
27
|
+
*/
|
|
28
|
+
/* eslint-disable consistent-return -- returns TerminalStatus | undefined via early exit */
|
|
29
|
+
export function isTerminalStatus(status) {
|
|
30
|
+
if (typeof status !== 'string') {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const normalized = normalizeWorkflowStatus(status);
|
|
34
|
+
return TERMINAL_STATUSES.has(normalized) ? normalized : undefined;
|
|
35
|
+
}
|
|
36
|
+
/* eslint-enable consistent-return */
|
|
7
37
|
export function formatWorkflowResult(result) {
|
|
8
38
|
const status = normalizeWorkflowStatus(result.status);
|
|
9
39
|
const lines = [
|
|
@@ -17,7 +47,10 @@ export function formatWorkflowResult(result) {
|
|
|
17
47
|
else {
|
|
18
48
|
lines.push(`Status: ${status || 'unknown'}`);
|
|
19
49
|
if (result.error) {
|
|
20
|
-
|
|
50
|
+
const error = typeof result.error === 'string' ?
|
|
51
|
+
result.error :
|
|
52
|
+
result.error.message ?? JSON.stringify(result.error, null, 2);
|
|
53
|
+
lines.push(`Error: ${error}`);
|
|
21
54
|
}
|
|
22
55
|
}
|
|
23
56
|
return lines.join('\n');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { formatWorkflowResult } from './format_workflow_result.js';
|
|
2
|
+
import { formatWorkflowResult, isErrorStatus } from './format_workflow_result.js';
|
|
3
3
|
describe('formatWorkflowResult', () => {
|
|
4
4
|
it('should display output for completed workflows', () => {
|
|
5
5
|
const result = formatWorkflowResult({
|
|
@@ -13,7 +13,7 @@ describe('formatWorkflowResult', () => {
|
|
|
13
13
|
expect(result).toContain('"values"');
|
|
14
14
|
expect(result).not.toContain('Status:');
|
|
15
15
|
});
|
|
16
|
-
it('should display
|
|
16
|
+
it('should display legacy string errors for failed workflows', () => {
|
|
17
17
|
const result = formatWorkflowResult({
|
|
18
18
|
workflowId: 'wf-456',
|
|
19
19
|
status: 'failed',
|
|
@@ -25,6 +25,29 @@ describe('formatWorkflowResult', () => {
|
|
|
25
25
|
expect(result).toContain('Error: Activity task failed');
|
|
26
26
|
expect(result).not.toContain('Output:');
|
|
27
27
|
});
|
|
28
|
+
it('should display the message from structured errors', () => {
|
|
29
|
+
const result = formatWorkflowResult({
|
|
30
|
+
workflowId: 'wf-v2',
|
|
31
|
+
status: 'failed',
|
|
32
|
+
output: null,
|
|
33
|
+
error: {
|
|
34
|
+
name: 'ValidationError',
|
|
35
|
+
message: 'Input is invalid',
|
|
36
|
+
code: 'INVALID_INPUT'
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
expect(result).toContain('Error: Input is invalid');
|
|
40
|
+
expect(result).not.toContain('[object Object]');
|
|
41
|
+
});
|
|
42
|
+
it('should serialize structured errors without a message', () => {
|
|
43
|
+
const result = formatWorkflowResult({
|
|
44
|
+
workflowId: 'wf-v2',
|
|
45
|
+
status: 'failed',
|
|
46
|
+
output: null,
|
|
47
|
+
error: { code: 'UNKNOWN' }
|
|
48
|
+
});
|
|
49
|
+
expect(result).toContain('"code": "UNKNOWN"');
|
|
50
|
+
});
|
|
28
51
|
it('should display status for terminated workflows', () => {
|
|
29
52
|
const result = formatWorkflowResult({
|
|
30
53
|
workflowId: 'wf-term',
|
|
@@ -35,15 +58,25 @@ describe('formatWorkflowResult', () => {
|
|
|
35
58
|
expect(result).toContain('Status: terminated');
|
|
36
59
|
expect(result).toContain('Error: Workflow terminated by user');
|
|
37
60
|
});
|
|
38
|
-
it('should display status for
|
|
61
|
+
it('should display status for cancelled workflows', () => {
|
|
39
62
|
const result = formatWorkflowResult({
|
|
63
|
+
workflowId: 'wf-cancel',
|
|
64
|
+
status: 'cancelled',
|
|
65
|
+
output: null,
|
|
66
|
+
error: 'Workflow was cancelled'
|
|
67
|
+
});
|
|
68
|
+
expect(result).toContain('Status: cancelled');
|
|
69
|
+
expect(result).toContain('Error: Workflow was cancelled');
|
|
70
|
+
});
|
|
71
|
+
it('normalizes canceled responses without changing the current status type', () => {
|
|
72
|
+
const legacyResult = {
|
|
40
73
|
workflowId: 'wf-cancel',
|
|
41
74
|
status: 'canceled',
|
|
42
75
|
output: null,
|
|
43
76
|
error: 'Workflow was canceled'
|
|
44
|
-
}
|
|
45
|
-
expect(
|
|
46
|
-
expect(
|
|
77
|
+
};
|
|
78
|
+
expect(formatWorkflowResult(legacyResult)).toContain('Status: cancelled');
|
|
79
|
+
expect(isErrorStatus('canceled')).toBe('cancelled');
|
|
47
80
|
});
|
|
48
81
|
it('should display status without error line for continued_as_new workflows', () => {
|
|
49
82
|
const result = formatWorkflowResult({
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stream's defaults when the tuning flags are not given. Caller-side, not a
|
|
3
|
+
* property of the loop: `streamWorkflowUpdates` takes all three as required
|
|
4
|
+
* options and never falls back. Single-sourced because only `workflow monitor`
|
|
5
|
+
* can hand them to oclif — `workflow start --monitor` has to apply them itself
|
|
6
|
+
* (see `gatedMonitorStreamFlags`), and a literal repeated on that side would
|
|
7
|
+
* drift the moment one of these changes.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MONITOR_DEFAULTS: {
|
|
10
|
+
readonly interval: 2500;
|
|
11
|
+
readonly color: true;
|
|
12
|
+
readonly includePayloads: false;
|
|
13
|
+
};
|
|
14
|
+
/** For a command that always monitors, where oclif can apply the defaults itself. */
|
|
15
|
+
export declare function monitorStreamFlags(): {
|
|
16
|
+
'include-payloads': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
interval: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
color: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* For a command where monitoring is opt-in (`workflow start --monitor`).
|
|
22
|
+
*
|
|
23
|
+
* These carry no oclif `default` on purpose: a defaulted flag counts as present
|
|
24
|
+
* (`validateFlags` runs a relationship check whenever the parsed value is not
|
|
25
|
+
* `undefined`, and `validateDependsOn` has no `setFromDefault` exemption the way
|
|
26
|
+
* `validateExclusive` does), which triggers its own `dependsOn` check and fails
|
|
27
|
+
* every invocation that omits `--monitor` — including a plain `workflow start`.
|
|
28
|
+
* The caller applies the defaults in `run()`, so the interval's default is
|
|
29
|
+
* spelled out in the help text rather than rendered by oclif.
|
|
30
|
+
*/
|
|
31
|
+
export declare function gatedMonitorStreamFlags(gatedBy: string): {
|
|
32
|
+
'include-payloads': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
33
|
+
interval: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
34
|
+
color: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
35
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
/**
|
|
3
|
+
* The stream's defaults when the tuning flags are not given. Caller-side, not a
|
|
4
|
+
* property of the loop: `streamWorkflowUpdates` takes all three as required
|
|
5
|
+
* options and never falls back. Single-sourced because only `workflow monitor`
|
|
6
|
+
* can hand them to oclif — `workflow start --monitor` has to apply them itself
|
|
7
|
+
* (see `gatedMonitorStreamFlags`), and a literal repeated on that side would
|
|
8
|
+
* drift the moment one of these changes.
|
|
9
|
+
*/
|
|
10
|
+
export const MONITOR_DEFAULTS = {
|
|
11
|
+
interval: 2500,
|
|
12
|
+
color: true,
|
|
13
|
+
includePayloads: false
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Descriptions and constraints for the three flags that tune
|
|
17
|
+
* `streamWorkflowUpdates`, single-sourced so `workflow monitor` and
|
|
18
|
+
* `workflow start --monitor` can't drift apart on help text — the interval
|
|
19
|
+
* caveat in particular is easy to lose in a copy-paste.
|
|
20
|
+
*/
|
|
21
|
+
const PAYLOADS_DESCRIPTION = 'Include decoded step input/output payloads';
|
|
22
|
+
const COLOR_DESCRIPTION = 'Colorize status output (use --no-color to disable)';
|
|
23
|
+
const INTERVAL_DESCRIPTION = 'Poll interval in milliseconds. Once a resumed poll is long-polling ' +
|
|
24
|
+
'server-side for new events, this also bounds how long that block may last (capped at the ' +
|
|
25
|
+
'server\'s configured max), so an idle workflow\'s update cadence is roughly twice this value ' +
|
|
26
|
+
'(the long-poll bound, then this sleep) rather than a much longer, separate server default.';
|
|
27
|
+
/** For a command that always monitors, where oclif can apply the defaults itself. */
|
|
28
|
+
export function monitorStreamFlags() {
|
|
29
|
+
return {
|
|
30
|
+
'include-payloads': Flags.boolean({
|
|
31
|
+
description: PAYLOADS_DESCRIPTION,
|
|
32
|
+
default: MONITOR_DEFAULTS.includePayloads
|
|
33
|
+
}),
|
|
34
|
+
interval: Flags.integer({
|
|
35
|
+
description: INTERVAL_DESCRIPTION,
|
|
36
|
+
default: MONITOR_DEFAULTS.interval,
|
|
37
|
+
min: 1
|
|
38
|
+
}),
|
|
39
|
+
color: Flags.boolean({
|
|
40
|
+
description: COLOR_DESCRIPTION,
|
|
41
|
+
default: MONITOR_DEFAULTS.color,
|
|
42
|
+
allowNo: true
|
|
43
|
+
})
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* For a command where monitoring is opt-in (`workflow start --monitor`).
|
|
48
|
+
*
|
|
49
|
+
* These carry no oclif `default` on purpose: a defaulted flag counts as present
|
|
50
|
+
* (`validateFlags` runs a relationship check whenever the parsed value is not
|
|
51
|
+
* `undefined`, and `validateDependsOn` has no `setFromDefault` exemption the way
|
|
52
|
+
* `validateExclusive` does), which triggers its own `dependsOn` check and fails
|
|
53
|
+
* every invocation that omits `--monitor` — including a plain `workflow start`.
|
|
54
|
+
* The caller applies the defaults in `run()`, so the interval's default is
|
|
55
|
+
* spelled out in the help text rather than rendered by oclif.
|
|
56
|
+
*/
|
|
57
|
+
export function gatedMonitorStreamFlags(gatedBy) {
|
|
58
|
+
const gate = { dependsOn: [gatedBy], helpGroup: 'MONITOR' };
|
|
59
|
+
const requires = ` (requires --${gatedBy})`;
|
|
60
|
+
return {
|
|
61
|
+
'include-payloads': Flags.boolean({
|
|
62
|
+
description: `${PAYLOADS_DESCRIPTION}${requires}`,
|
|
63
|
+
...gate
|
|
64
|
+
}),
|
|
65
|
+
interval: Flags.integer({
|
|
66
|
+
description: `${INTERVAL_DESCRIPTION}${requires} [default: ${MONITOR_DEFAULTS.interval}]`,
|
|
67
|
+
min: 1,
|
|
68
|
+
...gate
|
|
69
|
+
}),
|
|
70
|
+
color: Flags.boolean({
|
|
71
|
+
description: `${COLOR_DESCRIPTION}${requires}`,
|
|
72
|
+
allowNo: true,
|
|
73
|
+
...gate
|
|
74
|
+
})
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Normalizes statuses from earlier API contracts.
|
|
3
|
+
*
|
|
4
|
+
* This can be removed after Aug, 2026
|
|
4
5
|
*
|
|
5
6
|
* @param status - Workflow status from the API
|
|
6
7
|
* @returns Normalized workflow status
|
|
7
8
|
*/
|
|
8
|
-
export declare const normalizeWorkflowStatus: <T extends string | null | undefined>(status: T) => T | "continued_as_new";
|
|
9
|
+
export declare const normalizeWorkflowStatus: <T extends string | null | undefined>(status: T) => T | "continued_as_new" | "cancelled";
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Normalizes statuses from earlier API contracts.
|
|
3
|
+
*
|
|
4
|
+
* This can be removed after Aug, 2026
|
|
4
5
|
*
|
|
5
6
|
* @param status - Workflow status from the API
|
|
6
7
|
* @returns Normalized workflow status
|
|
7
8
|
*/
|
|
8
|
-
export const normalizeWorkflowStatus = (status) =>
|
|
9
|
+
export const normalizeWorkflowStatus = (status) => {
|
|
10
|
+
if (status === 'continued') {
|
|
11
|
+
return 'continued_as_new';
|
|
12
|
+
}
|
|
13
|
+
if (status === 'canceled') {
|
|
14
|
+
return 'cancelled';
|
|
15
|
+
}
|
|
16
|
+
return status;
|
|
17
|
+
};
|
|
@@ -4,6 +4,9 @@ describe('normalizeWorkflowStatus', () => {
|
|
|
4
4
|
it('temporarily maps continued to continued_as_new', () => {
|
|
5
5
|
expect(normalizeWorkflowStatus('continued')).toBe('continued_as_new');
|
|
6
6
|
});
|
|
7
|
+
it('maps the previous canceled spelling to cancelled', () => {
|
|
8
|
+
expect(normalizeWorkflowStatus('canceled')).toBe('cancelled');
|
|
9
|
+
});
|
|
7
10
|
it('leaves other statuses and nullish values unchanged', () => {
|
|
8
11
|
expect(normalizeWorkflowStatus('completed')).toBe('completed');
|
|
9
12
|
expect(normalizeWorkflowStatus('continued_as_new')).toBe('continued_as_new');
|
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
* an actionable hint that names the conflicting port and the env var to
|
|
4
4
|
* override.
|
|
5
5
|
*
|
|
6
|
-
* Docker
|
|
7
|
-
*
|
|
8
|
-
* - "failed to bind host port for 0.0.0.0:7233:.../tcp: address already in use"
|
|
6
|
+
* Docker wraps the same failure differently across versions and platforms —
|
|
7
|
+
* Docker 29 on macOS nests it three deep:
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Error response from daemon: failed to set up container networking: driver
|
|
10
|
+
* failed programming external connectivity on endpoint out-api-1 (a1b2…):
|
|
11
|
+
* Bind for 0.0.0.0:3001 failed: port is already allocated
|
|
12
|
+
*
|
|
13
|
+
* Matching whole message shapes means a new wrapper silently drops the hint, so
|
|
14
|
+
* we anchor on the terminal phrase instead and take the host port nearest to it.
|
|
15
|
+
* That survives wrappers we haven't seen.
|
|
16
|
+
*
|
|
17
|
+
* The port is then mapped back to the env var that sets it. The map prefers a
|
|
18
|
+
* runtime lookup of resolved ports (so a user who already set
|
|
19
|
+
* OUTPUT_API_HOST_PORT=3050 sees that var named when 3050 collides) and falls
|
|
20
|
+
* back to a default-port table for the unresolved case.
|
|
14
21
|
*/
|
|
15
22
|
/**
|
|
16
23
|
* Find the first host port mentioned in a docker compose bind failure.
|
|
@@ -24,6 +31,14 @@ export declare function extractCollidedPort(stderr: string): number | null;
|
|
|
24
31
|
* that overrides it; otherwise it suggests freeing the port.
|
|
25
32
|
*/
|
|
26
33
|
export declare function formatPortCollisionHint(stderr: string, resolvedPorts: Record<string, number>): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* Compose a docker-failure message from a caller-supplied core sentence and the
|
|
36
|
+
* process's recent output: an actionable port-collision hint (when one is
|
|
37
|
+
* detected) is prepended, and the raw recent output is appended. Shared by the
|
|
38
|
+
* foreground exit handler and the detached/reconcile path so both surface the
|
|
39
|
+
* same failure shape.
|
|
40
|
+
*/
|
|
41
|
+
export declare function formatComposeFailure(reason: string, output: string, resolvedPorts: Record<string, number>): string;
|
|
27
42
|
/**
|
|
28
43
|
* Build a hint from a known list of colliding ports. For a single collision
|
|
29
44
|
* the output matches `formatPortCollisionHint` exactly so callers stay
|