Merge pull request #20 from FuzzingLabs/dev

Release: v0.7.1 - Worker fixes, monitor consolidation, and findings improvements
This commit is contained in:
tduhamel42
2025-10-21 16:59:44 +02:00
committed by GitHub
9 changed files with 283 additions and 204 deletions
+8 -2
View File
@@ -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
-1
View File
@@ -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
+149 -12
View File
@@ -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 <rule-id>[/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-<run-id>.<format>)"
help="Output file path (defaults to findings-<run-id>-<timestamp>.<format>)"
)
):
"""
📤 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)
+52 -78
View File
@@ -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 <run-id>[/cyan] - Show execution statistics")
console.print(" • [cyan]ff monitor live <run-id>[/cyan] - Monitor with live updates (supports --once, --style)")
console.print(" • [cyan]ff monitor crashes <run-id>[/cyan] - Show crash reports")
console.print(" • [cyan]ff monitor live <run-id>[/cyan] - Real-time inline monitoring")
@@ -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")
+11 -51
View File
@@ -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 <id> # Show specific finding
fuzzforge finding export # Export latest findings
fuzzforge finding # Show latest finding
fuzzforge finding <id> # Show specific finding
fuzzforge finding show <run-id> --rule <id> # 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:
+52 -46
View File
@@ -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 == "<no value>" 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)
Generated
+3 -3
View File
@@ -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" },
-2
View File
@@ -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