diff --git a/run b/run new file mode 120000 index 0000000..5e69507 --- /dev/null +++ b/run @@ -0,0 +1 @@ +scripts/run/script_runner.py \ No newline at end of file diff --git a/scripts/run/__init__.py b/scripts/run/__init__.py new file mode 100644 index 0000000..b1f6269 --- /dev/null +++ b/scripts/run/__init__.py @@ -0,0 +1 @@ +"""@model-boss script runner package.""" diff --git a/scripts/run/dev_command.py b/scripts/run/dev_command.py new file mode 100644 index 0000000..a149a0d --- /dev/null +++ b/scripts/run/dev_command.py @@ -0,0 +1,127 @@ +"""Dev command handler for @model-boss script runner. + +Starts development servers using configuration from infrastructure/ports.yaml. +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +from service_config import get_service_config, list_services + + +def dev_command(args: list[str], workspace_root: Path) -> int: + """Start development servers. + + Args: + args: Command-line arguments + workspace_root: Path to workspace root + + Returns: + Exit code (0 = success, non-zero = failure) + """ + services = list_services("dev") + + parser = argparse.ArgumentParser( + prog="./run dev", + description="Start development servers", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + ./run dev coordinator # Start GPU coordinator + ./run dev llama-http # Start LLM backend + ./run dev --list # List all available services + +Available services: + coordinator GPU/model coordination (port 8210) + llama-http LLM backend with llama.cpp (port 10010) + +Note: Services run in foreground. Use Ctrl+C to stop. + """, + ) + + parser.add_argument( + "service", + nargs="?", + choices=services, + help="Service to start", + ) + + parser.add_argument( + "--list", + action="store_true", + help="List available services", + ) + + parsed = parser.parse_args(args) + + # List services + if parsed.list: + print("Development services:\n") + for svc_id in services: + cfg = get_service_config(svc_id, "dev") + print(f" {svc_id:15} port {cfg['port']}") + return 0 + + # Require service argument + if not parsed.service: + parser.print_help() + return 1 + + cfg = get_service_config(parsed.service, "dev") + service_dir = workspace_root / cfg["dir"] + + if not service_dir.exists(): + print(f"Error: Service directory not found: {service_dir}", file=sys.stderr) + return 1 + + print(f"Starting {parsed.service} on port {cfg['port']}...") + print(f"Directory: {service_dir}") + print() + + # Check for venv + venv_path = service_dir / ".venv" + if not venv_path.exists(): + print("Warning: No .venv found. You may need to create one:") + print(f" cd {service_dir}") + print(" python -m venv .venv") + print(" source .venv/bin/activate") + print(" pip install -e .") + print() + + # Use python -m to run the service module + cmd = ["python", "-m", cfg["module"]] + + # For Python services, activate venv + activate_script = venv_path / "bin" / "activate" + if activate_script.exists(): + full_cmd = f"source {activate_script} && {' '.join(cmd)}" + cmd = ["bash", "-c", full_cmd] + + print(f"Command: python -m {cfg['module']}") + print() + print("Press Ctrl+C to stop") + print("-" * 50) + print() + + # Run service + try: + result = subprocess.run(cmd, cwd=service_dir, check=False) + return result.returncode + except KeyboardInterrupt: + print("\n\nStopped by user") + return 0 + + +def register_dev_command(runner): + """Register the dev command with the script runner. + + Args: + runner: ScriptRunner instance + """ + runner.register_command( + "dev", + dev_command, + "Start development servers", + ) diff --git a/scripts/run/prod_command.py b/scripts/run/prod_command.py new file mode 100644 index 0000000..9e927ea --- /dev/null +++ b/scripts/run/prod_command.py @@ -0,0 +1,129 @@ +"""Production server command for @model-boss. + +Starts production servers using configuration from infrastructure/ports.production.yaml. +""" + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +from service_config import get_service_config, list_services + + +def prod_command(args: list[str], workspace_root: Path) -> int: + """Start production servers. + + Args: + args: Command-line arguments + workspace_root: Path to workspace root + + Returns: + Exit code (0 = success, non-zero = failure) + """ + services = list_services("prod") + + parser = argparse.ArgumentParser( + prog="./run prod", + description="Start production servers", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + ./run prod coordinator # Start coordinator on prod port + ./run prod llama-http # Start LLM backend on prod port + ./run prod --list # List prod services with settings + +Note: Production ports are dev + 10000 (e.g., 8210 -> 18210). +This allows running dev and prod simultaneously. + """, + ) + + parser.add_argument( + "service", + nargs="?", + choices=services, + help="Service to start", + ) + + parser.add_argument( + "--list", + action="store_true", + help="List available services with production settings", + ) + + parser.add_argument( + "--workers", + type=int, + help="Override worker count", + ) + + parser.add_argument( + "--log-level", + help="Override log level", + ) + + parsed = parser.parse_args(args) + + if parsed.list: + print("Production services:\n") + for svc_id in services: + cfg = get_service_config(svc_id, "prod") + timeout = cfg["timeout_keep_alive"] + print( + f" {svc_id:15} port {cfg['port']:5} " + f"workers={cfg['workers']} timeout={timeout}s" + ) + return 0 + + if not parsed.service: + parser.print_help() + return 1 + + cfg = get_service_config(parsed.service, "prod") + service_dir = workspace_root / cfg["dir"] + + if not service_dir.exists(): + print(f"Error: {service_dir} not found", file=sys.stderr) + return 1 + + # Set environment variables for production port + env = os.environ.copy() + if parsed.service == "coordinator": + env["MODEL_BOSS_PORT"] = str(cfg["port"]) + elif parsed.service == "llama-http": + env["LLAMA_HTTP_PORT"] = str(cfg["port"]) + + # Use python -m to run the service module + cmd = ["python", "-m", cfg["module"]] + + # For Python services, activate venv + venv = service_dir / ".venv/bin/activate" + if venv.exists(): + # Pass environment variables through bash + env_exports = " ".join(f"{k}={v}" for k, v in env.items() if k.startswith(("MODEL_BOSS_", "LLAMA_HTTP_"))) + full_cmd = f"source {venv} && {env_exports} {' '.join(cmd)}" + cmd = ["bash", "-c", full_cmd] + + print(f"Starting {parsed.service} (production) on port {cfg['port']}") + print(f"Workers: {parsed.workers or cfg['workers']}") + print(f"Timeout: {cfg['timeout_keep_alive']}s") + print() + + try: + return subprocess.run(cmd, cwd=service_dir, env=env).returncode + except KeyboardInterrupt: + return 0 + + +def register_prod_command(runner): + """Register the prod command with the script runner. + + Args: + runner: ScriptRunner instance + """ + runner.register_command( + "prod", + prod_command, + "Start production servers", + ) diff --git a/scripts/run/script_runner.py b/scripts/run/script_runner.py new file mode 100755 index 0000000..6e5c589 --- /dev/null +++ b/scripts/run/script_runner.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +@model-boss Workspace Script Runner + +Unified command runner for the @model-boss workspace. + +Usage: + ./run dev # Start development server + ./run prod # Start production server + ./run --help # Show all available commands + ./run --help # Show command-specific help +""" + +import argparse +import sys +from pathlib import Path + + +class ScriptRunner: + """Main script runner that dispatches to command handlers.""" + + def __init__(self): + # Get the actual script location (resolves symlinks) + script_path = Path(__file__).resolve() + # workspace_root is 3 levels up from script_runner.py + # script_runner.py -> run/ -> scripts/ -> workspace/ + self.workspace_root = script_path.parent.parent.parent + self.commands = {} + + def register_command(self, name: str, handler, help_text: str): + """Register a command handler.""" + self.commands[name] = { + "handler": handler, + "help": help_text, + } + + def run(self, args=None): + """Parse arguments and dispatch to command handler.""" + parser = argparse.ArgumentParser( + prog="./run", + description="@model-boss workspace script runner", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=self._build_command_list(), + ) + + parser.add_argument( + "command", + choices=list(self.commands.keys()), + help="Command to run", + ) + + # Parse just the command first + if args is None: + args = sys.argv[1:] + + if not args or args[0] in ["-h", "--help"]: + parser.print_help() + return 0 + + command_name = args[0] + command_args = args[1:] + + if command_name not in self.commands: + parser.print_help() + return 1 + + # Dispatch to command handler + command = self.commands[command_name] + try: + return command["handler"](command_args, self.workspace_root) + except KeyboardInterrupt: + print("\n\nInterrupted by user") + return 130 + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + return 1 + + def _build_command_list(self): + """Build formatted command list for help text.""" + if not self.commands: + return "" + + lines = ["\nAvailable commands:"] + max_len = max(len(name) for name in self.commands.keys()) + + for name, cmd in sorted(self.commands.items()): + padding = " " * (max_len - len(name)) + lines.append(f" {name}{padding} {cmd['help']}") + + return "\n".join(lines) + + +def load_command(command_path: Path): + """Dynamically load a command module.""" + import importlib.util + spec = importlib.util.spec_from_file_location(command_path.stem, command_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main(): + """Main entry point.""" + runner = ScriptRunner() + + # Resolve the actual file location (handles symlinks) + script_path = Path(__file__).resolve() + + # Register all commands + commands = [ + ("dev_command.py", "register_dev_command"), + ("prod_command.py", "register_prod_command"), + ] + + for cmd_file, register_func in commands: + cmd_path = script_path.parent / cmd_file + if cmd_path.exists(): + cmd_module = load_command(cmd_path) + getattr(cmd_module, register_func)(runner) + + # Run + sys.exit(runner.run()) + + +if __name__ == "__main__": + main() diff --git a/scripts/run/service_config.py b/scripts/run/service_config.py new file mode 100644 index 0000000..e7cc601 --- /dev/null +++ b/scripts/run/service_config.py @@ -0,0 +1,87 @@ +"""Service configuration loader for @model-boss. + +Loads from infrastructure/ports.yaml (service-registry compatible). +""" +from pathlib import Path +from typing import TypedDict + +import yaml + + +# Service directory mapping +SERVICE_DIRS: dict[str, str] = { + "coordinator": "services/coordinator/service", + "llama-http": "services/llama-http/service", +} + +# App entrypoints (uvicorn app paths) - using Python module paths +SERVICE_APPS: dict[str, str] = { + "coordinator": "model_boss_coordinator:create_app", + "llama-http": "llama_http:create_app", +} + +# Service module names for -m execution +SERVICE_MODULES: dict[str, str] = { + "coordinator": "model_boss_coordinator", + "llama-http": "llama_http", +} + + +class PortsConfig(TypedDict, total=False): + """Type for ports configuration.""" + + model_boss: dict[str, int] + runtime: dict + + +def load_ports(config_file: str = "ports.yaml") -> PortsConfig: + """Load ports from infrastructure config.""" + config_path = Path(__file__).parent.parent.parent / "infrastructure" / config_file + if not config_path.exists(): + raise FileNotFoundError(f"Config not found: {config_path}") + + with open(config_path) as f: + return yaml.safe_load(f) + + +def get_service_config(service_id: str, environment: str = "dev") -> dict: + """Get full service configuration.""" + config_file = "ports.production.yaml" if environment == "prod" else "ports.yaml" + ports_config = load_ports(config_file) + + # Handle both "model-boss" and "model_boss" keys in YAML + port_section = ports_config.get("model-boss") or ports_config.get("model_boss", {}) + port = port_section.get(service_id) + if port is None: + raise ValueError(f"Unknown service: {service_id}") + + runtime = ports_config.get("runtime", {}) + + return { + "id": service_id, + "port": port, + "dir": SERVICE_DIRS.get(service_id, f"services/{service_id}/service"), + "app": SERVICE_APPS.get(service_id), + "module": SERVICE_MODULES.get(service_id), + "type": "python", + "reload": runtime.get("reload", environment == "dev"), + "log_level": runtime.get("log_level", "info"), + "workers": runtime.get("workers", {}).get(service_id, 1), + "timeout_keep_alive": runtime.get("timeout_keep_alive", {}).get( + service_id, runtime.get("timeout_keep_alive", {}).get("default", 30) + ), + } + + +def list_services(environment: str = "dev") -> list[str]: + """List available service IDs.""" + config_file = "ports.production.yaml" if environment == "prod" else "ports.yaml" + ports_config = load_ports(config_file) + # Handle both key formats + port_section = ports_config.get("model-boss") or ports_config.get("model_boss", {}) + # Filter to only services that have directories defined + return [ + svc_id + for svc_id in port_section.keys() + if svc_id in SERVICE_DIRS + ]