"""Start and inspect the local state-center integration stack. Usage: python scripts/dev.py start python scripts/dev.py status python scripts/dev.py stop """ from __future__ import annotations import argparse import json import os import signal import socket import subprocess import sys import time from pathlib import Path from urllib.error import URLError from urllib.request import urlopen ROOT = Path(__file__).resolve().parents[1] RUNTIME_DIR = ROOT / ".runtime" PID_FILE = RUNTIME_DIR / "dev-pids.json" MYSQL_CONTAINER = "rpa-center-mysql" MYSQL_VOLUME = "rpa-center-mysql-data" MYSQL_IMAGE = "mysql:8" def build_backend_env(source: dict[str, str] | None = None) -> dict[str, str]: """Return backend environment with safe local defaults.""" env = dict(os.environ if source is None else source) env.setdefault("DEV_MODE", "true") env.setdefault( "MYSQL_DSN", "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4", ) env.setdefault("AGENT_TOKEN", "dev-agent-token") return env def _run(command: list[str], *, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess[str]: return subprocess.run( command, cwd=ROOT, check=check, text=True, capture_output=capture, ) def _command_exists(command: str) -> bool: return subprocess.run( [command, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ).returncode == 0 def _backend_python() -> str: configured = os.getenv("STATE_CENTER_PYTHON") if configured: return configured local_python = ROOT / ".venv_test" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") if local_python.exists(): return str(local_python) return sys.executable def _ensure_backend_runtime() -> str: python = _backend_python() probe = subprocess.run( [python, "-c", "import fastapi, pymysql, uvicorn, cryptography"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, check=False, ) if probe.returncode != 0: raise RuntimeError( f"Backend dependencies are missing for {python}. " f"Install them with: {python} -m pip install -r state_center/requirements.txt" ) return python def _docker_container_state() -> str | None: if not _command_exists("docker"): return None result = _run( ["docker", "inspect", "-f", "{{.State.Status}}", MYSQL_CONTAINER], check=False, capture=True, ) if result.returncode != 0: return None return result.stdout.strip() def ensure_mysql() -> None: if not _command_exists("docker"): raise RuntimeError("Docker is required to start the local MySQL container.") state = _docker_container_state() if state == "running": return if state: _run(["docker", "start", MYSQL_CONTAINER]) return _run( [ "docker", "run", "--detach", "--name", MYSQL_CONTAINER, "--env", "MYSQL_ROOT_PASSWORD=root", "--env", "MYSQL_DATABASE=rpa_center", "--publish", "3306:3306", "--volume", f"{MYSQL_VOLUME}:/var/lib/mysql", "--volume", f"{ROOT / 'state_center' / 'sql' / 'schema.sql'}:/docker-entrypoint-initdb.d/001-schema.sql:ro", MYSQL_IMAGE, ] ) def _port_open(host: str, port: int) -> bool: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(0.5) return sock.connect_ex((host, port)) == 0 def wait_for_port(host: str, port: int, timeout: float = 30) -> None: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if _port_open(host, port): return time.sleep(0.5) raise RuntimeError(f"Timed out waiting for {host}:{port}.") def _spawn(name: str, command: list[str], cwd: Path, env: dict[str, str]) -> int: RUNTIME_DIR.mkdir(exist_ok=True) log_path = RUNTIME_DIR / f"{name}.log" log_file = log_path.open("a", encoding="utf-8") kwargs: dict[str, object] = { "cwd": cwd, "env": env, "stdout": log_file, "stderr": subprocess.STDOUT, "stdin": subprocess.DEVNULL, } if os.name == "nt": kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP else: kwargs["start_new_session"] = True process = subprocess.Popen(command, **kwargs) log_file.close() return process.pid def _read_pids() -> dict[str, int]: if not PID_FILE.exists(): return {} try: return {key: int(value) for key, value in json.loads(PID_FILE.read_text()).items()} except (OSError, ValueError, TypeError): return {} def _write_pids(pids: dict[str, int]) -> None: RUNTIME_DIR.mkdir(exist_ok=True) PID_FILE.write_text(json.dumps(pids, indent=2), encoding="utf-8") def _pid_alive(pid: int) -> bool: if pid <= 0: return False try: os.kill(pid, 0) except OSError: return False return True def _service_alive(pid: int, host: str, port: int) -> bool: return _pid_alive(pid) or _port_open(host, port) def start() -> None: ensure_mysql() wait_for_port("127.0.0.1", 3306) backend_python = _ensure_backend_runtime() pids = _read_pids() backend_env = build_backend_env() if not _service_alive(pids.get("backend", -1), "127.0.0.1", 8000): pids["backend"] = _spawn( "backend", [ backend_python, "-m", "uvicorn", "state_center.app.main:app", "--host", "0.0.0.0", "--port", "8000", ], ROOT, backend_env, ) if not _service_alive(pids.get("frontend", -1), "127.0.0.1", 5173): pids["frontend"] = _spawn( "frontend", ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"], ROOT / "state_center_web", os.environ.copy(), ) _write_pids(pids) wait_for_port("127.0.0.1", 8000) wait_for_port("127.0.0.1", 5173) print("MySQL: 127.0.0.1:3306") print("Backend: http://127.0.0.1:8000") print("Frontend: http://127.0.0.1:5173") def status() -> int: db = _port_open("127.0.0.1", 3306) backend = False backend_detail = "" try: with urlopen("http://127.0.0.1:8000/health", timeout=2) as response: payload = json.loads(response.read().decode("utf-8")) backend = response.status == 200 and payload.get("database") == "ok" backend_detail = json.dumps(payload, ensure_ascii=False) except (OSError, URLError, json.JSONDecodeError) as exc: backend_detail = str(exc) frontend = _port_open("127.0.0.1", 5173) print(f"MySQL 3306: {'UP' if db else 'DOWN'}") print(f"Backend 8000: {'UP' if backend else 'DOWN'} {backend_detail}".rstrip()) print(f"Frontend 5173: {'UP' if frontend else 'DOWN'}") return 0 if db and backend and frontend else 1 def _stop_pid(pid: int) -> None: try: if os.name == "nt": os.kill(pid, signal.SIGTERM) else: os.killpg(os.getpgid(pid), signal.SIGTERM) except OSError: return def stop() -> None: for pid in _read_pids().values(): _stop_pid(pid) if _docker_container_state() == "running": _run(["docker", "stop", MYSQL_CONTAINER], check=False) if PID_FILE.exists(): PID_FILE.unlink() print("Local integration stack stopped.") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("command", choices=("start", "status", "stop")) args = parser.parse_args() try: if args.command == "start": start() return 0 if args.command == "status": return status() stop() return 0 except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: print(f"dev launcher error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())