feat(observatory): add durable recorded compute queue

This commit is contained in:
DCCONSTRUCTIONS
2026-08-31 01:04:25 +03:00
parent e72609120f
commit d67e86176e
23 changed files with 6300 additions and 369 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40
View File
@@ -42,6 +42,15 @@ from k1link.observatory import (
ObservatoryRunPreparationLedger,
load_observatory_run_preparation_ledger,
)
from k1link.observatory.m49_queue_binding import (
M49QueueBindingConfig,
M49QueueBindingError,
M49RecordedQueueBindingService,
)
from k1link.observatory.recorded_jobs import (
ObservatoryRecordedJobQueue,
ObservatoryRecordedQueueError,
)
from k1link.sessions import (
MaterializedRecording,
RecordedCameraFrameService,
@@ -242,6 +251,34 @@ 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)
OBSERVATORY_RECORDED_BINDING_SERVICE: M49RecordedQueueBindingService | None
OBSERVATORY_RECORDED_JOB_QUEUE: ObservatoryRecordedJobQueue | None
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR: str | None
try:
if OBSERVATORY_LABORATORY_SETUP_REGISTRY is None:
raise M49QueueBindingError("observatory setup registry is unavailable")
OBSERVATORY_RECORDED_BINDING_SERVICE = M49RecordedQueueBindingService(
data_dir=session_store.data_dir,
session_store=session_store,
setup_registry=OBSERVATORY_LABORATORY_SETUP_REGISTRY,
config=M49QueueBindingConfig.from_file(
REPOSITORY_ROOT
/ "config"
/ "observatory-m49-recorded-queue-binding.json"
),
)
OBSERVATORY_RECORDED_JOB_QUEUE = ObservatoryRecordedJobQueue(
session_store.data_dir,
definitions=OBSERVATORY_RECORDED_BINDING_SERVICE.definitions,
)
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR = None
except (M49QueueBindingError, ObservatoryRecordedQueueError, OSError, ValueError) as exc:
# Recorded execution remains an optional observation-only slice. A drifted
# seal or queue must fail closed without preventing K1, Simulation or legacy
# LAB from starting.
OBSERVATORY_RECORDED_BINDING_SERVICE = None
OBSERVATORY_RECORDED_JOB_QUEUE = None
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR = str(exc)
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)
@@ -725,6 +762,9 @@ app.include_router(
setup_registry_error=OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR,
run_preparation_ledger=OBSERVATORY_RUN_PREPARATION_LEDGER,
run_preparation_ledger_error=OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
recorded_binding_service=OBSERVATORY_RECORDED_BINDING_SERVICE,
recorded_job_queue=OBSERVATORY_RECORDED_JOB_QUEUE,
recorded_job_queue_error=OBSERVATORY_RECORDED_JOB_QUEUE_ERROR,
)
)
app.include_router(
+348 -3
View File
@@ -17,6 +17,18 @@ from k1link.observatory import (
is_admitted_observatory_recorded_result,
observatory_run_preparation_request_sha256,
)
from k1link.observatory.m49_queue_binding import (
M49QueueBindingError,
M49QueueBindingIntegrityError,
M49RecordedQueueBindingService,
)
from k1link.observatory.recorded_jobs import (
ObservatoryRecordedJobQueue,
ObservatoryRecordedQueueCapacityError,
ObservatoryRecordedQueueConflictError,
ObservatoryRecordedQueueError,
ObservatoryRecordedQueueNotFoundError,
)
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
from k1link.sessions.models import SessionSummary
@@ -32,6 +44,19 @@ OBSERVATORY_RUN_PREFLIGHT_REQUEST_SCHEMA: Literal[
OBSERVATORY_RUN_PREFLIGHT_SCHEMA: Literal[
"missioncore.observatory-run-preflight/v1"
] = "missioncore.observatory-run-preflight/v1"
OBSERVATORY_RECORDED_RUN_SUBMIT_SCHEMA: Literal[
"missioncore.observatory-recorded-run-submit/v1"
] = "missioncore.observatory-recorded-run-submit/v1"
OBSERVATORY_RECORDED_JOB_LIST_SCHEMA: Literal[
"missioncore.observatory-recorded-job-list/v1"
] = "missioncore.observatory-recorded-job-list/v1"
_OBSERVATION_ONLY_AUTHORITY: dict[str, bool] = {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
}
class _StrictApiModel(BaseModel):
@@ -88,6 +113,25 @@ class ObservatoryRunPreparationRequest(_StrictApiModel):
definition_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
class ObservatoryRecordedRunSubmitRequest(_StrictApiModel):
schema_version: Literal["missioncore.observatory-recorded-run-submit/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}$",
)
def build_observatory_router(
store: SessionStore,
*,
@@ -95,6 +139,9 @@ def build_observatory_router(
setup_registry_error: str | None = None,
run_preparation_ledger: ObservatoryRunPreparationLedger | None = None,
run_preparation_ledger_error: str | None = None,
recorded_binding_service: M49RecordedQueueBindingService | None = None,
recorded_job_queue: ObservatoryRecordedJobQueue | None = None,
recorded_job_queue_error: str | None = None,
) -> APIRouter:
"""Build bounded catalog-only mutations for typed Observatory projections."""
@@ -224,6 +271,34 @@ def build_observatory_router(
assert isinstance(executor, dict)
compatible = compatibility.get("compatible") is True
existing = preflight.get("outcome") == "existing"
queue_binding_ready = False
queue_binding_reason: str | None = None
binding_service = recorded_binding_service
exact_queue_setup = (
binding_service is not None
and request.setup_id
== binding_service.config.setup.setup_id
)
if (
compatible
and not existing
and binding_service is not None
and exact_queue_setup
and isinstance(expected_digest, str)
):
try:
binding_service.check(
source_session_id=request.source_session_id,
setup_id=request.setup_id,
definition_sha256=expected_digest,
)
queue_binding_ready = True
except M49QueueBindingError:
queue_binding_reason = (
"Точная привязка источника и исполняемого релиза не прошла "
"проверку целостности."
)
queueable = queue_binding_ready and recorded_job_queue is not None
checks: list[dict[str, Any]] = [
{
"check_id": "source-compatibility",
@@ -247,26 +322,66 @@ def build_observatory_router(
},
{
"check_id": "executor",
"outcome": "not-applicable" if existing else "fail",
"outcome": (
"not-applicable"
if existing
else "pass"
if queue_binding_ready
else "fail"
),
"reason_code": (
"existing-result-does-not-require-executor"
if existing
else "executor-release-sealed"
if queue_binding_ready
else str(executor.get("reason_code"))
),
"message": (
"Готовый результат открывается без повторного запуска Worker."
if existing
else (
"Исполняемый релиз и его ресурсы запечатаны; отдельный "
"Worker service заберёт расчёт из очереди после установки."
)
if queue_binding_ready
else str(executor.get("reason"))
),
},
{
"check_id": "durable-queue",
"outcome": (
"not-applicable"
if existing
else "pass"
if queueable
else "fail"
),
"reason_code": (
"existing-result-does-not-require-queue"
if existing
else "durable-queue-ready"
if queueable
else "durable-queue-unavailable"
),
"message": (
"Готовый результат не требует постановки в очередь."
if existing
else "Durable-очередь готова принять расчёт."
if queueable
else queue_binding_reason
or "Этот источник и сетап пока нельзя поставить в очередь."
),
},
]
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,
"outcome": (
"existing" if existing else "queueable" if queueable else "blocked"
),
"submission_allowed": queueable,
"checks": checks,
"existing_result_ids": preflight.get("existing_result_ids", []),
"executor": executor,
@@ -508,6 +623,236 @@ def build_observatory_router(
detail="Подготовка расчётов Обсерватории недоступна.",
)
if (
setup_registry is not None
and recorded_binding_service is not None
and recorded_job_queue is not None
):
@router.post("/api/v1/observatory/runs", status_code=202)
def submit_observatory_recorded_run(
request: ObservatoryRecordedRunSubmitRequest,
) -> dict[str, object]:
try:
existing_job = recorded_job_queue.get_by_idempotency_key(
request.idempotency_key
)
except ObservatoryRecordedQueueNotFoundError:
existing_job = None
except (ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from exc
if existing_job is not None:
if (
existing_job.source_session_id != request.source_session_id
or existing_job.setup_id != request.setup_id
):
raise HTTPException(
status_code=409,
detail="Ключ идемпотентности уже связан с другим расчётом.",
)
return existing_job.as_dict()
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.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="Каталог сетапов Обсерватории нарушил контракт.",
)
compatibility = projected.get("compatibility")
preflight = projected.get("preflight")
definition = projected.get("run_definition")
if not isinstance(compatibility, dict) or not isinstance(preflight, 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 not isinstance(definition, dict):
raise HTTPException(
status_code=409,
detail="Для сетапа нет воспроизводимой RunDefinition.",
)
definition_sha256 = definition.get("definition_sha256")
if not isinstance(definition_sha256, str):
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
try:
admission = recorded_binding_service.admit(
source_session_id=request.source_session_id,
setup_id=request.setup_id,
definition_sha256=definition_sha256,
)
job, _created = recorded_job_queue.submit(
admission.intent(idempotency_key=request.idempotency_key),
enqueue=True,
)
return job.as_dict()
except M49QueueBindingIntegrityError as exc:
raise HTTPException(
status_code=409,
detail=(
"Точная привязка источника и исполняемого релиза не прошла "
"проверку целостности."
),
) from exc
except ObservatoryRecordedQueueConflictError as exc:
try:
existing_job = recorded_job_queue.get_by_idempotency_key(
request.idempotency_key
)
except ObservatoryRecordedQueueNotFoundError:
existing_job = None
except (ObservatoryRecordedQueueError, ValueError) as lookup_exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from lookup_exc
if (
existing_job is not None
and existing_job.source_session_id == request.source_session_id
and existing_job.setup_id == request.setup_id
):
return existing_job.as_dict()
raise HTTPException(
status_code=409,
detail="Ключ идемпотентности уже связан с другим расчётом.",
) from exc
except ObservatoryRecordedQueueCapacityError as exc:
raise HTTPException(
status_code=503,
detail="Квота durable-очереди расчётов исчерпана.",
) from exc
except (M49QueueBindingError, ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from exc
@router.get("/api/v1/observatory/runs")
def list_observatory_recorded_runs(
source_session_id: str = Query(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
),
setup_id: str = Query(
min_length=3,
max_length=96,
pattern=r"^[a-z][a-z0-9-]{2,95}$",
),
limit: int = Query(default=20, ge=1, le=100),
) -> dict[str, object]:
try:
jobs = recorded_job_queue.list_jobs(
source_session_id=source_session_id,
setup_id=setup_id,
limit=limit,
)
except (ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from exc
return {
"schema_version": OBSERVATORY_RECORDED_JOB_LIST_SCHEMA,
"items": [job.as_dict() for job in jobs],
"authority": dict(_OBSERVATION_ONLY_AUTHORITY),
}
@router.get("/api/v1/observatory/runs/{job_id}")
def get_observatory_recorded_run(
job_id: str = ApiPath(
min_length=48,
max_length=48,
pattern=r"^observatory-run-[a-f0-9]{32}$",
),
) -> dict[str, object]:
try:
return recorded_job_queue.get(job_id).as_dict()
except ObservatoryRecordedQueueNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="Расчёт Обсерватории не найден.",
) from exc
except (ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from exc
elif recorded_job_queue_error is not None:
@router.post("/api/v1/observatory/runs")
def unavailable_observatory_recorded_run(
request: ObservatoryRecordedRunSubmitRequest,
) -> None:
del request
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
)
@router.get("/api/v1/observatory/runs")
def unavailable_observatory_recorded_runs(
source_session_id: str = Query(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
),
setup_id: str = Query(
min_length=3,
max_length=96,
pattern=r"^[a-z][a-z0-9-]{2,95}$",
),
limit: int = Query(default=20, ge=1, le=100),
) -> None:
del source_session_id, setup_id, limit
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
)
@router.patch(
"/api/v1/observatory/lab-projections/{session_id}",
response_model=ObservatoryProjectionDocument,