#!/usr/bin/env -S uv run """ Honcho Development Harness A script that orchestrates running Honcho with a Docker database for development and testing. This script: 1. Starts a PostgreSQL database in Docker with a configurable port 2. Provisions the database using Alembic migrations 3. Configures Honcho to use the database via environment variables 4. Starts the FastAPI server and deriver in separate processes """ import argparse import asyncio import os import shutil import subprocess import sys import tempfile import threading import time from pathlib import Path from typing import Any import yaml from src.cache.client import close_cache, init_cache class HonchoHarness: """ Orchestrates running Honcho with a Docker database for development. """ def __init__( self, db_port: int, api_port: int, redis_port: int, project_root: Path, instance_id: int = 0, ) -> None: """ Initialize the harness with database port, API port, Redis port, and project root. Args: db_port: Port for the PostgreSQL database api_port: Port for the FastAPI server redis_port: Port for the Redis server project_root: Path to the Honcho project root instance_id: Instance identifier for pool management """ self.db_port: int = db_port self.api_port: int = api_port self.redis_port: int = redis_port self.project_root: Path = project_root self.instance_id: int = instance_id self.temp_dir: Path | None = None self.docker_compose_file: Path | None = None self.processes: list[tuple[str, subprocess.Popen[str]]] = [] self.env_file_backup: Path | None = None self.output_threads: list[threading.Thread] = [] # DB credentials — populated from docker-compose.yml.example in create_temp_docker_compose self.db_user: str = "postgres" self.db_password: str = "postgres" self.db_name: str = "postgres" def _extract_db_credentials(self, compose_data: dict[str, Any]) -> None: """Extract POSTGRES_USER/PASSWORD/DB from the database service environment.""" services: dict[str, Any] = compose_data.get("services", {}) database: dict[str, Any] = services.get("database", {}) db_env: list[Any] = database.get("environment", []) env_map: dict[str, str] = {} for entry in db_env: if isinstance(entry, str) and "=" in entry: # Handle "- KEY=VALUE" format key, _, value = entry.partition("=") env_map[key.strip()] = value.strip() self.db_user = env_map.get("POSTGRES_USER", self.db_user) self.db_password = env_map.get("POSTGRES_PASSWORD", self.db_password) self.db_name = env_map.get("POSTGRES_DB", self.db_name) @property def db_connection_uri(self) -> str: """SQLAlchemy-style connection URI for the test database.""" return f"postgresql+psycopg://{self.db_user}:{self.db_password}@localhost:{self.db_port}/{self.db_name}" @property def db_connection_uri_plain(self) -> str: """Plain psycopg connection URI (no +psycopg driver prefix).""" return f"postgresql://{self.db_user}:{self.db_password}@localhost:{self.db_port}/{self.db_name}" def create_temp_docker_compose(self) -> Path: """ Create a temporary docker-compose.yml with the specified database port. Returns: Path to the temporary docker-compose.yml file """ # Read the example docker-compose file example_file = self.project_root / "docker-compose.yml.example" with open(example_file) as f: compose_data = yaml.safe_load(f) # Extract DB credentials from the compose file so the harness stays in sync self._extract_db_credentials(compose_data) # Update the database port compose_data["services"]["database"]["ports"] = [f"{self.db_port}:5432"] # Increase shared memory size compose_data["services"]["database"]["shm_size"] = "4gb" # Configure Postgres for larger workloads cmd = ["postgres"] params = { "max_connections": "800", "max_wal_size": "8GB", "min_wal_size": "2GB", "checkpoint_timeout": "15min", "maintenance_work_mem": "1GB", "work_mem": "64MB", } for k, v in params.items(): cmd.extend(["-c", f"{k}={v}"]) compose_data["services"]["database"]["command"] = cmd # Update the Redis port compose_data["services"]["redis"]["ports"] = [f"{self.redis_port}:6379"] # Add a unique project name to avoid conflicts compose_data["name"] = f"honcho_harness_{self.db_port}" # Configure named volumes for better performance and cleanup compose_data.setdefault("volumes", {}) db_vol_name = f"pgdata_{self.db_port}" redis_vol_name = f"redis_data_{self.db_port}" compose_data["volumes"][db_vol_name] = {} compose_data["volumes"][redis_vol_name] = {} # Update database volumes - use named volume instead of bind mount new_db_volumes: list[str] = [] if "volumes" in compose_data["services"]["database"]: for vol in compose_data["services"]["database"]["volumes"]: # Filter out init.sql (handled by provision script) and existing data mounts if "init.sql" not in vol and ":/var/lib/postgresql/data/" not in vol: new_db_volumes.append(vol) new_db_volumes.append(f"{db_vol_name}:/var/lib/postgresql/data/") compose_data["services"]["database"]["volumes"] = new_db_volumes # Update redis volumes - use named volume new_redis_volumes: list[str] = [] if "volumes" in compose_data["services"]["redis"]: for vol in compose_data["services"]["redis"]["volumes"]: if ":/data" not in vol: new_redis_volumes.append(vol) new_redis_volumes.append(f"{redis_vol_name}:/data") compose_data["services"]["redis"]["volumes"] = new_redis_volumes # Create temporary file self.temp_dir = Path(tempfile.mkdtemp(prefix="honcho_harness_")) self.docker_compose_file = self.temp_dir / "docker-compose.yml" with open(self.docker_compose_file, "w") as f: yaml.dump(compose_data, f, default_flow_style=False) print(f"Created temporary docker-compose.yml at {self.docker_compose_file}") return self.docker_compose_file def backup_env_file(self) -> None: """ Temporarily rename the .env file to prevent it from overriding our environment variables. """ env_file = self.project_root / ".env" if env_file.exists(): backup_name = f".env.backup.{int(time.time())}" self.env_file_backup = env_file.rename(env_file.parent / backup_name) print(f"Temporarily renamed .env to {backup_name}") else: print("No .env file found, using environment variables only") def restore_env_file(self) -> None: """ Restore the .env file from backup. """ if self.env_file_backup: try: self.env_file_backup.rename(self.project_root / ".env") print("Restored .env file") except Exception as e: print(f"Error restoring .env file: {e}") def get_database_env_vars(self) -> dict[str, str]: """ Get environment variables for database configuration, cache configuration, and required API keys. Returns: Dictionary of environment variables for database connection, cache, and API keys """ return { "DB_CONNECTION_URI": self.db_connection_uri, "CACHE_ENABLED": "true", "CACHE_URL": f"redis://localhost:{self.redis_port}/0", } def start_database(self) -> None: """ Start the PostgreSQL database using Docker Compose. """ print(f"Starting PostgreSQL database on port {self.db_port}...") # Ensure clean state by removing any existing containers/volumes # This handles cases where a previous run was interrupted subprocess.run( [ "docker", "compose", "-f", str(self.docker_compose_file), "-p", f"honcho_harness_{self.db_port}", "down", "--volumes", "--remove-orphans", ], cwd=self.temp_dir, capture_output=True, ) # Change to the temp directory and start the database service result = subprocess.run( [ "docker", "compose", "-f", str(self.docker_compose_file), "-p", f"honcho_harness_{self.db_port}", "up", "-d", "database", ], cwd=self.temp_dir, capture_output=True, text=True, ) if result.returncode != 0: print(f"Failed to start database: {result.stderr}") sys.exit(1) print("Database started successfully") def start_redis(self) -> None: """ Start the Redis cache server using Docker Compose. """ print(f"Starting Redis cache server on port {self.redis_port}...") # Change to the temp directory and start the redis service result = subprocess.run( [ "docker", "compose", "-f", str(self.docker_compose_file), "-p", f"honcho_harness_{self.db_port}", "up", "-d", "redis", ], cwd=self.temp_dir, capture_output=True, text=True, ) if result.returncode != 0: print(f"Failed to start Redis: {result.stderr}") sys.exit(1) print("Redis started successfully") def wait_for_redis(self, timeout: int = 30) -> bool: """ Wait for Redis to be ready. Args: timeout: Maximum time to wait in seconds Returns: True if Redis is ready, False otherwise """ print("Waiting for Redis to be ready...") start_time = time.time() redis_port = self.redis_port while time.time() - start_time < timeout: try: import redis # Test Redis connection r = redis.Redis( host="localhost", port=redis_port, decode_responses=True ) r.ping() # pyright: ignore[reportUnknownMemberType] print("Redis is ready!") return True except Exception: pass time.sleep(1) # Check every second print("Redis failed to become ready within timeout") return False async def init_cache(self) -> None: """ Initialize the Redis cache connection. """ try: # Add the project root to the path so we can import Honcho modules sys.path.insert(0, str(self.project_root)) # Set environment variables for cache configuration env = self.get_database_env_vars() for key, value in env.items(): os.environ[key] = value await init_cache() print(f"[Instance {self.instance_id}] Cache initialized successfully") except Exception as e: print(f"[Instance {self.instance_id}] Failed to initialize cache: {e}") async def close_cache(self) -> None: """ Close the Redis cache connection. """ try: # Add the project root to the path so we can import Honcho modules sys.path.insert(0, str(self.project_root)) await close_cache() print(f"[Instance {self.instance_id}] Cache closed successfully") except Exception as e: print(f"[Instance {self.instance_id}] Failed to close cache: {e}") def wait_for_database(self, timeout: int = 60) -> bool: """ Wait for the database to be ready. Args: timeout: Maximum time to wait in seconds Returns: True if database is ready, False otherwise """ print("Waiting for database to be ready...") start_time = time.time() while time.time() - start_time < timeout: try: # Test database connection using pg_isready result = subprocess.run( [ "pg_isready", "-h", "localhost", "-p", str(self.db_port), "-U", self.db_user, ], capture_output=True, text=True, timeout=5, ) if result.returncode == 0: print("Database is ready!") return True except subprocess.TimeoutExpired: pass except FileNotFoundError: # pg_isready not available, try a different approach try: import psycopg conn = psycopg.connect(self.db_connection_uri_plain) conn.close() print("Database is ready!") return True except Exception: pass time.sleep(1) # Faster polling for quicker startup print("Database failed to become ready within timeout") return False def provision_database(self) -> None: """ Provision the database using the provision_db.py script. """ print(f"[Instance {self.instance_id}] Provisioning database...") # Run the provision script with explicit environment variables provision_script = self.project_root / "scripts" / "provision_db.py" env = os.environ.copy() env.update(self.get_database_env_vars()) result = subprocess.run( [sys.executable, str(provision_script)], cwd=self.project_root, capture_output=True, text=True, env=env, ) if result.returncode != 0: print(f"Failed to provision database: {result.stderr}") sys.exit(1) print(f"[Instance {self.instance_id}] Database provisioned successfully") def verify_empty_database(self) -> None: """ Verify that the database is empty with no workspaces and an empty queue. """ try: import psycopg # Connect to the database using instance-specific connection string conn_string = self.db_connection_uri_plain conn = psycopg.connect(conn_string) with conn.cursor() as cursor: # Check the workspaces table cursor.execute("SELECT COUNT(*) FROM workspaces") workspace_result = cursor.fetchone() workspace_count = workspace_result[0] if workspace_result else 0 cursor.execute("SELECT COUNT(*) FROM queue") queue_result = cursor.fetchone() queue_count = queue_result[0] if queue_result else 0 conn.close() # Report results if workspace_count != 0 or queue_count != 0: print( f"[Instance {self.instance_id}] āŒ Database verification failed: Database is not empty" ) print( "This may indicate an issue with the database provisioning or cleanup." ) sys.exit(1) print( f"[Instance {self.instance_id}] Database verification passed: Database is empty" ) except Exception as e: print(f"[Instance {self.instance_id}] āŒ Error verifying database: {e}") print("Unable to verify database state. Continuing anyway...") def start_fastapi_server(self) -> subprocess.Popen[str]: """ Start the FastAPI server. Returns: Process object for the FastAPI server """ print( f"[Instance {self.instance_id}] Starting FastAPI server on port {self.api_port}..." ) # Create environment with instance-specific database connection env = os.environ.copy() env.update(self.get_database_env_vars()) process = subprocess.Popen( [ sys.executable, "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", str(self.api_port), "--no-access-log", "--workers", "1", ], cwd=self.project_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=0, universal_newlines=True, env=env, ) self.processes.append((f"FastAPI [{self.instance_id}]", process)) return process def start_deriver(self) -> subprocess.Popen[str]: """ Start the deriver process with debug logging enabled. Returns: Process object for the deriver """ print(f"[Instance {self.instance_id}] Starting deriver...") # Create environment with instance-specific database connection env = os.environ.copy() env.update(self.get_database_env_vars()) # Enable flush mode for tests - process messages immediately without waiting for batch threshold env["DERIVER_FLUSH_ENABLED"] = "true" process = subprocess.Popen( [sys.executable, "-m", "src.deriver"], cwd=self.project_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=0, universal_newlines=True, env=env, ) self.processes.append((f"[{self.instance_id}]", process)) return process def stream_process_output(self, name: str, process: subprocess.Popen[str]) -> None: """ Stream output from a process to stdout in real-time. Args: name: Name of the process for logging process: The subprocess to monitor """ if not process.stdout: return # Patterns to filter out (noisy logs) filter_patterns = [ "httpx -", # "src.routers.", # "src.crud.", "google_genai.models", "google.genai.models", ] try: for line in iter(process.stdout.readline, ""): if line: line_stripped = line.rstrip() # Skip lines that match filter patterns if any(pattern in line_stripped for pattern in filter_patterns): continue print(f"[{name}] {line_stripped}") except Exception as e: print(f"Error reading from {name}: {e}") finally: if process.stdout: process.stdout.close() def wait_for_fastapi(self, timeout: int = 20) -> bool: """ Wait for the FastAPI server to be ready. Args: timeout: Maximum time to wait in seconds Returns: True if server is ready, False otherwise """ print( f"[Instance {self.instance_id}] Waiting for FastAPI server to be ready..." ) start_time = time.time() while time.time() - start_time < timeout: try: import requests response = requests.get( f"http://localhost:{self.api_port}/docs", timeout=5 ) if response.status_code == 200: print(f"[Instance {self.instance_id}] FastAPI server is ready!") return True except Exception: pass # Check if the process has died and show output for name, process in self.processes: if process.poll() is not None: print(f"āŒ {name} process has died unexpectedly") if process.stdout: # Read all available output output = process.stdout.read() if output: print(f"šŸ“‹ {name} output:") print(output) return False time.sleep(1) # Faster polling for quicker startup print("FastAPI server failed to become ready within timeout") # Show any available output from the FastAPI process for name, process in self.processes: if "FastAPI" in name and process.stdout: # Read any available output output = process.stdout.read() if output: print(f"šŸ“‹ {name} output:") print(output) return False def print_honcho_config(self) -> None: """ Fetch and print the actual configuration that Honcho is using. """ print("\n" + "=" * 60) print("šŸ”§ Honcho Configuration") print("=" * 60) # Create a Python script to import and print the settings config_script = f""" import sys import os from pathlib import Path # Add the project root to the path project_root = Path("{self.project_root}") sys.path.insert(0, str(project_root)) # Environment variables are already set in the parent process # and will be inherited by this subprocess try: from pydantic import BaseModel from src.config import settings SENSITIVE_TOKENS = ('password', 'secret', 'key', 'uri') def _mask(full_key, value): if isinstance(full_key, str) and any(t in full_key.lower() for t in SENSITIVE_TOKENS): return '*' * len(value) if value else 'None' return value def _compact(model): # Render a pydantic BaseModel as `field=value` pairs, skipping # None / empty-dict fields and recursing into nested models. parts = [] for field_name in type(model).model_fields: val = getattr(model, field_name) if val is None: continue if isinstance(val, BaseModel): inner = _compact(val) if inner: parts.append(f"{{field_name}}=({{inner}})") continue if isinstance(val, dict) and not val: continue parts.append(f"{{field_name}}={{val!r}}") return " ".join(parts) def print_settings(obj, prefix="", max_depth=3, current_depth=0): if current_depth >= max_depth: return if not hasattr(obj, '__dict__'): return for key, value in obj.__dict__.items(): if key.startswith('_'): continue full_key = f"{{prefix}}.{{key}}" if prefix else key # dict-of-BaseModel → print each entry on its own line compactly if ( isinstance(value, dict) and value and all(isinstance(v, BaseModel) for v in value.values()) ): print(f"\\nšŸ“‹ {{full_key}}:") for k, v in value.items(): rendered = _compact(v) print(f" {{k}}: {{rendered}}") continue if isinstance(value, BaseModel): print(f"\\nšŸ“‹ {{full_key}}:") rendered = _compact(value) if rendered: print(f" {{rendered}}") continue if hasattr(value, '__dict__') and not isinstance(value, (str, int, float, bool, type(None))): print(f"\\nšŸ“‹ {{full_key}}:") print_settings(value, full_key, max_depth, current_depth + 1) continue print(f" {{key}}: {{_mask(full_key, value)}}") print_settings(settings) except Exception as e: print(f"āŒ Error importing settings: {{e}}") import traceback traceback.print_exc() """ # Write the script to a temporary file if self.temp_dir is None: self.temp_dir = Path(tempfile.mkdtemp(prefix="honcho_harness_")) script_file = self.temp_dir / "print_config.py" with open(script_file, "w") as f: f.write(config_script) # Run the script with instance-specific environment env = os.environ.copy() env.update(self.get_database_env_vars()) result = subprocess.run( [sys.executable, str(script_file)], cwd=self.project_root, capture_output=True, text=True, env=env, ) if result.returncode == 0: print(result.stdout) else: print(f"āŒ Error running config script: {result.stderr}") print("=" * 60) async def cleanup(self) -> None: """ Clean up resources and stop all processes. """ print("\nCleaning up...") # Stop all processes for name, process in self.processes: print(f"Stopping {name}...") try: process.terminate() process.wait(timeout=10) except subprocess.TimeoutExpired: print(f"Force killing {name}...") process.kill() except Exception as e: print(f"Error stopping {name}: {e}") # Wait for output threads to finish for thread in self.output_threads: if thread.is_alive(): thread.join(timeout=2) # Stop database if self.docker_compose_file and self.docker_compose_file.exists(): print("Stopping database...") try: # More aggressive cleanup - remove containers, volumes, and orphaned containers subprocess.run( [ "docker", "compose", "-f", str(self.docker_compose_file), "-p", f"honcho_harness_{self.db_port}", "down", "--volumes", "--remove-orphans", ], cwd=self.temp_dir, capture_output=True, ) # Also try to remove any containers that might still be running subprocess.run( ["docker", "ps", "-q", "--filter", "name=honcho_harness"], capture_output=True, text=True, ) except Exception as e: print(f"Error stopping database: {e}") # Remove temporary files if self.temp_dir and self.temp_dir.exists(): try: import shutil shutil.rmtree(self.temp_dir) print(f"Removed temporary directory {self.temp_dir}") except Exception as e: print(f"Error removing temp directory: {e}") # Close cache try: await self.close_cache() except Exception as e: print(f"Error closing cache: {e}") # Restore .env file self.restore_env_file() async def run(self) -> None: """ Run the complete Honcho harness. """ try: # Backup .env file to prevent it from overriding our environment variables self.backup_env_file() # Copy .env file from tests/bench to the project root for FastAPI shutil.copy( self.project_root / "tests" / "bench" / ".env", self.project_root / ".env", ) # Create temporary docker-compose.yml self.create_temp_docker_compose() # Create an empty .env file in temp directory to satisfy docker-compose # (even though the database service doesn't actually use it) if self.temp_dir and self.temp_dir.exists(): (self.temp_dir / ".env").touch() else: raise Exception("Temporary directory does not exist") # Start database self.start_database() # Start Redis self.start_redis() # Wait for database to be ready if not self.wait_for_database(): print("Database failed to start. Exiting.") sys.exit(1) # Wait for Redis to be ready if not self.wait_for_redis(): print("Redis failed to start. Exiting.") sys.exit(1) # Initialize cache await self.init_cache() # Provision database self.provision_database() # Verify database is empty self.verify_empty_database() # Start FastAPI server _fastapi_process = self.start_fastapi_server() # Wait for FastAPI to be ready if not self.wait_for_fastapi(): print("FastAPI server failed to start. Exiting.") sys.exit(1) # Print the actual configuration Honcho is using self.print_honcho_config() # Start deriver _deriver_process = self.start_deriver() print("\n" + "=" * 60) print(f"šŸŽ‰ Honcho Instance {self.instance_id} is running!") print(f"šŸ“Š Database: localhost:{self.db_port}") print(f"🌐 API Server: http://localhost:{self.api_port}") print(f"šŸ“š API Docs: http://localhost:{self.api_port}/docs") print("šŸ”„ Deriver: Running") print("=" * 60) print("Press Ctrl+C to stop all services") print("=" * 60 + "\n") # Start output streaming threads for each process for name, process in self.processes: thread = threading.Thread( target=self.stream_process_output, args=(name, process), daemon=True ) thread.start() self.output_threads.append(thread) # Monitor processes for unexpected termination while True: # Check if any process has died for name, process in self.processes: if process.poll() is not None: print(f"āŒ {name} has stopped unexpectedly") return time.sleep(1) # Check every second except KeyboardInterrupt: print("\nšŸ›‘ Received interrupt signal") except Exception as e: print(f"āŒ Error: {e}") finally: await self.cleanup() class HonchoHarnessPool: """ Manages a pool of HonchoHarness instances for parallel testing. """ def __init__( self, pool_size: int, base_db_port: int, base_api_port: int, base_redis_port: int, project_root: Path, ) -> None: """ Initialize a pool of Honcho harnesses. Args: pool_size: Number of Honcho instances to create base_db_port: Base port for PostgreSQL databases (each instance gets base + instance_id) base_api_port: Base port for FastAPI servers (each instance gets base + instance_id) base_redis_port: Base port for Redis servers (each instance gets base + instance_id) project_root: Path to the Honcho project root """ self.pool_size: int = pool_size self.base_db_port: int = base_db_port self.base_api_port: int = base_api_port self.base_redis_port: int = base_redis_port self.project_root: Path = project_root self.harnesses: list[HonchoHarness] = [] # Create all harness instances for i in range(pool_size): harness = HonchoHarness( db_port=base_db_port + i, api_port=base_api_port + i, redis_port=base_redis_port + i, project_root=project_root, instance_id=i, ) self.harnesses.append(harness) async def run(self) -> None: """ Run all Honcho harnesses in the pool. """ try: print(f"\n{'=' * 80}") print(f"Starting Honcho Pool with {self.pool_size} instances") print(f"{'=' * 80}\n") # Backup existing .env and copy test .env file # This provides API keys while we override DB settings via environment variables if self.harnesses: self.harnesses[0].backup_env_file() # Copy .env file from tests/bench to get API keys shutil.copy( self.project_root / "tests" / "bench" / ".env", self.project_root / ".env", ) # Remove DB_CONNECTION_URI from .env to ensure env vars take precedence env_file = self.project_root / ".env" if env_file.exists(): with open(env_file) as f: lines = f.readlines() with open(env_file, "w") as f: for line in lines: # Skip DB_CONNECTION_URI lines if not line.strip().startswith("DB_CONNECTION_URI"): f.write(line) # Start all harnesses for harness in self.harnesses: print(f"\n--- Starting Instance {harness.instance_id} ---") # Create temporary docker-compose.yml harness.create_temp_docker_compose() # Create an empty .env file in temp directory if harness.temp_dir and harness.temp_dir.exists(): (harness.temp_dir / ".env").touch() else: raise Exception( f"Temporary directory does not exist for instance {harness.instance_id}" ) # Start database harness.start_database() # Start Redis harness.start_redis() # Wait for database to be ready if not harness.wait_for_database(): print( f"Database failed to start for instance {harness.instance_id}. Exiting." ) sys.exit(1) # Wait for Redis to be ready if not harness.wait_for_redis(): print( f"Redis failed to start for instance {harness.instance_id}. Exiting." ) sys.exit(1) # Initialize cache await harness.init_cache() # Provision database harness.provision_database() # Verify database is empty harness.verify_empty_database() # Start FastAPI server harness.start_fastapi_server() # Wait for FastAPI to be ready if not harness.wait_for_fastapi(): print( f"FastAPI server failed to start for instance {harness.instance_id}. Exiting." ) sys.exit(1) # Start deriver harness.start_deriver() # Start output streaming threads for name, process in harness.processes: thread = threading.Thread( target=harness.stream_process_output, args=(name, process), daemon=True, ) thread.start() harness.output_threads.append(thread) print(f"āœ… Instance {harness.instance_id} is ready!") # Print summary print(f"\n{'=' * 80}") print(f"šŸŽ‰ All {self.pool_size} Honcho instances are running!") print(f"{'=' * 80}") for harness in self.harnesses: print(f"\nInstance {harness.instance_id}:") print(f" šŸ“Š Database: localhost:{harness.db_port}") print(f" 🌐 API Server: http://localhost:{harness.api_port}") print(f" šŸ“š API Docs: http://localhost:{harness.api_port}/docs") print(f"\n{'=' * 80}") print("Press Ctrl+C to stop all services") print(f"{'=' * 80}\n") # Monitor all processes for unexpected termination while True: for harness in self.harnesses: for name, process in harness.processes: if process.poll() is not None: print(f"āŒ {name} has stopped unexpectedly") return time.sleep(1) except KeyboardInterrupt: print("\nšŸ›‘ Received interrupt signal") except Exception as e: print(f"āŒ Error: {e}") finally: await self.cleanup() async def cleanup(self) -> None: """ Clean up all harnesses in the pool. """ print("\nCleaning up pool...") for harness in self.harnesses: print(f"\n--- Cleaning up Instance {harness.instance_id} ---") await harness.cleanup() def main(): """ Main entry point for the Honcho harness. """ parser = argparse.ArgumentParser( description="Run Honcho with a Docker database for development", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s --port 5433 # Run with database on port 5433 %(prog)s --pool-size 4 # Run pool of 4 instances (ports 5433-5436, APIs 8000-8003) %(prog)s --port 5434 --project-root /path/to/honcho # Custom project root """, ) parser.add_argument( "--port", type=int, default=5433, help="Base port for the PostgreSQL database (default: 5433)", ) parser.add_argument( "--api-port", type=int, default=8000, help="Base port for the FastAPI server (default: 8000)", ) parser.add_argument( "--redis-port", type=int, default=6379, help="Base port for the Redis server (default: 6379)", ) parser.add_argument( "--pool-size", type=int, default=1, help="Number of Honcho instances to run in parallel (default: 1)", ) parser.add_argument( "--project-root", type=Path, default=Path.cwd(), help="Path to the Honcho project root (default: current directory)", ) args = parser.parse_args() # Validate pool size if args.pool_size <= 0: print(f"Error: Pool size must be positive, got {args.pool_size}") sys.exit(1) # Validate project root if not (args.project_root / "src" / "main.py").exists(): print( f"Error: {args.project_root} does not appear to be a valid Honcho project root" ) print("Make sure you're running this script from the Honcho project directory") sys.exit(1) # Check for required files required_files = [ "docker-compose.yml.example", "config.toml.example", "scripts/provision_db.py", ] for file_path in required_files: if not (args.project_root / file_path).exists(): print(f"Error: Required file {file_path} not found in {args.project_root}") sys.exit(1) # Create and run the harness or pool try: if args.pool_size > 1: pool = HonchoHarnessPool( pool_size=args.pool_size, base_db_port=args.port, base_api_port=args.api_port, base_redis_port=args.redis_port, project_root=args.project_root, ) asyncio.run(pool.run()) else: harness = HonchoHarness( db_port=args.port, api_port=args.api_port, redis_port=args.redis_port, project_root=args.project_root, instance_id=0, ) asyncio.run(harness.run()) except KeyboardInterrupt: # Cleanup already handled in run() finally block pass if __name__ == "__main__": main()