feat(observatory): add laboratory setup preflight
This commit is contained in:
+20
-1
@@ -36,6 +36,7 @@ from k1link.laboratory.m48_raw_evidence import (
|
||||
M48RawEvidenceError,
|
||||
M48RawEvidenceReader,
|
||||
)
|
||||
from k1link.observatory import LaboratorySetupRegistry, LaboratorySetupRegistryError
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedCameraFrameService,
|
||||
@@ -190,6 +191,18 @@ LABORATORY_RUNNER = LaboratoryRunner(
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file(
|
||||
REPOSITORY_ROOT / "config" / "laboratory-value-review.json"
|
||||
)
|
||||
try:
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY = LaboratorySetupRegistry.from_file(
|
||||
REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json",
|
||||
repository_root=REPOSITORY_ROOT,
|
||||
)
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR = None
|
||||
except (LaboratorySetupRegistryError, OSError) as exc:
|
||||
# Observatory is an optional observation-only slice. Its configuration must
|
||||
# fail closed locally without preventing K1, Simulation or legacy LAB from
|
||||
# starting.
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY = None
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR = str(exc)
|
||||
LABORATORY_EVIDENCE_REPORTS = LaboratoryEvidenceReportService(
|
||||
LABORATORY_EVIDENCE_REGISTRY,
|
||||
lambda: REPOSITORY_ROOT / ".runtime" / "compute-experiments",
|
||||
@@ -692,7 +705,13 @@ app.include_router(
|
||||
point_color_renderers=plugin_environment.point_color_renderers,
|
||||
)
|
||||
)
|
||||
app.include_router(build_observatory_router(session_store))
|
||||
app.include_router(
|
||||
build_observatory_router(
|
||||
session_store,
|
||||
setup_registry=OBSERVATORY_LABORATORY_SETUP_REGISTRY,
|
||||
setup_registry_error=OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_environment_router(root_provider=lambda: session_store.data_dir / "ui-environment")
|
||||
)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.observatory import (
|
||||
LaboratorySetupRegistry,
|
||||
is_admitted_observatory_recorded_result,
|
||||
)
|
||||
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
|
||||
|
||||
OBSERVATORY_PROJECTION_SCHEMA: Literal[
|
||||
@@ -13,6 +17,12 @@ OBSERVATORY_PROJECTION_SCHEMA: Literal[
|
||||
OBSERVATORY_RENAME_SCHEMA: Literal[
|
||||
"missioncore.observatory-lab-projection-rename/v1"
|
||||
] = "missioncore.observatory-lab-projection-rename/v1"
|
||||
OBSERVATORY_RUN_PREFLIGHT_REQUEST_SCHEMA: Literal[
|
||||
"missioncore.observatory-run-preflight-request/v1"
|
||||
] = "missioncore.observatory-run-preflight-request/v1"
|
||||
OBSERVATORY_RUN_PREFLIGHT_SCHEMA: Literal[
|
||||
"missioncore.observatory-run-preflight/v1"
|
||||
] = "missioncore.observatory-run-preflight/v1"
|
||||
|
||||
|
||||
class _StrictApiModel(BaseModel):
|
||||
@@ -32,11 +42,206 @@ class ObservatoryProjectionDocument(_StrictApiModel):
|
||||
display_name: str
|
||||
|
||||
|
||||
def build_observatory_router(store: SessionStore) -> APIRouter:
|
||||
class ObservatoryRunPreflightRequest(_StrictApiModel):
|
||||
schema_version: Literal["missioncore.observatory-run-preflight-request/v1"]
|
||||
source_session_id: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
)
|
||||
setup_id: str = Field(
|
||||
min_length=3,
|
||||
max_length=96,
|
||||
pattern=r"^[a-z][a-z0-9-]{2,95}$",
|
||||
)
|
||||
definition_sha256: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
def build_observatory_router(
|
||||
store: SessionStore,
|
||||
*,
|
||||
setup_registry: LaboratorySetupRegistry | None = None,
|
||||
setup_registry_error: str | None = None,
|
||||
) -> APIRouter:
|
||||
"""Build bounded catalog-only mutations for typed Observatory projections."""
|
||||
|
||||
router = APIRouter(tags=["observatory"])
|
||||
|
||||
def source_summary(session_id: str):
|
||||
try:
|
||||
summary = store.get_session(session_id).summary
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Исходная сессия не найдена.") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректный идентификатор исходной сессии.",
|
||||
) from exc
|
||||
except SessionIntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Каталог исходной сессии нарушил контракт целостности.",
|
||||
) from exc
|
||||
if summary.lab is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Для запуска нужна исходная, а не лабораторная сессия.",
|
||||
)
|
||||
return summary
|
||||
|
||||
def available_observatory_results(source_session_id: str) -> frozenset[str]:
|
||||
if setup_registry is None:
|
||||
return frozenset()
|
||||
available: set[str] = set()
|
||||
for result_id in setup_registry.observatory_result_ids:
|
||||
expected_result_kind = setup_registry.observatory_result_kind(result_id)
|
||||
try:
|
||||
summary = store.get_session(result_id).summary
|
||||
except (SessionIntegrityError, SessionNotFoundError, ValueError):
|
||||
continue
|
||||
if expected_result_kind is not None and is_admitted_observatory_recorded_result(
|
||||
summary,
|
||||
expected_result_id=result_id,
|
||||
expected_source_session_id=source_session_id,
|
||||
expected_result_kind=expected_result_kind,
|
||||
):
|
||||
available.add(result_id)
|
||||
return frozenset(available)
|
||||
|
||||
if setup_registry is not None:
|
||||
|
||||
@router.get("/api/v1/observatory/laboratory-setups")
|
||||
def list_observatory_laboratory_setups(
|
||||
source_session_id: str = Query(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
source = source_summary(source_session_id)
|
||||
return setup_registry.catalog(
|
||||
source,
|
||||
available_observatory_result_ids=available_observatory_results(
|
||||
source_session_id
|
||||
),
|
||||
)
|
||||
|
||||
@router.post("/api/v1/observatory/run-preflights")
|
||||
def preflight_observatory_run(
|
||||
request: ObservatoryRunPreflightRequest,
|
||||
) -> dict[str, object]:
|
||||
source = source_summary(request.source_session_id)
|
||||
try:
|
||||
setup_registry.setup(request.setup_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="Сетап лаборатории не найден.") from exc
|
||||
catalog = setup_registry.catalog(
|
||||
source,
|
||||
available_observatory_result_ids=available_observatory_results(
|
||||
request.source_session_id
|
||||
),
|
||||
)
|
||||
setups = catalog["setups"]
|
||||
assert isinstance(setups, list)
|
||||
projected = next(
|
||||
item
|
||||
for item in setups
|
||||
if isinstance(item, dict) and item.get("setup_id") == request.setup_id
|
||||
)
|
||||
definition = projected["run_definition"]
|
||||
expected_digest = (
|
||||
definition.get("definition_sha256")
|
||||
if isinstance(definition, dict)
|
||||
else None
|
||||
)
|
||||
if request.definition_sha256 != expected_digest:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Идентичность RunDefinition изменилась; обновите каталог.",
|
||||
)
|
||||
compatibility = projected["compatibility"]
|
||||
preflight = projected["preflight"]
|
||||
executor = projected["executor"]
|
||||
assert isinstance(compatibility, dict)
|
||||
assert isinstance(preflight, dict)
|
||||
assert isinstance(executor, dict)
|
||||
compatible = compatibility.get("compatible") is True
|
||||
existing = preflight.get("outcome") == "existing"
|
||||
checks: list[dict[str, Any]] = [
|
||||
{
|
||||
"check_id": "source-compatibility",
|
||||
"outcome": "pass" if compatible else "fail",
|
||||
"reason_code": (
|
||||
"source-compatible" if compatible else "source-incompatible"
|
||||
),
|
||||
"message": (
|
||||
"Источник точно совместим с сохранённым сетапом."
|
||||
if compatible
|
||||
else str(preflight.get("reason"))
|
||||
),
|
||||
},
|
||||
{
|
||||
"check_id": "existing-result",
|
||||
"outcome": "pass" if existing else "not-applicable",
|
||||
"reason_code": (
|
||||
"exact-result-available" if existing else "exact-result-not-openable"
|
||||
),
|
||||
"message": str(preflight.get("reason")),
|
||||
},
|
||||
{
|
||||
"check_id": "executor",
|
||||
"outcome": "not-applicable" if existing else "fail",
|
||||
"reason_code": (
|
||||
"existing-result-does-not-require-executor"
|
||||
if existing
|
||||
else str(executor.get("reason_code"))
|
||||
),
|
||||
"message": (
|
||||
"Готовый результат открывается без повторного запуска Worker."
|
||||
if existing
|
||||
else str(executor.get("reason"))
|
||||
),
|
||||
},
|
||||
]
|
||||
return {
|
||||
"schema_version": OBSERVATORY_RUN_PREFLIGHT_SCHEMA,
|
||||
"source_session_id": request.source_session_id,
|
||||
"setup_id": request.setup_id,
|
||||
"definition_sha256": expected_digest,
|
||||
"outcome": "existing" if existing else "blocked",
|
||||
"submission_allowed": False,
|
||||
"checks": checks,
|
||||
"existing_result_ids": preflight.get("existing_result_ids", []),
|
||||
"executor": executor,
|
||||
"authority": catalog["authority"],
|
||||
}
|
||||
|
||||
elif setup_registry_error is not None:
|
||||
|
||||
@router.get("/api/v1/observatory/laboratory-setups")
|
||||
def unavailable_observatory_laboratory_setups(
|
||||
source_session_id: str = Query(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
),
|
||||
) -> None:
|
||||
del source_session_id
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории не прошёл проверку целостности.",
|
||||
)
|
||||
|
||||
@router.post("/api/v1/observatory/run-preflights")
|
||||
def unavailable_observatory_run_preflight(
|
||||
request: ObservatoryRunPreflightRequest,
|
||||
) -> None:
|
||||
del request
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории не прошёл проверку целостности.",
|
||||
)
|
||||
|
||||
@router.patch(
|
||||
"/api/v1/observatory/lab-projections/{session_id}",
|
||||
response_model=ObservatoryProjectionDocument,
|
||||
|
||||
Reference in New Issue
Block a user