@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
|
@@ -59,12 +59,26 @@ 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
|
});
|
|
66
75
|
await cmd.run();
|
|
67
|
-
expect(resolveInput).toHaveBeenCalledWith(
|
|
76
|
+
expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
|
|
77
|
+
workflowName: 'my_workflow',
|
|
78
|
+
commandName: 'run',
|
|
79
|
+
catalog: undefined,
|
|
80
|
+
json: false
|
|
81
|
+
}));
|
|
68
82
|
expect(postWorkflowRun).toHaveBeenCalledTimes(1);
|
|
69
83
|
expect(postWorkflowRun).toHaveBeenCalledWith({ workflowName: 'my_workflow', input: { key: 'value' }, catalog: undefined }, expect.objectContaining({ config: { timeout: 600000 } }));
|
|
70
84
|
expect(cmd.log).toHaveBeenCalledWith('Executing workflow: my_workflow...');
|
|
@@ -78,12 +92,26 @@ describe('workflow run command', () => {
|
|
|
78
92
|
});
|
|
79
93
|
resolveInput.mockResolvedValue({ key: 'value' });
|
|
80
94
|
postWorkflowRun.mockResolvedValue({
|
|
81
|
-
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
|
+
},
|
|
82
105
|
status: 200,
|
|
83
106
|
headers: new Headers()
|
|
84
107
|
});
|
|
85
108
|
await cmd.run();
|
|
86
|
-
expect(resolveInput).toHaveBeenCalledWith(
|
|
109
|
+
expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
|
|
110
|
+
workflowName: 'my_workflow',
|
|
111
|
+
scenario: 'basic',
|
|
112
|
+
commandName: 'run',
|
|
113
|
+
catalog: 'my-catalog'
|
|
114
|
+
}));
|
|
87
115
|
expect(postWorkflowRun).toHaveBeenCalledWith(expect.objectContaining({ catalog: 'my-catalog' }), expect.anything());
|
|
88
116
|
});
|
|
89
117
|
it('retries when response has Retry-After and succeeds on second attempt', async () => {
|
|
@@ -93,7 +121,16 @@ describe('workflow run command', () => {
|
|
|
93
121
|
postWorkflowRun
|
|
94
122
|
.mockRejectedValueOnce(new HttpError('Unavailable', { status: 503, headers }))
|
|
95
123
|
.mockResolvedValueOnce({
|
|
96
|
-
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
|
+
},
|
|
97
134
|
status: 200,
|
|
98
135
|
headers: new Headers()
|
|
99
136
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
|
+
import { type PostWorkflowStart200 } from '#api/generated/api.js';
|
|
2
3
|
export default class WorkflowStart extends Command {
|
|
3
4
|
static description: string;
|
|
5
|
+
static enableJsonFlag: boolean;
|
|
4
6
|
static examples: string[];
|
|
5
7
|
static args: {
|
|
6
8
|
workflowName: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
@@ -10,6 +12,6 @@ export default class WorkflowStart extends Command {
|
|
|
10
12
|
input: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
13
|
catalog: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
14
|
};
|
|
13
|
-
run(): Promise<
|
|
15
|
+
run(): Promise<PostWorkflowStart200>;
|
|
14
16
|
catch(error: Error): Promise<void>;
|
|
15
17
|
}
|
|
@@ -4,11 +4,13 @@ import { handleApiError } from '#utils/error_handler.js';
|
|
|
4
4
|
import { resolveInput } from '#utils/resolve_input.js';
|
|
5
5
|
export default class WorkflowStart extends Command {
|
|
6
6
|
static description = 'Start a workflow asynchronously without waiting for completion';
|
|
7
|
+
static enableJsonFlag = true;
|
|
7
8
|
static examples = [
|
|
8
9
|
'<%= config.bin %> <%= command.id %> simple basic_input',
|
|
9
10
|
'<%= config.bin %> <%= command.id %> simple --input \'{"values":[1,2,3]}\'',
|
|
10
11
|
'<%= config.bin %> <%= command.id %> simple --input input.json',
|
|
11
|
-
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --catalog my-catalog'
|
|
12
|
+
'<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --catalog my-catalog',
|
|
13
|
+
'<%= config.bin %> <%= command.id %> simple --json'
|
|
12
14
|
];
|
|
13
15
|
static args = {
|
|
14
16
|
workflowName: Args.string({
|
|
@@ -37,7 +39,14 @@ export default class WorkflowStart extends Command {
|
|
|
37
39
|
};
|
|
38
40
|
async run() {
|
|
39
41
|
const { args, flags } = await this.parse(WorkflowStart);
|
|
40
|
-
const input = await resolveInput(
|
|
42
|
+
const input = await resolveInput({
|
|
43
|
+
workflowName: args.workflowName,
|
|
44
|
+
scenario: args.scenario,
|
|
45
|
+
inputFlag: flags.input,
|
|
46
|
+
commandName: 'start',
|
|
47
|
+
catalog: flags.catalog,
|
|
48
|
+
json: this.jsonEnabled()
|
|
49
|
+
});
|
|
41
50
|
this.log(`Starting workflow: ${args.workflowName}...`);
|
|
42
51
|
const response = await postWorkflowStart({
|
|
43
52
|
workflowName: args.workflowName,
|
|
@@ -57,6 +66,7 @@ export default class WorkflowStart extends Command {
|
|
|
57
66
|
`Use "workflow result ${result.workflowId || '<workflow-id>'}" to get the workflow result when complete`
|
|
58
67
|
].join('\n');
|
|
59
68
|
this.log(`\n${output}`);
|
|
69
|
+
return result;
|
|
60
70
|
}
|
|
61
71
|
async catch(error) {
|
|
62
72
|
return handleApiError(error, (...args) => this.error(...args), {
|
|
@@ -36,13 +36,17 @@ describe('workflow start command', () => {
|
|
|
36
36
|
expect(WorkflowStart.flags.catalog.env).toBe('OUTPUT_CATALOG_ID');
|
|
37
37
|
expect(WorkflowStart.flags.catalog.char).toBe('c');
|
|
38
38
|
});
|
|
39
|
+
it('enables the built-in --json flag', async () => {
|
|
40
|
+
const WorkflowStart = (await import('./start.js')).default;
|
|
41
|
+
expect(WorkflowStart.enableJsonFlag).toBe(true);
|
|
42
|
+
});
|
|
39
43
|
});
|
|
40
44
|
describe('run()', () => {
|
|
41
|
-
const createCommand = async (flagOverrides = {}) => {
|
|
45
|
+
const createCommand = async (flagOverrides = {}, argv = ['my_workflow']) => {
|
|
42
46
|
const WorkflowStart = (await import('./start.js')).default;
|
|
43
47
|
const { postWorkflowStart } = await import('#api/generated/api.js');
|
|
44
48
|
const { resolveInput } = await import('#utils/resolve_input.js');
|
|
45
|
-
const cmd = new WorkflowStart(
|
|
49
|
+
const cmd = new WorkflowStart(argv, {});
|
|
46
50
|
cmd.log = vi.fn();
|
|
47
51
|
cmd.error = vi.fn(() => {
|
|
48
52
|
throw new Error('error called');
|
|
@@ -61,9 +65,15 @@ describe('workflow start command', () => {
|
|
|
61
65
|
status: 200,
|
|
62
66
|
headers: new Headers()
|
|
63
67
|
});
|
|
64
|
-
await cmd.run();
|
|
65
|
-
expect(resolveInput).toHaveBeenCalledWith(
|
|
68
|
+
const result = await cmd.run();
|
|
69
|
+
expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
|
|
70
|
+
workflowName: 'my_workflow',
|
|
71
|
+
commandName: 'start',
|
|
72
|
+
catalog: 'my-catalog',
|
|
73
|
+
json: false
|
|
74
|
+
}));
|
|
66
75
|
expect(postWorkflowStart).toHaveBeenCalledWith(expect.objectContaining({ workflowName: 'my_workflow', catalog: 'my-catalog' }));
|
|
76
|
+
expect(result).toEqual({ workflowId: 'wf-123' });
|
|
67
77
|
});
|
|
68
78
|
it('passes undefined catalog through when none is set', async () => {
|
|
69
79
|
const { cmd, postWorkflowStart, resolveInput } = await createCommand();
|
|
@@ -74,7 +84,22 @@ describe('workflow start command', () => {
|
|
|
74
84
|
headers: new Headers()
|
|
75
85
|
});
|
|
76
86
|
await cmd.run();
|
|
77
|
-
expect(resolveInput).toHaveBeenCalledWith(
|
|
87
|
+
expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
|
|
88
|
+
workflowName: 'my_workflow',
|
|
89
|
+
commandName: 'start',
|
|
90
|
+
catalog: undefined
|
|
91
|
+
}));
|
|
92
|
+
});
|
|
93
|
+
it('tells resolveInput to stay quiet when --json is set', async () => {
|
|
94
|
+
const { cmd, postWorkflowStart, resolveInput } = await createCommand({}, ['my_workflow', 'basic', '--json']);
|
|
95
|
+
resolveInput.mockResolvedValue({});
|
|
96
|
+
postWorkflowStart.mockResolvedValue({
|
|
97
|
+
data: { workflowId: 'wf-123' },
|
|
98
|
+
status: 200,
|
|
99
|
+
headers: new Headers()
|
|
100
|
+
});
|
|
101
|
+
await cmd.run();
|
|
102
|
+
expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({ json: true }));
|
|
78
103
|
});
|
|
79
104
|
});
|
|
80
105
|
});
|
|
@@ -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 = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -45,11 +45,11 @@ describe('workflow test command', () => {
|
|
|
45
45
|
});
|
|
46
46
|
describe('command definition', () => {
|
|
47
47
|
it('enables the built-in --json flag', async () => {
|
|
48
|
-
const WorkflowTest = (await import('./
|
|
48
|
+
const WorkflowTest = (await import('./test.js')).default;
|
|
49
49
|
expect(WorkflowTest.enableJsonFlag).toBe(true);
|
|
50
50
|
});
|
|
51
51
|
it('binds the catalog flag to OUTPUT_CATALOG_ID', async () => {
|
|
52
|
-
const WorkflowTest = (await import('./
|
|
52
|
+
const WorkflowTest = (await import('./test.js')).default;
|
|
53
53
|
expect(WorkflowTest.flags).toHaveProperty('catalog');
|
|
54
54
|
expect(WorkflowTest.flags.catalog.env).toBe('OUTPUT_CATALOG_ID');
|
|
55
55
|
expect(WorkflowTest.flags.catalog.char).toBe('c');
|
|
@@ -57,7 +57,7 @@ describe('workflow test command', () => {
|
|
|
57
57
|
});
|
|
58
58
|
describe('run()', () => {
|
|
59
59
|
const createCommand = async (jsonEnabled) => {
|
|
60
|
-
const WorkflowTest = (await import('./
|
|
60
|
+
const WorkflowTest = (await import('./test.js')).default;
|
|
61
61
|
const { postWorkflowRun } = await import('#api/generated/api.js');
|
|
62
62
|
const cmd = new WorkflowTest(['simple'], {});
|
|
63
63
|
cmd.log = vi.fn();
|
|
@@ -99,7 +99,7 @@ describe('workflow test command', () => {
|
|
|
99
99
|
expect(process.exitCode).toBe(1);
|
|
100
100
|
});
|
|
101
101
|
it('routes registration, dataset runs, and the eval run to the resolved catalog', async () => {
|
|
102
|
-
const WorkflowTest = (await import('./
|
|
102
|
+
const WorkflowTest = (await import('./test.js')).default;
|
|
103
103
|
const { postWorkflowRun } = await import('#api/generated/api.js');
|
|
104
104
|
const { fetchWorkflowCatalog } = await import('#api/workflow_catalog.js');
|
|
105
105
|
const cmd = new WorkflowTest(['my_workflow'], {});
|
|
@@ -24,10 +24,32 @@ export declare class DockerComposeConfigNotFoundError extends Error {
|
|
|
24
24
|
declare const isDockerInstalled: () => boolean;
|
|
25
25
|
export declare function validateDockerEnvironment(): void;
|
|
26
26
|
export declare function getDefaultDockerComposePath(): string;
|
|
27
|
+
export declare function resolveDockerComposePath(customPath?: string): Promise<string>;
|
|
27
28
|
export declare function parseServiceStatus(jsonOutput: string): ServiceStatus[];
|
|
28
29
|
export declare function getServiceStatus(dockerComposePath: string): Promise<ServiceStatus[]>;
|
|
29
30
|
export declare function isServiceHealthy(service: ServiceStatus): boolean;
|
|
30
31
|
export declare function isServiceFailed(service: ServiceStatus): boolean;
|
|
32
|
+
export declare const STACK_STATE: {
|
|
33
|
+
/** Nothing live for this project — a fresh start we own. */
|
|
34
|
+
readonly NONE: "none";
|
|
35
|
+
/** Every container found is running and healthy (or has no healthcheck). */
|
|
36
|
+
readonly RUNNING: "running";
|
|
37
|
+
/** Something is live but not everything is healthy — reconcile. */
|
|
38
|
+
readonly PARTIAL: "partial";
|
|
39
|
+
};
|
|
40
|
+
export type StackState = typeof STACK_STATE[keyof typeof STACK_STATE];
|
|
41
|
+
/**
|
|
42
|
+
* Classify the current state of a project's stack from `docker compose ps`.
|
|
43
|
+
*
|
|
44
|
+
* This is the detection signal `output dev` branches on: nothing live means a
|
|
45
|
+
* fresh start; an all-healthy result means we can attach and monitor without
|
|
46
|
+
* touching the stack; anything in between is reconciled with `up -d`.
|
|
47
|
+
*
|
|
48
|
+
* Scoped to the shared `output-sdk` compose project, which distinguishes our
|
|
49
|
+
* containers from unrelated processes — but not one Output checkout from
|
|
50
|
+
* another, since the project name defaults to a machine-global constant.
|
|
51
|
+
*/
|
|
52
|
+
export declare function classifyStackState(services: ServiceStatus[]): StackState;
|
|
31
53
|
export declare function waitForServicesHealthy(dockerComposePath: string, timeoutMs?: number, pollIntervalMs?: number): Promise<void>;
|
|
32
54
|
export interface DockerComposeHandlers {
|
|
33
55
|
onError?: (error: Error, output: string) => void;
|
|
@@ -39,6 +61,11 @@ export interface StartDockerComposeOptions extends DockerComposeHandlers {
|
|
|
39
61
|
pullPolicy?: PullPolicy;
|
|
40
62
|
}
|
|
41
63
|
export declare function startDockerCompose({ dockerComposePath, pullPolicy, onError, onExit }: StartDockerComposeOptions): Promise<ChildProcess>;
|
|
42
|
-
export
|
|
64
|
+
export interface DetachedUpResult {
|
|
65
|
+
code: number | null;
|
|
66
|
+
signal: NodeJS.Signals | null;
|
|
67
|
+
output: string;
|
|
68
|
+
}
|
|
69
|
+
export declare function runDockerComposeUpDetached(dockerComposePath: string, pullPolicy?: PullPolicy): Promise<DetachedUpResult>;
|
|
43
70
|
export declare function stopDockerCompose(dockerComposePath: string): Promise<void>;
|
|
44
71
|
export { isDockerInstalled, DockerValidationError };
|
package/dist/services/docker.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { execFileSync, execSync, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { ux } from '@oclif/core';
|
|
5
6
|
import semver from 'semver';
|
|
6
7
|
import { config } from '#config.js';
|
|
8
|
+
import { getErrorMessage } from '#utils/error_utils.js';
|
|
9
|
+
import { formatComposeFailure } from '#utils/port_collision.js';
|
|
7
10
|
const DEFAULT_COMPOSE_PATH = '../assets/docker/docker-compose-dev.yml';
|
|
8
11
|
export const SERVICE_HEALTH = {
|
|
9
12
|
HEALTHY: 'healthy',
|
|
@@ -83,6 +86,22 @@ export function validateDockerEnvironment() {
|
|
|
83
86
|
export function getDefaultDockerComposePath() {
|
|
84
87
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), DEFAULT_COMPOSE_PATH);
|
|
85
88
|
}
|
|
89
|
+
// Resolve the compose file a `dev` command should act on — a caller-supplied
|
|
90
|
+
// path (relative to cwd) or the bundled default — and verify it exists. Shared
|
|
91
|
+
// by `dev` and `dev down` so the resolution rule and not-found error stay in
|
|
92
|
+
// one place.
|
|
93
|
+
export async function resolveDockerComposePath(customPath) {
|
|
94
|
+
const dockerComposePath = customPath ?
|
|
95
|
+
path.resolve(process.cwd(), customPath) :
|
|
96
|
+
getDefaultDockerComposePath();
|
|
97
|
+
try {
|
|
98
|
+
await fs.access(dockerComposePath);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new DockerComposeConfigNotFoundError(dockerComposePath);
|
|
102
|
+
}
|
|
103
|
+
return dockerComposePath;
|
|
104
|
+
}
|
|
86
105
|
export function parseServiceStatus(jsonOutput) {
|
|
87
106
|
if (!jsonOutput.trim()) {
|
|
88
107
|
return [];
|
|
@@ -118,6 +137,37 @@ export function isServiceHealthy(service) {
|
|
|
118
137
|
export function isServiceFailed(service) {
|
|
119
138
|
return service.state === SERVICE_STATE.EXITED || service.health === SERVICE_HEALTH.UNHEALTHY;
|
|
120
139
|
}
|
|
140
|
+
export const STACK_STATE = {
|
|
141
|
+
/** Nothing live for this project — a fresh start we own. */
|
|
142
|
+
NONE: 'none',
|
|
143
|
+
/** Every container found is running and healthy (or has no healthcheck). */
|
|
144
|
+
RUNNING: 'running',
|
|
145
|
+
/** Something is live but not everything is healthy — reconcile. */
|
|
146
|
+
PARTIAL: 'partial'
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Classify the current state of a project's stack from `docker compose ps`.
|
|
150
|
+
*
|
|
151
|
+
* This is the detection signal `output dev` branches on: nothing live means a
|
|
152
|
+
* fresh start; an all-healthy result means we can attach and monitor without
|
|
153
|
+
* touching the stack; anything in between is reconciled with `up -d`.
|
|
154
|
+
*
|
|
155
|
+
* Scoped to the shared `output-sdk` compose project, which distinguishes our
|
|
156
|
+
* containers from unrelated processes — but not one Output checkout from
|
|
157
|
+
* another, since the project name defaults to a machine-global constant.
|
|
158
|
+
*/
|
|
159
|
+
export function classifyStackState(services) {
|
|
160
|
+
// No container is live. `ps --all` also reports exited ones, so a stack left
|
|
161
|
+
// behind by a reboot, a `docker compose stop`, or a failed teardown lands
|
|
162
|
+
// here — that's an owned fresh start, not something to attach to.
|
|
163
|
+
if (!services.some(service => service.state === SERVICE_STATE.RUNNING)) {
|
|
164
|
+
return STACK_STATE.NONE;
|
|
165
|
+
}
|
|
166
|
+
if (services.every(isServiceHealthy)) {
|
|
167
|
+
return STACK_STATE.RUNNING;
|
|
168
|
+
}
|
|
169
|
+
return STACK_STATE.PARTIAL;
|
|
170
|
+
}
|
|
121
171
|
export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 120000, pollIntervalMs = 2000) {
|
|
122
172
|
const startTime = Date.now();
|
|
123
173
|
while (Date.now() - startTime < timeoutMs) {
|
|
@@ -129,6 +179,18 @@ export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 1200
|
|
|
129
179
|
}
|
|
130
180
|
throw new Error('Timeout waiting for services to become healthy');
|
|
131
181
|
}
|
|
182
|
+
// A rolling buffer that retains the last ~20k chars of a spawned process's
|
|
183
|
+
// combined output, so a startup failure can surface recent Docker logs without
|
|
184
|
+
// holding the whole stream. Shared by the two compose spawn sites.
|
|
185
|
+
function createOutputBuffer() {
|
|
186
|
+
const buffer = { value: '' };
|
|
187
|
+
return {
|
|
188
|
+
append: (chunk) => {
|
|
189
|
+
buffer.value = `${buffer.value}${chunk.toString()}`.slice(-20000).trimStart();
|
|
190
|
+
},
|
|
191
|
+
read: () => buffer.value.trimEnd()
|
|
192
|
+
};
|
|
193
|
+
}
|
|
132
194
|
export async function startDockerCompose({ dockerComposePath, pullPolicy, onError, onExit }) {
|
|
133
195
|
const args = [
|
|
134
196
|
'compose',
|
|
@@ -140,29 +202,34 @@ export async function startDockerCompose({ dockerComposePath, pullPolicy, onErro
|
|
|
140
202
|
if (pullPolicy) {
|
|
141
203
|
args.push('--pull', pullPolicy);
|
|
142
204
|
}
|
|
143
|
-
const output =
|
|
144
|
-
value: ''
|
|
145
|
-
};
|
|
146
|
-
const appendOutput = (chunk) => {
|
|
147
|
-
output.value = `${output.value}${chunk.toString()}`.slice(-20000).trimStart();
|
|
148
|
-
};
|
|
205
|
+
const output = createOutputBuffer();
|
|
149
206
|
const dockerProcess = spawn('docker', args, {
|
|
150
207
|
cwd: process.cwd(),
|
|
151
208
|
// The Ink dev UI owns the terminal. Drain compose output so Docker cannot
|
|
152
209
|
// block on a full pipe, while keeping recent output for startup failures.
|
|
153
210
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
154
211
|
});
|
|
155
|
-
dockerProcess.stdout?.on('data',
|
|
156
|
-
dockerProcess.stderr?.on('data',
|
|
212
|
+
dockerProcess.stdout?.on('data', output.append);
|
|
213
|
+
dockerProcess.stderr?.on('data', output.append);
|
|
157
214
|
if (onError) {
|
|
158
|
-
dockerProcess.on('error', error => onError(error, output.
|
|
215
|
+
dockerProcess.on('error', error => onError(error, output.read()));
|
|
159
216
|
}
|
|
160
217
|
if (onExit) {
|
|
161
|
-
|
|
218
|
+
// `close` rather than `exit` so stdio has drained — the buffered output is
|
|
219
|
+
// what formatComposeFailure greps for a bind failure.
|
|
220
|
+
dockerProcess.on('close', (code, signal) => onExit(code, signal, output.read()));
|
|
162
221
|
}
|
|
163
222
|
return dockerProcess;
|
|
164
223
|
}
|
|
165
|
-
|
|
224
|
+
// Run `docker compose up -d` to completion, teeing Docker's progress to the
|
|
225
|
+
// user's terminal while retaining recent output. The predecessor used
|
|
226
|
+
// execFileSync with inherited stdio, which threw a raw compose error the caller
|
|
227
|
+
// couldn't inspect; returning the exit code and output lets it surface a
|
|
228
|
+
// port-collision hint instead. Async so image pulls don't block the event loop.
|
|
229
|
+
//
|
|
230
|
+
// Trade-off: piping means Docker sees a non-TTY and drops its redrawing
|
|
231
|
+
// progress bars for plain scrolling lines.
|
|
232
|
+
export function runDockerComposeUpDetached(dockerComposePath, pullPolicy) {
|
|
166
233
|
const args = [
|
|
167
234
|
'compose',
|
|
168
235
|
'-f', dockerComposePath,
|
|
@@ -173,7 +240,34 @@ export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
|
|
|
173
240
|
if (pullPolicy) {
|
|
174
241
|
args.push('--pull', pullPolicy);
|
|
175
242
|
}
|
|
176
|
-
|
|
243
|
+
const output = createOutputBuffer();
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
// Pipe rather than inherit so we can both echo Docker's progress and keep
|
|
246
|
+
// recent output for a startup-failure hint.
|
|
247
|
+
const child = spawn('docker', args, {
|
|
248
|
+
cwd: process.cwd(),
|
|
249
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
250
|
+
});
|
|
251
|
+
child.stdout?.on('data', (chunk) => {
|
|
252
|
+
process.stdout.write(chunk);
|
|
253
|
+
output.append(chunk);
|
|
254
|
+
});
|
|
255
|
+
child.stderr?.on('data', (chunk) => {
|
|
256
|
+
process.stderr.write(chunk);
|
|
257
|
+
output.append(chunk);
|
|
258
|
+
});
|
|
259
|
+
// `error` fires when the spawn itself failed (docker missing, EACCES).
|
|
260
|
+
// Reject with the buffered output attached so the caller keeps the context
|
|
261
|
+
// rather than surfacing a bare `spawn docker ENOENT`.
|
|
262
|
+
child.on('error', error => {
|
|
263
|
+
reject(new Error(formatComposeFailure(getErrorMessage(error), output.read(), config.ports)));
|
|
264
|
+
});
|
|
265
|
+
// `close`, not `exit`: exit fires while stdio may still be draining, and on
|
|
266
|
+
// a fast-failing `up -d` — exactly the bind-collision case — the stderr
|
|
267
|
+
// chunk carrying the bind error can land after it. Resolving early makes
|
|
268
|
+
// the port-collision hint disappear intermittently.
|
|
269
|
+
child.on('close', (code, signal) => resolve({ code, signal, output: output.read() }));
|
|
270
|
+
});
|
|
177
271
|
}
|
|
178
272
|
export async function stopDockerCompose(dockerComposePath) {
|
|
179
273
|
ux.stdout('⏹️ Stopping services...\n');
|