feat: add Polygon UI-0 run view
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from k1link.simulation import (
|
||||
AuthorityProfile,
|
||||
ProviderPin,
|
||||
QualificationArtifact,
|
||||
QualificationRun,
|
||||
QualificationRunStore,
|
||||
ReproducibilityTier,
|
||||
RunKind,
|
||||
RunState,
|
||||
)
|
||||
from k1link.web.polygon_api import build_polygon_router
|
||||
|
||||
SHA_A = "a" * 64
|
||||
SHA_B = "b" * 64
|
||||
SHA_C = "c" * 64
|
||||
|
||||
|
||||
def _run(run_id: str = "s1b-ui0-001") -> QualificationRun:
|
||||
return QualificationRun(
|
||||
run_id=run_id,
|
||||
episode_id=f"episode-{run_id}",
|
||||
kind=RunKind.SIMULATION_CLOSED_LOOP,
|
||||
state=RunState.ADMITTED,
|
||||
scenario_generation="px4-v1.17.0-stock-rover-ackermann",
|
||||
scenario_sha256=SHA_A,
|
||||
profile_generation="stock-rover-lifecycle-v1",
|
||||
profile_sha256=SHA_B,
|
||||
mission_core_commit="6cb1495a1234567890abcdef1234567890abcdef",
|
||||
providers=(
|
||||
ProviderPin(
|
||||
identifier="px4-autopilot",
|
||||
version="v1.17.0",
|
||||
revision="v1.17.0",
|
||||
),
|
||||
ProviderPin(
|
||||
identifier="gazebo",
|
||||
version="harmonic",
|
||||
revision="8.9.0",
|
||||
),
|
||||
),
|
||||
host_profile_id="mission-gpu-s0",
|
||||
host_profile_sha256=SHA_C,
|
||||
seed=42,
|
||||
reproducibility_tier=ReproducibilityTier.R1,
|
||||
authority=AuthorityProfile(
|
||||
generation=1,
|
||||
command_ttl_max_ns=250_000_000,
|
||||
heartbeat_timeout_monotonic_ns=500_000_000,
|
||||
),
|
||||
clock_domain="gazebo:/clock",
|
||||
created_at_utc="2026-07-24T15:35:00Z",
|
||||
)
|
||||
|
||||
|
||||
def _accepted_run(root: Path) -> QualificationRun:
|
||||
store = QualificationRunStore(root)
|
||||
admitted = store.create(_run())
|
||||
starting = store.transition(
|
||||
admitted.run_id,
|
||||
RunState.STARTING,
|
||||
expected_revision=0,
|
||||
observed_at_utc="2026-07-24T15:35:01Z",
|
||||
host_monotonic_ns=1,
|
||||
)
|
||||
running = store.transition(
|
||||
admitted.run_id,
|
||||
RunState.RUNNING,
|
||||
expected_revision=starting.revision,
|
||||
observed_at_utc="2026-07-24T15:35:02Z",
|
||||
host_monotonic_ns=2,
|
||||
sim_time_ns=0,
|
||||
)
|
||||
event = store.append_event(
|
||||
admitted.run_id,
|
||||
event_type="orchestrator.providers-started",
|
||||
observed_at_utc="2026-07-24T15:35:03Z",
|
||||
host_monotonic_ns=3,
|
||||
sim_time_ns=1_000_000,
|
||||
payload={"provider_ids": ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"]},
|
||||
expected_revision=running.revision,
|
||||
)
|
||||
store.register_artifact(
|
||||
admitted.run_id,
|
||||
QualificationArtifact(
|
||||
artifact_id="provider-log-index",
|
||||
kind="log-index",
|
||||
relative_path="provider-runtime/processes.json",
|
||||
sha256=SHA_A,
|
||||
byte_length=512,
|
||||
source_of_record=True,
|
||||
),
|
||||
)
|
||||
stopping = store.transition(
|
||||
admitted.run_id,
|
||||
RunState.STOPPING,
|
||||
expected_revision=event.sequence,
|
||||
observed_at_utc="2026-07-24T15:35:04Z",
|
||||
host_monotonic_ns=4,
|
||||
sim_time_ns=2_000_000,
|
||||
)
|
||||
return store.transition(
|
||||
admitted.run_id,
|
||||
RunState.COMPLETED,
|
||||
expected_revision=stopping.revision,
|
||||
observed_at_utc="2026-07-24T15:35:05Z",
|
||||
host_monotonic_ns=5,
|
||||
sim_time_ns=2_000_000,
|
||||
reason="operator-stop-clean",
|
||||
)
|
||||
|
||||
|
||||
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
||||
for route in router.routes:
|
||||
if isinstance(route, APIRoute) and route.path == path and method in route.methods:
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} route is missing")
|
||||
|
||||
|
||||
def test_polygon_api_is_read_only_and_fails_closed_when_unconfigured() -> None:
|
||||
router = build_polygon_router(root_provider=lambda: None)
|
||||
routes = [route for route in router.routes if isinstance(route, APIRoute)]
|
||||
|
||||
assert all(route.methods <= {"GET", "HEAD"} for route in routes)
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
_endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
|
||||
assert failure.value.status_code == 503
|
||||
assert "не настроен" in failure.value.detail
|
||||
|
||||
|
||||
def test_polygon_api_publishes_bounded_path_free_qualification_evidence(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "runs"
|
||||
accepted = _accepted_run(root)
|
||||
router = build_polygon_router(root_provider=lambda: root)
|
||||
|
||||
catalog = _endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
|
||||
detail = _endpoint(router, "/api/v1/polygon/runs/{run_id}", "GET")(
|
||||
run_id=accepted.run_id,
|
||||
event_limit=2,
|
||||
)
|
||||
|
||||
assert catalog["schema_version"] == "missioncore.polygon-run-catalog/v1"
|
||||
assert catalog["access"] == "read-only"
|
||||
assert catalog["total"] == 1
|
||||
assert catalog["items"][0]["state"] == "completed"
|
||||
assert catalog["items"][0]["terminal_reason"] == "operator-stop-clean"
|
||||
assert detail["schema_version"] == "missioncore.polygon-run-detail/v1"
|
||||
assert detail["access"] == "read-only"
|
||||
assert detail["run"]["run_id"] == accepted.run_id
|
||||
assert detail["run"]["authority"]["actuator_authority"] is False
|
||||
assert detail["commands"] == {"count": 0, "content_exposed": False}
|
||||
assert detail["events_total"] == accepted.revision
|
||||
assert detail["events_truncated"] is True
|
||||
assert len(detail["events"]) == 2
|
||||
assert detail["artifacts"][0]["relative_path"] == "provider-runtime/processes.json"
|
||||
serialized = repr({"catalog": catalog, "detail": detail})
|
||||
assert str(tmp_path) not in serialized
|
||||
assert "command_ttl_max_ns" in serialized
|
||||
assert all("http" not in artifact for artifact in detail["artifacts"])
|
||||
|
||||
|
||||
def test_polygon_api_rejects_corrupt_evidence_without_partial_response(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "runs"
|
||||
accepted = _accepted_run(root)
|
||||
event_path = root / accepted.run_id / "events" / "00000000000000000001.json"
|
||||
event_path.write_text("{broken", encoding="utf-8")
|
||||
router = build_polygon_router(root_provider=lambda: root)
|
||||
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
_endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
|
||||
assert failure.value.status_code == 500
|
||||
assert "целостности" in failure.value.detail
|
||||
@@ -106,6 +106,40 @@ def test_run_manifest_is_immutable_and_runtime_replays_from_events(tmp_path: Pat
|
||||
assert len(tuple((tmp_path / "runs/run-s1-001/events").iterdir())) == 2
|
||||
|
||||
|
||||
def test_read_only_store_replays_evidence_without_mutating_repository(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "runs"
|
||||
writer = QualificationRunStore(root)
|
||||
writer.create(_run())
|
||||
running = _start(writer)
|
||||
mode_before = root.stat().st_mode
|
||||
|
||||
reader = QualificationRunStore(root, read_only=True)
|
||||
|
||||
assert reader.load(running.run_id).state is RunState.RUNNING
|
||||
assert reader.list_runs()[0].run_id == running.run_id
|
||||
assert root.stat().st_mode == mode_before
|
||||
with pytest.raises(QualificationRunTransitionError, match="read-only"):
|
||||
reader.transition(
|
||||
running.run_id,
|
||||
RunState.PAUSED,
|
||||
expected_revision=running.revision,
|
||||
observed_at_utc="2026-07-24T17:00:03Z",
|
||||
host_monotonic_ns=3,
|
||||
)
|
||||
|
||||
|
||||
def test_read_only_store_requires_an_existing_regular_repository(tmp_path: Path) -> None:
|
||||
with pytest.raises(QualificationRunIntegrityError, match="missing"):
|
||||
QualificationRunStore(tmp_path / "missing", read_only=True)
|
||||
|
||||
linked = tmp_path / "linked"
|
||||
linked.symlink_to(tmp_path, target_is_directory=True)
|
||||
with pytest.raises(QualificationRunIntegrityError, match="symlink"):
|
||||
QualificationRunStore(linked, read_only=True)
|
||||
|
||||
|
||||
def test_lifecycle_rejects_stale_illegal_and_post_terminal_changes(tmp_path: Path) -> None:
|
||||
store = QualificationRunStore(tmp_path)
|
||||
store.create(_run())
|
||||
|
||||
Reference in New Issue
Block a user