feat(observatory): seal blocked run preparations
This commit is contained in:
+16
-1
@@ -36,7 +36,12 @@ from k1link.laboratory.m48_raw_evidence import (
|
||||
M48RawEvidenceError,
|
||||
M48RawEvidenceReader,
|
||||
)
|
||||
from k1link.observatory import LaboratorySetupRegistry, LaboratorySetupRegistryError
|
||||
from k1link.observatory import (
|
||||
LaboratorySetupRegistry,
|
||||
LaboratorySetupRegistryError,
|
||||
ObservatoryRunPreparationLedger,
|
||||
load_observatory_run_preparation_ledger,
|
||||
)
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedCameraFrameService,
|
||||
@@ -191,6 +196,8 @@ LABORATORY_RUNNER = LaboratoryRunner(
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file(
|
||||
REPOSITORY_ROOT / "config" / "laboratory-value-review.json"
|
||||
)
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY: LaboratorySetupRegistry | None
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR: str | None
|
||||
try:
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY = LaboratorySetupRegistry.from_file(
|
||||
REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json",
|
||||
@@ -229,6 +236,12 @@ 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)
|
||||
OBSERVATORY_RUN_PREPARATION_LEDGER: ObservatoryRunPreparationLedger | None
|
||||
OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR: str | None
|
||||
(
|
||||
OBSERVATORY_RUN_PREPARATION_LEDGER,
|
||||
OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
|
||||
) = load_observatory_run_preparation_ledger(session_store.data_dir)
|
||||
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)
|
||||
@@ -710,6 +723,8 @@ app.include_router(
|
||||
session_store,
|
||||
setup_registry=OBSERVATORY_LABORATORY_SETUP_REGISTRY,
|
||||
setup_registry_error=OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR,
|
||||
run_preparation_ledger=OBSERVATORY_RUN_PREPARATION_LEDGER,
|
||||
run_preparation_ledger_error=OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
|
||||
@@ -3,13 +3,22 @@ from __future__ import annotations
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from fastapi import Path as ApiPath
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.observatory import (
|
||||
LaboratorySetupRegistry,
|
||||
ObservatoryRunPreparationCapacityError,
|
||||
ObservatoryRunPreparationConflictError,
|
||||
ObservatoryRunPreparationIntegrityError,
|
||||
ObservatoryRunPreparationIntent,
|
||||
ObservatoryRunPreparationLedger,
|
||||
ObservatoryRunPreparationNotFoundError,
|
||||
is_admitted_observatory_recorded_result,
|
||||
observatory_run_preparation_request_sha256,
|
||||
)
|
||||
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
|
||||
from k1link.sessions.models import SessionSummary
|
||||
|
||||
OBSERVATORY_PROJECTION_SCHEMA: Literal[
|
||||
"missioncore.observatory-lab-projection/v1"
|
||||
@@ -57,17 +66,41 @@ class ObservatoryRunPreflightRequest(_StrictApiModel):
|
||||
definition_sha256: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
class ObservatoryRunPreparationRequest(_StrictApiModel):
|
||||
schema_version: Literal[
|
||||
"missioncore.observatory-run-preparation-request/v1"
|
||||
]
|
||||
idempotency_key: str = Field(
|
||||
min_length=1,
|
||||
max_length=160,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$",
|
||||
)
|
||||
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 = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
def build_observatory_router(
|
||||
store: SessionStore,
|
||||
*,
|
||||
setup_registry: LaboratorySetupRegistry | None = None,
|
||||
setup_registry_error: str | None = None,
|
||||
run_preparation_ledger: ObservatoryRunPreparationLedger | None = None,
|
||||
run_preparation_ledger_error: str | None = None,
|
||||
) -> APIRouter:
|
||||
"""Build bounded catalog-only mutations for typed Observatory projections."""
|
||||
|
||||
router = APIRouter(tags=["observatory"])
|
||||
|
||||
def source_summary(session_id: str):
|
||||
def source_summary(session_id: str) -> SessionSummary:
|
||||
try:
|
||||
summary = store.get_session(session_id).summary
|
||||
except SessionNotFoundError as exc:
|
||||
@@ -89,6 +122,30 @@ def build_observatory_router(
|
||||
)
|
||||
return summary
|
||||
|
||||
def source_summary_with_catalog_snapshot(
|
||||
session_id: str,
|
||||
) -> tuple[SessionSummary, str]:
|
||||
try:
|
||||
detail, snapshot_sha256 = store.get_session_with_catalog_snapshot(session_id)
|
||||
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 detail.summary.lab is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Для подготовки нужна исходная, а не лабораторная сессия.",
|
||||
)
|
||||
return detail.summary, snapshot_sha256
|
||||
|
||||
def available_observatory_results(source_session_id: str) -> frozenset[str]:
|
||||
if setup_registry is None:
|
||||
return frozenset()
|
||||
@@ -242,6 +299,215 @@ def build_observatory_router(
|
||||
detail="Каталог сетапов Обсерватории не прошёл проверку целостности.",
|
||||
)
|
||||
|
||||
if setup_registry is not None and run_preparation_ledger is not None:
|
||||
|
||||
@router.post("/api/v1/observatory/run-preparations")
|
||||
def prepare_observatory_run(
|
||||
request: ObservatoryRunPreparationRequest,
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
existing = run_preparation_ledger.get_by_idempotency_key(
|
||||
request.idempotency_key
|
||||
)
|
||||
request_sha256 = observatory_run_preparation_request_sha256(
|
||||
idempotency_key=request.idempotency_key,
|
||||
source_session_id=request.source_session_id,
|
||||
setup_id=request.setup_id,
|
||||
definition_sha256=request.definition_sha256,
|
||||
)
|
||||
except ObservatoryRunPreparationCapacityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Квота журнала подготовки расчётов исчерпана.",
|
||||
) from exc
|
||||
except (ObservatoryRunPreparationIntegrityError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Журнал подготовки расчётов недоступен.",
|
||||
) from exc
|
||||
if existing is not None:
|
||||
if existing.request_sha256 != request_sha256:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Ключ идемпотентности уже связан с другой подготовкой.",
|
||||
)
|
||||
return existing.as_dict()
|
||||
|
||||
source, source_catalog_sha256 = source_summary_with_catalog_snapshot(
|
||||
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.get("setups")
|
||||
if not isinstance(setups, list):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории нарушил контракт.",
|
||||
)
|
||||
projected = next(
|
||||
(
|
||||
item
|
||||
for item in setups
|
||||
if isinstance(item, dict) and item.get("setup_id") == request.setup_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if projected is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории нарушил контракт.",
|
||||
)
|
||||
definition = projected.get("run_definition")
|
||||
if not isinstance(definition, dict):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Для сетапа нет запускаемой RunDefinition.",
|
||||
)
|
||||
expected_digest = definition.get("definition_sha256")
|
||||
if request.definition_sha256 != expected_digest:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Идентичность RunDefinition изменилась; обновите каталог.",
|
||||
)
|
||||
compatibility = projected.get("compatibility")
|
||||
preflight = projected.get("preflight")
|
||||
executor = projected.get("executor")
|
||||
if (
|
||||
not isinstance(compatibility, dict)
|
||||
or not isinstance(preflight, dict)
|
||||
or not isinstance(executor, dict)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории нарушил контракт.",
|
||||
)
|
||||
if compatibility.get("compatible") is not True:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Исходная сессия несовместима с выбранным сетапом.",
|
||||
)
|
||||
if preflight.get("outcome") == "existing":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Точный результат уже существует; новый расчёт не подготовлен.",
|
||||
)
|
||||
if (
|
||||
preflight.get("submission_allowed") is not False
|
||||
or executor.get("state") != "not-installed"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Dispatch-контракт Обсерватории не установлен.",
|
||||
)
|
||||
definition_id = definition.get("definition_id")
|
||||
definition_version = definition.get("version")
|
||||
blocker_code = executor.get("reason_code")
|
||||
blocker_message = executor.get("reason")
|
||||
if (
|
||||
not isinstance(definition_id, str)
|
||||
or not isinstance(definition_version, int)
|
||||
or isinstance(definition_version, bool)
|
||||
or not isinstance(expected_digest, str)
|
||||
or not isinstance(blocker_code, str)
|
||||
or not isinstance(blocker_message, str)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории нарушил контракт.",
|
||||
)
|
||||
try:
|
||||
intent = ObservatoryRunPreparationIntent(
|
||||
idempotency_key=request.idempotency_key,
|
||||
source_session_id=request.source_session_id,
|
||||
source_catalog_sha256=source_catalog_sha256,
|
||||
setup_id=request.setup_id,
|
||||
definition_id=definition_id,
|
||||
definition_version=definition_version,
|
||||
definition_sha256=expected_digest,
|
||||
blocker_code=blocker_code,
|
||||
blocker_message=blocker_message,
|
||||
)
|
||||
preparation, _created = run_preparation_ledger.prepare(intent)
|
||||
except ObservatoryRunPreparationConflictError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Ключ идемпотентности уже связан с другой подготовкой.",
|
||||
) from exc
|
||||
except ObservatoryRunPreparationCapacityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Квота журнала подготовки расчётов исчерпана.",
|
||||
) from exc
|
||||
except (ObservatoryRunPreparationIntegrityError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Журнал подготовки расчётов недоступен.",
|
||||
) from exc
|
||||
return preparation.as_dict()
|
||||
|
||||
@router.get("/api/v1/observatory/run-preparations/{preparation_id}")
|
||||
def get_observatory_run_preparation(
|
||||
preparation_id: str = ApiPath(
|
||||
min_length=49,
|
||||
max_length=49,
|
||||
pattern=r"^observatory-prep-[a-f0-9]{32}$",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return run_preparation_ledger.get(preparation_id).as_dict()
|
||||
except ObservatoryRunPreparationNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Подготовка расчёта не найдена.",
|
||||
) from exc
|
||||
except ObservatoryRunPreparationCapacityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Квота журнала подготовки расчётов исчерпана.",
|
||||
) from exc
|
||||
except (ObservatoryRunPreparationIntegrityError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Журнал подготовки расчётов недоступен.",
|
||||
) from exc
|
||||
|
||||
elif setup_registry_error is not None or run_preparation_ledger_error is not None:
|
||||
|
||||
@router.post("/api/v1/observatory/run-preparations")
|
||||
def unavailable_observatory_run_preparation(
|
||||
request: ObservatoryRunPreparationRequest,
|
||||
) -> None:
|
||||
del request
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Подготовка расчётов Обсерватории недоступна.",
|
||||
)
|
||||
|
||||
@router.get("/api/v1/observatory/run-preparations/{preparation_id}")
|
||||
def unavailable_observatory_run_preparation_receipt(
|
||||
preparation_id: str = ApiPath(
|
||||
min_length=49,
|
||||
max_length=49,
|
||||
pattern=r"^observatory-prep-[a-f0-9]{32}$",
|
||||
),
|
||||
) -> None:
|
||||
del preparation_id
|
||||
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