@outputai/cli 0.10.1-dev.b7b2fbe.0 → 0.10.1-next.1070949.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 +93 -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/history.js +3 -3
- package/dist/commands/workflow/history.spec.js +31 -2
- package/dist/commands/workflow/monitor.d.ts +49 -0
- package/dist/commands/workflow/monitor.js +230 -0
- package/dist/commands/workflow/monitor.spec.d.ts +1 -0
- package/dist/commands/workflow/monitor.spec.js +243 -0
- 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 +3 -1
- package/dist/commands/workflow/start.js +12 -2
- package/dist/commands/workflow/start.spec.js +30 -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/workflow_history/correlator.d.ts +2 -0
- package/dist/services/workflow_history/correlator.js +2 -2
- package/dist/services/workflow_history.d.ts +28 -0
- package/dist/services/workflow_history.js +95 -12
- package/dist/services/workflow_history.spec.js +183 -1
- 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/color.d.ts +7 -0
- package/dist/utils/color.js +12 -0
- package/dist/utils/color.spec.d.ts +1 -0
- package/dist/utils/color.spec.js +43 -0
- package/dist/utils/env_loader.js +6 -2
- package/dist/utils/env_loader.spec.js +61 -32
- package/dist/utils/format_workflow_result.d.ts +4 -2
- package/dist/utils/format_workflow_result.js +10 -2
- package/dist/utils/format_workflow_result.spec.js +39 -6
- package/dist/utils/monitor_log.d.ts +20 -0
- package/dist/utils/monitor_log.js +48 -0
- package/dist/utils/monitor_log.spec.d.ts +1 -0
- package/dist/utils/monitor_log.spec.js +71 -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/utils/waterfall.d.ts +3 -1
- package/dist/utils/waterfall.js +8 -2
- 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 +8 -9
- package/dist/views/dev/hooks/use_run_detail.spec.js +1 -1
- package/dist/views/dev/hooks/use_step_graph.js +3 -1
- package/dist/views/dev/panels/runs_panel.js +2 -2
- package/dist/views/dev/utils/bounded_cache.d.ts +14 -0
- package/dist/views/dev/utils/bounded_cache.js +42 -0
- package/dist/views/dev/utils/bounded_cache.spec.d.ts +1 -0
- package/dist/views/dev/utils/bounded_cache.spec.js +53 -0
- package/oclif.manifest.json +126 -10
- package/package.json +7 -9
- /package/dist/commands/{workflow/test_eval.spec.d.ts → dev/down.spec.d.ts} +0 -0
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"output:worker:build": "rm -rf dist/* && tsc -p ./ && output-copy-assets",
|
|
10
10
|
"output:worker:start": "output-worker",
|
|
11
11
|
"output:worker:check": "output-worker --check",
|
|
12
|
-
"output:worker": "npm run output:worker:
|
|
13
|
-
"output:worker:watch": "npx nodemon --watch src --
|
|
12
|
+
"output:worker": "npm run output:worker:build && npm run output:worker:start",
|
|
13
|
+
"output:worker:watch": "npx nodemon --watch src --ext ts,js,json,prompt,md --ignore 'dist/**' --ignore '**/*.spec.*' --ignore '**/*.test.*' --exec 'npm run output:worker'",
|
|
14
14
|
"output:dev": "output dev"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createKyClient } from '@outputai/http';
|
|
2
2
|
|
|
3
3
|
export interface JinaReaderResponse {
|
|
4
4
|
code: number;
|
|
@@ -12,13 +12,13 @@ export interface JinaReaderResponse {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
|
|
15
|
+
const client = createKyClient( {
|
|
16
|
+
prefix: 'https://r.jina.ai',
|
|
17
17
|
timeout: 30000
|
|
18
18
|
} );
|
|
19
19
|
|
|
20
20
|
export async function fetchBlogContent( url: string ): Promise<JinaReaderResponse> {
|
|
21
|
-
const response = await
|
|
21
|
+
const response = await client.post( '', {
|
|
22
22
|
json: { url },
|
|
23
23
|
headers: {
|
|
24
24
|
'Accept': 'application/json',
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard TTY/env precedence for whether a command should colorize its
|
|
3
|
+
* output: the command's own --color/--no-color flag wins first, then
|
|
4
|
+
* NO_COLOR opts out, then FORCE_COLOR opts in even off a TTY, then finally
|
|
5
|
+
* fall back to whether stdout is an interactive terminal.
|
|
6
|
+
*/
|
|
7
|
+
export declare function shouldColorize(flag: boolean): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard TTY/env precedence for whether a command should colorize its
|
|
3
|
+
* output: the command's own --color/--no-color flag wins first, then
|
|
4
|
+
* NO_COLOR opts out, then FORCE_COLOR opts in even off a TTY, then finally
|
|
5
|
+
* fall back to whether stdout is an interactive terminal.
|
|
6
|
+
*/
|
|
7
|
+
export function shouldColorize(flag) {
|
|
8
|
+
// Per the NO_COLOR convention (https://no-color.org/), presence disables color
|
|
9
|
+
// regardless of value — `NO_COLOR=` must opt out just like `NO_COLOR=1`.
|
|
10
|
+
return flag && process.env.NO_COLOR === undefined &&
|
|
11
|
+
(!!process.env.FORCE_COLOR || process.stdout.isTTY === true);
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { shouldColorize } from '#utils/color.js';
|
|
3
|
+
describe('shouldColorize', () => {
|
|
4
|
+
const originalEnv = { ...process.env };
|
|
5
|
+
const originalTTY = process.stdout.isTTY;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
delete process.env.NO_COLOR;
|
|
8
|
+
delete process.env.FORCE_COLOR;
|
|
9
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
process.env = { ...originalEnv };
|
|
13
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalTTY, configurable: true });
|
|
14
|
+
});
|
|
15
|
+
it('returns false when the flag itself is false', () => {
|
|
16
|
+
expect(shouldColorize(false)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
it('returns true on a TTY with no overrides', () => {
|
|
19
|
+
expect(shouldColorize(true)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('disables color when NO_COLOR is set to a non-empty value', () => {
|
|
22
|
+
process.env.NO_COLOR = '1';
|
|
23
|
+
expect(shouldColorize(true)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it('disables color when NO_COLOR is present but empty, per the NO_COLOR convention', () => {
|
|
26
|
+
process.env.NO_COLOR = '';
|
|
27
|
+
expect(shouldColorize(true)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
it('enables color off a TTY when FORCE_COLOR is set', () => {
|
|
30
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
|
31
|
+
process.env.FORCE_COLOR = '1';
|
|
32
|
+
expect(shouldColorize(true)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it('disables color off a TTY with no FORCE_COLOR', () => {
|
|
35
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
|
36
|
+
expect(shouldColorize(true)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it('NO_COLOR wins even when FORCE_COLOR is also set', () => {
|
|
39
|
+
process.env.FORCE_COLOR = '1';
|
|
40
|
+
process.env.NO_COLOR = '1';
|
|
41
|
+
expect(shouldColorize(true)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
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
|
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { WorkflowResultResponse,
|
|
1
|
+
import type { WorkflowResultResponse, WorkflowResultStatus } from '../api/generated/api.js';
|
|
2
2
|
type WorkflowResult = Pick<WorkflowResultResponse, 'workflowId' | 'output' | 'status' | 'error'>;
|
|
3
|
-
export declare const ERROR_STATUSES: ReadonlySet<
|
|
3
|
+
export declare const ERROR_STATUSES: ReadonlySet<WorkflowResultStatus>;
|
|
4
|
+
export declare const isErrorWorkflowStatus: (status: string | null | undefined) => boolean;
|
|
5
|
+
export declare const TERMINAL_STATUSES: ReadonlySet<string>;
|
|
4
6
|
export declare function formatWorkflowResult(result: WorkflowResult): string;
|
|
5
7
|
export {};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { normalizeWorkflowStatus } from './normalize_workflow_status.js';
|
|
2
|
-
export const ERROR_STATUSES = new Set(['failed', '
|
|
2
|
+
export const ERROR_STATUSES = new Set(['failed', 'cancelled', 'terminated', 'timed_out']);
|
|
3
|
+
export const isErrorWorkflowStatus = (status) => ERROR_STATUSES.has(normalizeWorkflowStatus(status));
|
|
4
|
+
// Every error status plus the one success status — derived so the two sets can't
|
|
5
|
+
// silently drift apart as error statuses evolve. Shared by `workflow monitor` and
|
|
6
|
+
// the dev TUI's `useRunDetail`/`useStepGraph` so both agree on what "done" means.
|
|
7
|
+
export const TERMINAL_STATUSES = new Set(['completed', ...ERROR_STATUSES]);
|
|
3
8
|
export function formatWorkflowResult(result) {
|
|
4
9
|
const status = normalizeWorkflowStatus(result.status);
|
|
5
10
|
const lines = [
|
|
@@ -13,7 +18,10 @@ export function formatWorkflowResult(result) {
|
|
|
13
18
|
else {
|
|
14
19
|
lines.push(`Status: ${status || 'unknown'}`);
|
|
15
20
|
if (result.error) {
|
|
16
|
-
|
|
21
|
+
const error = typeof result.error === 'string' ?
|
|
22
|
+
result.error :
|
|
23
|
+
result.error.message ?? JSON.stringify(result.error, null, 2);
|
|
24
|
+
lines.push(`Error: ${error}`);
|
|
17
25
|
}
|
|
18
26
|
}
|
|
19
27
|
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, isErrorWorkflowStatus } 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(isErrorWorkflowStatus('canceled')).toBe(true);
|
|
47
80
|
});
|
|
48
81
|
it('should display status without error line for continued_as_new workflows', () => {
|
|
49
82
|
const result = formatWorkflowResult({
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns newly-correlated spans into append-only status lines for `workflow
|
|
3
|
+
* monitor`. Unlike the waterfall (which needs the full span set up front to
|
|
4
|
+
* lay out a time axis), a live monitor just reports each span's status
|
|
5
|
+
* transitions as they're observed on each poll.
|
|
6
|
+
*/
|
|
7
|
+
import type { Span, SpanStatus } from '#services/workflow_history/correlator.js';
|
|
8
|
+
export interface SpanUpdate {
|
|
9
|
+
span: Span;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns spans whose status changed since the last call. `seen` is mutated
|
|
14
|
+
* in place so callers can carry it across polls. Pending spans are skipped —
|
|
15
|
+
* nothing worth reporting until a step starts.
|
|
16
|
+
*/
|
|
17
|
+
export declare function diffSpanUpdates(spans: Span[], labels: Map<string, string>, seen: Map<string, SpanStatus>): SpanUpdate[];
|
|
18
|
+
/** Formats the continue-as-new transition line, keeping its glyph in the formatting layer. */
|
|
19
|
+
export declare function formatContinuedAsNew(runId: string): string;
|
|
20
|
+
export declare function formatSpanUpdate(update: SpanUpdate, color: boolean): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ANSI, formatDurationLabel, makeTint } from '#utils/waterfall.js';
|
|
2
|
+
const GLYPH = {
|
|
3
|
+
pending: '·',
|
|
4
|
+
running: '●',
|
|
5
|
+
completed: '✓',
|
|
6
|
+
failed: '✗'
|
|
7
|
+
};
|
|
8
|
+
// Continue-as-new is a workflow-level transition, not a span status, so it gets its own glyph
|
|
9
|
+
// here in the formatting layer rather than being concatenated into the message at the call site.
|
|
10
|
+
const CONTINUED_AS_NEW_GLYPH = '↻';
|
|
11
|
+
/**
|
|
12
|
+
* Returns spans whose status changed since the last call. `seen` is mutated
|
|
13
|
+
* in place so callers can carry it across polls. Pending spans are skipped —
|
|
14
|
+
* nothing worth reporting until a step starts.
|
|
15
|
+
*/
|
|
16
|
+
export function diffSpanUpdates(spans, labels, seen) {
|
|
17
|
+
const updates = [];
|
|
18
|
+
for (const span of spans) {
|
|
19
|
+
if (span.status === 'pending' || seen.get(span.id) === span.status) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
seen.set(span.id, span.status);
|
|
23
|
+
updates.push({ span, label: labels.get(span.id) ?? span.name });
|
|
24
|
+
}
|
|
25
|
+
return updates;
|
|
26
|
+
}
|
|
27
|
+
/** Formats the continue-as-new transition line, keeping its glyph in the formatting layer. */
|
|
28
|
+
export function formatContinuedAsNew(runId) {
|
|
29
|
+
return `${CONTINUED_AS_NEW_GLYPH} continued as new run ${runId}`;
|
|
30
|
+
}
|
|
31
|
+
export function formatSpanUpdate(update, color) {
|
|
32
|
+
const { span, label } = update;
|
|
33
|
+
const glyph = GLYPH[span.status];
|
|
34
|
+
const tint = makeTint(color);
|
|
35
|
+
const tintStatus = (text) => tint(text, ANSI[span.status]);
|
|
36
|
+
switch (span.status) {
|
|
37
|
+
case 'running':
|
|
38
|
+
return `${tintStatus(glyph)} ${label} running…`;
|
|
39
|
+
case 'completed':
|
|
40
|
+
return `${tintStatus(glyph)} ${label} ${formatDurationLabel(Math.max(0, span.durationMs))}`;
|
|
41
|
+
case 'failed': {
|
|
42
|
+
const reason = span.failureMessage ? `: ${span.failureMessage}` : '';
|
|
43
|
+
return `${tintStatus(glyph)} ${label} failed${reason}`;
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
return `${glyph} ${label} ${span.status}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diffSpanUpdates, formatSpanUpdate } from '#utils/monitor_log.js';
|
|
3
|
+
const span = (overrides) => ({
|
|
4
|
+
name: 'Step',
|
|
5
|
+
technicalName: 'wf#step',
|
|
6
|
+
description: null,
|
|
7
|
+
kind: 'activity',
|
|
8
|
+
attempt: 1,
|
|
9
|
+
startedAt: null,
|
|
10
|
+
scheduledAt: null,
|
|
11
|
+
completedAt: null,
|
|
12
|
+
startOffsetMs: 0,
|
|
13
|
+
endOffsetMs: 0,
|
|
14
|
+
durationMs: 0,
|
|
15
|
+
failureMessage: null,
|
|
16
|
+
...overrides
|
|
17
|
+
});
|
|
18
|
+
describe('diffSpanUpdates', () => {
|
|
19
|
+
it('skips pending spans', () => {
|
|
20
|
+
const seen = new Map();
|
|
21
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'pending' })], new Map(), seen);
|
|
22
|
+
expect(updates).toHaveLength(0);
|
|
23
|
+
expect(seen.size).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
it('reports a span the first time it is seen in a non-pending status', () => {
|
|
26
|
+
const seen = new Map();
|
|
27
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'running' })], new Map([['1', 'Fetch page']]), seen);
|
|
28
|
+
expect(updates).toHaveLength(1);
|
|
29
|
+
expect(updates[0].label).toBe('Fetch page');
|
|
30
|
+
expect(seen.get('1')).toBe('running');
|
|
31
|
+
});
|
|
32
|
+
it('does not re-report a span whose status is unchanged since the last call', () => {
|
|
33
|
+
const seen = new Map([['1', 'running']]);
|
|
34
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'running' })], new Map(), seen);
|
|
35
|
+
expect(updates).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
it('reports a span again once its status transitions (running -> completed)', () => {
|
|
38
|
+
const seen = new Map([['1', 'running']]);
|
|
39
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'completed' })], new Map(), seen);
|
|
40
|
+
expect(updates).toHaveLength(1);
|
|
41
|
+
expect(seen.get('1')).toBe('completed');
|
|
42
|
+
});
|
|
43
|
+
it('falls back to the span name when no label is provided', () => {
|
|
44
|
+
const seen = new Map();
|
|
45
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'running', name: 'Unlabeled' })], new Map(), seen);
|
|
46
|
+
expect(updates[0].label).toBe('Unlabeled');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('formatSpanUpdate', () => {
|
|
50
|
+
it('formats a running span', () => {
|
|
51
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'running' }), label: 'Fetch page' }, false);
|
|
52
|
+
expect(line).toBe('● Fetch page running…');
|
|
53
|
+
});
|
|
54
|
+
it('formats a completed span with its duration', () => {
|
|
55
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'completed', durationMs: 1234 }), label: 'Fetch page' }, false);
|
|
56
|
+
expect(line).toBe('✓ Fetch page 1s');
|
|
57
|
+
});
|
|
58
|
+
it('formats a failed span with its failure message', () => {
|
|
59
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'failed', failureMessage: 'boom' }), label: 'Fetch page' }, false);
|
|
60
|
+
expect(line).toBe('✗ Fetch page failed: boom');
|
|
61
|
+
});
|
|
62
|
+
it('formats a failed span without a failure message', () => {
|
|
63
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'failed' }), label: 'Fetch page' }, false);
|
|
64
|
+
expect(line).toBe('✗ Fetch page failed');
|
|
65
|
+
});
|
|
66
|
+
it('wraps the glyph in ANSI codes when color is enabled', () => {
|
|
67
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'running' }), label: 'Fetch page' }, true);
|
|
68
|
+
expect(line).toContain('●');
|
|
69
|
+
expect(line).not.toBe('● Fetch page running…'); // color codes present
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -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
|