feat(simulation): add Polygon live worker gateway
This commit is contained in:
@@ -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,
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user