Files
NODEDC_MISSION_CORE/src/k1link/simulation/projects.py
T

1153 lines
46 KiB
Python

"""Durable Mission Core catalog and orchestration for portable Gaussian worlds."""
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import shutil
import threading
import time
from collections import deque
from collections.abc import Callable
from contextlib import suppress
from pathlib import Path
from typing import Any, Final, TypeVar
from urllib.parse import quote
from uuid import uuid4
from k1link.artifacts import utc_now_iso
from k1link.simulation.gaussian_pipeline_gateway import (
BUILD_REQUEST_SCHEMA,
GaussianPipelineGateway,
GaussianPipelineGatewayError,
GaussianPipelineUnavailableError,
configured_gaussian_pipeline_gateway,
discover_gaussian_source_bundle,
is_xgrids_source_mesh_path,
)
PROJECT_SCHEMA: Final = "missioncore.simulation-project/v1"
WORLD_MANIFEST_SCHEMA: Final = "missioncore.simulation-world-manifest/v1"
VIEWER_SETTINGS_SCHEMA: Final = "missioncore.simulation-viewer-settings/v3"
PROJECT_ID_PATTERN: Final = re.compile(r"^sim-[a-f0-9]{32}$")
SOURCE_FILE_ID_PATTERN: Final = re.compile(r"^src-[0-9]{5}-[a-f0-9]{8}$")
PROVIDER_JOB_ID_PATTERN: Final = re.compile(r"^gsp-[0-9]{14}-[a-f0-9]{8}$")
PROVIDER_TIMESTAMP_PATTERN: Final = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$"
)
MAX_SOURCE_FILES: Final = 10_000
MAX_SOURCE_BYTES: Final = 16 * 1024 * 1024 * 1024
MAX_UPLOAD_CHUNK_BYTES: Final = 16 * 1024 * 1024
TERMINAL_STATES: Final = {"ready", "failed"}
ACTIVE_STATES: Final = {"queued", "processing", "importing"}
PROVIDER_JOB_STATES: Final = {
"queued",
"verifying_source",
"inspecting",
"building_preview",
"building_streamed_sog",
"building_collision",
"ready",
"failed",
}
PROVIDER_POLL_TIMEOUT_SECONDS: Final = 2 * 60 * 60 + 5 * 60
PROVIDER_UNAVAILABLE_RETRY_LIMIT: Final = 6
_T = TypeVar("_T")
class SimulationProjectError(RuntimeError):
"""The project request violates its durable catalog contract."""
class SimulationProjectNotFoundError(SimulationProjectError):
"""The requested project or source member does not exist."""
class SimulationProjectConflictError(SimulationProjectError):
"""The requested transition conflicts with current project state."""
class _SimulationProcessingCancelled(RuntimeError):
"""Internal cooperative stop after an operator deletes an active project."""
class SimulationProjectStore:
def __init__(self, data_dir: Path) -> None:
self.root = data_dir.expanduser().resolve() / "simulation-worlds"
self.projects_root = self.root / "projects"
self.projects_root.mkdir(mode=0o700, parents=True, exist_ok=True)
self._lock = threading.RLock()
def create(
self,
*,
name: str,
scene_type: str,
source_kind: str,
files: list[dict[str, object]],
) -> dict[str, Any]:
display_name = _project_name(name)
if scene_type not in {"interior", "outdoor", "object"}:
raise SimulationProjectError("simulation scene type is invalid")
if source_kind not in {"archive", "folder"}:
raise SimulationProjectError("simulation source kind is invalid")
if not files or len(files) > MAX_SOURCE_FILES:
raise SimulationProjectError("simulation source file count is outside limits")
normalized: list[dict[str, object]] = []
logical_paths: set[str] = set()
total_bytes = 0
for index, raw in enumerate(files):
if set(raw) != {"logical_path", "byte_length"}:
raise SimulationProjectError("simulation source file contract is invalid")
logical_path = _logical_path(raw.get("logical_path"))
byte_length = raw.get("byte_length")
if (
not isinstance(byte_length, int)
or isinstance(byte_length, bool)
or byte_length <= 0
or byte_length > MAX_SOURCE_BYTES
):
raise SimulationProjectError("simulation source file size is invalid")
if logical_path in logical_paths:
raise SimulationProjectError("simulation source paths must be unique")
logical_paths.add(logical_path)
total_bytes += byte_length
normalized.append(
{
"file_id": f"src-{index:05d}-{uuid4().hex[:8]}",
"logical_path": logical_path,
"byte_length": byte_length,
"uploaded_bytes": 0,
"sha256": None,
}
)
if total_bytes > MAX_SOURCE_BYTES:
raise SimulationProjectError("simulation source exceeds byte admission")
if source_kind == "archive":
if len(normalized) != 1 or "/" in str(normalized[0]["logical_path"]):
raise SimulationProjectError("archive source must be one top-level file")
if Path(str(normalized[0]["logical_path"])).suffix.lower() not in {
".zip",
".rar",
".7z",
}:
raise SimulationProjectError("archive source must be zip, rar or 7z")
project_id = f"sim-{uuid4().hex}"
now = utc_now_iso()
document: dict[str, Any] = {
"schema_version": PROJECT_SCHEMA,
"project_id": project_id,
"name": display_name,
"scene_type": scene_type,
"status": "uploading",
"source": {
"kind": source_kind,
"total_byte_length": total_bytes,
"uploaded_byte_length": 0,
"files": normalized,
"bundle_sha256": None,
},
"provider": {
"provider_id": "gaussian-pipeline",
"job_id": None,
"job_created_at_utc": None,
"state": None,
"state_started_at_utc": None,
"progress": None,
"runtime": None,
},
"artifacts": [],
"world_manifest": None,
"viewer_settings": _default_viewer_settings(),
"error": None,
"created_at_utc": now,
"updated_at_utc": now,
}
with self._lock:
project_root = self._project_root(project_id)
project_root.mkdir(mode=0o700, parents=False, exist_ok=False)
(project_root / "source").mkdir(mode=0o700)
(project_root / "artifacts").mkdir(mode=0o700)
self._write(document)
return document
def list(self) -> list[dict[str, Any]]:
with self._lock:
projects: list[dict[str, Any]] = []
for entry in self.projects_root.iterdir():
if not entry.is_dir() or PROJECT_ID_PATTERN.fullmatch(entry.name) is None:
continue
projects.append(self._read(entry.name))
projects.sort(
key=lambda item: (str(item["created_at_utc"]), str(item["project_id"])),
reverse=True,
)
return projects
def get(self, project_id: str) -> dict[str, Any]:
with self._lock:
return self._read(project_id)
def update_metadata(
self,
project_id: str,
*,
name: str,
scene_type: str,
) -> dict[str, Any]:
display_name = _project_name(name)
if scene_type not in {"interior", "outdoor", "object"}:
raise SimulationProjectError("simulation scene type is invalid")
with self._lock:
document = self._read(project_id)
document["name"] = display_name
document["scene_type"] = scene_type
document["updated_at_utc"] = utc_now_iso()
self._write(document)
return document
def update_viewer_settings(
self,
project_id: str,
settings: dict[str, Any],
) -> dict[str, Any]:
normalized = _viewer_settings(settings)
with self._lock:
document = self._read(project_id)
document["viewer_settings"] = normalized
document["updated_at_utc"] = utc_now_iso()
self._write(document)
return document
def upload_state(self, project_id: str, file_id: str) -> tuple[int, int]:
with self._lock:
document = self._read(project_id)
source_file = _source_file(document, file_id)
return int(source_file["uploaded_bytes"]), int(source_file["byte_length"])
def append_upload(
self,
project_id: str,
file_id: str,
*,
offset: int,
payload: bytes,
) -> dict[str, Any]:
if not payload or len(payload) > MAX_UPLOAD_CHUNK_BYTES:
raise SimulationProjectError("simulation upload chunk is outside limits")
with self._lock:
document = self._read(project_id)
if document["status"] != "uploading":
raise SimulationProjectConflictError("simulation source upload is closed")
source_file = _source_file(document, file_id)
uploaded = int(source_file["uploaded_bytes"])
byte_length = int(source_file["byte_length"])
if offset != uploaded:
raise SimulationProjectConflictError("simulation upload offset does not match")
if uploaded + len(payload) > byte_length:
raise SimulationProjectError("simulation upload exceeds declared file size")
target = self._source_path(project_id, str(source_file["logical_path"]))
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
mode = "xb" if uploaded == 0 else "r+b"
with target.open(mode) as stream:
if uploaded:
stream.seek(uploaded)
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
uploaded += len(payload)
source_file["uploaded_bytes"] = uploaded
if uploaded == byte_length:
source_file["sha256"] = _sha256(target)
document["source"]["uploaded_byte_length"] = sum(
int(item["uploaded_bytes"]) for item in document["source"]["files"]
)
document["updated_at_utc"] = utc_now_iso()
self._write(document)
return document
def begin_build(self, project_id: str) -> dict[str, Any]:
with self._lock:
document = self._read(project_id)
if document["status"] not in {"uploading", "failed", "ready"}:
raise SimulationProjectConflictError("simulation project cannot start a build")
if any(
int(item["uploaded_bytes"]) != int(item["byte_length"])
for item in document["source"]["files"]
):
raise SimulationProjectConflictError("simulation source upload is incomplete")
document["status"] = "queued"
document["provider"] = {
"provider_id": "gaussian-pipeline",
"job_id": None,
"job_created_at_utc": None,
"state": None,
"state_started_at_utc": None,
"progress": None,
"runtime": None,
}
document["artifacts"] = []
document["world_manifest"] = None
document["error"] = None
document["updated_at_utc"] = utc_now_iso()
self._write(document)
return document
def update_processing(
self,
project_id: str,
*,
status: str,
provider_job_id: str | None = None,
provider_job_created_at_utc: str | None = None,
provider_state: str | None = None,
provider_state_started_at_utc: str | None = None,
progress: object = None,
bundle_sha256: str | None = None,
) -> dict[str, Any]:
if status not in {"queued", "processing", "importing"}:
raise SimulationProjectError("simulation processing status is invalid")
with self._lock:
document = self._read(project_id)
document["status"] = status
if provider_job_id is not None:
if PROVIDER_JOB_ID_PATTERN.fullmatch(provider_job_id) is None:
raise SimulationProjectError("simulation provider job id is invalid")
document["provider"]["job_id"] = provider_job_id
if provider_job_created_at_utc is not None:
document["provider"]["job_created_at_utc"] = _provider_timestamp(
provider_job_created_at_utc,
"job creation",
)
if provider_state is not None:
previous_state = document["provider"].get("state")
document["provider"]["state"] = provider_state
if (
previous_state != provider_state
or document["provider"].get("state_started_at_utc") is None
):
document["provider"]["state_started_at_utc"] = (
_provider_timestamp(
provider_state_started_at_utc,
"state start",
)
if provider_state_started_at_utc is not None
else utc_now_iso()
)
if progress is not None:
document["provider"]["progress"] = progress
if bundle_sha256 is not None:
document["source"]["bundle_sha256"] = bundle_sha256
document["error"] = None
document["updated_at_utc"] = utc_now_iso()
self._write(document)
return document
def complete(
self,
project_id: str,
*,
result: dict[str, Any],
artifacts: list[dict[str, Any]],
world_manifest: dict[str, Any],
provider_job_id: str | None = None,
provider_progress: object = None,
) -> dict[str, Any]:
with self._lock:
document = self._read(project_id)
if provider_job_id is not None:
if PROVIDER_JOB_ID_PATTERN.fullmatch(provider_job_id) is None:
raise SimulationProjectError("simulation provider job id is invalid")
document["provider"]["job_id"] = provider_job_id
if provider_progress is not None:
document["provider"]["progress"] = provider_progress
document["status"] = "ready"
document["provider"]["state"] = "ready"
document["provider"]["runtime"] = result.get("runtime")
document["artifacts"] = artifacts
document["world_manifest"] = world_manifest
document["error"] = None
document["updated_at_utc"] = utc_now_iso()
self._write(document)
return document
def fail(self, project_id: str, message: str) -> dict[str, Any]:
with self._lock:
document = self._read(project_id)
document["status"] = "failed"
document["error"] = message[:2048]
document["updated_at_utc"] = utc_now_iso()
self._write(document)
return document
def artifact_path(self, project_id: str, logical_path: str) -> tuple[Path, dict[str, Any]]:
safe = _logical_path(logical_path)
with self._lock:
document = self._read(project_id)
descriptor = next(
(item for item in document["artifacts"] if item.get("logical_path") == safe),
None,
)
if descriptor is None:
raise SimulationProjectNotFoundError("simulation artifact is unavailable")
target = self._artifact_path(project_id, safe)
if not target.is_file() or target.is_symlink():
raise SimulationProjectNotFoundError("simulation artifact is unavailable")
return target, descriptor
def delete(self, project_id: str, *, allow_active: bool = False) -> None:
with self._lock:
document = self._read(project_id)
if (
not allow_active
and document["status"] not in TERMINAL_STATES
and document["status"] != "uploading"
):
raise SimulationProjectConflictError("active simulation project cannot be deleted")
shutil.rmtree(self._project_root(project_id))
def source_root(self, project_id: str) -> Path:
self.get(project_id)
return self._project_root(project_id) / "source"
def artifacts_root(self, project_id: str) -> Path:
self.get(project_id)
return self._project_root(project_id) / "artifacts"
def _read(self, project_id: str) -> dict[str, Any]:
project_root = self._project_root(project_id)
try:
document = json.loads((project_root / "project.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise SimulationProjectNotFoundError("simulation project is unavailable") from exc
if (
not isinstance(document, dict)
or document.get("schema_version") != PROJECT_SCHEMA
or document.get("project_id") != project_id
):
raise SimulationProjectError("persisted simulation project identity is invalid")
document["viewer_settings"] = _migrate_viewer_settings(document.get("viewer_settings"))
provider = document.get("provider")
if isinstance(provider, dict):
provider.setdefault("job_created_at_utc", None)
provider.setdefault("state_started_at_utc", None)
return document
def _write(self, document: dict[str, Any]) -> None:
project_id = str(document["project_id"])
destination = self._project_root(project_id) / "project.json"
temporary = destination.with_name(f".project-{uuid4().hex}.tmp")
with temporary.open("x", encoding="utf-8") as stream:
json.dump(document, stream, ensure_ascii=False, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
temporary.replace(destination)
def _project_root(self, project_id: str) -> Path:
if PROJECT_ID_PATTERN.fullmatch(project_id) is None:
raise SimulationProjectNotFoundError("simulation project is unavailable")
return self.projects_root / project_id
def _source_path(self, project_id: str, logical_path: str) -> Path:
return _confined_path(self._project_root(project_id) / "source", logical_path)
def _artifact_path(self, project_id: str, logical_path: str) -> Path:
return _confined_path(self._project_root(project_id) / "artifacts", logical_path)
class SimulationProjectService:
def __init__(
self,
store: SimulationProjectStore,
provider_factory=configured_gaussian_pipeline_gateway,
) -> None:
self.store = store
self.provider_factory = provider_factory
self._condition = threading.Condition()
self._queue: deque[str] = deque()
self._queued_ids: set[str] = set()
self._active_project_id: str | None = None
self._worker: threading.Thread | None = None
self._cancel_events: dict[str, threading.Event] = {}
self._inflight_jobs: dict[str, str] = {}
def recover_pending(self) -> int:
pending = sorted(
[project for project in self.store.list() if project.get("status") in ACTIVE_STATES],
key=lambda project: (
str(project.get("created_at_utc")),
str(project.get("project_id")),
),
)
for project in pending:
self.enqueue(str(project["project_id"]))
return len(pending)
def enqueue(self, project_id: str) -> None:
project = self.store.get(project_id)
if project.get("status") not in ACTIVE_STATES:
raise SimulationProjectConflictError(
"simulation project is not ready for queued processing"
)
with self._condition:
if project_id in self._queued_ids or project_id == self._active_project_id:
return
event = self._cancel_events.setdefault(project_id, threading.Event())
event.clear()
self._queue.append(project_id)
self._queued_ids.add(project_id)
if self._worker is None or not self._worker.is_alive():
self._worker = threading.Thread(
target=self._run_queue,
name="simulation-build-queue",
daemon=True,
)
self._worker.start()
self._condition.notify_all()
def _run_queue(self) -> None:
while True:
with self._condition:
if not self._queue:
self._worker = None
self._condition.notify_all()
return
project_id = self._queue.popleft()
self._queued_ids.discard(project_id)
self._active_project_id = project_id
try:
self.process(project_id)
finally:
with self._condition:
self._active_project_id = None
self._inflight_jobs.pop(project_id, None)
self._cancel_events.pop(project_id, None)
self._condition.notify_all()
def begin_build(self, project_id: str) -> dict[str, Any]:
project = self.store.get(project_id)
if project.get("status") == "failed":
job_id = project["provider"].get("job_id")
provider_state = project["provider"].get("state")
if (
isinstance(job_id, str)
and isinstance(provider_state, str)
and provider_state in PROVIDER_JOB_STATES - {"failed"}
):
provider = self.provider_factory()
if provider is None:
raise SimulationProjectConflictError(
"Gaussian Pipeline недоступен для продолжения сборки."
)
try:
job = _retry_provider_unavailable(lambda: provider.get_job(job_id))
finally:
provider.close()
current_state = job.get("state")
if isinstance(current_state, str) and current_state in PROVIDER_JOB_STATES - {
"failed"
}:
return self.store.update_processing(
project_id,
status="processing",
provider_job_id=job_id,
provider_job_created_at_utc=_optional_provider_timestamp(
job.get("created_at_utc")
),
provider_state=current_state,
provider_state_started_at_utc=_optional_provider_timestamp(
job.get("updated_at_utc")
),
progress=job.get("progress"),
)
if project.get("status") in {"failed", "ready"}:
job_id = project["provider"].get("job_id")
if isinstance(job_id, str):
provider = self.provider_factory()
if provider is None:
raise SimulationProjectConflictError(
"Gaussian Pipeline недоступен для повторной сборки."
)
try:
_retry_provider_unavailable(lambda: provider.delete_job(job_id))
finally:
provider.close()
return self.store.begin_build(project_id)
def process(self, project_id: str) -> None:
provider: GaussianPipelineGateway | None = None
try:
self._raise_if_cancelled(project_id)
project = self.store.get(project_id)
provider = self.provider_factory()
if provider is None:
raise SimulationProjectError("Gaussian Pipeline не настроен.")
_retry_provider_unavailable(provider.capabilities)
self._raise_if_cancelled(project_id)
existing_job_id = project["provider"].get("job_id")
if isinstance(existing_job_id, str):
job_id = existing_job_id
self._register_inflight_job(project_id, job_id, provider)
else:
if project["status"] != "queued":
raise SimulationProjectError(
"Активная Gaussian-сборка потеряла provider job id после перезапуска."
)
source_root = self.store.source_root(project_id)
if project["source"]["kind"] == "archive":
source_file = project["source"]["files"][0]
archive = provider.upload_source_archive(
_confined_path(source_root, str(source_file["logical_path"]))
)
source = provider.normalize_archive(archive)
else:
entrypoint, source_format = discover_gaussian_source_bundle(source_root)
source = provider.upload_source_bundle(
source_root,
entrypoint=entrypoint,
source_format=source_format,
)
source_mesh_available = any(
is_xgrids_source_mesh_path(member.logical_path)
for member in source.members
)
collision_profile = (
{
"scene_type": project["scene_type"],
"seed_position": [0.0, 0.0, 0.0],
"capsule_height": 0.4,
"capsule_radius": 0.4,
"voxel_size": 0.05,
"mesh_shape": "source",
}
if source_mesh_available
else None
)
request = {
"schema_version": BUILD_REQUEST_SCHEMA,
"idempotency_key": f"missioncore-{project_id}",
"source": source.to_dict(),
"outputs": {
"preview_sog": True,
"streamed_sog": True,
"collision": source_mesh_available,
},
"preview_lod": "coarsest",
"collision_profile": collision_profile,
}
submitted = provider.submit_build(request)
job_id = submitted.get("job_id")
if not isinstance(job_id, str):
raise SimulationProjectError("Gaussian Pipeline не вернул job id.")
self._register_inflight_job(project_id, job_id, provider)
self.store.update_processing(
project_id,
status="processing",
provider_job_id=job_id,
provider_job_created_at_utc=_optional_provider_timestamp(
submitted.get("created_at_utc")
),
provider_state=str(submitted.get("state") or "queued"),
provider_state_started_at_utc=_optional_provider_timestamp(
submitted.get("updated_at_utc")
),
progress=submitted.get("progress"),
bundle_sha256=source.bundle_sha256,
)
deadline = time.monotonic() + PROVIDER_POLL_TIMEOUT_SECONDS
while True:
self._raise_if_cancelled(project_id)
if time.monotonic() >= deadline:
raise SimulationProjectError(
"Gaussian Pipeline превысил лимит ожидания сборки."
)
job = _retry_provider_unavailable(lambda: provider.get_job(job_id))
state = job.get("state")
if not isinstance(state, str) or state not in PROVIDER_JOB_STATES:
raise SimulationProjectError(
"Gaussian Pipeline вернул неизвестное состояние сборки."
)
self.store.update_processing(
project_id,
status="processing",
provider_job_created_at_utc=_optional_provider_timestamp(
job.get("created_at_utc")
),
provider_state=str(state or "unknown"),
provider_state_started_at_utc=_optional_provider_timestamp(
job.get("updated_at_utc")
),
progress=job.get("progress"),
)
if state == "failed":
error = job.get("error")
message = error.get("message") if isinstance(error, dict) else None
raise SimulationProjectError(
str(message or "Gaussian Pipeline завершил сборку с ошибкой.")
)
if state == "ready":
break
time.sleep(2.0)
self.store.update_processing(
project_id,
status="importing",
provider_state="ready",
)
result = _retry_provider_unavailable(lambda: provider.get_result(job_id))
self._raise_if_cancelled(project_id)
artifacts = _artifact_descriptors(result.get("artifacts"))
artifacts_root = self.store.artifacts_root(project_id)
for descriptor in artifacts:
self._raise_if_cancelled(project_id)
_retry_provider_unavailable(
lambda descriptor=descriptor: provider.download_artifact(
job_id,
descriptor,
_confined_path(
artifacts_root,
str(descriptor["logical_path"]),
),
)
)
world_manifest = _world_manifest(project_id, artifacts)
self.store.complete(
project_id,
result=result,
artifacts=artifacts,
world_manifest=world_manifest,
)
except _SimulationProcessingCancelled:
pass
except (GaussianPipelineGatewayError, SimulationProjectError, OSError) as exc:
with suppress(SimulationProjectError):
self.store.fail(project_id, str(exc))
finally:
with self._condition:
self._inflight_jobs.pop(project_id, None)
if provider is not None:
provider.close()
def delete(self, project_id: str) -> None:
project = self.store.get(project_id)
with self._condition:
cancel = self._cancel_events.setdefault(project_id, threading.Event())
cancel.set()
if project_id in self._queued_ids:
self._queue = deque(item for item in self._queue if item != project_id)
self._queued_ids.discard(project_id)
job_id = self._inflight_jobs.pop(
project_id,
project["provider"].get("job_id"),
)
self._condition.notify_all()
if isinstance(job_id, str):
provider = self.provider_factory()
if provider is None:
with suppress(SimulationProjectError):
self.store.fail(
project_id,
"Gaussian Pipeline недоступен для удаления серверных артефактов.",
)
raise SimulationProjectConflictError(
"Gaussian Pipeline недоступен для удаления серверных артефактов."
)
try:
provider.delete_job(job_id)
except GaussianPipelineGatewayError:
with suppress(SimulationProjectError):
self.store.fail(
project_id,
"Не удалось удалить активную сборку на Worker 006.",
)
raise
finally:
provider.close()
self.store.delete(project_id, allow_active=True)
with self._condition:
self._cancel_events.pop(project_id, None)
self._condition.notify_all()
def _raise_if_cancelled(self, project_id: str) -> None:
with self._condition:
event = self._cancel_events.get(project_id)
if event is not None and event.is_set():
raise _SimulationProcessingCancelled(project_id)
def _register_inflight_job(
self,
project_id: str,
job_id: str,
provider: GaussianPipelineGateway,
) -> None:
with self._condition:
event = self._cancel_events.get(project_id)
cancelled = event is not None and event.is_set()
if not cancelled:
self._inflight_jobs[project_id] = job_id
if cancelled:
with suppress(GaussianPipelineGatewayError):
provider.delete_job(job_id)
raise _SimulationProcessingCancelled(project_id)
def _retry_provider_unavailable(operation: Callable[[], _T]) -> _T:
delay_seconds = 1.0
for attempt in range(PROVIDER_UNAVAILABLE_RETRY_LIMIT):
try:
return operation()
except GaussianPipelineUnavailableError:
if attempt + 1 >= PROVIDER_UNAVAILABLE_RETRY_LIMIT:
raise
time.sleep(delay_seconds)
delay_seconds = min(delay_seconds * 2.0, 10.0)
raise AssertionError("provider retry loop exhausted without returning or raising")
def _artifact_descriptors(value: object) -> list[dict[str, Any]]:
if not isinstance(value, list) or not value:
raise SimulationProjectError("Gaussian Pipeline не вернул артефакты сцены.")
descriptors: list[dict[str, Any]] = []
for item in value:
if not isinstance(item, dict):
raise SimulationProjectError("Gaussian artifact descriptor is invalid")
logical_path = _logical_path(item.get("logical_path"))
sha256 = item.get("sha256")
byte_length = item.get("byte_length")
media_type = item.get("media_type")
role = item.get("role")
if (
not isinstance(sha256, str)
or re.fullmatch(r"[a-f0-9]{64}", sha256) is None
or not isinstance(byte_length, int)
or isinstance(byte_length, bool)
or byte_length < 0
or not isinstance(media_type, str)
or not isinstance(role, str)
):
raise SimulationProjectError("Gaussian artifact fields are invalid")
descriptors.append(
{
"role": role,
"logical_path": logical_path,
"media_type": media_type,
"sha256": sha256,
"byte_length": byte_length,
}
)
return descriptors
def _world_manifest(project_id: str, artifacts: list[dict[str, Any]]) -> dict[str, Any]:
def url_for(role: str) -> str | None:
descriptor = next((item for item in artifacts if item["role"] == role), None)
if descriptor is None:
return None
encoded = "/".join(
quote(part, safe="") for part in str(descriptor["logical_path"]).split("/")
)
return f"/api/v1/simulation-worlds/projects/{project_id}/artifacts/{encoded}"
rover_collision_mesh_url = url_for("rover-collision-mesh") or url_for("rover-terrain-mesh")
collision_mesh_url = rover_collision_mesh_url or url_for("collision-mesh")
return {
"schema_version": WORLD_MANIFEST_SCHEMA,
"project_id": project_id,
"visual": {
"preview_sog_url": url_for("preview"),
"streamed_sog_url": url_for("stream-manifest"),
},
"collision": {
"mesh_url": collision_mesh_url,
"available": collision_mesh_url is not None,
},
"transforms": {
"world_from_visual": [1, 0, 0, 0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1],
"world_from_collision": (
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
if rover_collision_mesh_url is not None
else [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1]
),
},
}
def _default_viewer_settings() -> dict[str, Any]:
return {
"schema_version": VIEWER_SETTINGS_SCHEMA,
"quality": "high",
"visual": {"rotation_degrees": {"x": 180.0, "y": 0.0, "z": 0.0}},
"collision": {"rotation_degrees": {"x": 0.0, "y": 0.0, "z": 0.0}},
"camera": {
"invert_horizontal": True,
"invert_vertical": False,
},
"ugv": _default_ugv_settings(),
}
def _default_ugv_settings() -> dict[str, Any]:
return {
"preset_name": "UGV 100 кг",
"mass_kg": 100.0,
"dimensions_m": {
"length": 1.0,
"width": 0.8,
"height": 0.4,
"ground_clearance": 0.15,
},
"max_speed_mps": 1.2,
"max_turn_rate_degrees": 45.0,
"invert_steering": False,
}
def _viewer_settings(value: object) -> dict[str, Any]:
if not isinstance(value, dict) or set(value) != {
"schema_version",
"quality",
"visual",
"collision",
"camera",
"ugv",
}:
raise SimulationProjectError("simulation viewer settings contract is invalid")
if value.get("schema_version") != VIEWER_SETTINGS_SCHEMA:
raise SimulationProjectError("simulation viewer settings version is unsupported")
quality = value.get("quality")
if quality not in {"low", "medium", "high", "ultra", "maximum"}:
raise SimulationProjectError("simulation viewer quality is invalid")
def layer(name: str) -> dict[str, Any]:
raw = value.get(name)
if not isinstance(raw, dict) or set(raw) != {"rotation_degrees"}:
raise SimulationProjectError(f"simulation {name} transform is invalid")
raw_rotation = raw.get("rotation_degrees")
if not isinstance(raw_rotation, dict) or set(raw_rotation) != {"x", "y", "z"}:
raise SimulationProjectError(f"simulation {name} transform is invalid")
rotation: dict[str, float] = {}
for axis in ("x", "y", "z"):
angle = raw_rotation.get(axis)
if (
not isinstance(angle, (int, float))
or isinstance(angle, bool)
or not math.isfinite(angle)
or angle < -360.0
or angle > 360.0
):
raise SimulationProjectError(f"simulation {name} transform is invalid")
rotation[axis] = float(angle)
return {"rotation_degrees": rotation}
camera = value.get("camera")
if not isinstance(camera, dict) or set(camera) != {"invert_horizontal", "invert_vertical"}:
raise SimulationProjectError("simulation camera settings are invalid")
horizontal = camera.get("invert_horizontal")
vertical = camera.get("invert_vertical")
if not isinstance(horizontal, bool) or not isinstance(vertical, bool):
raise SimulationProjectError("simulation camera settings are invalid")
ugv = value.get("ugv")
if not isinstance(ugv, dict) or set(ugv) != {
"preset_name",
"mass_kg",
"dimensions_m",
"max_speed_mps",
"max_turn_rate_degrees",
"invert_steering",
}:
raise SimulationProjectError("simulation UGV settings are invalid")
preset_name = " ".join(str(ugv.get("preset_name", "")).split())
if not 1 <= len(preset_name) <= 80:
raise SimulationProjectError("simulation UGV preset name is invalid")
dimensions = ugv.get("dimensions_m")
if not isinstance(dimensions, dict) or set(dimensions) != {
"length",
"width",
"height",
"ground_clearance",
}:
raise SimulationProjectError("simulation UGV dimensions are invalid")
def bounded_number(
raw: object,
*,
minimum: float,
maximum: float,
label: str,
) -> float:
if (
not isinstance(raw, (int, float))
or isinstance(raw, bool)
or not math.isfinite(raw)
or raw < minimum
or raw > maximum
):
raise SimulationProjectError(f"simulation UGV {label} is invalid")
return float(raw)
mass_kg = bounded_number(
ugv.get("mass_kg"), minimum=0.01, maximum=1_000_000_000, label="mass"
)
length = bounded_number(
dimensions.get("length"), minimum=0.01, maximum=1_000_000_000, label="length"
)
width = bounded_number(
dimensions.get("width"), minimum=0.01, maximum=1_000_000_000, label="width"
)
height = bounded_number(
dimensions.get("height"), minimum=0.07, maximum=1_000_000_000, label="height"
)
ground_clearance = bounded_number(
dimensions.get("ground_clearance"),
minimum=0.01,
maximum=1_000_000_000,
label="ground clearance",
)
if ground_clearance >= height - 0.05:
raise SimulationProjectError("simulation UGV ground clearance exceeds its height")
max_speed_mps = bounded_number(
ugv.get("max_speed_mps"), minimum=0, maximum=1_000_000_000, label="maximum speed"
)
max_turn_rate_degrees = bounded_number(
ugv.get("max_turn_rate_degrees"),
minimum=0,
maximum=1_000_000_000,
label="maximum turn rate",
)
invert_steering = ugv.get("invert_steering")
if not isinstance(invert_steering, bool):
raise SimulationProjectError("simulation UGV steering inversion is invalid")
return {
"schema_version": VIEWER_SETTINGS_SCHEMA,
"quality": quality,
"visual": layer("visual"),
"collision": layer("collision"),
"camera": {
"invert_horizontal": horizontal,
"invert_vertical": vertical,
},
"ugv": {
"preset_name": preset_name,
"mass_kg": mass_kg,
"dimensions_m": {
"length": length,
"width": width,
"height": height,
"ground_clearance": ground_clearance,
},
"max_speed_mps": max_speed_mps,
"max_turn_rate_degrees": max_turn_rate_degrees,
"invert_steering": invert_steering,
},
}
def _migrate_viewer_settings(value: object) -> dict[str, Any]:
if not isinstance(value, dict):
return _default_viewer_settings()
if value.get("schema_version") == VIEWER_SETTINGS_SCHEMA:
return _viewer_settings(value)
if value.get("schema_version") == "missioncore.simulation-viewer-settings/v2":
migrated_v2 = {
**value,
"schema_version": VIEWER_SETTINGS_SCHEMA,
"ugv": _default_ugv_settings(),
}
try:
return _viewer_settings(migrated_v2)
except SimulationProjectError:
return _default_viewer_settings()
if value.get("schema_version") != "missioncore.simulation-viewer-settings/v1":
return _default_viewer_settings()
def legacy_layer(name: str) -> dict[str, Any]:
raw = value.get(name)
rotation = {"x": 0.0, "y": 0.0, "z": 0.0}
if isinstance(raw, dict) and raw.get("inverted") is True:
axis = raw.get("axis")
if axis in rotation:
rotation[axis] = 180.0
return {"rotation_degrees": rotation}
camera = value.get("camera")
if not isinstance(camera, dict):
camera = {}
migrated = {
"schema_version": VIEWER_SETTINGS_SCHEMA,
"quality": value.get("quality", "high"),
"visual": legacy_layer("visual"),
"collision": legacy_layer("collision"),
"camera": {
"invert_horizontal": camera.get("invert_horizontal", True),
"invert_vertical": camera.get("invert_vertical", False),
},
"ugv": _default_ugv_settings(),
}
try:
return _viewer_settings(migrated)
except SimulationProjectError:
return _default_viewer_settings()
def _project_name(value: str) -> str:
normalized = " ".join(value.split())
if not 1 <= len(normalized) <= 120:
raise SimulationProjectError("simulation project name is invalid")
return normalized
def _provider_timestamp(value: str, label: str) -> str:
if PROVIDER_TIMESTAMP_PATTERN.fullmatch(value) is None:
raise SimulationProjectError(f"simulation provider {label} timestamp is invalid")
return value
def _optional_provider_timestamp(value: object) -> str | None:
if isinstance(value, str) and PROVIDER_TIMESTAMP_PATTERN.fullmatch(value) is not None:
return value
return None
def _logical_path(value: object) -> str:
if not isinstance(value, str) or not value or len(value) > 1024:
raise SimulationProjectError("simulation logical path is invalid")
if value.startswith("/") or "\\" in value or "\x00" in value:
raise SimulationProjectError("simulation logical path is unsafe")
parts = value.split("/")
if any(part in {"", ".", ".."} for part in parts):
raise SimulationProjectError("simulation logical path is unsafe")
return value
def _source_file(document: dict[str, Any], file_id: str) -> dict[str, Any]:
if SOURCE_FILE_ID_PATTERN.fullmatch(file_id) is None:
raise SimulationProjectNotFoundError("simulation source file is unavailable")
source_file = next(
(item for item in document["source"]["files"] if item.get("file_id") == file_id),
None,
)
if source_file is None:
raise SimulationProjectNotFoundError("simulation source file is unavailable")
return source_file
def _confined_path(root: Path, logical_path: str) -> Path:
safe = _logical_path(logical_path)
candidate = root.joinpath(*safe.split("/")).resolve()
resolved_root = root.resolve()
if not candidate.is_relative_to(resolved_root):
raise SimulationProjectError("simulation path escaped its project root")
return candidate
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()