@outputai/cli 0.10.1-next.09ed166.0 → 0.10.1-next.2cbd0a2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/dev/down.d.ts +10 -0
- package/dist/commands/dev/down.js +34 -0
- package/dist/commands/dev/down.spec.d.ts +1 -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/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/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/views/dev/chrome/footer.d.ts +2 -0
- package/dist/views/dev/chrome/footer.js +4 -4
- 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 +3 -1
- package/dist/views/dev/hooks/use_step_graph.js +3 -1
- 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 +38 -2
- package/package.json +4 -4
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');
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { execFileSync, spawn } from 'node:child_process';
|
|
3
|
-
import
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { parseServiceStatus, getServiceStatus, startDockerCompose, runDockerComposeUpDetached, stopDockerCompose, waitForServicesHealthy, isServiceHealthy, isServiceFailed, classifyStackState, STACK_STATE, resolveDockerComposePath, getDefaultDockerComposePath, DockerComposeConfigNotFoundError } from './docker.js';
|
|
4
6
|
vi.mock('node:child_process', () => ({
|
|
5
7
|
execSync: vi.fn(),
|
|
6
8
|
execFileSync: vi.fn(),
|
|
7
9
|
spawn: vi.fn()
|
|
8
10
|
}));
|
|
11
|
+
vi.mock('node:fs/promises', () => ({
|
|
12
|
+
default: { access: vi.fn() }
|
|
13
|
+
}));
|
|
9
14
|
const mockChildProcess = (process) => process;
|
|
10
15
|
vi.mock('log-update', () => {
|
|
11
16
|
const fn = vi.fn();
|
|
@@ -155,7 +160,7 @@ describe('docker service', () => {
|
|
|
155
160
|
onExit
|
|
156
161
|
});
|
|
157
162
|
expect(dockerProcess.on).toHaveBeenCalledWith('error', expect.any(Function));
|
|
158
|
-
expect(dockerProcess.on).toHaveBeenCalledWith('
|
|
163
|
+
expect(dockerProcess.on).toHaveBeenCalledWith('close', expect.any(Function));
|
|
159
164
|
streamHandlers.stdout?.(Buffer.from('starting services\n'));
|
|
160
165
|
streamHandlers.stderr?.(Buffer.from('compose failed\n'));
|
|
161
166
|
const error = new Error('Docker failed');
|
|
@@ -165,26 +170,99 @@ describe('docker service', () => {
|
|
|
165
170
|
expect(onExit).toHaveBeenCalledWith(1, null, 'starting services\ncompose failed');
|
|
166
171
|
});
|
|
167
172
|
});
|
|
168
|
-
describe('
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
describe('runDockerComposeUpDetached', () => {
|
|
174
|
+
const makeProcess = () => {
|
|
175
|
+
const handlers = {};
|
|
176
|
+
const proc = {
|
|
177
|
+
on: vi.fn((event, handler) => {
|
|
178
|
+
if (event === 'error') {
|
|
179
|
+
handlers.error = handler;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
handlers.exit = handler;
|
|
183
|
+
}
|
|
184
|
+
return proc;
|
|
185
|
+
}),
|
|
186
|
+
stdout: {
|
|
187
|
+
on: vi.fn((event, handler) => {
|
|
188
|
+
handlers.stdout = handler;
|
|
189
|
+
return proc.stdout;
|
|
190
|
+
})
|
|
191
|
+
},
|
|
192
|
+
stderr: {
|
|
193
|
+
on: vi.fn((event, handler) => {
|
|
194
|
+
handlers.stderr = handler;
|
|
195
|
+
return proc.stderr;
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
return { proc, handlers };
|
|
200
|
+
};
|
|
201
|
+
it('passes --project-name and -d, tees output, and resolves with the exit code', async () => {
|
|
202
|
+
const { proc, handlers } = makeProcess();
|
|
203
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
|
|
204
|
+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
205
|
+
const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml');
|
|
206
|
+
expect(spawn).toHaveBeenCalledWith('docker', [
|
|
173
207
|
'compose', '-f', '/path/to/docker-compose.yml',
|
|
174
208
|
'--project-directory', process.cwd(),
|
|
175
209
|
'--project-name', 'output-sdk',
|
|
176
210
|
'up', '-d'
|
|
177
|
-
], expect.objectContaining({
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
211
|
+
], expect.objectContaining({ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] }));
|
|
212
|
+
handlers.stdout?.(Buffer.from('pulling images\n'));
|
|
213
|
+
handlers.exit?.(0);
|
|
214
|
+
expect(await promise).toEqual({ code: 0, output: 'pulling images' });
|
|
215
|
+
expect(stdoutSpy).toHaveBeenCalledWith(Buffer.from('pulling images\n'));
|
|
216
|
+
stdoutSpy.mockRestore();
|
|
217
|
+
});
|
|
218
|
+
it('appends --pull when pullPolicy is provided', async () => {
|
|
219
|
+
const { proc, handlers } = makeProcess();
|
|
220
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
|
|
221
|
+
vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
222
|
+
const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml', 'missing');
|
|
223
|
+
handlers.exit?.(0);
|
|
224
|
+
await promise;
|
|
225
|
+
expect(spawn).toHaveBeenCalledWith('docker', [
|
|
183
226
|
'compose', '-f', '/path/to/docker-compose.yml',
|
|
184
227
|
'--project-directory', process.cwd(),
|
|
185
228
|
'--project-name', 'output-sdk',
|
|
186
229
|
'up', '-d', '--pull', 'missing'
|
|
187
|
-
], expect.objectContaining({
|
|
230
|
+
], expect.objectContaining({ cwd: process.cwd() }));
|
|
231
|
+
});
|
|
232
|
+
it('resolves with a non-zero code and captured stderr so the caller can hint the collision', async () => {
|
|
233
|
+
const { proc, handlers } = makeProcess();
|
|
234
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
|
|
235
|
+
vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
236
|
+
const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml');
|
|
237
|
+
handlers.stderr?.(Buffer.from('Error: address already in use\n'));
|
|
238
|
+
handlers.exit?.(1);
|
|
239
|
+
expect(await promise).toEqual({ code: 1, output: 'Error: address already in use' });
|
|
240
|
+
});
|
|
241
|
+
it('rejects when the docker process fails to spawn', async () => {
|
|
242
|
+
const { proc, handlers } = makeProcess();
|
|
243
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
|
|
244
|
+
const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml');
|
|
245
|
+
handlers.error?.(new Error('spawn ENOENT'));
|
|
246
|
+
await expect(promise).rejects.toThrow('spawn ENOENT');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
describe('resolveDockerComposePath', () => {
|
|
250
|
+
it('resolves a custom path against cwd and returns it when it exists', async () => {
|
|
251
|
+
vi.mocked(fs.access).mockResolvedValue(undefined);
|
|
252
|
+
const result = await resolveDockerComposePath('custom/compose.yml');
|
|
253
|
+
const expected = path.resolve(process.cwd(), 'custom/compose.yml');
|
|
254
|
+
expect(result).toBe(expected);
|
|
255
|
+
expect(fs.access).toHaveBeenCalledWith(expected);
|
|
256
|
+
});
|
|
257
|
+
it('throws DockerComposeConfigNotFoundError when the path does not exist', async () => {
|
|
258
|
+
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
|
259
|
+
await expect(resolveDockerComposePath('missing.yml'))
|
|
260
|
+
.rejects.toBeInstanceOf(DockerComposeConfigNotFoundError);
|
|
261
|
+
});
|
|
262
|
+
it('falls back to the bundled default when no custom path is given', async () => {
|
|
263
|
+
vi.mocked(fs.access).mockResolvedValue(undefined);
|
|
264
|
+
const result = await resolveDockerComposePath();
|
|
265
|
+
expect(result).toBe(getDefaultDockerComposePath());
|
|
188
266
|
});
|
|
189
267
|
});
|
|
190
268
|
describe('DOCKER_SERVICE_NAME wiring', () => {
|
|
@@ -259,6 +337,58 @@ describe('docker service', () => {
|
|
|
259
337
|
expect(isServiceFailed({ name: 'temporal', state: 'running', health: 'starting', ports: [] })).toBe(false);
|
|
260
338
|
});
|
|
261
339
|
});
|
|
340
|
+
describe('classifyStackState', () => {
|
|
341
|
+
const svc = (state, health) => ({ name: 's', state, health, ports: [] });
|
|
342
|
+
it('returns NONE for an empty stack (fresh start)', () => {
|
|
343
|
+
expect(classifyStackState([])).toBe(STACK_STATE.NONE);
|
|
344
|
+
});
|
|
345
|
+
it('returns RUNNING when every service is up and healthy', () => {
|
|
346
|
+
expect(classifyStackState([
|
|
347
|
+
svc('running', 'healthy'),
|
|
348
|
+
svc('running', 'none')
|
|
349
|
+
])).toBe(STACK_STATE.RUNNING);
|
|
350
|
+
});
|
|
351
|
+
it('returns PARTIAL when any service has failed (orphaned stack)', () => {
|
|
352
|
+
expect(classifyStackState([
|
|
353
|
+
svc('running', 'healthy'),
|
|
354
|
+
svc('exited', 'none')
|
|
355
|
+
])).toBe(STACK_STATE.PARTIAL);
|
|
356
|
+
});
|
|
357
|
+
it('returns PARTIAL when services exist but some are still coming up', () => {
|
|
358
|
+
expect(classifyStackState([
|
|
359
|
+
svc('running', 'healthy'),
|
|
360
|
+
svc('created', 'none')
|
|
361
|
+
])).toBe(STACK_STATE.PARTIAL);
|
|
362
|
+
});
|
|
363
|
+
it('treats an unhealthy service as PARTIAL, not RUNNING', () => {
|
|
364
|
+
expect(classifyStackState([
|
|
365
|
+
svc('running', 'healthy'),
|
|
366
|
+
svc('running', 'unhealthy')
|
|
367
|
+
])).toBe(STACK_STATE.PARTIAL);
|
|
368
|
+
});
|
|
369
|
+
// `ps --all` reports exited containers, so a stack stopped by a reboot, a
|
|
370
|
+
// `docker compose stop`, or a failed teardown still has rows. Nothing is
|
|
371
|
+
// live, so this invocation would be the one starting it — that makes it an
|
|
372
|
+
// owned fresh start, not an attach.
|
|
373
|
+
it('returns NONE when every service is exited — an owned fresh start, not an attach', () => {
|
|
374
|
+
expect(classifyStackState([
|
|
375
|
+
svc('exited', 'none'),
|
|
376
|
+
svc('exited', 'none')
|
|
377
|
+
])).toBe(STACK_STATE.NONE);
|
|
378
|
+
});
|
|
379
|
+
it('returns NONE when containers are created but none have started', () => {
|
|
380
|
+
expect(classifyStackState([
|
|
381
|
+
svc('created', 'none'),
|
|
382
|
+
svc('created', 'none')
|
|
383
|
+
])).toBe(STACK_STATE.NONE);
|
|
384
|
+
});
|
|
385
|
+
it('still returns PARTIAL when at least one service is live', () => {
|
|
386
|
+
expect(classifyStackState([
|
|
387
|
+
svc('running', 'healthy'),
|
|
388
|
+
svc('exited', 'none')
|
|
389
|
+
])).toBe(STACK_STATE.PARTIAL);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
262
392
|
describe('waitForServicesHealthy', () => {
|
|
263
393
|
it('should resolve when all services are healthy', async () => {
|
|
264
394
|
const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
|
|
@@ -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
|
|
@@ -3,20 +3,25 @@
|
|
|
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
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/listen tcp [^:\s]+:(\d+):\s*bind: address already in use/
|
|
19
|
-
];
|
|
22
|
+
const COLLISION_PHRASES = ['port is already allocated', 'address already in use'];
|
|
23
|
+
/** Trailing `:<port>` in a fragment — the host port a bind failure names. */
|
|
24
|
+
const TRAILING_PORT = /:(\d+)(?!.*:\d)/s;
|
|
20
25
|
const DEFAULT_PORT_TO_ENV_VAR = {
|
|
21
26
|
3001: 'OUTPUT_API_HOST_PORT',
|
|
22
27
|
8080: 'OUTPUT_TEMPORAL_UI_HOST_PORT',
|
|
@@ -35,8 +40,15 @@ export function extractCollidedPort(stderr) {
|
|
|
35
40
|
if (!stderr) {
|
|
36
41
|
return null;
|
|
37
42
|
}
|
|
38
|
-
for (const
|
|
39
|
-
const
|
|
43
|
+
for (const phrase of COLLISION_PHRASES) {
|
|
44
|
+
const phraseIndex = stderr.indexOf(phrase);
|
|
45
|
+
if (phraseIndex === -1) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// The host port is the last one named before the phrase — every shape puts
|
|
49
|
+
// it there ("Bind for 0.0.0.0:3001 failed: port is already allocated",
|
|
50
|
+
// "listen tcp 0.0.0.0:3001: bind: address already in use").
|
|
51
|
+
const match = stderr.slice(0, phraseIndex).match(TRAILING_PORT);
|
|
40
52
|
if (match) {
|
|
41
53
|
return parseInt(match[1], 10);
|
|
42
54
|
}
|
|
@@ -87,6 +99,19 @@ export function formatPortCollisionHint(stderr, resolvedPorts) {
|
|
|
87
99
|
}
|
|
88
100
|
return formatSingleCollision(port, resolvedPorts);
|
|
89
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Compose a docker-failure message from a caller-supplied core sentence and the
|
|
104
|
+
* process's recent output: an actionable port-collision hint (when one is
|
|
105
|
+
* detected) is prepended, and the raw recent output is appended. Shared by the
|
|
106
|
+
* foreground exit handler and the detached/reconcile path so both surface the
|
|
107
|
+
* same failure shape.
|
|
108
|
+
*/
|
|
109
|
+
export function formatComposeFailure(reason, output, resolvedPorts) {
|
|
110
|
+
const hint = formatPortCollisionHint(output, resolvedPorts);
|
|
111
|
+
const prefix = hint ? `${hint}\n\n` : '';
|
|
112
|
+
const detail = output ? `\n\nRecent Docker output:\n${output}` : '';
|
|
113
|
+
return `${prefix}${reason}${detail}`;
|
|
114
|
+
}
|
|
90
115
|
/**
|
|
91
116
|
* Build a hint from a known list of colliding ports. For a single collision
|
|
92
117
|
* the output matches `formatPortCollisionHint` exactly so callers stay
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { extractCollidedPort, formatPortCollisionHint, formatPortCollisionsHint } from './port_collision.js';
|
|
2
|
+
import { extractCollidedPort, formatPortCollisionHint, formatPortCollisionsHint, formatComposeFailure } from './port_collision.js';
|
|
3
3
|
const DEFAULT_PORTS = { api: 3001, temporalUi: 8080, temporal: 7233 };
|
|
4
4
|
describe('extractCollidedPort', () => {
|
|
5
5
|
it('matches the "Bind for ... port is already allocated" shape', () => {
|
|
@@ -22,6 +22,24 @@ describe('extractCollidedPort', () => {
|
|
|
22
22
|
it('returns null when no bind failure is present', () => {
|
|
23
23
|
expect(extractCollidedPort('some unrelated stderr line')).toBeNull();
|
|
24
24
|
});
|
|
25
|
+
// Captured verbatim from Docker 29.4.0 on macOS. The bind failure is nested
|
|
26
|
+
// three wrappers deep; matching whole message shapes missed it.
|
|
27
|
+
it('extracts the port from Docker 29\'s nested container-networking wrapper', () => {
|
|
28
|
+
const stderr = 'Error response from daemon: failed to set up container networking: ' +
|
|
29
|
+
'driver failed programming external connectivity on endpoint out-api-1 ' +
|
|
30
|
+
'(e72baf85643fb5dc19000acf62c1ad0d11bffc653cabe1fc8861387ec1ebd629): ' +
|
|
31
|
+
'Bind for 0.0.0.0:3001 failed: port is already allocated';
|
|
32
|
+
expect(extractCollidedPort(stderr)).toBe(3001);
|
|
33
|
+
});
|
|
34
|
+
it('extracts the port from the "ports are not available" wrapper', () => {
|
|
35
|
+
const stderr = 'Error: ports are not available: exposing port TCP 0.0.0.0:3001 -> 0.0.0.0:0: ' +
|
|
36
|
+
'listen tcp 0.0.0.0:3001: bind: address already in use';
|
|
37
|
+
expect(extractCollidedPort(stderr)).toBe(3001);
|
|
38
|
+
});
|
|
39
|
+
it('ignores an IP-like prefix and takes the port nearest the failure phrase', () => {
|
|
40
|
+
const stderr = 'container 172.17.0.2:5432 started\nBind for 0.0.0.0:8080 failed: port is already allocated';
|
|
41
|
+
expect(extractCollidedPort(stderr)).toBe(8080);
|
|
42
|
+
});
|
|
25
43
|
it('returns null for empty input', () => {
|
|
26
44
|
expect(extractCollidedPort('')).toBeNull();
|
|
27
45
|
});
|
|
@@ -80,3 +98,24 @@ describe('formatPortCollisionsHint', () => {
|
|
|
80
98
|
expect(hint).toContain('• Port 5432 — stop the process holding it');
|
|
81
99
|
});
|
|
82
100
|
});
|
|
101
|
+
describe('formatComposeFailure', () => {
|
|
102
|
+
const reason = 'Docker compose failed to start services (exit code 1).';
|
|
103
|
+
it('prepends the actionable hint when the output names a collision', () => {
|
|
104
|
+
const message = formatComposeFailure(reason, 'Bind for 0.0.0.0:3001 failed: port is already allocated', DEFAULT_PORTS);
|
|
105
|
+
expect(message.startsWith('Port 3001 is already in use.')).toBe(true);
|
|
106
|
+
expect(message).toContain('OUTPUT_API_HOST_PORT=<other port>');
|
|
107
|
+
expect(message).toContain(reason);
|
|
108
|
+
expect(message).toContain('Recent Docker output:');
|
|
109
|
+
});
|
|
110
|
+
it('omits the output section entirely when nothing was captured', () => {
|
|
111
|
+
const message = formatComposeFailure(reason, '', DEFAULT_PORTS);
|
|
112
|
+
expect(message).toBe(reason);
|
|
113
|
+
expect(message).not.toContain('Recent Docker output:');
|
|
114
|
+
});
|
|
115
|
+
it('returns reason plus raw output, with no hint, for an unrecognized failure', () => {
|
|
116
|
+
const message = formatComposeFailure(reason, 'no such image: outputai/api:dev', DEFAULT_PORTS);
|
|
117
|
+
expect(message.startsWith(reason)).toBe(true);
|
|
118
|
+
expect(message).toContain('Recent Docker output:\nno such image');
|
|
119
|
+
expect(message).not.toContain('is already in use');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -5,16 +5,16 @@ import packageJson from '../../../../package.json' with { type: 'json' };
|
|
|
5
5
|
const GLOBAL_HINT_ROWS = 1;
|
|
6
6
|
const LOCAL_HINT_ROWS = 1;
|
|
7
7
|
export const getHeight = () => GLOBAL_HINT_ROWS + LOCAL_HINT_ROWS;
|
|
8
|
-
const
|
|
8
|
+
const globalHints = (attached) => [
|
|
9
9
|
{ key: 'tab', label: 'next tab' },
|
|
10
10
|
{ key: 'shift-tab', label: 'prev tab' },
|
|
11
11
|
{ key: '1-4', label: 'tabs' },
|
|
12
12
|
{ key: '/', label: 'search' },
|
|
13
13
|
{ key: '?', label: 'help' },
|
|
14
|
-
{ key: 'ctrl+c', label: 'quit' }
|
|
14
|
+
{ key: 'ctrl+c', label: attached ? 'detach (keeps running)' : 'quit' }
|
|
15
15
|
];
|
|
16
16
|
const VERSION = packageJson.version;
|
|
17
17
|
const HintRow = ({ hints }) => (_jsx(Box, { flexDirection: "row", children: hints.length === 0 ? (_jsx(Text, { children: " " })) : hints.map((hint, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { bold: true, children: hint.key }), _jsx(Text, { dimColor: true, children: ` ${hint.label}` })] }, hint.key))) }));
|
|
18
|
-
export const Footer = ({ hints = [], itemCount, itemLabel }) => {
|
|
19
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(HintRow, { hints:
|
|
18
|
+
export const Footer = ({ hints = [], itemCount, itemLabel, attached = false }) => {
|
|
19
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(HintRow, { hints: globalHints(attached) }), typeof itemCount === 'number' && itemLabel && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [itemCount, " ", itemLabel] }) }))] }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(HintRow, { hints: hints }), _jsxs(Text, { color: "blackBright", children: ["v", VERSION] })] })] }));
|
|
20
20
|
};
|
|
@@ -150,7 +150,7 @@ const overlayFor = (opts) => {
|
|
|
150
150
|
}
|
|
151
151
|
return null;
|
|
152
152
|
};
|
|
153
|
-
const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
153
|
+
const Shell = ({ dockerComposePath, onCleanup, attached }) => {
|
|
154
154
|
const { exit } = useApp();
|
|
155
155
|
const ui = useUiState();
|
|
156
156
|
const [phase, setPhase] = useState('waiting');
|
|
@@ -160,7 +160,16 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
|
160
160
|
onServices: setServices,
|
|
161
161
|
onAllHealthy: () => setPhase('running'),
|
|
162
162
|
onFailure: () => setPhase('failed'),
|
|
163
|
-
|
|
163
|
+
// An attach/reconcile session monitors a stack it doesn't own, so a slow
|
|
164
|
+
// health check must not be fatal — drop into the dashboard and keep
|
|
165
|
+
// polling status. Only an owned fresh start treats the timeout as an error.
|
|
166
|
+
onTimeout: () => {
|
|
167
|
+
if (attached) {
|
|
168
|
+
setPhase('running');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
exit(new Error('Timeout waiting for services to become healthy'));
|
|
172
|
+
}
|
|
164
173
|
});
|
|
165
174
|
useStatusRefresh(dockerComposePath, phase !== 'waiting', setServices);
|
|
166
175
|
useWorkflowRunsPolling(phase !== 'waiting', setRuns);
|
|
@@ -231,6 +240,6 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
|
231
240
|
if (overlay) {
|
|
232
241
|
return (_jsx(Box, { flexDirection: "column", height: rows, paddingX: 1, children: overlay }));
|
|
233
242
|
}
|
|
234
|
-
return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab, borderColor: RULE_PURPLE }), _jsx(SearchBar, { active: ui.search.open }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, height: contentRows, overflow: "hidden", children: [ui.tab === 'workflows' && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && _jsx(RunsPanel, { runs: runs, height: contentRows }), ui.tab === 'services' && (_jsx(ServicesPanel, { height: contentRows, phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && _jsx(HelpPanel, {})] }), _jsx(Toasts, {}), _jsx(Footer, { hints: footer.hints, itemCount: footer.itemCount, itemLabel: footer.itemLabel })] }));
|
|
243
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab, borderColor: RULE_PURPLE }), _jsx(SearchBar, { active: ui.search.open }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, height: contentRows, overflow: "hidden", children: [ui.tab === 'workflows' && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && _jsx(RunsPanel, { runs: runs, height: contentRows }), ui.tab === 'services' && (_jsx(ServicesPanel, { height: contentRows, phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && _jsx(HelpPanel, {})] }), _jsx(Toasts, {}), _jsx(Footer, { hints: footer.hints, itemCount: footer.itemCount, itemLabel: footer.itemLabel, attached: attached })] }));
|
|
235
244
|
};
|
|
236
|
-
export const DevApp = ({ dockerComposePath, onCleanup }) => (_jsx(UiStateProvider, { children: _jsx(Shell, { dockerComposePath: dockerComposePath, onCleanup: onCleanup }) }));
|
|
245
|
+
export const DevApp = ({ dockerComposePath, onCleanup, attached = false }) => (_jsx(UiStateProvider, { children: _jsx(Shell, { dockerComposePath: dockerComposePath, onCleanup: onCleanup, attached: attached }) }));
|
|
@@ -3,13 +3,15 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { getWorkflowIdResult, getWorkflowIdRunsRidResult, getWorkflowIdTraceLog, getWorkflowIdRunsRidTraceLog } from '#api/generated/api.js';
|
|
4
4
|
import { normalizeWorkflowStatus } from '#utils/normalize_workflow_status.js';
|
|
5
5
|
import { TERMINAL_STATUSES } from '#utils/format_workflow_result.js';
|
|
6
|
+
import { createBoundedCache } from '#views/dev/utils/bounded_cache.js';
|
|
6
7
|
const EMPTY_DETAIL = {
|
|
7
8
|
result: null,
|
|
8
9
|
trace: null,
|
|
9
10
|
steps: [],
|
|
10
11
|
loading: false
|
|
11
12
|
};
|
|
12
|
-
const
|
|
13
|
+
const RUN_DETAIL_CACHE_MAX = 50;
|
|
14
|
+
const runDetailCache = createBoundedCache(RUN_DETAIL_CACHE_MAX);
|
|
13
15
|
const stepNameOf = (node) => {
|
|
14
16
|
if (node.name) {
|
|
15
17
|
return node.name;
|