@outputai/cli 0.10.1-dev.b7b2fbe.0 → 0.10.1-next.2caa4a1.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 +12 -0
- package/dist/api/http_client.js +2 -2
- 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/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/run.js +8 -1
- package/dist/commands/workflow/run.spec.js +12 -2
- 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/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 +1 -1
- 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/format_workflow_result.d.ts +1 -0
- package/dist/utils/format_workflow_result.js +4 -0
- 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/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/dev_app.d.ts +1 -0
- package/dist/views/dev/dev_app.js +13 -4
- package/dist/views/dev/hooks/use_run_detail.js +7 -8
- 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 +122 -4
- package/package.json +7 -8
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
4
|
-
import fs from 'node:fs/promises';
|
|
5
4
|
import { render } from 'ink';
|
|
6
5
|
import * as dockerService from '#services/docker.js';
|
|
7
6
|
import * as codingAgentsService from '#services/coding_agents.js';
|
|
@@ -13,34 +12,23 @@ vi.mock('#services/coding_agents.js', () => ({
|
|
|
13
12
|
vi.mock('#utils/port_availability.js', () => ({
|
|
14
13
|
findUnavailablePorts: vi.fn().mockResolvedValue([])
|
|
15
14
|
}));
|
|
16
|
-
vi.mock('#services/docker.js', () =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
},
|
|
34
|
-
SERVICE_STATE: {
|
|
35
|
-
RUNNING: 'running',
|
|
36
|
-
EXITED: 'exited'
|
|
37
|
-
}
|
|
38
|
-
}));
|
|
39
|
-
vi.mock('node:fs/promises', () => ({
|
|
40
|
-
default: {
|
|
41
|
-
access: vi.fn()
|
|
42
|
-
}
|
|
43
|
-
}));
|
|
15
|
+
vi.mock('#services/docker.js', async (importActual) => {
|
|
16
|
+
const actual = await importActual();
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
// Override only the IO surface. Pure helpers (classifyStackState,
|
|
20
|
+
// isServiceHealthy, STACK_STATE, error classes) come from the real module,
|
|
21
|
+
// so branch selection here exercises the same logic production runs.
|
|
22
|
+
validateDockerEnvironment: vi.fn(),
|
|
23
|
+
startDockerCompose: vi.fn(),
|
|
24
|
+
runDockerComposeUpDetached: vi.fn().mockResolvedValue({ code: 0, signal: null, output: '' }),
|
|
25
|
+
stopDockerCompose: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
// Default: nothing running — a fresh start. Individual tests opt into an
|
|
27
|
+
// existing stack by overriding this to drive attach / reconcile branches.
|
|
28
|
+
getServiceStatus: vi.fn().mockResolvedValue([]),
|
|
29
|
+
resolveDockerComposePath: vi.fn().mockResolvedValue('/path/to/docker-compose-dev.yml')
|
|
30
|
+
};
|
|
31
|
+
});
|
|
44
32
|
vi.mock('ink', () => ({
|
|
45
33
|
render: vi.fn().mockReturnValue({
|
|
46
34
|
waitUntilExit: vi.fn().mockResolvedValue(undefined),
|
|
@@ -88,12 +76,22 @@ describe('dev command', () => {
|
|
|
88
76
|
vi.mocked(dockerService.validateDockerEnvironment).mockResolvedValue(undefined);
|
|
89
77
|
// By default, no host port is taken — individual tests opt in.
|
|
90
78
|
vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([]);
|
|
79
|
+
// By default, no stack is running — a fresh start. Tests opt into the
|
|
80
|
+
// attach / reconcile branches by overriding getServiceStatus.
|
|
81
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue([]);
|
|
82
|
+
vi.mocked(dockerService.runDockerComposeUpDetached).mockResolvedValue({ code: 0, signal: null, output: '' });
|
|
91
83
|
// By default, startDockerCompose returns a mock process
|
|
92
84
|
vi.mocked(dockerService.startDockerCompose).mockResolvedValue(createMockDockerProcess());
|
|
93
|
-
// By default,
|
|
94
|
-
vi.mocked(
|
|
85
|
+
// By default, the compose file resolves and exists.
|
|
86
|
+
vi.mocked(dockerService.resolveDockerComposePath).mockResolvedValue('/path/to/docker-compose-dev.yml');
|
|
95
87
|
// By default, ensureClaudePlugin succeeds
|
|
96
88
|
vi.mocked(codingAgentsService.ensureClaudePlugin).mockResolvedValue(undefined);
|
|
89
|
+
// By default, render returns an instance that exits immediately. Tests
|
|
90
|
+
// needing a controllable lifecycle override this.
|
|
91
|
+
vi.mocked(render).mockReturnValue({
|
|
92
|
+
waitUntilExit: vi.fn().mockResolvedValue(undefined),
|
|
93
|
+
unmount: vi.fn()
|
|
94
|
+
});
|
|
97
95
|
});
|
|
98
96
|
afterEach(() => {
|
|
99
97
|
vi.restoreAllMocks();
|
|
@@ -154,19 +152,19 @@ describe('dev command', () => {
|
|
|
154
152
|
});
|
|
155
153
|
await expect(cmd.run()).rejects.toThrow('Docker is not installed');
|
|
156
154
|
});
|
|
157
|
-
it('should call validateDockerEnvironment', async () => {
|
|
155
|
+
it('should call validateDockerEnvironment before doing any work', async () => {
|
|
158
156
|
const cmd = new Dev([], {});
|
|
159
157
|
cmd.log = vi.fn();
|
|
160
158
|
cmd.error = vi.fn();
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
expect(
|
|
159
|
+
Object.defineProperty(cmd, 'parse', {
|
|
160
|
+
value: vi.fn().mockResolvedValue({
|
|
161
|
+
flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: true },
|
|
162
|
+
args: {}
|
|
163
|
+
}),
|
|
164
|
+
configurable: true
|
|
165
|
+
});
|
|
166
|
+
await cmd.run();
|
|
167
|
+
expect(dockerService.validateDockerEnvironment).toHaveBeenCalled();
|
|
170
168
|
});
|
|
171
169
|
});
|
|
172
170
|
describe('Claude plugin update', () => {
|
|
@@ -223,7 +221,7 @@ describe('dev command', () => {
|
|
|
223
221
|
runPromise.catch(() => { });
|
|
224
222
|
});
|
|
225
223
|
it('should handle docker compose configuration not found', async () => {
|
|
226
|
-
vi.mocked(
|
|
224
|
+
vi.mocked(dockerService.resolveDockerComposePath).mockRejectedValue(new dockerService.DockerComposeConfigNotFoundError('/path/to/docker-compose-dev.yml'));
|
|
227
225
|
const cmd = new Dev([], {});
|
|
228
226
|
cmd.log = vi.fn();
|
|
229
227
|
cmd.error = vi.fn();
|
|
@@ -327,6 +325,356 @@ describe('dev command', () => {
|
|
|
327
325
|
expect(inkInstance.unmount).not.toHaveBeenCalledWith(expect.any(Error));
|
|
328
326
|
expect(cmd.error).not.toHaveBeenCalled();
|
|
329
327
|
});
|
|
328
|
+
// Without this, mutating teardownIfOwned to return unconditionally leaves
|
|
329
|
+
// every test green while each session leaks containers holding ports.
|
|
330
|
+
it('stops compose and kills the foreground process when it owns the stack', async () => {
|
|
331
|
+
const dockerProcess = createMockDockerProcess();
|
|
332
|
+
vi.mocked(dockerService.startDockerCompose).mockResolvedValue(dockerProcess);
|
|
333
|
+
const cmd = new Dev([], {});
|
|
334
|
+
cmd.log = vi.fn();
|
|
335
|
+
cmd.error = vi.fn();
|
|
336
|
+
Object.defineProperty(cmd, 'parse', {
|
|
337
|
+
value: vi.fn().mockResolvedValue({
|
|
338
|
+
flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: false },
|
|
339
|
+
args: {}
|
|
340
|
+
}),
|
|
341
|
+
configurable: true
|
|
342
|
+
});
|
|
343
|
+
const runPromise = cmd.run();
|
|
344
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
345
|
+
const appElement = vi.mocked(render).mock.calls.at(-1)?.[0];
|
|
346
|
+
if (!React.isValidElement(appElement)) {
|
|
347
|
+
throw new Error('Expected render to receive a React element');
|
|
348
|
+
}
|
|
349
|
+
await appElement.props.onCleanup();
|
|
350
|
+
expect(dockerService.stopDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml');
|
|
351
|
+
expect(dockerProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
|
352
|
+
runPromise.catch(() => { });
|
|
353
|
+
});
|
|
354
|
+
// The abnormal-exit route (health timeout, compose crash) resolves outside
|
|
355
|
+
// the signal path and has its own teardown call.
|
|
356
|
+
it('tears down an owned stack when the TUI exits abnormally', async () => {
|
|
357
|
+
const dockerProcess = createMockDockerProcess();
|
|
358
|
+
vi.mocked(dockerService.startDockerCompose).mockResolvedValue(dockerProcess);
|
|
359
|
+
const inkInstance = createControllableInkInstance();
|
|
360
|
+
vi.mocked(render).mockReturnValue(inkInstance);
|
|
361
|
+
const cmd = new Dev([], {});
|
|
362
|
+
cmd.log = vi.fn();
|
|
363
|
+
cmd.error = vi.fn(() => {
|
|
364
|
+
throw new Error('aborted');
|
|
365
|
+
});
|
|
366
|
+
Object.defineProperty(cmd, 'parse', {
|
|
367
|
+
value: vi.fn().mockResolvedValue({
|
|
368
|
+
flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: false },
|
|
369
|
+
args: {}
|
|
370
|
+
}),
|
|
371
|
+
configurable: true
|
|
372
|
+
});
|
|
373
|
+
const runPromise = cmd.run();
|
|
374
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
375
|
+
inkInstance.unmount(new Error('Timeout waiting for services to become healthy'));
|
|
376
|
+
await expect(runPromise).rejects.toThrow('aborted');
|
|
377
|
+
expect(dockerService.stopDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml');
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
describe('attach and reconcile behavior', () => {
|
|
381
|
+
const runningStack = [
|
|
382
|
+
{ name: 'redis', state: 'running', health: 'healthy', ports: [] },
|
|
383
|
+
{ name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] },
|
|
384
|
+
{ name: 'api', state: 'running', health: 'none', ports: ['3001:3001'] }
|
|
385
|
+
];
|
|
386
|
+
const makeCmd = () => {
|
|
387
|
+
const cmd = new Dev([], {});
|
|
388
|
+
cmd.log = vi.fn();
|
|
389
|
+
cmd.error = vi.fn();
|
|
390
|
+
Object.defineProperty(cmd, 'parse', {
|
|
391
|
+
value: vi.fn().mockResolvedValue({
|
|
392
|
+
flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: false },
|
|
393
|
+
args: {}
|
|
394
|
+
}),
|
|
395
|
+
configurable: true
|
|
396
|
+
});
|
|
397
|
+
return cmd;
|
|
398
|
+
};
|
|
399
|
+
const lastRenderedProps = () => {
|
|
400
|
+
const appElement = vi.mocked(render).mock.calls.at(-1)?.[0];
|
|
401
|
+
if (!React.isValidElement(appElement)) {
|
|
402
|
+
throw new Error('Expected render to receive a React element');
|
|
403
|
+
}
|
|
404
|
+
return appElement.props;
|
|
405
|
+
};
|
|
406
|
+
it('attaches to a healthy running stack without a foreground up or port probe', async () => {
|
|
407
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue(runningStack);
|
|
408
|
+
const cmd = makeCmd();
|
|
409
|
+
await cmd.run();
|
|
410
|
+
expect(portAvailability.findUnavailablePorts).not.toHaveBeenCalled();
|
|
411
|
+
expect(dockerService.startDockerCompose).not.toHaveBeenCalled();
|
|
412
|
+
expect(dockerService.runDockerComposeUpDetached).not.toHaveBeenCalled();
|
|
413
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
414
|
+
expect(lastRenderedProps().attached).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
it('leaves an attached stack running on cleanup (no teardown)', async () => {
|
|
417
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue(runningStack);
|
|
418
|
+
const cmd = makeCmd();
|
|
419
|
+
await cmd.run();
|
|
420
|
+
await lastRenderedProps().onCleanup();
|
|
421
|
+
expect(dockerService.stopDockerCompose).not.toHaveBeenCalled();
|
|
422
|
+
});
|
|
423
|
+
it('does not port-probe or error when our own stack already holds the ports', async () => {
|
|
424
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue(runningStack);
|
|
425
|
+
vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([3001]);
|
|
426
|
+
const cmd = makeCmd();
|
|
427
|
+
await cmd.run();
|
|
428
|
+
expect(portAvailability.findUnavailablePorts).not.toHaveBeenCalled();
|
|
429
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
it('reconciles a partially-failed stack with a detached up, then monitors', async () => {
|
|
432
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
|
|
433
|
+
{ name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] },
|
|
434
|
+
{ name: 'worker', state: 'exited', health: 'none', ports: [] }
|
|
435
|
+
]);
|
|
436
|
+
const cmd = makeCmd();
|
|
437
|
+
await cmd.run();
|
|
438
|
+
expect(dockerService.runDockerComposeUpDetached).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', 'always');
|
|
439
|
+
// Reconcile monitors via ps polling, not a foreground up.
|
|
440
|
+
expect(dockerService.startDockerCompose).not.toHaveBeenCalled();
|
|
441
|
+
expect(portAvailability.findUnavailablePorts).not.toHaveBeenCalled();
|
|
442
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
443
|
+
expect(lastRenderedProps().attached).toBe(true);
|
|
444
|
+
});
|
|
445
|
+
it('aborts with a port-collision hint when the reconcile up fails to bind', async () => {
|
|
446
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
|
|
447
|
+
{ name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] },
|
|
448
|
+
{ name: 'worker', state: 'exited', health: 'none', ports: [] }
|
|
449
|
+
]);
|
|
450
|
+
vi.mocked(dockerService.runDockerComposeUpDetached).mockResolvedValue({
|
|
451
|
+
code: 1,
|
|
452
|
+
signal: null,
|
|
453
|
+
output: 'Error response from daemon: failed to set up container networking: driver failed ' +
|
|
454
|
+
'programming external connectivity on endpoint out-api-1 (a1b2c3): ' +
|
|
455
|
+
'Bind for 0.0.0.0:3001 failed: port is already allocated'
|
|
456
|
+
});
|
|
457
|
+
const cmd = makeCmd();
|
|
458
|
+
// this.error throws in oclif; emulate so the flow stops at the abort.
|
|
459
|
+
const errorMock = vi.fn((_message) => {
|
|
460
|
+
throw new Error('aborted');
|
|
461
|
+
});
|
|
462
|
+
cmd.error = errorMock;
|
|
463
|
+
await expect(cmd.run()).rejects.toThrow('aborted');
|
|
464
|
+
const message = errorMock.mock.calls.at(-1)?.[0];
|
|
465
|
+
expect(message).toContain('Port 3001 is already in use.');
|
|
466
|
+
expect(message).toContain('OUTPUT_API_HOST_PORT=');
|
|
467
|
+
expect(render).not.toHaveBeenCalled();
|
|
468
|
+
});
|
|
469
|
+
it('falls back to a fresh foreground start when no stack is running', async () => {
|
|
470
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue([]);
|
|
471
|
+
const cmd = makeCmd();
|
|
472
|
+
const runPromise = cmd.run();
|
|
473
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
474
|
+
expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
|
|
475
|
+
expect(dockerService.startDockerCompose).toHaveBeenCalled();
|
|
476
|
+
expect(dockerService.runDockerComposeUpDetached).not.toHaveBeenCalled();
|
|
477
|
+
expect(lastRenderedProps().attached).toBe(false);
|
|
478
|
+
runPromise.catch(() => { });
|
|
479
|
+
});
|
|
480
|
+
// A stopped stack (reboot, `docker compose stop`, failed teardown) leaves
|
|
481
|
+
// exited rows in `ps --all`. Before the classifier keyed on "is anything
|
|
482
|
+
// live", these reconciled and then disowned themselves: a plain `output dev`
|
|
483
|
+
// started every container and left them running on quit.
|
|
484
|
+
it('treats an all-exited stack as an owned fresh start, not an attach', async () => {
|
|
485
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
|
|
486
|
+
{ name: 'temporal', state: 'exited', health: 'none', ports: [] },
|
|
487
|
+
{ name: 'worker', state: 'exited', health: 'none', ports: [] }
|
|
488
|
+
]);
|
|
489
|
+
const cmd = makeCmd();
|
|
490
|
+
const runPromise = cmd.run();
|
|
491
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
492
|
+
expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
|
|
493
|
+
expect(dockerService.startDockerCompose).toHaveBeenCalled();
|
|
494
|
+
expect(dockerService.runDockerComposeUpDetached).not.toHaveBeenCalled();
|
|
495
|
+
expect(lastRenderedProps().attached).toBe(false);
|
|
496
|
+
runPromise.catch(() => { });
|
|
497
|
+
});
|
|
498
|
+
it('tears down an all-exited stack it restarted, since it owns it', async () => {
|
|
499
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
|
|
500
|
+
{ name: 'temporal', state: 'exited', health: 'none', ports: [] }
|
|
501
|
+
]);
|
|
502
|
+
const cmd = makeCmd();
|
|
503
|
+
const runPromise = cmd.run();
|
|
504
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
505
|
+
await lastRenderedProps().onCleanup();
|
|
506
|
+
expect(dockerService.stopDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml');
|
|
507
|
+
runPromise.catch(() => { });
|
|
508
|
+
});
|
|
509
|
+
// The `ps` query absorbs a broken compose file, a dead daemon, EACCES on the
|
|
510
|
+
// socket, and any JSON.parse throw. Falling back to a fresh start is right;
|
|
511
|
+
// doing it silently is what reported a port collision with the wrong remedy.
|
|
512
|
+
it('falls back to a fresh start and warns when the state query fails', async () => {
|
|
513
|
+
vi.mocked(dockerService.getServiceStatus).mockRejectedValue(new Error('no configuration file provided: not found'));
|
|
514
|
+
const cmd = makeCmd();
|
|
515
|
+
cmd.warn = vi.fn();
|
|
516
|
+
const runPromise = cmd.run();
|
|
517
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
518
|
+
expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
|
|
519
|
+
expect(dockerService.startDockerCompose).toHaveBeenCalled();
|
|
520
|
+
expect(lastRenderedProps().attached).toBe(false);
|
|
521
|
+
const warned = vi.mocked(cmd.warn).mock.calls.map(c => String(c[0])).join('\n');
|
|
522
|
+
expect(warned).toContain('no configuration file provided: not found');
|
|
523
|
+
expect(warned).toContain('output dev down');
|
|
524
|
+
runPromise.catch(() => { });
|
|
525
|
+
});
|
|
526
|
+
it('warns that quitting leaves the stack up when attaching', async () => {
|
|
527
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue(runningStack);
|
|
528
|
+
const cmd = makeCmd();
|
|
529
|
+
await cmd.run();
|
|
530
|
+
const logged = vi.mocked(cmd.log).mock.calls.map(c => c[0]).join('\n');
|
|
531
|
+
expect(logged).toContain('Quitting leaves these services running');
|
|
532
|
+
expect(logged).toContain('output dev down');
|
|
533
|
+
});
|
|
534
|
+
it('does not claim the stack survives quitting on an owned fresh start', async () => {
|
|
535
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue([]);
|
|
536
|
+
const cmd = makeCmd();
|
|
537
|
+
const runPromise = cmd.run();
|
|
538
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
539
|
+
const logged = vi.mocked(cmd.log).mock.calls.map(c => c[0]).join('\n');
|
|
540
|
+
expect(logged).not.toContain('Quitting leaves these services running');
|
|
541
|
+
runPromise.catch(() => { });
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
describe('detached flag', () => {
|
|
545
|
+
const makeDetachedCmd = () => {
|
|
546
|
+
const cmd = new Dev([], {});
|
|
547
|
+
cmd.log = vi.fn();
|
|
548
|
+
cmd.error = vi.fn();
|
|
549
|
+
Object.defineProperty(cmd, 'parse', {
|
|
550
|
+
value: vi.fn().mockResolvedValue({
|
|
551
|
+
flags: { 'compose-file': undefined, 'image-pull-policy': 'always', detached: true },
|
|
552
|
+
args: {}
|
|
553
|
+
}),
|
|
554
|
+
configurable: true
|
|
555
|
+
});
|
|
556
|
+
return cmd;
|
|
557
|
+
};
|
|
558
|
+
it('brings the stack up detached and exits without mounting the TUI', async () => {
|
|
559
|
+
const cmd = makeDetachedCmd();
|
|
560
|
+
await cmd.run();
|
|
561
|
+
expect(dockerService.runDockerComposeUpDetached).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', 'always');
|
|
562
|
+
expect(render).not.toHaveBeenCalled();
|
|
563
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
564
|
+
});
|
|
565
|
+
// The -d path lost its probe when the state check moved inside the NONE
|
|
566
|
+
// branch. It matters more than it looks: a non-container process holding a
|
|
567
|
+
// published port does NOT fail `compose up -d` on Docker Desktop — the
|
|
568
|
+
// container starts and the port keeps answering the other process.
|
|
569
|
+
it('probes host ports before starting a fresh detached stack', async () => {
|
|
570
|
+
const cmd = makeDetachedCmd();
|
|
571
|
+
await cmd.run();
|
|
572
|
+
expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
|
|
573
|
+
expect(dockerService.runDockerComposeUpDetached).toHaveBeenCalled();
|
|
574
|
+
});
|
|
575
|
+
it('aborts before compose when a foreign process holds a port', async () => {
|
|
576
|
+
vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([3001]);
|
|
577
|
+
const cmd = makeDetachedCmd();
|
|
578
|
+
const errorMock = vi.fn((_message) => {
|
|
579
|
+
throw new Error('aborted');
|
|
580
|
+
});
|
|
581
|
+
cmd.error = errorMock;
|
|
582
|
+
await expect(cmd.run()).rejects.toThrow('aborted');
|
|
583
|
+
expect(errorMock.mock.calls.at(-1)?.[0]).toContain('Port 3001 is already in use.');
|
|
584
|
+
expect(dockerService.runDockerComposeUpDetached).not.toHaveBeenCalled();
|
|
585
|
+
});
|
|
586
|
+
it('warns rather than silently assuming a fresh start when the query fails', async () => {
|
|
587
|
+
vi.mocked(dockerService.getServiceStatus).mockRejectedValue(new Error('daemon not running'));
|
|
588
|
+
const cmd = makeDetachedCmd();
|
|
589
|
+
cmd.warn = vi.fn();
|
|
590
|
+
await cmd.run();
|
|
591
|
+
const warned = vi.mocked(cmd.warn).mock.calls.map(c => String(c[0])).join('\n');
|
|
592
|
+
expect(warned).toContain('daemon not running');
|
|
593
|
+
expect(portAvailability.findUnavailablePorts).toHaveBeenCalled();
|
|
594
|
+
});
|
|
595
|
+
it('does not probe when our own stack is already running', async () => {
|
|
596
|
+
vi.mocked(dockerService.getServiceStatus).mockResolvedValue([
|
|
597
|
+
{ name: 'api', state: 'running', health: 'healthy', ports: ['3001:3001'] }
|
|
598
|
+
]);
|
|
599
|
+
vi.mocked(portAvailability.findUnavailablePorts).mockResolvedValue([3001]);
|
|
600
|
+
const cmd = makeDetachedCmd();
|
|
601
|
+
await cmd.run();
|
|
602
|
+
expect(portAvailability.findUnavailablePorts).not.toHaveBeenCalled();
|
|
603
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
604
|
+
});
|
|
605
|
+
it('aborts with a port-collision hint when the detached up fails to bind', async () => {
|
|
606
|
+
vi.mocked(dockerService.runDockerComposeUpDetached).mockResolvedValue({
|
|
607
|
+
code: 1,
|
|
608
|
+
signal: null,
|
|
609
|
+
output: 'Error response from daemon: failed to set up container networking: driver failed ' +
|
|
610
|
+
'programming external connectivity on endpoint out-temporal-1 (d4e5f6): ' +
|
|
611
|
+
'Bind for 0.0.0.0:7233 failed: port is already allocated'
|
|
612
|
+
});
|
|
613
|
+
const cmd = makeDetachedCmd();
|
|
614
|
+
const errorMock = vi.fn((_message) => {
|
|
615
|
+
throw new Error('aborted');
|
|
616
|
+
});
|
|
617
|
+
cmd.error = errorMock;
|
|
618
|
+
await expect(cmd.run()).rejects.toThrow('aborted');
|
|
619
|
+
const message = errorMock.mock.calls.at(-1)?.[0];
|
|
620
|
+
expect(message).toContain('Port 7233 is already in use.');
|
|
621
|
+
expect(message).toContain('OUTPUT_TEMPORAL_HOST_PORT=');
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
describe('fatal error handling', () => {
|
|
625
|
+
it('restores the terminal and exits non-zero on an uncaught exception', async () => {
|
|
626
|
+
const inkInstance = createControllableInkInstance();
|
|
627
|
+
vi.mocked(render).mockReturnValue(inkInstance);
|
|
628
|
+
// Capture the registered handlers instead of letting them attach to the
|
|
629
|
+
// real process, and neutralize process.exit so firing one doesn't kill
|
|
630
|
+
// the test runner.
|
|
631
|
+
const handlers = {};
|
|
632
|
+
const onSpy = vi.spyOn(process, 'on').mockImplementation(((event, handler) => {
|
|
633
|
+
handlers[event] = handler;
|
|
634
|
+
return process;
|
|
635
|
+
}));
|
|
636
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
637
|
+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
638
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
639
|
+
const cmd = new Dev([], {});
|
|
640
|
+
cmd.log = vi.fn();
|
|
641
|
+
cmd.error = vi.fn();
|
|
642
|
+
Object.defineProperty(cmd, 'parse', {
|
|
643
|
+
value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
|
|
644
|
+
configurable: true
|
|
645
|
+
});
|
|
646
|
+
const runPromise = cmd.run();
|
|
647
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
648
|
+
expect(handlers.uncaughtException).toBeInstanceOf(Function);
|
|
649
|
+
expect(handlers.unhandledRejection).toBeInstanceOf(Function);
|
|
650
|
+
const crash = new Error('boom');
|
|
651
|
+
handlers.uncaughtException(crash);
|
|
652
|
+
// Terminal restore, the crash print, and exit all run after docker
|
|
653
|
+
// teardown settles, so they land a tick later.
|
|
654
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
655
|
+
// Docker is torn down before exit, so a crash doesn't orphan the
|
|
656
|
+
// compose stack.
|
|
657
|
+
expect(dockerService.stopDockerCompose).toHaveBeenCalled();
|
|
658
|
+
expect(inkInstance.unmount).toHaveBeenCalled();
|
|
659
|
+
expect(stdoutSpy).toHaveBeenCalledWith('\x1b[?1049l');
|
|
660
|
+
expect(errorSpy).toHaveBeenCalledWith(crash);
|
|
661
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
662
|
+
// Discriminating order: docker must be fully torn down BEFORE Ink
|
|
663
|
+
// unmounts. Unmounting resolves waitUntilExit() and resumes run(),
|
|
664
|
+
// which strips the signal listeners — so an unmount-first ordering
|
|
665
|
+
// would drop the SIGINT handler mid-teardown and risk orphaning the
|
|
666
|
+
// stack on a Ctrl+C.
|
|
667
|
+
expect(vi.mocked(dockerService.stopDockerCompose).mock.invocationCallOrder[0])
|
|
668
|
+
.toBeLessThan(inkInstance.unmount.mock.invocationCallOrder[0]);
|
|
669
|
+
// Within the restore, Ink unmounts before the alt-screen is left, and
|
|
670
|
+
// the crash prints only after — otherwise console.error paints into a
|
|
671
|
+
// buffer the user never sees.
|
|
672
|
+
const leaveAltScreenCall = stdoutSpy.mock.invocationCallOrder[stdoutSpy.mock.calls.findIndex(([seq]) => seq === '\x1b[?1049l')];
|
|
673
|
+
expect(inkInstance.unmount.mock.invocationCallOrder[0]).toBeLessThan(leaveAltScreenCall);
|
|
674
|
+
expect(leaveAltScreenCall).toBeLessThan(errorSpy.mock.invocationCallOrder[0]);
|
|
675
|
+
onSpy.mockRestore();
|
|
676
|
+
runPromise.catch(() => { });
|
|
677
|
+
});
|
|
330
678
|
});
|
|
331
679
|
describe('image pull policy', () => {
|
|
332
680
|
it('should pass pull policy to startDockerCompose', async () => {
|
|
@@ -3,6 +3,7 @@ import { fetchWorkflowHistory } from '#services/workflow_history.js';
|
|
|
3
3
|
import buildSpanLabels from '#utils/span_labels.js';
|
|
4
4
|
import renderWaterfall, { formatDurationLabel } from '#utils/waterfall.js';
|
|
5
5
|
import { handleApiError } from '#utils/error_handler.js';
|
|
6
|
+
import { shouldColorize } from '#utils/color.js';
|
|
6
7
|
const DEFAULT_WIDTH = 80;
|
|
7
8
|
const OUTPUT_FORMAT = { JSON: 'json', TEXT: 'text' };
|
|
8
9
|
export default class WorkflowHistory extends Command {
|
|
@@ -56,7 +57,7 @@ export default class WorkflowHistory extends Command {
|
|
|
56
57
|
});
|
|
57
58
|
if (flags.raw) {
|
|
58
59
|
this.log(JSON.stringify({
|
|
59
|
-
workflow: result.
|
|
60
|
+
workflow: result.rawWorkflow,
|
|
60
61
|
runId: result.runId,
|
|
61
62
|
events: result.events
|
|
62
63
|
}, null, 2));
|
|
@@ -73,8 +74,7 @@ export default class WorkflowHistory extends Command {
|
|
|
73
74
|
}
|
|
74
75
|
const labels = buildSpanLabels(result.spans);
|
|
75
76
|
const width = flags.width ?? process.stdout.columns ?? DEFAULT_WIDTH;
|
|
76
|
-
const color = flags.color
|
|
77
|
-
(!!process.env.FORCE_COLOR || process.stdout.isTTY === true);
|
|
77
|
+
const color = shouldColorize(flags.color);
|
|
78
78
|
this.log(renderWaterfall(result.spans, result.totalDurationMs, {
|
|
79
79
|
width,
|
|
80
80
|
color,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
1
2
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
// Isolate the command module from the API/service layer at import time.
|
|
3
|
-
|
|
3
|
+
// Isolate the command module from the API/service layer at import time. Must use the
|
|
4
|
+
// `#`-aliased specifier — history.ts imports via that alias, and a relative specifier here
|
|
5
|
+
// resolves to a different module id, so the mock silently never intercepts the real import.
|
|
6
|
+
vi.mock('#services/workflow_history.js', () => ({
|
|
4
7
|
fetchWorkflowHistory: vi.fn()
|
|
5
8
|
}));
|
|
6
9
|
describe('workflow history command', () => {
|
|
@@ -23,4 +26,30 @@ describe('workflow history command', () => {
|
|
|
23
26
|
expect(flags.format.default).toBe('text');
|
|
24
27
|
expect(flags.raw.default).toBe(false);
|
|
25
28
|
});
|
|
29
|
+
describe('run() --raw', () => {
|
|
30
|
+
it('prints the server\'s literal status, not the client-normalized one', async () => {
|
|
31
|
+
const WorkflowHistory = (await import('./history.js')).default;
|
|
32
|
+
const { fetchWorkflowHistory } = await import('#services/workflow_history.js');
|
|
33
|
+
// `workflow` carries the normalized status (what monitor/waterfall consume);
|
|
34
|
+
// `rawWorkflow` is the untouched server value — `--raw` must use the latter.
|
|
35
|
+
vi.mocked(fetchWorkflowHistory).mockResolvedValueOnce({
|
|
36
|
+
workflow: { workflowId: 'wf-1', runId: 'run-1', status: 'continued_as_new' },
|
|
37
|
+
rawWorkflow: { workflowId: 'wf-1', runId: 'run-1', status: 'continued' },
|
|
38
|
+
runId: 'run-1',
|
|
39
|
+
events: [],
|
|
40
|
+
spans: [],
|
|
41
|
+
totalDurationMs: 0,
|
|
42
|
+
continuedAsNewRunId: null
|
|
43
|
+
});
|
|
44
|
+
const cmd = new WorkflowHistory(['wf-1', '--raw'], {});
|
|
45
|
+
cmd.log = vi.fn();
|
|
46
|
+
cmd.parse = vi.fn().mockResolvedValue({
|
|
47
|
+
args: { workflowId: 'wf-1' },
|
|
48
|
+
flags: { 'run-id': undefined, format: 'text', raw: true, 'include-payloads': false, width: undefined, color: false }
|
|
49
|
+
});
|
|
50
|
+
await cmd.run();
|
|
51
|
+
const printed = JSON.parse(cmd.log.mock.calls[0][0]);
|
|
52
|
+
expect(printed.workflow.status).toBe('continued');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
26
55
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
/**
|
|
3
|
+
* Unlike `run`/`status`/`result` (migrated to oclif's native `--json` in
|
|
4
|
+
* OUT-419, #281), this command deliberately keeps a custom `--format json`
|
|
5
|
+
* instead of `enableJsonFlag`. Native `--json` suppresses all `this.log()`
|
|
6
|
+
* calls and prints exactly one JSON object — the command's return value —
|
|
7
|
+
* after `run()` resolves. `monitor` has no single "return value": it emits a
|
|
8
|
+
* live stream of discrete events (span status changes, a continue-as-new
|
|
9
|
+
* notice, a final summary) while the workflow is still in progress, and
|
|
10
|
+
* `--format json` prints each as its own NDJSON line as it happens. That's
|
|
11
|
+
* the point — a caller (often another automated/agent process, not a human)
|
|
12
|
+
* can tail and parse the stream incrementally, which native `--json`'s
|
|
13
|
+
* "one object at the end" model can't do. See docs/guides/packages/cli.mdx
|
|
14
|
+
* ("output workflow monitor") for the same rationale written up for users.
|
|
15
|
+
*/
|
|
16
|
+
export default class WorkflowMonitor extends Command {
|
|
17
|
+
static description: string;
|
|
18
|
+
static examples: string[];
|
|
19
|
+
static args: {
|
|
20
|
+
workflowId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
21
|
+
};
|
|
22
|
+
static flags: {
|
|
23
|
+
'run-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
24
|
+
format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
25
|
+
'include-payloads': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
26
|
+
interval: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
27
|
+
color: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
28
|
+
};
|
|
29
|
+
run(): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Wraps a single poll: a failure on the very first tick propagates (there's
|
|
32
|
+
* nothing to fall back on), but a transient blip (see `isTransientPollError`)
|
|
33
|
+
* after we've already been monitoring successfully just returns `null` so the
|
|
34
|
+
* loop can retry — matching the dev TUI's `useStepGraph` behavior of keeping
|
|
35
|
+
* the last good state on a poll hiccup. A non-transient error (e.g. a stale
|
|
36
|
+
* resume cursor, or a bug in the parsing pipeline) rethrows immediately since
|
|
37
|
+
* retrying it cannot succeed. `MAX_CONSECUTIVE_ERRORS` bounds how long we'll
|
|
38
|
+
* retry transient failures before giving up.
|
|
39
|
+
*
|
|
40
|
+
* Fetch strategy is driven by `state.cursor`, not tick count: no cursor yet
|
|
41
|
+
* (the very first poll, or the first poll of a run chained via continue-as-new)
|
|
42
|
+
* uses `fetchWorkflowHistory` (fast, no long-poll) so that render isn't delayed;
|
|
43
|
+
* once a cursor exists, every poll resumes via `fetchWorkflowHistoryUpdates`
|
|
44
|
+
* instead of re-paging the whole history — see `plan_workflow_monitor_history.md`
|
|
45
|
+
* for why a full re-fetch every tick is expensive for long-running workflows.
|
|
46
|
+
*/
|
|
47
|
+
private poll;
|
|
48
|
+
catch(error: Error): Promise<void>;
|
|
49
|
+
}
|