diff --git a/backend/src/api/runs.py b/backend/src/api/runs.py index 727e211..b975f4b 100644 --- a/backend/src/api/runs.py +++ b/backend/src/api/runs.py @@ -55,9 +55,12 @@ async def get_run_status( is_failed = workflow_status == "FAILED" is_running = workflow_status == "RUNNING" + # Extract workflow name from run_id (format: workflow_name-unique_id) + workflow_name = run_id.rsplit('-', 1)[0] if '-' in run_id else "unknown" + return WorkflowStatus( run_id=run_id, - workflow="unknown", # Temporal doesn't track workflow name in status + workflow=workflow_name, status=workflow_status, is_completed=is_completed, is_failed=is_failed, @@ -123,6 +126,9 @@ async def get_run_findings( else: sarif = {} + # Extract workflow name from run_id (format: workflow_name-unique_id) + workflow_name = run_id.rsplit('-', 1)[0] if '-' in run_id else "unknown" + # Metadata metadata = { "completion_time": status.get("close_time"), @@ -130,7 +136,7 @@ async def get_run_findings( } return WorkflowFindings( - workflow="unknown", + workflow=workflow_name, run_id=run_id, sarif=sarif, metadata=metadata diff --git a/backend/src/api/workflows.py b/backend/src/api/workflows.py index 513ffea..3ffda9d 100644 --- a/backend/src/api/workflows.py +++ b/backend/src/api/workflows.py @@ -566,7 +566,6 @@ async def get_workflow_worker_info( return { "workflow": workflow_name, "vertical": vertical, - "worker_container": f"fuzzforge-worker-{vertical}", "worker_service": f"worker-{vertical}", "task_queue": f"{vertical}-queue", "required": True diff --git a/cli/src/fuzzforge_cli/commands/findings.py b/cli/src/fuzzforge_cli/commands/findings.py index 3adfd7d..6335db1 100644 --- a/cli/src/fuzzforge_cli/commands/findings.py +++ b/cli/src/fuzzforge_cli/commands/findings.py @@ -140,11 +140,145 @@ def get_findings( else: # table format display_findings_table(findings.sarif) + # Suggest export command and show command + console.print(f"\nšŸ’” View full details of a finding: [bold cyan]ff finding show {run_id} --rule [/bold cyan]") + console.print(f"šŸ’” Export these findings: [bold cyan]ff findings export {run_id} --format sarif[/bold cyan]") + console.print(" Supported formats: [cyan]sarif[/cyan] (standard), [cyan]json[/cyan], [cyan]csv[/cyan], [cyan]html[/cyan]") + except Exception as e: console.print(f"āŒ Failed to get findings: {e}", style="red") raise typer.Exit(1) +def show_finding( + run_id: str = typer.Argument(..., help="Run ID to get finding from"), + rule_id: str = typer.Option(..., "--rule", "-r", help="Rule ID of the specific finding to show") +): + """ + šŸ” Show detailed information about a specific finding + + This function is registered as a command in main.py under the finding (singular) command group. + """ + try: + require_project() + validate_run_id(run_id) + + # Try to get from database first, fallback to API + db = get_project_db() + findings_data = None + if db: + findings_data = db.get_findings(run_id) + + if not findings_data: + with get_client() as client: + console.print(f"šŸ” Fetching findings for run: {run_id}") + findings = client.get_run_findings(run_id) + sarif_data = findings.sarif + else: + sarif_data = findings_data.sarif_data + + # Find the specific finding by rule_id + runs = sarif_data.get("runs", []) + if not runs: + console.print("āŒ No findings data available", style="red") + raise typer.Exit(1) + + run_data = runs[0] + results = run_data.get("results", []) + tool = run_data.get("tool", {}).get("driver", {}) + + # Search for matching finding + matching_finding = None + for result in results: + if result.get("ruleId") == rule_id: + matching_finding = result + break + + if not matching_finding: + console.print(f"āŒ No finding found with rule ID: {rule_id}", style="red") + console.print(f"šŸ’” Use [bold cyan]ff findings get {run_id}[/bold cyan] to see all findings", style="dim") + raise typer.Exit(1) + + # Display detailed finding + display_finding_detail(matching_finding, tool, run_id) + + except Exception as e: + console.print(f"āŒ Failed to get finding: {e}", style="red") + raise typer.Exit(1) + + +def display_finding_detail(finding: Dict[str, Any], tool: Dict[str, Any], run_id: str): + """Display detailed information about a single finding""" + rule_id = finding.get("ruleId", "unknown") + level = finding.get("level", "note") + message = finding.get("message", {}) + message_text = message.get("text", "No summary available") + message_markdown = message.get("markdown", message_text) + + # Get location + locations = finding.get("locations", []) + location_str = "Unknown location" + code_snippet = None + + if locations: + physical_location = locations[0].get("physicalLocation", {}) + artifact_location = physical_location.get("artifactLocation", {}) + region = physical_location.get("region", {}) + + file_path = artifact_location.get("uri", "") + if file_path: + location_str = file_path + if region.get("startLine"): + location_str += f":{region['startLine']}" + if region.get("startColumn"): + location_str += f":{region['startColumn']}" + + # Get code snippet if available + if region.get("snippet", {}).get("text"): + code_snippet = region["snippet"]["text"].strip() + + # Get severity style + severity_color = { + "error": "red", + "warning": "yellow", + "note": "blue", + "info": "cyan" + }.get(level.lower(), "white") + + # Build detailed content + content_lines = [] + content_lines.append(f"[bold]Rule ID:[/bold] {rule_id}") + content_lines.append(f"[bold]Severity:[/bold] [{severity_color}]{level.upper()}[/{severity_color}]") + content_lines.append(f"[bold]Location:[/bold] {location_str}") + content_lines.append(f"[bold]Tool:[/bold] {tool.get('name', 'Unknown')} v{tool.get('version', 'unknown')}") + content_lines.append(f"[bold]Run ID:[/bold] {run_id}") + content_lines.append("") + content_lines.append(f"[bold]Summary:[/bold]") + content_lines.append(message_text) + content_lines.append("") + content_lines.append(f"[bold]Description:[/bold]") + content_lines.append(message_markdown) + + if code_snippet: + content_lines.append("") + content_lines.append(f"[bold]Code Snippet:[/bold]") + content_lines.append(f"[dim]{code_snippet}[/dim]") + + content = "\n".join(content_lines) + + # Display in panel + console.print() + console.print(Panel( + content, + title=f"šŸ” Finding Detail", + border_style=severity_color, + box=box.ROUNDED, + padding=(1, 2) + )) + console.print() + console.print(f"šŸ’” Export this run: [bold cyan]ff findings export {run_id} --format sarif[/bold cyan]") + + def display_findings_table(sarif_data: Dict[str, Any]): """Display SARIF findings in a rich table format""" runs = sarif_data.get("runs", []) @@ -195,8 +329,8 @@ def display_findings_table(sarif_data: Dict[str, Any]): # Detailed results - Rich Text-based table with proper emoji alignment results_table = Table(box=box.ROUNDED) results_table.add_column("Severity", width=12, justify="left", no_wrap=True) - results_table.add_column("Rule", width=25, justify="left", style="bold cyan", no_wrap=True) - results_table.add_column("Message", width=55, justify="left", no_wrap=True) + results_table.add_column("Rule", justify="left", style="bold cyan", no_wrap=True) + results_table.add_column("Message", width=45, justify="left", no_wrap=True) results_table.add_column("Location", width=20, justify="left", style="dim", no_wrap=True) for result in results[:50]: # Limit to first 50 results @@ -224,18 +358,16 @@ def display_findings_table(sarif_data: Dict[str, Any]): severity_text = Text(level.upper(), style=severity_style(level)) severity_text.truncate(12, overflow="ellipsis") - rule_text = Text(rule_id) - rule_text.truncate(25, overflow="ellipsis") - + # Show full rule ID without truncation message_text = Text(message) - message_text.truncate(55, overflow="ellipsis") + message_text.truncate(45, overflow="ellipsis") location_text = Text(location_str) location_text.truncate(20, overflow="ellipsis") results_table.add_row( severity_text, - rule_text, + rule_id, # Pass string directly to show full UUID message_text, location_text ) @@ -307,16 +439,20 @@ def findings_history( def export_findings( run_id: str = typer.Argument(..., help="Run ID to export findings for"), format: str = typer.Option( - "json", "--format", "-f", - help="Export format: json, csv, html, sarif" + "sarif", "--format", "-f", + help="Export format: sarif (standard), json, csv, html" ), output: Optional[str] = typer.Option( None, "--output", "-o", - help="Output file path (defaults to findings-.)" + help="Output file path (defaults to findings--.)" ) ): """ šŸ“¤ Export security findings in various formats + + SARIF is the standard format for security findings and is recommended + for interoperability with other security tools. Filenames are automatically + made unique with timestamps to prevent overwriting previous exports. """ db = get_project_db() if not db: @@ -334,9 +470,10 @@ def export_findings( else: sarif_data = findings_data.sarif_data - # Generate output filename + # Generate output filename with timestamp for uniqueness if not output: - output = f"findings-{run_id[:8]}.{format}" + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + output = f"findings-{run_id[:8]}-{timestamp}.{format}" output_path = Path(output) diff --git a/cli/src/fuzzforge_cli/commands/monitor.py b/cli/src/fuzzforge_cli/commands/monitor.py index b308f06..eb6d3ba 100644 --- a/cli/src/fuzzforge_cli/commands/monitor.py +++ b/cli/src/fuzzforge_cli/commands/monitor.py @@ -59,66 +59,6 @@ def format_number(num: int) -> str: return str(num) -@app.command("stats") -def fuzzing_stats( - run_id: str = typer.Argument(..., help="Run ID to get statistics for"), - refresh: int = typer.Option( - 5, "--refresh", "-r", - help="Refresh interval in seconds" - ), - once: bool = typer.Option( - False, "--once", - help="Show stats once and exit" - ) -): - """ - šŸ“Š Show current fuzzing statistics for a run - """ - try: - with get_client() as client: - if once: - # Show stats once - stats = client.get_fuzzing_stats(run_id) - display_stats_table(stats) - else: - # Live updating stats - console.print(f"šŸ“Š [bold]Live Fuzzing Statistics[/bold] (Run: {run_id[:12]}...)") - console.print(f"Refreshing every {refresh}s. Press Ctrl+C to stop.\n") - - with Live(auto_refresh=False, console=console) as live: - while True: - try: - # Check workflow status - run_status = client.get_run_status(run_id) - stats = client.get_fuzzing_stats(run_id) - table = create_stats_table(stats) - live.update(table, refresh=True) - - # Exit if workflow completed or failed - if getattr(run_status, 'is_completed', False) or getattr(run_status, 'is_failed', False): - final_status = getattr(run_status, 'status', 'Unknown') - if getattr(run_status, 'is_completed', False): - console.print("\nāœ… [bold green]Workflow completed[/bold green]", style="green") - else: - console.print(f"\nāš ļø [bold yellow]Workflow ended[/bold yellow] | Status: {final_status}", style="yellow") - break - - time.sleep(refresh) - except KeyboardInterrupt: - console.print("\nšŸ“Š Monitoring stopped", style="yellow") - break - - except Exception as e: - console.print(f"āŒ Failed to get fuzzing stats: {e}", style="red") - raise typer.Exit(1) - - -def display_stats_table(stats): - """Display stats in a simple table""" - table = create_stats_table(stats) - console.print(table) - - def create_stats_table(stats) -> Panel: """Create a rich table for fuzzing statistics""" # Create main stats table @@ -266,8 +206,8 @@ def crash_reports( raise typer.Exit(1) -def _live_monitor(run_id: str, refresh: int): - """Helper for live monitoring with inline real-time display""" +def _live_monitor(run_id: str, refresh: int, once: bool = False, style: str = "inline"): + """Helper for live monitoring with inline real-time display or table display""" with get_client() as client: start_time = time.time() @@ -319,16 +259,29 @@ def _live_monitor(run_id: str, refresh: int): self.elapsed_time = 0 self.last_crash_time = None - with Live(auto_refresh=False, console=console) as live: - # Initial fetch - try: - run_status = client.get_run_status(run_id) - stats = client.get_fuzzing_stats(run_id) - except Exception: - stats = FallbackStats(run_id) - run_status = type("RS", (), {"status":"Unknown","is_completed":False,"is_failed":False})() + # Initial fetch + try: + run_status = client.get_run_status(run_id) + stats = client.get_fuzzing_stats(run_id) + except Exception: + stats = FallbackStats(run_id) + run_status = type("RS", (), {"status":"Unknown","is_completed":False,"is_failed":False})() - live.update(render_inline_stats(run_status, stats), refresh=True) + # Handle --once mode: show stats once and exit + if once: + if style == "table": + console.print(create_stats_table(stats)) + else: + console.print(render_inline_stats(run_status, stats)) + return + + # Live monitoring mode + with Live(auto_refresh=False, console=console) as live: + # Render based on style + if style == "table": + live.update(create_stats_table(stats), refresh=True) + else: + live.update(render_inline_stats(run_status, stats), refresh=True) # Polling loop consecutive_errors = 0 @@ -354,8 +307,11 @@ def _live_monitor(run_id: str, refresh: int): except Exception: stats = FallbackStats(run_id) - # Update display - live.update(render_inline_stats(run_status, stats), refresh=True) + # Update display based on style + if style == "table": + live.update(create_stats_table(stats), refresh=True) + else: + live.update(render_inline_stats(run_status, stats), refresh=True) # Check if completed if getattr(run_status, 'is_completed', False) or getattr(run_status, 'is_failed', False): @@ -386,17 +342,36 @@ def live_monitor( refresh: int = typer.Option( 2, "--refresh", "-r", help="Refresh interval in seconds" + ), + once: bool = typer.Option( + False, "--once", + help="Show stats once and exit" + ), + style: str = typer.Option( + "inline", "--style", + help="Display style: 'inline' (default) or 'table'" ) ): """ - šŸ“ŗ Real-time inline monitoring with live statistics updates + šŸ“ŗ Real-time monitoring with live statistics updates + + Display styles: + - inline: Visual inline display with emojis (default) + - table: Clean table-based display + + Use --once to show stats once without continuous monitoring (useful for scripts) """ try: - _live_monitor(run_id, refresh) + # Validate style + if style not in ["inline", "table"]: + console.print(f"āŒ Invalid style: {style}. Must be 'inline' or 'table'", style="red") + raise typer.Exit(1) + + _live_monitor(run_id, refresh, once, style) except KeyboardInterrupt: console.print("\n\nšŸ“Š Monitoring stopped by user.", style="yellow") except Exception as e: - console.print(f"\nāŒ Failed to start live monitoring: {e}", style="red") + console.print(f"\nāŒ Failed to start monitoring: {e}", style="red") raise typer.Exit(1) @@ -423,6 +398,5 @@ def monitor_callback(ctx: typer.Context): console = Console() console.print("šŸ“Š [bold cyan]Monitor Command[/bold cyan]") console.print("\nAvailable subcommands:") - console.print(" • [cyan]ff monitor stats [/cyan] - Show execution statistics") + console.print(" • [cyan]ff monitor live [/cyan] - Monitor with live updates (supports --once, --style)") console.print(" • [cyan]ff monitor crashes [/cyan] - Show crash reports") - console.print(" • [cyan]ff monitor live [/cyan] - Real-time inline monitoring") diff --git a/cli/src/fuzzforge_cli/commands/workflow_exec.py b/cli/src/fuzzforge_cli/commands/workflow_exec.py index 959e94f..aadd75c 100644 --- a/cli/src/fuzzforge_cli/commands/workflow_exec.py +++ b/cli/src/fuzzforge_cli/commands/workflow_exec.py @@ -365,7 +365,7 @@ def execute_workflow( should_auto_start = auto_start if auto_start is not None else config.workers.auto_start_workers should_auto_stop = auto_stop if auto_stop is not None else config.workers.auto_stop_workers - worker_container = None # Track for cleanup + worker_service = None # Track for cleanup worker_mgr = None wait_completed = False # Track if wait completed successfully @@ -384,7 +384,6 @@ def execute_workflow( ) # Ensure worker is running - worker_container = worker_info["worker_container"] worker_service = worker_info.get("worker_service", f"worker-{worker_info['vertical']}") if not worker_mgr.ensure_worker_running(worker_info, auto_start=should_auto_start): console.print( @@ -434,7 +433,7 @@ def execute_workflow( # Don't fail the whole operation if database save fails console.print(f"āš ļø Failed to save execution to database: {e}", style="yellow") - console.print(f"\nšŸ’” Monitor progress: [bold cyan]fuzzforge monitor stats {response.run_id}[/bold cyan]") + console.print(f"\nšŸ’” Monitor progress: [bold cyan]fuzzforge monitor live {response.run_id}[/bold cyan]") console.print(f"šŸ’” Check status: [bold cyan]fuzzforge workflow status {response.run_id}[/bold cyan]") # Suggest --live for fuzzing workflows @@ -461,7 +460,7 @@ def execute_workflow( console.print("\nā¹ļø Live monitoring stopped (execution continues in background)", style="yellow") except Exception as e: console.print(f"āš ļø Failed to start live monitoring: {e}", style="yellow") - console.print(f"šŸ’” You can still monitor manually: [bold cyan]fuzzforge monitor {response.run_id}[/bold cyan]") + console.print(f"šŸ’” You can still monitor manually: [bold cyan]fuzzforge monitor live {response.run_id}[/bold cyan]") # Wait for completion if requested elif wait: @@ -527,11 +526,11 @@ def execute_workflow( handle_error(e, "executing workflow") finally: # Stop worker if auto-stop is enabled and wait completed - if should_auto_stop and worker_container and worker_mgr and wait_completed: + if should_auto_stop and worker_service and worker_mgr and wait_completed: try: console.print("\nšŸ›‘ Stopping worker (auto-stop enabled)...") - if worker_mgr.stop_worker(worker_container): - console.print(f"āœ… Worker stopped: {worker_container}") + if worker_mgr.stop_worker(worker_service): + console.print(f"āœ… Worker stopped: {worker_service}") except Exception as e: console.print( f"āš ļø Failed to stop worker: {e}", @@ -608,7 +607,7 @@ def workflow_status( # Show next steps if status.is_running: - console.print(f"\nšŸ’” Monitor live: [bold cyan]fuzzforge monitor {execution_id}[/bold cyan]") + console.print(f"\nšŸ’” Monitor live: [bold cyan]fuzzforge monitor live {execution_id}[/bold cyan]") elif status.is_completed: console.print(f"šŸ’” View findings: [bold cyan]fuzzforge finding {execution_id}[/bold cyan]") elif status.is_failed: @@ -770,7 +769,7 @@ def retry_workflow( except Exception as e: console.print(f"āš ļø Failed to save execution to database: {e}", style="yellow") - console.print(f"\nšŸ’” Monitor progress: [bold cyan]fuzzforge monitor stats {response.run_id}[/bold cyan]") + console.print(f"\nšŸ’” Monitor progress: [bold cyan]fuzzforge monitor live {response.run_id}[/bold cyan]") except Exception as e: handle_error(e, "retrying workflow") diff --git a/cli/src/fuzzforge_cli/main.py b/cli/src/fuzzforge_cli/main.py index 5726275..10c9bdc 100644 --- a/cli/src/fuzzforge_cli/main.py +++ b/cli/src/fuzzforge_cli/main.py @@ -260,57 +260,17 @@ def workflow_main(): # === Finding commands (singular) === -@finding_app.command("export") -def export_finding( - execution_id: Optional[str] = typer.Argument(None, help="Execution ID (defaults to latest)"), - format: str = typer.Option( - "sarif", "--format", "-f", - help="Export format: sarif, json, csv" - ), - output: Optional[str] = typer.Option( - None, "--output", "-o", - help="Output file (defaults to stdout)" - ) +@finding_app.command("show") +def show_finding_detail( + run_id: str = typer.Argument(..., help="Run ID to get finding from"), + rule_id: str = typer.Option(..., "--rule", "-r", help="Rule ID of the specific finding to show") ): """ - šŸ“¤ Export findings to file + šŸ” Show detailed information about a specific finding """ - from .commands.findings import export_findings - from .database import get_project_db - from .exceptions import require_project + from .commands.findings import show_finding + show_finding(run_id=run_id, rule_id=rule_id) - try: - require_project() - - # If no ID provided, get the latest - if not execution_id: - db = get_project_db() - if db: - recent_runs = db.list_runs(limit=1) - if recent_runs: - execution_id = recent_runs[0].run_id - console.print(f"šŸ” Using most recent execution: {execution_id}") - else: - console.print("āš ļø No findings found in project database", style="yellow") - return - else: - console.print("āŒ No project database found", style="red") - return - - export_findings(run_id=execution_id, format=format, output=output) - except Exception as e: - console.print(f"āŒ Failed to export findings: {e}", style="red") - - -@finding_app.command("analyze") -def analyze_finding( - finding_id: Optional[str] = typer.Argument(None, help="Finding ID to analyze") -): - """ - šŸ¤– AI analysis of a finding - """ - from .commands.ai import analyze_finding as ai_analyze - ai_analyze(finding_id) @finding_app.callback(invoke_without_command=True) def finding_main( @@ -320,9 +280,9 @@ def finding_main( View and analyze individual findings Examples: - fuzzforge finding # Show latest finding - fuzzforge finding # Show specific finding - fuzzforge finding export # Export latest findings + fuzzforge finding # Show latest finding + fuzzforge finding # Show specific finding + fuzzforge finding show --rule # Show specific finding detail """ # Check if a subcommand is being invoked if ctx.invoked_subcommand is not None: @@ -418,7 +378,7 @@ def main(): # Handle finding command with pattern recognition if len(args) >= 2 and args[0] == 'finding': - finding_subcommands = ['export', 'analyze'] + finding_subcommands = ['show'] # Skip custom dispatching if help flags are present if not any(arg in ['--help', '-h', '--version', '-v'] for arg in args): if args[1] not in finding_subcommands: diff --git a/cli/src/fuzzforge_cli/worker_manager.py b/cli/src/fuzzforge_cli/worker_manager.py index 2af758b..b6102e0 100644 --- a/cli/src/fuzzforge_cli/worker_manager.py +++ b/cli/src/fuzzforge_cli/worker_manager.py @@ -95,17 +95,30 @@ class WorkerManager: check=True ) - def is_worker_running(self, container_name: str) -> bool: + def _service_to_container_name(self, service_name: str) -> str: """ - Check if a worker container is running. + Convert service name to container name based on docker-compose naming convention. Args: - container_name: Name of the Docker container (e.g., "fuzzforge-worker-ossfuzz") + service_name: Docker Compose service name (e.g., "worker-python") + + Returns: + Container name (e.g., "fuzzforge-worker-python") + """ + return f"fuzzforge-{service_name}" + + def is_worker_running(self, service_name: str) -> bool: + """ + Check if a worker service is running. + + Args: + service_name: Name of the Docker Compose service (e.g., "worker-ossfuzz") Returns: True if container is running, False otherwise """ try: + container_name = self._service_to_container_name(service_name) result = subprocess.run( ["docker", "inspect", "-f", "{{.State.Running}}", container_name], capture_output=True, @@ -120,46 +133,42 @@ class WorkerManager: logger.debug(f"Failed to check worker status: {e}") return False - def start_worker(self, container_name: str) -> bool: + def start_worker(self, service_name: str) -> bool: """ - Start a worker container using docker. + Start a worker service using docker-compose. Args: - container_name: Name of the Docker container to start + service_name: Name of the Docker Compose service to start (e.g., "worker-python") Returns: True if started successfully, False otherwise """ try: - console.print(f"šŸš€ Starting worker: {container_name}") + console.print(f"šŸš€ Starting worker: {service_name}") - # Use docker start directly (works with container name) - subprocess.run( - ["docker", "start", container_name], - capture_output=True, - text=True, - check=True - ) + # Use docker-compose up to create and start the service + result = self._run_docker_compose("up", "-d", service_name) - logger.info(f"Worker {container_name} started") + logger.info(f"Worker {service_name} started") return True except subprocess.CalledProcessError as e: - logger.error(f"Failed to start worker {container_name}: {e.stderr}") + logger.error(f"Failed to start worker {service_name}: {e.stderr}") console.print(f"āŒ Failed to start worker: {e.stderr}", style="red") + console.print(f"šŸ’” Start the worker manually: docker compose up -d {service_name}", style="yellow") return False except Exception as e: - logger.error(f"Unexpected error starting worker {container_name}: {e}") + logger.error(f"Unexpected error starting worker {service_name}: {e}") console.print(f"āŒ Unexpected error: {e}", style="red") return False - def wait_for_worker_ready(self, container_name: str, timeout: Optional[int] = None) -> bool: + def wait_for_worker_ready(self, service_name: str, timeout: Optional[int] = None) -> bool: """ Wait for a worker to be healthy and ready to process tasks. Args: - container_name: Name of the Docker container + service_name: Name of the Docker Compose service timeout: Maximum seconds to wait (uses instance default if not specified) Returns: @@ -170,13 +179,14 @@ class WorkerManager: """ timeout = timeout or self.startup_timeout start_time = time.time() + container_name = self._service_to_container_name(service_name) console.print("ā³ Waiting for worker to be ready...") while time.time() - start_time < timeout: # Check if container is running - if not self.is_worker_running(container_name): - logger.debug(f"Worker {container_name} not running yet") + if not self.is_worker_running(service_name): + logger.debug(f"Worker {service_name} not running yet") time.sleep(self.health_check_interval) continue @@ -193,16 +203,16 @@ class WorkerManager: # If no health check is defined, assume healthy after running if health_status == "" or health_status == "": - logger.info(f"Worker {container_name} is running (no health check)") - console.print(f"āœ… Worker ready: {container_name}") + logger.info(f"Worker {service_name} is running (no health check)") + console.print(f"āœ… Worker ready: {service_name}") return True if health_status == "healthy": - logger.info(f"Worker {container_name} is healthy") - console.print(f"āœ… Worker ready: {container_name}") + logger.info(f"Worker {service_name} is healthy") + console.print(f"āœ… Worker ready: {service_name}") return True - logger.debug(f"Worker {container_name} health: {health_status}") + logger.debug(f"Worker {service_name} health: {health_status}") except Exception as e: logger.debug(f"Failed to check health: {e}") @@ -210,41 +220,36 @@ class WorkerManager: time.sleep(self.health_check_interval) elapsed = time.time() - start_time - logger.warning(f"Worker {container_name} did not become ready within {elapsed:.1f}s") + logger.warning(f"Worker {service_name} did not become ready within {elapsed:.1f}s") console.print(f"āš ļø Worker startup timeout after {elapsed:.1f}s", style="yellow") return False - def stop_worker(self, container_name: str) -> bool: + def stop_worker(self, service_name: str) -> bool: """ - Stop a worker container using docker. + Stop a worker service using docker-compose. Args: - container_name: Name of the Docker container to stop + service_name: Name of the Docker Compose service to stop Returns: True if stopped successfully, False otherwise """ try: - console.print(f"šŸ›‘ Stopping worker: {container_name}") + console.print(f"šŸ›‘ Stopping worker: {service_name}") - # Use docker stop directly (works with container name) - subprocess.run( - ["docker", "stop", container_name], - capture_output=True, - text=True, - check=True - ) + # Use docker-compose down to stop and remove the service + result = self._run_docker_compose("stop", service_name) - logger.info(f"Worker {container_name} stopped") + logger.info(f"Worker {service_name} stopped") return True except subprocess.CalledProcessError as e: - logger.error(f"Failed to stop worker {container_name}: {e.stderr}") + logger.error(f"Failed to stop worker {service_name}: {e.stderr}") console.print(f"āŒ Failed to stop worker: {e.stderr}", style="red") return False except Exception as e: - logger.error(f"Unexpected error stopping worker {container_name}: {e}") + logger.error(f"Unexpected error stopping worker {service_name}: {e}") console.print(f"āŒ Unexpected error: {e}", style="red") return False @@ -257,17 +262,18 @@ class WorkerManager: Ensure a worker is running, starting it if necessary. Args: - worker_info: Worker information dict from API (contains worker_container, etc.) + worker_info: Worker information dict from API (contains worker_service, etc.) auto_start: Whether to automatically start the worker if not running Returns: True if worker is running, False otherwise """ - container_name = worker_info["worker_container"] + # Get worker_service (docker-compose service name) + service_name = worker_info.get("worker_service", f"worker-{worker_info['vertical']}") vertical = worker_info["vertical"] # Check if already running - if self.is_worker_running(container_name): + if self.is_worker_running(service_name): console.print(f"āœ“ Worker already running: {vertical}") return True @@ -279,8 +285,8 @@ class WorkerManager: return False # Start the worker - if not self.start_worker(container_name): + if not self.start_worker(service_name): return False # Wait for it to be ready - return self.wait_for_worker_ready(container_name) + return self.wait_for_worker_ready(service_name) diff --git a/cli/uv.lock b/cli/uv.lock index 841c873..e83f96f 100644 --- a/cli/uv.lock +++ b/cli/uv.lock @@ -1257,7 +1257,7 @@ wheels = [ [[package]] name = "fuzzforge-ai" -version = "0.6.0" +version = "0.7.0" source = { editable = "../ai" } dependencies = [ { name = "a2a-sdk" }, @@ -1303,7 +1303,7 @@ dev = [ [[package]] name = "fuzzforge-cli" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "fuzzforge-ai" }, @@ -1347,7 +1347,7 @@ provides-extras = ["dev"] [[package]] name = "fuzzforge-sdk" -version = "0.6.0" +version = "0.7.0" source = { editable = "../sdk" } dependencies = [ { name = "httpx" }, diff --git a/docker-compose.yml b/docker-compose.yml index f55e5ec..271f7e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,6 @@ # Development: docker-compose -f docker-compose.temporal.yaml up # Production: docker-compose -f docker-compose.temporal.yaml -f docker-compose.temporal.prod.yaml up -version: '3.8' - services: # ============================================================================ # Temporal Server - Workflow Orchestration