feat(observatory): seal blocked run preparations

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 23:14:05 +03:00
parent 8d5aeb0533
commit 2a5763d3fb
12 changed files with 1935 additions and 25 deletions
+16 -1
View File
@@ -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(
+267 -1
View File
@@ -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,