@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.
Files changed (63) hide show
  1. package/dist/api/generated/api.d.ts +12 -0
  2. package/dist/api/http_client.js +2 -2
  3. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  4. package/dist/commands/dev/down.d.ts +10 -0
  5. package/dist/commands/dev/down.js +34 -0
  6. package/dist/commands/dev/down.spec.d.ts +1 -0
  7. package/dist/commands/dev/down.spec.js +71 -0
  8. package/dist/commands/dev/index.d.ts +4 -0
  9. package/dist/commands/dev/index.js +200 -53
  10. package/dist/commands/dev/index.spec.js +390 -42
  11. package/dist/commands/workflow/history.js +3 -3
  12. package/dist/commands/workflow/history.spec.js +31 -2
  13. package/dist/commands/workflow/monitor.d.ts +49 -0
  14. package/dist/commands/workflow/monitor.js +230 -0
  15. package/dist/commands/workflow/monitor.spec.d.ts +1 -0
  16. package/dist/commands/workflow/monitor.spec.js +243 -0
  17. package/dist/commands/workflow/run.js +8 -1
  18. package/dist/commands/workflow/run.spec.js +12 -2
  19. package/dist/commands/workflow/start.d.ts +3 -1
  20. package/dist/commands/workflow/start.js +12 -2
  21. package/dist/commands/workflow/start.spec.js +30 -5
  22. package/dist/generated/framework_version.json +1 -1
  23. package/dist/services/docker.d.ts +28 -1
  24. package/dist/services/docker.js +106 -12
  25. package/dist/services/docker.spec.js +144 -14
  26. package/dist/services/workflow_history/correlator.d.ts +2 -0
  27. package/dist/services/workflow_history/correlator.js +2 -2
  28. package/dist/services/workflow_history.d.ts +28 -0
  29. package/dist/services/workflow_history.js +95 -12
  30. package/dist/services/workflow_history.spec.js +183 -1
  31. package/dist/templates/agent_instructions/CLAUDE.md.template +1 -1
  32. package/dist/templates/project/src/clients/jina.ts.template +4 -4
  33. package/dist/utils/color.d.ts +7 -0
  34. package/dist/utils/color.js +12 -0
  35. package/dist/utils/color.spec.d.ts +1 -0
  36. package/dist/utils/color.spec.js +43 -0
  37. package/dist/utils/format_workflow_result.d.ts +1 -0
  38. package/dist/utils/format_workflow_result.js +4 -0
  39. package/dist/utils/monitor_log.d.ts +20 -0
  40. package/dist/utils/monitor_log.js +48 -0
  41. package/dist/utils/monitor_log.spec.d.ts +1 -0
  42. package/dist/utils/monitor_log.spec.js +71 -0
  43. package/dist/utils/port_collision.d.ts +22 -7
  44. package/dist/utils/port_collision.js +39 -14
  45. package/dist/utils/port_collision.spec.js +40 -1
  46. package/dist/utils/resolve_input.d.ts +9 -1
  47. package/dist/utils/resolve_input.js +8 -2
  48. package/dist/utils/resolve_input.spec.d.ts +1 -0
  49. package/dist/utils/resolve_input.spec.js +75 -0
  50. package/dist/utils/waterfall.d.ts +3 -1
  51. package/dist/utils/waterfall.js +8 -2
  52. package/dist/views/dev/chrome/footer.d.ts +2 -0
  53. package/dist/views/dev/chrome/footer.js +4 -4
  54. package/dist/views/dev/dev_app.d.ts +1 -0
  55. package/dist/views/dev/dev_app.js +13 -4
  56. package/dist/views/dev/hooks/use_run_detail.js +7 -8
  57. package/dist/views/dev/hooks/use_step_graph.js +3 -1
  58. package/dist/views/dev/utils/bounded_cache.d.ts +14 -0
  59. package/dist/views/dev/utils/bounded_cache.js +42 -0
  60. package/dist/views/dev/utils/bounded_cache.spec.d.ts +1 -0
  61. package/dist/views/dev/utils/bounded_cache.spec.js +53 -0
  62. package/oclif.manifest.json +122 -4
  63. 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
- validateDockerEnvironment: vi.fn(),
18
- startDockerCompose: vi.fn(),
19
- stopDockerCompose: vi.fn().mockResolvedValue(undefined),
20
- getServiceStatus: vi.fn().mockResolvedValue([
21
- { name: 'redis', state: 'running', health: 'healthy', ports: ['6379:6379'] },
22
- { name: 'temporal', state: 'running', health: 'healthy', ports: ['7233:7233'] }
23
- ]),
24
- isServiceFailed: vi.fn((s) => s.state === 'exited' || s.health === 'unhealthy'),
25
- DockerComposeConfigNotFoundError: Error,
26
- DockerValidationError: Error,
27
- getDefaultDockerComposePath: vi.fn(() => '/path/to/docker-compose-dev.yml'),
28
- SERVICE_HEALTH: {
29
- HEALTHY: 'healthy',
30
- UNHEALTHY: 'unhealthy',
31
- STARTING: 'starting',
32
- NONE: 'none'
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, fs.access succeeds (file exists)
94
- vi.mocked(fs).access.mockResolvedValue(undefined);
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
- // Mock the subprocess spawn to prevent actual execution
162
- vi.doMock('node:child_process', () => ({
163
- spawn: vi.fn().mockReturnValue({
164
- on: vi.fn(),
165
- kill: vi.fn()
166
- })
167
- }));
168
- // This test just verifies the function is called
169
- expect(vi.mocked(dockerService.validateDockerEnvironment)).toBeDefined();
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(fs).access.mockRejectedValue(new Error('File not found'));
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.workflow,
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 && !process.env.NO_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
- vi.mock('../../services/workflow_history.js', () => ({
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
+ }