Files

146 lines
4.6 KiB
Python

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"<unable to read startup log: {type(error).__name__}: {error}>"
if not data:
return "<empty startup log>"
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())