feat(observatory): add laboratory setup preflight
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
"""Observation-only laboratory orchestration contracts."""
|
||||
|
||||
from k1link.observatory.canonical_result import (
|
||||
is_admitted_observatory_recorded_result,
|
||||
)
|
||||
from k1link.observatory.setups import (
|
||||
LABORATORY_SETUP_CATALOG_SCHEMA,
|
||||
LABORATORY_SETUP_REGISTRY_SCHEMA,
|
||||
LaboratorySetupRegistry,
|
||||
LaboratorySetupRegistryError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LABORATORY_SETUP_CATALOG_SCHEMA",
|
||||
"LABORATORY_SETUP_REGISTRY_SCHEMA",
|
||||
"LaboratorySetupRegistry",
|
||||
"LaboratorySetupRegistryError",
|
||||
"is_admitted_observatory_recorded_result",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Exact admission for the current canonical recorded Observatory result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from k1link.sessions.models import SessionSummary
|
||||
|
||||
_SHA256: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||
_CANONICAL_RESULT: Final = re.compile(r"^lab-v1-vegetation-shadow-([a-f0-9]{64})$")
|
||||
_CANONICAL_SOURCE_SESSION_ID: Final = "20260828T130511Z_viewer_live"
|
||||
_CANONICAL_PIPELINE_ID: Final = (
|
||||
"ravnoves004tree-full-eomt-ddrnet-recorded-review/v1"
|
||||
)
|
||||
|
||||
|
||||
def is_admitted_observatory_recorded_result(
|
||||
summary: SessionSummary,
|
||||
*,
|
||||
expected_result_id: str,
|
||||
expected_source_session_id: str,
|
||||
expected_result_kind: str,
|
||||
) -> bool:
|
||||
"""Return true only for the exact capability-owned canonical projection."""
|
||||
|
||||
lab = summary.lab
|
||||
match = _CANONICAL_RESULT.fullmatch(expected_result_id)
|
||||
if (
|
||||
lab is None
|
||||
or match is None
|
||||
or expected_source_session_id != _CANONICAL_SOURCE_SESSION_ID
|
||||
or summary.session_id != expected_result_id
|
||||
or summary.origin != "missioncore.lab-instance/v1"
|
||||
or summary.status != "ready"
|
||||
or not summary.replayable
|
||||
or lab.session_id != expected_result_id
|
||||
or lab.result_id != expected_result_id
|
||||
or lab.source_session_id != expected_source_session_id
|
||||
or lab.lab_id != "LAB V1"
|
||||
or lab.result_kind != expected_result_kind
|
||||
or lab.result_kind != "recorded-perception-qualification"
|
||||
or lab.config_sha256 is not None
|
||||
or not isinstance(lab.source_result_id, str)
|
||||
or _CANONICAL_RESULT.fullmatch(lab.source_result_id) is None
|
||||
or lab.replay_capability is None
|
||||
):
|
||||
return False
|
||||
|
||||
capability = lab.replay_capability.as_dict()
|
||||
provenance = lab.provenance
|
||||
evidence_identity = match.group(1)
|
||||
if set(provenance) != {
|
||||
"schema_version",
|
||||
"evidence_identity_sha256",
|
||||
"result_document_sha256",
|
||||
"replay_capability",
|
||||
"authority",
|
||||
"method",
|
||||
}:
|
||||
return False
|
||||
result_document_sha256 = provenance.get("result_document_sha256")
|
||||
if (
|
||||
provenance.get("schema_version")
|
||||
!= "missioncore.canonical-recorded-lab-projection/v1"
|
||||
or provenance.get("evidence_identity_sha256") != evidence_identity
|
||||
or not isinstance(result_document_sha256, str)
|
||||
or _SHA256.fullmatch(result_document_sha256) is None
|
||||
or provenance.get("replay_capability") != capability
|
||||
or provenance.get("authority")
|
||||
!= {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"actuation_accepted": False,
|
||||
}
|
||||
):
|
||||
return False
|
||||
|
||||
return provenance.get("method") == {
|
||||
"schema_version": "missioncore.laboratory-method/v1",
|
||||
"completeness": "legacy-partial",
|
||||
"execution_class": "ai-inference",
|
||||
"pipeline_id": _CANONICAL_PIPELINE_ID,
|
||||
"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,
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
"""Strict setup catalog for the Observatory laboratory configurator.
|
||||
|
||||
The catalog can represent both a real historical RunDefinition and an exact
|
||||
sealed result that predates RunDefinitions. It never upgrades the latter into
|
||||
a fabricated executable configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from k1link.sessions.models import SessionSummary
|
||||
|
||||
LABORATORY_SETUP_REGISTRY_SCHEMA: Final = (
|
||||
"missioncore.observatory-laboratory-setup-registry/v1"
|
||||
)
|
||||
LABORATORY_SETUP_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.observatory-laboratory-setup-catalog/v1"
|
||||
)
|
||||
_MAX_REGISTRY_BYTES: Final = 256 * 1024
|
||||
_MAX_CONFIGURATION_BYTES: Final = 4 * 1024 * 1024
|
||||
_IDENTIFIER: Final = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
|
||||
_RESULT_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,159}$")
|
||||
_SHA256: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||
_MODALITIES: Final = frozenset({"point-cloud", "trajectory", "video"})
|
||||
_AUTHORITY: Final = {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
}
|
||||
|
||||
SetupOrigin = Literal["archived-definition", "existing-result"]
|
||||
ResultAccess = Literal["legacy-lab", "observatory", "evidence-only"]
|
||||
|
||||
|
||||
class LaboratorySetupRegistryError(ValueError):
|
||||
"""A setup registry or its immutable references are invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ConfigurationReference:
|
||||
role: str
|
||||
path: PurePosixPath
|
||||
sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RunDefinition:
|
||||
definition_id: str
|
||||
version: int
|
||||
work_id: str
|
||||
configuration: tuple[_ConfigurationReference, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Executor:
|
||||
contour_id: str
|
||||
state: Literal["not-installed"]
|
||||
reason_code: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreservedResult:
|
||||
result_id: str
|
||||
result_kind: str
|
||||
relation: str
|
||||
access: ResultAccess
|
||||
created_at_utc: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LaboratorySetup:
|
||||
setup_id: str
|
||||
display_name: str
|
||||
description: str
|
||||
origin: SetupOrigin
|
||||
source_session_id: str
|
||||
source_label: str
|
||||
required_modalities: tuple[str, ...]
|
||||
run_definition: _RunDefinition | None
|
||||
executor: _Executor
|
||||
preserved_results: tuple[_PreservedResult, ...]
|
||||
|
||||
def project(
|
||||
self,
|
||||
source: SessionSummary,
|
||||
*,
|
||||
available_observatory_result_ids: frozenset[str],
|
||||
) -> dict[str, object]:
|
||||
reasons: list[dict[str, str]] = []
|
||||
if source.lab is not None:
|
||||
reasons.append(_reason("source-is-lab", "Нужна исходная, а не LAB-сессия."))
|
||||
if source.session_id != self.source_session_id:
|
||||
reasons.append(
|
||||
_reason(
|
||||
"source-session-not-admitted",
|
||||
"Эта версия привязана к другой запечатанной исходной сессии.",
|
||||
)
|
||||
)
|
||||
if source.display_name != self.source_label:
|
||||
reasons.append(
|
||||
_reason(
|
||||
"source-label-mismatch",
|
||||
"Идентичность источника не совпадает с сохранённым сетапом.",
|
||||
)
|
||||
)
|
||||
if source.status != "ready":
|
||||
reasons.append(
|
||||
_reason(
|
||||
"source-not-ready",
|
||||
"Исходная сессия не находится в запечатанном состоянии ready.",
|
||||
)
|
||||
)
|
||||
if not source.replayable:
|
||||
reasons.append(
|
||||
_reason(
|
||||
"source-not-replayable",
|
||||
"Для исходной сессии недоступно воспроизводимое чтение.",
|
||||
)
|
||||
)
|
||||
missing = sorted(set(self.required_modalities) - set(source.modalities))
|
||||
if missing:
|
||||
reasons.append(
|
||||
_reason(
|
||||
"required-modalities-unavailable",
|
||||
"Не хватает каналов: " + ", ".join(missing) + ".",
|
||||
)
|
||||
)
|
||||
compatible = not reasons
|
||||
ready_result_ids = [
|
||||
result.result_id
|
||||
for result in self.preserved_results
|
||||
if result.access == "observatory"
|
||||
and result.result_id in available_observatory_result_ids
|
||||
]
|
||||
if compatible and ready_result_ids:
|
||||
action = "open-existing"
|
||||
action_reason = "Точный запечатанный результат уже опубликован в Обсерватории."
|
||||
elif compatible and any(
|
||||
result.access == "legacy-lab" for result in self.preserved_results
|
||||
):
|
||||
action = "open-legacy"
|
||||
action_reason = "Точный результат сохранён в legacy LAB."
|
||||
else:
|
||||
action = "blocked"
|
||||
action_reason = reasons[0]["message"] if reasons else self.executor.reason
|
||||
return {
|
||||
"setup_id": self.setup_id,
|
||||
"display_name": self.display_name,
|
||||
"description": self.description,
|
||||
"origin": self.origin,
|
||||
"source": {
|
||||
"session_id": self.source_session_id,
|
||||
"label": self.source_label,
|
||||
"required_modalities": list(self.required_modalities),
|
||||
},
|
||||
"run_definition": _project_definition(self, self.run_definition),
|
||||
"compatibility": {"compatible": compatible, "reasons": reasons},
|
||||
"executor": {
|
||||
"contour_id": self.executor.contour_id,
|
||||
"state": self.executor.state,
|
||||
"reason_code": self.executor.reason_code,
|
||||
"reason": self.executor.reason,
|
||||
},
|
||||
"preserved_results": [
|
||||
{
|
||||
"result_id": result.result_id,
|
||||
"result_kind": result.result_kind,
|
||||
"relation": result.relation,
|
||||
"access": result.access,
|
||||
"created_at_utc": result.created_at_utc,
|
||||
"observatory_projection_available": (
|
||||
result.result_id in available_observatory_result_ids
|
||||
),
|
||||
}
|
||||
for result in self.preserved_results
|
||||
],
|
||||
"preflight": {
|
||||
"outcome": "existing" if action == "open-existing" else "blocked",
|
||||
"action": action,
|
||||
"reason": action_reason,
|
||||
"submission_allowed": False,
|
||||
"existing_result_ids": ready_result_ids,
|
||||
},
|
||||
"authority": dict(_AUTHORITY),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LaboratorySetupRegistry:
|
||||
setups: tuple[LaboratorySetup, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
setup_ids = [setup.setup_id for setup in self.setups]
|
||||
if not setup_ids or len(setup_ids) != len(set(setup_ids)):
|
||||
raise LaboratorySetupRegistryError("setup IDs must be unique and non-empty")
|
||||
definition_versions = [
|
||||
(setup.run_definition.definition_id, setup.run_definition.version)
|
||||
for setup in self.setups
|
||||
if setup.run_definition is not None
|
||||
]
|
||||
if len(definition_versions) != len(set(definition_versions)):
|
||||
raise LaboratorySetupRegistryError("RunDefinition versions must be unique")
|
||||
result_ids = [
|
||||
result.result_id
|
||||
for setup in self.setups
|
||||
for result in setup.preserved_results
|
||||
]
|
||||
if len(result_ids) != len(set(result_ids)):
|
||||
raise LaboratorySetupRegistryError("preserved result IDs must be globally unique")
|
||||
|
||||
@classmethod
|
||||
def from_file(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
repository_root: Path,
|
||||
) -> LaboratorySetupRegistry:
|
||||
candidate = path.expanduser().absolute()
|
||||
root = repository_root.expanduser().resolve(strict=True)
|
||||
if candidate.is_symlink() or not candidate.is_file():
|
||||
raise LaboratorySetupRegistryError("setup registry must be a regular file")
|
||||
if candidate.stat().st_size > _MAX_REGISTRY_BYTES:
|
||||
raise LaboratorySetupRegistryError("setup registry is too large")
|
||||
try:
|
||||
document = _object(json.loads(candidate.read_text(encoding="utf-8")), "registry")
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise LaboratorySetupRegistryError("setup registry is unreadable") from exc
|
||||
_exact_keys(document, {"schema_version", "setups"}, "registry")
|
||||
if document["schema_version"] != LABORATORY_SETUP_REGISTRY_SCHEMA:
|
||||
raise LaboratorySetupRegistryError("setup registry schema is invalid")
|
||||
rows = document["setups"]
|
||||
if not isinstance(rows, list):
|
||||
raise LaboratorySetupRegistryError("setup registry rows must be an array")
|
||||
return cls(tuple(_setup(row, repository_root=root) for row in rows))
|
||||
|
||||
def catalog(
|
||||
self,
|
||||
source: SessionSummary,
|
||||
*,
|
||||
available_observatory_result_ids: frozenset[str] = frozenset(),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": LABORATORY_SETUP_CATALOG_SCHEMA,
|
||||
"source_session_id": source.session_id,
|
||||
"setups": [
|
||||
setup.project(
|
||||
source,
|
||||
available_observatory_result_ids=available_observatory_result_ids,
|
||||
)
|
||||
for setup in self.setups
|
||||
],
|
||||
"authority": dict(_AUTHORITY),
|
||||
}
|
||||
|
||||
def setup(self, setup_id: str) -> LaboratorySetup:
|
||||
for setup in self.setups:
|
||||
if setup.setup_id == setup_id:
|
||||
return setup
|
||||
raise KeyError(setup_id)
|
||||
|
||||
@property
|
||||
def observatory_result_ids(self) -> frozenset[str]:
|
||||
return frozenset(
|
||||
result.result_id
|
||||
for setup in self.setups
|
||||
for result in setup.preserved_results
|
||||
if result.access == "observatory"
|
||||
)
|
||||
|
||||
def observatory_result_kind(self, result_id: str) -> str | None:
|
||||
for setup in self.setups:
|
||||
for result in setup.preserved_results:
|
||||
if result.access == "observatory" and result.result_id == result_id:
|
||||
return result.result_kind
|
||||
return None
|
||||
|
||||
|
||||
def _setup(value: object, *, repository_root: Path) -> LaboratorySetup:
|
||||
row = _object(value, "setup")
|
||||
_exact_keys(
|
||||
row,
|
||||
{
|
||||
"setup_id",
|
||||
"display_name",
|
||||
"description",
|
||||
"origin",
|
||||
"source",
|
||||
"run_definition",
|
||||
"executor",
|
||||
"preserved_results",
|
||||
"authority",
|
||||
},
|
||||
"setup",
|
||||
)
|
||||
origin = row["origin"]
|
||||
if not isinstance(origin, str) or origin not in (
|
||||
"archived-definition",
|
||||
"existing-result",
|
||||
):
|
||||
raise LaboratorySetupRegistryError("setup origin is invalid")
|
||||
source = _object(row["source"], "source")
|
||||
_exact_keys(source, {"session_id", "label", "required_modalities"}, "source")
|
||||
modalities = _text_array(source["required_modalities"], "required_modalities")
|
||||
if not set(modalities).issubset(_MODALITIES):
|
||||
raise LaboratorySetupRegistryError("setup modalities are invalid")
|
||||
definition = (
|
||||
None
|
||||
if row["run_definition"] is None
|
||||
else _definition(row["run_definition"], repository_root=repository_root)
|
||||
)
|
||||
if (origin == "archived-definition") != (definition is not None):
|
||||
raise LaboratorySetupRegistryError("setup origin and RunDefinition disagree")
|
||||
if row["authority"] != _AUTHORITY:
|
||||
raise LaboratorySetupRegistryError("setup authority must remain observation-only")
|
||||
results = row["preserved_results"]
|
||||
if not isinstance(results, list) or not results:
|
||||
raise LaboratorySetupRegistryError("preserved results must be non-empty")
|
||||
preserved = tuple(_preserved_result(item) for item in results)
|
||||
if len({item.result_id for item in preserved}) != len(preserved):
|
||||
raise LaboratorySetupRegistryError("preserved result IDs must be unique")
|
||||
return LaboratorySetup(
|
||||
setup_id=_identifier(row["setup_id"], "setup_id"),
|
||||
display_name=_text(row["display_name"], "display_name"),
|
||||
description=_text(row["description"], "description"),
|
||||
origin=origin,
|
||||
source_session_id=_text(source["session_id"], "source session_id"),
|
||||
source_label=_text(source["label"], "source label"),
|
||||
required_modalities=modalities,
|
||||
run_definition=definition,
|
||||
executor=_executor(row["executor"]),
|
||||
preserved_results=preserved,
|
||||
)
|
||||
|
||||
|
||||
def _definition(value: object, *, repository_root: Path) -> _RunDefinition:
|
||||
row = _object(value, "RunDefinition")
|
||||
_exact_keys(
|
||||
row,
|
||||
{"definition_id", "version", "work_id", "configuration"},
|
||||
"RunDefinition",
|
||||
)
|
||||
version = row["version"]
|
||||
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
||||
raise LaboratorySetupRegistryError("RunDefinition version is invalid")
|
||||
configuration = row["configuration"]
|
||||
if not isinstance(configuration, list) or not configuration:
|
||||
raise LaboratorySetupRegistryError("RunDefinition configuration is empty")
|
||||
references = tuple(
|
||||
_configuration(item, repository_root=repository_root) for item in configuration
|
||||
)
|
||||
if len({item.role for item in references}) != len(references):
|
||||
raise LaboratorySetupRegistryError("configuration roles must be unique")
|
||||
return _RunDefinition(
|
||||
definition_id=_identifier(row["definition_id"], "definition_id"),
|
||||
version=version,
|
||||
work_id=_identifier(row["work_id"], "work_id"),
|
||||
configuration=references,
|
||||
)
|
||||
|
||||
|
||||
def _configuration(value: object, *, repository_root: Path) -> _ConfigurationReference:
|
||||
row = _object(value, "configuration")
|
||||
_exact_keys(row, {"role", "path", "sha256"}, "configuration")
|
||||
relative = PurePosixPath(_text(row["path"], "configuration path"))
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise LaboratorySetupRegistryError("configuration path is unsafe")
|
||||
candidate = repository_root.joinpath(*relative.parts)
|
||||
if candidate.is_symlink() or not candidate.is_file():
|
||||
raise LaboratorySetupRegistryError("configuration file is unavailable")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
if resolved.stat().st_size > _MAX_CONFIGURATION_BYTES:
|
||||
raise LaboratorySetupRegistryError("configuration file is too large")
|
||||
except OSError as exc:
|
||||
raise LaboratorySetupRegistryError("configuration file is unavailable") from exc
|
||||
if not resolved.is_relative_to(repository_root):
|
||||
raise LaboratorySetupRegistryError("configuration escaped repository root")
|
||||
digest = _digest(row["sha256"], "configuration sha256")
|
||||
if _file_sha256(resolved) != digest:
|
||||
raise LaboratorySetupRegistryError("configuration digest changed")
|
||||
return _ConfigurationReference(
|
||||
role=_identifier(row["role"], "configuration role"),
|
||||
path=relative,
|
||||
sha256=digest,
|
||||
)
|
||||
|
||||
|
||||
def _executor(value: object) -> _Executor:
|
||||
row = _object(value, "executor")
|
||||
_exact_keys(row, {"contour_id", "state", "reason_code", "reason"}, "executor")
|
||||
if row["state"] != "not-installed":
|
||||
raise LaboratorySetupRegistryError("only fail-closed executors are admitted in v1")
|
||||
return _Executor(
|
||||
contour_id=_identifier(row["contour_id"], "contour_id"),
|
||||
state="not-installed",
|
||||
reason_code=_identifier(row["reason_code"], "reason_code"),
|
||||
reason=_text(row["reason"], "executor reason"),
|
||||
)
|
||||
|
||||
|
||||
def _preserved_result(value: object) -> _PreservedResult:
|
||||
row = _object(value, "preserved result")
|
||||
_exact_keys(
|
||||
row,
|
||||
{"result_id", "result_kind", "relation", "access", "created_at_utc"},
|
||||
"preserved result",
|
||||
)
|
||||
result_id = _text(row["result_id"], "result_id")
|
||||
if _RESULT_ID.fullmatch(result_id) is None:
|
||||
raise LaboratorySetupRegistryError("result_id is invalid")
|
||||
access = row["access"]
|
||||
if not isinstance(access, str) or access not in (
|
||||
"legacy-lab",
|
||||
"observatory",
|
||||
"evidence-only",
|
||||
):
|
||||
raise LaboratorySetupRegistryError("result access is invalid")
|
||||
return _PreservedResult(
|
||||
result_id=result_id,
|
||||
result_kind=_identifier(row["result_kind"], "result_kind"),
|
||||
relation=_identifier(row["relation"], "result relation"),
|
||||
access=access,
|
||||
created_at_utc=_text(row["created_at_utc"], "created_at_utc"),
|
||||
)
|
||||
|
||||
|
||||
def _project_definition(
|
||||
setup: LaboratorySetup,
|
||||
definition: _RunDefinition | None,
|
||||
) -> dict[str, object] | None:
|
||||
if definition is None:
|
||||
return None
|
||||
identity = {
|
||||
"schema_version": "missioncore.observatory-run-definition/v1",
|
||||
"definition_id": definition.definition_id,
|
||||
"version": definition.version,
|
||||
"work_id": definition.work_id,
|
||||
"source": {
|
||||
"session_id": setup.source_session_id,
|
||||
"label": setup.source_label,
|
||||
"required_modalities": list(setup.required_modalities),
|
||||
},
|
||||
"configuration": [
|
||||
{"role": item.role, "sha256": item.sha256}
|
||||
for item in definition.configuration
|
||||
],
|
||||
"authority": dict(_AUTHORITY),
|
||||
}
|
||||
return {
|
||||
**identity,
|
||||
"definition_sha256": hashlib.sha256(_canonical_json(identity)).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(128 * 1024):
|
||||
digest.update(chunk)
|
||||
except OSError as exc:
|
||||
raise LaboratorySetupRegistryError("configuration file is unavailable") from exc
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _reason(code: str, message: str) -> dict[str, str]:
|
||||
return {"code": code, "message": message}
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise LaboratorySetupRegistryError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
|
||||
if set(value) != expected:
|
||||
raise LaboratorySetupRegistryError(f"{label} keys are invalid")
|
||||
|
||||
|
||||
def _text(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip() or value != value.strip():
|
||||
raise LaboratorySetupRegistryError(f"{label} must be non-empty text")
|
||||
return value
|
||||
|
||||
|
||||
def _identifier(value: object, label: str) -> str:
|
||||
text = _text(value, label)
|
||||
if _IDENTIFIER.fullmatch(text) is None:
|
||||
raise LaboratorySetupRegistryError(f"{label} is invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _digest(value: object, label: str) -> str:
|
||||
text = _text(value, label)
|
||||
if _SHA256.fullmatch(text) is None:
|
||||
raise LaboratorySetupRegistryError(f"{label} is invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _text_array(value: object, label: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or not value:
|
||||
raise LaboratorySetupRegistryError(f"{label} must be a non-empty array")
|
||||
result = tuple(_text(item, label) for item in value)
|
||||
if len(result) != len(set(result)):
|
||||
raise LaboratorySetupRegistryError(f"{label} must contain unique values")
|
||||
return result
|
||||
+20
-1
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user