feat: add Gaussian simulation workspace
This commit is contained in:
@@ -48,6 +48,7 @@ from k1link.sessions import (
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
)
|
||||
from k1link.simulation.projects import SimulationProjectService, SimulationProjectStore
|
||||
from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
|
||||
from k1link.web.artifact_health_api import build_artifact_health_router
|
||||
from k1link.web.compute_contour_api import build_compute_contour_router
|
||||
@@ -153,6 +154,7 @@ from k1link.web.runtime_readiness import (
|
||||
)
|
||||
from k1link.web.session_api import build_session_router
|
||||
from k1link.web.simulation_world_provider_api import build_simulation_world_provider_router
|
||||
from k1link.web.simulation_projects_api import build_simulation_projects_router
|
||||
from k1link.web.system_telemetry_api import build_system_telemetry_router
|
||||
from k1link.web.viewer_diagnostics_api import build_viewer_diagnostics_router
|
||||
|
||||
@@ -201,6 +203,8 @@ plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
session_store = SessionStore(REPOSITORY_ROOT)
|
||||
simulation_project_store = SimulationProjectStore(session_store.data_dir)
|
||||
simulation_project_service = SimulationProjectService(simulation_project_store)
|
||||
session_artifact_gateway = configured_artifact_gateway(session_store.data_dir)
|
||||
lidar_local_surface_read_service = K1LocalSurfaceReadService(
|
||||
session_store.data_dir / "lidar-read-cache"
|
||||
@@ -478,6 +482,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
configure_scanner_diagnostics(session_store.data_dir / "logs")
|
||||
session_recording_preparation_manager.start()
|
||||
simulation_project_service.recover_pending()
|
||||
# Recovery is intentionally a one-shot startup phase. The archive
|
||||
# helper owns a cross-process lease, while ordinary catalog requests
|
||||
# only perform discovery and therefore never touch a live writer.
|
||||
@@ -1310,6 +1315,12 @@ app.include_router(
|
||||
)
|
||||
)
|
||||
app.include_router(build_simulation_world_provider_router())
|
||||
app.include_router(
|
||||
build_simulation_projects_router(
|
||||
store=simulation_project_store,
|
||||
service=simulation_project_service,
|
||||
)
|
||||
)
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
app.include_router(
|
||||
build_viewer_diagnostics_router(
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Mission Core API for durable Gaussian simulation projects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.simulation.gaussian_pipeline_gateway import GaussianPipelineGatewayError
|
||||
from k1link.simulation.projects import (
|
||||
MAX_UPLOAD_CHUNK_BYTES,
|
||||
PROJECT_SCHEMA,
|
||||
SimulationProjectConflictError,
|
||||
SimulationProjectError,
|
||||
SimulationProjectNotFoundError,
|
||||
SimulationProjectService,
|
||||
SimulationProjectStore,
|
||||
)
|
||||
|
||||
|
||||
class SimulationSourceFileCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
logical_path: str = Field(min_length=1, max_length=1024)
|
||||
byte_length: int = Field(gt=0)
|
||||
|
||||
|
||||
class SimulationProjectCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: Literal["missioncore.simulation-project-create/v1"]
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
scene_type: Literal["interior", "outdoor", "object"]
|
||||
source_kind: Literal["archive", "folder"]
|
||||
files: list[SimulationSourceFileCreate] = Field(min_length=1, max_length=10_000)
|
||||
|
||||
|
||||
class SimulationProjectUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: Literal["missioncore.simulation-project-update/v1"]
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
scene_type: Literal["interior", "outdoor", "object"]
|
||||
|
||||
|
||||
class SimulationProjectDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
schema_version: Literal["missioncore.simulation-project/v1"] = PROJECT_SCHEMA
|
||||
project_id: str
|
||||
name: str
|
||||
scene_type: Literal["interior", "outdoor", "object"]
|
||||
status: Literal["uploading", "queued", "processing", "importing", "ready", "failed"]
|
||||
source: dict[str, Any]
|
||||
provider: dict[str, Any]
|
||||
artifacts: list[dict[str, Any]]
|
||||
world_manifest: dict[str, Any] | None
|
||||
error: str | None
|
||||
created_at_utc: str
|
||||
updated_at_utc: str
|
||||
|
||||
|
||||
class SimulationProjectPage(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: Literal["missioncore.simulation-project-page/v1"] = (
|
||||
"missioncore.simulation-project-page/v1"
|
||||
)
|
||||
projects: list[SimulationProjectDocument]
|
||||
|
||||
|
||||
def build_simulation_projects_router(
|
||||
*,
|
||||
store: SimulationProjectStore,
|
||||
service: SimulationProjectService,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/simulation-worlds", tags=["simulation-worlds"])
|
||||
|
||||
@router.get("/projects", response_model=SimulationProjectPage)
|
||||
def list_projects() -> SimulationProjectPage:
|
||||
return SimulationProjectPage(projects=store.list())
|
||||
|
||||
@router.post("/projects", response_model=SimulationProjectDocument, status_code=201)
|
||||
def create_project(request: SimulationProjectCreate) -> dict[str, Any]:
|
||||
try:
|
||||
return store.create(
|
||||
name=request.name,
|
||||
scene_type=request.scene_type,
|
||||
source_kind=request.source_kind,
|
||||
files=[item.model_dump() for item in request.files],
|
||||
)
|
||||
except SimulationProjectError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=SimulationProjectDocument)
|
||||
def get_project(project_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return store.get(project_id)
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
|
||||
|
||||
@router.patch("/projects/{project_id}", response_model=SimulationProjectDocument)
|
||||
def update_project(
|
||||
project_id: str,
|
||||
request: SimulationProjectUpdate,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return store.update_metadata(
|
||||
project_id,
|
||||
name=request.name,
|
||||
scene_type=request.scene_type,
|
||||
)
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
|
||||
except SimulationProjectError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.head("/projects/{project_id}/source/{file_id}")
|
||||
def source_upload_state(project_id: str, file_id: str) -> Response:
|
||||
try:
|
||||
offset, byte_length = store.upload_state(project_id, file_id)
|
||||
return Response(status_code=204, headers={
|
||||
"Upload-Offset": str(offset),
|
||||
"Upload-Length": str(byte_length),
|
||||
"Cache-Control": "no-store",
|
||||
})
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Исходный файл не найден.") from exc
|
||||
|
||||
@router.patch("/projects/{project_id}/source/{file_id}")
|
||||
async def append_source_upload(
|
||||
project_id: str,
|
||||
file_id: str,
|
||||
request: Request,
|
||||
upload_offset: str | None = Header(default=None, alias="Upload-Offset"),
|
||||
) -> Response:
|
||||
if upload_offset is None or not upload_offset.isdigit():
|
||||
raise HTTPException(status_code=400, detail="Некорректное смещение загрузки.")
|
||||
content_length = request.headers.get("content-length")
|
||||
if (
|
||||
content_length is None
|
||||
or not content_length.isdigit()
|
||||
or not 0 < int(content_length) <= MAX_UPLOAD_CHUNK_BYTES
|
||||
):
|
||||
raise HTTPException(status_code=413, detail="Некорректный размер блока загрузки.")
|
||||
payload = await request.body()
|
||||
if len(payload) != int(content_length):
|
||||
raise HTTPException(status_code=400, detail="Неполный блок загрузки.")
|
||||
try:
|
||||
project = store.append_upload(
|
||||
project_id,
|
||||
file_id,
|
||||
offset=int(upload_offset),
|
||||
payload=payload,
|
||||
)
|
||||
source_file = next(
|
||||
item for item in project["source"]["files"] if item["file_id"] == file_id
|
||||
)
|
||||
return Response(status_code=204, headers={
|
||||
"Upload-Offset": str(source_file["uploaded_bytes"]),
|
||||
"Upload-Length": str(source_file["byte_length"]),
|
||||
"Cache-Control": "no-store",
|
||||
})
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Исходный файл не найден.") from exc
|
||||
except SimulationProjectConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except SimulationProjectError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/projects/{project_id}/build", response_model=SimulationProjectDocument)
|
||||
def build_project(
|
||||
project_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
project = store.begin_build(project_id)
|
||||
background_tasks.add_task(service.process, project_id)
|
||||
return project
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
|
||||
except SimulationProjectConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
@router.delete("/projects/{project_id}", status_code=204)
|
||||
def delete_project(project_id: str) -> Response:
|
||||
try:
|
||||
service.delete(project_id)
|
||||
return Response(status_code=204, headers={"Cache-Control": "no-store"})
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
|
||||
except SimulationProjectConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except GaussianPipelineGatewayError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
@router.get("/projects/{project_id}/artifacts/{logical_path:path}")
|
||||
def get_artifact(project_id: str, logical_path: str) -> FileResponse:
|
||||
try:
|
||||
filename, descriptor = store.artifact_path(project_id, logical_path)
|
||||
return FileResponse(
|
||||
filename,
|
||||
media_type=str(descriptor["media_type"]),
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"sha256-{descriptor["sha256"]}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Артефакт сцены не найден.") from exc
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user