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