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

View File

@ -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,
));
}

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,

View File

@ -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);
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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" }));
});

60
simulation/s1/worker-agent.sh Executable file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env bash
#
# Run the persistent Simulation Worker agent in a fresh loopback-only network
# namespace. The Unix socket remains reachable from Mission Core because the
# worker shares the host mount namespace while PX4/Gazebo have no routed NIC.
set -Eeuo pipefail
readonly EXPECTED_DISTRO="MissionCore-Sim"
readonly D_ROOT="/mnt/d/NDC_MISSIONCORE/simulation"
readonly SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
readonly MISSION_CORE_COMMIT="${1:-}"
readonly SOCKET_ROOT="/run/missioncore-sim"
readonly SOCKET_PATH="${SOCKET_ROOT}/worker.sock"
if [[ -z "${MISSION_CORE_COMMIT}" ]]; then
echo "usage: $0 <exact-40-character-mission-core-commit>" >&2
exit 2
fi
if [[ "${SOURCE_ROOT}" != "${D_ROOT}/source/"* ]]; then
echo "error: worker source generation must be staged below the reviewed D root" >&2
exit 2
fi
if ! [[ "${MISSION_CORE_COMMIT}" =~ ^[a-f0-9]{40}$ ]]; then
echo "error: Mission Core commit must contain exactly 40 lowercase hex characters" >&2
exit 2
fi
if [[ "${MISSIONCORE_S1_NETNS:-0}" != "1" ]]; then
if [[ "${EUID}" -ne 0 ]]; then
echo "error: initial worker invocation must run as root to create the namespace" >&2
exit 2
fi
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
echo "error: expected WSL distro ${EXPECTED_DISTRO}" >&2
exit 2
fi
install -d -m 0700 -o missioncore -g missioncore -- "${SOCKET_ROOT}"
if [[ -e "${SOCKET_PATH}" && ! -S "${SOCKET_PATH}" ]]; then
echo "error: worker socket path is occupied by a non-socket" >&2
exit 2
fi
exec unshare --net -- bash -c '
set -e
ip link set lo up
exec runuser -u missioncore -- env MISSIONCORE_S1_NETNS=1 "$@"
' bash "$0" "$@"
fi
if [[ "${EUID}" -eq 0 || "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
echo "error: worker agent escaped the reviewed worker identity" >&2
exit 2
fi
exec env \
PYTHONPATH="${SOURCE_ROOT}/src" \
python3 -m k1link.simulation.worker_agent \
--socket "${SOCKET_PATH}" \
--mission-core-commit "${MISSION_CORE_COMMIT}" \
--lifecycle-profile "${SOURCE_ROOT}/simulation/s1/stock-rover-lifecycle.yaml"

View File

@ -0,0 +1,632 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import signal
import socket
import subprocess
import sys
import threading
import time
from datetime import UTC, datetime
from pathlib import Path
from types import FrameType
from typing import Any, Final
from k1link.simulation.contracts import (
AuthorityProfile,
ProviderPin,
QualificationRun,
ReproducibilityTier,
RunKind,
RunState,
)
from k1link.simulation.orchestrator import SimulationApplicationService
from k1link.simulation.process_supervisor import PosixProcessSupervisor
from k1link.simulation.run_store import (
ACTIVE_RECOVERY_STATES,
QualificationRunNotFoundError,
QualificationRunStore,
)
from k1link.simulation.s0 import ComponentPin
from k1link.simulation.stock_rover import (
StockRoverTargetPaths,
load_stock_rover_lifecycle_profile,
stock_rover_process_environment,
stock_rover_process_specs,
)
from k1link.simulation.worker import (
LocalProcessWorkerAdapter,
S0WorkerGuard,
SimulationWorldControl,
)
from k1link.simulation.worker_gateway import (
MAX_MESSAGE_BYTES,
OPERATIONS,
REQUEST_SCHEMA,
RESPONSE_SCHEMA,
STATUS_SCHEMA,
VEHICLE_STATE_SCHEMA,
)
EXPECTED_DISTRO: Final = "MissionCore-Sim"
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
SAFE_ID_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
PROVIDER_IDS: Final = (
"px4-autopilot",
"gazebo",
"px4-gazebo-models",
"micro-xrce-dds-agent",
)
ACTIVE_STATES: Final = frozenset(ACTIVE_RECOVERY_STATES)
POSE_TOPIC: Final = "/world/rover/dynamic_pose/info"
class WorkerAgentError(RuntimeError):
"""The worker agent cannot safely satisfy a gateway request."""
class _LifecycleOnlyWorldControl(SimulationWorldControl):
def pause(self, run_id: str) -> None:
raise WorkerAgentError(f"pause is not admitted for {run_id}")
def resume(self, run_id: str) -> None:
raise WorkerAgentError(f"resume is not admitted for {run_id}")
def step(self, run_id: str, step_count: int) -> int:
raise WorkerAgentError(f"step is not admitted for {run_id}:{step_count}")
class GazeboPoseCollector:
"""Read one bounded ground-truth pose sample from Gazebo Transport."""
def __init__(self, environment: dict[str, str]) -> None:
self.environment = dict(environment)
self._sequence = 0
self._lock = threading.Lock()
def collect(self, run_id: str) -> dict[str, Any]:
try:
completed = subprocess.run(
(
"/usr/bin/gz",
"topic",
"-e",
"--json-output",
"-t",
POSE_TOPIC,
"-n",
"1",
),
check=True,
capture_output=True,
env=self.environment,
timeout=3,
)
except (OSError, subprocess.SubprocessError) as exc:
raise WorkerAgentError("Gazebo pose sample is unavailable") from exc
if len(completed.stdout) > MAX_MESSAGE_BYTES:
raise WorkerAgentError("Gazebo pose sample exceeds the size limit")
try:
document = json.loads(completed.stdout.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise WorkerAgentError("Gazebo pose sample is not valid JSON") from exc
if not isinstance(document, dict):
raise WorkerAgentError("Gazebo pose sample must be an object")
pose = _rover_pose(document)
stamp = _object(_object(document.get("header"), "header").get("stamp"), "stamp")
sim_time_ns = _integer(stamp.get("sec"), "stamp.sec") * 1_000_000_000 + _integer(
stamp.get("nsec"),
"stamp.nsec",
)
with self._lock:
self._sequence += 1
sequence = self._sequence
return {
"schema_version": VEHICLE_STATE_SCHEMA,
"run_id": run_id,
"sequence": sequence,
"observed_at_utc": _utc_now(),
"host_monotonic_ns": time.monotonic_ns(),
"sim_time_ns": sim_time_ns,
"frame_id": "map_enu",
"child_frame_id": "base_link_flu",
"pose": {
"position_m": {
"x": _number(_object(pose.get("position"), "position").get("x"), "position.x"),
"y": _number(_object(pose.get("position"), "position").get("y"), "position.y"),
"z": _number(_object(pose.get("position"), "position").get("z"), "position.z"),
},
"orientation_xyzw": {
"x": _number(
_object(pose.get("orientation"), "orientation").get("x"),
"orientation.x",
),
"y": _number(
_object(pose.get("orientation"), "orientation").get("y"),
"orientation.y",
),
"z": _number(
_object(pose.get("orientation"), "orientation").get("z"),
"orientation.z",
),
"w": _number(
_object(pose.get("orientation"), "orientation").get("w"),
"orientation.w",
),
},
},
"source": {
"provider": "gazebo",
"topic": POSE_TOPIC,
"signal": "ground-truth",
"quality": "diagnostic",
},
"safety": {
"scope": "virtual-only",
"actuator_authority": False,
"navigation_or_safety_accepted": False,
},
}
class SimulationWorkerAgent:
"""One loopback-only worker process owning provider lifecycle and live state."""
def __init__(
self,
*,
socket_path: Path,
mission_core_commit: str,
d_root: Path,
s0_profile_path: Path,
lifecycle_profile_path: Path,
) -> None:
if not COMMIT_PATTERN.fullmatch(mission_core_commit):
raise WorkerAgentError("Mission Core commit must be an exact 40-character revision")
if os.geteuid() == 0:
raise WorkerAgentError("worker agent must run as an unprivileged identity")
if os.environ.get("WSL_DISTRO_NAME") != EXPECTED_DISTRO:
raise WorkerAgentError(f"worker agent requires {EXPECTED_DISTRO}")
if tuple(sorted(name for _, name in socket.if_nameindex())) != ("lo",):
raise WorkerAgentError("worker agent requires a loopback-only network namespace")
self.socket_path = socket_path
self.mission_core_commit = mission_core_commit
self.paths = StockRoverTargetPaths.from_d_root(d_root)
self.lifecycle_profile_path = lifecycle_profile_path.expanduser().resolve()
self.lifecycle_profile = load_stock_rover_lifecycle_profile(self.lifecycle_profile_path)
self.guard = S0WorkerGuard(s0_profile_path)
self._preflight()
self.store = QualificationRunStore(self.paths.d_root / "artifacts/s1/runs")
self.supervisor = PosixProcessSupervisor(
self.paths.d_root / "runtime/s1/runs",
base_environment=stock_rover_process_environment(self.paths),
)
self.worker = LocalProcessWorkerAdapter(
self.guard,
self.supervisor,
stock_rover_process_specs(self.paths, self.lifecycle_profile),
_LifecycleOnlyWorldControl(),
)
self.service = SimulationApplicationService(self.store, self.worker)
self.collector = GazeboPoseCollector(stock_rover_process_environment(self.paths))
self._shutdown = threading.Event()
self._server_socket: socket.socket | None = None
self._connection_threads: set[threading.Thread] = set()
self.service.reconcile(
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
)
def serve_forever(self) -> None:
self.socket_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
if self.socket_path.exists():
if not self.socket_path.is_socket():
raise WorkerAgentError("worker socket path is occupied by a non-socket")
self.socket_path.unlink()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
self._server_socket = server
server.bind(str(self.socket_path))
self.socket_path.chmod(0o600)
server.listen(8)
server.settimeout(0.5)
while not self._shutdown.is_set():
try:
connection, _ = server.accept()
except TimeoutError:
continue
self._connection_threads = {
thread for thread in self._connection_threads if thread.is_alive()
}
thread = threading.Thread(
target=self._serve_connection,
args=(connection,),
daemon=True,
)
self._connection_threads.add(thread)
thread.start()
for thread in tuple(self._connection_threads):
thread.join(timeout=1)
self._server_socket = None
if self.socket_path.is_socket():
self.socket_path.unlink()
def shutdown(self) -> None:
self._shutdown.set()
active = self._active_run()
if active is not None:
try:
self.service.stop(
active.run_id,
idempotency_key=f"{active.run_id}:agent-shutdown",
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
sim_time_ns=None,
terminal_state=RunState.ABORTED,
reason="worker-agent-shutdown",
)
except Exception:
self.supervisor.stop()
def _serve_connection(self, connection: socket.socket) -> None:
with connection:
self._handle_connection(connection)
def dispatch(self, request: dict[str, Any]) -> dict[str, Any]:
if set(request) != {"schema_version", "request_id", "operation", "payload"}:
raise WorkerAgentError("request keys do not match v1")
request_id = request.get("request_id")
operation = request.get("operation")
payload = request.get("payload")
if (
request.get("schema_version") != REQUEST_SCHEMA
or not isinstance(request_id, str)
or not 1 <= len(request_id) <= 64
or operation not in OPERATIONS
or not isinstance(payload, dict)
):
raise WorkerAgentError("request envelope is invalid")
if operation == "status":
_exact_keys(payload, set(), "status payload")
return self.status()
if operation == "live":
_exact_keys(payload, set(), "live payload")
active = self._active_run()
if active is None or active.state is not RunState.RUNNING:
raise WorkerAgentError("no running simulation is available")
return self.collector.collect(active.run_id)
if operation == "start":
_exact_keys(
payload,
{"run_id", "mission_core_commit", "idempotency_key"},
"start payload",
)
return self.start(
run_id=_safe_id(payload["run_id"], "run id"),
mission_core_commit=_string(payload["mission_core_commit"], "commit", 40),
idempotency_key=_string(payload["idempotency_key"], "idempotency key", 160),
)
_exact_keys(payload, {"run_id", "idempotency_key"}, "stop payload")
return self.stop(
run_id=_safe_id(payload["run_id"], "run id"),
idempotency_key=_string(payload["idempotency_key"], "idempotency key", 160),
)
def status(self) -> dict[str, Any]:
active = self._active_run()
provider_ids: list[str] = []
if active is not None and active.state in {RunState.RUNNING, RunState.PAUSED}:
try:
provider_ids = [record.process_id for record in self.supervisor.snapshot()]
except Exception:
provider_ids = []
return {
"schema_version": STATUS_SCHEMA,
"worker_id": "mission-gpu-s1",
"transport": "unix",
"mode": "simulation",
"available": True,
"control_available": True,
"active_run_id": active.run_id if active else None,
"run_state": active.state.value if active else None,
"provider_ids": provider_ids,
"isolation": {
"network": "loopback-only-netns",
"process_identity": "missioncore",
"artifact_policy": "d-only",
},
"authority": {
"scope": "virtual-only",
"actuator_authority": False,
"direct_actuator_setpoints_allowed": False,
},
}
def start(
self,
*,
run_id: str,
mission_core_commit: str,
idempotency_key: str,
) -> dict[str, Any]:
if mission_core_commit != self.mission_core_commit:
raise WorkerAgentError("requested commit does not match the staged worker generation")
try:
run = self.store.load(run_id)
except QualificationRunNotFoundError:
run = self.store.create(self._new_run(run_id))
if run.mission_core_commit != mission_core_commit:
raise WorkerAgentError("existing run belongs to another Mission Core generation")
running = self.service.start(
run_id,
idempotency_key=idempotency_key,
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
)
if running.state is not RunState.RUNNING:
raise WorkerAgentError(
f"provider start ended in terminal state {running.state.value}"
)
return self.status()
def stop(self, *, run_id: str, idempotency_key: str) -> dict[str, Any]:
active = self._active_run()
if active is None or active.run_id != run_id:
raise WorkerAgentError("requested run does not own worker authority")
sim_time_ns: int | None
try:
sim_time_ns = int(self.collector.collect(run_id)["sim_time_ns"])
except Exception:
sim_time_ns = None
terminal = self.service.stop(
run_id,
idempotency_key=idempotency_key,
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
sim_time_ns=sim_time_ns,
)
if terminal.state is not RunState.COMPLETED:
raise WorkerAgentError(
f"provider stop ended in terminal state {terminal.state.value}"
)
return self.status()
def _handle_connection(self, connection: socket.socket) -> None:
request_id: str | None = None
try:
request_bytes = _read_line(connection)
request = _json_object(request_bytes)
raw_request_id = request.get("request_id")
if isinstance(raw_request_id, str) and 1 <= len(raw_request_id) <= 64:
request_id = raw_request_id
result = self.dispatch(request)
response = {
"schema_version": RESPONSE_SCHEMA,
"request_id": request_id,
"ok": True,
"result": result,
"error": None,
}
except Exception as exc:
response = {
"schema_version": RESPONSE_SCHEMA,
"request_id": request_id,
"ok": False,
"result": None,
"error": _safe_error(exc),
}
encoded = (
json.dumps(response, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + b"\n"
)
if len(encoded) <= MAX_MESSAGE_BYTES:
connection.sendall(encoded)
def _new_run(self, run_id: str) -> 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=_sha256(self.paths.scenario_path),
profile_generation=self.lifecycle_profile.profile_id,
profile_sha256=_sha256(self.lifecycle_profile_path),
mission_core_commit=self.mission_core_commit,
providers=_provider_pins(self.guard),
host_profile_id=self.guard.profile.profile_id,
host_profile_sha256=self.guard.profile_sha256,
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=_utc_now(),
)
def _active_run(self) -> QualificationRun | None:
active = tuple(run for run in self.store.list_runs() if run.state in ACTIVE_STATES)
if len(active) > 1:
raise WorkerAgentError("multiple active qualification runs violate worker authority")
return active[0] if active else None
def _preflight(self) -> None:
required = (self.paths.px4_root, self.paths.agent_binary, self.paths.scenario_path)
if any(not path.exists() for path in required):
raise WorkerAgentError("worker inputs are incomplete")
if not all(
component.qualification_state == "accepted"
for component in self.guard.profile.components
):
raise WorkerAgentError("an S0 provider generation is no longer accepted")
def _read_line(connection: socket.socket) -> bytes:
chunks: list[bytes] = []
size = 0
while True:
chunk = connection.recv(min(4096, MAX_MESSAGE_BYTES + 1 - size))
if not chunk:
raise WorkerAgentError("gateway request ended before newline")
newline = chunk.find(b"\n")
if newline >= 0:
chunks.append(chunk[:newline])
size += newline
if size > MAX_MESSAGE_BYTES:
raise WorkerAgentError("gateway request exceeds the size limit")
return b"".join(chunks)
chunks.append(chunk)
size += len(chunk)
if size > MAX_MESSAGE_BYTES:
raise WorkerAgentError("gateway request exceeds the size limit")
def _json_object(value: bytes) -> dict[str, Any]:
try:
document = json.loads(value.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise WorkerAgentError("gateway request is not valid UTF-8 JSON") from exc
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
raise WorkerAgentError("gateway request must be a JSON object")
return document
def _rover_pose(document: dict[str, Any]) -> dict[str, Any]:
poses = document.get("pose")
if not isinstance(poses, list) or not poses:
raise WorkerAgentError("Gazebo pose sample contains no poses")
for item in poses:
if isinstance(item, dict) and str(item.get("name", "")).startswith("rover_ackermann"):
return item
raise WorkerAgentError("Gazebo pose sample contains no stock rover")
def _provider_pins(guard: S0WorkerGuard) -> tuple[ProviderPin, ...]:
components = {component.identifier: component for component in guard.profile.components}
if any(identifier not in components for identifier in PROVIDER_IDS):
raise WorkerAgentError("S0 profile is missing a required provider")
return tuple(_provider_pin(components[identifier]) for identifier in PROVIDER_IDS)
def _provider_pin(component: ComponentPin) -> ProviderPin:
version = component.resolved_version or component.requested_ref
return ProviderPin(
identifier=component.identifier,
version=version,
revision=component.resolved_commit or version,
)
def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
if set(value) != expected:
raise WorkerAgentError(f"{label} keys do not match v1")
def _safe_id(value: object, label: str) -> str:
result = _string(value, label, 64)
if not SAFE_ID_PATTERN.fullmatch(result):
raise WorkerAgentError(f"{label} is not a safe identifier")
return result
def _string(value: object, label: str, maximum: int) -> str:
if not isinstance(value, str):
raise WorkerAgentError(f"{label} must be a string")
result = value.strip()
if not 1 <= len(result) <= maximum:
raise WorkerAgentError(f"{label} has an invalid length")
return result
def _object(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise WorkerAgentError(f"{label} must be an object")
return value
def _number(value: object, label: str) -> float:
if isinstance(value, bool) or not isinstance(value, int | float | str):
raise WorkerAgentError(f"{label} must be numeric")
try:
result = float(value)
except ValueError as exc:
raise WorkerAgentError(f"{label} must be numeric") from exc
if not -1e9 < result < 1e9:
raise WorkerAgentError(f"{label} is outside the accepted range")
return result
def _integer(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int | str):
raise WorkerAgentError(f"{label} must be an integer")
try:
result = int(value)
except ValueError as exc:
raise WorkerAgentError(f"{label} must be an integer") from exc
if result < 0:
raise WorkerAgentError(f"{label} must not be negative")
return result
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _safe_error(exc: Exception) -> str:
if isinstance(exc, WorkerAgentError):
normalized = " ".join(str(exc).split())
return normalized[:512] or "worker request rejected"
return f"{type(exc).__name__}: worker operation failed"
def main() -> int:
parser = argparse.ArgumentParser(description="Serve the Mission Core Simulation Worker agent.")
parser.add_argument("--socket", type=Path, required=True)
parser.add_argument("--mission-core-commit", required=True)
parser.add_argument(
"--d-root",
type=Path,
default=Path("/mnt/d/NDC_MISSIONCORE/simulation"),
)
parser.add_argument(
"--s0-profile",
type=Path,
default=Path("/mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml"),
)
parser.add_argument("--lifecycle-profile", type=Path, required=True)
args = parser.parse_args()
agent = SimulationWorkerAgent(
socket_path=args.socket,
mission_core_commit=args.mission_core_commit,
d_root=args.d_root,
s0_profile_path=args.s0_profile,
lifecycle_profile_path=args.lifecycle_profile,
)
def stop(_signum: int, _frame: FrameType | None) -> None:
agent.shutdown()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
try:
agent.serve_forever()
finally:
agent.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,260 @@
from __future__ import annotations
import json
import socket
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Final, Protocol
from uuid import uuid4
REQUEST_SCHEMA: Final = "missioncore.simulation-worker-request/v1"
RESPONSE_SCHEMA: Final = "missioncore.simulation-worker-response/v1"
STATUS_SCHEMA: Final = "missioncore.simulation-worker-status/v1"
VEHICLE_STATE_SCHEMA: Final = "missioncore.vehicle-state/v1"
MAX_MESSAGE_BYTES: Final = 64 * 1024
OPERATIONS: Final = frozenset({"status", "live", "start", "stop"})
class SimulationWorkerGatewayError(RuntimeError):
"""The worker transport or response violated the private gateway contract."""
class SimulationWorkerUnavailableError(SimulationWorkerGatewayError):
"""The configured worker cannot currently be reached."""
class SimulationWorkerRejectedError(SimulationWorkerGatewayError):
"""The worker safely rejected a valid gateway request."""
class PolygonWorkerGateway(Protocol):
def status(self) -> dict[str, Any]: ...
def live(self) -> dict[str, Any]: ...
def start(
self,
*,
run_id: str,
mission_core_commit: str,
idempotency_key: str,
) -> dict[str, Any]: ...
def stop(
self,
*,
run_id: str,
idempotency_key: str,
) -> dict[str, Any]: ...
class UnixSocketWorkerGateway:
"""Bounded request/response transport between Mission Core and one local worker."""
def __init__(
self,
socket_path: Path,
*,
request_timeout_seconds: float = 3.0,
lifecycle_timeout_seconds: float = 150.0,
) -> None:
path = socket_path.expanduser()
if not path.is_absolute():
raise ValueError("worker socket path must be absolute")
if request_timeout_seconds <= 0 or lifecycle_timeout_seconds <= 0:
raise ValueError("worker gateway timeouts must be positive")
self.socket_path = path
self.request_timeout_seconds = request_timeout_seconds
self.lifecycle_timeout_seconds = lifecycle_timeout_seconds
def status(self) -> dict[str, Any]:
result = self._request("status", {}, timeout_seconds=self.request_timeout_seconds)
_validate_status(result)
return result
def live(self) -> dict[str, Any]:
result = self._request("live", {}, timeout_seconds=self.request_timeout_seconds)
_validate_vehicle_state(result)
return result
def start(
self,
*,
run_id: str,
mission_core_commit: str,
idempotency_key: str,
) -> dict[str, Any]:
result = self._request(
"start",
{
"run_id": run_id,
"mission_core_commit": mission_core_commit,
"idempotency_key": idempotency_key,
},
timeout_seconds=self.lifecycle_timeout_seconds,
)
_validate_status(result)
return result
def stop(
self,
*,
run_id: str,
idempotency_key: str,
) -> dict[str, Any]:
result = self._request(
"stop",
{
"run_id": run_id,
"idempotency_key": idempotency_key,
},
timeout_seconds=self.lifecycle_timeout_seconds,
)
_validate_status(result)
return result
def _request(
self,
operation: str,
payload: Mapping[str, object],
*,
timeout_seconds: float,
) -> dict[str, Any]:
if operation not in OPERATIONS:
raise ValueError("unsupported worker operation")
request_id = uuid4().hex
encoded = (
json.dumps(
{
"schema_version": REQUEST_SCHEMA,
"request_id": request_id,
"operation": operation,
"payload": dict(payload),
},
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
+ b"\n"
)
if len(encoded) > MAX_MESSAGE_BYTES:
raise ValueError("worker request exceeds the bounded message size")
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(timeout_seconds)
client.connect(str(self.socket_path))
client.sendall(encoded)
response_bytes = _read_line(client)
except (FileNotFoundError, ConnectionError, OSError, TimeoutError) as exc:
raise SimulationWorkerUnavailableError(
"Simulation Worker недоступен через локальный шлюз."
) from exc
response = _json_object(response_bytes, "worker response")
if set(response) != {"schema_version", "request_id", "ok", "result", "error"}:
raise SimulationWorkerGatewayError("worker response keys do not match v1")
if response["schema_version"] != RESPONSE_SCHEMA or response["request_id"] != request_id:
raise SimulationWorkerGatewayError("worker response identity does not match request")
if not isinstance(response["ok"], bool):
raise SimulationWorkerGatewayError("worker response ok flag is invalid")
if response["ok"]:
if response["error"] is not None or not isinstance(response["result"], dict):
raise SimulationWorkerGatewayError("successful worker response is malformed")
return response["result"]
error = response["error"]
if response["result"] is not None or not isinstance(error, str) or not error.strip():
raise SimulationWorkerGatewayError("rejected worker response is malformed")
raise SimulationWorkerRejectedError(error[:512])
def _read_line(connection: socket.socket) -> bytes:
chunks: list[bytes] = []
size = 0
while True:
chunk = connection.recv(min(4096, MAX_MESSAGE_BYTES + 1 - size))
if not chunk:
raise SimulationWorkerGatewayError("worker closed the gateway without a response")
newline = chunk.find(b"\n")
if newline >= 0:
chunks.append(chunk[:newline])
size += newline
if size > MAX_MESSAGE_BYTES:
raise SimulationWorkerGatewayError("worker response exceeds the size limit")
return b"".join(chunks)
chunks.append(chunk)
size += len(chunk)
if size > MAX_MESSAGE_BYTES:
raise SimulationWorkerGatewayError("worker response exceeds the size limit")
def _json_object(value: bytes, label: str) -> dict[str, Any]:
try:
document = json.loads(value.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SimulationWorkerGatewayError(f"{label} is not valid UTF-8 JSON") from exc
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
raise SimulationWorkerGatewayError(f"{label} must be a JSON object")
return document
def _validate_status(value: Mapping[str, Any]) -> None:
expected = {
"schema_version",
"worker_id",
"transport",
"mode",
"available",
"control_available",
"active_run_id",
"run_state",
"provider_ids",
"isolation",
"authority",
}
if set(value) != expected or value.get("schema_version") != STATUS_SCHEMA:
raise SimulationWorkerGatewayError("worker status does not match v1")
if (
not isinstance(value.get("worker_id"), str)
or value.get("transport") != "unix"
or value.get("mode") != "simulation"
or value.get("available") is not True
or not isinstance(value.get("control_available"), bool)
or not isinstance(value.get("provider_ids"), list)
or any(not isinstance(item, str) for item in value["provider_ids"])
or not isinstance(value.get("isolation"), dict)
or not isinstance(value.get("authority"), dict)
):
raise SimulationWorkerGatewayError("worker status contains invalid values")
if value.get("active_run_id") is not None and not isinstance(value["active_run_id"], str):
raise SimulationWorkerGatewayError("worker active run id is invalid")
if value.get("run_state") is not None and not isinstance(value["run_state"], str):
raise SimulationWorkerGatewayError("worker run state is invalid")
def _validate_vehicle_state(value: Mapping[str, Any]) -> None:
expected = {
"schema_version",
"run_id",
"sequence",
"observed_at_utc",
"host_monotonic_ns",
"sim_time_ns",
"frame_id",
"child_frame_id",
"pose",
"source",
"safety",
}
if set(value) != expected or value.get("schema_version") != VEHICLE_STATE_SCHEMA:
raise SimulationWorkerGatewayError("vehicle state does not match v1")
if (
not isinstance(value.get("run_id"), str)
or not isinstance(value.get("sequence"), int)
or not isinstance(value.get("observed_at_utc"), str)
or not isinstance(value.get("host_monotonic_ns"), int)
or not isinstance(value.get("sim_time_ns"), int)
or value.get("frame_id") != "map_enu"
or value.get("child_frame_id") != "base_link_flu"
or not isinstance(value.get("pose"), dict)
or not isinstance(value.get("source"), dict)
or not isinstance(value.get("safety"), dict)
):
raise SimulationWorkerGatewayError("vehicle state contains invalid values")

View File

@ -1,12 +1,16 @@
from __future__ import annotations
import os
import re
import secrets
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated, Any, Final
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, Header, HTTPException, Query
from fastapi import Path as PathParameter
from pydantic import BaseModel, ConfigDict
from k1link.simulation import (
QualificationRun,
@ -14,10 +18,22 @@ from k1link.simulation import (
QualificationRunNotFoundError,
QualificationRunStore,
)
from k1link.simulation.worker_gateway import (
STATUS_SCHEMA,
PolygonWorkerGateway,
SimulationWorkerGatewayError,
SimulationWorkerRejectedError,
SimulationWorkerUnavailableError,
UnixSocketWorkerGateway,
)
POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT"
POLYGON_WORKER_SOCKET_ENV: Final = "MISSIONCORE_POLYGON_WORKER_SOCKET"
POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL"
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1"
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
"UI-0 публикует только квалификационные доказательства; "
"lifecycle-операции и команды отсутствуют.",
@ -27,6 +43,15 @@ READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
)
RootProvider = Callable[[], Path | None]
WorkerProvider = Callable[[], PolygonWorkerGateway | None]
ControlProvider = Callable[[], bool]
CommitProvider = Callable[[], str | None]
class StartStockRoverRequest(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
scenario_id: str
def configured_polygon_runs_root() -> Path | None:
@ -36,9 +61,31 @@ def configured_polygon_runs_root() -> Path | None:
return Path(raw.strip()).expanduser()
def configured_polygon_worker() -> PolygonWorkerGateway | None:
raw = os.environ.get(POLYGON_WORKER_SOCKET_ENV)
if raw is None or not raw.strip():
return None
return UnixSocketWorkerGateway(Path(raw.strip()).expanduser())
def configured_polygon_control() -> bool:
return os.environ.get(POLYGON_WORKER_CONTROL_ENV, "").strip() == "internal-virtual-only"
def configured_mission_core_commit() -> str | None:
raw = os.environ.get(MISSION_CORE_COMMIT_ENV)
if raw is None:
return None
normalized = raw.strip()
return normalized if COMMIT_PATTERN.fullmatch(normalized) else None
def build_polygon_router(
*,
root_provider: RootProvider = configured_polygon_runs_root,
worker_provider: WorkerProvider = configured_polygon_worker,
control_provider: ControlProvider = configured_polygon_control,
commit_provider: CommitProvider = configured_mission_core_commit,
) -> APIRouter:
router = APIRouter(prefix="/api/v1/polygon", tags=["polygon"])
@ -111,6 +158,98 @@ def build_polygon_router(
"limitations": list(READ_ONLY_LIMITATIONS),
}
@router.get("/worker")
def get_polygon_worker() -> dict[str, Any]:
gateway = worker_provider()
if gateway is None:
return _unavailable_worker_status()
try:
status = gateway.status()
except SimulationWorkerGatewayError:
return _unavailable_worker_status()
return {
**status,
"control_available": bool(status["control_available"] and control_provider()),
}
@router.get("/worker/live")
def get_polygon_worker_live() -> dict[str, Any]:
gateway = _required_worker(worker_provider)
try:
return gateway.live()
except SimulationWorkerUnavailableError as exc:
raise HTTPException(
status_code=503,
detail="Simulation Worker недоступен.",
) from exc
except SimulationWorkerRejectedError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except SimulationWorkerGatewayError as exc:
raise HTTPException(
status_code=502,
detail="Simulation Worker вернул некорректное состояние.",
) from exc
@router.post("/worker/runs")
def start_polygon_worker_run(
request: StartStockRoverRequest,
idempotency_key: Annotated[
str,
Header(alias="Idempotency-Key", min_length=1, max_length=160),
],
) -> dict[str, Any]:
if request.scenario_id != "stock-rover-ackermann":
raise HTTPException(status_code=422, detail="Сценарий Полигона не поддерживается.")
gateway, commit = _control_context(
worker_provider,
control_provider,
commit_provider,
)
run_id = _new_run_id(commit)
try:
return gateway.start(
run_id=run_id,
mission_core_commit=commit,
idempotency_key=idempotency_key,
)
except SimulationWorkerUnavailableError as exc:
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
except SimulationWorkerRejectedError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except SimulationWorkerGatewayError as exc:
raise HTTPException(
status_code=502,
detail="Simulation Worker не подтвердил запуск.",
) from exc
@router.post("/worker/runs/{run_id}/stop")
def stop_polygon_worker_run(
run_id: Annotated[
str,
PathParameter(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$"),
],
idempotency_key: Annotated[
str,
Header(alias="Idempotency-Key", min_length=1, max_length=160),
],
) -> dict[str, Any]:
gateway, _ = _control_context(
worker_provider,
control_provider,
commit_provider,
)
try:
return gateway.stop(run_id=run_id, idempotency_key=idempotency_key)
except SimulationWorkerUnavailableError as exc:
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
except SimulationWorkerRejectedError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except SimulationWorkerGatewayError as exc:
raise HTTPException(
status_code=502,
detail="Simulation Worker не подтвердил остановку.",
) from exc
return router
@ -155,3 +294,62 @@ def _run_summary(run: QualificationRun) -> dict[str, Any]:
"provider_ids": [provider.identifier for provider in run.providers],
"artifact_count": len(run.artifacts),
}
def _required_worker(provider: WorkerProvider) -> PolygonWorkerGateway:
gateway = provider()
if gateway is None:
raise HTTPException(
status_code=503,
detail="Simulation Worker не зарегистрирован на этом экземпляре Mission Core.",
)
return gateway
def _control_context(
worker_provider: WorkerProvider,
control_provider: ControlProvider,
commit_provider: CommitProvider,
) -> tuple[PolygonWorkerGateway, str]:
if not control_provider():
raise HTTPException(
status_code=403,
detail="Lifecycle Полигона отключён на этом экземпляре Mission Core.",
)
gateway = _required_worker(worker_provider)
commit = commit_provider()
if commit is None:
raise HTTPException(
status_code=503,
detail="Mission Core не привязан к точной Git-ревизии.",
)
return gateway, commit
def _unavailable_worker_status() -> dict[str, Any]:
return {
"schema_version": STATUS_SCHEMA,
"worker_id": "mission-gpu-s1",
"transport": "unix",
"mode": "simulation",
"available": False,
"control_available": False,
"active_run_id": None,
"run_state": None,
"provider_ids": [],
"isolation": {
"network": "unavailable",
"process_identity": "missioncore",
"artifact_policy": "d-only",
},
"authority": {
"scope": "virtual-only",
"actuator_authority": False,
"direct_actuator_setpoints_allowed": False,
},
}
def _new_run_id(commit: str) -> str:
stamp = datetime.now(UTC).strftime("%Y%m%dt%H%M%Sz").lower()
return f"s1c-{commit[:7]}-{stamp}-{secrets.token_hex(3)}"

View File

@ -18,7 +18,7 @@ from k1link.simulation import (
RunKind,
RunState,
)
from k1link.web.polygon_api import build_polygon_router
from k1link.web.polygon_api import StartStockRoverRequest, build_polygon_router
SHA_A = "a" * 64
SHA_B = "b" * 64
@ -130,7 +130,8 @@ 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)
archive_routes = [route for route in routes if "/worker" not in route.path]
assert all(route.methods <= {"GET", "HEAD"} for route in archive_routes)
with pytest.raises(HTTPException) as failure:
_endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
assert failure.value.status_code == 503
@ -183,3 +184,134 @@ def test_polygon_api_rejects_corrupt_evidence_without_partial_response(
_endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
assert failure.value.status_code == 500
assert "целостности" in failure.value.detail
class _FakeWorkerGateway:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
self.active_run_id: str | None = None
def status(self) -> dict[str, Any]:
return self._status()
def live(self) -> dict[str, Any]:
if self.active_run_id is None:
raise AssertionError("test worker has no active run")
return {
"schema_version": "missioncore.vehicle-state/v1",
"run_id": self.active_run_id,
"sequence": 1,
"observed_at_utc": "2026-07-24T18:00:00Z",
"host_monotonic_ns": 123,
"sim_time_ns": 456,
"frame_id": "map_enu",
"child_frame_id": "base_link_flu",
"pose": {
"position_m": {"x": 1.0, "y": 2.0, "z": 0.1},
"orientation_xyzw": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0},
},
"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,
},
}
def start(
self,
*,
run_id: str,
mission_core_commit: str,
idempotency_key: str,
) -> dict[str, Any]:
self.calls.append(("start", idempotency_key))
assert mission_core_commit == "d" * 40
self.active_run_id = run_id
return self._status()
def stop(self, *, run_id: str, idempotency_key: str) -> dict[str, Any]:
self.calls.append(("stop", idempotency_key))
assert run_id == self.active_run_id
self.active_run_id = None
return self._status()
def _status(self) -> dict[str, Any]:
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": self.active_run_id,
"run_state": "running" if self.active_run_id else None,
"provider_ids": ["px4-gazebo-stock-rover"] if self.active_run_id else [],
"isolation": {
"network": "loopback-only-netns",
"process_identity": "missioncore",
"artifact_policy": "d-only",
},
"authority": {
"scope": "virtual-only",
"actuator_authority": False,
"direct_actuator_setpoints_allowed": False,
},
}
def test_polygon_worker_api_fails_closed_then_proxies_virtual_only_lifecycle() -> None:
worker = _FakeWorkerGateway()
disabled = build_polygon_router(
root_provider=lambda: None,
worker_provider=lambda: worker,
control_provider=lambda: False,
commit_provider=lambda: "d" * 40,
)
status = _endpoint(disabled, "/api/v1/polygon/worker", "GET")()
assert status["available"] is True
assert status["control_available"] is False
with pytest.raises(HTTPException) as failure:
_endpoint(disabled, "/api/v1/polygon/worker/runs", "POST")(
request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"),
idempotency_key="start-disabled",
)
assert failure.value.status_code == 403
enabled = build_polygon_router(
root_provider=lambda: None,
worker_provider=lambda: worker,
control_provider=lambda: True,
commit_provider=lambda: "d" * 40,
)
running = _endpoint(enabled, "/api/v1/polygon/worker/runs", "POST")(
request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"),
idempotency_key="start-001",
)
assert running["active_run_id"].startswith("s1c-ddddddd-")
live = _endpoint(enabled, "/api/v1/polygon/worker/live", "GET")()
assert live["run_id"] == running["active_run_id"]
assert live["source"]["quality"] == "diagnostic"
stopped = _endpoint(enabled, "/api/v1/polygon/worker/runs/{run_id}/stop", "POST")(
run_id=running["active_run_id"],
idempotency_key="stop-001",
)
assert stopped["active_run_id"] is None
assert worker.calls == [("start", "start-001"), ("stop", "stop-001")]
def test_polygon_worker_status_is_explicitly_unavailable_when_not_registered() -> None:
router = build_polygon_router(
root_provider=lambda: None,
worker_provider=lambda: None,
)
status = _endpoint(router, "/api/v1/polygon/worker", "GET")()
assert status["schema_version"] == "missioncore.simulation-worker-status/v1"
assert status["available"] is False
assert status["control_available"] is False
assert status["authority"]["scope"] == "virtual-only"

View File

@ -0,0 +1,123 @@
from __future__ import annotations
import json
import socket
import threading
from pathlib import Path
from typing import Any
from uuid import uuid4
import pytest
from k1link.simulation.worker_gateway import (
REQUEST_SCHEMA,
RESPONSE_SCHEMA,
SimulationWorkerGatewayError,
SimulationWorkerUnavailableError,
UnixSocketWorkerGateway,
)
def _status() -> dict[str, Any]:
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": None,
"run_state": None,
"provider_ids": [],
"isolation": {
"network": "loopback-only-netns",
"process_identity": "missioncore",
"artifact_policy": "d-only",
},
"authority": {
"scope": "virtual-only",
"actuator_authority": False,
"direct_actuator_setpoints_allowed": False,
},
}
def _serve_once(
socket_path: Path,
result: dict[str, Any],
*,
mutate_response: bool = False,
) -> tuple[threading.Thread, list[dict[str, Any]]]:
requests: list[dict[str, Any]] = []
ready = threading.Event()
def serve() -> None:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
server.bind(str(socket_path))
server.listen(1)
ready.set()
connection, _ = server.accept()
with connection:
request = json.loads(connection.makefile("rb").readline())
requests.append(request)
response = {
"schema_version": RESPONSE_SCHEMA,
"request_id": "wrong" if mutate_response else request["request_id"],
"ok": True,
"result": result,
"error": None,
}
connection.sendall(
json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n"
)
thread = threading.Thread(target=serve, daemon=True)
thread.start()
assert ready.wait(timeout=2)
return thread, requests
def test_unix_worker_gateway_uses_exact_bounded_private_protocol() -> None:
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
thread, requests = _serve_once(socket_path, _status())
gateway = UnixSocketWorkerGateway(socket_path)
try:
status = gateway.status()
thread.join(timeout=2)
assert status["worker_id"] == "mission-gpu-s1"
assert requests == [
{
"schema_version": REQUEST_SCHEMA,
"request_id": requests[0]["request_id"],
"operation": "status",
"payload": {},
}
]
assert len(requests[0]["request_id"]) == 32
finally:
socket_path.unlink(missing_ok=True)
def test_unix_worker_gateway_rejects_response_identity_drift() -> None:
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
thread, _ = _serve_once(socket_path, _status(), mutate_response=True)
gateway = UnixSocketWorkerGateway(socket_path)
try:
with pytest.raises(SimulationWorkerGatewayError, match="identity"):
gateway.status()
thread.join(timeout=2)
finally:
socket_path.unlink(missing_ok=True)
def test_unix_worker_gateway_reports_missing_worker_without_path_disclosure(
tmp_path: Path,
) -> None:
gateway = UnixSocketWorkerGateway(tmp_path / "missing.sock")
with pytest.raises(SimulationWorkerUnavailableError) as failure:
gateway.status()
assert str(tmp_path) not in str(failure.value)