feat(observatory): add laboratory setup preflight

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 22:21:58 +03:00
parent be83283ee0
commit 8d5aeb0533
15 changed files with 2491 additions and 9 deletions
+20 -1
View File
@@ -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")
)
+208 -3
View File
@@ -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,