feat(simulation): add Polygon live worker gateway

This commit is contained in:
DCCONSTRUCTIONS
2026-07-24 20:33:08 +03:00
parent 359f0d1d39
commit b7ccca3481
13 changed files with 2365 additions and 9 deletions
+199 -1
View File
@@ -1,12 +1,16 @@
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, HTTPException, Query
from fastapi import APIRouter, Header, HTTPException, Query
from fastapi import Path as PathParameter
from pydantic import BaseModel, ConfigDict
from k1link.simulation import (
QualificationRun,
@@ -14,10 +18,22 @@ from k1link.simulation import (
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 публикует только квалификационные доказательства; "
"lifecycle-операции и команды отсутствуют.",
@@ -27,6 +43,15 @@ READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
)
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
def configured_polygon_runs_root() -> Path | None:
@@ -36,9 +61,31 @@ def configured_polygon_runs_root() -> Path | 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"])
@@ -111,6 +158,98 @@ def build_polygon_router(
"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
return router
@@ -155,3 +294,62 @@ def _run_summary(run: QualificationRun) -> dict[str, Any]:
"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)}"