feat(simulation): add Polygon live worker gateway
This commit is contained in:
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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)}"
|
||||
|
||||
Reference in New Issue
Block a user