diff --git a/services/controller/app/run_compat.py b/services/controller/app/run_compat.py index 0c64d33..1b4f452 100644 --- a/services/controller/app/run_compat.py +++ b/services/controller/app/run_compat.py @@ -35,6 +35,7 @@ resolve_run_max_ticks, resolve_run_runtime_limit, ) +from app.run_frontend_ports import apply_frontend_port, reserve_frontend_port from app.run_launcher import RunLaunchConfig, run_launcher from app import run_public_ops from app.run_read_model import build_run_response @@ -101,6 +102,37 @@ async def create_bound_run( ) db.add(db_run) db.flush() + try: + launch_manifest = run_launcher.resolve_environment_launch( + str(normalized_env_config.get("environment_id") or "").strip() or environment_id + ) + frontend_port = reserve_frontend_port( + db, + run_id=db_run.run_id, + environment_has_frontend=bool(launch_manifest.get("frontend_service")), + ) + apply_frontend_port( + run_id=db_run.run_id, + environment_config=normalized_env_config, + snapshot=snapshot, + frontend_port=frontend_port, + ) + except Exception as port_error: + db.rollback() + db_run.status = RunStatusEnum.FAILED.value + db_run.ended_at = datetime.utcnow() + emit_run_terminal_event( + db=db, + run=db_run, + terminal_status=RunStatusEnum.FAILED.value, + terminal_reason=f"Failed to reserve run frontend port: {port_error}", + terminal_source="create_run_frontend_port", + ) + db.commit() + raise HTTPException( + status_code=500, + detail=f"Failed to reserve run frontend port: {port_error}", + ) from port_error run_binding.upsert_run_binding( db, run=db_run, @@ -161,6 +193,7 @@ async def create_bound_run( seed=request.seed, resolved_bundle_hash=resolved_bundle_hash, api_key=effective_api_key, + frontend_port=frontend_port, ) async def mark_run_failed_and_stop( diff --git a/services/controller/app/run_frontend_ports.py b/services/controller/app/run_frontend_ports.py new file mode 100644 index 0000000..3ea5754 --- /dev/null +++ b/services/controller/app/run_frontend_ports.py @@ -0,0 +1,103 @@ +"""Helpers for reserving per-run environment frontend ports.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from app.database import RunBinding as RunBindingDB +from app.run_launcher import RunLauncher + + +def _coerce_port(value: Any) -> int | None: + try: + if value is None: + return None + port = int(value) + except (TypeError, ValueError): + return None + if port <= 0: + return None + return port + + +def extract_frontend_port(*payloads: Any) -> int | None: + """Read a reserved frontend port from run binding payloads.""" + for payload in payloads: + if not isinstance(payload, dict): + continue + launch = payload.get("launch") if isinstance(payload.get("launch"), dict) else {} + for key in ("frontend_port", "environment_frontend_port"): + port = _coerce_port(launch.get(key)) + if port is not None: + return port + frontend_url = str(launch.get("frontend_url") or launch.get("environment_frontend_url") or "").strip() + if frontend_url.startswith("http://localhost:"): + port = _coerce_port(frontend_url.rsplit(":", 1)[-1]) + if port is not None: + return port + return None + + +def has_reserved_frontend(environment_config: dict[str, Any] | None, snapshot: dict[str, Any] | None = None) -> bool: + """Return True when a run binding already carries a frontend reservation.""" + return extract_frontend_port(environment_config or {}, snapshot or {}) is not None + + +def reserve_frontend_port( + db: Session, + *, + run_id: str, + environment_has_frontend: bool, +) -> int | None: + """Reserve a stable frontend port for a run. + + Ports are reserved across all bound runs so stopped runs can still be + restarted later without colliding with newer runs. + """ + if not environment_has_frontend: + return None + + reserved_ports: set[int] = set() + for binding in db.query(RunBindingDB).all(): + if str(binding.run_id) == str(run_id): + continue + port = extract_frontend_port( + binding.environment_config if isinstance(binding.environment_config, dict) else {}, + binding.snapshot if isinstance(binding.snapshot, dict) else {}, + ) + if port is not None: + reserved_ports.add(port) + + preferred_port = RunLauncher.get_environment_frontend_port(run_id) + base = RunLauncher.FRONTEND_PORT_BASE + span = RunLauncher.FRONTEND_PORT_SPAN + start_offset = preferred_port - base + + for step in range(span): + candidate = base + ((start_offset + step) % span) + if candidate not in reserved_ports: + return candidate + + raise ValueError("No available reserved frontend ports remain in the configured span") + + +def apply_frontend_port( + *, + run_id: str, + environment_config: dict[str, Any], + snapshot: dict[str, Any], + frontend_port: int | None, +) -> None: + """Persist a resolved frontend port into environment config and snapshot.""" + if frontend_port is None: + return + + frontend_url = RunLauncher.get_environment_frontend_url(run_id, frontend_port=frontend_port) + + for payload in (environment_config, snapshot): + launch = payload.get("launch") if isinstance(payload.get("launch"), dict) else {} + launch["frontend_port"] = int(frontend_port) + launch["frontend_url"] = frontend_url + payload["launch"] = launch diff --git a/services/controller/app/run_launcher.py b/services/controller/app/run_launcher.py index d1afdcf..6dfb7e5 100644 --- a/services/controller/app/run_launcher.py +++ b/services/controller/app/run_launcher.py @@ -28,6 +28,7 @@ class RunLaunchConfig(BaseModel): seed: Optional[int] = Field(None, description="Random seed") resolved_bundle_hash: Optional[str] = Field(None, description="Bundle hash") api_key: Optional[str] = Field(None, description="LLM API key for agents") + frontend_port: Optional[int] = Field(None, description="Reserved host port for environment frontend") class RunLauncher: @@ -124,9 +125,18 @@ def get_environment_frontend_port(cls, run_id: str, namespace: Optional[str] = N return cls.FRONTEND_PORT_BASE + offset @classmethod - def get_environment_frontend_url(cls, run_id: str, namespace: Optional[str] = None) -> str: + def get_environment_frontend_url( + cls, + run_id: str, + namespace: Optional[str] = None, + frontend_port: Optional[int] = None, + ) -> str: """Get browser-facing frontend URL for a run.""" - return f"http://localhost:{cls.get_environment_frontend_port(run_id, namespace=namespace)}" + resolved_port = int(frontend_port) if frontend_port is not None else cls.get_environment_frontend_port( + run_id, + namespace=namespace, + ) + return f"http://localhost:{resolved_port}" @classmethod def default_environment_image_repository(cls, environment_id: str, asset_kind: str) -> str: @@ -378,6 +388,7 @@ def get_service_urls( self, run_id: str, environment_id: Optional[str] = None, + frontend_port: Optional[int] = None, ) -> Dict[str, str]: """Get service URLs for a run. @@ -406,7 +417,10 @@ def get_service_urls( urls["environment_frontend_internal"] = ( f"http://{frontend_service['container_prefix']}-{run_id}:{frontend_service['port']}" ) - urls["environment_frontend"] = self.get_environment_frontend_url(run_id) + urls["environment_frontend"] = self.get_environment_frontend_url( + run_id, + frontend_port=frontend_port, + ) return urls @@ -431,7 +445,11 @@ def generate_run_override(self, config: RunLaunchConfig, oracle_bundle_paths: Op environment_service = launch_config["environment_service"] agent_service = launch_config["agent_worker_service"] frontend_service = launch_config.get("frontend_service") - frontend_port = self.get_environment_frontend_port(run_id) + frontend_port = ( + int(config.frontend_port) + if config.frontend_port is not None + else self.get_environment_frontend_port(run_id) + ) resolved_llm_provider = ( "dummy" if str(config.api_key or "").strip().lower() == "dummy" @@ -450,7 +468,10 @@ def generate_run_override(self, config: RunLaunchConfig, oracle_bundle_paths: Op "ENV_STATE_KEY": state_key, "ENV_STATE_PATH": state_path, "ENV_FRONTEND_PORT": str(frontend_port), - "ENV_FRONTEND_URL": self.get_environment_frontend_url(config.run_id), + "ENV_FRONTEND_URL": self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), "DATABASE_URL": database_url, "SEED": str(config.seed) if config.seed else "", "RESOLVED_BUNDLE_HASH": config.resolved_bundle_hash or "", @@ -475,7 +496,10 @@ def generate_run_override(self, config: RunLaunchConfig, oracle_bundle_paths: Op state_key=state_key, state_path=state_path, frontend_port=frontend_port, - frontend_url=self.get_environment_frontend_url(config.run_id), + frontend_url=self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), ) ) @@ -690,7 +714,11 @@ def launch_run(self, config: RunLaunchConfig) -> Dict: state_key = f"{config.environment_id}:{config.run_id}" state_path = f"/app/data/state/{config.run_id}" database_url = f"sqlite:////app/data/state/{config.run_id}/environment.db" - frontend_port = self.get_environment_frontend_port(config.run_id) + frontend_port = ( + int(config.frontend_port) + if config.frontend_port is not None + else self.get_environment_frontend_port(config.run_id) + ) resolved_llm_provider = ( "dummy" if str(config.api_key or "").strip().lower() == "dummy" @@ -705,10 +733,16 @@ def launch_run(self, config: RunLaunchConfig) -> Dict: "ENV_STATE_PATH": state_path, "DATABASE_URL": database_url, "ENV_FRONTEND_PORT": str(frontend_port), - "ENV_FRONTEND_URL": self.get_environment_frontend_url(config.run_id), + "ENV_FRONTEND_URL": self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), "ENV_CONTAINER_PREFIX": config.environment_id, "ENVIRONMENT_FRONTEND_PORT": str(frontend_port), - "ENVIRONMENT_FRONTEND_URL": self.get_environment_frontend_url(config.run_id), + "ENVIRONMENT_FRONTEND_URL": self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), "ENVIRONMENT_NAME": config.environment_id, "SEED": str(config.seed) if config.seed else "", "RESOLVED_BUNDLE_HASH": config.resolved_bundle_hash or "", @@ -723,7 +757,10 @@ def launch_run(self, config: RunLaunchConfig) -> Dict: state_key=state_key, state_path=state_path, frontend_port=frontend_port, - frontend_url=self.get_environment_frontend_url(config.run_id), + frontend_url=self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), ) ) diff --git a/services/controller/app/run_read_model.py b/services/controller/app/run_read_model.py index 2b5f5a6..c00f6e1 100644 --- a/services/controller/app/run_read_model.py +++ b/services/controller/app/run_read_model.py @@ -16,6 +16,7 @@ from app.models import Run, RunStatus from app.run_binding import build_run_context from app.run_config import resolve_run_runtime_limit +from app.run_frontend_ports import extract_frontend_port from app.run_launcher import RunLaunchConfig, run_launcher @@ -102,10 +103,15 @@ def resolve_run_service_urls(run: RunDB, db: Session) -> dict[str, str]: context = build_run_context(run, db) environment_id = resolve_environment_id_for_run(run, db) has_frontend = _context_has_frontend(context) + frontend_port = extract_frontend_port( + context.get("environment_config") if isinstance(context.get("environment_config"), dict) else {}, + context.get("snapshot") if isinstance(context.get("snapshot"), dict) else {}, + ) try: return run_launcher.get_service_urls( run_id=run.run_id, environment_id=environment_id, + frontend_port=frontend_port, ) except Exception as exc: logger.warning( @@ -119,7 +125,10 @@ def resolve_run_service_urls(run: RunDB, db: Session) -> dict[str, str]: "agent_worker": settings.get_agent_worker_url(run.run_id, environment_id=environment_id), } if has_frontend: - fallback_urls["environment_frontend"] = run_launcher.get_environment_frontend_url(run.run_id) + fallback_urls["environment_frontend"] = run_launcher.get_environment_frontend_url( + run.run_id, + frontend_port=frontend_port, + ) return fallback_urls @@ -127,6 +136,10 @@ def build_restart_launch_config(run: RunDB, db: Session) -> RunLaunchConfig: context = build_run_context(run, db) environment_id = resolve_environment_id_for_run(run, db) effective_api_key = settings.openrouter_api_key or None + frontend_port = extract_frontend_port( + context.get("environment_config") if isinstance(context.get("environment_config"), dict) else {}, + context.get("snapshot") if isinstance(context.get("snapshot"), dict) else {}, + ) return RunLaunchConfig( run_id=run.run_id, @@ -137,6 +150,7 @@ def build_restart_launch_config(run: RunDB, db: Session) -> RunLaunchConfig: or None ), api_key=effective_api_key, + frontend_port=frontend_port, ) @@ -150,6 +164,10 @@ def build_run_response( context = build_run_context(run, db) runtime_limit_minutes, _ = resolve_run_runtime_limit(context.get("environment_config")) has_frontend = _context_has_frontend(context) + frontend_port = extract_frontend_port( + context.get("environment_config") if isinstance(context.get("environment_config"), dict) else {}, + context.get("snapshot") if isinstance(context.get("snapshot"), dict) else {}, + ) service_urls = resolve_run_service_urls(run, db) environment_id = resolve_environment_id_for_run(run, db) @@ -159,7 +177,10 @@ def build_run_response( ) frontend_url = None if has_frontend: - frontend_url = service_urls.get("environment_frontend") or run_launcher.get_environment_frontend_url(run.run_id) + frontend_url = service_urls.get("environment_frontend") or run_launcher.get_environment_frontend_url( + run.run_id, + frontend_port=frontend_port, + ) return Run( run_id=run.run_id, resolved_bundle_hash=run.resolved_bundle_hash, diff --git a/services/controller/tests/test_run_frontend_ports.py b/services/controller/tests/test_run_frontend_ports.py new file mode 100644 index 0000000..3fe9023 --- /dev/null +++ b/services/controller/tests/test_run_frontend_ports.py @@ -0,0 +1,113 @@ +"""Unit tests for reserved per-run frontend ports.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, Run as RunDB, RunBinding as RunBindingDB +from app.run_frontend_ports import apply_frontend_port, reserve_frontend_port +from app.run_launcher import RunLauncher +from app.run_read_model import build_restart_launch_config + + +@pytest.fixture +def db_session(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine, autocommit=False, autoflush=False)() + try: + yield session + finally: + session.close() + + +@pytest.mark.unit +def test_reserve_frontend_port_skips_reserved_binding_ports( + db_session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + db_session.add_all( + [ + RunBindingDB( + run_id="run-a", + environment_id="hello-world", + environment_config={"launch": {"frontend_port": 18100}}, + snapshot={"launch": {"frontend_port": 18100}}, + ), + RunBindingDB( + run_id="run-b", + environment_id="hello-world", + environment_config={"launch": {"frontend_port": 18101}}, + snapshot={"launch": {"frontend_port": 18101}}, + ), + ] + ) + db_session.commit() + + monkeypatch.setattr(RunLauncher, "get_environment_frontend_port", classmethod(lambda cls, run_id, namespace=None: 18100)) + + reserved = reserve_frontend_port( + db_session, + run_id="run-c", + environment_has_frontend=True, + ) + + assert reserved == 18102 + + +@pytest.mark.unit +def test_apply_frontend_port_persists_launch_metadata() -> None: + environment_config = {"launch": {"environment_id": "hello-world"}} + snapshot = {"launch": {"environment_id": "hello-world"}} + + apply_frontend_port( + run_id="run-123", + environment_config=environment_config, + snapshot=snapshot, + frontend_port=18234, + ) + + assert environment_config["launch"]["frontend_port"] == 18234 + assert snapshot["launch"]["frontend_port"] == 18234 + assert environment_config["launch"]["frontend_url"] == "http://localhost:18234" + assert snapshot["launch"]["frontend_url"] == "http://localhost:18234" + + +@pytest.mark.unit +def test_build_restart_launch_config_uses_reserved_frontend_port(db_session) -> None: + run = RunDB( + run_id="run-front", + resolved_bundle_hash="sha256:bundle", + status="completed", + started_at=datetime(2026, 3, 30, 12, 0, 0, tzinfo=timezone.utc), + ) + db_session.add(run) + db_session.flush() + db_session.add( + RunBindingDB( + run_id=run.run_id, + environment_id="hello-world", + runtime_id="openclaw", + environment_ref="environment/hello-world", + environment_config={ + "environment_id": "hello-world", + "runtime_id": "openclaw", + "launch": {"frontend_port": 18234}, + }, + snapshot={"environment_id": "hello-world", "runtime_id": "openclaw", "launch": {"frontend_port": 18234}}, + ) + ) + db_session.commit() + + config = build_restart_launch_config(run, db_session) + + assert config.frontend_port == 18234