+
{selected.status === "failed" ? "Сборка остановлена" : "Worker 006 собирает мир"}
-
{selected.error ?? processingMessage(selected)}
+
{error ?? selected.error ?? processingMessage(selected)}
+
+
+
+ {selected.provider.state ?? selected.status}
+
+ {selected.status === "failed" ? (
+
+ : }
+ onClick={() => void retry(selected)}
+ >
+ Повторить сборку
+
+ ) : null}
-
- {selected.provider.state ?? selected.status}
-
)}
diff --git a/apps/control-station/test/simulationWorkspace.test.mjs b/apps/control-station/test/simulationWorkspace.test.mjs
index 8c84602..b534207 100644
--- a/apps/control-station/test/simulationWorkspace.test.mjs
+++ b/apps/control-station/test/simulationWorkspace.test.mjs
@@ -39,6 +39,18 @@ test("browser upload remains same-origin, resumable and token-free", async () =>
assert.match(window, /webkitdirectory/);
assert.match(window, /webkitGetAsEntry/);
assert.match(window, /ровно одна сцена \.lcc или \.lcc2/);
+ assert.doesNotMatch(window, /Загружаем источник/);
+});
+
+test("failed simulation can restart from its retained Mission Core source", async () => {
+ const [core, workspace] = await Promise.all([
+ read("core/simulation/projects.ts"),
+ read("workspaces/simulation/SimulationWorkspace.tsx"),
+ ]);
+
+ assert.match(core, /retrySimulationProject/);
+ assert.match(core, /\$\{API_ROOT\}\/\$\{encodeURIComponent\(projectId\)\}\/build/);
+ assert.match(workspace, /Повторить сборку/);
});
test("PlayCanvas owns the realtime scene graph without an iframe or React entity tree", async () => {
diff --git a/src/k1link/simulation/gaussian_pipeline_gateway.py b/src/k1link/simulation/gaussian_pipeline_gateway.py
index a5304c9..0ff878b 100644
--- a/src/k1link/simulation/gaussian_pipeline_gateway.py
+++ b/src/k1link/simulation/gaussian_pipeline_gateway.py
@@ -29,6 +29,7 @@ IMAGE_DIGEST_PATTERN: Final = re.compile(r"^sha256:[a-f0-9]{64}$")
DEFAULT_CHUNK_BYTES: Final = 8 * 1024 * 1024
MAX_JSON_RESPONSE_BYTES: Final = 32 * 1024 * 1024
MAX_RETRIES: Final = 3
+DEFAULT_INGEST_TIMEOUT_SECONDS: Final = 30 * 60.0
class GaussianPipelineGatewayError(RuntimeError):
@@ -108,6 +109,7 @@ class GaussianPipelineGateway:
token_file: Path,
*,
timeout_seconds: float = 30.0,
+ ingest_timeout_seconds: float = DEFAULT_INGEST_TIMEOUT_SECONDS,
chunk_bytes: int = DEFAULT_CHUNK_BYTES,
transport: httpx.BaseTransport | None = None,
) -> None:
@@ -116,9 +118,14 @@ class GaussianPipelineGateway:
self.token = _read_token(self.token_file)
if timeout_seconds <= 0:
raise GaussianPipelineConfigurationError("provider timeout must be positive")
+ if ingest_timeout_seconds <= 0:
+ raise GaussianPipelineConfigurationError(
+ "provider archive ingest timeout must be positive"
+ )
if chunk_bytes <= 0 or chunk_bytes > 64 * 1024 * 1024:
raise GaussianPipelineConfigurationError("provider upload chunk size is invalid")
self.chunk_bytes = chunk_bytes
+ self.ingest_timeout_seconds = ingest_timeout_seconds
self._client = httpx.Client(
base_url=self.endpoint,
headers={"Authorization": f"Bearer {self.token}"},
@@ -271,6 +278,7 @@ class GaussianPipelineGateway:
"schema_version": ARCHIVE_INGEST_REQUEST_SCHEMA,
"archive": archive.to_dict(),
},
+ timeout_seconds=self.ingest_timeout_seconds,
)
if document.get("schema_version") != ARCHIVE_INGEST_SCHEMA:
raise GaussianPipelineGatewayError(
@@ -480,11 +488,22 @@ class GaussianPipelineGateway:
path: str,
*,
document: Mapping[str, object] | None = None,
+ timeout_seconds: float | None = None,
) -> dict[str, Any]:
try:
- response = self._client.request(method, path, json=document)
+ if timeout_seconds is None:
+ response = self._client.request(method, path, json=document)
+ else:
+ response = self._client.request(
+ method,
+ path,
+ json=document,
+ timeout=httpx.Timeout(timeout_seconds),
+ )
response.raise_for_status()
- except httpx.HTTPError as exc:
+ except httpx.HTTPStatusError as exc:
+ raise _provider_rejection(exc.response) from exc
+ except httpx.TransportError as exc:
raise _unavailable("Gaussian provider request failed", exc) from exc
if len(response.content) > MAX_JSON_RESPONSE_BYTES:
raise GaussianPipelineGatewayError("Gaussian provider JSON response is too large")
@@ -853,3 +872,24 @@ def _unavailable(message: str, error: httpx.HTTPError) -> GaussianPipelineGatewa
if isinstance(error, httpx.TransportError):
return GaussianPipelineUnavailableError(message)
return GaussianPipelineGatewayError(message)
+
+
+def _provider_rejection(response: httpx.Response) -> GaussianPipelineGatewayError:
+ detail: str | None = None
+ if len(response.content) <= MAX_JSON_RESPONSE_BYTES:
+ try:
+ document = response.json()
+ except json.JSONDecodeError:
+ document = None
+ if isinstance(document, dict):
+ raw_detail = document.get("message")
+ if not isinstance(raw_detail, str) or not raw_detail.strip():
+ raw_detail = document.get("error")
+ if isinstance(raw_detail, str):
+ normalized = " ".join(raw_detail.split())
+ if normalized:
+ detail = normalized[:1024]
+ message = f"Gaussian provider rejected request (HTTP {response.status_code})"
+ if detail is not None:
+ message = f"{message}: {detail}"
+ return GaussianPipelineGatewayError(message)
diff --git a/src/k1link/simulation/projects.py b/src/k1link/simulation/projects.py
index 3223e6a..2c6ddf2 100644
--- a/src/k1link/simulation/projects.py
+++ b/src/k1link/simulation/projects.py
@@ -239,7 +239,7 @@ class SimulationProjectStore:
def begin_build(self, project_id: str) -> dict[str, Any]:
with self._lock:
document = self._read(project_id)
- if document["status"] != "uploading":
+ if document["status"] not in {"uploading", "failed"}:
raise SimulationProjectConflictError("simulation project cannot start a build")
if any(
int(item["uploaded_bytes"]) != int(item["byte_length"])
@@ -247,6 +247,15 @@ class SimulationProjectStore:
):
raise SimulationProjectConflictError("simulation source upload is incomplete")
document["status"] = "queued"
+ document["provider"] = {
+ "provider_id": "gaussian-pipeline",
+ "job_id": None,
+ "state": None,
+ "progress": None,
+ "runtime": None,
+ }
+ document["artifacts"] = []
+ document["world_manifest"] = None
document["error"] = None
document["updated_at_utc"] = utc_now_iso()
self._write(document)
@@ -402,6 +411,22 @@ class SimulationProjectService:
).start()
return len(pending)
+ 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")
+ if isinstance(job_id, str):
+ provider = self.provider_factory()
+ if provider is None:
+ raise SimulationProjectConflictError(
+ "Gaussian Pipeline недоступен для повторной сборки."
+ )
+ try:
+ 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:
diff --git a/src/k1link/web/simulation_projects_api.py b/src/k1link/web/simulation_projects_api.py
index e7ca746..ec76746 100644
--- a/src/k1link/web/simulation_projects_api.py
+++ b/src/k1link/web/simulation_projects_api.py
@@ -176,13 +176,15 @@ def build_simulation_projects_router(
background_tasks: BackgroundTasks,
) -> dict[str, Any]:
try:
- project = store.begin_build(project_id)
+ project = service.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
+ except GaussianPipelineGatewayError as exc:
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.delete("/projects/{project_id}", status_code=204)
def delete_project(project_id: str) -> Response:
diff --git a/tests/test_gaussian_pipeline_gateway.py b/tests/test_gaussian_pipeline_gateway.py
index 99f1f1c..45b3ee1 100644
--- a/tests/test_gaussian_pipeline_gateway.py
+++ b/tests/test_gaussian_pipeline_gateway.py
@@ -10,6 +10,7 @@ import pytest
from k1link.simulation.gaussian_pipeline_gateway import (
BUILD_REQUEST_SCHEMA,
+ GaussianArchiveUpload,
GaussianPipelineGateway,
GaussianPipelineGatewayError,
GaussianPipelineIntegrityError,
@@ -163,6 +164,7 @@ def test_gateway_uploads_and_normalizes_archive_with_tus(tmp_path: Path) -> None
received.extend(request.content)
return httpx.Response(204, headers={"Upload-Offset": str(len(received))})
if request.method == "POST" and request.url.path == "/v1/ingests":
+ assert request.extensions["timeout"]["read"] == 30 * 60.0
submitted = json.loads(request.content)
assert submitted["archive"] == {
"upload_id": "archive-001",
@@ -202,6 +204,36 @@ def test_gateway_uploads_and_normalizes_archive_with_tus(tmp_path: Path) -> None
assert source.bundle_sha256 == bundle_sha
+def test_gateway_surfaces_bounded_provider_rejection_detail(tmp_path: Path) -> None:
+ def handler(_request: httpx.Request) -> httpx.Response:
+ return httpx.Response(
+ 400,
+ json={
+ "error": "archive_link",
+ "message": "archive links are not supported",
+ },
+ )
+
+ with (
+ GaussianPipelineGateway(
+ "http://gaussian.test",
+ _token_file(tmp_path),
+ transport=httpx.MockTransport(handler),
+ ) as gateway,
+ pytest.raises(
+ GaussianPipelineGatewayError,
+ match=r"HTTP 400.*archive links are not supported",
+ ),
+ ):
+ gateway.normalize_archive(GaussianArchiveUpload(
+ upload_id="archive-001",
+ archive_name="source.rar",
+ format="rar",
+ sha256="a" * 64,
+ byte_length=128,
+ ))
+
+
def test_gateway_rejects_incomplete_lcc_bundle(tmp_path: Path) -> None:
bundle = tmp_path / "bundle"
bundle.mkdir()
diff --git a/tests/test_simulation_projects.py b/tests/test_simulation_projects.py
index 5526b3a..38ea844 100644
--- a/tests/test_simulation_projects.py
+++ b/tests/test_simulation_projects.py
@@ -228,6 +228,37 @@ def test_service_resumes_a_persisted_provider_job_without_reupload(tmp_path: Pat
assert provider.submit_calls == 0
+def test_failed_project_retries_from_retained_source_and_releases_old_job(tmp_path: Path) -> None:
+ store = SimulationProjectStore(tmp_path)
+ project = store.create(
+ name="Retry scene",
+ scene_type="outdoor",
+ source_kind="folder",
+ files=_folder_files(),
+ )
+ _upload_all(store, project)
+ store.begin_build(project["project_id"])
+ store.update_processing(
+ project["project_id"],
+ status="processing",
+ provider_job_id="gsp-20260826000000-deadbeef",
+ provider_state="failed",
+ progress={"completed_steps": 1, "total_steps": 4},
+ bundle_sha256="d" * 64,
+ )
+ store.fail(project["project_id"], "provider failure")
+ provider = _ReadyProvider()
+ service = SimulationProjectService(store, provider_factory=lambda: provider) # type: ignore[arg-type]
+
+ queued = service.begin_build(project["project_id"])
+
+ assert provider.deleted == ["gsp-20260826000000-deadbeef"]
+ assert queued["status"] == "queued"
+ assert queued["error"] is None
+ assert queued["provider"]["job_id"] is None
+ assert queued["source"]["uploaded_byte_length"] == queued["source"]["total_byte_length"]
+
+
def test_api_exposes_same_origin_resumable_upload_contract(tmp_path: Path) -> None:
store = SimulationProjectStore(tmp_path)
service = SimulationProjectService(store, provider_factory=lambda: None)