"""App Factory - Autonomous multi-agent orchestration framework. Usage: python main.py --prompt "Build a video transcription service" --repo-path /path/to/project python main.py --prompt "Build a REST API" --max-concurrent-tasks 3 --debug python main.py --dry-run --prompt "Test project" """ import argparse import asyncio import logging import os import shutil import signal import subprocess import sys from datetime import datetime, timezone from dotenv import load_dotenv from app_factory.core.logging_utils import LevelColorFormatter # --------------------------------------------------------------------------- # Custom exceptions # --------------------------------------------------------------------------- class AppFactoryError(Exception): """Base exception for App Factory.""" class ClarificationTimeout(AppFactoryError): """A task needs human input but timed out waiting.""" class DockerDaemonError(AppFactoryError): """Docker daemon is not reachable.""" class GitError(AppFactoryError): """Git operation failed.""" class MCPConnectionError(AppFactoryError): """MCP server connection failed.""" class ConfigurationError(AppFactoryError): """Missing or invalid configuration.""" logger = logging.getLogger("app_factory") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def parse_args(argv=None): """Parse CLI arguments.""" parser = argparse.ArgumentParser( description="App Factory - Autonomous multi-agent orchestration framework.", ) parser.add_argument( "--prompt", required=True, help="Project description for the App Factory to build.", ) parser.add_argument( "--repo-path", default=os.getcwd(), help="Target repository path (default: current directory).", ) parser.add_argument( "--max-concurrent-tasks", type=int, default=5, help="Maximum parallel dev agents (default: 5).", ) parser.add_argument( "--debug", action="store_true", help="Enable verbose debug logging.", ) parser.add_argument( "--dry-run", action="store_true", help="Validate configuration without executing.", ) return parser.parse_args(argv) def validate_environment(): """Check required env vars, Docker daemon, and git availability. Returns: dict of validated config values. Raises: DockerDaemonError: If Docker is unreachable. GitError: If git is not available. """ # API key (optional — Claude Code OAuth is also supported) api_key = "".join(os.environ.get("ANTHROPIC_API_KEY", "").split()) auth_token = "".join(os.environ.get("ANTHROPIC_AUTH_TOKEN", "").split()) if api_key: os.environ["ANTHROPIC_API_KEY"] = api_key if auth_token: os.environ["ANTHROPIC_AUTH_TOKEN"] = auth_token if not api_key and not auth_token: logger.warning( "No ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN set; relying on Claude Code OAuth session." ) # Docker try: result = subprocess.run( ["docker", "info"], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: raise DockerDaemonError( "Docker daemon not running. Start Docker Desktop and retry." ) except FileNotFoundError: raise DockerDaemonError( "Docker not found on PATH. Install Docker and retry." ) except subprocess.TimeoutExpired: raise DockerDaemonError( "Docker daemon not responding. Start Docker Desktop and retry." ) # Git if not shutil.which("git"): raise GitError("git not found on PATH. Install git and retry.") return { "api_key": api_key, "auth_token": auth_token, "langsmith_api_key": os.environ.get("LANGSMITH_API_KEY", ""), "langsmith_project": os.environ.get("LANGSMITH_PROJECT", "app-factory"), } # --------------------------------------------------------------------------- # Orchestrator execution # --------------------------------------------------------------------------- async def run_factory(args, config: dict) -> dict: """Main execution flow: initialize all components and run the orchestrator. Returns: Final state dict from the orchestrator. """ from app_factory.agents.dev_agent import DevAgentManager from app_factory.agents.pm_agent import PMAgent from app_factory.agents.qa_agent import QAAgent from app_factory.agents.task_agent import TaskMasterAgent from app_factory.core.architecture_tracker import ArchitectureTracker from app_factory.core.graph import AppFactoryOrchestrator from app_factory.core.observability import ObservabilityManager from app_factory.core.workspace import WorkspaceManager api_key = config.get("api_key") or None auth_token = config.get("auth_token") or None raw_debug_flag = getattr(args, "debug", False) debug_enabled = raw_debug_flag if isinstance(raw_debug_flag, bool) else False # 1. Observability observability = ObservabilityManager( project_name=config.get("langsmith_project", "app-factory"), ) # 2. Workspace workspace_manager = WorkspaceManager(repo_path=args.repo_path) # 3. Architecture tracker arch_tracker = ArchitectureTracker( api_key=api_key, auth_token=auth_token, debug=debug_enabled, observability=observability, ) # 4. Agents pm_agent = PMAgent( api_key=api_key, auth_token=auth_token, debug=debug_enabled, observability=observability, ) task_agent = TaskMasterAgent(project_root=args.repo_path) dev_manager = DevAgentManager( docker_client=workspace_manager.docker_client, max_retries=3, ) qa_agent = QAAgent( repo_path=args.repo_path, api_key=api_key, auth_token=auth_token, debug=debug_enabled, observability=observability, ) # 5. Orchestrator orchestrator = AppFactoryOrchestrator( pm_agent=pm_agent, task_agent=task_agent, dev_manager=dev_manager, qa_agent=qa_agent, workspace_manager=workspace_manager, observability=observability, ) # 6. Set up graceful shutdown GracefulShutdown(workspace_manager=workspace_manager) # 7. Execute result = await orchestrator.run(args.prompt) return result # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- def print_summary(result: dict, start_time: datetime): """Print final execution summary.""" elapsed = datetime.now(timezone.utc) - start_time minutes, seconds = divmod(int(elapsed.total_seconds()), 60) completed = result.get("completed_tasks", []) tasks = result.get("tasks", []) errors = result.get("errors", []) print("\n" + "=" * 60) print("APP FACTORY - Execution Summary") print("=" * 60) print(f" Tasks completed : {len(completed)} / {len(tasks)}") print(f" Execution time : {minutes}m {seconds}s") print(f" Iterations : {result.get('iteration_count', 0)}") if errors: print(f" Errors : {len(errors)}") for err in errors[:5]: print(f" - {err[:120]}") if len(errors) > 5: print(f" ... and {len(errors) - 5} more") langsmith_project = os.environ.get("LANGSMITH_PROJECT", "") if langsmith_project: print(f" LangSmith : project={langsmith_project}") print("=" * 60) # --------------------------------------------------------------------------- # Graceful shutdown # --------------------------------------------------------------------------- class GracefulShutdown: """Signal handler for SIGINT/SIGTERM with two-stage shutdown.""" def __init__(self, workspace_manager=None): self.shutdown_requested = False self.workspace_manager = workspace_manager signal.signal(signal.SIGINT, self._handler) signal.signal(signal.SIGTERM, self._handler) def _handler(self, signum, frame): if self.shutdown_requested: print("\nForce exit.") sys.exit(1) self.shutdown_requested = True print("\nShutting down gracefully... (press Ctrl+C again to force)") if self.workspace_manager: try: loop = asyncio.get_running_loop() loop.create_task(self.workspace_manager.cleanup_all()) except RuntimeError: # No running loop - run synchronously is not possible for async cleanup pass # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main(): """Entry point: load env, parse args, validate, run factory.""" load_dotenv() args = parse_args() # Logging level = logging.DEBUG if args.debug else logging.INFO stream_handler = logging.StreamHandler() stream_handler.setFormatter( LevelColorFormatter( fmt="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", stream=stream_handler.stream, ) ) logging.basicConfig( level=level, handlers=[stream_handler], force=True, ) # Validate or dry-run try: config = validate_environment() except ConfigurationError as e: print(f"Configuration error: {e}", file=sys.stderr) sys.exit(1) except DockerDaemonError as e: print(f"Docker error: {e}", file=sys.stderr) sys.exit(1) except GitError as e: print(f"Git error: {e}", file=sys.stderr) sys.exit(1) if args.dry_run: print("Dry-run: configuration is valid. All checks passed.") return # Run start_time = datetime.now(timezone.utc) try: result = asyncio.run(run_factory(args, config)) print_summary(result, start_time) except ClarificationTimeout as e: print( f"Clarification timeout: {e}. " "Run with --interactive for manual resolution.", file=sys.stderr, ) sys.exit(1) except KeyboardInterrupt: print("\nInterrupted.", file=sys.stderr) sys.exit(130) except Exception as e: logger.exception("Fatal error") print(f"Fatal error: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()