feat(observatory): add laboratory setup preflight
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.observatory import LaboratorySetupRegistry, LaboratorySetupRegistryError
|
||||
from k1link.sessions import LabReplayCapability, LabSessionBinding, SessionNotFoundError
|
||||
from k1link.sessions.models import SessionSummary
|
||||
from k1link.web.observatory_api import build_observatory_router
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json"
|
||||
RAV00_SESSION_ID = "20260720T065719Z_viewer_live"
|
||||
RAV004_SESSION_ID = "20260828T130511Z_viewer_live"
|
||||
RAV004_RESULT_ID = (
|
||||
"lab-v1-vegetation-shadow-"
|
||||
"8c8f387599955dd79a16ded2c2d7cfd7f9f308d704f52c42fc26bd39d6da2d60"
|
||||
)
|
||||
|
||||
|
||||
def _registry() -> LaboratorySetupRegistry:
|
||||
return LaboratorySetupRegistry.from_file(
|
||||
REGISTRY_PATH,
|
||||
repository_root=REPOSITORY_ROOT,
|
||||
)
|
||||
|
||||
|
||||
def _source(session_id: str, label: str) -> SessionSummary:
|
||||
return SessionSummary(
|
||||
session_id=session_id,
|
||||
display_name=label,
|
||||
status="ready",
|
||||
started_at_utc="2026-08-28T13:05:16Z",
|
||||
completed_at_utc="2026-08-28T13:18:45Z",
|
||||
duration_seconds=808.0,
|
||||
modalities=("point-cloud", "trajectory", "video"),
|
||||
source_count=3,
|
||||
total_bytes=1,
|
||||
replayable=True,
|
||||
origin="recorded",
|
||||
)
|
||||
|
||||
|
||||
def _rav004_projection() -> SessionSummary:
|
||||
evidence_identity = RAV004_RESULT_ID.removeprefix("lab-v1-vegetation-shadow-")
|
||||
capability = LabReplayCapability(
|
||||
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||
kind="canonical-recorded-rerun",
|
||||
viewer_profile="recorded-session",
|
||||
timeline="session_time",
|
||||
activation="explicit",
|
||||
commands_enabled=False,
|
||||
)
|
||||
binding = LabSessionBinding(
|
||||
session_id=RAV004_RESULT_ID,
|
||||
source_session_id=RAV004_SESSION_ID,
|
||||
lab_id="LAB V1",
|
||||
result_kind="recorded-perception-qualification",
|
||||
result_id=RAV004_RESULT_ID,
|
||||
source_result_id=(
|
||||
"lab-v1-vegetation-shadow-"
|
||||
"d179462134967ace1c5ebd6fbdbdd8659905d390484b9c01ea7930f083bb74d1"
|
||||
),
|
||||
config_sha256=None,
|
||||
run_created_at_utc="2026-08-29T18:05:11.329061Z",
|
||||
published_at_utc="2026-08-30T15:16:27.747Z",
|
||||
replay_capability=capability,
|
||||
provenance={
|
||||
"schema_version": "missioncore.canonical-recorded-lab-projection/v1",
|
||||
"evidence_identity_sha256": evidence_identity,
|
||||
"result_document_sha256": (
|
||||
"598a3d859fffcc7426e4a08b7ab4b59cbf09cad634ac0b618ff5845254e82b64"
|
||||
),
|
||||
"replay_capability": capability.as_dict(),
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"actuation_accepted": False,
|
||||
},
|
||||
"method": {
|
||||
"schema_version": "missioncore.laboratory-method/v1",
|
||||
"completeness": "legacy-partial",
|
||||
"execution_class": "ai-inference",
|
||||
"pipeline_id": (
|
||||
"ravnoves004tree-full-eomt-ddrnet-recorded-review/v1"
|
||||
),
|
||||
"components": [
|
||||
{
|
||||
"kind": "source",
|
||||
"name": "sealed full-route LAB result",
|
||||
"version": "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
"role": "immutable Session catalog projection",
|
||||
"identity_sha256": evidence_identity,
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
return SessionSummary(
|
||||
session_id=RAV004_RESULT_ID,
|
||||
display_name="RAVNOVES004TREE · полный маршрут восприятия",
|
||||
status="ready",
|
||||
started_at_utc=binding.run_created_at_utc,
|
||||
completed_at_utc=binding.run_created_at_utc,
|
||||
duration_seconds=718.0,
|
||||
modalities=(),
|
||||
source_count=0,
|
||||
total_bytes=0,
|
||||
replayable=True,
|
||||
origin="missioncore.lab-instance/v1",
|
||||
lab=binding,
|
||||
)
|
||||
|
||||
|
||||
def test_repository_setup_registry_keeps_real_definition_and_pre_definition_result() -> None:
|
||||
registry = _registry()
|
||||
|
||||
assert [setup.setup_id for setup in registry.setups] == [
|
||||
"m49-tgs-full-shadow-v1",
|
||||
"lab-v1-ravnoves004tree-final",
|
||||
]
|
||||
rav00 = registry.catalog(_source(RAV00_SESSION_ID, "RAVNOVES00"))
|
||||
m49, current = rav00["setups"]
|
||||
assert m49["origin"] == "archived-definition"
|
||||
assert m49["compatibility"]["compatible"] is True
|
||||
assert m49["preflight"] == {
|
||||
"outcome": "blocked",
|
||||
"action": "open-legacy",
|
||||
"reason": "Точный результат сохранён в legacy LAB.",
|
||||
"submission_allowed": False,
|
||||
"existing_result_ids": [],
|
||||
}
|
||||
assert m49["run_definition"]["definition_sha256"]
|
||||
assert m49["run_definition"]["configuration"] == [
|
||||
{
|
||||
"role": "primary-profile",
|
||||
"sha256": (
|
||||
"c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"
|
||||
),
|
||||
}
|
||||
]
|
||||
assert all("path" not in row for row in m49["run_definition"]["configuration"])
|
||||
assert current["compatibility"]["compatible"] is False
|
||||
|
||||
rav004 = registry.catalog(
|
||||
_source(RAV004_SESSION_ID, "RAVNOVES004TREE"),
|
||||
available_observatory_result_ids=frozenset({RAV004_RESULT_ID}),
|
||||
)
|
||||
existing = rav004["setups"][1]
|
||||
assert existing["origin"] == "existing-result"
|
||||
assert existing["run_definition"] is None
|
||||
assert existing["preflight"]["outcome"] == "existing"
|
||||
assert existing["preflight"]["action"] == "open-existing"
|
||||
assert existing["preflight"]["submission_allowed"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "reason_code"),
|
||||
[
|
||||
(replace(_source(RAV00_SESSION_ID, "RAVNOVES00"), status="failed"), "source-not-ready"),
|
||||
(
|
||||
replace(_source(RAV00_SESSION_ID, "RAVNOVES00"), replayable=False),
|
||||
"source-not-replayable",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_setup_compatibility_requires_a_ready_replayable_source(
|
||||
source: SessionSummary,
|
||||
reason_code: str,
|
||||
) -> None:
|
||||
setup = _registry().catalog(source)["setups"][0]
|
||||
|
||||
assert setup["compatibility"]["compatible"] is False
|
||||
assert reason_code in {
|
||||
reason["code"] for reason in setup["compatibility"]["reasons"]
|
||||
}
|
||||
assert setup["preflight"]["outcome"] == "blocked"
|
||||
assert setup["preflight"]["submission_allowed"] is False
|
||||
|
||||
|
||||
def test_registry_fails_closed_when_referenced_configuration_digest_changes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
document = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
document["setups"][0]["run_definition"]["configuration"][0]["sha256"] = "0" * 64
|
||||
path = tmp_path / "setups.json"
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
|
||||
with pytest.raises(LaboratorySetupRegistryError, match="digest changed"):
|
||||
LaboratorySetupRegistry.from_file(path, repository_root=REPOSITORY_ROOT)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_path", "invalid"),
|
||||
[
|
||||
(("setups", 0, "origin"), []),
|
||||
(("setups", 0, "preserved_results", 0, "access"), {}),
|
||||
],
|
||||
)
|
||||
def test_registry_normalizes_untrusted_json_types_to_registry_error(
|
||||
tmp_path: Path,
|
||||
field_path: tuple[str | int, ...],
|
||||
invalid: object,
|
||||
) -> None:
|
||||
document: object = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
target = document
|
||||
for part in field_path[:-1]:
|
||||
target = target[part] # type: ignore[index]
|
||||
target[field_path[-1]] = invalid # type: ignore[index]
|
||||
path = tmp_path / "setups.json"
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
|
||||
with pytest.raises(LaboratorySetupRegistryError):
|
||||
LaboratorySetupRegistry.from_file(path, repository_root=REPOSITORY_ROOT)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self) -> None:
|
||||
self.sessions = {
|
||||
RAV00_SESSION_ID: _source(RAV00_SESSION_ID, "RAVNOVES00"),
|
||||
RAV004_SESSION_ID: _source(RAV004_SESSION_ID, "RAVNOVES004TREE"),
|
||||
RAV004_RESULT_ID: _rav004_projection(),
|
||||
}
|
||||
|
||||
def get_session(self, session_id: str):
|
||||
try:
|
||||
summary = self.sessions[session_id]
|
||||
except KeyError as exc:
|
||||
raise SessionNotFoundError(session_id) from exc
|
||||
return SimpleNamespace(summary=summary)
|
||||
|
||||
|
||||
def test_observatory_setup_catalog_and_preflight_are_read_only_and_fail_closed() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(build_observatory_router(_Store(), setup_registry=_registry())) # type: ignore[arg-type]
|
||||
client = TestClient(app)
|
||||
|
||||
catalog = client.get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
)
|
||||
assert catalog.status_code == 200
|
||||
existing = catalog.json()["setups"][1]
|
||||
assert existing["preflight"]["outcome"] == "existing"
|
||||
assert existing["run_definition"] is None
|
||||
|
||||
preflight = client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": RAV004_SESSION_ID,
|
||||
"setup_id": existing["setup_id"],
|
||||
"definition_sha256": None,
|
||||
},
|
||||
)
|
||||
assert preflight.status_code == 200
|
||||
assert preflight.json()["outcome"] == "existing"
|
||||
assert preflight.json()["submission_allowed"] is False
|
||||
assert preflight.json()["existing_result_ids"] == [RAV004_RESULT_ID]
|
||||
assert next(
|
||||
check
|
||||
for check in preflight.json()["checks"]
|
||||
if check["check_id"] == "executor"
|
||||
)["outcome"] == "not-applicable"
|
||||
|
||||
m49_catalog = client.get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV00_SESSION_ID},
|
||||
).json()
|
||||
m49 = m49_catalog["setups"][0]
|
||||
blocked = client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": RAV00_SESSION_ID,
|
||||
"setup_id": m49["setup_id"],
|
||||
"definition_sha256": m49["run_definition"]["definition_sha256"],
|
||||
},
|
||||
)
|
||||
assert blocked.status_code == 200
|
||||
assert blocked.json()["outcome"] == "blocked"
|
||||
assert blocked.json()["submission_allowed"] is False
|
||||
assert blocked.json()["executor"]["state"] == "not-installed"
|
||||
|
||||
stale = client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": RAV00_SESSION_ID,
|
||||
"setup_id": m49["setup_id"],
|
||||
"definition_sha256": "f" * 64,
|
||||
},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert client.post("/api/v1/observatory/runs", json={}).status_code == 404
|
||||
|
||||
|
||||
def test_existing_result_is_not_openable_when_its_sealed_contract_drifts() -> None:
|
||||
store = _Store()
|
||||
projection = store.sessions[RAV004_RESULT_ID]
|
||||
assert projection.lab is not None
|
||||
store.sessions[RAV004_RESULT_ID] = replace(
|
||||
projection,
|
||||
lab=replace(projection.lab, result_kind="unexpected-result-kind"),
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(build_observatory_router(store, setup_registry=_registry())) # type: ignore[arg-type]
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
current = response.json()["setups"][1]
|
||||
assert current["preflight"]["outcome"] == "blocked"
|
||||
assert current["preflight"]["action"] == "blocked"
|
||||
assert current["preflight"]["existing_result_ids"] == []
|
||||
|
||||
|
||||
def test_existing_result_is_not_openable_without_canonical_provenance() -> None:
|
||||
store = _Store()
|
||||
projection = store.sessions[RAV004_RESULT_ID]
|
||||
assert projection.lab is not None
|
||||
store.sessions[RAV004_RESULT_ID] = replace(
|
||||
projection,
|
||||
lab=replace(projection.lab, provenance={}),
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(build_observatory_router(store, setup_registry=_registry())) # type: ignore[arg-type]
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["setups"][1]["preflight"]["outcome"] == "blocked"
|
||||
|
||||
|
||||
def test_existing_result_is_not_openable_outside_capability_owned_origin() -> None:
|
||||
store = _Store()
|
||||
store.sessions[RAV004_RESULT_ID] = replace(
|
||||
store.sessions[RAV004_RESULT_ID],
|
||||
origin="laboratory",
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(build_observatory_router(store, setup_registry=_registry())) # type: ignore[arg-type]
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["setups"][1]["preflight"]["outcome"] == "blocked"
|
||||
|
||||
|
||||
def test_setup_routes_reject_unsafe_identifiers_and_isolate_registry_failure() -> None:
|
||||
normal = FastAPI()
|
||||
normal.include_router(build_observatory_router(_Store(), setup_registry=_registry())) # type: ignore[arg-type]
|
||||
normal_client = TestClient(normal, raise_server_exceptions=False)
|
||||
|
||||
assert normal_client.get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": "../bad"},
|
||||
).status_code == 422
|
||||
assert normal_client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": "../bad",
|
||||
"setup_id": "m49-tgs-full-shadow-v1",
|
||||
"definition_sha256": None,
|
||||
},
|
||||
).status_code == 422
|
||||
|
||||
unavailable = FastAPI()
|
||||
unavailable.include_router(
|
||||
build_observatory_router(
|
||||
_Store(), # type: ignore[arg-type]
|
||||
setup_registry_error="configuration digest changed",
|
||||
)
|
||||
)
|
||||
unavailable_client = TestClient(unavailable)
|
||||
assert unavailable_client.get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
).status_code == 503
|
||||
assert unavailable_client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": RAV004_SESSION_ID,
|
||||
"setup_id": "lab-v1-ravnoves004tree-final",
|
||||
"definition_sha256": None,
|
||||
},
|
||||
).status_code == 503
|
||||
Reference in New Issue
Block a user