feat(simulation): add provider-neutral worker profile

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 00:29:00 +03:00
parent 5abd07d2bf
commit 37c24b5fa8
21 changed files with 1275 additions and 121 deletions
@@ -1,12 +1,44 @@
import type { PolygonRunState } from "./runArchive";
export type PolygonProviderRole =
| "world"
| "physics"
| "state"
| "controller"
| "transport"
| "sensor"
| "traffic";
export interface PolygonProviderDescriptor {
providerId: string;
roles: PolygonProviderRole[];
capabilities: string[];
}
export interface PolygonProviderProfile {
profileId: string;
providers: PolygonProviderDescriptor[];
clock: {
providerId: string;
domain: string;
unit: "nanoseconds";
mode: "simulation";
};
controlProfiles: string[];
canonicalFrames: {
world: "map_enu";
body: "base_link_flu";
};
}
export interface PolygonWorkerStatus {
workerId: string;
available: boolean;
controlAvailable: boolean;
activeRunId: string | null;
runState: PolygonRunState | null;
providerIds: string[];
activeProviderIds: string[];
providerProfile: PolygonProviderProfile | null;
isolation: {
network: string;
processIdentity: string;
@@ -22,6 +54,10 @@ export interface PolygonVehicleState {
simTimeNs: number;
position: { x: number; y: number; z: number };
orientation: { x: number; y: number; z: number; w: number };
sourceProvider: string;
sourceTopic: string;
sourceSignal: "ground-truth";
sourceQuality: "diagnostic";
}
export interface PolygonCommandAcceptance {
@@ -32,9 +68,12 @@ export interface PolygonCommandAcceptance {
validUntilSimNs: number;
speedMps: number;
steeringNormalized: number;
armed: boolean;
offboard: boolean;
deliveryProvider: string;
controlProfile: string;
accepted: true;
controllerReady: boolean;
ttlExpiredCount: number;
diagnostics: Record<string, boolean>;
}
export class PolygonWorkerContractError extends Error {}
@@ -51,6 +90,17 @@ type PolygonWorkerFetch = (
) => Promise<Response>;
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SAFE_CAPABILITY = /^[a-z0-9][a-z0-9._/-]{0,127}$/;
const SAFE_CLOCK_DOMAIN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
const PROVIDER_ROLES = new Set<PolygonProviderRole>([
"world",
"physics",
"state",
"controller",
"transport",
"sensor",
"traffic",
]);
const RUN_STATES = new Set<PolygonRunState>([
"admitted",
"starting",
@@ -70,10 +120,22 @@ const STATUS_KEYS = new Set([
"control_available",
"active_run_id",
"run_state",
"provider_ids",
"active_provider_ids",
"provider_profile",
"isolation",
"authority",
]);
const PROVIDER_PROFILE_KEYS = new Set([
"schema_version",
"profile_id",
"providers",
"clock",
"control_profiles",
"canonical_frames",
]);
const PROVIDER_DESCRIPTOR_KEYS = new Set(["provider_id", "roles", "capabilities"]);
const CLOCK_KEYS = new Set(["provider_id", "domain", "unit", "mode"]);
const CANONICAL_FRAME_KEYS = new Set(["world", "body"]);
const ISOLATION_KEYS = new Set(["network", "process_identity", "artifact_policy"]);
const AUTHORITY_KEYS = new Set([
"scope",
@@ -115,11 +177,12 @@ const COMMAND_KEYS = new Set([
"delivery",
]);
const COMMAND_DELIVERY_KEYS = new Set([
"provider",
"mode",
"armed",
"offboard",
"provider_id",
"control_profile",
"accepted",
"controller_ready",
"ttl_expired_count",
"diagnostics",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -184,11 +247,130 @@ function finiteValue(value: unknown, label: string): number {
return value;
}
function uniqueStrings(
value: unknown,
label: string,
validator: (item: unknown, itemLabel: string) => string,
): string[] {
if (!Array.isArray(value) || value.length === 0 || value.length > 32) {
throw new PolygonWorkerContractError(`${label} должен быть ограниченным массивом.`);
}
const result = value.map((item, index) => validator(item, `${label}[${index}]`));
if (new Set(result).size !== result.length) {
throw new PolygonWorkerContractError(`${label} содержит повторения.`);
}
return result;
}
function decodeProviderProfile(payload: unknown): PolygonProviderProfile {
const value = record(payload, "provider_profile");
exactKeys(value, PROVIDER_PROFILE_KEYS, "provider_profile");
if (value.schema_version !== "missioncore.simulation-provider-profile/v1") {
throw new PolygonWorkerContractError("provider_profile имеет неизвестную схему.");
}
if (!Array.isArray(value.providers) || value.providers.length === 0 || value.providers.length > 32) {
throw new PolygonWorkerContractError("provider_profile.providers имеет недопустимый размер.");
}
const providers = value.providers.map((item, index): PolygonProviderDescriptor => {
const provider = record(item, `providers[${index}]`);
exactKeys(provider, PROVIDER_DESCRIPTOR_KEYS, `providers[${index}]`);
const roles = uniqueStrings(
provider.roles,
`providers[${index}].roles`,
(role, label) => {
const result = stringValue(role, label, 32);
if (!PROVIDER_ROLES.has(result as PolygonProviderRole)) {
throw new PolygonWorkerContractError(`${label} содержит неизвестную роль.`);
}
return result;
},
) as PolygonProviderRole[];
const capabilities = uniqueStrings(
provider.capabilities,
`providers[${index}].capabilities`,
(capability, label) => {
const result = stringValue(capability, label, 128);
if (!SAFE_CAPABILITY.test(result)) {
throw new PolygonWorkerContractError(`${label} содержит небезопасную возможность.`);
}
return result;
},
);
return {
providerId: safeId(provider.provider_id, `providers[${index}].provider_id`),
roles,
capabilities,
};
});
if (new Set(providers.map((provider) => provider.providerId)).size !== providers.length) {
throw new PolygonWorkerContractError("provider_profile содержит повторяющиеся provider_id.");
}
const clockValue = record(value.clock, "provider_profile.clock");
exactKeys(clockValue, CLOCK_KEYS, "provider_profile.clock");
const clockProviderId = safeId(clockValue.provider_id, "clock.provider_id");
const clockDomain = stringValue(clockValue.domain, "clock.domain", 128);
if (
!SAFE_CLOCK_DOMAIN.test(clockDomain) ||
clockValue.unit !== "nanoseconds" ||
clockValue.mode !== "simulation"
) {
throw new PolygonWorkerContractError("provider_profile.clock нарушает v1 контракт.");
}
const clockProvider = providers.find((provider) => provider.providerId === clockProviderId);
if (!clockProvider?.capabilities.includes("clock.simulation")) {
throw new PolygonWorkerContractError("Источник simulation clock не объявлен провайдером.");
}
if (!providers.some((provider) =>
provider.roles.includes("state") &&
provider.capabilities.includes("state.vehicle-pose"))) {
throw new PolygonWorkerContractError("provider_profile не предоставляет VehicleState.");
}
const controlProfiles = uniqueStrings(
value.control_profiles,
"provider_profile.control_profiles",
(profile, label) => {
const result = stringValue(profile, label, 64);
if (!["rover-speed-steering/v1", "rover-speed-yaw-rate/v1"].includes(result)) {
throw new PolygonWorkerContractError(`${label} содержит неизвестный профиль.`);
}
return result;
},
);
const controllerCapabilities = new Set(
providers
.filter((provider) => provider.roles.includes("controller"))
.flatMap((provider) => provider.capabilities),
);
if (controlProfiles.some((profile) => !controllerCapabilities.has(`command.${profile}`))) {
throw new PolygonWorkerContractError("Контроллер не поддерживает заявленный профиль команд.");
}
const frames = record(value.canonical_frames, "canonical_frames");
exactKeys(frames, CANONICAL_FRAME_KEYS, "canonical_frames");
if (frames.world !== "map_enu" || frames.body !== "base_link_flu") {
throw new PolygonWorkerContractError("provider_profile не нормализован в ENU/FLU.");
}
return {
profileId: safeId(value.profile_id, "provider_profile.profile_id"),
providers,
clock: {
providerId: clockProviderId,
domain: clockDomain,
unit: "nanoseconds",
mode: "simulation",
},
controlProfiles,
canonicalFrames: {
world: "map_enu",
body: "base_link_flu",
},
};
}
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.schema_version !== "missioncore.simulation-worker-status/v2" ||
value.transport !== "unix" ||
value.mode !== "simulation"
) {
@@ -208,11 +390,22 @@ export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus
) {
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
}
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
throw new PolygonWorkerContractError("provider_ids должен быть ограниченным массивом.");
if (!Array.isArray(value.active_provider_ids) || value.active_provider_ids.length > 32) {
throw new PolygonWorkerContractError("active_provider_ids должен быть ограниченным массивом.");
}
const activeProviderIds = value.active_provider_ids.map((item, index) =>
safeId(item, `active_provider_ids[${index}]`));
if (new Set(activeProviderIds).size !== activeProviderIds.length) {
throw new PolygonWorkerContractError("active_provider_ids содержит повторения.");
}
const available = booleanValue(value.available, "available");
const controlAvailable = booleanValue(value.control_available, "control_available");
const providerProfile = value.provider_profile === null
? null
: decodeProviderProfile(value.provider_profile);
if ((available && providerProfile === null) || (!available && controlAvailable)) {
throw new PolygonWorkerContractError("Доступность worker и provider_profile противоречат.");
}
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");
@@ -227,11 +420,12 @@ export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus
}
return {
workerId: safeId(value.worker_id, "worker_id"),
available: booleanValue(value.available, "available"),
controlAvailable: booleanValue(value.control_available, "control_available"),
available,
controlAvailable,
activeRunId,
runState: runStateValue as PolygonRunState | null,
providerIds,
activeProviderIds,
providerProfile,
isolation: {
network: stringValue(isolation.network, "isolation.network", 64),
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
@@ -259,11 +453,10 @@ export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState
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.");
throw new PolygonWorkerContractError("Live-сигнал не маркирован как diagnostic ground truth.");
}
const safety = record(value.safety, "safety");
exactKeys(safety, SAFETY_KEYS, "safety");
@@ -295,6 +488,10 @@ export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState
z: finiteValue(orientation.z, "orientation.z"),
w: finiteValue(orientation.w, "orientation.w"),
},
sourceProvider: safeId(source.provider, "source.provider"),
sourceTopic: stringValue(source.topic, "source.topic", 512),
sourceSignal: "ground-truth",
sourceQuality: "diagnostic",
};
}
@@ -302,19 +499,26 @@ export function decodePolygonCommandAcceptance(payload: unknown): PolygonCommand
const value = record(payload, "Подтверждение команды");
exactKeys(value, COMMAND_KEYS, "Подтверждение команды");
if (
value.schema_version !== "missioncore.command-acceptance/v1" ||
value.schema_version !== "missioncore.command-acceptance/v2" ||
value.authority_scope !== "virtual-only"
) {
throw new PolygonWorkerContractError("Команда вышла за virtual-only контракт.");
}
const delivery = record(value.delivery, "delivery");
exactKeys(delivery, COMMAND_DELIVERY_KEYS, "delivery");
if (
delivery.provider !== "px4-ros2-offboard" ||
delivery.mode !== "speed-steering"
) {
throw new PolygonWorkerContractError("Команда подтверждена неизвестным PX4 adapter.");
if (delivery.control_profile !== "rover-speed-steering/v1" || delivery.accepted !== true) {
throw new PolygonWorkerContractError("Команда подтверждена несовместимым adapter.");
}
const diagnosticsValue = record(delivery.diagnostics, "delivery.diagnostics");
if (Object.keys(diagnosticsValue).length > 16) {
throw new PolygonWorkerContractError("delivery.diagnostics превышает допустимый размер.");
}
const diagnostics = Object.fromEntries(
Object.entries(diagnosticsValue).map(([key, item]) => [
safeId(key, "delivery.diagnostics key"),
booleanValue(item, `delivery.diagnostics.${key}`),
]),
);
const sequence = integerValue(value.sequence, "sequence");
const issuedAtSimNs = integerValue(value.issued_at_sim_ns, "issued_at_sim_ns");
const validUntilSimNs = integerValue(value.valid_until_sim_ns, "valid_until_sim_ns");
@@ -342,12 +546,15 @@ export function decodePolygonCommandAcceptance(payload: unknown): PolygonCommand
validUntilSimNs,
speedMps,
steeringNormalized,
armed: booleanValue(delivery.armed, "delivery.armed"),
offboard: booleanValue(delivery.offboard, "delivery.offboard"),
deliveryProvider: safeId(delivery.provider_id, "delivery.provider_id"),
controlProfile: "rover-speed-steering/v1",
accepted: true,
controllerReady: booleanValue(delivery.controller_ready, "delivery.controller_ready"),
ttlExpiredCount: integerValue(
delivery.ttl_expired_count,
"delivery.ttl_expired_count",
),
diagnostics,
};
}
@@ -136,6 +136,12 @@ export function PolygonLivePanel() {
}
}, [worker?.activeRunId]);
const controllerProvider = worker?.providerProfile?.providers.find((provider) =>
provider.roles.includes("controller"));
const providerStack = worker?.providerProfile?.providers
.map((provider) => provider.providerId)
.join(" · ");
const stopMotion = async () => {
const runId = worker?.activeRunId;
setControlIntent(null);
@@ -184,7 +190,10 @@ export function PolygonLivePanel() {
<div>
<span className="section-eyebrow">ПОЛИГОН / LIVE</span>
<h2>Stock Ackermann Rover</h2>
<p>PX4/Gazebo исполняются на отдельном worker; браузер показывает канонический срез.</p>
<p>
Провайдеры симуляции исполняются на отдельном worker; браузер показывает
канонический срез ENU/FLU.
</p>
</div>
<div>
<StatusBadge
@@ -198,7 +207,7 @@ export function PolygonLivePanel() {
disabled={!worker?.controlAvailable || actionBusy}
onClick={() => void runAction()}
>
{actionBusy ? "Ожидаем PX4/Gazebo…" : runActive ? "Остановить" : "Запустить"}
{actionBusy ? "Ожидаем провайдеры…" : runActive ? "Остановить" : "Запустить"}
</Button>
</div>
</header>
@@ -221,9 +230,11 @@ export function PolygonLivePanel() {
<div className="polygon-live-telemetry">
<div className="polygon-rover-controls">
<div className="polygon-rover-controls__heading">
<span>Управление PX4</span>
<StatusBadge tone={commandAcceptance?.offboard ? "success" : "neutral"}>
{commandAcceptance?.offboard ? "Armed · Offboard" : "Ожидает прогона"}
<span>
Управление {controllerProvider?.providerId ?? "машиной"}
</span>
<StatusBadge tone={commandAcceptance?.controllerReady ? "success" : "neutral"}>
{commandAcceptance?.controllerReady ? "Controller ready" : "Ожидает прогона"}
</StatusBadge>
</div>
<div className="polygon-rover-controls__grid">
@@ -270,8 +281,16 @@ export function PolygonLivePanel() {
</strong>
</div>
<div>
<span>Провайдеры</span>
<strong>{worker?.providerIds.join(" · ") || "—"}</strong>
<span>Провайдерный стек</span>
<strong>{providerStack || "—"}</strong>
</div>
<div>
<span>Активные процессы</span>
<strong>{worker?.activeProviderIds.join(" · ") || "—"}</strong>
</div>
<div>
<span>Источник live-состояния</span>
<strong>{live ? `${live.sourceProvider} · ${live.sourceSignal}` : "—"}</strong>
</div>
<div>
<span>Последняя команда</span>
@@ -284,8 +303,9 @@ export function PolygonLivePanel() {
<div className="polygon-live-boundary">
<StatusBadge tone="success">Virtual only</StatusBadge>
<p>
Команды проходят PX4 ROS 2 Offboard только в SITL. Физического actuator
authority нет; live-поза остаётся диагностической.
Команды проходят только через заявленный controller provider
{controllerProvider ? ` ${controllerProvider.providerId}` : ""}. Физического
actuator authority нет; live-поза остаётся диагностической.
</p>
</div>
{error && <p className="polygon-live-error">{error}</p>}
@@ -175,7 +175,7 @@ function jsonResponse(payload, status = 200) {
function workerStatus(overrides = {}) {
return {
schema_version: "missioncore.simulation-worker-status/v1",
schema_version: "missioncore.simulation-worker-status/v2",
worker_id: "mission-gpu-s1",
transport: "unix",
mode: "simulation",
@@ -183,7 +183,44 @@ function workerStatus(overrides = {}) {
control_available: true,
active_run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
run_state: "running",
provider_ids: ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"],
active_provider_ids: ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"],
provider_profile: {
schema_version: "missioncore.simulation-provider-profile/v1",
profile_id: "stock-rover-gazebo-px4-s1d",
providers: [
{
provider_id: "gazebo",
roles: ["world", "physics", "state", "sensor"],
capabilities: [
"clock.simulation",
"state.vehicle-pose",
"truth.ground-truth",
"sensor.virtual",
],
},
{
provider_id: "px4-ros2-offboard",
roles: ["controller"],
capabilities: ["command.rover-speed-steering/v1"],
},
{
provider_id: "micro-xrce-dds-agent",
roles: ["transport"],
capabilities: ["transport.ros2"],
},
],
clock: {
provider_id: "gazebo",
domain: "gazebo:/clock",
unit: "nanoseconds",
mode: "simulation",
},
control_profiles: ["rover-speed-steering/v1"],
canonical_frames: {
world: "map_enu",
body: "base_link_flu",
},
},
isolation: {
network: "loopback-only-netns",
process_identity: "missioncore",
@@ -229,7 +266,7 @@ function vehicleState(overrides = {}) {
function commandAcceptance(overrides = {}) {
return {
schema_version: "missioncore.command-acceptance/v1",
schema_version: "missioncore.command-acceptance/v2",
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
command_id: "cmd-aabbcc",
sequence: 1,
@@ -239,11 +276,15 @@ function commandAcceptance(overrides = {}) {
steering_normalized: -0.55,
authority_scope: "virtual-only",
delivery: {
provider: "px4-ros2-offboard",
mode: "speed-steering",
armed: true,
offboard: true,
provider_id: "px4-ros2-offboard",
control_profile: "rover-speed-steering/v1",
accepted: true,
controller_ready: true,
ttl_expired_count: 0,
diagnostics: {
armed: true,
offboard: true,
},
},
...overrides,
};
@@ -353,7 +394,8 @@ test("Polygon Live decodes only D-only virtual diagnostic state", () => {
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.equal(command.offboard, true);
assert.equal(command.controllerReady, true);
assert.equal(command.deliveryProvider, "px4-ros2-offboard");
assert.equal(command.steeringNormalized, -0.55);
assert.throws(
() => decodePolygonWorkerStatus(workerStatus({
@@ -381,12 +423,67 @@ test("Polygon Live decodes only D-only virtual diagnostic state", () => {
);
});
test("Polygon Live accepts an Unreal provider behind the canonical contract", () => {
const unrealStatus = workerStatus({
provider_profile: {
...workerStatus().provider_profile,
profile_id: "unreal-native-rover-v1",
providers: [
{
provider_id: "unreal-native",
roles: ["world", "physics", "state", "sensor"],
capabilities: [
"clock.simulation",
"state.vehicle-pose",
"truth.ground-truth",
"sensor.virtual",
],
},
{
provider_id: "unreal-direct-control",
roles: ["controller"],
capabilities: ["command.rover-speed-steering/v1"],
},
],
clock: {
provider_id: "unreal-native",
domain: "unreal:fixed-step",
unit: "nanoseconds",
mode: "simulation",
},
},
});
const status = decodePolygonWorkerStatus(unrealStatus);
const live = decodePolygonVehicleState(vehicleState({
source: {
provider: "unreal-native",
topic: "missioncore/vehicle-state",
signal: "ground-truth",
quality: "diagnostic",
},
}));
const command = decodePolygonCommandAcceptance(commandAcceptance({
delivery: {
provider_id: "unreal-direct-control",
control_profile: "rover-speed-steering/v1",
accepted: true,
controller_ready: true,
ttl_expired_count: 0,
diagnostics: { fixed_step: true },
},
}));
assert.equal(status.providerProfile.clock.domain, "unreal:fixed-step");
assert.equal(live.sourceProvider, "unreal-native");
assert.equal(command.deliveryProvider, "unreal-direct-control");
});
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: [],
active_provider_ids: [],
});
const fetcher = async (url, init) => {
calls.push({ url: String(url), init });