diff --git a/apps/control-station/src/core/polygon/liveWorker.ts b/apps/control-station/src/core/polygon/liveWorker.ts new file mode 100644 index 0000000..122685b --- /dev/null +++ b/apps/control-station/src/core/polygon/liveWorker.ts @@ -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; + +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const RUN_STATES = new Set([ + "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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function record(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new PolygonWorkerContractError(`${label} должен быть объектом.`); + } + return value; +} + +function exactKeys( + value: Record, + expected: ReadonlySet, + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + )); +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index 90d42e9..7ac16db 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -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, diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index a49ca92..1c487aa 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -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); } diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index b73cc7e..dc11cb1 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -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; diff --git a/apps/control-station/src/workspaces/PolygonLivePanel.tsx b/apps/control-station/src/workspaces/PolygonLivePanel.tsx new file mode 100644 index 0000000..1e8403f --- /dev/null +++ b/apps/control-station/src/workspaces/PolygonLivePanel.tsx @@ -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(null); + const [live, setLive] = useState(null); + const [trajectory, setTrajectory] = useState([]); + const [error, setError] = useState(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 ( + +
+
+ ПОЛИГОН / LIVE +

Stock Ackermann Rover

+

PX4/Gazebo исполняются на отдельном worker; браузер показывает канонический срез.

+
+
+ + {worker?.available ? (runActive ? "Симуляция идёт" : "Worker готов") : "Worker offline"} + + +
+
+ +
+
+ + + + + + + + + + + + + + E + N + {projected.points && ( + + )} + {live && ( + + + + + )} + + {!live && ( +
+ {worker?.available ? "Ровер не запущен" : "Нет связи с worker"} + После старта здесь появится ground-truth траектория из Gazebo. +
+ )} + map_enu · base_link_flu · diagnostic ground truth +
+ +
+
+ Прогон + {worker?.activeRunId ?? "—"} +
+
+ Sim time + {live ? `${(live.simTimeNs / 1e9).toFixed(2)} s` : "—"} +
+
+ Позиция ENU + + {live + ? `${live.position.x.toFixed(2)} · ${live.position.y.toFixed(2)} · ${live.position.z.toFixed(2)} m` + : "—"} + +
+
+ Провайдеры + {worker?.providerIds.join(" · ") || "—"} +
+
+ Virtual only +

+ Нет actuator authority. Live-поза диагностическая и пока не доказывает + приёмку PX4/ROS 2 telemetry, navigation или safety. +

+
+ {error &&

{error}

} +
+
+
+ ); +} diff --git a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx index 7c2c4a1..a33479c 100644 --- a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx +++ b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx @@ -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 (
+ Только чтение

Проверяем журнал прогона

@@ -125,6 +127,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { if (error) { return (
+ Данные недоступны

UI-0 не может открыть прогон

@@ -144,6 +147,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { if (!detail) { return (
+ Журнал пуст

Квалификационных прогонов пока нет

@@ -156,13 +160,14 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { const { run } = detail; return (
+
ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН

{run.runId}

- Канонический журнал Mission Core. Экран не содержит lifecycle-операций, - команд управления или доступа к физическим актуаторам. + Канонический архив Mission Core. Lifecycle live-контура отделён от истории; + команд физическим актуаторам и реального управления здесь нет.

diff --git a/apps/control-station/test/polygonRunArchive.test.mjs b/apps/control-station/test/polygonRunArchive.test.mjs index 21c99f3..fc5730c 100644 --- a/apps/control-station/test/polygonRunArchive.test.mjs +++ b/apps/control-station/test/polygonRunArchive.test.mjs @@ -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" })); +}); diff --git a/simulation/s1/worker-agent.sh b/simulation/s1/worker-agent.sh new file mode 100755 index 0000000..350124f --- /dev/null +++ b/simulation/s1/worker-agent.sh @@ -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 " >&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" diff --git a/src/k1link/simulation/worker_agent.py b/src/k1link/simulation/worker_agent.py new file mode 100644 index 0000000..e79a0cc --- /dev/null +++ b/src/k1link/simulation/worker_agent.py @@ -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()) diff --git a/src/k1link/simulation/worker_gateway.py b/src/k1link/simulation/worker_gateway.py new file mode 100644 index 0000000..f6ee515 --- /dev/null +++ b/src/k1link/simulation/worker_gateway.py @@ -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") diff --git a/src/k1link/web/polygon_api.py b/src/k1link/web/polygon_api.py index 72071dc..7a86f89 100644 --- a/src/k1link/web/polygon_api.py +++ b/src/k1link/web/polygon_api.py @@ -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)}" diff --git a/tests/test_polygon_api.py b/tests/test_polygon_api.py index 5732a56..07fbfe3 100644 --- a/tests/test_polygon_api.py +++ b/tests/test_polygon_api.py @@ -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" diff --git a/tests/test_simulation_worker_gateway.py b/tests/test_simulation_worker_gateway.py new file mode 100644 index 0000000..142c22e --- /dev/null +++ b/tests/test_simulation_worker_gateway.py @@ -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)