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
+632
View File
@@ -0,0 +1,632 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import signal
import socket
import subprocess
import sys
import threading
import time
from datetime import UTC, datetime
from pathlib import Path
from types import FrameType
from typing import Any, Final
from k1link.simulation.contracts import (
AuthorityProfile,
ProviderPin,
QualificationRun,
ReproducibilityTier,
RunKind,
RunState,
)
from k1link.simulation.orchestrator import SimulationApplicationService
from k1link.simulation.process_supervisor import PosixProcessSupervisor
from k1link.simulation.run_store import (
ACTIVE_RECOVERY_STATES,
QualificationRunNotFoundError,
QualificationRunStore,
)
from k1link.simulation.s0 import ComponentPin
from k1link.simulation.stock_rover import (
StockRoverTargetPaths,
load_stock_rover_lifecycle_profile,
stock_rover_process_environment,
stock_rover_process_specs,
)
from k1link.simulation.worker import (
LocalProcessWorkerAdapter,
S0WorkerGuard,
SimulationWorldControl,
)
from k1link.simulation.worker_gateway import (
MAX_MESSAGE_BYTES,
OPERATIONS,
REQUEST_SCHEMA,
RESPONSE_SCHEMA,
STATUS_SCHEMA,
VEHICLE_STATE_SCHEMA,
)
EXPECTED_DISTRO: Final = "MissionCore-Sim"
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
SAFE_ID_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
PROVIDER_IDS: Final = (
"px4-autopilot",
"gazebo",
"px4-gazebo-models",
"micro-xrce-dds-agent",
)
ACTIVE_STATES: Final = frozenset(ACTIVE_RECOVERY_STATES)
POSE_TOPIC: Final = "/world/rover/dynamic_pose/info"
class WorkerAgentError(RuntimeError):
"""The worker agent cannot safely satisfy a gateway request."""
class _LifecycleOnlyWorldControl(SimulationWorldControl):
def pause(self, run_id: str) -> None:
raise WorkerAgentError(f"pause is not admitted for {run_id}")
def resume(self, run_id: str) -> None:
raise WorkerAgentError(f"resume is not admitted for {run_id}")
def step(self, run_id: str, step_count: int) -> int:
raise WorkerAgentError(f"step is not admitted for {run_id}:{step_count}")
class GazeboPoseCollector:
"""Read one bounded ground-truth pose sample from Gazebo Transport."""
def __init__(self, environment: dict[str, str]) -> None:
self.environment = dict(environment)
self._sequence = 0
self._lock = threading.Lock()
def collect(self, run_id: str) -> dict[str, Any]:
try:
completed = subprocess.run(
(
"/usr/bin/gz",
"topic",
"-e",
"--json-output",
"-t",
POSE_TOPIC,
"-n",
"1",
),
check=True,
capture_output=True,
env=self.environment,
timeout=3,
)
except (OSError, subprocess.SubprocessError) as exc:
raise WorkerAgentError("Gazebo pose sample is unavailable") from exc
if len(completed.stdout) > MAX_MESSAGE_BYTES:
raise WorkerAgentError("Gazebo pose sample exceeds the size limit")
try:
document = json.loads(completed.stdout.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise WorkerAgentError("Gazebo pose sample is not valid JSON") from exc
if not isinstance(document, dict):
raise WorkerAgentError("Gazebo pose sample must be an object")
pose = _rover_pose(document)
stamp = _object(_object(document.get("header"), "header").get("stamp"), "stamp")
sim_time_ns = _integer(stamp.get("sec"), "stamp.sec") * 1_000_000_000 + _integer(
stamp.get("nsec"),
"stamp.nsec",
)
with self._lock:
self._sequence += 1
sequence = self._sequence
return {
"schema_version": VEHICLE_STATE_SCHEMA,
"run_id": run_id,
"sequence": sequence,
"observed_at_utc": _utc_now(),
"host_monotonic_ns": time.monotonic_ns(),
"sim_time_ns": sim_time_ns,
"frame_id": "map_enu",
"child_frame_id": "base_link_flu",
"pose": {
"position_m": {
"x": _number(_object(pose.get("position"), "position").get("x"), "position.x"),
"y": _number(_object(pose.get("position"), "position").get("y"), "position.y"),
"z": _number(_object(pose.get("position"), "position").get("z"), "position.z"),
},
"orientation_xyzw": {
"x": _number(
_object(pose.get("orientation"), "orientation").get("x"),
"orientation.x",
),
"y": _number(
_object(pose.get("orientation"), "orientation").get("y"),
"orientation.y",
),
"z": _number(
_object(pose.get("orientation"), "orientation").get("z"),
"orientation.z",
),
"w": _number(
_object(pose.get("orientation"), "orientation").get("w"),
"orientation.w",
),
},
},
"source": {
"provider": "gazebo",
"topic": POSE_TOPIC,
"signal": "ground-truth",
"quality": "diagnostic",
},
"safety": {
"scope": "virtual-only",
"actuator_authority": False,
"navigation_or_safety_accepted": False,
},
}
class SimulationWorkerAgent:
"""One loopback-only worker process owning provider lifecycle and live state."""
def __init__(
self,
*,
socket_path: Path,
mission_core_commit: str,
d_root: Path,
s0_profile_path: Path,
lifecycle_profile_path: Path,
) -> None:
if not COMMIT_PATTERN.fullmatch(mission_core_commit):
raise WorkerAgentError("Mission Core commit must be an exact 40-character revision")
if os.geteuid() == 0:
raise WorkerAgentError("worker agent must run as an unprivileged identity")
if os.environ.get("WSL_DISTRO_NAME") != EXPECTED_DISTRO:
raise WorkerAgentError(f"worker agent requires {EXPECTED_DISTRO}")
if tuple(sorted(name for _, name in socket.if_nameindex())) != ("lo",):
raise WorkerAgentError("worker agent requires a loopback-only network namespace")
self.socket_path = socket_path
self.mission_core_commit = mission_core_commit
self.paths = StockRoverTargetPaths.from_d_root(d_root)
self.lifecycle_profile_path = lifecycle_profile_path.expanduser().resolve()
self.lifecycle_profile = load_stock_rover_lifecycle_profile(self.lifecycle_profile_path)
self.guard = S0WorkerGuard(s0_profile_path)
self._preflight()
self.store = QualificationRunStore(self.paths.d_root / "artifacts/s1/runs")
self.supervisor = PosixProcessSupervisor(
self.paths.d_root / "runtime/s1/runs",
base_environment=stock_rover_process_environment(self.paths),
)
self.worker = LocalProcessWorkerAdapter(
self.guard,
self.supervisor,
stock_rover_process_specs(self.paths, self.lifecycle_profile),
_LifecycleOnlyWorldControl(),
)
self.service = SimulationApplicationService(self.store, self.worker)
self.collector = GazeboPoseCollector(stock_rover_process_environment(self.paths))
self._shutdown = threading.Event()
self._server_socket: socket.socket | None = None
self._connection_threads: set[threading.Thread] = set()
self.service.reconcile(
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
)
def serve_forever(self) -> None:
self.socket_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
if self.socket_path.exists():
if not self.socket_path.is_socket():
raise WorkerAgentError("worker socket path is occupied by a non-socket")
self.socket_path.unlink()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
self._server_socket = server
server.bind(str(self.socket_path))
self.socket_path.chmod(0o600)
server.listen(8)
server.settimeout(0.5)
while not self._shutdown.is_set():
try:
connection, _ = server.accept()
except TimeoutError:
continue
self._connection_threads = {
thread for thread in self._connection_threads if thread.is_alive()
}
thread = threading.Thread(
target=self._serve_connection,
args=(connection,),
daemon=True,
)
self._connection_threads.add(thread)
thread.start()
for thread in tuple(self._connection_threads):
thread.join(timeout=1)
self._server_socket = None
if self.socket_path.is_socket():
self.socket_path.unlink()
def shutdown(self) -> None:
self._shutdown.set()
active = self._active_run()
if active is not None:
try:
self.service.stop(
active.run_id,
idempotency_key=f"{active.run_id}:agent-shutdown",
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
sim_time_ns=None,
terminal_state=RunState.ABORTED,
reason="worker-agent-shutdown",
)
except Exception:
self.supervisor.stop()
def _serve_connection(self, connection: socket.socket) -> None:
with connection:
self._handle_connection(connection)
def dispatch(self, request: dict[str, Any]) -> dict[str, Any]:
if set(request) != {"schema_version", "request_id", "operation", "payload"}:
raise WorkerAgentError("request keys do not match v1")
request_id = request.get("request_id")
operation = request.get("operation")
payload = request.get("payload")
if (
request.get("schema_version") != REQUEST_SCHEMA
or not isinstance(request_id, str)
or not 1 <= len(request_id) <= 64
or operation not in OPERATIONS
or not isinstance(payload, dict)
):
raise WorkerAgentError("request envelope is invalid")
if operation == "status":
_exact_keys(payload, set(), "status payload")
return self.status()
if operation == "live":
_exact_keys(payload, set(), "live payload")
active = self._active_run()
if active is None or active.state is not RunState.RUNNING:
raise WorkerAgentError("no running simulation is available")
return self.collector.collect(active.run_id)
if operation == "start":
_exact_keys(
payload,
{"run_id", "mission_core_commit", "idempotency_key"},
"start payload",
)
return self.start(
run_id=_safe_id(payload["run_id"], "run id"),
mission_core_commit=_string(payload["mission_core_commit"], "commit", 40),
idempotency_key=_string(payload["idempotency_key"], "idempotency key", 160),
)
_exact_keys(payload, {"run_id", "idempotency_key"}, "stop payload")
return self.stop(
run_id=_safe_id(payload["run_id"], "run id"),
idempotency_key=_string(payload["idempotency_key"], "idempotency key", 160),
)
def status(self) -> dict[str, Any]:
active = self._active_run()
provider_ids: list[str] = []
if active is not None and active.state in {RunState.RUNNING, RunState.PAUSED}:
try:
provider_ids = [record.process_id for record in self.supervisor.snapshot()]
except Exception:
provider_ids = []
return {
"schema_version": STATUS_SCHEMA,
"worker_id": "mission-gpu-s1",
"transport": "unix",
"mode": "simulation",
"available": True,
"control_available": True,
"active_run_id": active.run_id if active else None,
"run_state": active.state.value if active else None,
"provider_ids": provider_ids,
"isolation": {
"network": "loopback-only-netns",
"process_identity": "missioncore",
"artifact_policy": "d-only",
},
"authority": {
"scope": "virtual-only",
"actuator_authority": False,
"direct_actuator_setpoints_allowed": False,
},
}
def start(
self,
*,
run_id: str,
mission_core_commit: str,
idempotency_key: str,
) -> dict[str, Any]:
if mission_core_commit != self.mission_core_commit:
raise WorkerAgentError("requested commit does not match the staged worker generation")
try:
run = self.store.load(run_id)
except QualificationRunNotFoundError:
run = self.store.create(self._new_run(run_id))
if run.mission_core_commit != mission_core_commit:
raise WorkerAgentError("existing run belongs to another Mission Core generation")
running = self.service.start(
run_id,
idempotency_key=idempotency_key,
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
)
if running.state is not RunState.RUNNING:
raise WorkerAgentError(
f"provider start ended in terminal state {running.state.value}"
)
return self.status()
def stop(self, *, run_id: str, idempotency_key: str) -> dict[str, Any]:
active = self._active_run()
if active is None or active.run_id != run_id:
raise WorkerAgentError("requested run does not own worker authority")
sim_time_ns: int | None
try:
sim_time_ns = int(self.collector.collect(run_id)["sim_time_ns"])
except Exception:
sim_time_ns = None
terminal = self.service.stop(
run_id,
idempotency_key=idempotency_key,
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
sim_time_ns=sim_time_ns,
)
if terminal.state is not RunState.COMPLETED:
raise WorkerAgentError(
f"provider stop ended in terminal state {terminal.state.value}"
)
return self.status()
def _handle_connection(self, connection: socket.socket) -> None:
request_id: str | None = None
try:
request_bytes = _read_line(connection)
request = _json_object(request_bytes)
raw_request_id = request.get("request_id")
if isinstance(raw_request_id, str) and 1 <= len(raw_request_id) <= 64:
request_id = raw_request_id
result = self.dispatch(request)
response = {
"schema_version": RESPONSE_SCHEMA,
"request_id": request_id,
"ok": True,
"result": result,
"error": None,
}
except Exception as exc:
response = {
"schema_version": RESPONSE_SCHEMA,
"request_id": request_id,
"ok": False,
"result": None,
"error": _safe_error(exc),
}
encoded = (
json.dumps(response, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + b"\n"
)
if len(encoded) <= MAX_MESSAGE_BYTES:
connection.sendall(encoded)
def _new_run(self, run_id: str) -> QualificationRun:
return QualificationRun(
run_id=run_id,
episode_id=f"episode-{run_id}",
kind=RunKind.SIMULATION_CLOSED_LOOP,
state=RunState.ADMITTED,
scenario_generation="px4-v1.17.0-stock-rover-ackermann",
scenario_sha256=_sha256(self.paths.scenario_path),
profile_generation=self.lifecycle_profile.profile_id,
profile_sha256=_sha256(self.lifecycle_profile_path),
mission_core_commit=self.mission_core_commit,
providers=_provider_pins(self.guard),
host_profile_id=self.guard.profile.profile_id,
host_profile_sha256=self.guard.profile_sha256,
seed=42,
reproducibility_tier=ReproducibilityTier.R1,
authority=AuthorityProfile(
generation=1,
command_ttl_max_ns=250_000_000,
heartbeat_timeout_monotonic_ns=500_000_000,
),
clock_domain="gazebo:/clock",
created_at_utc=_utc_now(),
)
def _active_run(self) -> QualificationRun | None:
active = tuple(run for run in self.store.list_runs() if run.state in ACTIVE_STATES)
if len(active) > 1:
raise WorkerAgentError("multiple active qualification runs violate worker authority")
return active[0] if active else None
def _preflight(self) -> None:
required = (self.paths.px4_root, self.paths.agent_binary, self.paths.scenario_path)
if any(not path.exists() for path in required):
raise WorkerAgentError("worker inputs are incomplete")
if not all(
component.qualification_state == "accepted"
for component in self.guard.profile.components
):
raise WorkerAgentError("an S0 provider generation is no longer accepted")
def _read_line(connection: socket.socket) -> bytes:
chunks: list[bytes] = []
size = 0
while True:
chunk = connection.recv(min(4096, MAX_MESSAGE_BYTES + 1 - size))
if not chunk:
raise WorkerAgentError("gateway request ended before newline")
newline = chunk.find(b"\n")
if newline >= 0:
chunks.append(chunk[:newline])
size += newline
if size > MAX_MESSAGE_BYTES:
raise WorkerAgentError("gateway request exceeds the size limit")
return b"".join(chunks)
chunks.append(chunk)
size += len(chunk)
if size > MAX_MESSAGE_BYTES:
raise WorkerAgentError("gateway request exceeds the size limit")
def _json_object(value: bytes) -> dict[str, Any]:
try:
document = json.loads(value.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise WorkerAgentError("gateway request is not valid UTF-8 JSON") from exc
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
raise WorkerAgentError("gateway request must be a JSON object")
return document
def _rover_pose(document: dict[str, Any]) -> dict[str, Any]:
poses = document.get("pose")
if not isinstance(poses, list) or not poses:
raise WorkerAgentError("Gazebo pose sample contains no poses")
for item in poses:
if isinstance(item, dict) and str(item.get("name", "")).startswith("rover_ackermann"):
return item
raise WorkerAgentError("Gazebo pose sample contains no stock rover")
def _provider_pins(guard: S0WorkerGuard) -> tuple[ProviderPin, ...]:
components = {component.identifier: component for component in guard.profile.components}
if any(identifier not in components for identifier in PROVIDER_IDS):
raise WorkerAgentError("S0 profile is missing a required provider")
return tuple(_provider_pin(components[identifier]) for identifier in PROVIDER_IDS)
def _provider_pin(component: ComponentPin) -> ProviderPin:
version = component.resolved_version or component.requested_ref
return ProviderPin(
identifier=component.identifier,
version=version,
revision=component.resolved_commit or version,
)
def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
if set(value) != expected:
raise WorkerAgentError(f"{label} keys do not match v1")
def _safe_id(value: object, label: str) -> str:
result = _string(value, label, 64)
if not SAFE_ID_PATTERN.fullmatch(result):
raise WorkerAgentError(f"{label} is not a safe identifier")
return result
def _string(value: object, label: str, maximum: int) -> str:
if not isinstance(value, str):
raise WorkerAgentError(f"{label} must be a string")
result = value.strip()
if not 1 <= len(result) <= maximum:
raise WorkerAgentError(f"{label} has an invalid length")
return result
def _object(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise WorkerAgentError(f"{label} must be an object")
return value
def _number(value: object, label: str) -> float:
if isinstance(value, bool) or not isinstance(value, int | float | str):
raise WorkerAgentError(f"{label} must be numeric")
try:
result = float(value)
except ValueError as exc:
raise WorkerAgentError(f"{label} must be numeric") from exc
if not -1e9 < result < 1e9:
raise WorkerAgentError(f"{label} is outside the accepted range")
return result
def _integer(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int | str):
raise WorkerAgentError(f"{label} must be an integer")
try:
result = int(value)
except ValueError as exc:
raise WorkerAgentError(f"{label} must be an integer") from exc
if result < 0:
raise WorkerAgentError(f"{label} must not be negative")
return result
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _safe_error(exc: Exception) -> str:
if isinstance(exc, WorkerAgentError):
normalized = " ".join(str(exc).split())
return normalized[:512] or "worker request rejected"
return f"{type(exc).__name__}: worker operation failed"
def main() -> int:
parser = argparse.ArgumentParser(description="Serve the Mission Core Simulation Worker agent.")
parser.add_argument("--socket", type=Path, required=True)
parser.add_argument("--mission-core-commit", required=True)
parser.add_argument(
"--d-root",
type=Path,
default=Path("/mnt/d/NDC_MISSIONCORE/simulation"),
)
parser.add_argument(
"--s0-profile",
type=Path,
default=Path("/mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml"),
)
parser.add_argument("--lifecycle-profile", type=Path, required=True)
args = parser.parse_args()
agent = SimulationWorkerAgent(
socket_path=args.socket,
mission_core_commit=args.mission_core_commit,
d_root=args.d_root,
s0_profile_path=args.s0_profile,
lifecycle_profile_path=args.lifecycle_profile,
)
def stop(_signum: int, _frame: FrameType | None) -> None:
agent.shutdown()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
try:
agent.serve_forever()
finally:
agent.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())
+260
View File
@@ -0,0 +1,260 @@
from __future__ import annotations
import json
import socket
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Final, Protocol
from uuid import uuid4
REQUEST_SCHEMA: Final = "missioncore.simulation-worker-request/v1"
RESPONSE_SCHEMA: Final = "missioncore.simulation-worker-response/v1"
STATUS_SCHEMA: Final = "missioncore.simulation-worker-status/v1"
VEHICLE_STATE_SCHEMA: Final = "missioncore.vehicle-state/v1"
MAX_MESSAGE_BYTES: Final = 64 * 1024
OPERATIONS: Final = frozenset({"status", "live", "start", "stop"})
class SimulationWorkerGatewayError(RuntimeError):
"""The worker transport or response violated the private gateway contract."""
class SimulationWorkerUnavailableError(SimulationWorkerGatewayError):
"""The configured worker cannot currently be reached."""
class SimulationWorkerRejectedError(SimulationWorkerGatewayError):
"""The worker safely rejected a valid gateway request."""
class PolygonWorkerGateway(Protocol):
def status(self) -> dict[str, Any]: ...
def live(self) -> dict[str, Any]: ...
def start(
self,
*,
run_id: str,
mission_core_commit: str,
idempotency_key: str,
) -> dict[str, Any]: ...
def stop(
self,
*,
run_id: str,
idempotency_key: str,
) -> dict[str, Any]: ...
class UnixSocketWorkerGateway:
"""Bounded request/response transport between Mission Core and one local worker."""
def __init__(
self,
socket_path: Path,
*,
request_timeout_seconds: float = 3.0,
lifecycle_timeout_seconds: float = 150.0,
) -> None:
path = socket_path.expanduser()
if not path.is_absolute():
raise ValueError("worker socket path must be absolute")
if request_timeout_seconds <= 0 or lifecycle_timeout_seconds <= 0:
raise ValueError("worker gateway timeouts must be positive")
self.socket_path = path
self.request_timeout_seconds = request_timeout_seconds
self.lifecycle_timeout_seconds = lifecycle_timeout_seconds
def status(self) -> dict[str, Any]:
result = self._request("status", {}, timeout_seconds=self.request_timeout_seconds)
_validate_status(result)
return result
def live(self) -> dict[str, Any]:
result = self._request("live", {}, timeout_seconds=self.request_timeout_seconds)
_validate_vehicle_state(result)
return result
def start(
self,
*,
run_id: str,
mission_core_commit: str,
idempotency_key: str,
) -> dict[str, Any]:
result = self._request(
"start",
{
"run_id": run_id,
"mission_core_commit": mission_core_commit,
"idempotency_key": idempotency_key,
},
timeout_seconds=self.lifecycle_timeout_seconds,
)
_validate_status(result)
return result
def stop(
self,
*,
run_id: str,
idempotency_key: str,
) -> dict[str, Any]:
result = self._request(
"stop",
{
"run_id": run_id,
"idempotency_key": idempotency_key,
},
timeout_seconds=self.lifecycle_timeout_seconds,
)
_validate_status(result)
return result
def _request(
self,
operation: str,
payload: Mapping[str, object],
*,
timeout_seconds: float,
) -> dict[str, Any]:
if operation not in OPERATIONS:
raise ValueError("unsupported worker operation")
request_id = uuid4().hex
encoded = (
json.dumps(
{
"schema_version": REQUEST_SCHEMA,
"request_id": request_id,
"operation": operation,
"payload": dict(payload),
},
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
+ b"\n"
)
if len(encoded) > MAX_MESSAGE_BYTES:
raise ValueError("worker request exceeds the bounded message size")
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(timeout_seconds)
client.connect(str(self.socket_path))
client.sendall(encoded)
response_bytes = _read_line(client)
except (FileNotFoundError, ConnectionError, OSError, TimeoutError) as exc:
raise SimulationWorkerUnavailableError(
"Simulation Worker недоступен через локальный шлюз."
) from exc
response = _json_object(response_bytes, "worker response")
if set(response) != {"schema_version", "request_id", "ok", "result", "error"}:
raise SimulationWorkerGatewayError("worker response keys do not match v1")
if response["schema_version"] != RESPONSE_SCHEMA or response["request_id"] != request_id:
raise SimulationWorkerGatewayError("worker response identity does not match request")
if not isinstance(response["ok"], bool):
raise SimulationWorkerGatewayError("worker response ok flag is invalid")
if response["ok"]:
if response["error"] is not None or not isinstance(response["result"], dict):
raise SimulationWorkerGatewayError("successful worker response is malformed")
return response["result"]
error = response["error"]
if response["result"] is not None or not isinstance(error, str) or not error.strip():
raise SimulationWorkerGatewayError("rejected worker response is malformed")
raise SimulationWorkerRejectedError(error[:512])
def _read_line(connection: socket.socket) -> bytes:
chunks: list[bytes] = []
size = 0
while True:
chunk = connection.recv(min(4096, MAX_MESSAGE_BYTES + 1 - size))
if not chunk:
raise SimulationWorkerGatewayError("worker closed the gateway without a response")
newline = chunk.find(b"\n")
if newline >= 0:
chunks.append(chunk[:newline])
size += newline
if size > MAX_MESSAGE_BYTES:
raise SimulationWorkerGatewayError("worker response exceeds the size limit")
return b"".join(chunks)
chunks.append(chunk)
size += len(chunk)
if size > MAX_MESSAGE_BYTES:
raise SimulationWorkerGatewayError("worker response exceeds the size limit")
def _json_object(value: bytes, label: str) -> dict[str, Any]:
try:
document = json.loads(value.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SimulationWorkerGatewayError(f"{label} is not valid UTF-8 JSON") from exc
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
raise SimulationWorkerGatewayError(f"{label} must be a JSON object")
return document
def _validate_status(value: Mapping[str, Any]) -> None:
expected = {
"schema_version",
"worker_id",
"transport",
"mode",
"available",
"control_available",
"active_run_id",
"run_state",
"provider_ids",
"isolation",
"authority",
}
if set(value) != expected or value.get("schema_version") != STATUS_SCHEMA:
raise SimulationWorkerGatewayError("worker status does not match v1")
if (
not isinstance(value.get("worker_id"), str)
or value.get("transport") != "unix"
or value.get("mode") != "simulation"
or value.get("available") is not True
or not isinstance(value.get("control_available"), bool)
or not isinstance(value.get("provider_ids"), list)
or any(not isinstance(item, str) for item in value["provider_ids"])
or not isinstance(value.get("isolation"), dict)
or not isinstance(value.get("authority"), dict)
):
raise SimulationWorkerGatewayError("worker status contains invalid values")
if value.get("active_run_id") is not None and not isinstance(value["active_run_id"], str):
raise SimulationWorkerGatewayError("worker active run id is invalid")
if value.get("run_state") is not None and not isinstance(value["run_state"], str):
raise SimulationWorkerGatewayError("worker run state is invalid")
def _validate_vehicle_state(value: Mapping[str, Any]) -> None:
expected = {
"schema_version",
"run_id",
"sequence",
"observed_at_utc",
"host_monotonic_ns",
"sim_time_ns",
"frame_id",
"child_frame_id",
"pose",
"source",
"safety",
}
if set(value) != expected or value.get("schema_version") != VEHICLE_STATE_SCHEMA:
raise SimulationWorkerGatewayError("vehicle state does not match v1")
if (
not isinstance(value.get("run_id"), str)
or not isinstance(value.get("sequence"), int)
or not isinstance(value.get("observed_at_utc"), str)
or not isinstance(value.get("host_monotonic_ns"), int)
or not isinstance(value.get("sim_time_ns"), int)
or value.get("frame_id") != "map_enu"
or value.get("child_frame_id") != "base_link_flu"
or not isinstance(value.get("pose"), dict)
or not isinstance(value.get("source"), dict)
or not isinstance(value.get("safety"), dict)
):
raise SimulationWorkerGatewayError("vehicle state contains invalid values")
+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)}"