feat(simulation): add Polygon live worker gateway

This commit is contained in:
DCCONSTRUCTIONS
2026-07-24 20:33:08 +03:00
parent 359f0d1d39
commit b7ccca3481
13 changed files with 2365 additions and 9 deletions
@@ -0,0 +1,393 @@
import type { PolygonRunState } from "./runArchive";
export interface PolygonWorkerStatus {
workerId: string;
available: boolean;
controlAvailable: boolean;
activeRunId: string | null;
runState: PolygonRunState | null;
providerIds: string[];
isolation: {
network: string;
processIdentity: string;
artifactPolicy: "d-only";
};
}
export interface PolygonVehicleState {
runId: string;
sequence: number;
observedAtUtc: string;
hostMonotonicNs: number;
simTimeNs: number;
position: { x: number; y: number; z: number };
orientation: { x: number; y: number; z: number; w: number };
}
export class PolygonWorkerContractError extends Error {}
export class PolygonWorkerApiError extends Error {
constructor(message: string, readonly status: number | null = null) {
super(message);
}
}
type PolygonWorkerFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RUN_STATES = new Set<PolygonRunState>([
"admitted",
"starting",
"running",
"paused",
"stopping",
"completed",
"failed",
"aborted",
]);
const STATUS_KEYS = new Set([
"schema_version",
"worker_id",
"transport",
"mode",
"available",
"control_available",
"active_run_id",
"run_state",
"provider_ids",
"isolation",
"authority",
]);
const ISOLATION_KEYS = new Set(["network", "process_identity", "artifact_policy"]);
const AUTHORITY_KEYS = new Set([
"scope",
"actuator_authority",
"direct_actuator_setpoints_allowed",
]);
const VEHICLE_KEYS = new Set([
"schema_version",
"run_id",
"sequence",
"observed_at_utc",
"host_monotonic_ns",
"sim_time_ns",
"frame_id",
"child_frame_id",
"pose",
"source",
"safety",
]);
const POSE_KEYS = new Set(["position_m", "orientation_xyzw"]);
const POSITION_KEYS = new Set(["x", "y", "z"]);
const ORIENTATION_KEYS = new Set(["x", "y", "z", "w"]);
const SOURCE_KEYS = new Set(["provider", "topic", "signal", "quality"]);
const SAFETY_KEYS = new Set([
"scope",
"actuator_authority",
"navigation_or_safety_accepted",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new PolygonWorkerContractError(`${label} должен быть объектом.`);
}
return value;
}
function exactKeys(
value: Record<string, unknown>,
expected: ReadonlySet<string>,
label: string,
) {
const keys = Object.keys(value);
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
throw new PolygonWorkerContractError(`${label} содержит неизвестные или отсутствующие поля.`);
}
}
function stringValue(value: unknown, label: string, maximum = 512): string {
if (typeof value !== "string") {
throw new PolygonWorkerContractError(`${label} должен быть строкой.`);
}
const normalized = value.trim();
if (!normalized || normalized.length > maximum) {
throw new PolygonWorkerContractError(`${label} имеет недопустимую длину.`);
}
return normalized;
}
function safeId(value: unknown, label: string): string {
const identifier = stringValue(value, label, 128);
if (!SAFE_ID.test(identifier)) {
throw new PolygonWorkerContractError(`${label} содержит небезопасный идентификатор.`);
}
return identifier;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new PolygonWorkerContractError(`${label} должен быть boolean.`);
}
return value;
}
function integerValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new PolygonWorkerContractError(`${label} должен быть неотрицательным целым.`);
}
return value;
}
function finiteValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) >= 1e9) {
throw new PolygonWorkerContractError(`${label} должен быть конечным числом.`);
}
return value;
}
export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus {
const value = record(payload, "Статус Simulation Worker");
exactKeys(value, STATUS_KEYS, "Статус Simulation Worker");
if (
value.schema_version !== "missioncore.simulation-worker-status/v1" ||
value.transport !== "unix" ||
value.mode !== "simulation"
) {
throw new PolygonWorkerContractError("Статус Simulation Worker имеет неизвестную схему.");
}
const isolation = record(value.isolation, "isolation");
exactKeys(isolation, ISOLATION_KEYS, "isolation");
if (isolation.artifact_policy !== "d-only") {
throw new PolygonWorkerContractError("Simulation Worker нарушает D-only политику.");
}
const authority = record(value.authority, "authority");
exactKeys(authority, AUTHORITY_KEYS, "authority");
if (
authority.scope !== "virtual-only" ||
authority.actuator_authority !== false ||
authority.direct_actuator_setpoints_allowed !== false
) {
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
}
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
throw new PolygonWorkerContractError("provider_ids должен быть ограниченным массивом.");
}
const providerIds = value.provider_ids.map((item, index) =>
safeId(item, `provider_ids[${index}]`));
const activeRunId = value.active_run_id === null
? null
: safeId(value.active_run_id, "active_run_id");
const runStateValue = value.run_state === null
? null
: stringValue(value.run_state, "run_state", 32);
if (
(runStateValue !== null && !RUN_STATES.has(runStateValue as PolygonRunState)) ||
(activeRunId === null) !== (runStateValue === null)
) {
throw new PolygonWorkerContractError("Активный прогон и его состояние противоречат друг другу.");
}
return {
workerId: safeId(value.worker_id, "worker_id"),
available: booleanValue(value.available, "available"),
controlAvailable: booleanValue(value.control_available, "control_available"),
activeRunId,
runState: runStateValue as PolygonRunState | null,
providerIds,
isolation: {
network: stringValue(isolation.network, "isolation.network", 64),
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
artifactPolicy: "d-only",
},
};
}
export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState {
const value = record(payload, "VehicleState");
exactKeys(value, VEHICLE_KEYS, "VehicleState");
if (
value.schema_version !== "missioncore.vehicle-state/v1" ||
value.frame_id !== "map_enu" ||
value.child_frame_id !== "base_link_flu"
) {
throw new PolygonWorkerContractError("VehicleState имеет неизвестную схему координат.");
}
const pose = record(value.pose, "pose");
exactKeys(pose, POSE_KEYS, "pose");
const position = record(pose.position_m, "position_m");
exactKeys(position, POSITION_KEYS, "position_m");
const orientation = record(pose.orientation_xyzw, "orientation_xyzw");
exactKeys(orientation, ORIENTATION_KEYS, "orientation_xyzw");
const source = record(value.source, "source");
exactKeys(source, SOURCE_KEYS, "source");
if (
source.provider !== "gazebo" ||
source.signal !== "ground-truth" ||
source.quality !== "diagnostic"
) {
throw new PolygonWorkerContractError("Live-сигнал не маркирован как Gazebo diagnostic.");
}
const safety = record(value.safety, "safety");
exactKeys(safety, SAFETY_KEYS, "safety");
if (
safety.scope !== "virtual-only" ||
safety.actuator_authority !== false ||
safety.navigation_or_safety_accepted !== false
) {
throw new PolygonWorkerContractError("VehicleState нарушает virtual-only границу.");
}
const observedAtUtc = stringValue(value.observed_at_utc, "observed_at_utc", 64);
if (!Number.isFinite(Date.parse(observedAtUtc))) {
throw new PolygonWorkerContractError("observed_at_utc должен быть ISO-датой.");
}
return {
runId: safeId(value.run_id, "run_id"),
sequence: integerValue(value.sequence, "sequence"),
observedAtUtc,
hostMonotonicNs: integerValue(value.host_monotonic_ns, "host_monotonic_ns"),
simTimeNs: integerValue(value.sim_time_ns, "sim_time_ns"),
position: {
x: finiteValue(position.x, "position.x"),
y: finiteValue(position.y, "position.y"),
z: finiteValue(position.z, "position.z"),
},
orientation: {
x: finiteValue(orientation.x, "orientation.x"),
y: finiteValue(orientation.y, "orientation.y"),
z: finiteValue(orientation.z, "orientation.z"),
w: finiteValue(orientation.w, "orientation.w"),
},
};
}
async function responseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().startsWith("application/json")) {
throw new PolygonWorkerContractError("Polygon Worker API вернул не JSON.");
}
try {
return await response.json();
} catch {
throw new PolygonWorkerContractError("Polygon Worker API вернул повреждённый JSON.");
}
}
function apiError(body: unknown, fallback: string, status: number): PolygonWorkerApiError {
if (isRecord(body) && typeof body.detail === "string") {
const detail = body.detail.trim();
if (detail && detail.length <= 1_000) return new PolygonWorkerApiError(detail, status);
}
return new PolygonWorkerApiError(`${fallback} HTTP ${status}.`, status);
}
async function requestJson(
url: string,
init: RequestInit,
fallback: string,
fetcher: PolygonWorkerFetch,
): Promise<unknown> {
let response: Response;
try {
response = await fetcher(url, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new PolygonWorkerApiError(fallback);
}
const body = await responseBody(response);
if (!response.ok) throw apiError(body, fallback, response.status);
return body;
}
export async function fetchPolygonWorkerStatus({
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: PolygonWorkerFetch;
} = {}): Promise<PolygonWorkerStatus> {
return decodePolygonWorkerStatus(await requestJson(
"/api/v1/polygon/worker",
{ method: "GET", headers: { Accept: "application/json" }, signal },
"Не удалось получить статус Simulation Worker.",
fetcher,
));
}
export async function fetchPolygonVehicleState({
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: PolygonWorkerFetch;
} = {}): Promise<PolygonVehicleState> {
return decodePolygonVehicleState(await requestJson(
"/api/v1/polygon/worker/live",
{ method: "GET", headers: { Accept: "application/json" }, signal },
"Не удалось получить live-состояние ровера.",
fetcher,
));
}
export async function startPolygonWorker({
idempotencyKey,
signal,
fetcher = globalThis.fetch,
}: {
idempotencyKey: string;
signal?: AbortSignal;
fetcher?: PolygonWorkerFetch;
}): Promise<PolygonWorkerStatus> {
return decodePolygonWorkerStatus(await requestJson(
"/api/v1/polygon/worker/runs",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ scenario_id: "stock-rover-ackermann" }),
signal,
},
"Не удалось запустить Simulation Worker.",
fetcher,
));
}
export async function stopPolygonWorker(
runId: string,
{
idempotencyKey,
signal,
fetcher = globalThis.fetch,
}: {
idempotencyKey: string;
signal?: AbortSignal;
fetcher?: PolygonWorkerFetch;
},
): Promise<PolygonWorkerStatus> {
if (!SAFE_ID.test(runId)) {
throw new PolygonWorkerContractError("Некорректный идентификатор активного прогона.");
}
return decodePolygonWorkerStatus(await requestJson(
`/api/v1/polygon/worker/runs/${encodeURIComponent(runId)}/stop`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: "{}",
signal,
},
"Не удалось остановить Simulation Worker.",
fetcher,
));
}