feat: add Polygon UI-0 run view

This commit is contained in:
DCCONSTRUCTIONS
2026-07-24 19:02:10 +03:00
parent a78c8c83f5
commit b790f29d59
18 changed files with 2193 additions and 43 deletions
+26 -5
View File
@@ -78,14 +78,23 @@ class QualificationRunStore:
exclusive filesystem semantics and fsynced before acknowledgement.
"""
def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve()
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
_reject_symlink(self.root, "qualification repository")
_chmod_private(self.root)
def __init__(self, root: Path, *, read_only: bool = False) -> None:
configured_root = root.expanduser()
if read_only:
if not configured_root.is_dir():
raise QualificationRunIntegrityError("qualification repository is missing")
_reject_symlink(configured_root, "qualification repository")
self.root = configured_root.resolve()
else:
self.root = configured_root.resolve()
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
_reject_symlink(self.root, "qualification repository")
_chmod_private(self.root)
self.read_only = read_only
self._lock = threading.RLock()
def create(self, run: QualificationRun) -> QualificationRun:
self._require_writable()
if (
run.state is not RunState.ADMITTED
or run.revision != 0
@@ -152,6 +161,7 @@ class QualificationRunStore:
sim_time_ns: int | None = None,
reason: str | None = None,
) -> QualificationRun:
self._require_writable()
with self._lock:
current = self.load(run_id)
if current.revision != expected_revision:
@@ -193,6 +203,7 @@ class QualificationRunStore:
sim_time_ns: int | None = None,
expected_revision: int | None = None,
) -> QualificationEvent:
self._require_writable()
if event_type == "lifecycle.state-changed":
raise QualificationRunTransitionError("lifecycle events must use transition()")
with self._lock:
@@ -215,6 +226,7 @@ class QualificationRunStore:
)
def submit_command(self, command: ControlSetpoint) -> ControlSetpoint:
self._require_writable()
with self._lock:
run = self.load(command.run_id)
if run.kind not in COMMANDABLE_RUN_KINDS:
@@ -267,6 +279,7 @@ class QualificationRunStore:
run_id: str,
artifact: QualificationArtifact,
) -> QualificationArtifact:
self._require_writable()
with self._lock:
run = self.load(run_id)
if run.state.terminal:
@@ -304,6 +317,7 @@ class QualificationRunStore:
episode_id: str,
created_at_utc: str,
) -> QualificationRun:
self._require_writable()
previous = self.load(previous_run_id)
if not previous.state.terminal:
raise QualificationRunTransitionError(
@@ -338,6 +352,7 @@ class QualificationRunStore:
observed_at_utc: str,
host_monotonic_ns: int,
) -> QualificationRun:
self._require_writable()
run = self.load(run_id)
if run.state not in ACTIVE_RECOVERY_STATES:
return run
@@ -446,6 +461,12 @@ class QualificationRunStore:
_reject_symlink(directory, f"run {name} journal")
return path
def _require_writable(self) -> None:
if self.read_only:
raise QualificationRunTransitionError(
"the qualification repository is open read-only"
)
def _replay_runtime(
manifest: QualificationRun,
+2
View File
@@ -43,6 +43,7 @@ from k1link.web.plugin_runtime import (
PluginNotFoundError,
PluginRuntimeUnavailableError,
)
from k1link.web.polygon_api import build_polygon_router
from k1link.web.session_api import build_session_router
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
@@ -396,6 +397,7 @@ app.include_router(
point_color_renderers=plugin_environment.point_color_renderers,
)
)
app.include_router(build_polygon_router())
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import os
from collections.abc import Callable
from pathlib import Path
from typing import Annotated, Any, Final
from fastapi import APIRouter, HTTPException, Query
from fastapi import Path as PathParameter
from k1link.simulation import (
QualificationRun,
QualificationRunIntegrityError,
QualificationRunNotFoundError,
QualificationRunStore,
)
POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT"
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1"
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
"UI-0 публикует только квалификационные доказательства; "
"lifecycle-операции и команды отсутствуют.",
"Артефакты представлены относительными метаданными; их содержимое не публикуется этим API.",
"Терминальное состояние прогона не означает приёмку навигации, "
"восприятия или физической безопасности.",
)
RootProvider = Callable[[], Path | None]
def configured_polygon_runs_root() -> Path | None:
raw = os.environ.get(POLYGON_RUNS_ROOT_ENV)
if raw is None or not raw.strip():
return None
return Path(raw.strip()).expanduser()
def build_polygon_router(
*,
root_provider: RootProvider = configured_polygon_runs_root,
) -> APIRouter:
router = APIRouter(prefix="/api/v1/polygon", tags=["polygon"])
@router.get("/runs")
def list_polygon_runs(
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> dict[str, Any]:
store = _open_read_only_store(root_provider)
try:
ordered = sorted(
store.list_runs(),
key=lambda run: (run.created_at_utc, run.run_id),
reverse=True,
)
except QualificationRunIntegrityError as exc:
raise HTTPException(
status_code=500,
detail="Журнал прогонов Полигона не прошёл проверку целостности.",
) from exc
return {
"schema_version": CATALOG_SCHEMA,
"access": "read-only",
"items": [_run_summary(run) for run in ordered[:limit]],
"total": len(ordered),
"limitations": list(READ_ONLY_LIMITATIONS),
}
@router.get("/runs/{run_id}")
def get_polygon_run(
run_id: Annotated[
str,
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
],
event_limit: Annotated[int, Query(ge=1, le=500)] = 200,
) -> dict[str, Any]:
store = _open_read_only_store(root_provider)
try:
run = store.load(run_id)
events = store.list_events(run_id)
commands = store.list_commands(run_id)
except QualificationRunNotFoundError as exc:
raise HTTPException(status_code=404, detail="Прогон Полигона не найден.") from exc
except QualificationRunIntegrityError as exc:
raise HTTPException(
status_code=500,
detail="Доказательства прогона не прошли проверку целостности.",
) from exc
visible_events = events[-event_limit:]
return {
"schema_version": DETAIL_SCHEMA,
"access": "read-only",
"run": {
**_run_summary(run),
"scenario_sha256": run.scenario_sha256,
"profile_sha256": run.profile_sha256,
"host_profile_sha256": run.host_profile_sha256,
"seed": run.seed,
"parent_run_id": run.parent_run_id,
"providers": [provider.to_dict() for provider in run.providers],
"authority": run.authority.to_dict(),
},
"events": [event.to_dict() for event in visible_events],
"events_total": len(events),
"events_truncated": len(visible_events) != len(events),
"commands": {
"count": len(commands),
"content_exposed": False,
},
"artifacts": [artifact.to_dict() for artifact in run.artifacts],
"limitations": list(READ_ONLY_LIMITATIONS),
}
return router
def _open_read_only_store(root_provider: RootProvider) -> QualificationRunStore:
root = root_provider()
if root is None:
raise HTTPException(
status_code=503,
detail="Источник журналов Полигона не настроен на этом экземпляре Mission Core.",
)
if not root.is_absolute():
raise HTTPException(
status_code=503,
detail="Источник журналов Полигона настроен некорректно.",
)
try:
return QualificationRunStore(root, read_only=True)
except QualificationRunIntegrityError as exc:
raise HTTPException(
status_code=503,
detail="Источник журналов Полигона недоступен или настроен некорректно.",
) from exc
def _run_summary(run: QualificationRun) -> dict[str, Any]:
return {
"run_id": run.run_id,
"episode_id": run.episode_id,
"kind": run.kind.value,
"state": run.state.value,
"created_at_utc": run.created_at_utc,
"started_at_utc": run.started_at_utc,
"ended_at_utc": run.ended_at_utc,
"terminal_reason": run.terminal_reason,
"scenario_generation": run.scenario_generation,
"profile_generation": run.profile_generation,
"mission_core_commit": run.mission_core_commit,
"host_profile_id": run.host_profile_id,
"reproducibility_tier": run.reproducibility_tier.value,
"clock_domain": run.clock_domain,
"revision": run.revision,
"provider_ids": [provider.identifier for provider in run.providers],
"artifact_count": len(run.artifacts),
}