from __future__ import annotations import os import re import secrets from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import Annotated, Any, Final from fastapi import APIRouter, Header, HTTPException, Query from fastapi import Path as PathParameter from pydantic import BaseModel, ConfigDict, Field from k1link.simulation import ( QualificationRun, QualificationRunIntegrityError, QualificationRunNotFoundError, QualificationRunStore, ) from k1link.simulation.worker_gateway import ( STATUS_SCHEMA, PolygonWorkerGateway, SimulationWorkerGatewayError, SimulationWorkerRejectedError, SimulationWorkerUnavailableError, UnixSocketWorkerGateway, ) POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT" POLYGON_WORKER_SOCKET_ENV: Final = "MISSIONCORE_POLYGON_WORKER_SOCKET" POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL" MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT" CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1" DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1" COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$") READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = ( "Архивный UI-0 публикует только квалификационные доказательства; " "содержимое команд и mutation-маршруты в архиве отсутствуют.", "Артефакты представлены относительными метаданными; их содержимое не публикуется этим API.", "Терминальное состояние прогона не означает приёмку навигации, " "восприятия или физической безопасности.", ) RootProvider = Callable[[], Path | None] WorkerProvider = Callable[[], PolygonWorkerGateway | None] ControlProvider = Callable[[], bool] CommitProvider = Callable[[], str | None] class StartStockRoverRequest(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) scenario_id: str class AckermannCommandRequest(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) speed_mps: float = Field(ge=-1.5, le=1.5, allow_inf_nan=False) steering_normalized: float = Field(ge=-1.0, le=1.0, allow_inf_nan=False) def configured_polygon_runs_root() -> Path | None: raw = os.environ.get(POLYGON_RUNS_ROOT_ENV) if raw is None or not raw.strip(): return None return Path(raw.strip()).expanduser() def configured_polygon_worker() -> PolygonWorkerGateway | None: raw = os.environ.get(POLYGON_WORKER_SOCKET_ENV) if raw is None or not raw.strip(): return None return UnixSocketWorkerGateway(Path(raw.strip()).expanduser()) def configured_polygon_control() -> bool: return os.environ.get(POLYGON_WORKER_CONTROL_ENV, "").strip() == "internal-virtual-only" def configured_mission_core_commit() -> str | None: raw = os.environ.get(MISSION_CORE_COMMIT_ENV) if raw is None: return None normalized = raw.strip() return normalized if COMMIT_PATTERN.fullmatch(normalized) else None def build_polygon_router( *, root_provider: RootProvider = configured_polygon_runs_root, worker_provider: WorkerProvider = configured_polygon_worker, control_provider: ControlProvider = configured_polygon_control, commit_provider: CommitProvider = configured_mission_core_commit, ) -> APIRouter: router = APIRouter(prefix="/api/v1/polygon", tags=["polygon"]) @router.get("/runs") def list_polygon_runs( limit: Annotated[int, Query(ge=1, le=100)] = 20, ) -> dict[str, Any]: store = _open_read_only_store(root_provider) try: ordered = sorted( store.list_runs(), key=lambda run: (run.created_at_utc, run.run_id), reverse=True, ) except QualificationRunIntegrityError as exc: raise HTTPException( status_code=500, detail="Журнал прогонов Полигона не прошёл проверку целостности.", ) from exc return { "schema_version": CATALOG_SCHEMA, "access": "read-only", "items": [_run_summary(run) for run in ordered[:limit]], "total": len(ordered), "limitations": list(READ_ONLY_LIMITATIONS), } @router.get("/runs/{run_id}") def get_polygon_run( run_id: Annotated[ str, PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"), ], event_limit: Annotated[int, Query(ge=1, le=500)] = 200, ) -> dict[str, Any]: store = _open_read_only_store(root_provider) try: run = store.load(run_id) events = store.list_events(run_id) commands = store.list_commands(run_id) except QualificationRunNotFoundError as exc: raise HTTPException(status_code=404, detail="Прогон Полигона не найден.") from exc except QualificationRunIntegrityError as exc: raise HTTPException( status_code=500, detail="Доказательства прогона не прошли проверку целостности.", ) from exc visible_events = events[-event_limit:] return { "schema_version": DETAIL_SCHEMA, "access": "read-only", "run": { **_run_summary(run), "scenario_sha256": run.scenario_sha256, "profile_sha256": run.profile_sha256, "host_profile_sha256": run.host_profile_sha256, "seed": run.seed, "parent_run_id": run.parent_run_id, "providers": [provider.to_dict() for provider in run.providers], "authority": run.authority.to_dict(), }, "events": [event.to_dict() for event in visible_events], "events_total": len(events), "events_truncated": len(visible_events) != len(events), "commands": { "count": len(commands), "content_exposed": False, }, "artifacts": [artifact.to_dict() for artifact in run.artifacts], "limitations": list(READ_ONLY_LIMITATIONS), } @router.get("/worker") def get_polygon_worker() -> dict[str, Any]: gateway = worker_provider() if gateway is None: return _unavailable_worker_status() try: status = gateway.status() except SimulationWorkerGatewayError: return _unavailable_worker_status() return { **status, "control_available": bool(status["control_available"] and control_provider()), } @router.get("/worker/live") def get_polygon_worker_live() -> dict[str, Any]: gateway = _required_worker(worker_provider) try: return gateway.live() except SimulationWorkerUnavailableError as exc: raise HTTPException( status_code=503, detail="Simulation Worker недоступен.", ) from exc except SimulationWorkerRejectedError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except SimulationWorkerGatewayError as exc: raise HTTPException( status_code=502, detail="Simulation Worker вернул некорректное состояние.", ) from exc @router.post("/worker/runs") def start_polygon_worker_run( request: StartStockRoverRequest, idempotency_key: Annotated[ str, Header(alias="Idempotency-Key", min_length=1, max_length=160), ], ) -> dict[str, Any]: if request.scenario_id != "stock-rover-ackermann": raise HTTPException(status_code=422, detail="Сценарий Полигона не поддерживается.") gateway, commit = _control_context( worker_provider, control_provider, commit_provider, ) run_id = _new_run_id(commit) try: return gateway.start( run_id=run_id, mission_core_commit=commit, idempotency_key=idempotency_key, ) except SimulationWorkerUnavailableError as exc: raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc except SimulationWorkerRejectedError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except SimulationWorkerGatewayError as exc: raise HTTPException( status_code=502, detail="Simulation Worker не подтвердил запуск.", ) from exc @router.post("/worker/runs/{run_id}/stop") def stop_polygon_worker_run( run_id: Annotated[ str, PathParameter(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$"), ], idempotency_key: Annotated[ str, Header(alias="Idempotency-Key", min_length=1, max_length=160), ], ) -> dict[str, Any]: gateway, _ = _control_context( worker_provider, control_provider, commit_provider, ) try: return gateway.stop(run_id=run_id, idempotency_key=idempotency_key) except SimulationWorkerUnavailableError as exc: raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc except SimulationWorkerRejectedError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except SimulationWorkerGatewayError as exc: raise HTTPException( status_code=502, detail="Simulation Worker не подтвердил остановку.", ) from exc @router.post("/worker/runs/{run_id}/commands") def command_polygon_worker_run( run_id: Annotated[ str, PathParameter(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$"), ], request: AckermannCommandRequest, idempotency_key: Annotated[ str, Header(alias="Idempotency-Key", min_length=1, max_length=160), ], ) -> dict[str, Any]: gateway, _ = _control_context( worker_provider, control_provider, commit_provider, ) try: return gateway.command( run_id=run_id, speed_mps=request.speed_mps, steering_normalized=request.steering_normalized, idempotency_key=idempotency_key, ) except SimulationWorkerUnavailableError as exc: raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc except SimulationWorkerRejectedError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except SimulationWorkerGatewayError as exc: raise HTTPException( status_code=502, detail="Simulation Worker не подтвердил команду ровера.", ) from exc return router def _open_read_only_store(root_provider: RootProvider) -> QualificationRunStore: root = root_provider() if root is None: raise HTTPException( status_code=503, detail="Источник журналов Полигона не настроен на этом экземпляре Mission Core.", ) if not root.is_absolute(): raise HTTPException( status_code=503, detail="Источник журналов Полигона настроен некорректно.", ) try: return QualificationRunStore(root, read_only=True) except QualificationRunIntegrityError as exc: raise HTTPException( status_code=503, detail="Источник журналов Полигона недоступен или настроен некорректно.", ) from exc def _run_summary(run: QualificationRun) -> dict[str, Any]: return { "run_id": run.run_id, "episode_id": run.episode_id, "kind": run.kind.value, "state": run.state.value, "created_at_utc": run.created_at_utc, "started_at_utc": run.started_at_utc, "ended_at_utc": run.ended_at_utc, "terminal_reason": run.terminal_reason, "scenario_generation": run.scenario_generation, "profile_generation": run.profile_generation, "mission_core_commit": run.mission_core_commit, "host_profile_id": run.host_profile_id, "reproducibility_tier": run.reproducibility_tier.value, "clock_domain": run.clock_domain, "revision": run.revision, "provider_ids": [provider.identifier for provider in run.providers], "artifact_count": len(run.artifacts), } def _required_worker(provider: WorkerProvider) -> PolygonWorkerGateway: gateway = provider() if gateway is None: raise HTTPException( status_code=503, detail="Simulation Worker не зарегистрирован на этом экземпляре Mission Core.", ) return gateway def _control_context( worker_provider: WorkerProvider, control_provider: ControlProvider, commit_provider: CommitProvider, ) -> tuple[PolygonWorkerGateway, str]: if not control_provider(): raise HTTPException( status_code=403, detail="Lifecycle Полигона отключён на этом экземпляре Mission Core.", ) gateway = _required_worker(worker_provider) commit = commit_provider() if commit is None: raise HTTPException( status_code=503, detail="Mission Core не привязан к точной Git-ревизии.", ) return gateway, commit def _unavailable_worker_status() -> dict[str, Any]: return { "schema_version": STATUS_SCHEMA, "worker_id": "mission-gpu-s1", "transport": "unix", "mode": "simulation", "available": False, "control_available": False, "active_run_id": None, "run_state": None, "provider_ids": [], "isolation": { "network": "unavailable", "process_identity": "missioncore", "artifact_policy": "d-only", }, "authority": { "scope": "virtual-only", "actuator_authority": False, "direct_actuator_setpoints_allowed": False, }, } def _new_run_id(commit: str) -> str: stamp = datetime.now(UTC).strftime("%Y%m%dt%H%M%Sz").lower() return f"s1c-{commit[:7]}-{stamp}-{secrets.token_hex(3)}"