from __future__ import annotations import os import shutil import socket import subprocess import sys import time from datetime import datetime, timezone from pathlib import Path from urllib.error import HTTPError, URLError from urllib.request import urlopen SCRIPT_DIR = Path(__file__).resolve().parent REPO_ROOT = SCRIPT_DIR.parents[1] def main() -> int: run_id = os.environ.get("STUDENT_LOOP_RUN_ID") or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") port = int(os.environ.get("PORT") or find_free_port()) run_dir = REPO_ROOT / ".app" / "student-loop" / "runs" / run_id base_url = f"http://127.0.0.1:{port}" env = { **os.environ, "AI_PROVIDER": "", "AI_MODEL": "", "AI_API_KEY": "", "AI_BASE_URL": "", "LLM_PROVIDER": "", "LLM_MODEL": "", "LLM_API_KEY": "", "LLM_RESPONSES_ENDPOINT": "", "PORT": str(port), "BASE_URL": base_url, "STUDENT_LOOP_RUN_ID": run_id, "STUDENT_LOOP_EVIDENCE_MODE": "local_only", "APP_DATA_DIR": str(run_dir / "data"), "PROGRESS_DB_PATH": str(run_dir / "data" / "progress.db"), "STUDENT_LOOP_ARTIFACT_DIR": str(run_dir / "artifacts"), } run_dir.mkdir(parents=True, exist_ok=True) artifact_dir = run_dir / "artifacts" artifact_dir.mkdir(parents=True, exist_ok=True) startup_log_path = artifact_dir / "server-startup.log" with startup_log_path.open("wb") as startup_log: server = subprocess.Popen( [npm_executable(), "start"], cwd=REPO_ROOT, env=env, stdout=startup_log, stderr=subprocess.STDOUT, ) try: wait_for_http_ready(base_url, server, timeout_seconds=45, startup_log_path=startup_log_path) return subprocess.call([sys.executable, str(SCRIPT_DIR / "student_loop.py")], cwd=REPO_ROOT, env=env) finally: stop_process(server) def find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) def npm_executable() -> str: command = shutil.which("npm") or shutil.which("npm.cmd") if not command: raise FileNotFoundError("npm executable was not found on PATH") return command def wait_for_http_ready( base_url: str, server: subprocess.Popen[bytes], *, timeout_seconds: int, startup_log_path: Path, ) -> None: deadline = time.time() + timeout_seconds last_error = "" while time.time() < deadline: if server.poll() is not None: startup_log_tail = tail_file_text(startup_log_path) raise RuntimeError( "server exited before HTTP readiness check completed: " f"{server.returncode}; startup log: {startup_log_path}; tail: {startup_log_tail}" ) try: with urlopen(base_url, timeout=1.0) as response: if 200 <= int(response.status) < 500: return except HTTPError as error: last_error = f"HTTP {error.code}" if 400 <= int(error.code) < 500: return except (OSError, URLError) as error: last_error = f"{type(error).__name__}: {error}" time.sleep(0.25) suffix = f": {last_error}" if last_error else "" startup_log_tail = tail_file_text(startup_log_path) raise TimeoutError( f"server did not become HTTP-ready at {base_url} within {timeout_seconds}s{suffix}; " f"startup log: {startup_log_path}; tail: {startup_log_tail}" ) def tail_file_text(path: Path, *, limit: int = 4000) -> str: try: data = path.read_bytes() except OSError as error: return f"" if not data: return "" return data[-limit:].decode("utf-8", errors="replace").replace("\r", "") def stop_process(process: subprocess.Popen[bytes]) -> None: if process.poll() is not None: return if os.name == "nt": subprocess.run( ["taskkill", "/PID", str(process.pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) try: process.wait(timeout=10) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=10) return process.terminate() try: process.wait(timeout=10) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=10) if __name__ == "__main__": raise SystemExit(main())