1153 lines
46 KiB
Python
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()
|