mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 19:52:23 +02:00
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q1TVSLNNsXAoyM7sJuVKP
112 lines
4.3 KiB
Python
112 lines
4.3 KiB
Python
"""Boot the OpenSwarm backend inside the container and wait until it answers.
|
|
|
|
Spawned the same way the desktop shell spawns it (`python -m uvicorn backend.main:app`
|
|
on loopback) so the cloud path and the laptop path are the same code on the same
|
|
socket. Loopback, not 0.0.0.0: every caller of this API lives in this container, and
|
|
the agent running inside it has Bash, so there is no reason to publish the port onto
|
|
the machine's private network.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import time
|
|
from typing import Dict, List, Optional
|
|
|
|
import httpx
|
|
from pydantic import BaseModel, ConfigDict, InstanceOf
|
|
from typeguard import typechecked
|
|
|
|
HOST = "127.0.0.1"
|
|
HEALTH_PATH = "/api/health/check"
|
|
# 9Router is a Next.js standalone server: it binds `process.env.HOSTNAME || '0.0.0.0'`, and Docker sets HOSTNAME to the container id, so left alone it listens on the container's eth0 address and every probe of 127.0.0.1:20128 gets ECONNREFUSED. Set on the backend's env because the backend is what spawns node.
|
|
ROUTER_BIND_HOSTNAME = "127.0.0.1"
|
|
AUTH_TOKEN_FILENAME = "auth.token"
|
|
# The backend imports the whole app graph before it binds; on a cold Fly machine that has been measured in tens of seconds, so the budget is generous rather than tight.
|
|
BOOT_TIMEOUT_SECONDS = 120.0
|
|
SHUTDOWN_GRACE_SECONDS = 10.0
|
|
|
|
|
|
class BackendUnavailable(RuntimeError):
|
|
"""The backend never came up, or died while we were using it."""
|
|
|
|
|
|
class BackendProcess(BaseModel):
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
process: InstanceOf[subprocess.Popen]
|
|
base_url: str
|
|
token: str
|
|
|
|
@typechecked
|
|
def headers(self) -> Dict[str, str]:
|
|
return {"Authorization": f"Bearer {self.token}"}
|
|
|
|
@typechecked
|
|
def is_alive(self) -> bool:
|
|
return self.process.poll() is None
|
|
|
|
|
|
@typechecked
|
|
def p_command(port: int) -> List[str]:
|
|
return ["python3", "-m", "uvicorn", "backend.main:app", "--host", HOST, "--port", str(port)]
|
|
|
|
|
|
@typechecked
|
|
def p_read_auth_token(data_root: str) -> str:
|
|
"""The backend mints this before it binds, so by the time health passes the file exists."""
|
|
path = os.path.join(data_root, AUTH_TOKEN_FILENAME)
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
return handle.read().strip()
|
|
except OSError as exc:
|
|
raise BackendUnavailable(f"backend is up but its auth token is unreadable at {path}: {exc}") from exc
|
|
|
|
|
|
@typechecked
|
|
def start_backend(app_root: str, data_root: str, port: int, deadline: float) -> BackendProcess:
|
|
"""Spawn the backend and block until it answers health, or raise BackendUnavailable."""
|
|
environment = dict(os.environ)
|
|
environment["OPENSWARM_DATA_ROOT"] = data_root
|
|
environment["OPENSWARM_HEADLESS"] = "1"
|
|
environment["OPENSWARM_PORT"] = str(port)
|
|
environment["OPENSWARM_HOST"] = HOST
|
|
environment["HOSTNAME"] = ROUTER_BIND_HOSTNAME
|
|
environment["PYTHONPATH"] = app_root
|
|
|
|
process = subprocess.Popen(p_command(port), cwd=app_root, env=environment)
|
|
base_url = f"http://{HOST}:{port}"
|
|
budget = min(time.monotonic() + BOOT_TIMEOUT_SECONDS, deadline)
|
|
|
|
with httpx.Client(timeout=2.0) as client:
|
|
while time.monotonic() < budget:
|
|
if process.poll() is not None:
|
|
raise BackendUnavailable(f"backend exited during startup with code {process.returncode}")
|
|
try:
|
|
healthy = client.get(f"{base_url}{HEALTH_PATH}").status_code == 200
|
|
except httpx.HTTPError:
|
|
healthy = False
|
|
if healthy:
|
|
try:
|
|
token = p_read_auth_token(data_root)
|
|
except BackendUnavailable:
|
|
stop_backend(process)
|
|
raise
|
|
return BackendProcess(process=process, base_url=base_url, token=token)
|
|
time.sleep(0.25)
|
|
|
|
stop_backend(process)
|
|
raise BackendUnavailable(f"backend did not answer {HEALTH_PATH} within its startup budget")
|
|
|
|
|
|
@typechecked
|
|
def stop_backend(process: Optional[subprocess.Popen]) -> None:
|
|
"""SIGTERM then SIGKILL. The machine is about to die anyway; this just stops the logs mid-sentence."""
|
|
if process is None or process.poll() is not None:
|
|
return
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=SHUTDOWN_GRACE_SECONDS)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=SHUTDOWN_GRACE_SECONDS)
|