NODEDC_MISSION_CORE/src/k1link/web/polygon_api.py

397 lines
15 KiB
Python

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)}"