feat: add Polygon UI-0 run view
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user