diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index fe958bba..b7f1a3ad 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -310,4 +310,67 @@ describe('runMainWorkflow', () => { await expect(runMainWorkflow(configWithDiagnostics, dependencies, { logger, performCleanup: jest.fn() })).resolves.toBe(1); }); + + it('calls collectDiagnosticLogs on startContainers failure when diagnosticLogs is enabled', async () => { + const startError = new Error('Squid container is unhealthy'); + const collectDiagnosticLogs = jest.fn().mockResolvedValue(undefined); + const configWithDiagnostics: WrapperConfig = { + ...baseConfig, + diagnosticLogs: true, + }; + const dependencies: WorkflowDependencies = { + ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }), + setupHostIptables: jest.fn().mockResolvedValue(undefined), + writeConfigs: jest.fn().mockResolvedValue(undefined), + startContainers: jest.fn().mockRejectedValue(startError), + runAgentCommand: jest.fn(), + collectDiagnosticLogs, + }; + const logger = createLogger(); + + await expect(runMainWorkflow(configWithDiagnostics, dependencies, { logger, performCleanup: jest.fn() })).rejects.toBe(startError); + + expect(collectDiagnosticLogs).toHaveBeenCalledWith(configWithDiagnostics.workDir); + expect(dependencies.runAgentCommand).not.toHaveBeenCalled(); + }); + + it('does not call collectDiagnosticLogs on startContainers failure when diagnosticLogs is disabled', async () => { + const startError = new Error('Squid container is unhealthy'); + const collectDiagnosticLogs = jest.fn().mockResolvedValue(undefined); + const dependencies: WorkflowDependencies = { + ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }), + setupHostIptables: jest.fn().mockResolvedValue(undefined), + writeConfigs: jest.fn().mockResolvedValue(undefined), + startContainers: jest.fn().mockRejectedValue(startError), + runAgentCommand: jest.fn(), + collectDiagnosticLogs, + }; + const logger = createLogger(); + + await expect(runMainWorkflow(baseConfig, dependencies, { logger, performCleanup: jest.fn() })).rejects.toBe(startError); + + expect(collectDiagnosticLogs).not.toHaveBeenCalled(); + }); + + it('rethrows startContainers error after collecting diagnostics', async () => { + const startError = new Error('docker compose failed'); + const configWithDiagnostics: WrapperConfig = { + ...baseConfig, + diagnosticLogs: true, + }; + const performCleanup = jest.fn().mockResolvedValue(undefined); + const dependencies: WorkflowDependencies = { + ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }), + setupHostIptables: jest.fn().mockResolvedValue(undefined), + writeConfigs: jest.fn().mockResolvedValue(undefined), + startContainers: jest.fn().mockRejectedValue(startError), + runAgentCommand: jest.fn(), + collectDiagnosticLogs: jest.fn().mockResolvedValue(undefined), + }; + const logger = createLogger(); + + await expect(runMainWorkflow(configWithDiagnostics, dependencies, { logger, performCleanup })).rejects.toBe(startError); + // performCleanup should NOT be called — that is the caller's (cli.ts) responsibility + expect(performCleanup).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 91130c6c..46c4defb 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -71,7 +71,25 @@ export async function runMainWorkflow( await dependencies.writeConfigs(config); // Step 2: Start containers - await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir, config.skipPull); + try { + await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir, config.skipPull); + } catch (startError) { + // Signal that containers may have been partially created so the caller's + // cleanup (stopContainers / docker compose down -v) will tear them down + // instead of leaving orphaned containers and networks. + onContainersStarted?.(); + + // Collect diagnostics for startup failures before containers are torn down. + // Must happen before performCleanup() / stopContainers() destroys them. + if (config.diagnosticLogs && dependencies.collectDiagnosticLogs) { + try { + await dependencies.collectDiagnosticLogs(config.workDir); + } catch (diagError) { + logger.warn('Failed to collect diagnostic logs; continuing with cleanup.', diagError); + } + } + throw startError; + } onContainersStarted?.(); // Step 3: Wait for agent to complete diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 94edf330..549a5693 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -2521,9 +2521,9 @@ export async function collectDiagnosticLogs(workDir: string): Promise { ]; for (const container of containers) { - // Collect stdout+stderr from docker logs + // Collect stdout+stderr from docker logs (last 200 lines to keep files manageable) try { - const result = await execa('docker', ['logs', container], { reject: false }); + const result = await execa('docker', ['logs', '--tail', '200', container], { reject: false }); if (result.exitCode === 0) { const combined = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); if (combined) {