feat(simulation): add Polygon live worker gateway

This commit is contained in:
DCCONSTRUCTIONS
2026-07-24 20:33:08 +03:00
parent 359f0d1d39
commit b7ccca3481
13 changed files with 2365 additions and 9 deletions
@@ -0,0 +1,393 @@
import type { PolygonRunState } from "./runArchive";
export interface PolygonWorkerStatus {
workerId: string;
available: boolean;
controlAvailable: boolean;
activeRunId: string | null;
runState: PolygonRunState | null;
providerIds: string[];
isolation: {
network: string;
processIdentity: string;
artifactPolicy: "d-only";
};
}
export interface PolygonVehicleState {
runId: string;
sequence: number;
observedAtUtc: string;
hostMonotonicNs: number;
simTimeNs: number;
position: { x: number; y: number; z: number };
orientation: { x: number; y: number; z: number; w: number };
}
export class PolygonWorkerContractError extends Error {}
export class PolygonWorkerApiError extends Error {
constructor(message: string, readonly status: number | null = null) {
super(message);
}
}
type PolygonWorkerFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RUN_STATES = new Set<PolygonRunState>([
"admitted",
"starting",
"running",
"paused",
"stopping",
"completed",
"failed",
"aborted",
]);
const STATUS_KEYS = new Set([
"schema_version",
"worker_id",
"transport",
"mode",
"available",
"control_available",
"active_run_id",
"run_state",
"provider_ids",
"isolation",
"authority",
]);
const ISOLATION_KEYS = new Set(["network", "process_identity", "artifact_policy"]);
const AUTHORITY_KEYS = new Set([
"scope",
"actuator_authority",
"direct_actuator_setpoints_allowed",
]);
const VEHICLE_KEYS = new Set([
"schema_version",
"run_id",
"sequence",
"observed_at_utc",
"host_monotonic_ns",
"sim_time_ns",
"frame_id",
"child_frame_id",
"pose",
"source",
"safety",
]);
const POSE_KEYS = new Set(["position_m", "orientation_xyzw"]);
const POSITION_KEYS = new Set(["x", "y", "z"]);
const ORIENTATION_KEYS = new Set(["x", "y", "z", "w"]);
const SOURCE_KEYS = new Set(["provider", "topic", "signal", "quality"]);
const SAFETY_KEYS = new Set([
"scope",
"actuator_authority",
"navigation_or_safety_accepted",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new PolygonWorkerContractError(`${label} должен быть объектом.`);
}
return value;
}
function exactKeys(
value: Record<string, unknown>,
expected: ReadonlySet<string>,
label: string,
) {
const keys = Object.keys(value);
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
throw new PolygonWorkerContractError(`${label} содержит неизвестные или отсутствующие поля.`);
}
}
function stringValue(value: unknown, label: string, maximum = 512): string {
if (typeof value !== "string") {
throw new PolygonWorkerContractError(`${label} должен быть строкой.`);
}
const normalized = value.trim();
if (!normalized || normalized.length > maximum) {
throw new PolygonWorkerContractError(`${label} имеет недопустимую длину.`);
}
return normalized;
}
function safeId(value: unknown, label: string): string {
const identifier = stringValue(value, label, 128);
if (!SAFE_ID.test(identifier)) {
throw new PolygonWorkerContractError(`${label} содержит небезопасный идентификатор.`);
}
return identifier;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new PolygonWorkerContractError(`${label} должен быть boolean.`);
}
return value;
}
function integerValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new PolygonWorkerContractError(`${label} должен быть неотрицательным целым.`);
}
return value;
}
function finiteValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) >= 1e9) {
throw new PolygonWorkerContractError(`${label} должен быть конечным числом.`);
}
return value;
}
export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus {
const value = record(payload, "Статус Simulation Worker");
exactKeys(value, STATUS_KEYS, "Статус Simulation Worker");
if (
value.schema_version !== "missioncore.simulation-worker-status/v1" ||
value.transport !== "unix" ||
value.mode !== "simulation"
) {
throw new PolygonWorkerContractError("Статус Simulation Worker имеет неизвестную схему.");
}
const isolation = record(value.isolation, "isolation");
exactKeys(isolation, ISOLATION_KEYS, "isolation");
if (isolation.artifact_policy !== "d-only") {
throw new PolygonWorkerContractError("Simulation Worker нарушает D-only политику.");
}
const authority = record(value.authority, "authority");
exactKeys(authority, AUTHORITY_KEYS, "authority");
if (
authority.scope !== "virtual-only" ||
authority.actuator_authority !== false ||
authority.direct_actuator_setpoints_allowed !== false
) {
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
}
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
throw new PolygonWorkerContractError("provider_ids должен быть ограниченным массивом.");
}
const providerIds = value.provider_ids.map((item, index) =>
safeId(item, `provider_ids[${index}]`));
const activeRunId = value.active_run_id === null
? null
: safeId(value.active_run_id, "active_run_id");
const runStateValue = value.run_state === null
? null
: stringValue(value.run_state, "run_state", 32);
if (
(runStateValue !== null && !RUN_STATES.has(runStateValue as PolygonRunState)) ||
(activeRunId === null) !== (runStateValue === null)
) {
throw new PolygonWorkerContractError("Активный прогон и его состояние противоречат друг другу.");
}
return {
workerId: safeId(value.worker_id, "worker_id"),
available: booleanValue(value.available, "available"),
controlAvailable: booleanValue(value.control_available, "control_available"),
activeRunId,
runState: runStateValue as PolygonRunState | null,
providerIds,
isolation: {
network: stringValue(isolation.network, "isolation.network", 64),
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
artifactPolicy: "d-only",
},
};
}
export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState {
const value = record(payload, "VehicleState");
exactKeys(value, VEHICLE_KEYS, "VehicleState");
if (
value.schema_version !== "missioncore.vehicle-state/v1" ||
value.frame_id !== "map_enu" ||
value.child_frame_id !== "base_link_flu"
) {
throw new PolygonWorkerContractError("VehicleState имеет неизвестную схему координат.");
}
const pose = record(value.pose, "pose");
exactKeys(pose, POSE_KEYS, "pose");
const position = record(pose.position_m, "position_m");
exactKeys(position, POSITION_KEYS, "position_m");
const orientation = record(pose.orientation_xyzw, "orientation_xyzw");
exactKeys(orientation, ORIENTATION_KEYS, "orientation_xyzw");
const source = record(value.source, "source");
exactKeys(source, SOURCE_KEYS, "source");
if (
source.provider !== "gazebo" ||
source.signal !== "ground-truth" ||
source.quality !== "diagnostic"
) {
throw new PolygonWorkerContractError("Live-сигнал не маркирован как Gazebo diagnostic.");
}
const safety = record(value.safety, "safety");
exactKeys(safety, SAFETY_KEYS, "safety");
if (
safety.scope !== "virtual-only" ||
safety.actuator_authority !== false ||
safety.navigation_or_safety_accepted !== false
) {
throw new PolygonWorkerContractError("VehicleState нарушает virtual-only границу.");
}
const observedAtUtc = stringValue(value.observed_at_utc, "observed_at_utc", 64);
if (!Number.isFinite(Date.parse(observedAtUtc))) {
throw new PolygonWorkerContractError("observed_at_utc должен быть ISO-датой.");
}
return {
runId: safeId(value.run_id, "run_id"),
sequence: integerValue(value.sequence, "sequence"),
observedAtUtc,
hostMonotonicNs: integerValue(value.host_monotonic_ns, "host_monotonic_ns"),
simTimeNs: integerValue(value.sim_time_ns, "sim_time_ns"),
position: {
x: finiteValue(position.x, "position.x"),
y: finiteValue(position.y, "position.y"),
z: finiteValue(position.z, "position.z"),
},
orientation: {
x: finiteValue(orientation.x, "orientation.x"),
y: finiteValue(orientation.y, "orientation.y"),
z: finiteValue(orientation.z, "orientation.z"),
w: finiteValue(orientation.w, "orientation.w"),
},
};
}
async function responseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().startsWith("application/json")) {
throw new PolygonWorkerContractError("Polygon Worker API вернул не JSON.");
}
try {
return await response.json();
} catch {
throw new PolygonWorkerContractError("Polygon Worker API вернул повреждённый JSON.");
}
}
function apiError(body: unknown, fallback: string, status: number): PolygonWorkerApiError {
if (isRecord(body) && typeof body.detail === "string") {
const detail = body.detail.trim();
if (detail && detail.length <= 1_000) return new PolygonWorkerApiError(detail, status);
}
return new PolygonWorkerApiError(`${fallback} HTTP ${status}.`, status);
}
async function requestJson(
url: string,
init: RequestInit,
fallback: string,
fetcher: PolygonWorkerFetch,
): Promise<unknown> {
let response: Response;
try {
response = await fetcher(url, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new PolygonWorkerApiError(fallback);
}
const body = await responseBody(response);
if (!response.ok) throw apiError(body, fallback, response.status);
return body;
}
export async function fetchPolygonWorkerStatus({
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: PolygonWorkerFetch;
} = {}): Promise<PolygonWorkerStatus> {
return decodePolygonWorkerStatus(await requestJson(
"/api/v1/polygon/worker",
{ method: "GET", headers: { Accept: "application/json" }, signal },
"Не удалось получить статус Simulation Worker.",
fetcher,
));
}
export async function fetchPolygonVehicleState({
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: PolygonWorkerFetch;
} = {}): Promise<PolygonVehicleState> {
return decodePolygonVehicleState(await requestJson(
"/api/v1/polygon/worker/live",
{ method: "GET", headers: { Accept: "application/json" }, signal },
"Не удалось получить live-состояние ровера.",
fetcher,
));
}
export async function startPolygonWorker({
idempotencyKey,
signal,
fetcher = globalThis.fetch,
}: {
idempotencyKey: string;
signal?: AbortSignal;
fetcher?: PolygonWorkerFetch;
}): Promise<PolygonWorkerStatus> {
return decodePolygonWorkerStatus(await requestJson(
"/api/v1/polygon/worker/runs",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ scenario_id: "stock-rover-ackermann" }),
signal,
},
"Не удалось запустить Simulation Worker.",
fetcher,
));
}
export async function stopPolygonWorker(
runId: string,
{
idempotencyKey,
signal,
fetcher = globalThis.fetch,
}: {
idempotencyKey: string;
signal?: AbortSignal;
fetcher?: PolygonWorkerFetch;
},
): Promise<PolygonWorkerStatus> {
if (!SAFE_ID.test(runId)) {
throw new PolygonWorkerContractError("Некорректный идентификатор активного прогона.");
}
return decodePolygonWorkerStatus(await requestJson(
`/api/v1/polygon/worker/runs/${encodeURIComponent(runId)}/stop`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: "{}",
signal,
},
"Не удалось остановить Simulation Worker.",
fetcher,
));
}
+4 -4
View File
@@ -135,10 +135,10 @@ export const workspaces: WorkspaceDefinition[] = [
{
id: "polygon-run-internal",
root: "system",
label: рогон Полигона",
title: рогон Полигона",
eyebrow: "ПОЛИГОН / UI-0",
description: "Read-only доказательства квалификационного прогона PX4/Gazebo.",
label: "Полигон",
title: "Полигон",
eyebrow: "ПОЛИГОН / UI-1",
description: "Live Simulation Worker и доказательства прогонов PX4/Gazebo.",
icon: "activity",
kind: "polygon-run",
internalOnly: true,
@@ -11,6 +11,7 @@
@media (max-width: 1280px) {
.overview-grid,
.mission-layout,
.polygon-live-layout,
.polygon-run-layout,
.polygon-run-evidence-grid {
grid-template-columns: 1fr;
@@ -83,6 +84,15 @@
gap: 0.7rem;
}
.polygon-live-heading {
align-items: stretch;
flex-direction: column;
}
.polygon-live-heading > div:last-child {
justify-content: space-between;
}
.workspace-lead__note,
.workspace-lead__status {
max-width: none;
@@ -151,6 +161,15 @@
grid-template-columns: 1fr;
}
.polygon-live-heading > div:last-child {
align-items: stretch;
flex-direction: column;
}
.polygon-live-map {
min-height: 15rem;
}
.polygon-run-event-list article {
grid-template-columns: 2.3rem minmax(0, 1fr);
}
@@ -94,6 +94,184 @@
gap: 0.85rem;
}
.polygon-live-panel {
overflow: hidden;
}
.polygon-live-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.polygon-live-heading h2,
.polygon-live-heading p {
margin: 0;
}
.polygon-live-heading h2 {
margin-top: 0.32rem;
color: var(--nodedc-text-primary);
font-size: 1.25rem;
letter-spacing: -0.035em;
}
.polygon-live-heading p {
max-width: 42rem;
margin-top: 0.42rem;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
line-height: 1.5;
}
.polygon-live-heading > div:last-child {
display: flex;
align-items: center;
gap: 0.65rem;
}
.polygon-live-layout {
display: grid;
grid-template-columns: minmax(22rem, 1.45fr) minmax(17rem, 0.55fr);
gap: 0.75rem;
}
.polygon-live-map {
position: relative;
overflow: hidden;
min-height: 19rem;
border: 1px solid var(--station-hairline);
border-radius: 1rem;
background:
radial-gradient(circle at 48% 46%, rgb(var(--nodedc-accent-rgb) / 0.055), transparent 45%),
rgb(255 255 255 / 0.018);
}
.polygon-live-map svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.polygon-live-map pattern path {
fill: none;
stroke: rgb(255 255 255 / 0.045);
stroke-width: 0.25;
}
.polygon-live-map text {
fill: var(--nodedc-text-muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 3px;
}
.polygon-live-axis-x,
.polygon-live-axis-y {
stroke: rgb(255 255 255 / 0.28);
stroke-width: 0.45;
}
.polygon-live-trajectory {
fill: none;
stroke: rgb(var(--nodedc-accent-rgb) / 0.58);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 0.65;
}
.polygon-live-rover {
fill: rgb(var(--nodedc-accent-rgb));
stroke: rgb(var(--nodedc-on-accent-rgb));
stroke-width: 0.35;
}
.polygon-live-map > div {
position: absolute;
top: 50%;
left: 50%;
display: grid;
width: min(22rem, 76%);
gap: 0.35rem;
text-align: center;
transform: translate(-50%, -50%);
}
.polygon-live-map > div strong {
color: var(--nodedc-text-primary);
font-size: 0.78rem;
}
.polygon-live-map > div span,
.polygon-live-map > small {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
line-height: 1.45;
}
.polygon-live-map > small {
position: absolute;
right: 0.8rem;
bottom: 0.7rem;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.polygon-live-telemetry {
display: grid;
align-content: start;
gap: 0.42rem;
}
.polygon-live-telemetry > div:not(.polygon-live-boundary) {
display: grid;
min-width: 0;
gap: 0.22rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.028);
padding: 0.65rem 0.75rem;
}
.polygon-live-telemetry span {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
}
.polygon-live-telemetry strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.62rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-live-boundary {
display: grid;
gap: 0.4rem;
margin-top: 0.2rem;
border: 1px solid rgb(var(--nodedc-success-rgb) / 0.18);
border-radius: 0.8rem;
background: rgb(var(--nodedc-success-rgb) / 0.035);
padding: 0.7rem;
}
.polygon-live-boundary p,
.polygon-live-error {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.56rem;
line-height: 1.5;
}
.polygon-live-error {
border-radius: 0.65rem;
background: rgb(var(--nodedc-danger-rgb) / 0.08);
padding: 0.6rem;
color: rgb(var(--nodedc-danger-rgb));
}
.polygon-run-message {
display: grid;
min-height: 18rem;
@@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
GlassSurface,
StatusBadge,
} from "@nodedc/ui-react";
import {
fetchPolygonVehicleState,
fetchPolygonWorkerStatus,
startPolygonWorker,
stopPolygonWorker,
type PolygonVehicleState,
type PolygonWorkerStatus,
} from "../core/polygon/liveWorker";
function message(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message;
return "Simulation Worker не подтвердил операцию.";
}
function yawDegrees(state: PolygonVehicleState): number {
const { x, y, z, w } = state.orientation;
return Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180 / Math.PI;
}
function trajectoryPoints(states: PolygonVehicleState[]): {
points: string;
roverX: number;
roverY: number;
} {
if (!states.length) return { points: "", roverX: 50, roverY: 50 };
const xs = states.map(({ position }) => position.x);
const ys = states.map(({ position }) => position.y);
const minimumX = Math.min(...xs);
const maximumX = Math.max(...xs);
const minimumY = Math.min(...ys);
const maximumY = Math.max(...ys);
const span = Math.max(maximumX - minimumX, maximumY - minimumY, 4);
const centerX = (minimumX + maximumX) / 2;
const centerY = (minimumY + maximumY) / 2;
const project = (state: PolygonVehicleState) => ({
x: 50 + ((state.position.x - centerX) / span) * 80,
y: 50 - ((state.position.y - centerY) / span) * 80,
});
const projected = states.map(project);
const rover = projected[projected.length - 1];
return {
points: projected.map(({ x, y }) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" "),
roverX: rover.x,
roverY: rover.y,
};
}
export function PolygonLivePanel() {
const [worker, setWorker] = useState<PolygonWorkerStatus | null>(null);
const [live, setLive] = useState<PolygonVehicleState | null>(null);
const [trajectory, setTrajectory] = useState<PolygonVehicleState[]>([]);
const [error, setError] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const [generation, setGeneration] = useState(0);
useEffect(() => {
if (actionBusy) return;
const controller = new AbortController();
let timer: number | null = null;
const poll = async () => {
try {
const status = await fetchPolygonWorkerStatus({ signal: controller.signal });
if (controller.signal.aborted) return;
setWorker(status);
setError(null);
if (status.available && status.activeRunId && status.runState === "running") {
const state = await fetchPolygonVehicleState({ signal: controller.signal });
if (controller.signal.aborted) return;
setLive(state);
setTrajectory((current) => {
const sameRun = current[0]?.runId === state.runId;
const next = sameRun ? [...current, state] : [state];
return next.slice(-120);
});
} else {
setLive(null);
if (!status.activeRunId) setTrajectory([]);
}
} catch (pollError) {
if (controller.signal.aborted) return;
setError(message(pollError));
} finally {
if (!controller.signal.aborted) {
timer = window.setTimeout(() => void poll(), 1_000);
}
}
};
void poll();
return () => {
controller.abort();
if (timer !== null) window.clearTimeout(timer);
};
}, [actionBusy, generation]);
const projected = useMemo(() => trajectoryPoints(trajectory), [trajectory]);
const runActive = Boolean(worker?.activeRunId);
const runAction = async () => {
if (!worker?.controlAvailable || actionBusy) return;
setActionBusy(true);
setError(null);
try {
const idempotencyKey = crypto.randomUUID();
const next = worker.activeRunId
? await stopPolygonWorker(worker.activeRunId, { idempotencyKey })
: await startPolygonWorker({ idempotencyKey });
setWorker(next);
if (!next.activeRunId) {
setLive(null);
setTrajectory([]);
}
setGeneration((value) => value + 1);
} catch (actionError) {
setError(message(actionError));
} finally {
setActionBusy(false);
}
};
return (
<GlassSurface className="polygon-live-panel" padding="lg">
<header className="polygon-live-heading">
<div>
<span className="section-eyebrow">ПОЛИГОН / LIVE</span>
<h2>Stock Ackermann Rover</h2>
<p>PX4/Gazebo исполняются на отдельном worker; браузер показывает канонический срез.</p>
</div>
<div>
<StatusBadge
tone={worker?.available ? (runActive ? "accent" : "success") : "neutral"}
>
{worker?.available ? (runActive ? "Симуляция идёт" : "Worker готов") : "Worker offline"}
</StatusBadge>
<Button
size="compact"
variant={runActive ? "secondary" : "primary"}
disabled={!worker?.controlAvailable || actionBusy}
onClick={() => void runAction()}
>
{actionBusy ? "Ожидаем PX4/Gazebo…" : runActive ? "Остановить" : "Запустить"}
</Button>
</div>
</header>
<div className="polygon-live-layout">
<div className="polygon-live-map" aria-label="Live-траектория ровера в ENU">
<svg viewBox="0 0 100 100" role="img">
<defs>
<pattern id="polygon-grid" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M 10 0 L 0 0 0 10" />
</pattern>
<radialGradient id="polygon-rover-glow">
<stop offset="0" stopColor="rgb(var(--nodedc-accent-rgb))" stopOpacity="0.9" />
<stop offset="1" stopColor="rgb(var(--nodedc-accent-rgb))" stopOpacity="0" />
</radialGradient>
</defs>
<rect width="100" height="100" fill="url(#polygon-grid)" />
<line x1="8" y1="92" x2="25" y2="92" className="polygon-live-axis-x" />
<line x1="8" y1="92" x2="8" y2="75" className="polygon-live-axis-y" />
<text x="27" y="94">E</text>
<text x="5" y="72">N</text>
{projected.points && (
<polyline points={projected.points} className="polygon-live-trajectory" />
)}
{live && (
<g
transform={
`translate(${projected.roverX} ${projected.roverY}) rotate(${-yawDegrees(live)})`
}
>
<circle r="7" fill="url(#polygon-rover-glow)" />
<path d="M -3 -2.5 L 4 0 L -3 2.5 Z" className="polygon-live-rover" />
</g>
)}
</svg>
{!live && (
<div>
<strong>{worker?.available ? "Ровер не запущен" : "Нет связи с worker"}</strong>
<span>После старта здесь появится ground-truth траектория из Gazebo.</span>
</div>
)}
<small>map_enu · base_link_flu · diagnostic ground truth</small>
</div>
<div className="polygon-live-telemetry">
<div>
<span>Прогон</span>
<strong>{worker?.activeRunId ?? "—"}</strong>
</div>
<div>
<span>Sim time</span>
<strong>{live ? `${(live.simTimeNs / 1e9).toFixed(2)} s` : "—"}</strong>
</div>
<div>
<span>Позиция ENU</span>
<strong>
{live
? `${live.position.x.toFixed(2)} · ${live.position.y.toFixed(2)} · ${live.position.z.toFixed(2)} m`
: "—"}
</strong>
</div>
<div>
<span>Провайдеры</span>
<strong>{worker?.providerIds.join(" · ") || "—"}</strong>
</div>
<div className="polygon-live-boundary">
<StatusBadge tone="success">Virtual only</StatusBadge>
<p>
Нет actuator authority. Live-поза диагностическая и пока не доказывает
приёмку PX4/ROS 2 telemetry, navigation или safety.
</p>
</div>
{error && <p className="polygon-live-error">{error}</p>}
</div>
</div>
</GlassSurface>
);
}
@@ -13,6 +13,7 @@ import {
type PolygonRunRoute,
type PolygonRunState,
} from "../core/polygon/runArchive";
import { PolygonLivePanel } from "./PolygonLivePanel";
interface PolygonRunWorkspaceProps {
route: PolygonRunRoute;
@@ -113,6 +114,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
if (loading && !detail) {
return (
<div className="standard-workspace polygon-run-workspace">
<PolygonLivePanel />
<GlassSurface className="polygon-run-message" padding="lg">
<StatusBadge tone="accent">Только чтение</StatusBadge>
<h2>Проверяем журнал прогона</h2>
@@ -125,6 +127,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
if (error) {
return (
<div className="standard-workspace polygon-run-workspace">
<PolygonLivePanel />
<GlassSurface className="polygon-run-message" padding="lg">
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
<h2>UI-0 не может открыть прогон</h2>
@@ -144,6 +147,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
if (!detail) {
return (
<div className="standard-workspace polygon-run-workspace">
<PolygonLivePanel />
<GlassSurface className="polygon-run-message" padding="lg">
<StatusBadge tone="neutral">Журнал пуст</StatusBadge>
<h2>Квалификационных прогонов пока нет</h2>
@@ -156,13 +160,14 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const { run } = detail;
return (
<div className="standard-workspace polygon-run-workspace">
<PolygonLivePanel />
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН</span>
<h2>{run.runId}</h2>
<p>
Канонический журнал Mission Core. Экран не содержит lifecycle-операций,
команд управления или доступа к физическим актуаторам.
Канонический архив Mission Core. Lifecycle live-контура отделён от истории;
команд физическим актуаторам и реального управления здесь нет.
</p>
</div>
<div className="polygon-run-lead-status">
@@ -10,6 +10,13 @@ let fetchPolygonRunCatalog;
let fetchPolygonRunDetail;
let resolvePolygonRunRoute;
let PolygonRunContractError;
let decodePolygonWorkerStatus;
let decodePolygonVehicleState;
let fetchPolygonWorkerStatus;
let fetchPolygonVehicleState;
let startPolygonWorker;
let stopPolygonWorker;
let PolygonWorkerContractError;
let workspaceById;
let workspacesForRoot;
@@ -27,6 +34,15 @@ before(async () => {
resolvePolygonRunRoute,
PolygonRunContractError,
} = await server.ssrLoadModule("/src/core/polygon/runArchive.ts"));
({
decodePolygonWorkerStatus,
decodePolygonVehicleState,
fetchPolygonWorkerStatus,
fetchPolygonVehicleState,
startPolygonWorker,
stopPolygonWorker,
PolygonWorkerContractError,
} = await server.ssrLoadModule("/src/core/polygon/liveWorker.ts"));
({ workspaceById, workspacesForRoot } = await server.ssrLoadModule("/src/productModel.ts"));
});
@@ -148,6 +164,60 @@ function jsonResponse(payload, status = 200) {
});
}
function workerStatus(overrides = {}) {
return {
schema_version: "missioncore.simulation-worker-status/v1",
worker_id: "mission-gpu-s1",
transport: "unix",
mode: "simulation",
available: true,
control_available: true,
active_run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
run_state: "running",
provider_ids: ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"],
isolation: {
network: "loopback-only-netns",
process_identity: "missioncore",
artifact_policy: "d-only",
},
authority: {
scope: "virtual-only",
actuator_authority: false,
direct_actuator_setpoints_allowed: false,
},
...overrides,
};
}
function vehicleState(overrides = {}) {
return {
schema_version: "missioncore.vehicle-state/v1",
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
sequence: 7,
observed_at_utc: "2026-07-24T18:00:07Z",
host_monotonic_ns: 123_456_789,
sim_time_ns: 7_000_000_000,
frame_id: "map_enu",
child_frame_id: "base_link_flu",
pose: {
position_m: { x: 1.25, y: -0.5, z: 0.18 },
orientation_xyzw: { x: 0, y: 0, z: 0.1, w: 0.995 },
},
source: {
provider: "gazebo",
topic: "/world/rover/dynamic_pose/info",
signal: "ground-truth",
quality: "diagnostic",
},
safety: {
scope: "virtual-only",
actuator_authority: false,
navigation_or_safety_accepted: false,
},
...overrides,
};
}
test("UI-0 route is direct-only and does not add Polygon to system navigation", () => {
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-run"), {
active: true,
@@ -228,3 +298,64 @@ test("Polygon fetchers use bounded same-origin GET endpoints", async () => {
assert.ok(calls.every(({ init }) => init.method === "GET"));
assert.ok(calls.every(({ init }) => init.headers.Accept === "application/json"));
});
test("Polygon Live decodes only D-only virtual diagnostic state", () => {
const status = decodePolygonWorkerStatus(workerStatus());
const live = decodePolygonVehicleState(vehicleState());
assert.equal(status.available, true);
assert.equal(status.activeRunId, live.runId);
assert.equal(status.isolation.artifactPolicy, "d-only");
assert.deepEqual(live.position, { x: 1.25, y: -0.5, z: 0.18 });
assert.throws(
() => decodePolygonWorkerStatus(workerStatus({
authority: {
...workerStatus().authority,
actuator_authority: true,
},
})),
PolygonWorkerContractError,
);
assert.throws(
() => decodePolygonVehicleState(vehicleState({
source: {
...vehicleState().source,
quality: "accepted",
},
})),
PolygonWorkerContractError,
);
});
test("Polygon Live uses same-origin gateway and idempotent lifecycle requests", async () => {
const calls = [];
const stopped = workerStatus({
active_run_id: null,
run_state: null,
provider_ids: [],
});
const fetcher = async (url, init) => {
calls.push({ url: String(url), init });
if (String(url).endsWith("/live")) return jsonResponse(vehicleState());
if (String(url).endsWith("/stop")) return jsonResponse(stopped);
return jsonResponse(workerStatus());
};
await fetchPolygonWorkerStatus({ fetcher });
await fetchPolygonVehicleState({ fetcher });
await startPolygonWorker({ idempotencyKey: "start-001", fetcher });
await stopPolygonWorker("s1c-6cb1495-20260724t180000z-aabbcc", {
idempotencyKey: "stop-001",
fetcher,
});
assert.deepEqual(calls.map(({ url }) => url), [
"/api/v1/polygon/worker",
"/api/v1/polygon/worker/live",
"/api/v1/polygon/worker/runs",
"/api/v1/polygon/worker/runs/s1c-6cb1495-20260724t180000z-aabbcc/stop",
]);
assert.equal(calls[2].init.headers["Idempotency-Key"], "start-001");
assert.equal(calls[3].init.headers["Idempotency-Key"], "stop-001");
assert.equal(calls[2].init.body, JSON.stringify({ scenario_id: "stock-rover-ackermann" }));
});